Merge pull request 'frontend' (#26) from frontend into main

Reviewed-on: #26
This commit is contained in:
ismail 2025-10-30 13:17:25 +03:00
commit 2e62700146
51 changed files with 2764 additions and 1087 deletions

View File

@ -135,9 +135,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'norahuniversity',
'USER': 'norahuniversity',
'PASSWORD': 'norahuniversity',
'NAME': 'haikal_db',
'USER': 'faheed',
'PASSWORD': 'Faheed@215',
'HOST': '127.0.0.1',
'PORT': '5432',
}

View File

@ -11,7 +11,7 @@ from .models import (
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
Profile,MeetingComment,ScheduledInterview,Source,HiringAgency,
AgencyJobAssignment, AgencyAccessLink
AgencyJobAssignment, AgencyAccessLink,Participants
)
# from django_summernote.widgets import SummernoteWidget
from django_ckeditor_5.widgets import CKEditor5Widget
@ -638,7 +638,11 @@ class JobPostingStatusForm(forms.ModelForm):
widgets = {
'status': forms.Select(attrs={'class': 'form-select'}),
}
class LinkedPostContentForm(forms.ModelForm):
class Meta:
model = JobPosting
fields = ['linkedin_post_formated_data']
class FormTemplateIsActiveForm(forms.ModelForm):
class Meta:
model = FormTemplate
@ -1141,3 +1145,57 @@ class AgencyLoginForm(forms.Form):
raise ValidationError('Invalid access token.')
return cleaned_data
#participants form
class ParticipantsForm(forms.ModelForm):
"""Form for creating and editing Participants"""
class Meta:
model = Participants
fields = ['name', 'email', 'phone', 'designation']
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter participant name',
'required': True
}),
'email': forms.EmailInput(attrs={
'class': 'form-control',
'placeholder': 'Enter email address',
'required': True
}),
'phone': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter phone number'
}),
'designation': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter designation'
}),
# 'jobs': forms.CheckboxSelectMultiple(),
}
class ParticipantsSelectForm(forms.ModelForm):
"""Form for selecting Participants"""
participants=forms.ModelMultipleChoiceField(
queryset=Participants.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Select Participants"))
users=forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Select Users"))
class Meta:
model = JobPosting
fields = ['participants','users'] # No direct fields from Participants model

View File

@ -1,19 +1,18 @@
# jobs/linkedin_service.py
import uuid
import re
from html import unescape
from urllib.parse import quote, urlencode
import requests
import logging
import time
from django.conf import settings
from urllib.parse import quote, urlencode
logger = logging.getLogger(__name__)
# Define constants
LINKEDIN_API_VERSION = '2.0.0'
LINKEDIN_VERSION = '202409'
MAX_POST_CHARS = 3000 # LinkedIn's maximum character limit for shareCommentary
class LinkedInService:
def __init__(self):
@ -162,113 +161,114 @@ class LinkedInService:
# ---------------- POSTING UTILITIES ----------------
def clean_html_for_social_post(self, html_content):
"""Converts safe HTML to plain text with basic formatting."""
if not html_content:
return ""
# def clean_html_for_social_post(self, html_content):
# """Converts safe HTML to plain text with basic formatting."""
# if not html_content:
# return ""
text = html_content
# 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)
# # 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)
# # 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)
# # 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)
# # 4. Strip all remaining, unsupported HTML tags
# clean_text = re.sub(r'<[^>]+>', '', text)
# 5. Unescape HTML entities
clean_text = unescape(clean_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()
# # 6. Clean up excessive whitespace/newlines
# clean_text = re.sub(r'(\n\s*){3,}', '\n\n', clean_text).strip()
return clean_text
# 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"]
# 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]
# 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"]
# if not tags:
# return ["#HigherEd", "#Hiring", "#UniversityJobs"]
return tags
# return tags
def _build_post_message(self, job_posting):
"""
Constructs the final text message.
Includes a unique suffix for duplicate content prevention (422 fix).
"""
message_parts = [
f"🔥 *Job Alert!* Were looking for a talented professional to join our team.",
f"👉 **{job_posting.title}** 👈",
]
# def _build_post_message(self, job_posting):
# """
# Constructs the final text message.
# Includes a unique suffix for duplicate content prevention (422 fix).
# """
# message_parts = [
# f"🔥 *Job Alert!* Were looking for a talented professional to join our team.",
# f"👉 **{job_posting.title}** 👈",
# ]
if job_posting.department:
message_parts.append(f"*{job_posting.department}*")
# if job_posting.department:
# message_parts.append(f"*{job_posting.department}*")
message_parts.append("\n" + "=" * 25 + "\n")
# 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}")
# # 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")
# 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}")
# # 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}")
# clean_
# CALL TO ACTION
if job_posting.application_url:
message_parts.append(f"\n\n---")
# CRITICAL: Include the URL explicitly in the text body.
# When media_category is NONE, LinkedIn often makes these URLs clickable.
message_parts.append(f"🔗 **APPLY NOW:** {job_posting.application_url}")
# # CALL TO ACTION
# if job_posting.application_url:
# message_parts.append(f"\n\n---")
# # CRITICAL: Include the URL explicitly in the text body.
# # When media_category is NONE, LinkedIn often makes these URLs clickable.
# 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)
# # 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))
# message_parts.append("\n" + " ".join(hashtags))
final_message = "\n".join(message_parts)
# final_message = "\n".join(message_parts)
# --- FIX: ADD UNIQUE SUFFIX AND HANDLE LENGTH (422 fix) ---
unique_suffix = f"\n\n| Ref: {int(time.time())}"
# # --- FIX: ADD UNIQUE SUFFIX AND HANDLE LENGTH (422 fix) ---
# unique_suffix = f"\n\n| Ref: {int(time.time())}"
available_length = MAX_POST_CHARS - len(unique_suffix)
# available_length = MAX_POST_CHARS - len(unique_suffix)
if len(final_message) > available_length:
logger.warning("Post message truncated due to character limit.")
final_message = final_message[:available_length - 3] + "..."
# if len(final_message) > available_length:
# logger.warning("Post message truncated due to character limit.")
# final_message = final_message[:available_length - 3] + "..."
return final_message + unique_suffix
# return final_message + unique_suffix
# ---------------- MAIN POSTING METHODS ----------------
@ -279,7 +279,9 @@ class LinkedInService:
CRITICAL FIX: Avoids ARTICLE category if not using an image to prevent 402 errors.
"""
message = self._build_post_message(job_posting)
message = job_posting.linkedin_post_formated_data
if len(message)>=3000:
message=message[:2900]+"...."
# --- FIX FOR 402: Force NONE if no image is present. ---
if media_category != "IMAGE":

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-10-25 14:57
# Generated by Django 5.2.7 on 2025-10-29 18:04
import django.core.validators
import django.db.models.deletion
@ -66,6 +66,22 @@ class Migration(migrations.Migration):
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Participants',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Participant Name')),
('email', models.EmailField(max_length=254, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')),
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Source',
fields=[
@ -84,6 +100,11 @@ class Migration(migrations.Migration):
('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')),
('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')),
('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')),
('sync_endpoint', models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint')),
('sync_method', models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method')),
('test_method', models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method')),
('custom_headers', models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers')),
('supports_outbound_sync', models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync')),
],
options={
'verbose_name': 'Source',
@ -201,7 +222,7 @@ class Migration(migrations.Migration):
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
@ -209,9 +230,12 @@ class Migration(migrations.Migration):
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')),
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
],
options={
'verbose_name': 'Candidate',
@ -221,6 +245,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='JobPosting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
@ -238,7 +263,7 @@ class Migration(migrations.Migration):
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
('application_deadline', models.DateField(db_index=True)),
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
('internal_job_id', models.CharField(editable=False, max_length=50)),
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
@ -247,6 +272,7 @@ class Migration(migrations.Migration):
('posted_to_linkedin', models.BooleanField(default=False)),
('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
('linkedin_post_formated_data', models.TextField(blank=True, null=True)),
('published_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
@ -256,6 +282,8 @@ class Migration(migrations.Migration):
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('users', models.ManyToManyField(blank=True, help_text='Internal staff involved in the recruitment process for this job', related_name='jobs_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Internal Participant')),
('participants', models.ManyToManyField(blank=True, help_text='External participants involved in the recruitment process for this job', related_name='jobs_participating', to='recruitment.participants', verbose_name='External Participant')),
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
],
options={
@ -295,6 +323,31 @@ class Migration(migrations.Migration):
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
),
migrations.CreateModel(
name='AgencyJobAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')),
('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')),
('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')),
('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')),
('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')),
('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')),
('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')),
('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')),
],
options={
'verbose_name': 'Agency Job Assignment',
'verbose_name_plural': 'Agency Job Assignments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='JobPostingImage',
fields=[
@ -308,6 +361,8 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])),
('designation', models.CharField(blank=True, max_length=100, null=True)),
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
@ -336,7 +391,7 @@ class Migration(migrations.Migration):
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')),
('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')),
('method', models.CharField(blank=True, max_length=10, verbose_name='HTTP Method')),
('method', models.CharField(blank=True, max_length=50, verbose_name='HTTP Method')),
('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')),
('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')),
('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')),
@ -424,6 +479,28 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AgencyAccessLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')),
('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')),
('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')),
('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
],
options={
'verbose_name': 'Agency Access Link',
'verbose_name_plural': 'Agency Access Links',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx')],
},
),
migrations.CreateModel(
name='FieldResponse',
fields=[
@ -474,6 +551,26 @@ class Migration(migrations.Migration):
model_name='candidate',
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'),
),
migrations.AlterUniqueTogether(
name='agencyjobassignment',
unique_together={('agency', 'job')},
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-26 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='retry',
field=models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry'),
),
]

View File

@ -1,48 +0,0 @@
# Generated by Django 5.2.4 on 2025-10-26 13:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_candidate_retry'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='hired_date',
field=models.DateField(blank=True, null=True, verbose_name='Hired Date'),
),
migrations.AddField(
model_name='source',
name='custom_headers',
field=models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers'),
),
migrations.AddField(
model_name='source',
name='supports_outbound_sync',
field=models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync'),
),
migrations.AddField(
model_name='source',
name='sync_endpoint',
field=models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint'),
),
migrations.AddField(
model_name='source',
name='sync_method',
field=models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method'),
),
migrations.AddField(
model_name='source',
name='test_method',
field=models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method'),
),
migrations.AlterField(
model_name='candidate',
name='stage',
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.4 on 2025-10-26 13:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_candidate_hired_date_source_custom_headers_and_more'),
]
operations = [
migrations.AlterField(
model_name='integrationlog',
name='method',
field=models.CharField(blank=True, max_length=50, verbose_name='HTTP Method'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.4 on 2025-10-26 14:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_alter_integrationlog_method'),
]
operations = [
migrations.RenameField(
model_name='candidate',
old_name='submitted_by_agency',
new_name='hiring_agency',
),
]

View File

@ -1,129 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-26 14:51
import django.db.models.deletion
import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_rename_submitted_by_agency_candidate_hiring_agency'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AgencyJobAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')),
('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')),
('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')),
('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')),
('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')),
('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')),
('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')),
('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')),
],
options={
'verbose_name': 'Agency Job Assignment',
'verbose_name_plural': 'Agency Job Assignments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AgencyAccessLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')),
('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')),
('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')),
('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
],
options={
'verbose_name': 'Agency Access Link',
'verbose_name_plural': 'Agency Access Links',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AgencyMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('subject', models.CharField(max_length=200, verbose_name='Subject')),
('message', models.TextField(verbose_name='Message')),
('message_type', models.CharField(choices=[('INFO', 'Information'), ('WARNING', 'Warning'), ('EXTENSION', 'Deadline Extension'), ('GENERAL', 'General')], default='GENERAL', max_length=20, verbose_name='Message Type')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
('recipient_agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to='recruitment.hiringagency', verbose_name='Recipient Agency')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
],
options={
'verbose_name': 'Agency Message',
'verbose_name_plural': 'Agency Messages',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'),
),
migrations.AlterUniqueTogether(
name='agencyjobassignment',
unique_together={('agency', 'job')},
),
migrations.AddIndex(
model_name='agencyaccesslink',
index=models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'),
),
migrations.AddIndex(
model_name='agencyaccesslink',
index=models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'),
),
migrations.AddIndex(
model_name='agencyaccesslink',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['assignment', 'is_read'], name='recruitment_assignm_4f518d_idx'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['recipient_agency', 'is_read'], name='recruitment_recipie_427b10_idx'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['sender'], name='recruitment_sender__97dd96_idx'),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-27 11:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.source', verbose_name='Source'),
),
migrations.AddField(
model_name='candidate',
name='source_type',
field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Source'),
),
]

View File

@ -1,32 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-27 11:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_candidate_source_candidate_source_type'),
]
operations = [
migrations.RemoveField(
model_name='candidate',
name='source',
),
migrations.RemoveField(
model_name='candidate',
name='source_type',
),
migrations.AddField(
model_name='candidate',
name='hiring_source',
field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source'),
),
migrations.AlterField(
model_name='candidate',
name='hiring_agency',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency'),
),
]

View File

@ -1,59 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-27 20:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_remove_candidate_source_remove_candidate_source_type_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='agencymessage',
name='priority',
field=models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', max_length=10, verbose_name='Priority'),
),
migrations.AddField(
model_name='agencymessage',
name='recipient_user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient User'),
),
migrations.AddField(
model_name='agencymessage',
name='send_email',
field=models.BooleanField(default=False, verbose_name='Send Email Notification'),
),
migrations.AddField(
model_name='agencymessage',
name='send_sms',
field=models.BooleanField(default=False, verbose_name='Send SMS Notification'),
),
migrations.AddField(
model_name='agencymessage',
name='sender_agency',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to='recruitment.hiringagency', verbose_name='Sender Agency'),
),
migrations.AddField(
model_name='agencymessage',
name='sender_type',
field=models.CharField(choices=[('ADMIN', 'Admin'), ('AGENCY', 'Agency')], default='ADMIN', max_length=10, verbose_name='Sender Type'),
),
migrations.AlterField(
model_name='agencymessage',
name='sender',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['sender_type', 'created_at'], name='recruitment_sender__14b136_idx'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['priority', 'created_at'], name='recruitment_priorit_80d9f1_idx'),
),
]

View File

@ -1,16 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-29 10:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_agencymessage_priority_agencymessage_recipient_user_and_more'),
]
operations = [
migrations.DeleteModel(
name='AgencyMessage',
),
]

View File

@ -28,6 +28,8 @@ class Base(models.Model):
class Profile(models.Model):
profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/",validators=[validate_image_size])
designation = models.CharField(max_length=100, blank=True,null=True)
phone=models.CharField(blank=True,null=True,verbose_name=_("Phone Number"),max_length=12)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
def __str__(self):
@ -36,6 +38,7 @@ class Profile(models.Model):
class JobPosting(Base):
# Basic Job Information
JOB_TYPES = [
("FULL_TIME", "Full-time"),
("PART_TIME", "Part-time"),
@ -51,6 +54,19 @@ class JobPosting(Base):
("HYBRID", "Hybrid"),
]
users=models.ManyToManyField(
User,
blank=True,related_name="jobs_assigned",
verbose_name=_("Internal Participant"),
help_text=_("Internal staff involved in the recruitment process for this job"),
)
participants=models.ManyToManyField('Participants',
blank=True,related_name="jobs_participating",
verbose_name=_("External Participant"),
help_text=_("External participants involved in the recruitment process for this job"),
)
# Core Fields
title = models.CharField(max_length=200)
department = models.CharField(max_length=100, blank=True)
@ -92,7 +108,7 @@ class JobPosting(Base):
)
# Internal Tracking
internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False)
internal_job_id = models.CharField(max_length=50, editable=False)
created_by = models.CharField(
max_length=100, blank=True, help_text="Name of person who created this job"
@ -130,6 +146,7 @@ class JobPosting(Base):
max_length=50, blank=True, help_text="Status of LinkedIn posting"
)
linkedin_posted_at = models.DateTimeField(null=True, blank=True)
linkedin_post_formated_data=models.TextField(null=True,blank=True)
published_at = models.DateTimeField(db_index=True, null=True, blank=True) # Added index
# University Specific Fields
@ -328,23 +345,41 @@ class JobPosting(Base):
def all_candidates_count(self):
return self.candidates.annotate(
sortable_score=Cast('ai_analysis_data__match_score', output_field=CharField())).order_by(
'-sortable_score').count()
'-sortable_score').count() or 0
@property
def screening_candidates_count(self):
return self.all_candidates.filter(stage="Applied").count()
return self.all_candidates.filter(stage="Applied").count() or 0
@property
def exam_candidates_count(self):
return self.all_candidates.filter(stage="Exam").count()
return self.all_candidates.filter(stage="Exam").count() or 0
@property
def interview_candidates_count(self):
return self.all_candidates.filter(stage="Interview").count()
return self.all_candidates.filter(stage="Interview").count() or 0
@property
def offer_candidates_count(self):
return self.all_candidates.filter(stage="Offer").count()
return self.all_candidates.filter(stage="Offer").count() or 0
@property
def hired_candidates_count(self):
return self.all_candidates.filter(stage="Hired").count() or 0
@property
def vacancy_fill_rate(self):
total_positions = self.open_positions
no_of_positions_filled = self.candidates.filter(stage__in=['HIRED']).count()
if total_positions > 0:
vacancy_fill_rate = no_of_positions_filled / total_positions
else:
vacancy_fill_rate = 0.0
return vacancy_fill_rate
class JobPostingImage(models.Model):
@ -643,6 +678,12 @@ class Candidate(Base):
).exists()
return future_meetings or today_future_meetings
# @property
# def time_to_hire(self):
# time_to_hire=self.hired_date-self.created_at
# return time_to_hire
class TrainingMaterial(Base):
@ -711,6 +752,41 @@ class ZoomMeeting(Base):
def __str__(self):
return self.topic
@property
def get_job(self):
try:
job=self.interview.job.first()
return job
except:
return None
@property
def get_candidate(self):
try:
candidate=self.interview.candidate.first()
return candidate
except:
return None
@property
def get_external_participants(self):
try:
interview=self.interview.first()
if interview:
return interview.job.participants.all()
return None
except:
return None
@property
def get_users_participants(self):
try:
interview=self.interview.first()
if interview:
return interview.job.users.all()
return None
except:
return None
class MeetingComment(Base):
@ -1553,7 +1629,8 @@ class InterviewSchedule(Base):
models.Index(fields=['end_date']),
models.Index(fields=['created_by']),
]
class ScheduledInterview(Base):
"""Stores individual scheduled interviews"""
@ -1564,6 +1641,8 @@ class ScheduledInterview(Base):
related_name="scheduled_interviews",
db_index=True
)
job = models.ForeignKey(
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True
)
@ -1672,3 +1751,19 @@ class Notification(models.Model):
self.last_error = error_message
self.attempts += 1
self.save(update_fields=['status', 'last_error', 'attempts'])
class Participants(Base):
"""Model to store Participants details"""
name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True)
email= models.EmailField(verbose_name=_("Email"))
phone = models.CharField(max_length=12,verbose_name=_("Phone Number"),null=True,blank=True)
designation = models.CharField(
max_length=100, blank=True, verbose_name=_("Designation"),null=True
)
def __str__(self):
return f"{self.name} - {self.email}"

View File

@ -47,6 +47,15 @@ def format_job(sender, instance, created, **kwargs):
# If the instance is no longer active, delete the scheduled task
existing_schedule.delete()
# @receiver(post_save, sender=JobPosting)
# def update_form_template_status(sender, instance, created, **kwargs):
# if not created:
# if instance.status == "Active":
# instance.form_template.is_active = True
# else:
# instance.form_template.is_active = False
# instance.save()
@receiver(post_save, sender=Candidate)
def score_candidate_resume(sender, instance, created, **kwargs):
if not instance.is_resume_parsed:

View File

@ -116,25 +116,61 @@ def format_job_description(pk):
job_posting = JobPosting.objects.get(pk=pk)
print(job_posting)
prompt = f"""
Can you please organize and format this unformatted job description and qualifications into clear, readable sections using headings and bullet points?
Format the Content: You need to convert the clear, formatted job description and qualifications into a 2 blocks of HTML code.
**JOB DESCRIPTION:**
{job_posting.description}
You are a dual-purpose AI assistant specializing in content formatting and social media copywriting for job announcements.
**QUALIFICATIONS:**
{job_posting.qualifications}
**JOB POSTING DATA (Raw Input):**
---
**JOB DESCRIPTION:**
{job_posting.description}
**STRICT JSON OUTPUT INSTRUCTIONS:**
Output a single, valid JSON object with ONLY the following two top-level keys:
**QUALIFICATIONS:**
{job_posting.qualifications}
'job_description': 'A HTML containing the formatted job description',
'job_qualifications': 'A HTML containing the formatted job qualifications',
**BENEFITS:**
{job_posting.benefits}
**APPLICATION INSTRUCTIONS:**
{job_posting.application_instructions}
Do not include any other text except for the JSON output.
**APPLICATION DEADLINE:**
{job_posting.application_deadline}
**HASHTAGS: for search and reach:**
{job_posting.hash_tags}
**APPLICATION URL: for career page only if it is provided**
{job_posting.application_url}
---
**TASK 1: HTML Formatting (Two Blocks)**
1. **Format the Job Description:** Organize and format the raw JOB DESCRIPTION and BENEFITS data into clear, readable sections using `<h2>` headings and `<ul>`/`<li>` bullet points. Encapsulate the entire formatted block within a single `<div>`.
2. **Format the Qualifications:** Organize and format the raw QUALIFICATIONS data into clear, readable sections using `<h2>` headings and `<ul>`/`<li>` bullet points. Encapsulate the entire formatted block within a single `<div>`.
3. **Format the Benefits:** Organize and format the raw Requirements data into clear, readable sections using `<h2>` headings and `<ul>`/`<li>` bullet points. Encapsulate the entire formatted block within a single `<div>`.
4. **Application Instructions:** Organize and format the raw Requirements data into clear, readable sections using `<h2>` headings and `<ul>`/`<li>` bullet points. Encapsulate the entire formatted block within a single `<div>`.
**TASK 2: LinkedIn Post Creation**
1. **Write the Post:** Create an engaging, professional, and concise LinkedIn post (maximum 1300 characters) summarizing the opportunity.
2. **Encourage Action:** The post must have a strong call-to-action (CTA) encouraging applications.
3. **Use Hashtags:** Integrate relevant industry, role, and company hashtags (including any provided in the raw input) naturally at the end of the post.
**STRICT JSON OUTPUT INSTRUCTIONS:**
Output a **single, valid JSON object** with **ONLY** the following three top-level key-value pairs.
* The values for `html_job_description` and `html_qualifications` MUST be the complete, formatted HTML strings (including all tags).
* The value for `linkedin_post` MUST be the complete, final LinkedIn post as a single string not greater than 3000 characters.
**Output Keys:**
1. `html_job_description`
2. `html_qualifications`
3. 'html_benefits'
4. 'html_application_instructions'
5. `linkedin_post_data`
**Do not include any other text, explanation, or markdown outside of the final JSON object.**
"""
result = ai_handler(prompt)
print(f"REsults: {result}")
if result['status'] == 'error':
logger.error(f"AI handler returned error for candidate {job_posting.pk}")
print(f"AI handler returned error for candidate {job_posting.pk}")
@ -144,9 +180,12 @@ def format_job_description(pk):
data = json.loads(data)
print(data)
job_posting.description = data.get('job_description')
job_posting.qualifications = data.get('job_qualifications')
job_posting.save(update_fields=['description', 'qualifications'])
job_posting.description = data.get('html_job_description')
job_posting.qualifications = data.get('html_qualifications')
job_posting.benefits=data.get('html_benefits')
job_posting.application_instructions=data.get('html_application_instruction')
job_posting.linkedin_post_formated_data=data.get('linkedin_post_data')
job_posting.save(update_fields=['description', 'qualifications','linkedin_post_formated_data'])
def ai_handler(prompt):
@ -400,6 +439,8 @@ def handle_reume_parsing_and_scoring(pk):
logger.info(f"Successfully scored and saved analysis for candidate {instance.id}")
print(f"Successfully scored and saved analysis for candidate {instance.id}")
def create_interview_and_meeting(
candidate_id,
job_id,

View File

@ -65,7 +65,8 @@ urlpatterns = [
path('forms/builder/<slug:template_slug>/', views.form_builder, name='form_builder'),
path('forms/', views.form_templates_list, name='form_templates_list'),
path('forms/create-template/', views.create_form_template, name='create_form_template'),
path('jobs/<slug:slug>/edit_linkedin_post_content/',views.edit_linkedin_post_content,name='edit_linkedin_post_content'),
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_interview_view/', views.candidate_interview_view, name='candidate_interview_view'),
@ -217,4 +218,12 @@ urlpatterns = [
path('notifications/<int:notification_id>/delete/', views.notification_delete, name='notification_delete'),
path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
#participants urls
path('participants/', views_frontend.ParticipantsListView.as_view(), name='participants_list'),
path('participants/create/', views_frontend.ParticipantsCreateView.as_view(), name='participants_create'),
path('participants/<slug:slug>/', views_frontend.ParticipantsDetailView.as_view(), name='participants_detail'),
path('participants/<slug:slug>/update/', views_frontend.ParticipantsUpdateView.as_view(), name='participants_update'),
path('participants/<slug:slug>/delete/', views_frontend.ParticipantsDeleteView.as_view(), name='participants_delete'),
]

View File

@ -612,4 +612,8 @@ def update_meeting(instance, updated_data):
return {"status": "success", "message": "Zoom meeting updated successfully."}
logger.warning(f"Failed to update Zoom meeting {instance.meeting_id}. Error: {result.get('message', 'Unknown error')}")
return {"status": "error", "message": result.get("message", "Zoom meeting update failed.")}
return {"status": "error", "message": result.get("message", "Zoom meeting update failed.")}

View File

@ -17,7 +17,8 @@ from django.urls import reverse
from django.conf import settings
from django.utils import timezone
from django.db.models import FloatField,CharField, DurationField
from django.db.models.functions import Cast
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
from django.db.models.functions import Cast, Coalesce, TruncDate
from django.db.models.fields.json import KeyTextTransform
from django.db.models.expressions import ExpressionWrapper
from django.db.models import Count, Avg, F,Q
@ -38,7 +39,9 @@ from .forms import (
AgencyCandidateSubmissionForm,
AgencyLoginForm,
AgencyAccessLinkForm,
AgencyJobAssignmentForm
AgencyJobAssignmentForm,
LinkedPostContentForm,
ParticipantsSelectForm
)
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
from rest_framework import viewsets
@ -332,6 +335,8 @@ def edit_job(request, slug):
return render(request, "jobs/edit_job.html", {"form": form, "job": job})
SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
HIGH_POTENTIAL_THRESHOLD=75
@login_required
def job_detail(request, slug):
"""View details of a specific job"""
@ -351,6 +356,7 @@ def job_detail(request, slug):
offer_count = applicants.filter(stage="Offer").count()
status_form = JobPostingStatusForm(instance=job)
linkedin_content_form=LinkedPostContentForm(instance=job)
try:
# If the related object exists, use its instance data
image_upload_form = JobPostingImageForm(instance=job.post_images)
@ -365,6 +371,15 @@ def job_detail(request, slug):
status_form = JobPostingStatusForm(request.POST, instance=job)
if status_form.is_valid():
job_status=status_form.cleaned_data['status']
form_template=job.form_template
if job_status=='ACTIVE':
form_template.is_active=True
form_template.save(update_fields=['is_active'])
else:
form_template.is_active=False
form_template.save(update_fields=['is_active'])
status_form.save()
# Add a success message
@ -381,29 +396,31 @@ def job_detail(request, slug):
# --- 2. Quality Metrics (JSON Aggregation) ---
# Filter for candidates who have been scored and annotate with a sortable score
candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
# Extract the score as TEXT
score_as_text=KeyTextTransform(
'match_score',
KeyTextTransform('resume_data', F('ai_analysis_data'))
)
# candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
# # Extract the score as TEXT
# score_as_text=KeyTextTransform(
# 'match_score',
# KeyTextTransform('resume_data', F('ai_analysis_data'))
# )
# ).annotate(
# # Cast the extracted text score to a FloatField for numerical operations
# sortable_score=Cast('score_as_text', output_field=FloatField())
# )
candidates_with_score = applicants.filter(
is_resume_parsed=True
).annotate(
# Cast the extracted text score to a FloatField for numerical operations
sortable_score=Cast('score_as_text', output_field=FloatField())
annotated_match_score=Coalesce(
Cast(SCORE_PATH, output_field=IntegerField()),
0
)
)
total_candidates=applicants.count()
avg_match_score_result = candidates_with_score.aggregate(avg_score=Avg('annotated_match_score'))['avg_score']
avg_match_score = round(avg_match_score_result or 0, 1)
high_potential_count = candidates_with_score.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count()
high_potential_ratio = round( (high_potential_count / total_candidates) * 100, 1 ) if total_candidates > 0 else 0
# Aggregate: Average Match Score
avg_match_score_result = candidates_with_score.aggregate(
avg_score=Avg('sortable_score')
)['avg_score']
avg_match_score = round(avg_match_score_result or 0, 1)
# Metric: High Potential Count (Score >= 75)
high_potential_count = candidates_with_score.filter(
sortable_score__gte=75
).count()
high_potential_ratio = round((high_potential_count / total_applicant) * 100, 1) if total_applicant > 0 else 0
# --- 3. Time Metrics (Duration Aggregation) ---
# Metric: Average Time from Applied to Interview (T2I)
@ -470,6 +487,7 @@ def job_detail(request, slug):
'high_potential_ratio': high_potential_ratio,
'avg_t2i_days': avg_t2i_days,
'avg_t_in_exam_days': avg_t_in_exam_days,
'linkedin_content_form':linkedin_content_form
}
return render(request, "jobs/job_detail.html", context)
@ -507,6 +525,30 @@ def job_image_upload(request, slug):
return redirect('job_detail', slug=job.slug)
@login_required
def edit_linkedin_post_content(request,slug):
job=get_object_or_404(JobPosting,slug=slug)
linkedin_content_form=LinkedPostContentForm(instance=job)
if request.method=='POST':
linkedin_content_form=LinkedPostContentForm(request.POST,instance=job)
if linkedin_content_form.is_valid():
linkedin_content_form.save()
messages.success(request,"Linked post content updated successfully!")
return redirect('job_detail',job.slug)
else:
messages.error(request,"Error update the Linkedin Post content")
return redirect('job_detail',job.slug)
else:
linkedin_content_form=LinkedPostContentForm()
return redirect('job_detail',job.slug)
def kaauh_career(request):
active_jobs = JobPosting.objects.select_related(
'form_template'
@ -1391,8 +1433,43 @@ def candidate_update_status(request, slug):
@login_required
def candidate_interview_view(request,slug):
job = get_object_or_404(JobPosting, slug=slug)
context = {"job":job,"candidates":job.interview_candidates,'current_stage':'Interview'}
job = get_object_or_404(JobPosting,slug=slug)
if request.method == "POST":
form = ParticipantsSelectForm(request.POST, instance=job)
print(form.errors)
if form.is_valid():
# Save the main instance (JobPosting)
job_instance = form.save(commit=False)
job_instance.save()
# MANUALLY set the M2M relationships based on submitted data
job_instance.participants.set(form.cleaned_data['participants'])
job_instance.users.set(form.cleaned_data['users'])
messages.success(request, "Interview participants updated successfully.")
return redirect("candidate_interview_view", slug=job.slug)
else:
# 🛑 FIX: Explicitly pass the initial data for M2M fields
initial_data = {
'participants': job.participants.all(),
'users': job.users.all(),
}
form = ParticipantsSelectForm(instance=job, initial=initial_data)
else:
form = ParticipantsSelectForm(instance=job)
context = {
"job":job,
"candidates":job.interview_candidates,
'current_stage':'Interview',
'form':form
}
return render(request,"recruitment/candidate_interview_view.html",context)
@login_required

View File

@ -243,7 +243,7 @@ def candidate_detail(request, slug):
stage_form = None
if request.user.is_staff:
stage_form = forms.CandidateStageForm()
# parsed = JSON(json.dumps(parsed), indent=2, highlight=True, skip_keys=False, ensure_ascii=False, check_circular=True, allow_nan=True, default=None, sort_keys=False)
# parsed = json_to_markdown_table([parsed])
return render(request, 'recruitment/candidate_detail.html', {
@ -339,110 +339,227 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
success_url = reverse_lazy('training_list')
success_message = 'Training material deleted successfully.'
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
from django.db.models.functions import Cast, Coalesce, TruncDate
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.utils import timezone
from datetime import timedelta
import json
# IMPORTANT: Ensure 'models' correctly refers to your Django models file
# Example: from . import models
# --- Constants ---
SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
HIGH_POTENTIAL_THRESHOLD = 75
MAX_TIME_TO_HIRE_DAYS = 90
TARGET_TIME_TO_HIRE_DAYS = 45 # Used for the template visualization
@login_required
def dashboard_view(request):
# --- Performance Optimization: Aggregate Data in ONE Query ---
selected_job_pk = request.GET.get('selected_job_pk')
today = timezone.now().date()
# --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) ---
all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at')
all_candidates_queryset = models.Candidate.objects.all()
# 1. Base Job Query: Get all jobs and annotate with candidate count
jobs_with_counts = models.JobPosting.objects.annotate(
candidate_count=Count('candidates')
).order_by('-candidate_count')
# Global KPI Card Metrics
total_jobs_global = all_jobs_queryset.count()
total_participants = models.Participants.objects.count()
total_jobs_posted_linkedin = all_jobs_queryset.filter(linkedin_post_id__isnull=False).count()
# Data for Job App Count Chart (always for ALL jobs)
job_titles = [job.title for job in all_jobs_queryset]
job_app_counts = [job.candidates.count() for job in all_jobs_queryset]
total_jobs = jobs_with_counts.count()
total_candidates = models.Candidate.objects.count()
# --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS ---
# Group ALL candidates by creation date
global_daily_applications_qs = all_candidates_queryset.annotate(
date=TruncDate('created_at')
).values('date').annotate(
count=Count('pk')
).order_by('date')
job_titles = [job.title for job in jobs_with_counts]
job_app_counts = [job.candidate_count for job in jobs_with_counts]
global_dates = [item['date'].strftime('%Y-%m-%d') for item in global_daily_applications_qs]
global_counts = [item['count'] for item in global_daily_applications_qs]
average_applications = round(jobs_with_counts.aggregate(
avg_apps=Avg('candidate_count')
)['avg_apps'] or 0, 2)
# 5. New: Candidate Quality & Funnel Metrics
# --- 3. FILTERING LOGIC: Determine the scope for scoped metrics ---
candidate_queryset = all_candidates_queryset
job_scope_queryset = all_jobs_queryset
interview_queryset = models.ScheduledInterview.objects.all()
current_job = None
if selected_job_pk:
# Filter all base querysets
candidate_queryset = candidate_queryset.filter(job__pk=selected_job_pk)
interview_queryset = interview_queryset.filter(job__pk=selected_job_pk)
try:
current_job = all_jobs_queryset.get(pk=selected_job_pk)
job_scope_queryset = models.JobPosting.objects.filter(pk=selected_job_pk)
except models.JobPosting.DoesNotExist:
pass
# Assuming 'match_score' is a direct IntegerField/FloatField on the Candidate model
# (based on the final, optimized version of handle_reume_parsing_and_scoring)
# --- 4. TIME SERIES: SCOPED DAILY APPLICANTS ---
# Average Match Score (Overall Quality)
candidates_with_score = models.Candidate.objects.filter(
# Filter only candidates that have been parsed/scored
# Only run if a specific job is selected
scoped_dates = []
scoped_counts = []
if selected_job_pk:
scoped_daily_applications_qs = candidate_queryset.annotate(
date=TruncDate('created_at')
).values('date').annotate(
count=Count('pk')
).order_by('date')
scoped_dates = [item['date'].strftime('%Y-%m-%d') for item in scoped_daily_applications_qs]
scoped_counts = [item['count'] for item in scoped_daily_applications_qs]
# --- 5. SCOPED CORE AGGREGATIONS (FILTERED OR ALL) ---
total_candidates = candidate_queryset.count()
candidates_with_score_query = candidate_queryset.filter(
is_resume_parsed=True
).annotate(
score_as_text=KeyTextTransform(
'match_score',
KeyTextTransform('scoring_data', F('ai_analysis_data'))
annotated_match_score=Coalesce(
Cast(SCORE_PATH, output_field=IntegerField()),
0
)
).annotate(
# Cast the extracted text score to a FloatField so AVG() can operate on it.
sortable_score=Cast('score_as_text', output_field=FloatField())
)
# 2b. AGGREGATE using the newly created 'sortable_score' field
avg_match_score_result = candidates_with_score.aggregate(
avg_score=Avg('sortable_score')
)['avg_score']
# A. Pipeline & Volume Metrics (Scoped)
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
last_week = timezone.now() - timedelta(days=7)
new_candidates_7days = candidate_queryset.filter(created_at__gte=last_week).count()
open_positions_agg = job_scope_queryset.filter(status="ACTIVE").aggregate(total_open=Sum('open_positions'))
total_open_positions = open_positions_agg['total_open'] or 0
average_applications_result = job_scope_queryset.annotate(
candidate_count=Count('candidates', distinct=True)
).aggregate(avg_apps=Avg('candidate_count'))['avg_apps']
average_applications = round(average_applications_result or 0, 2)
avg_match_score = round(avg_match_score_result or 0, 1)
# 2c. Use the annotated QuerySet for other metrics
# B. Efficiency & Conversion Metrics (Scoped)
hired_candidates = candidate_queryset.filter(
Q(offer_status="Accepted") | Q(stage='HIRED'),
join_date__isnull=False
)
time_to_hire_query = hired_candidates.annotate(
time_diff=ExpressionWrapper(
F('join_date') - F('created_at__date'),
output_field=fields.DurationField()
)
).aggregate(avg_time_to_hire=Avg('time_diff'))
avg_time_to_hire_days = (
time_to_hire_query.get('avg_time_to_hire').days
if time_to_hire_query.get('avg_time_to_hire') else 0
)
applied_count = candidate_queryset.filter(stage='Applied').count()
advanced_count = candidate_queryset.filter(stage__in=['Exam', 'Interview', 'Offer']).count()
screening_pass_rate = round( (advanced_count / applied_count) * 100, 1 ) if applied_count > 0 else 0
offers_extended_count = candidate_queryset.filter(stage='Offer').count()
offers_accepted_count = candidate_queryset.filter(offer_status='Accepted').count()
offers_accepted_rate = round( (offers_accepted_count / offers_extended_count) * 100, 1 ) if offers_extended_count > 0 else 0
filled_positions = offers_accepted_count
vacancy_fill_rate = round( (filled_positions / total_open_positions) * 100, 1 ) if total_open_positions > 0 else 0
# Scored Candidates Ratio (Now simpler, as we filtered the QuerySet)
total_scored = candidates_with_score.count()
scored_ratio = round((total_scored / total_candidates) * 100, 1) if total_candidates > 0 else 0
# High Potential Candidates (Filter the annotated QuerySet)
high_potential_count = candidates_with_score.filter(
sortable_score__gte=75
# C. Activity & Quality Metrics (Scoped)
current_year, current_week, _ = today.isocalendar()
meetings_scheduled_this_week = interview_queryset.filter(
interview_date__week=current_week, interview_date__year=current_year
).count()
high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0
avg_match_score_result = candidates_with_score_query.aggregate(avg_score=Avg('annotated_match_score'))['avg_score']
avg_match_score = round(avg_match_score_result or 0, 1)
high_potential_count = candidates_with_score_query.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count()
high_potential_ratio = round( (high_potential_count / total_candidates) * 100, 1 ) if total_candidates > 0 else 0
total_scored_candidates = candidates_with_score_query.count()
scored_ratio = round( (total_scored_candidates / total_candidates) * 100, 1 ) if total_candidates > 0 else 0
jobs=models.JobPosting.objects.all().order_by('internal_job_id')
selected_job_id=request.GET.get('selected_job_id','')
candidate_stage=['APPLIED','EXAM','INTERVIEW','OFFER']
apply_count,exam_count,interview_count,offer_count=[0]*4
if selected_job_id:
job=jobs.get(internal_job_id=selected_job_id)
apply_count=job.screening_candidates_count
exam_count=job.exam_candidates_count
interview_count=job.interview_candidates_count
offer_count=job.offer_candidates_count
all_candidates_count=job.all_candidates_count
# --- 6. CHART DATA PREPARATION ---
else: #default job
job=jobs.first()
apply_count=job.screening_candidates_count
exam_count=job.exam_candidates_count
interview_count=job.interview_candidates_count
offer_count=job.offer_candidates_count
all_candidates_count=job.all_candidates_count
candidates_count=[ apply_count,exam_count,interview_count,offer_count ]
# A. Pipeline Funnel (Scoped)
stage_counts = candidate_queryset.values('stage').annotate(count=Count('stage'))
stage_map = {item['stage']: item['count'] for item in stage_counts}
candidate_stage = ['Applied', 'Exam', 'Interview', 'Offer', 'HIRED']
candidates_count = [
stage_map.get('Applied', 0), stage_map.get('Exam', 0), stage_map.get('Interview', 0),
stage_map.get('Offer', 0), filled_positions
]
# --- 7. GAUGE CHART CALCULATION (Time-to-Hire) ---
current_days = avg_time_to_hire_days
rotation_percent = current_days / MAX_TIME_TO_HIRE_DAYS if MAX_TIME_TO_HIRE_DAYS > 0 else 0
rotation_degrees = rotation_percent * 180
rotation_degrees_final = round(min(rotation_degrees, 180), 1) # Ensure max 180 degrees
# --- 8. CONTEXT RETURN ---
context = {
'total_jobs': total_jobs,
# Global KPIs
'total_jobs_global': total_jobs_global,
'total_participants': total_participants,
'total_jobs_posted_linkedin': total_jobs_posted_linkedin,
# Scoped KPIs
'total_active_jobs': total_active_jobs,
'total_candidates': total_candidates,
'new_candidates_7days': new_candidates_7days,
'total_open_positions': total_open_positions,
'average_applications': average_applications,
# Chart Data
'job_titles': json.dumps(job_titles),
'job_app_counts': json.dumps(job_app_counts),
# New Analytical Metrics (FIXED)
'avg_match_score': avg_match_score,
'avg_time_to_hire_days': avg_time_to_hire_days,
'screening_pass_rate': screening_pass_rate,
'offers_accepted_rate': offers_accepted_rate,
'vacancy_fill_rate': vacancy_fill_rate,
'meetings_scheduled_this_week': meetings_scheduled_this_week,
'avg_match_score': avg_match_score,
'high_potential_count': high_potential_count,
'high_potential_ratio': high_potential_ratio,
'scored_ratio': scored_ratio,
'current_job_id':selected_job_id,
'jobs':jobs,
'all_candidates_count':all_candidates_count,
'candidate_stage':json.dumps(candidate_stage),
'candidates_count':json.dumps(candidates_count)
,'my_job':job
# Chart Data
'candidate_stage': json.dumps(candidate_stage),
'candidates_count': json.dumps(candidates_count),
'job_titles': json.dumps(job_titles),
'job_app_counts': json.dumps(job_app_counts),
# 'source_volume_chart_data' is intentionally REMOVED
# Time Series Data
'global_dates': json.dumps(global_dates),
'global_counts': json.dumps(global_counts),
'scoped_dates': json.dumps(scoped_dates),
'scoped_counts': json.dumps(scoped_counts),
'is_job_scoped': bool(selected_job_pk),
# Gauge Data
'gauge_max_days': MAX_TIME_TO_HIRE_DAYS,
'gauge_target_days': TARGET_TIME_TO_HIRE_DAYS,
'gauge_rotation_degrees': rotation_degrees_final,
# UI Control
'jobs': all_jobs_queryset,
'current_job_id': selected_job_pk,
'current_job': current_job,
}
return render(request, 'recruitment/dashboard.html', context)
@login_required
def candidate_offer_view(request, slug):
"""View for candidates in the Offer stage"""
@ -859,3 +976,71 @@ def sync_history(request, job_slug=None):
}
return render(request, 'recruitment/sync_history.html', context)
#participants views
class ParticipantsListView(LoginRequiredMixin, ListView):
model = models.Participants
template_name = 'participants/participants_list.html'
context_object_name = 'participants'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
# Handle search
search_query = self.request.GET.get('search', '')
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(email__icontains=search_query) |
Q(phone__icontains=search_query) |
Q(designation__icontains=search_query)
)
# Filter for non-staff users
if not self.request.user.is_staff:
return models.Participants.objects.none() # Restrict for non-staff
return queryset.order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class ParticipantsDetailView(LoginRequiredMixin, DetailView):
model = models.Participants
template_name = 'participants/participants_detail.html'
context_object_name = 'participant'
slug_url_kwarg = 'slug'
class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Participants
form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html'
success_url = reverse_lazy('job_list')
success_message = 'Participant created successfully.'
# def get_initial(self):
# initial = super().get_initial()
# if 'slug' in self.kwargs:
# job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
# initial['jobs'] = [job]
# return initial
class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Participants
form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html'
success_url = reverse_lazy('job_list')
success_message = 'Participant updated successfully.'
slug_url_kwarg = 'slug'
class ParticipantsDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.Participants
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
success_message = 'Participant deleted successfully.'
slug_url_kwarg = 'slug'

View File

@ -6,7 +6,7 @@
</p>
<p style="font-family: Arial, sans-serif; font-size: 16px; color: #333;">
{% blocktrans %}Thank you for choosing **KAAUH ATS**. To verify the ownership of your email address, please click the confirmation link below:{% endblocktrans %}
{% blocktrans %}To verify the ownership of your email address, please click the confirmation link below:{% endblocktrans %}
</p>
<div style="text-align: center; margin: 30px 0;">

View File

@ -0,0 +1,18 @@
{% load account i18n %}
{% autoescape off %}
{% blocktrans %}Hello,{% endblocktrans %}
{% blocktrans %}To verify the ownership of your email address, please click the confirmation link below:{% endblocktrans %}
{% trans "Confirm My KAAUH ATS Email" %}
{{ activate_url }}
{% blocktrans %}If you did not request this verification, you can safely ignore this email.{% endblocktrans %}
{% blocktrans %}Alternatively, copy and paste this link into your browser:{% endblocktrans %}
{{ activate_url }}
{% endautoescape %}

View File

@ -0,0 +1,27 @@
{% load i18n %}
{% load static %}
{% autoescape off %}
{% trans "Password Reset Request" %}
{% trans "Hello," %}
{% blocktrans %}You are receiving this email because you or someone else has requested a password reset for your account at{% endblocktrans %} {{ current_site.name }}.
------------------------------------------------------
{% trans "Click Here to Reset Your Password" %}
{{ password_reset_url }}
------------------------------------------------------
{% trans "This link is only valid for a limited time." %}
{% trans "If you did not request a password reset, please ignore this email. Your password will remain unchanged." %}
{% trans "Thank you," %}
{% trans "KAAUH ATS Team" %}
---
{% trans "If the button above does not work, copy and paste the following link into your browser:" %}
{{ password_reset_url }}
{% endautoescape %}

View File

@ -1,6 +1,8 @@
{% load static %}
{% load static i18n %}
{% get_current_language_bidi as LANGUAGE_BIDI %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html lang="en">
<html lang="{{LANGUAGE_CODE}}" dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -146,34 +148,34 @@
<div class="right-panel-content-wrapper">
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">Sign In</h2>
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">{% trans "Sign In" %}</h2>
<div class="form-fields">
<form id="login-form" class="space-y-4" method="post" action="{% url 'account_login' %}">
{% csrf_token %}
<div class="mb-3">
<label for="id_login" class="form-label fw-semibold">Username or Email *</label>
<input type="text" name="login" id="id_login" class="form-control" placeholder="Enter username or email" required autofocus>
<label for="id_login" class="form-label fw-semibold"> {% trans "Email *" %}</label>
<input type="text" name="login" id="id_login" class="form-control" placeholder="{% trans 'Enter your email' %}" required autofocus>
</div>
<div class="mb-3">
<label for="id_password" class="form-label fw-semibold">Password *</label>
<input type="password" name="password" id="id_password" class="form-control" placeholder="Password" required>
<label for="id_password" class="form-label fw-semibold">{% trans "Password *" %}</label>
<input type="password" name="password" id="id_password" class="form-control" placeholder="{% trans 'Password' %}" required>
<div class="text-end mt-2">
<a href="{% url 'account_reset_password' %}" class="small text-accent fw-medium">Forgot Password?</a>
<a href="{% url 'account_reset_password' %}" class="small text-accent fw-medium">{% trans 'Forgot Password?' %}</a>
</div>
</div>
<div class="form-check pt-3">
<input class="form-check-input" type="checkbox" name="remember" id="id_remember">
<label class="form-check-label text-muted" for="id_remember">
Keep me signed in
{% trans "Keep me signed in" %}
</label>
</div>
<button type="submit" class="btn btn-primary w-100 mt-4">Sign In</button>
<button type="submit" class="btn btn-primary w-100 mt-4">{% trans "Sign In" %}</button>
</form>
</div>
</div>

View File

@ -255,16 +255,18 @@
</span>
</a>
</li>
{% comment %} <li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'participants_list' %}active{% endif %}" href="{% url 'participants_list' %}">
<span class="d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" />
</svg>
{% trans "Training" %}
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
{% trans "Participants" %}
</span>
</a>
</li> {% endcomment %}
</li>
{% comment %} <li class="nav-item dropdown ms-lg-2">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
data-bs-offset="0, 8" data-bs-auto-close="outside">

View File

@ -347,10 +347,19 @@
<i class="fas fa-plus-circle me-1"></i> {% trans "Create New Form Template" %}
</a>
{% else %}
<a href="{% url 'application_submit_form' job.form_template.pk %}" class="btn btn-outline-secondary w-100">
{% if job.form_template.is_active %}
<a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-outline-secondary w-100">
<i class="fas fa-list-alt me-1"></i> {% trans "View Form Template" %}
</a>
{% else %}
<p>{% trans "This job status is not active, the form will appear once the job is made active"%}</p>
{% endif %}
{% endif %}
</div>
</div>
@ -399,6 +408,11 @@
<small>{% trans "Error:" %} {{ job.linkedin_post_status }}</small>
</div>
{% endif %}
{% if job.linkedin_post_formated_data %}
<button type="button" class="btn btn-main-action w-100" data-bs-toggle="modal" data-bs-target="#linkedinData">
<i class="fas fa-edit me-1"></i> {% trans "Update LinkedIn Content" %}
</button>
{% endif %}
</div>
</div>
</div>
@ -474,6 +488,16 @@
</div>
</div>
</div>
<!--Vacancy fill rate-->
<div class="col-6">
<div class="card text-center h-100">
<div class="card-body p-2">
<i class="fas fa-trophy text-secondary mb-1 d-block" style="font-size: 1.2rem;"></i>
<div class="h4 mb-0 text-secondary fw-bold">{{ job.vacancy_fill_rate|floatformat:2 }}</div>
<small class="text-muted d-block">{% trans "Vacancy Fill Rate" %}</small>
</div>
</div>
</div>
</div>
</div>
</div>
@ -484,6 +508,7 @@
</div>
</div>
{% include "jobs/partials/image_upload.html" %}
{% include "jobs/partials/linkedin_content_form.html"%}
<div class="modal fade" id="editStatusModal" tabindex="-1" aria-labelledby="editStatusModalLabel" aria-hidden="true">
<div class="modal-dialog">

View File

@ -257,7 +257,7 @@
{% 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 d-md-none">
<div class="card shadow-sm">
<div class="table-responsive ">
<table class="table table-hover align-middle mb-0 table-sm">
@ -327,9 +327,9 @@
{# CANDIDATE MANAGEMENT DATA - URLS NEUTRALIZED #}
<td class="candidate-data-cell text-primary-theme"><a href="#" class="text-primary-theme">{% if job.all_candidates.count %}{{ job.all_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-info"><a href="{% url 'candidate_screening_view' job.slug %}" class="text-info">{% if job.screening_candidates.count %}{{ job.screening_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_screening_view' job.slug %}" class="text-success">{% if job.exam_candidates.count %}{{ job.exam_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_exam_view' job.slug %}" class="text-success">{% if job.interview_candidates.count %}{{ job.interview_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_interview_view' job.slug %}" class="text-success">{% if job.offer_candidates.count %}{{ job.offer_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_exam_view' job.slug %}" class="text-success">{% if job.exam_candidates.count %}{{ job.exam_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_interview_view' job.slug %}" class="text-success">{% if job.interview_candidates.count %}{{ job.interview_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_offer_view' job.slug %}" class="text-success">{% if job.offer_candidates.count %}{{ job.offer_candidates.count }}{% else %}-{% endif %}</a></td>
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,22 @@
{% load crispy_forms_tags %}
<div class="modal fade mt-4" id="linkedinData" tabindex="-1" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="myModalLabel">Edit linkedin Post content</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="{% url 'edit_linkedin_post_content' job.slug %}" enctype="multipart/form-data" >
{% csrf_token %}
{{ linkedin_content_form|crispy }}
<div class="modal-footer mt-2">
<button type="button" class="btn btn-lg btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-main-action ">Save changes</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@ -3,199 +3,199 @@
{% block customCSS %}
<style>
/* -------------------------------------------------------------------------- */
/* KAAT-S Redesign CSS - Optimized Compact Detail View (Comments Left) */
/* KAAT-S Redesign CSS - Compacted and Reordered Layout */
/* -------------------------------------------------------------------------- */
:root {
--kaauh-teal: #00636e; /* Primary Brand Teal */
--kaauh-teal-dark: #004a53; /* Darker Teal for Text/Hover */
--kaauh-teal-light: #e0f7f9; /* Lightest Teal for background accents */
--kaauh-border: #e9ecef; /* Soft Border Gray */
--kaauh-primary-text: #212529; /* Dark Text */
--kaauh-secondary-text: #6c757d;/* Muted Text */
--kaauh-gray-light: #f8f9fa; /* Card Header/Footer Background */
--kaauh-success: #198754; /* Success Green */
--kaauh-danger: #dc3545; /* Danger Red */
/* New CRM/ATS Specific Colors */
--kaauh-link: #007bff; /* Standard Blue Link */
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-teal-light: #e0f7f9;
--kaauh-border: #e9ecef;
--kaauh-primary-text: #212529;
--kaauh-secondary-text: #6c757d;
--kaauh-gray-light: #f8f9fa;
--kaauh-success: #198754;
--kaauh-danger: #dc3545;
--kaauh-link: #007bff;
--kaauh-link-hover: #0056b3;
--kaauh-accent-bg: #fff3cd; /* Light Yellow for Attention/Context */
--kaauh-accent-text: #664d03; /* Dark Yellow Text */
}
body {
background-color: #f0f2f5;
background-color: #f0f2f5;
font-family: 'Inter', sans-serif;
}
/* ------------------ General Layout & Card Styles ------------------ */
/* ------------------ Card & Header Styles ------------------ */
.card {
border: none;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05), 0 2px 5px rgba(0,0,0,0.03);
margin-bottom: 1.5rem;
transition: all 0.2s ease;
}
.card:not(.no-hover):hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.08);
border-radius: 8px; /* Slightly smaller radius */
box-shadow: 0 3px 10px rgba(0,0,0,0.04); /* Lighter shadow */
margin-bottom: 1rem;
}
.card-body {
padding: 1.5rem;
padding: 1rem 1.25rem; /* Reduced padding */
}
/* ------------------ Main Header & Title Styles ------------------ */
.main-title-card {
padding: 1.5rem 2rem;
#comments-card .card-header {
background-color: white;
border-bottom: 3px solid var(--kaauh-teal);
border-radius: 12px 12px 0 0;
color: var(--kaauh-teal-dark);
padding: 0.75rem 1.25rem; /* Reduced header padding */
font-weight: 600;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid var(--kaauh-border);
}
.main-title-card h1 {
color: var(--kaauh-teal-dark);
font-weight: 800;
margin: 0;
font-size: 2rem;
/* ------------------ Main Title & Status ------------------ */
.main-title-container {
padding: 0 0 1rem 0; /* Space below the main title */
}
.main-title-card .heroicon {
width: 2rem;
height: 2rem;
color: var(--kaauh-teal);
.main-title-container h1 {
font-size: 1.75rem; /* Reduced size */
font-weight: 700;
}
.status-badge {
font-size: 0.75rem;
padding: 0.35em 0.8em;
border-radius: 15px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 0.7rem; /* Smaller badge */
padding: 0.3em 0.7em;
border-radius: 12px;
}
.bg-scheduled { background-color: #00636e !important; color: white !important;}
.bg-completed { background-color: #198754 !important; color: white !important;}
.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: var(--kaauh-danger) !important; color: white !important;}
/* ------------------ Detail Row & Content Styles ------------------ */
/* ------------------ Detail Row & Content Styles (Made Smaller) ------------------ */
.detail-section h2 {
.detail-section h2, .card h2 {
color: var(--kaauh-teal-dark);
font-weight: 700;
font-size: 1.25rem;
margin-bottom: 1rem;
border-bottom: 2px solid var(--kaauh-teal-light);
font-size: 1.25rem; /* Reduced size */
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--kaauh-border);
}
.detail-row {
.detail-row-simple {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
padding: 0.4rem 0; /* Reduced vertical padding */
border-bottom: 1px dashed var(--kaauh-border);
align-items: center;
font-size: 0.85rem; /* Smaller text */
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
.detail-label-simple {
font-weight: 600;
color: var(--kaauh-teal);
font-size: 0.9rem;
flex-basis: 45%;
color: var(--kaauh-teal-dark);
flex-basis: 40%;
}
.detail-value {
.detail-value-simple {
color: var(--kaauh-primary-text);
word-wrap: break-word;
font-weight: 500;
text-align: right;
font-size: 0.9rem;
flex-basis: 55%;
flex-basis: 60%;
}
/* --- CRM ASSOCIATED RECORD STYLING --- */
.associated-record-card {
background-color: var(--kaauh-accent-bg);
color: var(--kaauh-accent-text);
border: 1px solid var(--kaauh-accent-text);
}
.associated-record-card a {
color: var(--kaauh-link);
font-weight: 700;
}
.associated-record-card a:hover {
color: var(--kaauh-link-hover);
}
/* ------------------ Join Info & Copy Button ------------------ */
.join-info-card {
border-left: 5px solid var(--kaauh-teal); /* Highlight join info */
}
/* Consolidated primary button style */
.btn-primary {
.btn-primary-teal {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
font-weight: 600;
padding: 0.6rem 1.25rem;
padding: 0.6rem 1.2rem;
font-size: 0.95rem; /* Slightly smaller button */
border-radius: 6px;
transition: background-color 0.2s;
color: white; /* Ensure text color is white for teal primary */
}
.btn-primary:hover {
.btn-primary-teal:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
}
.join-url-container {
margin-top: 1rem;
/* Added Danger Button Style for main delete */
.btn-danger-red {
background-color: var(--kaauh-danger);
border-color: var(--kaauh-danger);
color: white;
padding: 0.6rem 1.2rem;
font-size: 0.95rem;
border-radius: 6px;
font-weight: 600;
}
.join-url-display {
background-color: var(--kaauh-gray-light);
border: 1px solid var(--kaauh-border);
.btn-danger-red:hover {
background-color: #c82333;
border-color: #bd2130;
}
.btn-secondary-back {
/* Subtle Back Button */
background-color: transparent;
border: none;
color: var(--kaauh-secondary-text);
font-weight: 600;
font-size: 1rem;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
transition: color 0.2s;
}
.btn-copy {
.btn-secondary-back:hover {
color: var(--kaauh-teal);
text-decoration: underline;
}
.join-url-display {
background-color: white;
border: 1px solid var(--kaauh-border);
padding: 0.5rem; /* Reduced padding */
font-size: 0.85rem; /* Smaller text */
}
.btn-copy-simple {
padding: 0.5rem 0.75rem;
background-color: var(--kaauh-teal-dark);
border: none;
color: white; /* Ensure copy button icon is white */
color: white;
border-radius: 4px;
}
.btn-copy:hover {
.btn-copy-simple:hover {
background-color: var(--kaauh-teal);
}
/* ------------------ Footer & Actions ------------------ */
.action-bar-footer {
border-top: 1px solid var(--kaauh-border);
padding: 1rem 1.5rem;
background-color: var(--kaauh-gray-light);
border-radius: 0 0 12px 12px;
/* Explicitly use flex for layout control */
display: flex;
justify-content: space-between; /* Separate the left/right groups */
align-items: center;
/* ------------------ Simple Table Styles ------------------ */
.simple-table {
width: 100%;
margin-top: 0.5rem;
border-collapse: collapse;
}
.btn-footer-action {
font-weight: 600;
/* Made buttons smaller and consistent */
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.85rem;
}
/* --- Comment Card Header Style --- */
#comments-card .card-header {
background-color: white;
.simple-table th {
background-color: var(--kaauh-teal-light);
color: var(--kaauh-teal-dark);
padding: 1rem 1.5rem;
font-weight: 600;
border-radius: 12px 12px 0 0;
font-weight: 700;
padding: 8px 12px; /* Reduced padding */
border: 1px solid var(--kaauh-border);
font-size: 0.8rem; /* Smaller table header text */
}
.simple-table td {
padding: 8px 12px; /* Reduced padding */
border: 1px solid var(--kaauh-border);
background-color: white;
font-size: 0.85rem; /* Smaller table body text */
}
/* ------------------ Comment Specific Styles ------------------ */
.comment-item {
border: 1px solid var(--kaauh-border);
background-color: var(--kaauh-gray-light);
border-radius: 6px;
}
/* Style for in-page edit button */
.btn-edit-comment {
background-color: transparent;
border: 1px solid var(--kaauh-teal);
color: var(--kaauh-teal);
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
font-weight: 500;
}
.btn-edit-comment:hover {
background-color: var(--kaauh-teal-light);
}
</style>
{% endblock %}
@ -203,251 +203,263 @@ body {
{% block content %}
<div class="container-fluid py-4">
{# --- TOP BAR / BACK BUTTON --- #}
<div class="d-flex justify-content-between align-items-center mb-3">
{# --- TOP BAR / BACK BUTTON & ACTIONS (EDIT/DELETE) --- #}
<div class="d-flex justify-content-between align-items-center mb-4">
{# Back Button #}
<a href="{% url 'list_meetings' %}" class="btn btn-secondary-back">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Meetings" %}
</a>
{# Edit and Delete Buttons #}
<div class="d-flex gap-2">
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary-teal btn-sm">
<i class="fas fa-edit me-1"></i> {% trans "Edit Meeting" %}
</a>
{# DELETE MEETING FORM #}
<form method="post" action="{% url 'delete_meeting' meeting.slug %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-danger-red btn-sm" onclick="return confirm('{% trans "Are you sure you want to delete this meeting? This action is permanent." %}')">
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete Meeting" %}
</button>
</form>
</div>
</div>
<div class="row g-4">
{# ========================================================= #}
{# --- SECTION 1: PROMINENT TOP DETAILS & JOIN INFO --- #}
{# ========================================================= #}
<div class="row g-4 mb-5">
{# --- LEFT COLUMN (COMMENTS & INTERNAL CONTEXT) - Takes 50% of the screen #}
<div class="col-lg-6 d-flex flex-column">
{# --- 1. INTERNAL NOTES / DESCRIPTION CARD (New CRM Feature) --- #}
{% if meeting.description %}
<div class="card no-hover mb-4 flex-shrink-0">
<div class="card-body detail-section">
<h2 class="d-flex align-items-center"><i class="fas fa-clipboard-list me-2"></i> {% trans "Internal Context" %}</h2>
<p class="text-muted small">{% trans "Meeting agenda, purpose, or interview details for internal team use." %}</p>
<div class="p-3 bg-light rounded border">
<p class="mb-0">{{ meeting.description|safe }}</p>
</div>
{# --- LEFT HALF: MAIN TOPIC & JOB CONTEXT --- #}
<div class="col-lg-6">
<div class="main-title-container">
<h1 class="text-start" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-video me-2" style="color: var(--kaauh-teal);"></i>
{{ meeting.topic|default:"[Meeting Topic N/A]" }}
<span class="status-badge bg-{{ meeting.status|lower|default:'bg-secondary' }} ms-3">
{{ meeting.status|title|default:'N/A' }}
</span>
</h1>
</div>
{# JOB CONTEXT DETAILS (Simple Divs) #}
<div class="p-3 bg-white rounded shadow-sm">
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
<div class="detail-row-group">
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple">{{ meeting.get_job.title|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Type" %}:</div><div class="detail-value-simple">{{ meeting.get_job.job_type|default:"N/A" }}</div></div>
</div>
</div>
{% endif %}
</div>
{# --- 2. Comments Section (Now in the Left Column) --- #}
<div class="card no-hover flex-grow-1" id="comments-card">
{# --- RIGHT HALF: ZOOM LINK / ACTIONS --- #}
<div class="col-lg-6">
<div class="p-3 bg-white rounded shadow-sm">
<h2 class="text-start"><i class="fas fa-info-circle me-2"></i> {% trans "Connection Details" %}</h2>
<div class="detail-row-group">
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Date & Time" %}:</div><div class="detail-value-simple">{{ meeting.start_time|date:"M d, Y H:i"|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Duration" %}:</div><div class="detail-value-simple">{{ meeting.duration|default:"N/A" }} {% trans "minutes" %}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Meeting ID" %}:</div><div class="detail-value-simple">{{ meeting.meeting_id|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Host Email" %}:</div><div class="detail-value-simple">{{ meeting.host_email|default:"N/A" }}</div></div>
{% if meeting.join_url %}
<div class="join-url-container pt-3">
<div id="copy-message" class="text-white rounded px-2 py-1 small fw-bold mb-2 text-center" style="opacity: 0; transition: opacity 0.3s; position: absolute; right: 0; top: 5px; background-color: var(--kaauh-success); z-index: 10;">{% trans "Copied!" %}</div>
<div class="join-url-display d-flex justify-content-between align-items-center position-relative">
<div class="text-truncate me-2">
<strong>{% trans "Join URL" %}:</strong>
<span id="meeting-join-url">{{ meeting.join_url }}</span>
</div>
<button class="btn-copy-simple ms-2 flex-shrink-0" onclick="copyLink()" title="{% trans 'Copy URL' %}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# ========================================================= #}
{# --- SECTION 2: PERSONNEL TABLES --- #}
{# ========================================================= #}
<div class="row g-4 mt-1 mb-5">
{# --- PARTICIPANTS TABLE --- #}
<div class="col-lg-12">
<div class="p-3 bg-white rounded shadow-sm">
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
<table class="simple-table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Role/Designation" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Phone Number" %}</th>
<th>{% trans "Source Type" %}</th>
</tr>
</thead>
<tbody>
{% for participant in meeting.get_external_participants %}
<tr>
<td>{{participant.name}}</td>
<td>{{participant.designation}}</td>
<td>{{participant.email}}</td>
<td>{{participant.phone}}</td>
<td>{% trans "External Participants" %}</td>
</tr>
{% endfor %}
{% for participant in meeting.get_users_participants %}
<tr>
<td>{{participant.name}}</td>
<td>{{participant.designation}}</td>
<td>{{participant.email}}</td>
<td>{{participant.phone}}</td>
<td>{% trans "System User" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ========================================================= #}
{# --- SECTION 3: COMMENTS (CORRECTED) --- #}
{# ========================================================= #}
<div class="row g-4 mt-1">
<div class="col-lg-12">
<div class="card flex-grow-1" id="comments-card" style="height: 100%;">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-comments me-2"></i>
{% trans "Comments" %} ({{ meeting.comments.count }})
{% trans "Comments" %} ({% if meeting.comments %}{{ meeting.comments.count }}{% else %}0{% endif %})
</h5>
{% if user.is_authenticated %}
<button type="button" class="btn btn-primary btn-sm"
hx-get="{% url 'add_meeting_comment' meeting.slug %}"
hx-target="#comment-section"
>
<i class="fas fa-plus me-1"></i> {% trans "Add Comment" %}
</button>
{% endif %}
</div>
<div class="card-body overflow-auto">
<div id="comment-section">
{# 1. COMMENT DISPLAY & IN-PAGE EDIT FORMS #}
<div id="comment-section" class="mb-4">
{% if meeting.comments.all %}
{% for comment in meeting.comments.all|dictsortreversed:"created_at" %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong class="me-2">{{ comment.author.get_full_name|default:comment.author.username }}</strong>
{% if comment.author != user %}
<span class="badge bg-secondary ms-1">{% trans "Comment" %}</span>
{% endif %}
</div>
<small class="text-muted">{{ comment.created_at|date:"M d, Y P" }}</small>
</div>
<div class="card-body">
<p class="card-text">{{ comment.content|safe }}</p>
</div>
<div class="card-footer">
{% if comment.author == user or user.is_staff %}
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary"
hx-get="{% url 'edit_meeting_comment' meeting.slug comment.id %}"
hx-target="#comment-section"
title="{% trans 'Edit Comment' %}">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-outline-danger"
hx-get="{% url 'delete_meeting_comment' meeting.slug comment.id %}"
hx-target="#comment-section"
title="{% trans 'Delete Comment' %}">
<i class="fas fa-trash"></i>
</button>
<div class="comment-item mb-3 p-3">
{# Read-Only Comment View #}
<div id="comment-view-{{ comment.pk }}">
<p class="mb-1 d-flex justify-content-between align-items-start" style="font-size: 0.9rem;">
<div>
<strong>{{ comment.author.get_full_name|default:comment.author.username }}</strong>
<span class="text-muted small ms-2">{{ comment.created_at|date:"M d, Y H:i" }}</span>
</div>
{% endif %}
{% if comment.author == user or user.is_staff %}
<div class="btn-group btn-group-sm">
{# Edit Button: Toggles the hidden form #}
<button type="button" class="btn btn-edit-comment py-0 px-1 me-2" onclick="toggleCommentEdit('{{ comment.pk }}')" id="edit-btn-{{ comment.pk }}" title="{% trans 'Edit Comment' %}">
<i class="fas fa-edit"></i>
</button>
{# Delete Form: Submits a POST request #}
<form method="post" action="{% url 'delete_meeting_comment' meeting.slug comment.pk %}" style="display: inline;" id="delete-form-{{ comment.pk }}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger py-0 px-1" title="{% trans 'Delete Comment' %}" onclick="return confirm('{% trans "Are you sure you want to delete this comment?" %}')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
{% endif %}
</p>
<p class="mb-0 comment-content" style="font-size: 0.85rem; white-space: pre-wrap;">{{ comment.content|linebreaksbr }}</p>
</div>
{# Hidden Edit Form #}
<div id="comment-edit-form-{{ comment.pk }}" style="display: none; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--kaauh-border);">
<form method="POST" action="{% url 'edit_meeting_comment' meeting.slug comment.pk %}" id="form-{{ comment.pk }}">
{% csrf_token %}
<div class="mb-2">
<label for="id_content_{{ comment.pk }}" class="form-label small">{% trans "Edit Comment" %}</label>
{# NOTE: The textarea name must match your Comment model field (usually 'content') #}
<textarea name="content" id="id_content_{{ comment.pk }}" rows="3" class="form-control" required>{{ comment.content }}</textarea>
</div>
<button type="submit" class="btn btn-sm btn-success me-2">
<i class="fas fa-save me-1"></i> {% trans "Save Changes" %}
</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleCommentEdit('{{ comment.pk }}')">
{% trans "Cancel" %}
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">{% trans "No comments yet. Be the first to comment!" %}</p>
{% endif %}
</div>
</div>
</div>
</div>
<hr>
{# --- RIGHT COLUMN (MAIN DETAILS & JOIN INFO) - Takes 50% of the screen #}
<div class="col-lg-6">
<div class="d-flex flex-column h-100">
{# --- CRM ASSOCIATED RECORD CARD (Elevated Importance) --- #}
{% if meeting.interview %}
<div class="card associated-record-card mb-4 flex-shrink-0">
<div class="card-body pt-3 pb-3">
<div class="d-flex align-items-center">
<i class="fas fa-user-tag fa-2x me-3"></i>
<div>
<h6 class="mb-0 text-uppercase small fw-bold">{% trans "Associated Record" %}</h6>
<span class="fw-bold fs-5 me-2">
<a href="{% url 'candidate_detail' meeting.interview.candidate.slug %}" class="text-decoration-none">
{{ meeting.interview.candidate.name }}
</a>
</span>
<span class="badge bg-secondary-subtle text-secondary small fw-normal">{{ meeting.interview.job_position }}</span>
{# 2. NEW COMMENT SUBMISSION (Remains the same) #}
<h6 class="mb-3" style="color: var(--kaauh-teal-dark);">{% trans "Add a New Comment" %}</h6>
{% if user.is_authenticated %}
<form method="POST" action="{% url 'add_meeting_comment' meeting.slug %}">
{% csrf_token %}
{% if comment_form %}
{{ comment_form.as_p }}
{% else %}
<div class="mb-3">
<label for="id_content" class="form-label small">{% trans "Comment" %}</label>
<textarea name="content" id="id_content" rows="3" class="form-control" required></textarea>
</div>
</div>
</div>
</div>
{% endif %}
{# --- 1. MAIN DETAILS CARD --- #}
<div class="card no-hover flex-grow-1 mb-4">
{# --- CONSOLIDATED HEADER --- #}
<div class="main-title-card">
<div class="d-flex justify-content-between align-items-start">
<div class="card-header-title-group">
<h1 class="mb-1">
<svg class="heroicon me-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>
<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>
</svg>
{{ meeting.topic }}
</h1>
<div class="d-flex align-items-center gap-3">
<span class="status-badge bg-{{ meeting.status }}">
{{ meeting.status|title }}
</span>
</div>
</div>
</div>
</div>
{# --- CONNECTION DETAIL BODY (Renamed from Core Details) --- #}
<div class="card-body detail-section">
<h2><i class="fas fa-calendar-alt me-2"></i> {% trans "Connection Details" %}</h2>
<div class="detail-row-group">
<div class="detail-row"><div class="detail-label">{% trans "Meeting ID" %}:</div><div class="detail-value">{{ meeting.meeting_id }}</div></div>
<div class="detail-row"><div class="detail-label">{% trans "Start Time" %}:</div><div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div></div>
<div class="detail-row"><div class="detail-label">{% trans "Duration" %}:</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 class="detail-row"><div class="detail-label">{% trans "Host Email" %}:</div><div class="detail-value">{{ meeting.host_email|default:"N/A" }}</div></div>
</div>
</div>
{# --- ACTION BAR AT THE BOTTOM OF THE MAIN CARD --- #}
<div class="card-footer action-bar-footer">
<div>
{# Placeholder for future left-aligned button #}
</div>
<div class="d-flex gap-2">
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary btn-footer-action">
<i class="fas fa-edit me-1"></i> {% trans "Update" %}
</a>
{% if meeting.zoom_gateway_response %}
<button type="button" class="btn btn-secondary btn-footer-action" onclick="toggleGateway()">
<i class="fas fa-code me-1"></i> {% trans "API Response" %}
</button>
{% endif %}
<button type="button" class="btn btn-danger btn-footer-action ms-3" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt me-1"></i>
Delete
<button type="submit" class="btn btn-primary-teal btn-sm mt-2">
<i class="fas fa-paper-plane me-1"></i> {% trans "Submit Comment" %}
</button>
</div>
</div>
</form>
{% else %}
<p class="text-muted small">{% trans "You must be logged in to add a comment." %}</p>
{% endif %}
</div>
{# --- 2. JOIN INFO CARD (Separate from details, but in the same column) --- #}
{% if meeting.join_url %}
<div class="card no-hover join-info-card detail-section flex-shrink-0">
<div class="card-body">
<h2><i class="fas fa-link me-2"></i> {% trans "Join Information" %}</h2>
<a href="{{ meeting.join_url }}" class="btn btn-primary w-100 mb-4" target="_blank">
<i class="fas fa-video me-1"></i> {% trans "Join Meeting Now" %}
</a>
<div class="join-url-container">
{# Message should not be display: none; but opacity: 0; for smooth transition #}
<div id="copy-message" class="text-white rounded px-2 py-1 small fw-bold mb-2 text-center" style="opacity: 0; transition: opacity 0.3s; position: absolute; right: 0; top: -30px; background-color: var(--kaauh-success); z-index: 10;">{% trans "Copied!" %}</div>
<div class="join-url-display d-flex justify-content-between align-items-center position-relative">
<div class="text-truncate me-2">
<strong>{% trans "Join URL" %}:</strong>
<span id="meeting-join-url">{{ meeting.join_url }}</span>
</div>
<button class="btn-copy ms-2 flex-shrink-0" onclick="copyLink()" title="{% trans 'Copy URL' %}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
{% if meeting.password %}
<div class="detail-row" style="border: none; padding-top: 1rem;">
<div class="detail-label" style="font-size: 1rem;">{% trans "Password" %}:</div>
<div class="detail-value fw-bolder text-danger">{{ meeting.password }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
{# --- API RESPONSE CARD (Full width, hidden by default) --- #}
{% if meeting.zoom_gateway_response %}
<div id="gateway-response-card" class="card mt-4" style="display: none;">
<div class="card-body">
<h3>{% trans "API Gateway Response" %}</h3>
<pre>{{ meeting.zoom_gateway_response|safe }}</pre>
</div>
</div>
{% endif %}
</div>
{# MODALS (KEEP OUTSIDE OF THE MAIN LAYOUT ROWS) #}
{% comment %} {% include 'modals/delete_modal.html' with item_name="Meeting" delete_url_name='delete_meeting' %} {% endcomment %}
<div class="modal fade" id="commentModal" tabindex="-1" aria-labelledby="commentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="commentModalLabel">Add Comment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="commentModalBody">
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
function toggleGateway() {
const element = document.getElementById('gateway-response-card');
if (element.style.display === 'none' || element.style.display === '') {
element.style.display = 'block';
// --- COMMENT EDITING FUNCTION ---
function toggleCommentEdit(commentPk) {
const viewDiv = document.getElementById(`comment-view-${commentPk}`);
const editFormDiv = document.getElementById(`comment-edit-form-${commentPk}`);
const editButton = document.getElementById(`edit-btn-${commentPk}`);
const deleteForm = document.getElementById(`delete-form-${commentPk}`);
if (viewDiv.style.display !== 'none') {
// Switch to Edit Mode
viewDiv.style.display = 'none';
editFormDiv.style.display = 'block';
if (editButton) editButton.style.display = 'none'; // Hide edit button
if (deleteForm) deleteForm.style.display = 'none'; // Hide delete button
} else {
element.style.display = 'none';
// Switch back to View Mode (Cancel)
viewDiv.style.display = 'block';
editFormDiv.style.display = 'none';
if (editButton) editButton.style.display = 'inline-block'; // Show edit button
if (deleteForm) deleteForm.style.display = 'inline'; // Show delete button
}
}
// --- COPY LINK FUNCTION ---
// CopyLink function implementation (slightly improved for message placement)
function copyLink() {
const urlElement = document.getElementById('meeting-join-url');
@ -464,6 +476,7 @@ body {
// Position the message relative to the display container
const rect = displayContainer.getBoundingClientRect();
// Note: This positioning logic relies on the .join-url-container being position:relative or position:absolute
messageElement.style.left = (rect.width / 2) - (messageElement.offsetWidth / 2) + 'px';
messageElement.style.top = '-35px';

View File

@ -0,0 +1,137 @@
{% extends "base.html" %}
{% load static i18n crispy_forms_tags %}
{% block title %}Create Participant - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* ================================================= */
/* THEME VARIABLES AND GLOBAL STYLES (KAASUH ATS - Teal Theme) */
/* ================================================= */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
}
/* Primary Color Overrides */
.text-primary { color: var(--kaauh-teal) !important; }
/* Main Action Button Style */
.btn-main-action, .btn-primary {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
padding: 0.6rem 1.2rem;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-main-action:hover, .btn-primary:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Outlined Button Styles */
.btn-secondary, .btn-outline-secondary {
background-color: #f8f9fa;
color: var(--kaauh-teal-dark);
border: 1px solid var(--kaauh-teal);
font-weight: 500;
}
.btn-secondary:hover, .btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Card enhancements */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
}
/* Colored Header Card */
.participant-header-card {
background: linear-gradient(135deg, var(--kaauh-teal), #004d57);
color: white;
border-radius: 0.75rem 0.75rem 0 0;
padding: 1.5rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
}
.participant-header-card h1 {
font-weight: 700;
margin: 0;
font-size: 1.8rem;
}
.heroicon {
width: 1.25rem;
height: 1.25rem;
vertical-align: text-bottom;
stroke: currentColor;
margin-right: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="card mb-4">
<div class="participant-header-card">
<div class="d-flex justify-content-between align-items-start flex-wrap">
<div class="flex-grow-1">
<h1 class="h3 mb-1">
<i class="fas fa-user-plus"></i>
{% trans "Create New Participant" %}
</h1>
<p class="text-white opacity-75 mb-0">{% trans "Enter details to create a new participant record." %}</p>
</div>
<div class="d-flex gap-2 mt-1">
<a href="{% url 'participants_list'%}" class="btn btn-outline-light btn-sm" title="{% trans 'Back to List' %}">
<i class="fas fa-arrow-left"></i>
<span class="d-none d-sm-inline">{% trans "Back to List" %}</span>
</a>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white border-bottom">
<h2 class="h5 mb-0 text-primary">
<i class="fas fa-file-alt me-1"></i>
{% trans "Participant Information" %}
</h2>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{# Split form into two columns for better horizontal use #}
<div class="row g-4">
{% for field in form %}
<div class="col-md-6">
{{ field|as_crispy_field }}
</div>
{% endfor %}
</div>
<hr class="mt-4 mb-4">
<button class="btn btn-main-action" type="submit">
<i class="fas fa-save me-1"></i>
{% trans "Save Participant" %}
</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,295 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ participant.name }} - Participant Details{% endblock %}
{% block customCSS %}
<style>
/* ================================================= */
/* THEME VARIABLES AND GLOBAL STYLES (KAAT-S Teal Theme) */
/* ================================================= */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-bg-light: #f8f9fa;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary/Outline Button Styles */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border: 1px solid var(--kaauh-teal);
font-weight: 500;
padding: 0.6rem 1.2rem;
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Card enhancements */
.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;
}
/* Primary Header Card (For Details Page Banner) */
.detail-header-card {
background: linear-gradient(135deg, var(--kaauh-teal), #004d57);
color: white;
border-radius: 0.75rem 0.75rem 0 0;
padding: 1.5rem 2rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
}
.detail-header-card h1 {
font-weight: 700;
font-size: 2rem;
margin: 0;
}
/* Detail Labels */
.detail-label {
font-size: 0.8rem;
font-weight: 600;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
display: block;
}
.detail-value {
font-size: 1.1rem;
color: var(--kaauh-primary-text);
font-weight: 500;
}
/* Badge Styling for Jobs */
.job-badge {
font-weight: 600;
padding: 0.4em 0.7em;
border-radius: 0.3rem;
background-color: var(--kaauh-teal);
color: white;
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
transition: background-color 0.2s;
}
.job-badge:hover {
background-color: var(--kaauh-teal-dark);
text-decoration: none;
}
/* Section Separator */
.section-title {
color: var(--kaauh-teal-dark);
font-weight: 600;
font-size: 1.4rem;
margin-bottom: 1.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
{# --- HEADER CARD WITH ACTIONS --- #}
<div class="card mb-4 border-0" style="border-radius: 0.75rem;">
<div class="detail-header-card">
<div class="d-flex justify-content-between align-items-center flex-wrap">
<div class="flex-grow-1">
<h1 class="mb-1">
<i class="fas fa-user-tag me-2"></i>
{{ participant.name }}
</h1>
<p class="text-white opacity-75 mb-0">{% trans "Participant Details" %}</p>
</div>
<div class="d-flex gap-3 mt-3 mt-lg-0">
<a href="{% url 'participants_list' %}" class="btn btn-outline-light" title="{% trans 'Back to List' %}">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
</a>
{% if user.is_staff %}
<a href="{% url 'participants_update' participant.slug %}" class="btn btn-main-action" title="{% trans 'Edit Participant' %}">
<i class="fas fa-edit me-1"></i> {% trans "Edit Profile" %}
</a>
<button type="button" class="btn btn-danger" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'participants_delete' participant.slug %}"
data-item-name="{{ participant.name }}">
<i class="fas fa-trash-alt"></i>
</button>
{% endif %}
</div>
</div>
</div>
</div>
{# --- END HEADER CARD --- #}
<div class="row g-4">
{# --- LEFT COLUMN: CORE CONTACT AND PROFESSIONAL INFO --- #}
<div class="col-lg-8">
<div class="card p-4 h-100">
<div class="card-body">
<h2 class="section-title mb-4">{% trans "Contact & Role Information" %}</h2>
<div class="row g-4">
{# Name (Redundant here but included for clarity) #}
<div class="col-md-6">
<span class="detail-label">{% trans "Full Name" %}</span>
<span class="detail-value">{{ participant.name }}</span>
</div>
{# Email #}
<div class="col-md-6">
<span class="detail-label">{% trans "Email Address" %}</span>
<span class="detail-value text-primary-theme">{{ participant.email }}</span>
</div>
{# Phone #}
<div class="col-md-6">
<span class="detail-label">{% trans "Phone Number" %}</span>
<span class="detail-value">{{ participant.phone|default:"N/A" }}</span>
</div>
{# Designation #}
<div class="col-md-6">
<span class="detail-label">{% trans "Designation" %}</span>
<span class="detail-value">{{ participant.designation|default:"N/A" }}</span>
</div>
</div>
<hr class="my-5">
{# Assigned Jobs Section #}
<h2 class="section-title">{% trans "Assigned Jobs" %}</h2>
<div class="d-flex flex-wrap">
{% for job in participant.jobs_participating.all %}
<a href="{% url 'job_detail' job.slug %}" class="job-badge text-decoration-none">
<i class="fas fa-briefcase me-1"></i> {{ job.title }}
</a>
{% empty %}
<p class="text-muted">{% trans "This participant is not currently assigned to any job." %}</p>
{% endfor %}
</div>
</div>
</div>
</div>
{# --- RIGHT COLUMN: TIMESTAMPS AND METADATA --- #}
<div class="col-lg-4">
<div class="card p-4 h-100 bg-light">
<div class="card-body">
<h2 class="section-title mb-4">{% trans "Metadata" %}</h2>
<div class="mb-4">
<span class="detail-label">{% trans "Record Created" %}</span>
<span class="detail-value">{{ participant.created_at|date:"F d, Y" }} ({% trans "at" %} {{ participant.created_at|time:"H:i" }})</span>
</div>
<div class="mb-4">
<span class="detail-label">{% trans "Last Updated" %}</span>
<span class="detail-value">{{ participant.updated_at|date:"F d, Y" }} ({% trans "at" %} {{ participant.updated_at|time:"H:i" }})</span>
</div>
<hr class="my-4">
<div class="mb-2">
<span class="detail-label">{% trans "Total Assigned Jobs" %}</span>
<h3 class="fw-bold text-primary-theme">{{ participant.jobs_participating.count }}</h3>
</div>
</div>
</div>
</div>
</div>
</div>
{# Delete Confirmation Modal #}
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">{% trans "Confirm Deletion" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{% trans "Are you sure you want to delete" %} <strong id="itemName"></strong>?</p>
<p class="text-danger">
<i class="fas fa-exclamation-triangle me-1"></i>
{% trans "This action cannot be undone." %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<form method="post" id="deleteForm">
{% csrf_token %}
<button type="submit" class="btn btn-danger">{% trans "Delete" %}</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
// Populate Delete Modal with dynamic data
var deleteModal = document.getElementById('deleteModal');
// We must check if the modal element exists before adding the listener
if (deleteModal) {
deleteModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
// Get data from the button that triggered the modal
var deleteUrl = button.getAttribute('data-delete-url');
var itemName = button.getAttribute('data-item-name');
// Get modal elements
var modalItemName = deleteModal.querySelector('#itemName');
var deleteForm = deleteModal.querySelector('#deleteForm');
// Set the dynamic content
if (modalItemName) {
modalItemName.textContent = itemName;
}
// Set the form action URL
if (deleteForm) {
deleteForm.action = deleteUrl;
}
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,377 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}Participants - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme (Consistent with Reference) */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa; /* Added for hover/background consistency */
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
.text-success { color: var(--kaauh-success) !important; }
.text-danger { color: var(--kaauh-danger) !important; }
.text-info { color: #17a2b8 !important; }
/* Enhanced Card Styling (Consistent) */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
transition: transform 0.2s, box-shadow 0.2s;
background-color: white;
}
.card:not(.no-hover):hover { /* Use no-hover class for main structure cards */
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
}
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style (Teal Theme) */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style (For Edit/Outline - Consistent) */
.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);
}
/* Card Specifics */
.participant-card .card-title {
color: var(--kaauh-teal-dark);
font-weight: 600;
font-size: 1.15rem;
}
.participant-card .card-text i {
color: var(--kaauh-teal);
width: 1.25rem;
}
/* Table & Card Badge Styling (Unified) */
.badge {
font-weight: 600;
padding: 0.4em 0.7em;
border-radius: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Status Badge Mapping (Unified to Primary Theme Color) */
.bg-primary { background-color: var(--kaauh-teal) !important; color: white !important;} /* Main job/stage badge */
.bg-success { background-color: #28a745 !important; color: white !important;}
.bg-warning { background-color: #ffc107 !important; color: #343a40 !important;}
/* Table Styling (Consistent with Reference) */
.table-view .table thead th {
background-color: var(--kaauh-teal-dark); /* Dark header background */
color: white;
font-weight: 600;
border-color: var(--kaauh-border);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
padding: 1rem;
}
.table-view .table tbody td {
vertical-align: middle;
padding: 1rem;
border-color: var(--kaauh-border);
}
.table-view .table tbody tr:hover {
background-color: var(--kaauh-gray-light);
}
/* Pagination Link Styling (Consistent) */
.pagination .page-item .page-link {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-border);
}
.pagination .page-item.active .page-link {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
}
.pagination .page-item:hover .page-link:not(.active) {
background-color: #e9ecef;
}
/* Filter & Search Layout Adjustments */
.filter-buttons {
display: flex;
gap: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-users me-2"></i> {% trans "Participants List" %}
</h1>
{% if user.is_staff %}
<a href="{% url 'participants_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Add New Participant" %}
</a>
{% endif %}
</div>
<div class="card mb-4 shadow-sm no-hover">
<div class="card-body">
<div class="row g-4">
<div class="col-md-6">
<label for="search" class="form-label small text-muted">{% trans "Search by Name or Email" %}</label>
<div class="input-group input-group-lg">
<form method="get" action="" class="w-100">
{# Assuming this includes your search input and submit button #}
{% include 'includes/search_form.html' %}
</form>
</div>
</div>
<div class="col-md-6">
{% url 'participant_list' as participant_list_url %}
<form method="GET" class="row g-3 align-items-end h-100">
{% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
<div class="col-md-8">
<label for="job_filter" class="form-label small text-muted">{% trans "Filter by Assigned Job" %}</label>
<select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Jobs" %}</option>
{# available_jobs should be passed from the view #}
{% for job in available_jobs %}
<option value="{{ job.slug }}" {% if job_filter == job.slug %}selected{% endif %}>{{ job.title }}</option>
{% endfor %}
</select>
</div>
{# Buttons Group (pushed to the right/bottom) #}
<div class="col-md-4 d-flex justify-content-end align-self-end">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply" %}
</button>
{% if job_filter or search_query %}
<a href="{% url 'participant_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a>
{% endif %}
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% if participants %}
<div id="participant-list">
{# View Switcher - list_id must match the container ID #}
{% include "includes/_list_view_switcher.html" with list_id="participant-list" %}
{# Table View (Default) #}
<div class="table-view active">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th scope="col" style="width: 15%;">{% trans "Name" %}</th>
<th scope="col" style="width: 15%;">{% trans "Email" %}</th>
<th scope="col" style="width: 10%;">{% trans "Phone" %}</th>
<th scope="col" style="width: 15%;">{% trans "Designation" %}</th>
<th scope="col" style="width: 15%;">{% trans "Created At" %}</th>
<th scope="col" style="width: 5%;" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for participant in participants %}
<tr>
<td class="fw-medium"><a href="{% url 'participants_detail' participant.slug %}" class="text-decoration-none link-secondary">{{ participant.name }}<a></td>
<td>{{ participant.email }}</td>
<td>{{ participant.phone|default:"N/A" }}</td>
<td>{{ participant.designation|default:"N/A" }}</td>
<td>{{ participant.created_at|date:"d-m-Y" }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'participants_detail' participant.slug%}" class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="fas fa-eye"></i>
</a>
{% if user.is_staff %}
<a href="{% url 'participants_update' participant.slug%}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'participants_delete' participant.slug %}"
data-item-name="{{ participant.name }}">
<i class="fas fa-trash-alt"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# Card View #}
<div class="card-view row">
{% for participant in participants %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card participant-card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title flex-grow-1 me-3"><a href="{% url 'participants_detail' participant.slug%}" class="text-decoration-none text-primary-theme ">{{ participant.name }}</a></h5>
</div>
<p class="card-text text-muted small">
<i class="fas fa-envelope"></i> {{ participant.email }}<br>
<i class="fas fa-phone-alt"></i> {{ participant.phone|default:"N/A" }}<br>
<i class="fas fa-briefcase"></i> {{ participant.designation|default:"N/A" }}
</p>
<div class="mt-auto pt-2 border-top">
<div class="d-flex gap-2">
<a href="{% url 'participants_detail' participant.slug %}" class="btn btn-sm btn-main-action">
<i class="fas fa-eye"></i> {% trans "View" %}
</a>
{% if user.is_staff %}
<a href="#" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-edit"></i> {% trans "Edit" %}
</a>
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'participants_delete' participant.slug %}"
data-item-name="{{ participant.name }}">
<i class="fas fa-trash-alt"></i>
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{# Pagination #}
{% include "includes/paginator.html" %}
{% else %}
<div class="text-center py-5 card shadow-sm">
<div class="card-body">
<i class="fas fa-users fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
<h3>{% trans "No participants found" %}</h3>
<p class="text-muted">{% trans "Create your first participant record or adjust your filters." %}</p>
{% if user.is_staff %}
<a href="{% url 'participants_create' %}" class="btn btn-main-action mt-3">
<i class="fas fa-plus me-1"></i> {% trans "Add Participant" %}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{# Delete Confirmation Modal #}
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">{% trans "Confirm Deletion" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{% trans "Are you sure you want to delete" %} <strong id="itemName"></strong>?</p>
<p class="text-danger">
<i class="fas fa-exclamation-triangle me-1"></i>
{% trans "This action cannot be undone." %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<form method="post" id="deleteForm">
{% csrf_token %}
<button type="submit" class="btn btn-danger">{% trans "Delete" %}</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
// Populate Delete Modal with dynamic data
var deleteModal = document.getElementById('deleteModal');
// We must check if the modal element exists before adding the listener
if (deleteModal) {
deleteModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
// Get data from the button that triggered the modal
var deleteUrl = button.getAttribute('data-delete-url');
var itemName = button.getAttribute('data-item-name');
// Get modal elements
var modalItemName = deleteModal.querySelector('#itemName');
var deleteForm = deleteModal.querySelector('#deleteForm');
// Set the dynamic content
if (modalItemName) {
modalItemName.textContent = itemName;
}
// Set the form action URL
if (deleteForm) {
deleteForm.action = deleteUrl;
}
});
}
</script>
{% endblock %}

View File

@ -11,9 +11,11 @@
/* ================================================= */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-light: #007a88;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-accent: #ffcc00;
}
/* Primary Color Overrides */
@ -176,6 +178,88 @@
.timeline-bg-interview { background-color: #ffc107 !important; }
.timeline-bg-offer { background-color: #28a745 !important; }
.timeline-bg-rejected { background-color: #dc3545 !important; }
/* ------------------------------------------- */
/* 1. Base Spinner Styling */
/* ------------------------------------------- */
.ai-loading-container {
/* Flex container to center content and align icon/spinner */
display: inline-flex;
align-items: center;
gap: 5px; /* Space between robot icon and spinner */
font-size: 14px; /* Default text size */
color: var(--kaauh-teal-light); /* Loading text color */
}
.kaats-spinner {
animation: kaats-spinner-rotate 2s linear infinite; /* Slower, more deliberate rotation */
width: 100px; /* Smaller size to be subtle */
height: 100px;
vertical-align: middle;
}
.kaats-spinner .path {
stroke: var(--kaauh-teal-light, #007a88); /* Lighter Teal for the active path */
stroke-linecap: round;
}
/* Optional: Background circle for better contrast (similar to Bootstrap) */
.kaats-spinner circle {
stroke: var(--kaauh-border, #e9ecef); /* Light gray background */
fill: none;
stroke-width: 4; /* Slightly smaller stroke-width */
}
/* New: Robot Icon Style and Animation */
.ai-robot-icon {
font-size: 2rem; /* Slightly larger than text */
color: var(--kaauh-teal);
animation: ai-pulse 2s infinite ease-in-out; /* Pulsing effect for 'intelligence' */
transform-origin: center;
}
/* ------------------------------------------- */
/* 2. Keyframe Animations */
/* ------------------------------------------- */
@keyframes kaats-spinner-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes kaats-spinner-dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
/* New Keyframe: AI Pulse effect */
@keyframes ai-pulse {
0% {
color: var(--kaauh-teal);
transform: scale(1);
}
50% {
color: var(--kaauh-accent); /* Accent color pulse */
transform: scale(1.05);
}
100% {
color: var(--kaauh-teal);
transform: scale(1);
}
}
</style>
{% endblock %}
@ -534,7 +618,7 @@
{# ACTIONS CARD #}
<div class="card shadow-sm mb-4 p-3">
<div class="card shadow-sm mb-2 p-2">
<h5 class="text-muted mb-3"><i class="fas fa-cog me-2"></i>{% trans "Management Actions" %}</h5>
<div class="d-grid gap-2">
<a href="{% url 'candidate_update' candidate.slug %}" class="btn btn-outline-primary">
@ -565,6 +649,12 @@
{% endif %}
</div>
</div>
<div class="card shadow-sm mb-4 p-2">
<h5 class="text-muted mb-3"><i class="fas fa-clock me-2"></i>{% trans "Time to Hire: " %}{{candidate.time_to_hire|default:100}}&nbsp;days</h5>
</div>
</div>
@ -572,8 +662,29 @@
</div>
{% if candidate.is_resume_parsed %}
{% include 'recruitment/candidate_resume_template.html' %}
{% else %}
<a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none">
<div style="display: flex; justify-content: center; align-items: center; height: 100%;">
<div class="ai-loading-container">
{# Robot Icon (Requires Font Awesome or similar library) #}
<i class="fas fa-robot ai-robot-icon"></i>
{# The Spinner #}
<svg class="kaats-spinner" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20"></circle>
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="4"
style="animation: kaats-spinner-dash 1.5s ease-in-out infinite;"></circle>
</svg>
<span>AI Scoring...</span>
</div>
{% include 'recruitment/candidate_resume_template.html' %}
</div>
</a>
{% endif %}
{% if user.is_staff %}

View File

@ -167,6 +167,8 @@
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
@ -229,8 +231,13 @@
<i class="fas fa-calendar-plus me-1"></i> {% trans "Schedule Interviews" %}
</button>
</form>
</div>
<div class="vr" style="height: 28px;"></div>
<!--manage participants for interview-->
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#jobAssignmentModal">
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %}
</button>
</div>
{% endif %}
<div class="table-responsive">
@ -425,8 +432,47 @@
</div>
</div>
</div>
<div class="modal fade" id="jobAssignmentModal" tabindex="-1" aria-labelledby="jobAssignmentLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobAssignmentLabel">{% trans "Manage all participants" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{% url 'candidate_interview_view' job.slug %}">
{% csrf_token %}
<div class="modal-body">
{{ job.internal_job_id }} {{ job.title}}
<hr>
<h3>👥 {% trans "Participants" %}</h3>
{{ form.participants.errors }}
{{ form.participants }}
<hr>
<h3>🧑‍💼 {% trans "Users" %}</h3>
{{ form.users.errors }}
{{ form.users }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-main-action">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
@ -528,5 +574,19 @@
}
});
});
$(document).ready(function() {
// Check the flag passed from the Django view
var shouldOpenModal = {{ show_modal_on_load|yesno:"true,false" }};
// If the view detected an invalid form submission (POST request), open the modal
if (shouldOpenModal) {
// Use the native Bootstrap 5 JS function to show the modal
var myModal = new bootstrap.Modal(document.getElementById('jobAssignmentModal'));
myModal.show();
}
});
</script>
{% endblock %}

View File

@ -132,6 +132,57 @@
display: flex;
gap: 0.5rem;
}
/* ------------------------------------------- */
/* 1. Base Spinner Styling */
/* ------------------------------------------- */
.kaats-spinner {
animation: kaats-spinner-rotate 1.5s linear infinite; /* Faster rotation */
width: 40px; /* Standard size */
height: 40px;
display: inline-block; /* Useful for table cells */
vertical-align: middle;
}
.kaats-spinner .path {
stroke: var(--kaauh-teal, #00636e); /* Use Teal color, fallback to dark teal */
stroke-linecap: round;
/* Optional: Add a lighter background circle for contrast */
/* stroke-dashoffset will be reset by the dash animation */
}
/* Optional: Background circle for better contrast (similar to Bootstrap) */
.kaats-spinner circle {
stroke: var(--kaauh-border, #e9ecef); /* Light gray background */
fill: none;
stroke-width: 5; /* Keep stroke-width on both circles */
}
/* ------------------------------------------- */
/* 2. Keyframe Animations */
/* ------------------------------------------- */
@keyframes kaats-spinner-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes kaats-spinner-dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
</style>
{% endblock %}
@ -203,6 +254,7 @@
</div>
</div>
</div>
{% if candidates %}
<div id="candidate-list">
{# View Switcher - list_id must match the container ID #}
@ -233,11 +285,24 @@
<td>{{ candidate.phone }}</td>
<td> <span class="badge bg-primary"><a href="{% url 'job_detail' candidate.job.slug %}" class="text-decoration-none text-white">{{ candidate.job.title }}</a></span></td>
<td>
{% if candidate.is_resume_parsed %}
{% if candidate.professional_category != 'Uncategorized' %}
<span class="badge bg-primary">
{{ candidate.professional_category }}
</span>
{% endif %}
{% endif %}
{% else %}
<a href="{% url 'candidate_list' %}" class="text-decoration-none">
<div>
<svg class="kaats-spinner" viewBox="0 0 50 50" style="width: 25px; height: 25px;">
<circle cx="25" cy="25" r="20"></circle>
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"
style="animation: kaats-spinner-dash 1.5s ease-in-out infinite;"></circle>
</svg>
<span class="text-teal-primary text-nowrap">{% trans "AI Scoring..." %}</span>
</div>
</a>
{% endif %}
</td>
<td>
<span class="badge bg-primary">

View File

@ -162,8 +162,56 @@
font-size: 0.8rem !important; /* Slightly smaller font */
}
<<<<<<< HEAD
.kaats-spinner {
animation: kaats-spinner-rotate 1.5s linear infinite; /* Faster rotation */
width: 40px; /* Standard size */
height: 40px;
display: inline-block; /* Useful for table cells */
vertical-align: middle;
}
=======
>>>>>>> f71a202ed3606d299f9ac6515247662b6d3370b4
.kaats-spinner .path {
stroke: var(--kaauh-teal, #00636e); /* Use Teal color, fallback to dark teal */
stroke-linecap: round;
/* Optional: Add a lighter background circle for contrast */
/* stroke-dashoffset will be reset by the dash animation */
}
/* Optional: Background circle for better contrast (similar to Bootstrap) */
.kaats-spinner circle {
stroke: var(--kaauh-border, #e9ecef); /* Light gray background */
fill: none;
stroke-width: 5; /* Keep stroke-width on both circles */
}
/* ------------------------------------------- */
/* 2. Keyframe Animations */
/* ------------------------------------------- */
@keyframes kaats-spinner-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes kaats-spinner-dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
</style>
{% endblock %}
{% block content %}
@ -358,11 +406,22 @@
</div>
</td>
<td class="text-center">
{% if candidate.is_resume_parsed %}
{% if candidate.match_score %}
<span class="badge ai-score-badge">
{{ candidate.match_score|default:"0" }}%
</span>
{% endif %}
{% else %}
<div class="text-nowrap">
<svg class="kaats-spinner" viewBox="0 0 50 50" style="width: 25px; height: 25px;">
<circle cx="25" cy="25" r="20"></circle>
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"
style="animation: kaats-spinner-dash 1.5s ease-in-out infinite;"></circle>
</svg>
<span class="text-teal-primary">{% trans 'AI scoring..' %}</span>
</div>
{% endif %}
</td>
<td class="text-center">
{% if candidate.screening_stage_rating %}

View File

@ -36,7 +36,7 @@
padding: 1.25rem;
border-bottom: 1px solid var(--kaauh-border);
background-color: #f8f9fa;
display: flex; /* Ensure title and filter are aligned */
display: flex;
justify-content: space-between;
align-items: center;
}
@ -107,14 +107,11 @@
padding: 2rem;
}
/* Bootstrap Overrides (Optional, for full consistency) */
.btn-primary {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
}
.btn-primary:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
/* Funnel Specific Styles */
#candidate_funnel_chart {
max-height: 400px;
width: 100%;
margin: 0 auto;
}
</style>
@ -123,69 +120,63 @@
{% block content %}
<div class="container-fluid py-4">
<h1 class="mb-4" style="color: var(--kaauh-teal-dark); font-weight: 700;">{% trans "Recruitment Intelligence" %} 🧠</h1>
<h1 class="mb-4" style="color: var(--kaauh-teal-dark); font-weight: 700;">{% trans "Recruitment Analytics" %}</h1>
{# -------------------------------------------------------------------------- #}
{# STATS CARDS SECTION #}
{# JOB FILTER SECTION #}
{# -------------------------------------------------------------------------- #}
<div class="stats">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-briefcase stat-icon"></i> {% trans "Total Jobs" %}</h3>
</div>
<div class="stat-value">{{ total_jobs }}</div>
<div class="stat-caption">{% trans "Active & Drafted Positions" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users stat-icon"></i> {% trans "Total Candidates" %}</h3>
</div>
<div class="stat-value">{{ total_candidates }}</div>
<div class="stat-caption">{% trans "All Profiles in ATS" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-chart-line stat-icon"></i> {% trans "Avg. Apps per Job" %}</h3>
</div>
<div class="stat-value">{{ average_applications|floatformat:1 }}</div>
<div class="stat-caption">{% trans "Efficiency Metric" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-star stat-icon" style="color: var(--color-warning);"></i> {% trans "Avg. Match Score" %}</h3>
</div>
<div class="stat-value">{{ avg_match_score|floatformat:1 }}</div>
<div class="stat-caption">{% trans "Average AI Score (0-100)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-trophy stat-icon" style="color: var(--color-success);"></i> {% trans "High Potential" %}</h3>
</div>
<div class="stat-value">{{ high_potential_count }}</div>
<div class="stat-caption">{% trans "Candidates with Score ≥ 75 ({{ high_potential_ratio }}%)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-cogs stat-icon" style="color: var(--color-info);"></i> {% trans "Scored Profiles" %}</h3>
</div>
<div class="stat-value">{{ scored_ratio|floatformat:1 }}%</div>
<div class="stat-caption">{% trans "Percent of profiles processed by AI" %}</div>
<div class="card mb-4">
<div class="card-header">
<h2>
<i class="fas fa-search stat-icon"></i>
{% if current_job %}
{% trans "Data Scope: " %} **{{ current_job.title }}**
{% else %}
{% trans "Data Scope: All Jobs" %}
{% endif %}
</h2>
{# Job Filter Dropdown #}
<form method="get" action="" class="job-filter-container">
<label for="job-select" class="job-filter-label d-none d-md-inline">{% trans "Filter Job:" %}</label>
<select name="selected_job_pk" id="job-select" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans "All Jobs (Default View)" %}</option>
{% for job in jobs%}
<option value="{{ job.pk}}" {% if current_job_id|slugify == job.pk|slugify %}selected{% endif %}>
{{ job.title }}
</option>
{% endfor %}
</select>
</form>
</div>
</div>
{# -------------------------------------------------------------------------- #}
{# STATS CARDS SECTION (12 KPIs) #}
{# -------------------------------------------------------------------------- #}
{% include 'recruitment/partials/stats_cards.html' %}
{# -------------------------------------------------------------------------- #}
{# CHARTS SECTION (Using a row/col layout for structure) #}
{# CHARTS SECTION #}
{# -------------------------------------------------------------------------- #}
<div class="row g-4">
{# BAR CHART - Application Volume #}
{# AREA CHART - Daily Candidate Applications Trend (Global Chart) #}
<div class="col-lg-12">
<div class="card shadow-lg h-100">
<div class="card-header">
<h2>
<i class="fas fa-chart-area stat-icon"></i>
{% trans "Daily Candidate Applications Trend" %}
</h2>
</div>
<div class="chart-container">
<canvas id="dailyApplicationsChart"></canvas>
</div>
</div>
</div>
{# BAR CHART - Application Volume (Global Chart) #}
<div class="col-lg-6">
<div class="card shadow-lg h-100">
<div class="card-header">
<h2>
@ -199,84 +190,56 @@
</div>
</div>
{# DONUT CHART - Candidate Pipeline Status #}
<div class="col-lg-12">
{# FUNNEL CHART - Candidate Pipeline Status (Scoped Chart) #}
<div class="col-lg-6">
<div class="card shadow-lg h-100">
<div class="card-header">
<h2>
<i class="fas fa-filter stat-icon"></i>
{% trans "Candidate Pipeline Status for job: " %}
<i class="fas fa-funnel-dollar stat-icon"></i>
{% if current_job %}
{% trans "Pipeline Funnel: " %} **{{ current_job.title }}**
{% else %}
{% trans "Total Pipeline Funnel (All Jobs)" %}
{% endif %}
</h2>
<small>{{my_job}}</small>
{# Job Filter Dropdown - Consistent with Card Header Layout #}
<form method="get" action="." class="job-filter-container">
<label for="job-select" class="job-filter-label d-none d-md-inline">{% trans "Filter Job:" %}</label>
<select name="selected_job_id" id="job-select" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% trans "All Jobs (Default View)" %}</option>
{% for job in jobs%}
<option value="{{ job.internal_job_id }}" {% if selected_job_id == job.internal_job_id %}selected{% endif %}>
{{ job }}
</option>
{% endfor %}
</select>
</form>
</div>
<div class="chart-container d-flex justify-content-center align-items-center">
<canvas id="candidate_donout_chart"></canvas>
<canvas id="candidate_funnel_chart"></canvas>
</div>
</div>
</div>
{# GAUGE CHART - Average Time-to-Hire (Avg. Days) #}
<div class="col-lg-12">
<div class="card shadow-lg h-100">
<div class="card-header">
<h2><i class="fas fa-tachometer-alt stat-icon"></i> {% trans "Time-to-Hire Target Check" %}</h2>
</div>
<div class="chart-container d-flex justify-content-center align-items-center" style="height: 300px;">
<div id="timeToHireGauge" class="text-center">
{% include "recruitment/partials/_guage_chart.html" %}
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Get the all_candidates_count value from Django context
const ALL_CANDIDATES_COUNT = '{{ all_candidates_count|default:0 }}';
// --- 1. DONUT CHART CENTER TEXT PLUGIN (Custom) ---
const centerTextPlugin = {
id: 'centerText',
beforeDraw: (chart) => {
const { ctx } = chart;
// Convert to integer (handle case where all_candidates_count might be missing)
const total = parseInt(ALL_CANDIDATES_COUNT) || 0;
if (total === 0) return; // Don't draw if count is zero
// Get chart center coordinates
const xCenter = chart.getDatasetMeta(0).data[0].x;
const yCenter = chart.getDatasetMeta(0).data[0].y;
ctx.restore();
// --- First Line: The Total Count (Bold Number) ---
ctx.font = 'bold 28px sans-serif';
ctx.fillStyle = 'var(--kaauh-teal-dark)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(total.toString(), xCenter, yCenter - 10);
// --- Second Line: The Label ---
const labelText = '{% trans "Total" %}';
ctx.font = '12px sans-serif';
ctx.fillStyle = '#6c757d';
ctx.fillText(labelText, xCenter, yCenter + 20);
ctx.save();
}
};
// ------------------------------------------------
// Pass context data safely to JavaScript
const jobTitles = JSON.parse('{{ job_titles|escapejs }}').slice(0, 5); // Take top 5
const jobAppCounts = JSON.parse('{{ job_app_counts|escapejs }}').slice(0, 5); // Take top 5
const totalCandidatesScoped = parseInt('{{ total_candidates|default:0 }}');
const jobTitles = JSON.parse('{{ job_titles|escapejs }}').slice(0, 5);
const jobAppCounts = JSON.parse('{{ job_app_counts|escapejs }}').slice(0, 5);
const stages = JSON.parse('{{ candidate_stage|escapejs }}');
const counts = JSON.parse('{{ candidates_count|safe }}');
// BAR CHART configuration
// --- 1. BAR CHART configuration (Top 5 Applications) ---
const ctxBar = document.getElementById('applicationsChart').getContext('2d');
new Chart(ctxBar, {
type: 'bar',
@ -285,7 +248,7 @@
datasets: [{
label: '{% trans "Applications" %}',
data: jobAppCounts,
backgroundColor: 'var(--kaauh-teal)',
backgroundColor: '#00636e',
borderColor: 'var(--kaauh-teal-dark)',
borderWidth: 1,
barThickness: 50
@ -307,7 +270,7 @@
y: {
beginAtZero: true,
title: { display: true, text: '{% trans "Total Applications" %}' },
ticks: { color: '#333333', precision: 0 },
ticks: { color: '#2222', precision: 0 },
grid: { color: '#e0e0e0' }
},
x: {
@ -318,44 +281,167 @@
}
});
// DONUT CHART configuration
const ctxDonut = document.getElementById('candidate_donout_chart').getContext('2d');
new Chart(ctxDonut, {
type: 'doughnut',
// --- 2. CANDIDATE PIPELINE CENTERED FUNNEL CHART ---
// 1. Find the maximum count (for the widest bar)
const maxCount = Math.max(...counts);
// 2. Calculate the transparent "spacer" data needed to center each bar
const spacerData = counts.map(count => (maxCount - count) / 2);
// Define the dark-to-light teal shades (5 stages, reverse order for funnel look)
const tealShades = [
'#00acc0', // APPLIED - Lighter Teal
'#0093a3', // EXAM
'#007a88', // INTERVIEW
'#00636e', // OFFER
'#004a53', // HIRED - Darkest Teal
];
// Slice and use the first N shades based on the number of stages
const stageColors = tealShades.slice(tealShades.length - stages.length);
const ctxFunnel = document.getElementById('candidate_funnel_chart').getContext('2d');
new Chart(ctxFunnel, {
type: 'bar',
data: {
// Ensure these contexts are always output as valid JSON arrays
labels: JSON.parse('{{ candidate_stage|safe }}'),
datasets: [{
label: '{% trans "Candidate Count" %}',
data: JSON.parse('{{ candidates_count|safe }}'),
backgroundColor: [
'var(--kaauh-teal)', // Applied (Primary)
'rgb(255, 159, 64)', // Exam (Orange)
'rgb(54, 162, 235)', // Interview (Blue)
'rgb(75, 192, 192)' // Offer (Green)
],
hoverOffset: 4
}]
labels: stages,
datasets: [
// 1. TRANSPARENT SPACER DATASET (Pushes the bar to the center)
{
label: 'Spacer',
data: spacerData,
backgroundColor: 'transparent',
hoverBackgroundColor: 'transparent',
barThickness: 50,
datalabels: { display: false },
tooltip: { enabled: false }
},
// 2. VISIBLE CANDIDATE COUNT DATASET
{
label: '{% trans "Candidate Count" %}',
data: counts,
backgroundColor: stageColors,
barThickness: 50
}
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
stacked: true,
display: false,
max: maxCount
},
y: {
stacked: true,
grid: { display: false },
ticks: {
color: 'var(--kaauh-primary-text)',
font: { size: 12, weight: 'bold' }
}
}
},
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
filter: (tooltipItem) => {
return tooltipItem.datasetIndex === 1;
}
}
}
}
});
// ... (after Treemap/Source Chart JS)
// Pass context data safely to JavaScript
const globalDates = JSON.parse('{{ global_dates|escapejs }}');
const globalCounts = JSON.parse('{{ global_counts|escapejs }}');
const scopedDates = JSON.parse('{{ scoped_dates|escapejs }}');
const scopedCounts = JSON.parse('{{ scoped_counts|escapejs }}');
const isJobScoped = '{{ is_job_scoped }}' === 'True';
// --- 4. DAILY APPLICATIONS LINE CHART ---
const ctxLine = document.getElementById('dailyApplicationsChart').getContext('2d');
// Create datasets
const datasets = [{
label: '{% trans "All Jobs" %}',
data: globalCounts,
borderColor: '#004a53', // Dark Teal
backgroundColor: 'rgba(0, 74, 83, 0.1)',
fill: true,
tension: 0.2
}];
// Add scoped data if a job is selected
if (isJobScoped) {
datasets.push({
label: '{% trans "Current Job" %}',
data: scopedCounts,
borderColor: '#0093a3', // Light Teal
backgroundColor: 'rgba(0, 147, 163, 0.1)',
fill: true,
tension: 0.2
});
}
new Chart(ctxLine, {
type: 'line',
data: {
// Use global dates as the base labels
labels: globalDates,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 3.5,
plugins: {
legend: {
position: 'bottom',
labels: { padding: 20 }
position: 'top',
},
title: {
display: true,
text: '{% trans "Pipeline Status Breakdown" %}',
font: { size: 16 }
text: '{% trans "Daily Applications (Last 30 Days)" %}',
font: { size: 16 },
color: 'var(--kaauh-primary-text)'
}
},
scales: {
x: {
type: 'time',
time: {
unit: 'day',
tooltipFormat: 'MMM D',
displayFormats: {
day: 'MMM D'
}
},
title: { display: true, text: '{% trans "Date" %}' },
grid: { display: false }
},
y: {
beginAtZero: true,
title: { display: true, text: '{% trans "New Candidates" %}' },
ticks: { precision: 0 },
grid: { color: '#e0e0e0' }
}
}
},
// --- Register the custom plugin here ---
plugins: [centerTextPlugin]
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,51 @@
{% load i18n %}
<style>
/* ... (CSS styles remain the same - omitted here for brevity) ... */
.gauge-container {
width: 250px;
height: 125px;
position: relative;
overflow: hidden;
margin: 20px auto 10px;
border-radius: 10px 10px 0 0;
}
.gauge-arc {
/* ... */
}
.gauge-color-fill {
/* Color sectors: Green (0-45), Yellow (45-67.5), Red (67.5-90)
Note: The degrees in conic-gradient correspond to the 90-degree
segmentation of the 180-degree gauge arc. */
background: conic-gradient(
var(--color-success) 0deg 90deg,
var(--color-warning) 90deg 135deg,
red 135deg 180deg,
#e9ecef 180deg 360deg
);
/* ... */
}
.gauge-needle {
/* ... */
transition: transform 1.5s ease-out;
}
/* ... (rest of CSS) ... */
</style>
{# Use variables directly from the context #}
<div class="gauge-value-display">
{{ avg_time_to_hire_days|default:0 }} {% trans "Days" %}
</div>
<div class="gauge-container">
<div class="gauge-color-fill"></div>
<div class="gauge-arc"></div>
{# Inject the final, calculated degrees directly into the style attribute #}
<div class="gauge-needle" style="transform: rotate({{ gauge_rotation_degrees }}deg);">
<div class="gauge-center"></div>
</div>
</div>
<div class="text-muted small mt-3">
{% trans "Target:" %} **{{ gauge_target_days }}** {% trans "Days" %} | {% trans "Max Scale:" %} {{ gauge_max_days }} {% trans "Days" %}
</div>

View File

@ -52,7 +52,7 @@
<i class="fas fa-times me-1"></i>Cancel
</button>
<button type="submit" class="btn btn-primary" id="stageUpdateSubmit">
<button type="submit" class="btn btn-main-action" id="stageUpdateSubmit">
<i class="fas fa-save me-1"></i>
<span class="ms-2">Update</span>
</button>

View File

@ -0,0 +1,112 @@
{%load i18n %}
{# -------------------------------------------------------------------------- #}
{# STATS CARDS SECTION (12 KPIs) #}
{# -------------------------------------------------------------------------- #}
<div class="stats">
{# GLOBAL - 1. Total Jobs (System) #}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-list stat-icon"></i> {% trans "Total Jobs" %}</h3>
</div>
<div class="stat-value">{{ total_jobs_global }}</div>
<div class="stat-caption">{% trans "All Active & Drafted Positions (Global)" %}</div>
</div>
{# SCOPED - 2. Total Active Jobs #}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-briefcase stat-icon"></i> {% trans "Active Jobs" %}</h3>
</div>
<div class="stat-value">{{ total_active_jobs }}</div>
<div class="stat-caption">{% trans "Currently Open Requisitions (Scoped)" %}</div>
</div>
{# SCOPED - 3. Total Candidates #}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users stat-icon"></i> {% trans "Total Candidates" %}</h3>
</div>
<div class="stat-value">{{ total_candidates }}</div>
<div class="stat-caption">{% trans "Total Profiles in Current Scope" %}</div>
</div>
{# SCOPED - 4. Open Positions #}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-th-list stat-icon"></i> {% trans "Open Positions" %}</h3>
</div>
<div class="stat-value">{{ total_open_positions }}</div>
<div class="stat-caption">{% trans "Total Slots to be Filled (Scoped)" %}</div>
</div>
{# GLOBAL - 5. Total Participants #}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-address-book stat-icon"></i> {% trans "Total Participants" %}</h3>
</div>
<div class="stat-value">{{ total_participants }}</div>
<div class="stat-caption">{% trans "Total Recruiters/Interviewers (Global)" %}</div>
</div>
{# GLOBAL - 6. Total LinkedIn Posts #}
<div class="card">
<div class="card-header">
<h3><i class="fab fa-linkedin stat-icon"></i> {% trans "LinkedIn Posts" %}</h3>
</div>
<div class="stat-value">{{ total_jobs_posted_linkedin }}</div>
<div class="stat-caption">{% trans "Total Job Posts Sent to LinkedIn (Global)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-chart-line stat-icon"></i> {% trans "New Apps (7 Days)" %}</h3>
</div>
<div class="stat-value">{{ new_candidates_7days }}</div>
<div class="stat-caption">{% trans "Incoming applications last week" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-cogs stat-icon"></i> {% trans "Avg. Apps per Job" %}</h3>
</div>
<div class="stat-value">{{ average_applications|floatformat:1 }}</div>
<div class="stat-caption">{% trans "Average Applications per Job (Scoped)" %}</div>
</div>
{# --- Efficiency & Quality Metrics --- #}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clock stat-icon"></i> {% trans "Time-to-Hire" %}</h3>
</div>
<div class="stat-value">{{ avg_time_to_hire_days }}</div>
<div class="stat-caption">{% trans "Avg. Days (Application to Hired)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-star stat-icon"></i> {% trans "Avg. Match Score" %}</h3>
</div>
<div class="stat-value">{{ avg_match_score|floatformat:1 }}</div>
<div class="stat-caption">{% trans "Average AI Score (Current Scope)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-trophy stat-icon"></i> {% trans "High Potential" %}</h3>
</div>
<div class="stat-value">{{ high_potential_count }}</div>
<div class="stat-caption">{% trans "Score ≥ 75% Profiles" %} ({{ high_potential_ratio|floatformat:1 }}%)</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-alt stat-icon"></i> {% trans "Meetings This Week" %}</h3>
</div>
<div class="stat-value">{{ meetings_scheduled_this_week }}</div>
<div class="stat-caption">{% trans "Scheduled Interviews (Current Week)" %}</div>
</div>
</div>