frontend #31
Binary file not shown.
Binary file not shown.
@ -66,7 +66,7 @@ INSTALLED_APPS = [
|
||||
SITE_ID = 1
|
||||
|
||||
|
||||
LOGIN_REDIRECT_URL = 'dashboard'
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
|
||||
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
|
||||
@ -155,27 +155,19 @@ DATABASES = {
|
||||
|
||||
|
||||
|
||||
# AUTH_PASSWORD_VALIDATORS = [
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
# },
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
# },
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
# },
|
||||
# {
|
||||
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
# },
|
||||
# ]
|
||||
|
||||
# settings.py
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -183,8 +175,10 @@ ACCOUNT_LOGIN_METHODS = ['email']
|
||||
ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*']
|
||||
|
||||
ACCOUNT_UNIQUE_EMAIL = True
|
||||
ACCOUNT_EMAIL_VERIFICATION = 'none'
|
||||
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
|
||||
|
||||
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
|
||||
|
||||
|
||||
@ -195,7 +189,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
# Crispy Forms Configuration
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
CRISPY_TEMPLATE_PACK = "bootstrapconsole5"
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
|
||||
# Bootstrap 5 Configuration
|
||||
CRISPY_BS5 = {
|
||||
@ -236,7 +230,7 @@ STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static'
|
||||
]
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'static/media')
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
@ -407,3 +401,11 @@ CKEDITOR_5_CONFIGS = {
|
||||
|
||||
# Define a constant in settings.py to specify file upload permissions
|
||||
CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any"
|
||||
|
||||
|
||||
|
||||
|
||||
from django.contrib.messages import constants as messages
|
||||
MESSAGE_TAGS = {
|
||||
messages.ERROR: 'danger',
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ urlpatterns = [
|
||||
path('application/<slug:template_slug>/submit/', views.application_submit, name='application_submit'),
|
||||
path('application/<slug:slug>/apply/', views.application_detail, name='application_detail'),
|
||||
path('application/<slug:slug>/success/', views.application_success, name='application_success'),
|
||||
path('application/applicant/profile', views.applicant_profile, name='applicant_profile'),
|
||||
|
||||
path('api/templates/', views.list_form_templates, name='list_form_templates'),
|
||||
path('api/templates/save/', views.save_form_template, name='save_form_template'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -224,138 +224,208 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return {'success': False, 'error': error_msg}
|
||||
|
||||
from .models import Candidate
|
||||
from django.shortcuts import get_object_or_404
|
||||
# Assuming other necessary imports like logger, settings, EmailMultiAlternatives, strip_tags are present
|
||||
|
||||
def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False):
|
||||
from .models import Candidate
|
||||
from django.shortcuts import get_object_or_404
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.utils.html import strip_tags
|
||||
from django_q.tasks import async_task # Import needed at the top for clarity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False, from_interview=False):
|
||||
"""
|
||||
Send bulk email to multiple recipients with HTML support and attachments.
|
||||
|
||||
Args:
|
||||
subject: Email subject
|
||||
message: Email message (can be HTML)
|
||||
recipient_list: List of email addresses
|
||||
request: Django request object (optional)
|
||||
attachments: List of file attachments (optional)
|
||||
async_task: Whether to run as background task (default: False)
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and error message if failed
|
||||
Send bulk email to multiple recipients with HTML support and attachments,
|
||||
supporting synchronous or asynchronous dispatch.
|
||||
"""
|
||||
# Handle async task execution
|
||||
if async_task_:
|
||||
print("hereeeeeee")
|
||||
from django_q.tasks import async_task
|
||||
|
||||
# Process attachments for background task serialization
|
||||
# processed_attachments = []
|
||||
# if attachments:
|
||||
# for attachment in attachments:
|
||||
# if hasattr(attachment, 'read'):
|
||||
# # File-like object - save to temporary file
|
||||
# filename = getattr(attachment, 'name', 'attachment')
|
||||
# content_type = getattr(attachment, 'content_type', 'application/octet-stream')
|
||||
|
||||
# # Create temporary file
|
||||
# with tempfile.NamedTemporaryFile(delete=False, suffix=f'_{filename}') as temp_file:
|
||||
# content = attachment.read()
|
||||
# temp_file.write(content)
|
||||
# temp_file_path = temp_file.name
|
||||
|
||||
# # Store file info for background task
|
||||
# processed_attachments.append({
|
||||
# 'file_path': temp_file_path,
|
||||
# 'filename': filename,
|
||||
# 'content_type': content_type
|
||||
# })
|
||||
# elif isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
# # (filename, content, content_type) tuple - can be serialized directly
|
||||
# processed_attachments.append(attachment)
|
||||
|
||||
# Queue the email sending as a background task
|
||||
task_id = async_task(
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
subject,
|
||||
message,
|
||||
recipient_list,
|
||||
request,
|
||||
)
|
||||
logger.info(f"Bulk email queued as background task with ID: {task_id}")
|
||||
return {
|
||||
'success': True,
|
||||
'async': True,
|
||||
'task_id': task_id,
|
||||
'message': f'Email queued for background sending to {len(recipient_list)} recipient(s)'
|
||||
}
|
||||
|
||||
# Synchronous execution (default behavior)
|
||||
try:
|
||||
|
||||
# --- 1. Categorization and Custom Message Preparation (CORRECTED) ---
|
||||
if not from_interview:
|
||||
|
||||
agency_emails = []
|
||||
pure_candidate_emails = []
|
||||
candidate_through_agency_emails = []
|
||||
|
||||
if not recipient_list:
|
||||
return {'success': False, 'error': 'No recipients provided'}
|
||||
|
||||
# Clean recipient list and remove duplicates
|
||||
clean_recipients = []
|
||||
seen_emails = set()
|
||||
# This must contain (final_recipient_email, customized_message) for ALL sends
|
||||
customized_sends = []
|
||||
|
||||
# 1a. Classify Recipients and Prepare Custom Messages
|
||||
for email in recipient_list:
|
||||
email = email.strip().lower()
|
||||
|
||||
try:
|
||||
candidate = get_object_or_404(Candidate, email=email)
|
||||
except Exception:
|
||||
logger.warning(f"Candidate not found for email: {email}")
|
||||
continue
|
||||
|
||||
candidate_name = candidate.first_name
|
||||
|
||||
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
|
||||
if candidate.belong_to_an_agency and candidate.hiring_agency and candidate.hiring_agency.email:
|
||||
agency_email = candidate.hiring_agency.email
|
||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
|
||||
# Add Agency email as the recipient with the custom message
|
||||
customized_sends.append((agency_email, agency_message))
|
||||
agency_emails.append(agency_email)
|
||||
candidate_through_agency_emails.append(candidate.email) # For sync block only
|
||||
|
||||
# --- Pure Candidate (Final Recipient: Candidate) ---
|
||||
else:
|
||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
|
||||
# Add Candidate email as the recipient with the custom message
|
||||
customized_sends.append((email, candidate_message))
|
||||
pure_candidate_emails.append(email) # For sync block only
|
||||
|
||||
# Calculate total recipients based on the size of the final send list
|
||||
total_recipients = len(customized_sends)
|
||||
|
||||
for recipient in recipient_list:
|
||||
email = recipient.strip().lower()
|
||||
if email and email not in seen_emails:
|
||||
clean_recipients.append(email)
|
||||
seen_emails.add(email)
|
||||
if total_recipients == 0:
|
||||
return {'success': False, 'error': 'No valid recipients found for sending.'}
|
||||
else:
|
||||
# For interview flow
|
||||
total_recipients = len(recipient_list)
|
||||
|
||||
|
||||
# --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) ---
|
||||
if async_task_:
|
||||
try:
|
||||
|
||||
processed_attachments = attachments if attachments else []
|
||||
task_ids = []
|
||||
|
||||
if not from_interview:
|
||||
# Loop through ALL final customized sends
|
||||
for recipient_email, custom_message in customized_sends:
|
||||
task_id = async_task(
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
subject,
|
||||
custom_message, # Pass the custom message
|
||||
[recipient_email], # Pass the specific recipient as a list of one
|
||||
processed_attachments,
|
||||
hook='recruitment.tasks.email_success_hook'
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
|
||||
logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.")
|
||||
|
||||
if not clean_recipients:
|
||||
return {'success': False, 'error': 'No valid email addresses found'}
|
||||
return {
|
||||
'success': True,
|
||||
'async': True,
|
||||
'task_ids': task_ids,
|
||||
'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).'
|
||||
}
|
||||
|
||||
else: # from_interview is True (generic send to all participants)
|
||||
task_id = async_task(
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
subject,
|
||||
message,
|
||||
recipient_list, # Send the original message to the entire list
|
||||
processed_attachments,
|
||||
hook='recruitment.tasks.email_success_hook'
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
logger.info(f"Interview emails queued. ID: {task_id}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'async': True,
|
||||
'task_ids': task_ids,
|
||||
'message': f'Interview emails queued for background sending to {total_recipients} recipient(s)'
|
||||
}
|
||||
|
||||
# Prepare email content
|
||||
|
||||
except ImportError:
|
||||
logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.")
|
||||
async_task_ = False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True)
|
||||
return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"}
|
||||
|
||||
# --- 3. Handle SYNCHRONOUS Send (No changes needed here, as it was fixed previously) ---
|
||||
try:
|
||||
# NOTE: The synchronous block below should also use the 'customized_sends'
|
||||
# list for consistency instead of rebuilding messages from 'pure_candidate_emails'
|
||||
# and 'agency_emails', but keeping your current logic structure to minimize changes.
|
||||
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||
|
||||
# Check if message contains HTML tags
|
||||
is_html = '<' in message and '>' in message
|
||||
successful_sends = 0
|
||||
|
||||
if is_html:
|
||||
# Create HTML email with plain text fallback
|
||||
plain_message = strip_tags(message)
|
||||
# Helper Function for Sync Send (as provided)
|
||||
def send_individual_email(recipient, body_message):
|
||||
# ... (Existing helper function logic) ...
|
||||
nonlocal successful_sends
|
||||
|
||||
if is_html:
|
||||
plain_message = strip_tags(body_message)
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||
email_obj.attach_alternative(body_message, "text/html")
|
||||
else:
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
if hasattr(attachment, 'read'):
|
||||
filename = getattr(attachment, 'name', 'attachment')
|
||||
content = attachment.read()
|
||||
content_type = getattr(attachment, 'content_type', 'application/octet-stream')
|
||||
email_obj.attach(filename, content, content_type)
|
||||
elif isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
filename, content, content_type = attachment
|
||||
email_obj.attach(filename, content, content_type)
|
||||
|
||||
try:
|
||||
email_obj.send(fail_silently=False)
|
||||
successful_sends += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||
|
||||
# Create email with both HTML and plain text versions
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=plain_message,
|
||||
from_email=from_email,
|
||||
to=clean_recipients,
|
||||
)
|
||||
email.attach_alternative(message, "text/html")
|
||||
if not from_interview:
|
||||
# Send Emails - Pure Candidates
|
||||
for email in pure_candidate_emails:
|
||||
candidate_name = Candidate.objects.filter(email=email).first().first_name
|
||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
send_individual_email(email, candidate_message)
|
||||
|
||||
# Send Emails - Agencies
|
||||
i = 0
|
||||
for email in agency_emails:
|
||||
candidate_email = candidate_through_agency_emails[i]
|
||||
candidate_name = Candidate.objects.filter(email=candidate_email).first().first_name
|
||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
send_individual_email(email, agency_message)
|
||||
i += 1
|
||||
|
||||
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
|
||||
return {
|
||||
'success': True,
|
||||
'recipients_count': successful_sends,
|
||||
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
|
||||
}
|
||||
else:
|
||||
# Plain text email
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=message,
|
||||
from_email=from_email,
|
||||
to=clean_recipients,
|
||||
)
|
||||
|
||||
# Add attachments if provided
|
||||
# if attachments:
|
||||
# for attachment in attachments:
|
||||
# if hasattr(attachment, 'read'):
|
||||
# # File-like object
|
||||
# filename = getattr(attachment, 'name', 'attachment')
|
||||
# content = attachment.read()
|
||||
# content_type = getattr(attachment, 'content_type', 'application/octet-stream')
|
||||
# email.attach(filename, content, content_type)
|
||||
# elif isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
# # (filename, content, content_type) tuple
|
||||
# filename, content, content_type = attachment
|
||||
# email.attach(filename, content, content_type)
|
||||
|
||||
# Send email
|
||||
email.send(fail_silently=False)
|
||||
|
||||
logger.info(f"Bulk email sent successfully to {len(clean_recipients)} recipients")
|
||||
return {
|
||||
'success': True,
|
||||
'recipients_count': len(clean_recipients),
|
||||
'message': f'Email sent successfully to {len(clean_recipients)} recipient(s)'
|
||||
}
|
||||
for email in recipient_list:
|
||||
send_individual_email(email, message)
|
||||
|
||||
logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.")
|
||||
return {
|
||||
'success': True,
|
||||
'recipients_count': successful_sends,
|
||||
'message': f'Interview emails sent successfully to {successful_sends} recipient(s).'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to send bulk email: {str(e)}"
|
||||
error_msg = f"Failed to process bulk email send request: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return {'success': False, 'error': error_msg}
|
||||
return {'success': False, 'error': error_msg}
|
||||
@ -11,7 +11,7 @@ from .models import (
|
||||
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
|
||||
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
|
||||
Profile,MeetingComment,ScheduledInterview,Source,HiringAgency,
|
||||
AgencyJobAssignment, AgencyAccessLink,Participants
|
||||
AgencyJobAssignment, AgencyAccessLink,Participants,OnsiteMeeting
|
||||
)
|
||||
# from django_summernote.widgets import SummernoteWidget
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
@ -584,11 +584,12 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InterviewSchedule
|
||||
fields = [
|
||||
'candidates', 'start_date', 'end_date', 'working_days',
|
||||
'candidates', 'interview_type', 'start_date', 'end_date', 'working_days',
|
||||
'start_time', 'end_time', 'interview_duration', 'buffer_time',
|
||||
'break_start_time', 'break_end_time'
|
||||
]
|
||||
widgets = {
|
||||
'interview_type': forms.Select(attrs={'class': 'form-control'}),
|
||||
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
@ -1243,29 +1244,37 @@ class ParticipantsForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ParticipantsSelectForm(forms.ModelForm):
|
||||
"""Form for selecting Participants"""
|
||||
# class ParticipantsSelectForm(forms.ModelForm):
|
||||
# """Form for selecting Participants"""
|
||||
|
||||
participants=forms.ModelMultipleChoiceField(
|
||||
queryset=Participants.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label=_("Select 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"))
|
||||
# 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
|
||||
# class Meta:
|
||||
# model = JobPosting
|
||||
# fields = ['participants','users'] # No direct fields from Participants model
|
||||
|
||||
|
||||
class CandidateEmailForm(forms.Form):
|
||||
"""Form for composing emails to participants about a candidate"""
|
||||
|
||||
to = forms.MultipleChoiceField(
|
||||
widget=forms.CheckboxSelectMultiple(attrs={
|
||||
'class': 'form-check'
|
||||
}),
|
||||
label=_('Select Candidates'), # Use a descriptive label
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
subject = forms.CharField(
|
||||
max_length=200,
|
||||
widget=forms.TextInput(attrs={
|
||||
@ -1288,138 +1297,326 @@ class CandidateEmailForm(forms.Form):
|
||||
required=True
|
||||
)
|
||||
|
||||
recipients = forms.MultipleChoiceField(
|
||||
widget=forms.CheckboxSelectMultiple(attrs={
|
||||
'class': 'form-check'
|
||||
}),
|
||||
label=_('Recipients'),
|
||||
required=True
|
||||
)
|
||||
|
||||
include_candidate_info = forms.BooleanField(
|
||||
widget=forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
label=_('Include candidate information'),
|
||||
initial=True,
|
||||
required=False
|
||||
)
|
||||
|
||||
include_meeting_details = forms.BooleanField(
|
||||
widget=forms.CheckboxInput(attrs={
|
||||
'class': 'form-check-input'
|
||||
}),
|
||||
label=_('Include meeting details'),
|
||||
initial=True,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
|
||||
def __init__(self, job, candidate, *args, **kwargs):
|
||||
def __init__(self, job, candidates, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.job = job
|
||||
self.candidate = candidate
|
||||
|
||||
# Get all participants and users for this job
|
||||
recipient_choices = []
|
||||
|
||||
# Add job participants
|
||||
for participant in job.participants.all():
|
||||
recipient_choices.append(
|
||||
(f'participant_{participant.id}', f'{participant.name} - {participant.designation} (Participant)')
|
||||
self.candidates=candidates
|
||||
|
||||
candidate_choices=[]
|
||||
for candidate in candidates:
|
||||
candidate_choices.append(
|
||||
(f'candidate_{candidate.id}', f'{candidate.email}')
|
||||
)
|
||||
|
||||
|
||||
# Add job users
|
||||
for user in job.users.all():
|
||||
recipient_choices.append(
|
||||
(f'user_{user.id}', f'{user.get_full_name() or user.username} - {user.email} (User)')
|
||||
)
|
||||
|
||||
self.fields['recipients'].choices = recipient_choices
|
||||
self.fields['recipients'].initial = [choice[0] for choice in recipient_choices] # Select all by default
|
||||
|
||||
# Set initial subject
|
||||
self.fields['subject'].initial = f'Interview Update: {candidate.name} - {job.title}'
|
||||
self.fields['to'].choices =candidate_choices
|
||||
self.fields['to'].initial = [choice[0] for choice in candidate_choices]
|
||||
|
||||
|
||||
|
||||
# Set initial message with candidate and meeting info
|
||||
initial_message = self._get_initial_message()
|
||||
|
||||
if initial_message:
|
||||
self.fields['message'].initial = initial_message
|
||||
|
||||
def _get_initial_message(self):
|
||||
"""Generate initial message with candidate and meeting information"""
|
||||
message_parts = []
|
||||
candidate=self.candidates.first()
|
||||
message_parts=[]
|
||||
|
||||
if candidate and candidate.stage == 'Applied':
|
||||
message_parts = [
|
||||
f"Than you, for your interest in the {self.job.title} role.",
|
||||
f"We regret to inform you that you were not selected to move forward to the exam round at this time.",
|
||||
f"We encourage you to check our career page for further updates and future opportunities:",
|
||||
f"https://kaauh/careers",
|
||||
f"Wishing you the best in your job search,",
|
||||
f"The KAAUH Hiring team"
|
||||
]
|
||||
elif candidate and candidate.stage == 'Exam':
|
||||
message_parts = [
|
||||
f"Than you,for your interest in the {self.job.title} role.",
|
||||
f"We're pleased to inform you that your initial screening was successful!",
|
||||
f"The next step is the mandatory online assessment exam.",
|
||||
f"Please complete the assessment by using the following link:",
|
||||
f"https://kaauh/hire/exam",
|
||||
f"We look forward to reviewing your results.",
|
||||
f"Best regards, The KAAUH Hiring team"
|
||||
]
|
||||
|
||||
elif candidate and candidate.stage == 'Interview':
|
||||
message_parts = [
|
||||
f"Than you, for your interest in the {self.job.title} role.",
|
||||
f"We're pleased to inform you that your initial screening was successful!",
|
||||
f"The next step is the mandatory online assessment exam.",
|
||||
f"Please complete the assessment by using the following link:",
|
||||
f"https://kaauh/hire/exam",
|
||||
f"We look forward to reviewing your results.",
|
||||
f"Best regards, The KAAUH Hiring team"
|
||||
]
|
||||
|
||||
elif candidate and candidate.stage == 'Offer':
|
||||
message_parts = [
|
||||
f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.",
|
||||
f"This is an exciting moment, and we look forward to having you join the KAAUH team.",
|
||||
f"A detailed offer letter and compensation package will be sent to you via email within 24 hours.",
|
||||
f"In the meantime, please contact our HR department at [HR Contact] if you have immediate questions.",
|
||||
f"Welcome to the team!",
|
||||
f"Best regards, The KAAUH Hiring team"
|
||||
]
|
||||
elif candidate and candidate.stage == 'Hired':
|
||||
message_parts = [
|
||||
f"Welcome aboard,!",
|
||||
f"We are thrilled to officially confirm your employment as our new {self.job.title}.",
|
||||
f"You will receive a separate email shortly with details regarding your start date, first-day instructions, and onboarding documents.",
|
||||
f"We look forward to seeing you at KAAUH.",
|
||||
f"If you have any questions before your start date, please contact [Onboarding Contact].",
|
||||
f"Best regards, The KAAUH Hiring team"
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
# Add candidate information
|
||||
if self.candidate:
|
||||
message_parts.append(f"Candidate Information:")
|
||||
message_parts.append(f"Name: {self.candidate.name}")
|
||||
message_parts.append(f"Email: {self.candidate.email}")
|
||||
message_parts.append(f"Phone: {self.candidate.phone}")
|
||||
# # Add candidate information
|
||||
# if self.candidate:
|
||||
# message_parts.append(f"Candidate Information:")
|
||||
# message_parts.append(f"Name: {self.candidate.name}")
|
||||
# message_parts.append(f"Email: {self.candidate.email}")
|
||||
# message_parts.append(f"Phone: {self.candidate.phone}")
|
||||
|
||||
# Add latest meeting information if available
|
||||
latest_meeting = self.candidate.get_latest_meeting
|
||||
if latest_meeting:
|
||||
message_parts.append(f"\nMeeting Information:")
|
||||
message_parts.append(f"Topic: {latest_meeting.topic}")
|
||||
message_parts.append(f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}")
|
||||
message_parts.append(f"Duration: {latest_meeting.duration} minutes")
|
||||
if latest_meeting.join_url:
|
||||
message_parts.append(f"Join URL: {latest_meeting.join_url}")
|
||||
# # Add latest meeting information if available
|
||||
# latest_meeting = self.candidate.get_latest_meeting
|
||||
# if latest_meeting:
|
||||
# message_parts.append(f"\nMeeting Information:")
|
||||
# message_parts.append(f"Topic: {latest_meeting.topic}")
|
||||
# message_parts.append(f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}")
|
||||
# message_parts.append(f"Duration: {latest_meeting.duration} minutes")
|
||||
# if latest_meeting.join_url:
|
||||
# message_parts.append(f"Join URL: {latest_meeting.join_url}")
|
||||
|
||||
return '\n'.join(message_parts)
|
||||
|
||||
def clean_recipients(self):
|
||||
"""Ensure at least one recipient is selected"""
|
||||
recipients = self.cleaned_data.get('recipients')
|
||||
if not recipients:
|
||||
raise forms.ValidationError(_('Please select at least one recipient.'))
|
||||
return recipients
|
||||
|
||||
def get_email_addresses(self):
|
||||
"""Extract email addresses from selected recipients"""
|
||||
email_addresses = []
|
||||
recipients = self.cleaned_data.get('recipients', [])
|
||||
for recipient in recipients:
|
||||
if recipient.startswith('participant_'):
|
||||
participant_id = recipient.split('_')[1]
|
||||
try:
|
||||
participant = Participants.objects.get(id=participant_id)
|
||||
email_addresses.append(participant.email)
|
||||
except Participants.DoesNotExist:
|
||||
continue
|
||||
elif recipient.startswith('user_'):
|
||||
user_id = recipient.split('_')[1]
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
email_addresses.append(user.email)
|
||||
except User.DoesNotExist:
|
||||
continue
|
||||
|
||||
candidates=self.cleaned_data.get('to',[])
|
||||
|
||||
if candidates:
|
||||
for candidate in candidates:
|
||||
if candidate.startswith('candidate_'):
|
||||
print("candidadte: {candidate}")
|
||||
candidate_id = candidate.split('_')[1]
|
||||
try:
|
||||
candidate = Candidate.objects.get(id=candidate_id)
|
||||
email_addresses.append(candidate.email)
|
||||
except Candidate.DoesNotExist:
|
||||
continue
|
||||
|
||||
|
||||
return list(set(email_addresses)) # Remove duplicates
|
||||
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Get the formatted message with optional additional information"""
|
||||
message = self.cleaned_data.get('message', '')
|
||||
|
||||
# Add candidate information if requested
|
||||
if self.cleaned_data.get('include_candidate_info') and self.candidate:
|
||||
candidate_info = f"\n\n--- Candidate Information ---\n"
|
||||
candidate_info += f"Name: {self.candidate.name}\n"
|
||||
candidate_info += f"Email: {self.candidate.email}\n"
|
||||
candidate_info += f"Phone: {self.candidate.phone}\n"
|
||||
message += candidate_info
|
||||
|
||||
# Add meeting details if requested
|
||||
if self.cleaned_data.get('include_meeting_details') and self.candidate:
|
||||
latest_meeting = self.candidate.get_latest_meeting
|
||||
if latest_meeting:
|
||||
meeting_info = f"\n\n--- Meeting Details ---\n"
|
||||
meeting_info += f"Topic: {latest_meeting.topic}\n"
|
||||
meeting_info += f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
|
||||
meeting_info += f"Duration: {latest_meeting.duration} minutes\n"
|
||||
if latest_meeting.join_url:
|
||||
meeting_info += f"Join URL: {latest_meeting.join_url}\n"
|
||||
message += meeting_info
|
||||
|
||||
return message
|
||||
|
||||
|
||||
class InterviewParticpantsForm(forms.ModelForm):
|
||||
participants = forms.ModelMultipleChoiceField(
|
||||
queryset=Participants.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False ,
|
||||
|
||||
)
|
||||
system_users=forms.ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label=_("Select Users"))
|
||||
|
||||
class Meta:
|
||||
model = InterviewSchedule
|
||||
fields = ['participants','system_users']
|
||||
|
||||
|
||||
|
||||
class InterviewEmailForm(forms.Form):
|
||||
subject = forms.CharField(
|
||||
max_length=200,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter email subject',
|
||||
'required': True
|
||||
}),
|
||||
label=_('Subject'),
|
||||
required=True
|
||||
)
|
||||
|
||||
message_for_candidate= forms.CharField(
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 8,
|
||||
'placeholder': 'Enter your message here...',
|
||||
'required': True
|
||||
}),
|
||||
label=_('Message'),
|
||||
required=False
|
||||
)
|
||||
message_for_agency= forms.CharField(
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 8,
|
||||
'placeholder': 'Enter your message here...',
|
||||
'required': True
|
||||
}),
|
||||
label=_('Message'),
|
||||
required=False
|
||||
)
|
||||
message_for_participants= forms.CharField(
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 8,
|
||||
'placeholder': 'Enter your message here...',
|
||||
'required': True
|
||||
}),
|
||||
label=_('Message'),
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# --- Data Preparation ---
|
||||
# Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check)
|
||||
formatted_date = meeting.start_time.strftime('%Y-%m-%d')
|
||||
formatted_time = meeting.start_time.strftime('%I:%M %p')
|
||||
zoom_link = meeting.join_url
|
||||
duration = meeting.duration
|
||||
job_title = job.title
|
||||
agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency"
|
||||
|
||||
# --- Combined Participants List for Internal Email ---
|
||||
external_participants_names = ", ".join([p.name for p in external_participants ])
|
||||
system_participants_names = ", ".join([p.first_name for p in system_participants ])
|
||||
|
||||
# Combine and ensure no leading/trailing commas if one list is empty
|
||||
participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
|
||||
|
||||
|
||||
# --- 1. Candidate Message (More concise and structured) ---
|
||||
candidate_message = f"""
|
||||
Dear {candidate.full_name},
|
||||
|
||||
Thank you for your interest in the **{job_title}** position at KAAUH. We're pleased to invite you to an interview!
|
||||
|
||||
The details of your virtual interview are as follows:
|
||||
|
||||
- **Date:** {formatted_date}
|
||||
- **Time:** {formatted_time} (RIYADH TIME)
|
||||
- **Duration:** {duration}
|
||||
- **Meeting Link:** {zoom_link}
|
||||
|
||||
Please click the link at the scheduled time to join the interview.
|
||||
|
||||
Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary.
|
||||
|
||||
We look forward to meeting you.
|
||||
|
||||
Best regards,
|
||||
KAAUH Hiring Team
|
||||
"""
|
||||
|
||||
|
||||
# --- 2. Agency Message (Professional and clear details) ---
|
||||
agency_message = f"""
|
||||
Dear {agency_name},
|
||||
|
||||
We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role.
|
||||
|
||||
Please forward the following details to the candidate and ensure they are fully prepared.
|
||||
|
||||
**Interview Details:**
|
||||
|
||||
- **Candidate:** {candidate.full_name}
|
||||
- **Job Title:** {job_title}
|
||||
- **Date:** {formatted_date}
|
||||
- **Time:** {formatted_time} (RIYADH TIME)
|
||||
- **Duration:** {duration}
|
||||
- **Meeting Link:** {zoom_link}
|
||||
|
||||
Please let us know if you or the candidate have any questions.
|
||||
|
||||
Best regards,
|
||||
KAAUH Hiring Team
|
||||
"""
|
||||
|
||||
# --- 3. Participants Message (Action-oriented and informative) ---
|
||||
participants_message = f"""
|
||||
Hi Team,
|
||||
|
||||
This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position.
|
||||
|
||||
**Interview Summary:**
|
||||
|
||||
- **Candidate:** {candidate.full_name}
|
||||
- **Date:** {formatted_date}
|
||||
- **Time:** {formatted_time} (RIYADH TIME)
|
||||
- **Duration:** {duration}
|
||||
- **Your Fellow Interviewers:** {participant_names}
|
||||
|
||||
**Action Items:**
|
||||
|
||||
1. Please review **{candidate.full_name}'s** resume and notes.
|
||||
2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join.
|
||||
3. Be ready to start promptly at the scheduled time.
|
||||
|
||||
Thank you for your participation.
|
||||
|
||||
Best regards,
|
||||
KAAUH HIRING TEAM
|
||||
"""
|
||||
|
||||
# Set initial data
|
||||
self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
|
||||
# .strip() removes the leading/trailing blank lines caused by the f""" format
|
||||
self.initial['message_for_candidate'] = candidate_message.strip()
|
||||
self.initial['message_for_agency'] = agency_message.strip()
|
||||
self.initial['message_for_participants'] = participants_message.strip()
|
||||
|
||||
|
||||
|
||||
|
||||
# class OnsiteLocationForm(forms.ModelForm):
|
||||
# class Meta:
|
||||
# model=
|
||||
# fields=['location']
|
||||
# widgets={
|
||||
# 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
|
||||
# }
|
||||
|
||||
|
||||
|
||||
class OnsiteMeetingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = OnsiteMeeting
|
||||
fields = ['topic', 'start_time', 'duration', 'timezone', 'location', 'status']
|
||||
widgets = {
|
||||
'topic': forms.TextInput(attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}),
|
||||
'start_time': forms.DateTimeInput(
|
||||
attrs={'type': 'datetime-local', 'class': 'form-control'}
|
||||
),
|
||||
'duration': forms.NumberInput(
|
||||
attrs={'min': 15, 'placeholder': 'Duration in minutes', 'class': 'form-control'}
|
||||
),
|
||||
'location': forms.TextInput(attrs={'placeholder': 'Physical location', 'class': 'form-control'}),
|
||||
'timezone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'status': forms.Select(attrs={'class': 'form-control'}),
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-30 10:22
|
||||
# Generated by Django 5.2.7 on 2025-11-05 13:05
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-06 15:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scheduledinterview',
|
||||
name='participants',
|
||||
field=models.ManyToManyField(blank=True, to='recruitment.participants'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-06 15:37
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0002_scheduledinterview_participants'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scheduledinterview',
|
||||
name='system_users',
|
||||
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-06 15:44
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0003_scheduledinterview_system_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='jobposting',
|
||||
name='participants',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='jobposting',
|
||||
name='users',
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-09 11:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0004_remove_jobposting_participants_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scheduledinterview',
|
||||
name='meeting_type',
|
||||
field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-09 11:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0005_scheduledinterview_meeting_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='scheduledinterview',
|
||||
name='meeting_type',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interviewschedule',
|
||||
name='meeting_type',
|
||||
field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-09 11:30
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0006_remove_scheduledinterview_meeting_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='interviewschedule',
|
||||
old_name='meeting_type',
|
||||
new_name='interview_type',
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0008_interviewschedule_location.py
Normal file
18
recruitment/migrations/0008_interviewschedule_location.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-09 12:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0007_rename_meeting_type_interviewschedule_interview_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interviewschedule',
|
||||
name='location',
|
||||
field=models.CharField(blank=True, default='Remote', null=True),
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0009_alter_zoommeeting_meeting_id.py
Normal file
18
recruitment/migrations/0009_alter_zoommeeting_meeting_id.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-09 13:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0008_interviewschedule_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_id',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=20, null=True, unique=True, verbose_name='Meeting ID'),
|
||||
),
|
||||
]
|
||||
19
recruitment/migrations/0010_alter_zoommeeting_meeting_id.py
Normal file
19
recruitment/migrations/0010_alter_zoommeeting_meeting_id.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-09 13:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0009_alter_zoommeeting_meeting_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_id',
|
||||
field=models.CharField(db_index=True, default=1, max_length=20, unique=True, verbose_name='Meeting ID'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-09 13:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0010_alter_zoommeeting_meeting_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='scheduledinterview',
|
||||
name='zoom_meeting',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-10 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0011_alter_scheduledinterview_zoom_meeting'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interviewschedule',
|
||||
name='interview_topic',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
45
recruitment/migrations/0013_onsitemeeting_and_more.py
Normal file
45
recruitment/migrations/0013_onsitemeeting_and_more.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-10 13:00
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0012_interviewschedule_interview_topic'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OnsiteMeeting',
|
||||
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')),
|
||||
('topic', models.CharField(max_length=255, verbose_name='Topic')),
|
||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||
('duration', models.PositiveIntegerField(verbose_name='Duration')),
|
||||
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
|
||||
('location', models.CharField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interviewschedule',
|
||||
name='interview_topic',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='interviewschedule',
|
||||
name='location',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scheduledinterview',
|
||||
name='onsite_meeting',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.onsitemeeting'),
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0014_onsitemeeting_status.py
Normal file
18
recruitment/migrations/0014_onsitemeeting_status.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-10 13:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0013_onsitemeeting_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='onsitemeeting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-10 13:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0014_onsitemeeting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='scheduledinterview',
|
||||
name='onsite_meeting',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='onsite_interview', to='recruitment.onsitemeeting'),
|
||||
),
|
||||
]
|
||||
@ -54,18 +54,18 @@ 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"),
|
||||
)
|
||||
# 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"),
|
||||
)
|
||||
# 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)
|
||||
@ -421,6 +421,7 @@ class Candidate(Base):
|
||||
related_name="candidates",
|
||||
verbose_name=_("Job"),
|
||||
)
|
||||
|
||||
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
|
||||
last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
|
||||
email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index
|
||||
@ -706,6 +707,11 @@ class Candidate(Base):
|
||||
time_to_hire = self.hired_date - self.created_at.date()
|
||||
return time_to_hire.days
|
||||
return 0
|
||||
|
||||
@property
|
||||
def belong_to_an_agency(self):
|
||||
return self.hiring_source=='Agency'
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||
@ -725,6 +731,27 @@ class TrainingMaterial(Base):
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class OnsiteMeeting(Base):
|
||||
class MeetingStatus(models.TextChoices):
|
||||
WAITING = "waiting", _("Waiting")
|
||||
STARTED = "started", _("Started")
|
||||
ENDED = "ended", _("Ended")
|
||||
CANCELLED = "cancelled",_("Cancelled")
|
||||
# Basic meeting details
|
||||
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
||||
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index
|
||||
duration = models.PositiveIntegerField(
|
||||
verbose_name=_("Duration")
|
||||
) # Duration in minutes
|
||||
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"))
|
||||
location=models.CharField(null=True,blank=True)
|
||||
status = models.CharField(
|
||||
db_index=True, max_length=20, # Added index
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Status"),
|
||||
default=MeetingStatus.WAITING,
|
||||
)
|
||||
|
||||
class ZoomMeeting(Base):
|
||||
class MeetingStatus(models.TextChoices):
|
||||
@ -736,6 +763,7 @@ class ZoomMeeting(Base):
|
||||
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
||||
meeting_id = models.CharField(
|
||||
db_index=True, max_length=20, unique=True, verbose_name=_("Meeting ID") # Added index
|
||||
|
||||
) # Unique identifier for the meeting
|
||||
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index
|
||||
duration = models.PositiveIntegerField(
|
||||
@ -772,7 +800,7 @@ class ZoomMeeting(Base):
|
||||
# Timestamps
|
||||
|
||||
def __str__(self):
|
||||
return self.topic\
|
||||
return self.topic
|
||||
@property
|
||||
def get_job(self):
|
||||
return self.interview.job
|
||||
@ -781,10 +809,10 @@ class ZoomMeeting(Base):
|
||||
return self.interview.candidate
|
||||
@property
|
||||
def get_participants(self):
|
||||
return self.interview.job.participants.all()
|
||||
return self.interview.participants.all()
|
||||
@property
|
||||
def get_users(self):
|
||||
return self.interview.job.users.all()
|
||||
return self.interview.system_users.all()
|
||||
|
||||
class MeetingComment(Base):
|
||||
"""
|
||||
@ -1593,6 +1621,19 @@ class BreakTime(models.Model):
|
||||
|
||||
class InterviewSchedule(Base):
|
||||
"""Stores the scheduling criteria for interviews"""
|
||||
"""Stores individual scheduled interviews"""
|
||||
|
||||
class InterviewType(models.TextChoices):
|
||||
REMOTE = 'Remote', 'Remote Interview'
|
||||
ONSITE = 'Onsite', 'In-Person Interview'
|
||||
|
||||
interview_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=InterviewType.choices,
|
||||
default=InterviewType.REMOTE,
|
||||
verbose_name="Interview Meeting Type"
|
||||
)
|
||||
|
||||
|
||||
job = models.ForeignKey(
|
||||
JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True
|
||||
@ -1630,21 +1671,32 @@ class InterviewSchedule(Base):
|
||||
|
||||
|
||||
class ScheduledInterview(Base):
|
||||
"""Stores individual scheduled interviews"""
|
||||
|
||||
|
||||
#for one candidate
|
||||
candidate = models.ForeignKey(
|
||||
Candidate,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="scheduled_interviews",
|
||||
db_index=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
participants = models.ManyToManyField('Participants', blank=True)
|
||||
system_users=models.ManyToManyField(User,blank=True)
|
||||
|
||||
|
||||
job = models.ForeignKey(
|
||||
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True
|
||||
)
|
||||
zoom_meeting = models.OneToOneField(
|
||||
ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True
|
||||
ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True,
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
onsite_meeting= models.OneToOneField(
|
||||
OnsiteMeeting, on_delete=models.CASCADE, related_name="onsite_interview", db_index=True,
|
||||
null=True, blank=True
|
||||
)
|
||||
schedule = models.ForeignKey(
|
||||
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True
|
||||
@ -1753,6 +1805,7 @@ class Notification(models.Model):
|
||||
|
||||
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)
|
||||
@ -1763,3 +1816,5 @@ class Participants(Base):
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.email}"
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from django.db import transaction
|
||||
from django_q.models import Schedule
|
||||
from django_q.tasks import schedule
|
||||
@ -8,7 +9,7 @@ from django_q.tasks import async_task
|
||||
from django.db.models.signals import post_save
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification
|
||||
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification,AgencyJobAssignment,AgencyAccessLink
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -397,3 +398,12 @@ def notification_created(sender, instance, created, **kwargs):
|
||||
SSE_NOTIFICATION_CACHE[user_id] = SSE_NOTIFICATION_CACHE[user_id][-50:]
|
||||
|
||||
logger.info(f"Notification cached for SSE: {notification_data}")
|
||||
|
||||
@receiver(post_save,sender=AgencyJobAssignment)
|
||||
def create_access_link(sender,instance,created,**kwargs):
|
||||
if created:
|
||||
link=AgencyAccessLink(assignment=instance)
|
||||
link.access_password = link.generate_password()
|
||||
link.unique_token = link.generate_token()
|
||||
link.expires_at = datetime.now() + timedelta(days=4)
|
||||
link.save()
|
||||
@ -461,6 +461,7 @@ def create_interview_and_meeting(
|
||||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||||
|
||||
# 1. External API Call (Slow)
|
||||
|
||||
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
@ -746,70 +747,74 @@ def sync_candidate_to_source_task(candidate_id, source_id):
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
|
||||
def send_bulk_email_task(subject, message, recipient_list, request=None, attachments=None):
|
||||
"""
|
||||
Django-Q background task to send bulk email to multiple recipients.
|
||||
|
||||
Args:
|
||||
subject: Email subject
|
||||
message: Email message (can be HTML)
|
||||
recipient_list: List of email addresses
|
||||
request: Django request object (optional)
|
||||
attachments: List of file attachment data (optional)
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and error message if failed
|
||||
"""
|
||||
from .email_service import send_bulk_email
|
||||
import os
|
||||
|
||||
logger.info(f"Starting bulk email task for {len(recipient_list)} recipients")
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
def _task_send_individual_email(subject, body_message, recipient, attachments):
|
||||
"""Internal helper to create and send a single email."""
|
||||
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||
is_html = '<' in body_message and '>' in body_message
|
||||
|
||||
if is_html:
|
||||
plain_message = strip_tags(body_message)
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||
email_obj.attach_alternative(body_message, "text/html")
|
||||
else:
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
if isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
filename, content, content_type = attachment
|
||||
email_obj.attach(filename, content, content_type)
|
||||
|
||||
try:
|
||||
# Process attachments - convert file data back to file objects if needed
|
||||
# processed_attachments = []
|
||||
# if attachments:
|
||||
# for attachment in attachments:
|
||||
# if isinstance(attachment, dict) and 'file_path' in attachment:
|
||||
# # This is a serialized file from background task
|
||||
# file_path = attachment['file_path']
|
||||
# filename = attachment.get('filename', os.path.basename(file_path))
|
||||
# content_type = attachment.get('content_type', 'application/octet-stream')
|
||||
|
||||
# try:
|
||||
# with open(file_path, 'rb') as f:
|
||||
# content = f.read()
|
||||
# processed_attachments.append((filename, content, content_type))
|
||||
|
||||
# # Clean up temporary file
|
||||
# try:
|
||||
# os.unlink(file_path)
|
||||
# except OSError:
|
||||
# pass # File might already be deleted
|
||||
|
||||
# except FileNotFoundError:
|
||||
# logger.warning(f"Attachment file not found: {file_path}")
|
||||
# continue
|
||||
# else:
|
||||
# # Direct attachment (file object or tuple)
|
||||
# processed_attachments.append(attachment)
|
||||
|
||||
# Call the existing send_bulk_email function synchronously within the task
|
||||
result = send_bulk_email(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipient_list=recipient_list,
|
||||
request=request,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
logger.info(f"Bulk email task completed successfully for {result.get('recipients_count', len(recipient_list))} recipients")
|
||||
else:
|
||||
logger.error(f"Bulk email task failed: {result.get('error', 'Unknown error')}")
|
||||
|
||||
return result
|
||||
|
||||
email_obj.send(fail_silently=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
error_msg = f"Critical error in bulk email task: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return {'success': False, 'error': error_msg}
|
||||
logger.error(f"Task failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def send_bulk_email_task(subject, message, recipient_list, attachments=None, hook='recruitment.tasks.email_success_hook'):
|
||||
"""
|
||||
Django-Q background task to send pre-formatted email to a list of recipients.
|
||||
Receives arguments directly from the async_task call.
|
||||
"""
|
||||
logger.info(f"Starting bulk email task for {len(recipient_list)} recipients")
|
||||
successful_sends = 0
|
||||
total_recipients = len(recipient_list)
|
||||
|
||||
if not recipient_list:
|
||||
return {'success': False, 'error': 'No recipients provided to task.'}
|
||||
|
||||
# Since the async caller sends one task per recipient, total_recipients should be 1.
|
||||
for recipient in recipient_list:
|
||||
# The 'message' is the custom message specific to this recipient.
|
||||
if _task_send_individual_email(subject, message, recipient, attachments):
|
||||
successful_sends += 1
|
||||
|
||||
if successful_sends > 0:
|
||||
logger.info(f"Bulk email task completed successfully. Sent to {successful_sends}/{total_recipients} recipients.")
|
||||
return {
|
||||
'success': True,
|
||||
'recipients_count': successful_sends,
|
||||
'message': f"Sent successfully to {successful_sends} recipient(s)."
|
||||
}
|
||||
else:
|
||||
logger.error(f"Bulk email task failed: No emails were sent successfully.")
|
||||
return {'success': False, 'error': "No emails were sent successfully in the background task."}
|
||||
|
||||
|
||||
def email_success_hook(task):
|
||||
"""
|
||||
The success hook must accept the Task object as the first and only required positional argument.
|
||||
"""
|
||||
if task.success:
|
||||
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
|
||||
else:
|
||||
logger.error(f"Task ID {task.id} failed. Error: {task.result}")
|
||||
|
||||
19
recruitment/templatetags/url_extras.py
Normal file
19
recruitment/templatetags/url_extras.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.simple_tag
|
||||
def add_get_params(request_get, *args):
|
||||
"""
|
||||
Constructs a GET query string by preserving all current
|
||||
parameters EXCEPT 'page', which is handled separately.
|
||||
"""
|
||||
params = request_get.copy()
|
||||
|
||||
# Remove the page parameter to prevent it from duplicating or interfering
|
||||
if 'page' in params:
|
||||
del params['page']
|
||||
|
||||
# Return the URL-encoded string (e.g., department=IT&employment_type=FULL_TIME)
|
||||
# The template prepends the '&' and the 'page=X'
|
||||
return params.urlencode()
|
||||
@ -5,7 +5,7 @@ from . import views_integration
|
||||
from . import views_source
|
||||
|
||||
urlpatterns = [
|
||||
path('dashboard/', views_frontend.dashboard_view, name='dashboard'),
|
||||
path('', views_frontend.dashboard_view, name='dashboard'),
|
||||
|
||||
# Job URLs (using JobPosting model)
|
||||
path('jobs/', views_frontend.JobListView.as_view(), name='job_list'),
|
||||
@ -14,6 +14,7 @@ urlpatterns = [
|
||||
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
|
||||
# path('jobs/<slug:slug>/delete/', views., name='job_delete'),
|
||||
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
|
||||
path('jobs/<slug:slug>/download/cvs/', views.job_cvs_download, name='job_cvs_download'),
|
||||
|
||||
path('careers/',views.kaauh_career,name='kaauh_career'),
|
||||
|
||||
@ -231,5 +232,17 @@ urlpatterns = [
|
||||
path('participants/<slug:slug>/delete/', views_frontend.ParticipantsDeleteView.as_view(), name='participants_delete'),
|
||||
|
||||
# Email composition URLs
|
||||
path('jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/compose-email/', views.compose_candidate_email, name='compose_candidate_email'),
|
||||
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
|
||||
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
||||
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||
|
||||
|
||||
|
||||
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
||||
# path('interview/list/', views.InterviewListView.as_view(), name='interview_list'),
|
||||
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
||||
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
|
||||
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
||||
|
||||
|
||||
]
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import json
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from django.core.paginator import Paginator
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@ -21,6 +24,7 @@ from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrap
|
||||
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.urls import reverse_lazy
|
||||
from django.db.models import Count, Avg, F,Q
|
||||
from .forms import (
|
||||
CandidateExamDateForm,
|
||||
@ -41,9 +45,10 @@ from .forms import (
|
||||
AgencyAccessLinkForm,
|
||||
AgencyJobAssignmentForm,
|
||||
LinkedPostContentForm,
|
||||
ParticipantsSelectForm,
|
||||
CandidateEmailForm,
|
||||
SourceForm
|
||||
SourceForm,
|
||||
InterviewEmailForm,
|
||||
|
||||
)
|
||||
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
||||
from rest_framework import viewsets
|
||||
@ -52,7 +57,7 @@ from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
from .linkedin_service import LinkedInService
|
||||
from .serializers import JobPostingSerializer, CandidateSerializer
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
from django.views.generic import CreateView, UpdateView, DetailView, ListView
|
||||
from django.views.generic import CreateView, UpdateView, DetailView, ListView,DeleteView
|
||||
from .utils import (
|
||||
create_zoom_meeting,
|
||||
delete_zoom_meeting,
|
||||
@ -192,12 +197,73 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView):
|
||||
context["status_filter"] = self.request.GET.get("status", "")
|
||||
context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
|
||||
return context
|
||||
|
||||
# @login_required
|
||||
# def InterviewListView(request):
|
||||
# # interview_type=request.GET.get('interview_type','Remote')
|
||||
# # print(interview_type)
|
||||
# interview_type='Onsite'
|
||||
# meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type)
|
||||
# return render(request, "meetings/list_meetings.html",{
|
||||
# 'meetings':meetings,
|
||||
# })
|
||||
|
||||
|
||||
# search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency
|
||||
# if search_query:
|
||||
# interviews = interviews.filter(
|
||||
# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query)
|
||||
# )
|
||||
|
||||
# # Handle filter by status
|
||||
# status_filter = request.GET.get("status", "")
|
||||
# if status_filter:
|
||||
# queryset = queryset.filter(status=status_filter)
|
||||
|
||||
# # Handle search by candidate name
|
||||
# candidate_name = request.GET.get("candidate_name", "")
|
||||
# if candidate_name:
|
||||
# # Filter based on the name of the candidate associated with the meeting's interview
|
||||
# queryset = queryset.filter(
|
||||
# Q(interview__candidate__first_name__icontains=candidate_name) |
|
||||
# Q(interview__candidate__last_name__icontains=candidate_name)
|
||||
# )
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView):
|
||||
model = ZoomMeeting
|
||||
template_name = "meetings/meeting_details.html"
|
||||
context_object_name = "meeting"
|
||||
def get_context_data(self, **kwargs):
|
||||
context=super().get_context_data(**kwargs)
|
||||
meeting = self.object
|
||||
try:
|
||||
interview=meeting.interview
|
||||
except Exception as e:
|
||||
print(e)
|
||||
candidate = interview.candidate
|
||||
job=meeting.get_job
|
||||
|
||||
# Assuming interview.participants and interview.system_users hold the people:
|
||||
participants = list(interview.participants.all()) + list(interview.system_users.all())
|
||||
external_participants=list(interview.participants.all())
|
||||
system_participants= list(interview.system_users.all())
|
||||
total_participants=len(participants)
|
||||
form = InterviewParticpantsForm(instance=interview)
|
||||
context['form']=form
|
||||
context['email_form'] = InterviewEmailForm(
|
||||
candidate=candidate,
|
||||
external_participants=external_participants,
|
||||
system_participants=system_participants,
|
||||
meeting=meeting,
|
||||
job=job
|
||||
)
|
||||
context['total_participants']=total_participants
|
||||
return context
|
||||
|
||||
|
||||
|
||||
class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView):
|
||||
@ -340,10 +406,13 @@ def edit_job(request, slug):
|
||||
|
||||
SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
|
||||
HIGH_POTENTIAL_THRESHOLD=75
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
@login_required
|
||||
def job_detail(request, slug):
|
||||
"""View details of a specific job"""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
current_site=get_current_site(request)
|
||||
print(current_site)
|
||||
# Get all candidates for this job, ordered by most recent
|
||||
applicants = job.candidates.all().order_by("-created_at")
|
||||
|
||||
@ -494,6 +563,60 @@ def job_detail(request, slug):
|
||||
}
|
||||
return render(request, "jobs/job_detail.html", context)
|
||||
|
||||
|
||||
|
||||
ALLOWED_EXTENSIONS = ('.pdf', '.docx')
|
||||
|
||||
def job_cvs_download(request,slug):
|
||||
|
||||
job = get_object_or_404(JobPosting,slug=slug)
|
||||
entries=Candidate.objects.filter(job=job)
|
||||
|
||||
|
||||
# 2. Create an in-memory byte stream (BytesIO)
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
# 3. Create the ZIP archive
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
|
||||
for entry in entries:
|
||||
# Check if the file field has a file
|
||||
if not entry.resume:
|
||||
continue
|
||||
|
||||
# Get the file name and check extension (case-insensitive)
|
||||
file_name = entry.resume.name.split('/')[-1]
|
||||
file_name_lower = file_name.lower()
|
||||
|
||||
if file_name_lower.endswith(ALLOWED_EXTENSIONS):
|
||||
try:
|
||||
# Open the file object (rb is read binary)
|
||||
file_obj = entry.resume.open('rb')
|
||||
|
||||
# *** ROBUST METHOD: Read the content and write it to the ZIP ***
|
||||
file_content = file_obj.read()
|
||||
|
||||
# Write the file content directly to the ZIP archive
|
||||
zf.writestr(file_name, file_content)
|
||||
|
||||
file_obj.close()
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but continue with the rest of the files
|
||||
print(f"Error processing file {file_name}: {e}")
|
||||
continue
|
||||
|
||||
# 4. Prepare the response
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# 5. Create the HTTP response
|
||||
response = HttpResponse(zip_buffer.read(), content_type='application/zip')
|
||||
|
||||
# Set the header for the browser to download the file
|
||||
response['Content-Disposition'] = 'attachment; filename=f"all_cvs_for_{job.title}.zip"'
|
||||
|
||||
return response
|
||||
|
||||
@login_required
|
||||
def job_image_upload(request, slug):
|
||||
#only for handling the post request
|
||||
@ -550,6 +673,20 @@ def edit_linkedin_post_content(request,slug):
|
||||
|
||||
|
||||
|
||||
JOB_TYPES = [
|
||||
("FULL_TIME", "Full-time"),
|
||||
("PART_TIME", "Part-time"),
|
||||
("CONTRACT", "Contract"),
|
||||
("INTERNSHIP", "Internship"),
|
||||
("FACULTY", "Faculty"),
|
||||
("TEMPORARY", "Temporary"),
|
||||
]
|
||||
|
||||
WORKPLACE_TYPES = [
|
||||
("ON_SITE", "On-site"),
|
||||
("REMOTE", "Remote"),
|
||||
("HYBRID", "Hybrid"),
|
||||
]
|
||||
|
||||
|
||||
def kaauh_career(request):
|
||||
@ -559,13 +696,53 @@ def kaauh_career(request):
|
||||
status='ACTIVE',
|
||||
form_template__is_active=True
|
||||
)
|
||||
selected_department=request.GET.get('department','')
|
||||
department_type_keys=active_jobs.exclude(
|
||||
department__isnull=True
|
||||
).exclude(department__exact=''
|
||||
).values_list(
|
||||
'department',
|
||||
flat=True
|
||||
).distinct().order_by('department')
|
||||
|
||||
if selected_department and selected_department in department_type_keys:
|
||||
active_jobs=active_jobs.filter(department=selected_department)
|
||||
selected_workplace_type=request.GET.get('workplace_type','')
|
||||
print(selected_workplace_type)
|
||||
selected_job_type = request.GET.get('employment_type', '')
|
||||
|
||||
job_type_keys = active_jobs.values_list('job_type', flat=True).distinct()
|
||||
workplace_type_keys=active_jobs.values_list('workplace_type',flat=True).distinct()
|
||||
if selected_job_type and selected_job_type in job_type_keys:
|
||||
active_jobs=active_jobs.filter(job_type=selected_job_type)
|
||||
if selected_workplace_type and selected_workplace_type in workplace_type_keys:
|
||||
active_jobs=active_jobs.filter(workplace_type=selected_workplace_type)
|
||||
|
||||
return render(request,'jobs/career.html',{'active_jobs':active_jobs})
|
||||
JOBS_PER_PAGE=10
|
||||
paginator = Paginator(active_jobs, JOBS_PER_PAGE)
|
||||
page_number = request.GET.get('page', 1)
|
||||
|
||||
try:
|
||||
page_obj = paginator.get_page(page_number)
|
||||
except EmptyPage:
|
||||
page_obj = paginator.page(paginator.num_pages)
|
||||
|
||||
total_open_roles=active_jobs.all().count()
|
||||
|
||||
|
||||
return render(request,'applicant/career.html',{'active_jobs': page_obj.object_list,
|
||||
'job_type_keys':job_type_keys,
|
||||
'selected_job_type':selected_job_type,
|
||||
'workplace_type_keys':workplace_type_keys,
|
||||
'selected_workplace_type':selected_workplace_type,
|
||||
'selected_department':selected_department,
|
||||
'department_type_keys':department_type_keys,
|
||||
'total_open_roles': total_open_roles,'page_obj': page_obj})
|
||||
|
||||
# job detail facing the candidate:
|
||||
def application_detail(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
return render(request, "forms/application_detail.html", {"job": job})
|
||||
return render(request, "applicant/application_detail.html", {"job": job})
|
||||
|
||||
|
||||
from django_q.tasks import async_task
|
||||
@ -872,10 +1049,13 @@ def application_submit_form(request, template_slug):
|
||||
|
||||
return render(
|
||||
request,
|
||||
"forms/application_submit_form.html",
|
||||
"applicant/application_submit_form.html",
|
||||
{"template_slug": template_slug, "job_id": job_id},
|
||||
)
|
||||
|
||||
def applicant_profile(request):
|
||||
return render(request,'applicant/applicant_profile.html')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
@ -1099,6 +1279,7 @@ def _handle_get_request(request, slug, job):
|
||||
|
||||
|
||||
def _handle_preview_submission(request, slug, job):
|
||||
|
||||
"""
|
||||
Handles the initial POST request (Preview Schedule).
|
||||
Validates forms, calculates slots, saves data to session, and renders preview.
|
||||
@ -1110,6 +1291,7 @@ def _handle_preview_submission(request, slug, job):
|
||||
if form.is_valid():
|
||||
# Get the form data
|
||||
candidates = form.cleaned_data["candidates"]
|
||||
interview_type=form.cleaned_data["interview_type"]
|
||||
start_date = form.cleaned_data["start_date"]
|
||||
end_date = form.cleaned_data["end_date"]
|
||||
working_days = form.cleaned_data["working_days"]
|
||||
@ -1170,6 +1352,7 @@ def _handle_preview_submission(request, slug, job):
|
||||
|
||||
# Save the form data to session for later use
|
||||
schedule_data = {
|
||||
"interview_type":interview_type,
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": end_date.isoformat(),
|
||||
"working_days": working_days,
|
||||
@ -1190,6 +1373,7 @@ def _handle_preview_submission(request, slug, job):
|
||||
{
|
||||
"job": job,
|
||||
"schedule": preview_schedule,
|
||||
"interview_type":interview_type,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"working_days": working_days,
|
||||
@ -1233,6 +1417,7 @@ def _handle_confirm_schedule(request, slug, job):
|
||||
schedule = InterviewSchedule.objects.create(
|
||||
job=job,
|
||||
created_by=request.user,
|
||||
interview_type=schedule_data["interview_type"],
|
||||
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
|
||||
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
|
||||
working_days=schedule_data["working_days"],
|
||||
@ -1259,34 +1444,59 @@ def _handle_confirm_schedule(request, slug, job):
|
||||
available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast
|
||||
|
||||
# 4. Queue scheduled interviews asynchronously (FAST RESPONSE)
|
||||
queued_count = 0
|
||||
for i, candidate in enumerate(candidates):
|
||||
if i < len(available_slots):
|
||||
slot = available_slots[i]
|
||||
if schedule.interview_type=='Remote':
|
||||
queued_count = 0
|
||||
for i, candidate in enumerate(candidates):
|
||||
if i < len(available_slots):
|
||||
slot = available_slots[i]
|
||||
|
||||
# Dispatch the individual creation task to the background queue
|
||||
async_task(
|
||||
"recruitment.tasks.create_interview_and_meeting",
|
||||
candidate.pk,
|
||||
job.pk,
|
||||
schedule.pk,
|
||||
slot['date'],
|
||||
slot['time'],
|
||||
schedule.interview_duration,
|
||||
)
|
||||
queued_count += 1
|
||||
# Dispatch the individual creation task to the background queue
|
||||
|
||||
async_task(
|
||||
"recruitment.tasks.create_interview_and_meeting",
|
||||
candidate.pk,
|
||||
job.pk,
|
||||
schedule.pk,
|
||||
slot['date'],
|
||||
slot['time'],
|
||||
schedule.interview_duration,
|
||||
)
|
||||
queued_count += 1
|
||||
|
||||
# 5. Success and Cleanup (IMMEDIATE RESPONSE)
|
||||
messages.success(
|
||||
request,
|
||||
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!"
|
||||
)
|
||||
# 5. Success and Cleanup (IMMEDIATE RESPONSE)
|
||||
messages.success(
|
||||
request,
|
||||
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!"
|
||||
)
|
||||
|
||||
# Clear both session data keys upon successful completion
|
||||
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
|
||||
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
||||
# Clear both session data keys upon successful completion
|
||||
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
|
||||
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
||||
|
||||
return redirect("job_detail", slug=slug)
|
||||
else:
|
||||
for i, candidate in enumerate(candidates):
|
||||
if i < len(available_slots):
|
||||
slot = available_slots[i]
|
||||
ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
# zoom_meeting=None,
|
||||
schedule=schedule,
|
||||
interview_date=slot['date'],
|
||||
interview_time= slot['time']
|
||||
)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Onsite schedule Interview Create succesfully"
|
||||
)
|
||||
|
||||
# Clear both session data keys upon successful completion
|
||||
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
|
||||
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
||||
return redirect('schedule_interview_location_form',slug=schedule.slug)
|
||||
|
||||
return redirect("job_detail", slug=slug)
|
||||
|
||||
def schedule_interviews_view(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
@ -1295,6 +1505,7 @@ def schedule_interviews_view(request, slug):
|
||||
return _handle_preview_submission(request, slug, job)
|
||||
else:
|
||||
return _handle_get_request(request, slug, job)
|
||||
|
||||
def confirm_schedule_interviews_view(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
if request.method == "POST":
|
||||
@ -1454,40 +1665,12 @@ def candidate_update_status(request, slug):
|
||||
def candidate_interview_view(request,slug):
|
||||
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:
|
||||
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,
|
||||
'participants_count': job.participants.count() + job.users.count(),
|
||||
|
||||
}
|
||||
return render(request,"recruitment/candidate_interview_view.html",context)
|
||||
|
||||
@ -3257,6 +3440,8 @@ def agency_portal_submit_candidate_page(request, slug):
|
||||
slug=slug
|
||||
)
|
||||
|
||||
|
||||
|
||||
if assignment.is_full:
|
||||
messages.error(request, 'Maximum candidate limit reached for this assignment.')
|
||||
return redirect('agency_portal_assignment_detail', slug=assignment.slug)
|
||||
@ -3315,6 +3500,7 @@ def agency_portal_submit_candidate_page(request, slug):
|
||||
'form': form,
|
||||
'assignment': assignment,
|
||||
'total_submitted': total_submitted,
|
||||
'job':assignment.job
|
||||
}
|
||||
return render(request, 'recruitment/agency_portal_submit_candidate.html', context)
|
||||
|
||||
@ -3693,108 +3879,77 @@ def api_candidate_detail(request, candidate_id):
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
def compose_candidate_email(request, job_slug, candidate_slug):
|
||||
def compose_candidate_email(request, job_slug):
|
||||
"""Compose email to participants about a candidate"""
|
||||
from .email_service import send_bulk_email
|
||||
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(Candidate, slug=candidate_slug, job=job)
|
||||
candidate_ids=request.GET.getlist('candidate_ids')
|
||||
candidates=Candidate.objects.filter(id__in=candidate_ids)
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
form = CandidateEmailForm(job, candidate, request.POST)
|
||||
print("........................................................inside candidate conpose.............")
|
||||
candidate_ids = request.POST.getlist('candidate_ids')
|
||||
candidates=Candidate.objects.filter(id__in=candidate_ids)
|
||||
form = CandidateEmailForm(job, candidates, request.POST)
|
||||
if form.is_valid():
|
||||
print("form is valid ...")
|
||||
# Get email addresses
|
||||
email_addresses = form.get_email_addresses()
|
||||
print(email_addresses)
|
||||
|
||||
|
||||
if not email_addresses:
|
||||
messages.error(request, 'No valid email addresses found for selected recipients.')
|
||||
return render(request, 'includes/email_compose_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate
|
||||
})
|
||||
messages.error(request, 'No email selected')
|
||||
referer = request.META.get('HTTP_REFERER')
|
||||
|
||||
if referer:
|
||||
# Redirect back to the referring page
|
||||
return redirect(referer)
|
||||
else:
|
||||
|
||||
return redirect('dashboard')
|
||||
|
||||
# Check if this is an interview invitation
|
||||
subject = form.cleaned_data.get('subject', '').lower()
|
||||
is_interview_invitation = 'interview' in subject or 'meeting' in subject
|
||||
|
||||
if is_interview_invitation:
|
||||
# Use HTML template for interview invitations
|
||||
meeting_details = None
|
||||
if form.cleaned_data.get('include_meeting_details'):
|
||||
# Try to get meeting details from candidate
|
||||
meeting_details = {
|
||||
'topic': f'Interview for {job.title}',
|
||||
'date_time': getattr(candidate, 'interview_date', 'To be scheduled'),
|
||||
'duration': '60 minutes',
|
||||
'join_url': getattr(candidate, 'meeting_url', ''),
|
||||
}
|
||||
message = form.get_formatted_message()
|
||||
subject = form.cleaned_data.get('subject')
|
||||
|
||||
from .email_service import send_interview_invitation_email
|
||||
email_result = send_interview_invitation_email(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
meeting_details=meeting_details,
|
||||
recipient_list=email_addresses
|
||||
)
|
||||
else:
|
||||
# Get formatted message for regular emails
|
||||
message = form.get_formatted_message()
|
||||
subject = form.cleaned_data.get('subject')
|
||||
|
||||
# Send emails using email service (no attachments, synchronous to avoid pickle issues)
|
||||
email_result = send_bulk_email(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipient_list=email_addresses,
|
||||
request=request,
|
||||
async_task_=False # Changed to False to avoid pickle issues
|
||||
)
|
||||
# Send emails using email service (no attachments, synchronous to avoid pickle issues)
|
||||
|
||||
email_result = send_bulk_email(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipient_list=email_addresses,
|
||||
request=request,
|
||||
attachments=None,
|
||||
async_task_=True, # Changed to False to avoid pickle issues
|
||||
from_interview=False
|
||||
)
|
||||
|
||||
if email_result['success']:
|
||||
messages.success(request, f'Email sent successfully to {len(email_addresses)} recipient(s).')
|
||||
|
||||
# For HTMX requests, return success response
|
||||
if 'HX-Request' in request.headers:
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'Email sent successfully to {len(email_addresses)} recipient(s).'
|
||||
})
|
||||
|
||||
|
||||
return redirect('candidate_interview_view', slug=job.slug)
|
||||
else:
|
||||
messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}')
|
||||
|
||||
# For HTMX requests, return error response
|
||||
if 'HX-Request' in request.headers:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': email_result.get("message", "Failed to send email")
|
||||
})
|
||||
|
||||
|
||||
return render(request, 'includes/email_compose_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate
|
||||
'candidate': candidates
|
||||
})
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error sending candidate email: {e}")
|
||||
# messages.error(request, f'An error occurred while sending the email: {str(e)}')
|
||||
|
||||
# # For HTMX requests, return error response
|
||||
# if 'HX-Request' in request.headers:
|
||||
# return JsonResponse({
|
||||
# 'success': False,
|
||||
# 'error': f'An error occurred while sending the email: {str(e)}'
|
||||
# })
|
||||
|
||||
# return render(request, 'includes/email_compose_form.html', {
|
||||
# 'form': form,
|
||||
# 'job': job,
|
||||
# 'candidate': candidate
|
||||
# })
|
||||
|
||||
else:
|
||||
# Form validation errors
|
||||
print('form is not valid')
|
||||
print(form.errors)
|
||||
messages.error(request, 'Please correct the errors below.')
|
||||
|
||||
@ -3804,21 +3959,26 @@ def compose_candidate_email(request, job_slug, candidate_slug):
|
||||
'success': False,
|
||||
'error': 'Please correct the form errors and try again.'
|
||||
})
|
||||
|
||||
|
||||
return render(request, 'includes/email_compose_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate
|
||||
'candidates': candidates
|
||||
})
|
||||
|
||||
else:
|
||||
else:
|
||||
|
||||
# GET request - show the form
|
||||
form = CandidateEmailForm(job, candidate)
|
||||
form = CandidateEmailForm(job, candidates)
|
||||
|
||||
print("GET request made for candidate email form")
|
||||
|
||||
return render(request, 'includes/email_compose_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate
|
||||
'candidates':candidates
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
@ -3987,3 +4147,118 @@ def source_toggle_status(request, slug):
|
||||
|
||||
# For GET requests, return error
|
||||
return JsonResponse({'success': False, 'error': 'Method not allowed'})
|
||||
|
||||
|
||||
|
||||
from .forms import InterviewParticpantsForm
|
||||
|
||||
def create_interview_participants(request,slug):
|
||||
schedule_interview=get_object_or_404(ScheduledInterview,slug=slug)
|
||||
interview_slug=schedule_interview.zoom_meeting.slug
|
||||
if request.method == 'POST':
|
||||
form = InterviewParticpantsForm(request.POST,instance=schedule_interview)
|
||||
if form.is_valid():
|
||||
# Save the main Candidate object, but don't commit to DB yet
|
||||
candidate = form.save(commit=False)
|
||||
candidate.save()
|
||||
# This is important for ManyToMany fields: save the many-to-many data
|
||||
form.save_m2m()
|
||||
return redirect('meeting_details',slug=interview_slug) # Redirect to a success page
|
||||
else:
|
||||
form = InterviewParticpantsForm(instance=schedule_interview)
|
||||
|
||||
return render(request, 'interviews/interview_participants_form.html', {'form': form})
|
||||
|
||||
|
||||
from django.core.mail import send_mail
|
||||
def send_interview_email(request, slug):
|
||||
from .email_service import send_bulk_email
|
||||
|
||||
interview = get_object_or_404(ScheduledInterview, slug=slug)
|
||||
|
||||
# 2. Retrieve the required data for the form's constructor
|
||||
candidate = interview.candidate
|
||||
job=interview.job
|
||||
meeting=interview.zoom_meeting
|
||||
participants = list(interview.participants.all()) + list(interview.system_users.all())
|
||||
external_participants=list(interview.participants.all())
|
||||
system_participants=list(interview.system_users.all())
|
||||
|
||||
participant_emails = [p.email for p in participants if hasattr(p, 'email')]
|
||||
print(participant_emails)
|
||||
total_recipients=1+len(participant_emails)
|
||||
|
||||
# --- POST REQUEST HANDLING ---
|
||||
if request.method == 'POST':
|
||||
|
||||
form = InterviewEmailForm(
|
||||
request.POST,
|
||||
candidate=candidate,
|
||||
external_participants=external_participants,
|
||||
system_participants=system_participants,
|
||||
meeting=meeting,
|
||||
job=job
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
# 4. Extract cleaned data
|
||||
subject = form.cleaned_data['subject']
|
||||
msg_candidate = form.cleaned_data['message_for_candidate']
|
||||
msg_agency = form.cleaned_data['message_for_agency']
|
||||
msg_participants = form.cleaned_data['message_for_participants']
|
||||
|
||||
# --- SEND EMAILS Candidate or agency---
|
||||
if candidate.belong_to_an_agency:
|
||||
send_mail(
|
||||
subject,
|
||||
msg_agency,
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[candidate.hiring_agency.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
else:
|
||||
send_mail(
|
||||
subject,
|
||||
msg_candidate,
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[candidate.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
email_result = send_bulk_email(
|
||||
subject=subject,
|
||||
message=msg_participants,
|
||||
recipient_list=participant_emails,
|
||||
request=request,
|
||||
attachments=None,
|
||||
async_task_=True, # Changed to False to avoid pickle issues,
|
||||
from_interview=True
|
||||
)
|
||||
|
||||
if email_result['success']:
|
||||
messages.success(request, f'Email sent successfully to {total_recipients} recipient(s).')
|
||||
|
||||
return redirect('list_meetings')
|
||||
else:
|
||||
messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}')
|
||||
return redirect('list_meetings')
|
||||
|
||||
|
||||
|
||||
# def schedule_interview_location_form(request,slug):
|
||||
# schedule=get_object_or_404(InterviewSchedule,slug=slug)
|
||||
# if request.method=='POST':
|
||||
# form=InterviewScheduleLocationForm(request.POST,instance=schedule)
|
||||
# form.save()
|
||||
# return redirect('list_meetings')
|
||||
# else:
|
||||
# form=InterviewScheduleLocationForm(instance=schedule)
|
||||
# return render(request,'interviews/schedule_interview_location_form.html',{'form':form,'schedule':schedule})
|
||||
|
||||
|
||||
|
||||
def onsite_interview_list_view(request):
|
||||
onsite_interviews=ScheduledInterview.objects.filter(schedule__interview_type='Onsite')
|
||||
return render(request,'interviews/onsite_interview_list.html',{'onsite_interviews':onsite_interviews})
|
||||
|
||||
|
||||
@ -461,9 +461,9 @@ def dashboard_view(request):
|
||||
hired_candidates = candidate_queryset.filter(
|
||||
stage='Hired'
|
||||
)
|
||||
print(hired_candidates)
|
||||
|
||||
lst=[c.time_to_hire_days for c in hired_candidates]
|
||||
print(lst)
|
||||
|
||||
time_to_hire_query = hired_candidates.annotate(
|
||||
time_diff=ExpressionWrapper(
|
||||
F('hired_date') - F('created_at__date'),
|
||||
@ -471,14 +471,13 @@ def dashboard_view(request):
|
||||
)
|
||||
).aggregate(avg_time_to_hire=Avg('time_diff'))
|
||||
|
||||
print(time_to_hire_query)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
print(avg_time_to_hire_days)
|
||||
|
||||
|
||||
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
|
||||
|
||||
422
templates/applicant/applicant_profile.html
Normal file
422
templates/applicant/applicant_profile.html
Normal file
@ -0,0 +1,422 @@
|
||||
{% extends 'applicant/partials/candidate_facing_base.html'%}
|
||||
{% load static i18n %}
|
||||
|
||||
|
||||
{% block title %}{% trans "My Dashboard" %} - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* THEME VARIABLES (Refined for Premium Look) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-teal-accent: #008c9c; /* A slightly brighter, more vibrant teal for key links */
|
||||
--kaauh-teal-light: #e6f7f8;
|
||||
--kaauh-bg-subtle: #f9fbfb; /* Lighter, cleaner background */
|
||||
--kaauh-border: #e0e5eb; /* Very subtle border color */
|
||||
--kaauh-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); /* Lightest shadow */
|
||||
--kaauh-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.08); /* Modern, lifted shadow */
|
||||
--gray-text: #5e6c84; /* Deeper, more readable gray */
|
||||
--success-subtle: #d4edda;
|
||||
--danger-subtle: #f8d7da;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--kaauh-bg-subtle);
|
||||
}
|
||||
|
||||
.text-primary-theme { color: var(--kaauh-teal-accent) !important; }
|
||||
.text-gray-subtle { color: var(--gray-text) !important; }
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* 1. PRIMARY BUTTONS & ACTIONS */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 99, 110, 0.4);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(0, 99, 110, 0.5);
|
||||
}
|
||||
.btn-outline-secondary {
|
||||
border-color: var(--kaauh-border);
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* 2. CARDS & CONTAINERS (Increased Padding & Smoother Shadow) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.kaauh-card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 12px; /* Smoother corner radius */
|
||||
box-shadow: var(--kaauh-shadow-sm);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.kaauh-card:hover {
|
||||
box-shadow: var(--kaauh-shadow-lg); /* Subtle lift on hover */
|
||||
}
|
||||
|
||||
.profile-data-list li {
|
||||
padding: 1rem 0; /* More vertical space */
|
||||
border-bottom: 1px dashed var(--kaauh-border);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.profile-data-list li strong {
|
||||
font-weight: 700;
|
||||
color: var(--kaauh-teal-dark);
|
||||
min-width: 120px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* 3. TABS (Minimalist & Clean) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid var(--kaauh-border); /* Thinner border */
|
||||
}
|
||||
.nav-tabs .nav-link {
|
||||
color: var(--gray-text); /* Use subdued text color for inactive tabs */
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent; /* Thinner accent line */
|
||||
padding: 1rem 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-light);
|
||||
}
|
||||
.nav-tabs .nav-link.active {
|
||||
color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
background-color: transparent;
|
||||
}
|
||||
.nav-tabs .nav-link i {
|
||||
color: var(--kaauh-teal-accent) !important;
|
||||
}
|
||||
.nav-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-scroll .nav-tabs { flex-wrap: nowrap; border-bottom: none; }
|
||||
.nav-scroll .nav-tabs .nav-item { flex-shrink: 0; }
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* 4. APPLICATION TABLE (Refined Aesthetics) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.application-table thead th {
|
||||
background-color: var(--kaauh-teal-light); /* Light, subtle header */
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
.application-table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.application-table tbody tr:hover {
|
||||
background-color: var(--kaauh-teal-light);
|
||||
}
|
||||
.badge-stage {
|
||||
font-weight: 600;
|
||||
padding: 0.4em 0.8em;
|
||||
border-radius: 50rem;
|
||||
}
|
||||
.bg-success { background-color: #38a169 !important; }
|
||||
.bg-warning { background-color: #f6ad55 !important; }
|
||||
|
||||
/* Responsive Table for Mobile (High contrast labels) */
|
||||
@media (max-width: 767.98px) {
|
||||
.application-table thead { display: none; }
|
||||
.application-table tr {
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--kaauh-shadow-sm);
|
||||
}
|
||||
.application-table td {
|
||||
text-align: right !important;
|
||||
padding: 0.75rem 1rem;
|
||||
padding-left: 50%;
|
||||
position: relative;
|
||||
}
|
||||
.application-table td::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
width: 45%;
|
||||
font-weight: 700;
|
||||
color: var(--gray-text); /* Use muted gray for labels */
|
||||
}
|
||||
}
|
||||
|
||||
/* Document Management List */
|
||||
.list-group-item {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.list-group-item:hover {
|
||||
background-color: var(--kaauh-teal-light);
|
||||
border-color: var(--kaauh-teal-accent);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4 py-md-5">
|
||||
|
||||
{# Header: Larger, more dynamic on large screens. Stacks cleanly on mobile. #}
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5">
|
||||
<h1 class="display-6 display-md-5 fw-extrabold mb-3 mb-md-0" style="color: var(--kaauh-teal-dark);">
|
||||
{% trans "Your Candidate Dashboard" %}
|
||||
</h1>
|
||||
<a href="#profile-details" data-bs-toggle="tab" class="btn btn-main-action btn-sm btn-md-lg px-4 py-2 rounded-pill shadow-sm shadow-md-lg">
|
||||
<i class="fas fa-edit me-2"></i> {% trans "Update Profile" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Candidate Quick Overview Card: Use a softer background color #}
|
||||
<div class="card kaauh-card mb-5 p-4 bg-white">
|
||||
<div class="d-flex align-items-center flex-column flex-sm-row text-center text-sm-start">
|
||||
<img src="{% if candidate.profile_picture %}{{ candidate.profile_picture.url }}{% else %}{% static 'image/default_avatar.png' %}{% endif %}"
|
||||
alt="{% trans 'Profile Picture' %}"
|
||||
class="rounded-circle me-sm-4 mb-3 mb-sm-0 shadow-lg"
|
||||
style="width: 80px; height: 80px; object-fit: cover; border: 4px solid var(--kaauh-teal-accent);">
|
||||
<div>
|
||||
<h3 class="card-title mb-1 fw-bold text-dark">{{ candidate.name|default:"Candidate Name" }}</h3>
|
||||
<p class="text-gray-subtle mb-0">{{ candidate.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================= #}
|
||||
{# MAIN TABBED INTERFACE #}
|
||||
{# ================================================= #}
|
||||
<div class="card kaauh-card p-0 bg-white">
|
||||
|
||||
{# Tab Navigation: Used nav-scroll for responsiveness #}
|
||||
<div class="nav-scroll px-4 pt-3">
|
||||
<ul class="nav nav-tabs" id="candidateTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="profile-tab" data-bs-toggle="tab" data-bs-target="#profile-details" type="button" role="tab" aria-controls="profile-details" aria-selected="true">
|
||||
<i class="fas fa-user-circle me-2"></i> {% trans "Profile Details" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="applications-tab" data-bs-toggle="tab" data-bs-target="#applications-history" type="button" role="tab" aria-controls="applications-history" aria-selected="false">
|
||||
<i class="fas fa-list-alt me-2"></i> {% trans "My Applications" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#document-management" type="button" role="tab" aria-controls="document-management" aria-selected="false">
|
||||
<i class="fas fa-file-upload me-2"></i> {% trans "Documents" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="settings-tab" data-bs-toggle="tab" data-bs-target="#account-settings" type="button" role="tab" aria-controls="account-settings" aria-selected="false">
|
||||
<i class="fas fa-cogs me-2"></i> {% trans "Settings" %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Tab Content #}
|
||||
<div class="tab-content p-4 p-md-5" id="candidateTabsContent">
|
||||
|
||||
<div class="tab-pane fade show active" id="profile-details" role="tabpanel" aria-labelledby="profile-tab">
|
||||
<h4 class="mb-4 fw-bold text-gray-subtle">{% trans "Personal Information" %}</h4>
|
||||
<ul class="list-unstyled profile-data-list p-0">
|
||||
<li class="d-flex justify-content-between align-items-center">
|
||||
<div><i class="fas fa-phone-alt me-2 text-primary-theme"></i> <strong>{% trans "Phone" %}</strong></div>
|
||||
<span class="text-end">{{ candidate.phone|default:"N/A" }}</span>
|
||||
</li>
|
||||
<li class="d-flex justify-content-between align-items-center">
|
||||
<div><i class="fas fa-globe me-2 text-primary-theme"></i> <strong>{% trans "Nationality" %}</strong></div>
|
||||
<span class="text-end">{{ candidate.get_nationality_display|default:"N/A" }}</span>
|
||||
</li>
|
||||
<li class="d-flex justify-content-between align-items-center">
|
||||
<div><i class="fas fa-calendar-alt me-2 text-primary-theme"></i> <strong>{% trans "Date of Birth" %}</strong></div>
|
||||
<span class="text-end">{{ candidate.date_of_birth|date:"M d, Y"|default:"N/A" }}</span>
|
||||
</li>
|
||||
<li class="small pt-3 text-muted border-bottom-0">{% trans "Use the 'Update Profile' button above to edit these details." %}</li>
|
||||
</ul>
|
||||
|
||||
<hr class="my-5">
|
||||
|
||||
<h4 class="mb-4 fw-bold text-gray-subtle">{% trans "Quick Actions" %}</h4>
|
||||
<div class="row g-3 g-md-4">
|
||||
<div class="col-6 col-sm-4 col-md-4">
|
||||
<a href="#applications-history" data-bs-toggle="tab" class="btn btn-action-tile w-100 d-grid text-center text-dark text-decoration-none">
|
||||
<span class="action-tile-icon mb-2"><i class="fas fa-list-check"></i></span>
|
||||
<span class="fw-bold">{% trans "Track Jobs" %}</span>
|
||||
<span class="small text-muted d-none d-sm-block">{% trans "View stages" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-4">
|
||||
<a href="#document-management" data-bs-toggle="tab" class="btn btn-action-tile w-100 d-grid text-center text-dark text-decoration-none">
|
||||
<span class="action-tile-icon mb-2"><i class="fas fa-cloud-upload-alt"></i></span>
|
||||
<span class="fw-bold">{% trans "Manage Documents" %}</span>
|
||||
<span class="small text-muted d-none d-sm-block">{% trans "Upload/View files" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-4 col-md-4">
|
||||
<a href="{% url 'kaauh_career' %}" class="btn btn-action-tile w-100 d-grid text-center text-dark text-decoration-none">
|
||||
<span class="action-tile-icon mb-2"><i class="fas fa-search"></i></span>
|
||||
<span class="fw-bold">{% trans "Find New Careers" %}</span>
|
||||
<span class="small text-muted d-none d-sm-block">{% trans "Explore open roles" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="applications-history" role="tabpanel" aria-labelledby="applications-tab">
|
||||
<h4 class="mb-4 fw-bold text-gray-subtle">{% trans "Application Tracking" %}</h4>
|
||||
|
||||
{% if applications %}
|
||||
<div class="kaauh-card shadow-lg p-0">
|
||||
<table class="table table-borderless align-middle mb-0 application-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="min-width: 250px;">{% trans "Job Title" %}</th>
|
||||
<th scope="col">{% trans "Applied On" %}</th>
|
||||
<th scope="col">{% trans "Current Stage" %}</th>
|
||||
<th scope="col">{% trans "Status" %}</th>
|
||||
<th scope="col" class="text-end" style="min-width: 140px;">{% trans "Action" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for application in applications %}
|
||||
<tr>
|
||||
<td class="fw-medium" data-label="{% trans 'Job Title' %}">
|
||||
<a href="{% url 'job_detail' application.job.slug %}" class="text-decoration-none text-primary-theme">
|
||||
{{ application.job.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td data-label="{% trans 'Applied On' %}" class="text-gray-subtle">{{ application.applied_date|date:"d M Y" }}</td>
|
||||
<td data-label="{% trans 'Current Stage' %}">
|
||||
<span class="badge badge-stage bg-info text-white">
|
||||
{{ application.stage }}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="{% trans 'Status' %}">
|
||||
{% if application.is_active %}
|
||||
<span class="badge badge-stage bg-success">{% trans "Active" %}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-stage bg-warning text-dark">{% trans "Closed" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end" data-label="{% trans 'Action' %}">
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm rounded-pill px-3">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "Details" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center p-5 rounded-3" style="border: 1px dashed var(--kaauh-border); background-color: var(--kaauh-teal-light);">
|
||||
<i class="fas fa-info-circle fa-2x mb-3 text-primary-theme"></i>
|
||||
<h5 class="mb-3 fw-bold text-primary-theme">{% trans "You haven't submitted any applications yet." %}</h5>
|
||||
<a href="{% url 'kaauh_career' %}" class="ms-3 btn btn-main-action mt-2 rounded-pill px-4">
|
||||
{% trans "View Available Jobs" %} <i class="fas fa-arrow-right ms-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="document-management" role="tabpanel" aria-labelledby="documents-tab">
|
||||
<h4 class="mb-4 fw-bold text-gray-subtle">{% trans "My Uploaded Documents" %}</h4>
|
||||
|
||||
<p class="text-gray-subtle">{% trans "You can upload and manage your resume, certificates, and professional documents here. These documents will be attached to your applications." %}</p>
|
||||
|
||||
<a href="#" class="btn btn-main-action rounded-pill px-4 me-3 d-block d-sm-inline-block w-100 w-sm-auto mb-4">
|
||||
<i class="fas fa-cloud-upload-alt me-2"></i> {% trans "Upload New Document" %}
|
||||
</a>
|
||||
|
||||
<hr class="my-5">
|
||||
|
||||
{# Example Document List (Refined structure) #}
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center bg-white p-3">
|
||||
<div class="mb-2 mb-sm-0 fw-medium">
|
||||
<i class="fas fa-file-pdf me-2 text-primary-theme"></i> **{% trans "Resume" %}** <span class="text-muted small">(CV\_John\_Doe\_2024.pdf)</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted small me-3">{% trans "Uploaded: 10 Jan 2024" %}</span>
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary me-2"><i class="fas fa-eye"></i></a>
|
||||
<a href="#" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash-alt"></i></a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center bg-white p-3">
|
||||
<div class="mb-2 mb-sm-0 fw-medium">
|
||||
<i class="fas fa-file-alt me-2 text-primary-theme"></i> **{% trans "Medical Certificate" %}** <span class="text-muted small">(Cert\_KSA\_MED.jpg)</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted small me-3">{% trans "Uploaded: 22 Feb 2023" %}</span>
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary me-2"><i class="fas fa-eye"></i></a>
|
||||
<a href="#" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash-alt"></i></a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="account-settings" role="tabpanel" aria-labelledby="settings-tab">
|
||||
<h4 class="mb-4 fw-bold text-gray-subtle">{% trans "Security & Preferences" %}</h4>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card kaauh-card p-4 h-100 bg-white">
|
||||
<h5 class="fw-bold"><i class="fas fa-key me-2 text-primary-theme"></i> {% trans "Password Security" %}</h5>
|
||||
<p class="text-muted small">{% trans "Update your password regularly to keep your account secure." %}</p>
|
||||
<a href="#" class="btn btn-outline-secondary mt-auto w-100 py-2 fw-medium">
|
||||
{% trans "Change Password" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card kaauh-card p-4 h-100 bg-white">
|
||||
<h5 class="fw-bold"><i class="fas fa-envelope me-2 text-primary-theme"></i> {% trans "Email Preferences" %}</h5>
|
||||
<p class="text-muted small">{% trans "Manage subscriptions and job alert settings." %}</p>
|
||||
<a href="#" class="btn btn-outline-secondary mt-auto w-100 py-2 fw-medium">
|
||||
{% trans "Manage Alerts" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert mt-5 py-3" style="background-color: var(--danger-subtle); color: #842029; border: 1px solid #f5c2c7; border-radius: 8px;">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i> {% trans "To delete your profile, please contact HR support." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{# ================================================= #}
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
216
templates/applicant/application_detail.html
Normal file
216
templates/applicant/application_detail.html
Normal file
@ -0,0 +1,216 @@
|
||||
{% extends 'applicant/partials/candidate_facing_base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# ------------------------------------------------ #}
|
||||
{# 🚀 TOP NAV BAR (Sticky and Themed) #}
|
||||
{# ------------------------------------------------ #}
|
||||
<nav id="bottomNavbar" class="navbar navbar-expand-lg sticky-top border-bottom"
|
||||
style="background-color: var(--kaauh-teal); z-index: 1030; height: 50px;">
|
||||
<div class="container-fluid container-lg">
|
||||
<span class="navbar-text text-white fw-bold fs-6">{% trans "Job Overview" %}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{# ------------------------------------------------ #}
|
||||
{# 🔔 DJANGO MESSAGES (Refined placement and styling) #}
|
||||
{# ------------------------------------------------ #}
|
||||
|
||||
|
||||
{# ------------------------------------------------ #}
|
||||
{# 💻 MAIN CONTENT CONTAINER #}
|
||||
{# ------------------------------------------------ #}
|
||||
<div class="container mt-4 mb-5">
|
||||
<div class="row g-4 main-content-area">
|
||||
|
||||
{# 📌 RIGHT COLUMN: Sticky Apply Card (Desktop Only) #}
|
||||
<div class="col-lg-4 order-lg-2 d-none d-lg-block">
|
||||
<div class="card shadow-lg border-0" style="position: sticky; top: 70px;">
|
||||
<div class="card-header bg-white border-bottom p-3">
|
||||
<h5 class="mb-0 fw-bold text-kaauh-teal">
|
||||
<i class="fas fa-file-signature me-2"></i>{% trans "Ready to Apply?" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body text-center p-4">
|
||||
<p class="text-muted small mb-3">{% trans "Review the full job details below before submitting your application." %}</p>
|
||||
|
||||
{% if job.form_template %}
|
||||
<a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-main-action btn-lg w-100 shadow-sm">
|
||||
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
|
||||
</a>
|
||||
{% elif not job.is_expired %}
|
||||
<p class="text-danger fw-bold">{% trans "Application form is unavailable." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 📝 LEFT COLUMN: Job Details #}
|
||||
<div class="col-lg-8 order-lg-1">
|
||||
<article class="card shadow-lg border-0">
|
||||
|
||||
{# Job Title Header #}
|
||||
<header class="card-header bg-white border-bottom p-4">
|
||||
<h1 class="h2 mb-0 fw-bolder text-kaauh-teal">{{ job.title }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="card-body p-4">
|
||||
|
||||
<h4 class="mb-4 fw-bold text-muted border-bottom pb-2">{% trans "Summary" %}</h4>
|
||||
|
||||
{# Job Metadata/Overview Grid #}
|
||||
<section class="row row-cols-1 row-cols-md-2 g-3 mb-5 small text-secondary p-3 rounded bg-light-subtle border">
|
||||
|
||||
{# SALARY #}
|
||||
{% if job.salary_range %}
|
||||
<div class="col">
|
||||
<i class="fas fa-money-bill-wave text-success me-2 fa-fw"></i>
|
||||
<strong>{% trans "Salary:" %}</strong>
|
||||
<span class="fw-bold text-success">{{ job.salary_range }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# DEADLINE #}
|
||||
<div class="col">
|
||||
<i class="fas fa-calendar-alt text-muted me-2 fa-fw"></i>
|
||||
<strong>{% trans "Deadline:" %}</strong>
|
||||
{% if job.application_deadline %}
|
||||
<time datetime="{{ job.application_deadline|date:'Y-m-d' }}">
|
||||
{{ job.application_deadline|date:"M d, Y" }}
|
||||
</time>
|
||||
{% if job.is_expired %}
|
||||
<span class="badge bg-danger ms-2">{% trans "EXPIRED" %}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "Ongoing" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# JOB TYPE #}
|
||||
<div class="col">
|
||||
<i class="fas fa-briefcase text-muted me-2 fa-fw"></i>
|
||||
<strong>{% trans "Job Type:" %}</strong> {{ job.get_job_type_display }}
|
||||
</div>
|
||||
|
||||
{# LOCATION #}
|
||||
<div class="col">
|
||||
<i class="fas fa-map-marker-alt text-muted me-2 fa-fw"></i>
|
||||
<strong>{% trans "Location:" %}</strong> {{ job.get_location_display }}
|
||||
</div>
|
||||
|
||||
{# DEPARTMENT #}
|
||||
<div class="col">
|
||||
<i class="fas fa-building text-muted me-2 fa-fw"></i>
|
||||
<strong>{% trans "Department:" %}</strong> {{ job.department|default:"N/A" }}
|
||||
</div>
|
||||
|
||||
{# JOB ID #}
|
||||
<div class="col">
|
||||
<i class="fas fa-hashtag text-muted me-2 fa-fw"></i>
|
||||
<strong>{% trans "JOB ID:" %}</strong> {{ job.internal_job_id|default:"N/A" }}
|
||||
</div>
|
||||
|
||||
{# WORKPLACE TYPE #}
|
||||
<div class="col">
|
||||
<i class="fas fa-laptop-house text-muted me-2 fa-fw"></i>
|
||||
<strong>{% trans "Workplace:" %}</strong> {{ job.get_workplace_type_display }}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{# Detailed Accordion Section #}
|
||||
<div class="accordion accordion-flush" id="jobDetailAccordion">
|
||||
|
||||
{% with active_collapse="collapseOne" %}
|
||||
|
||||
{# JOB DESCRIPTION #}
|
||||
{% if job.has_description_content %}
|
||||
<div class="accordion-item border-top border-bottom">
|
||||
<h2 class="accordion-header" id="headingOne">
|
||||
<button class="accordion-button fw-bold fs-5 text-kaauh-teal-dark" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#{{ active_collapse }}" aria-expanded="true"
|
||||
aria-controls="{{ active_collapse }}">
|
||||
<i class="fas fa-info-circle me-3 fa-fw"></i> {% trans "Job Description" %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="{{ active_collapse }}" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#jobDetailAccordion">
|
||||
<div class="accordion-body text-secondary p-4">
|
||||
<div class="wysiwyg-content">{{ job.description|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# QUALIFICATIONS #}
|
||||
{% if job.has_qualifications_content %}
|
||||
<div class="accordion-item border-bottom">
|
||||
<h2 class="accordion-header" id="headingTwo">
|
||||
<button class="accordion-button collapsed fw-bold fs-5 text-kaauh-teal-dark" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
|
||||
<i class="fas fa-graduation-cap me-3 fa-fw"></i> {% trans "Qualifications" %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo" data-bs-parent="#jobDetailAccordion">
|
||||
<div class="accordion-body text-secondary p-4">
|
||||
<div class="wysiwyg-content">{{ job.qualifications|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# BENEFITS #}
|
||||
{% if job.has_benefits_content %}
|
||||
<div class="accordion-item border-bottom">
|
||||
<h2 class="accordion-header" id="headingThree">
|
||||
<button class="accordion-button collapsed fw-bold fs-5 text-kaauh-teal-dark" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
|
||||
<i class="fas fa-hand-holding-usd me-3 fa-fw"></i> {% trans "Benefits" %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree" data-bs-parent="#jobDetailAccordion">
|
||||
<div class="accordion-body text-secondary p-4">
|
||||
<div class="wysiwyg-content">{{ job.benefits|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# APPLICATION INSTRUCTIONS #}
|
||||
{% if job.has_application_instructions_content %}
|
||||
<div class="accordion-item border-bottom">
|
||||
<h2 class="accordion-header" id="headingFour">
|
||||
<button class="accordion-button collapsed fw-bold fs-5 text-kaauh-teal-dark" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#collapseFour" aria-expanded="false" aria-controls="collapseFour">
|
||||
<i class="fas fa-file-alt me-3 fa-fw"></i> {% trans "Application Instructions" %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseFour" class="accordion-collapse collapse" aria-labelledby="headingFour" data-bs-parent="#jobDetailAccordion">
|
||||
<div class="accordion-body text-secondary p-4">
|
||||
<div class="wysiwyg-content">{{ job.application_instructions|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 📱 MOBILE FIXED APPLY BAR (Replaced inline style with utility classes) #}
|
||||
{% if job.form_template %}
|
||||
<footer class="fixed-bottom d-lg-none bg-white border-top shadow-lg p-3">
|
||||
<a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-main-action btn-lg w-100">
|
||||
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
|
||||
</a>
|
||||
</footer>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock content%}
|
||||
@ -1,4 +1,4 @@
|
||||
{% extends 'forms/partials/candidate_facing_base.html'%}
|
||||
{% extends 'applicant/partials/candidate_facing_base.html'%}
|
||||
{% load static i18n %}
|
||||
{% block content %}
|
||||
<style>
|
||||
274
templates/applicant/career.html
Normal file
274
templates/applicant/career.html
Normal file
@ -0,0 +1,274 @@
|
||||
{% extends 'applicant/partials/candidate_facing_base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{# Use a dynamic title for better SEO and user context #}
|
||||
{% block title %}{% trans "Career Opportunities" %} | KAAUH - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="main-content-area">
|
||||
|
||||
{# ------------------------------------------------ #}
|
||||
{# 🌟 HERO SECTION (High Visual Impact) #}
|
||||
{# ------------------------------------------------ #}
|
||||
<header class="hero-section text-white py-5 py-lg-6" style=" background-size: cover; background-position: center;">
|
||||
{# Overlay for readability, assuming custom CSS defines .hero-overlay #}
|
||||
<div class="hero-overlay"></div>
|
||||
<div class="container position-relative" style="z-index: 2;">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-12 col-xl-10 text-center">
|
||||
{# Use a large, commanding font size #}
|
||||
<h1 class="display-4 fw-bolder mb-4 animate__animated animate__fadeInDown">
|
||||
{% trans "Your Career in Health & Academia Starts Here." %}
|
||||
</h1>
|
||||
<p class="lead mb-5 fs-5 animate__animated animate__fadeInUp">
|
||||
{% trans "Join KAAUH, a national leader in patient care, research, and education. We are building the future of healthcare." %}
|
||||
</p>
|
||||
|
||||
{# Primary Action to scroll to listings/filters #}
|
||||
<a href="#job-list-start" class="btn btn-hero-action btn-lg rounded-pill px-5 me-3 mb-3 mb-lg-0 shadow-lg animate__animated animate__zoomIn">
|
||||
<i class="fas fa-compass me-2"></i> {% trans "Find Your Path" %}
|
||||
</a>
|
||||
{# Secondary Action #}
|
||||
<a href="https://kaauh.edu.sa/about-us" class="btn btn-outline-light rounded-pill px-5 btn-lg animate__animated animate__zoomIn">
|
||||
{% trans "About US" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
{# ------------------------------------------------ #}
|
||||
{# 💻 JOB LISTING SECTION #}
|
||||
{# ------------------------------------------------ #}
|
||||
<section class="py-5 job-listing-section" id="job-list-start">
|
||||
<div class="container">
|
||||
|
||||
<div class="row g-5">
|
||||
|
||||
{# 📌 LEFT COLUMN: FILTERS (Smaller on larger screens) #}
|
||||
<div class="col-lg-4">
|
||||
|
||||
{# Mobile Filter Toggle (Used aria-controls for better accessibility) #}
|
||||
<button class="btn btn-outline-dark filter-toggle-button d-lg-none w-100 mb-3" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#filterSidebar" aria-expanded="false" aria-controls="filterSidebar">
|
||||
<i class="fas fa-filter me-2"></i> {% trans "Filter Jobs" %}
|
||||
</button>
|
||||
|
||||
<div class="collapse d-lg-block filter-sidebar-collapse" id="filterSidebar">
|
||||
{# Sticky top ensures filters remain visible while scrolling results #}
|
||||
<div class="card border-0 shadow-sm sticky-top-filters p-4 bg-light-subtle" style="top: 20px;">
|
||||
|
||||
<h4 class="fw-bold mb-4 text-dark border-bottom pb-2">
|
||||
<i class="fas fa-search me-2 text-primary-theme"></i>{% trans "Refine Your Search" %}
|
||||
</h4>
|
||||
<form method="GET" action="{% url 'kaauh_career'%}" class="d-grid gap-3">
|
||||
{# NOTE: Replace select with Django form fields for real functionality #}
|
||||
|
||||
|
||||
|
||||
<select class="form-select form-select-lg" name="employment_type" aria-label="Employment Type filter" >
|
||||
<option value="" {% if not selected_job_type %}selected{% endif %}>{% trans "Employment Type" %}</option>
|
||||
|
||||
{% for key in job_type_keys %}
|
||||
<option value="{{ key }}" {% if key == selected_job_type %}selected{% endif %}>
|
||||
<!-- Hard-coded mapping using IF statements -->
|
||||
{% if key == 'FULL_TIME' %}{% trans "Full-time" %}{% endif %}
|
||||
{% if key == 'PART_TIME' %}{% trans "Part-time" %}{% endif %}
|
||||
{% if key == 'CONTRACT' %}{% trans "Contract" %}{% endif %}
|
||||
{% if key == 'INTERNSHIP' %}{% trans "Internship" %}{% endif %}
|
||||
{% if key == 'FACULTY' %}{% trans "Faculty" %}{% endif %}
|
||||
{% if key == 'TEMPORARY' %}{% trans "Temporary" %}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<select class="form-select form-select-lg" name="workplace_type" aria-label="Workplace Type filter" >
|
||||
<option value="" {% if not selected_workplace_type %}selected{% endif %}>{% trans "Workplace Type" %}</option>
|
||||
|
||||
{% for key in workplace_type_keys %}
|
||||
<option value="{{ key }}" {% if key == selected_workplace_type %}selected{% endif %}>
|
||||
<!-- Hard-coded mapping using IF statements -->
|
||||
{% if key == 'ON_SITE' %}{% trans "On-site" %}{% endif %}
|
||||
{% if key == 'REMOTE' %}{% trans "Remote" %}{% endif %}
|
||||
{% if key == 'HYBRID' %}{% trans "Hybrid" %}{% endif %}
|
||||
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
|
||||
<select class="form-select form-select-lg" name="department" aria-label="Department Type filter" >
|
||||
<option value="" {% if not selected_department %}selected{% endif %}>{% trans "Departments" %}</option>
|
||||
|
||||
{% for key in department_type_keys %}
|
||||
<option value="{{ key }}" {% if key == selected_department %}selected{% endif %}>
|
||||
<!-- Hard-coded mapping using IF statements -->
|
||||
{{key}}
|
||||
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
<button type="submit" class="btn btn-main-action btn-lg rounded-pill mt-3 shadow-sm">
|
||||
{% trans "Apply Filters" %}
|
||||
</button>
|
||||
<a href="." class="btn btn-outline-secondary btn-sm">{% trans "Clear Filters" %}</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{# 📜 RIGHT COLUMN: JOB LISTINGS (Wider on larger screens) #}
|
||||
<div class="col-lg-8">
|
||||
|
||||
{# Sticky Filter Bar (Summary of results and active filters) #}
|
||||
<div class="sticky-filter-bar bg-white p-3 border-bottom mb-4 shadow-sm">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center">
|
||||
|
||||
{# Dynamic Count #}
|
||||
<h3 class="fw-bold mb-0 text-dark fs-5">
|
||||
<span class="text-primary-theme">{{ total_open_roles|default:"0" }}</span> {% trans "Open Roles" %}
|
||||
</h3>
|
||||
|
||||
{# Active Filter Chips (Use a dedicated class for styling) #}
|
||||
<div class="d-flex flex-wrap gap-2 pt-2 pt-md-0">
|
||||
|
||||
{# --- Active Employment Type Filter Chip --- #}
|
||||
{% if selected_job_type %}
|
||||
<span class="filter-chip badge bg-primary-theme-subtle text-primary-theme fw-normal p-2 active-filter-chip">
|
||||
{% trans "Type" %}:
|
||||
{# Map the key back to its human-readable translation #}
|
||||
<strong class="mx-1">
|
||||
{% if selected_job_type == 'FULL_TIME' %}{% trans "Full-time" %}
|
||||
{% elif selected_job_type == 'PART_TIME' %}{% trans "Part-time" %}
|
||||
{% elif selected_job_type == 'CONTRACT' %}{% trans "Contract" %}
|
||||
{% elif selected_job_type == 'INTERNSHIP' %}{% trans "Internship" %}
|
||||
{% elif selected_job_type == 'FACULTY' %}{% trans "Faculty" %}
|
||||
{% elif selected_job_type == 'TEMPORARY' %}{% trans "Temporary" %}
|
||||
{% endif %}
|
||||
</strong>
|
||||
{# Link to clear this specific filter: use current URL but remove `employment_type` parameter #}
|
||||
<a href="?{% for key, value in request.GET.items %}{% if key != 'employment_type' %}{{ key }}={{ value }}&{% endif %}{% endfor %}"
|
||||
class="text-primary-theme text-decoration-none ms-2" role="button" aria-label="Remove Employment Type filter">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# --- Active Workplace Type Filter Chip --- #}
|
||||
{% if selected_workplace_type %}
|
||||
<span class="filter-chip badge bg-primary-theme-subtle text-primary-theme fw-normal p-2 active-filter-chip">
|
||||
{% trans "Workplace" %}:
|
||||
{# Map the key back to its human-readable translation #}
|
||||
<strong class="mx-1">
|
||||
{% if selected_workplace_type == 'ON_SITE' %}{% trans "On-site" %}
|
||||
{% elif selected_workplace_type == 'REMOTE' %}{% trans "Remote" %}
|
||||
{% elif selected_workplace_type == 'HYBRID' %}{% trans "Hybrid" %}
|
||||
{% endif %}
|
||||
</strong>
|
||||
{# Link to clear this specific filter: use current URL but remove `workplace_type` parameter #}
|
||||
<a href="?{% for key, value in request.GET.items %}{% if key != 'workplace_type' %}{{ key }}={{ value }}&{% endif %}{% endfor %}"
|
||||
class="text-primary-theme text-decoration-none ms-2" role="button" aria-label="Remove Workplace Type filter">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# --- Active Department Filter Chip --- #}
|
||||
{% if selected_department %}
|
||||
<span class="filter-chip badge bg-primary-theme-subtle text-primary-theme fw-normal p-2 active-filter-chip">
|
||||
{% trans "Department" %}:
|
||||
<strong class="mx-1">{{ selected_department }}</strong>
|
||||
{# Link to clear this specific filter: use current URL but remove `department` parameter #}
|
||||
<a href="?{% for key, value in request.GET.items %}{% if key != 'department' %}{{ key }}={{ value }}&{% endif %}{% endfor %}"
|
||||
class="text-primary-theme text-decoration-none ms-2" role="button" aria-label="Remove Department filter">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Job Cards Grid #}
|
||||
<div class="mt-4 d-grid gap-3">
|
||||
|
||||
{% for job in active_jobs %}
|
||||
{# Optimized Job Listing Card #}
|
||||
<a href="{% url 'application_detail' job.slug %}"
|
||||
class="card d-block text-decoration-none text-dark job-listing-card p-4 border-2 shadow-hover transition-all">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
{# Job Title #}
|
||||
<h4 class="h5 fw-bold mb-0 text-primary-theme-hover">
|
||||
{{ job.title }}
|
||||
</h4>
|
||||
|
||||
{# Tag Badge (Prominent) #}
|
||||
<span class="badge rounded-pill bg-kaauh-teal job-tag px-3 py-2 fs-6">
|
||||
|
||||
<i class="fas fa-tag me-1"></i>{% trans "Apply Before: " %}{{job.application_deadline}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Department/Context (Sub-text) #}
|
||||
<p class="text-muted small mb-3">{% trans 'Department: '%}{{ job.department|default:"KAAUH Department" }}</p>
|
||||
|
||||
{# Job Metadata Icons (Horizontal list for quick scan) #}
|
||||
<div class="d-flex flex-wrap gap-4 small text-secondary">
|
||||
|
||||
<span class="d-flex align-items-center fw-medium">
|
||||
<i class="fas fa-map-marker-alt me-2 text-primary-theme fa-fw"></i>
|
||||
{{ job.location_country|default:"Kindom of Saudi Arabia" }} | {{job.location_state|default:"Riyadh Province"}} | {{job.location_city|default:"Riyadh"}}
|
||||
</span>
|
||||
|
||||
<span class="d-flex align-items-center fw-medium">
|
||||
<i class="fas fa-user-md me-2 text-primary-theme fa-fw"></i>
|
||||
{{ job.workplace_type|default:"" }}
|
||||
</span>
|
||||
|
||||
<span class="d-flex align-items-center fw-medium">
|
||||
<i class="fas fa-calendar-alt me-2 text-primary-theme fa-fw"></i>
|
||||
{{ job.job_type|default:"Full-Time" }}
|
||||
</span>
|
||||
|
||||
{% if job.posted_date %}
|
||||
<span class="d-flex align-items-center fw-medium">
|
||||
<i class="fas fa-clock me-2 text-primary-theme fa-fw"></i>
|
||||
{% trans "Posted:" %} {{ job.posted_date|timesince }} {% trans "ago" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="alert alert-info border-0 shadow-sm mt-5" role="alert">
|
||||
<h5 class="alert-heading">{% trans "No Matching Opportunities" %}</h5>
|
||||
<p>{% trans "We currently have no open roles that match your search and filters. Please modify your criteria or check back soon!" %}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Load More Button #}
|
||||
{% if show_load_more %}
|
||||
<div class="text-center mt-5 mb-3">
|
||||
<button class="btn btn-main-action btn-lg rounded-pill px-5 shadow-lg">
|
||||
{% trans "Load More Jobs" %} <i class="fas fa-redo ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{% include "includes/paginator.html" %}
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock content %}
|
||||
273
templates/applicant/partials/candidate_facing_base.html
Normal file
273
templates/applicant/partials/candidate_facing_base.html
Normal file
@ -0,0 +1,273 @@
|
||||
{% load static i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_available_languages as LANGUAGES %}
|
||||
{% get_language_info_list for LANGUAGES as language_info_list %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% trans "Careers" %} - {% block title %}{% translate "Application Form" %}{% endblock %}</title>
|
||||
|
||||
{% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %}
|
||||
{% if LANGUAGE_CODE == 'ar' %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* THEME & UTILITY VARIABLES */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-teal-light: #e6f7f8; /* Very light teal for hover/background */
|
||||
--success: #198754;
|
||||
--danger: #dc3545;
|
||||
--light-bg: #f8f9fa;
|
||||
--gray-text: #6c757d;
|
||||
--kaauh-border: #d0d7de; /* Cleaner border color */
|
||||
--kaauh-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); /* Deeper shadow for premium look */
|
||||
--kaauh-dark-bg: #0d0d0d;
|
||||
--kaauh-dark-contrast: #1c1c1c;
|
||||
|
||||
/* CALCULATED STICKY HEIGHTS (As provided in base) */
|
||||
--navbar-height: 56px;
|
||||
--navbar-gap: 16px;
|
||||
--sticky-navbar-total-height: 128px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #f0f0f5;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||
.text-primary-theme-hover:hover { color: var(--kaauh-teal-dark) !important; }
|
||||
|
||||
.bg-kaauh-teal {
|
||||
background-color: #00636e;
|
||||
}
|
||||
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
border: none;
|
||||
transition: background-color 0.3s ease, transform 0.2s ease, box-shadow 0.3s ease;
|
||||
box-shadow: 0 6px 15px rgba(0, 99, 110, 0.4); /* Stronger shadow */
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
transform: translateY(-2px); /* More pronounced lift */
|
||||
box-shadow: 0 10px 20px rgba(0, 99, 110, 0.5);
|
||||
}
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* 1. DARK HERO STYLING (High Contrast) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--kaauh-dark-contrast) 0%, var(--kaauh-dark-bg) 100%);
|
||||
padding: 4rem 0; /* Reduced from 8rem to 4rem */
|
||||
margin-top: -1px;
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 2.5rem; /* Reduced from 3.5rem to 2.5rem */
|
||||
font-weight: 800; /* Extra bold */
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.05em;
|
||||
max-width: 900px;
|
||||
}
|
||||
.hero-section .lead {
|
||||
font-size: 1.35rem; /* Larger lead text */
|
||||
}
|
||||
.btn-hero-action {
|
||||
/* Primary CTA: Retained strong teal look */
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
font-weight: 600;
|
||||
padding: 0.8rem 2.2rem;
|
||||
border-radius: 50rem;
|
||||
box-shadow: 0 4px 10px rgba(0, 99, 110, 0.4);
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.hero-section {
|
||||
padding: 10rem 0;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 5.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* 2. PATH CARDS SECTION (New Segmented Entry) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.path-card-section {
|
||||
background-color: white;
|
||||
margin-top: -80px; /* Pulls the section up over the dark hero for a modern layered look */
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
padding-top: 5rem;
|
||||
padding-bottom: 5rem;
|
||||
border-top-left-radius: 2rem;
|
||||
border-top-right-radius: 2rem;
|
||||
box-shadow: var(--kaauh-shadow); /* Defines the separation */
|
||||
}
|
||||
.path-card {
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.path-card:hover {
|
||||
border-color: var(--kaauh-teal);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 30px rgba(0, 99, 110, 0.1);
|
||||
}
|
||||
.path-card-icon {
|
||||
color: var(--kaauh-teal);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.path-card h5 {
|
||||
font-weight: 700;
|
||||
color: var(--kaauh-teal-dark);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* 3. JOB LISTING & FILTER (Refined) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.job-listing-section {
|
||||
background-color: #f0f0f5; /* Separates the job list from the white path section */
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
.job-listing-card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-left: 6px solid var(--kaauh-teal);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem !important;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); /* Lighter default shadow */
|
||||
}
|
||||
.job-listing-card:hover {
|
||||
transform: translateY(-3px); /* Increased lift */
|
||||
box-shadow: 0 12px 25px rgba(0, 99, 110, 0.15); /* Stronger hover shadow */
|
||||
background-color: var(--kaauh-teal-light);
|
||||
}
|
||||
|
||||
.card.sticky-top-filters {
|
||||
box-shadow: var(--kaauh-shadow); /* Uses the deeper card shadow */
|
||||
}
|
||||
|
||||
/* RTL Corrections (retained) */
|
||||
html[dir="rtl"] .alert { border-right: 5px solid; border-left: none; }
|
||||
html[dir="rtl"] .job-listing-card { border-right: 6px solid var(--kaauh-teal); border-left: 1px solid var(--kaauh-border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav id="topNavbar" class="navbar navbar-expand-lg sticky-top bg-white border-bottom" style="z-index: 1040;">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand text-dark fw-bold" href="{% url 'kaauh_career' %}">
|
||||
<img src="{% static 'image/kaauh.jpeg' %}" alt="{% translate 'KAAUH IMAGE' %}" style="height: 50px; margin-right: 10px;">
|
||||
<span style="color:#00636e;">KAAUH Careers</span>
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
|
||||
{% comment %} <li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="{% url 'applicant_profile' %}">{% translate "Applications" %}</a>
|
||||
</li> {% endcomment %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="{% url 'applicant_profile' %}">{% translate "Profile" %}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="{% url 'kaauh_career' %}">{% translate "Careers" %}</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<button class="language-toggle-btn dropdown-toggle" type="button"
|
||||
data-bs-toggle="dropdown" data-bs-offset="0, 8" aria-expanded="false"
|
||||
aria-label="{% trans 'Toggle language menu' %}">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="d-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu mx-auto {% if LANGUAGE_CODE == 'ar' %}dropdown-menu-end{% else %}dropdown-menu-end{% endif %}" aria-labelledby="navbarLanguageDropdown">
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇺🇸</span> English
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇸🇦</span> العربية (Arabic)
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% if messages %}
|
||||
<div class="container message-container mt-3">
|
||||
<div class="row">
|
||||
{# Use responsive columns matching the main content block for alignment #}
|
||||
<div class="col-lg-12 order-lg-1 col-12 mx-auto">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags|default:'info' }} alert-dismissible fade show" role="alert">
|
||||
<i class="fas fa-check-circle me-2"></i> {{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ================================================= #}
|
||||
{# DJANGO MESSAGE BLOCK - Placed directly below the main navbar #}
|
||||
{# ================================================= #}
|
||||
|
||||
{# ================================================= #}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
{% endblock content %}
|
||||
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
{% block customJS %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@ -208,7 +208,11 @@
|
||||
<i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i>
|
||||
<span style="color:red;">{% trans "Sign Out" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
{% comment %} <a class="d-inline text-decoration-none px-4 d-flex align-items-center border-0 bg-transparent text-start text-center" href={% url "account_logout" %}>
|
||||
<i class="fas fa-sign-out-alt me-3 fs-5 " style="color:red;"></i>
|
||||
<span style="color:red;">{% trans "Sign Out" %}</span>
|
||||
</a> {% endcomment %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@ -255,6 +259,16 @@
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{% comment %} <li class="nav-item me-lg-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'interview_list' %}active{% endif %}" href="{% url 'interview_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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
{% trans "Onsite Interviews" %}
|
||||
</span>
|
||||
</a>
|
||||
</li> {% endcomment %}
|
||||
<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">
|
||||
@ -290,14 +304,15 @@
|
||||
|
||||
|
||||
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
{% extends 'forms/partials/candidate_facing_base.html'%}
|
||||
{% load static i18n %}
|
||||
{% block content %}
|
||||
|
||||
<nav id="bottomNavbar" class="navbar navbar-expand-lg sticky-top" style="background-color: var(--kaauh-teal); z-index: 1030;">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-text text-white fw-bold">{% trans "Job Overview" %}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
{# ================================================= #}
|
||||
{# DJANGO MESSAGE BLOCK - Placed directly below the main navbar #}
|
||||
{# ================================================= #}
|
||||
{% if messages %}
|
||||
<div class="container-fluid message-container">
|
||||
<div class="row">
|
||||
{# Using responsive columns to center the message content, similar to your form structure #}
|
||||
<div class="col-lg-8 offset-lg-2 col-md-10 offset-md-1 col-12">
|
||||
{% for message in messages %}
|
||||
{# Use 'alert-{{ message.tags }}' to apply Bootstrap styling based on Django's tag (success, error/danger, info, warning) #}
|
||||
<div class="alert alert-{{ message.tags|default:'info' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# ================================================= #}
|
||||
|
||||
<div class="container">
|
||||
<div class="row mb-5 mt-3 main-content-area">
|
||||
|
||||
<div class="col-lg-4 order-lg-2 order-1 d-none d-lg-block">
|
||||
<div class="card shadow-sm sticky-top">
|
||||
<div class="card-header bg-kaauh-teal-dark text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-file-signature me-2"></i>{% trans "Ready to Apply?" %}</h5>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<p class="text-muted">{% trans "Review the job details, then apply below." %}</p>
|
||||
|
||||
{% if job.form_template %}
|
||||
<a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-main-action btn-lg w-100">
|
||||
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 order-lg-1 order-2">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-kaauh-teal-dark text-white d-flex justify-content-between align-items-center">
|
||||
<h2 class="h3 mb-0 fw-bold">{{ job.title }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<h4 class="mb-3" style="color: var(--kaauh-teal-dark);">{% trans "Job Overview" %}</h4>
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3 mb-4 small text-secondary">
|
||||
{% if job.salary_range %}
|
||||
<div class="col">
|
||||
<i class="fas fa-money-bill-wave text-success me-2"></i>
|
||||
<strong>{% trans "Salary:" %}</strong>
|
||||
<span class="fw-bold text-success">{{ job.salary_range }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col">
|
||||
<i class="fas fa-calendar-alt text-muted me-2"></i>
|
||||
<strong>{% trans "Deadline:" %}</strong>
|
||||
{% if job.application_deadline %}
|
||||
{{ job.application_deadline|date:"M d, Y" }}
|
||||
{% if job.is_expired %}
|
||||
<span class="badge bg-danger ms-2">{% trans "EXPIRED" %}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "Not specified" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col"> <i class="fas fa-briefcase text-muted me-2"></i> <strong>{% trans "Job Type:" %}</strong> {{ job.get_job_type_display }} </div>
|
||||
<div class="col"> <i class="fas fa-map-marker-alt text-muted me-2"></i> <strong>{% trans "Location:" %}</strong> {{ job.get_location_display }} </div>
|
||||
<div class="col"> <i class="fas fa-building text-muted me-2"></i> <strong>{% trans "Department:" %}</strong> {{ job.department|default:"N/A" }} </div>
|
||||
<div class="col"> <i class="fas fa-hashtag text-muted me-2"></i> <strong>{% trans "JOB ID:" %}</strong> {{ job.internal_job_id|default:"N/A" }} </div>
|
||||
<div class="col"> <i class="fas fa-desktop text-muted me-2"></i> <strong>{% trans "Workplace:" %}</strong> {{ job.get_workplace_type_display }} </div>
|
||||
</div>
|
||||
|
||||
{% if job.has_description_content %}<hr class="my-4"><div class="mb-4"><h5 class="fw-bold" style="color: var(--kaauh-teal-dark);"><i class="fas fa-info-circle me-2"></i>{% trans "Job Description" %}</h5><div class="text-secondary">{{ job.description|safe }}</div></div>{% endif %}
|
||||
{% if job.has_qualifications_content %}<hr class="my-4"><div class="mb-4"><h5 class="fw-bold" style="color: var(--kaauh-teal-dark);"><i class="fas fa-graduation-cap me-2"></i>{% trans "Qualifications" %}</h5><div class="text-secondary">{{ job.qualifications|safe }}</div></div>{% endif %}
|
||||
{% if job.has_benefits_content %}<hr class="my-4"><div class="mb-4"><h5 class="fw-bold" style="color: var(--kaauh-teal-dark);"><i class="fas fa-hand-holding-usd me-2"></i>{% trans "Benefits" %}</h5><div class="text-secondary">{{ job.benefits|safe }}</div></div>{% endif %}
|
||||
{% if job.has_application_instructions_content %}<hr class="my-4"><div class="mb-4"><h5 class="fw-bold" style="color: var(--kaauh-teal-dark);"><i class="fas fa-file-alt me-2"></i>{% trans "Application Instructions" %}</h5><div class="text-secondary">{{ job.application_instructions|safe }}</div></div>{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-fixed-apply-bar d-lg-none">
|
||||
{% if job.form_template %}
|
||||
<a href="{% url 'application_submit_form' job.form_template.pk %}" class="btn btn-main-action btn-lg w-100">
|
||||
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock content%}
|
||||
@ -133,7 +133,7 @@
|
||||
<h1 class="h3 mb-0 fw-bold text-primary">
|
||||
<i class="fas fa-file-alt me-2"></i>Create Form Template
|
||||
</h1>
|
||||
<a href="{% url 'form_templates_list' %}" class="btn btn-secondary">
|
||||
<a href="{% url 'form_templates_list' %}" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Templates
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -1,304 +0,0 @@
|
||||
{% load static i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_available_languages as LANGUAGES %}
|
||||
{% get_language_info_list for LANGUAGES as language_info_list %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% translate "Application Form" %}</title>
|
||||
|
||||
{% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %}
|
||||
{% if LANGUAGE_CODE == 'ar' %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* THEME & UTILITY VARIABLES */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--success: #198754;
|
||||
--danger: #dc3545;
|
||||
--light-bg: #f8f9fa;
|
||||
--gray-text: #6c757d;
|
||||
--kaauh-border: #eaeff3; /* Added for dropdown styling */
|
||||
|
||||
/* CALCULATED STICKY HEIGHTS */
|
||||
--navbar-height: 56px;
|
||||
--navbar-gap: 16px;
|
||||
--sticky-navbar-total-height: 128px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #f0f0f5; /* Light gray background for contrast */
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
border: none;
|
||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 99, 110, 0.3);
|
||||
}
|
||||
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bg-kaauh-teal-dark {
|
||||
background-color: var(--kaauh-teal-dark) !important;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* NEW: MESSAGES STYLING */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.message-container {
|
||||
/* Position right below the sticky navbar (56px) with a small top margin */
|
||||
margin-top: calc(var(--navbar-height) + 10px);
|
||||
}
|
||||
.alert {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.08);
|
||||
margin-bottom: 0; /* Handled by container margin */
|
||||
border-left: 5px solid; /* Feature highlight */
|
||||
}
|
||||
.alert-success {
|
||||
color: var(--success);
|
||||
background-color: #d1e7dd;
|
||||
border-color: var(--success);
|
||||
}
|
||||
.alert-error, .alert-danger {
|
||||
color: var(--danger);
|
||||
background-color: #f8d7da;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.alert-info {
|
||||
color: var(--kaauh-teal-dark);
|
||||
background-color: #cff4fc;
|
||||
border-color: var(--kaauh-teal);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* LANGUAGE TOGGLE STYLES (COPIED FROM MAIN LAYOUT) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.language-toggle-btn {
|
||||
color: var(--gray-text) !important; /* Use secondary color */
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.language-toggle-btn:hover {
|
||||
background: var(--light-bg) !important;
|
||||
border-radius: 4px;
|
||||
color: var(--kaauh-teal) !important;
|
||||
}
|
||||
|
||||
/* Dropdown Menu styling for language */
|
||||
.dropdown-menu {
|
||||
backdrop-filter: blur(4px);
|
||||
background-color: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid var(--kaauh-border);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0;
|
||||
min-width: 150px;
|
||||
}
|
||||
.dropdown-item {
|
||||
padding: 0.5rem 1.25rem;
|
||||
transition: background-color 0.15s;
|
||||
text-align: inherit; /* Ensure text alignment is controlled by dir="rtl" */
|
||||
}
|
||||
|
||||
/* Use button as dropdown-item inside form for full click area */
|
||||
.dropdown-item[type="submit"] {
|
||||
width: 100%;
|
||||
text-align: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* LAYOUT & STICKY POSITIONING FIXES (Desktop/Tablet) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
#topNavbar {
|
||||
z-index: 1040; /* Higher than the bottom bar */
|
||||
}
|
||||
|
||||
/* 1. Position the dark navbar below the white navbar + gap */
|
||||
#bottomNavbar {
|
||||
/* 56px (white nav) + 16px (mb-3) = 72px */
|
||||
top: calc(var(--navbar-height) + var(--navbar-gap));
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
/* 2. Pushes the main content down so it's not hidden under the navbars */
|
||||
.main-content-area {
|
||||
/* Total Sticky Height (128px) + Extra Margin (12px) = 140px */
|
||||
margin-top: calc(var(--sticky-navbar-total-height) + 12px);
|
||||
}
|
||||
|
||||
/* 3. Positions the sticky sidebar correctly */
|
||||
.card.sticky-top {
|
||||
/* Start scrolling the sidebar just below the two navbars + a small gap */
|
||||
top: calc(var(--sticky-navbar-total-height) + 15px);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* RTL / ARABIC SUPPORT - Optimized */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
html[dir="rtl"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Flip Margin Utilities (m-end and m-start) */
|
||||
html[dir="rtl"] .ms-auto { margin-right: auto !important; margin-left: 0 !important; }
|
||||
html[dir="rtl"] .me-auto { margin-left: auto !important; margin-right: 0 !important; }
|
||||
html[dir="rtl"] .ms-2 { margin-right: 0.5rem !important; margin-left: 0 !important; }
|
||||
html[dir="rtl"] .me-2 { margin-left: 0.5rem !important; margin-right: 0 !important; }
|
||||
html[dir="rtl"] .me-1 { margin-left: 0.25rem !important; margin-right: 0 !important; } /* For the globe icon */
|
||||
|
||||
/* Flip alignment for text-end/text-start */
|
||||
html[dir="rtl"] .text-end { text-align: left !important; }
|
||||
html[dir="rtl"] .text-start { text-align: right !important; }
|
||||
|
||||
/* Flip border-left for RTL alerts */
|
||||
html[dir="rtl"] .alert {
|
||||
border-right: 5px solid;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* MOBILE RESPONSIVE STYLES (Below 992px) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
@media (max-width: 991.98px) {
|
||||
|
||||
/* Ensures dropdown items in mobile menu align correctly */
|
||||
html[dir="rtl"] .navbar-collapse .dropdown-menu {
|
||||
text-align: right;
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* On mobile, the top navbar is generally only 56px tall when collapsed. */
|
||||
#bottomNavbar {
|
||||
top: calc(var(--navbar-height) + var(--navbar-gap));
|
||||
}
|
||||
|
||||
.main-content-area {
|
||||
/* Reduced margin-top for smaller screens */
|
||||
margin-top: calc(var(--sticky-navbar-total-height) / 2);
|
||||
}
|
||||
|
||||
/* Mobile Fixed Footer Bar for Application */
|
||||
.mobile-fixed-apply-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background-color: var(--light-bg);
|
||||
border-top: 1px solid #ddd;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -4px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Add padding to the bottom of the body content to prevent it from hiding under the fixed bar */
|
||||
body {
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
/* Fix job overview grid responsiveness (ensures 1 column) */
|
||||
.row-cols-md-2 > .col {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav id="topNavbar" class="navbar navbar-expand-lg sticky-top" style="background-color: white; z-index: 1040;">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand text-white fw-bold" href="{% url 'kaauh_career' %}">
|
||||
<img src="{% static 'image/kaauh.jpeg' %}" alt="{% translate 'KAAUH IMAGE' %}" style="height: 50px; margin-right: 10px;">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
|
||||
{% comment %} <li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="/applications/">{% translate "Applications" %}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="/profile/">{% translate "Profile" %}</a>
|
||||
</li> {% endcomment %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="{% url 'kaauh_career' %}">{% translate "Careers" %}</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<button class="language-toggle-btn dropdown-toggle" type="button"
|
||||
data-bs-toggle="dropdown" data-bs-offset="0, 8" aria-expanded="false"
|
||||
aria-label="{% trans 'Toggle language menu' %}">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="d-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu {% if LANGUAGE_CODE == 'ar' %}dropdown-menu-start{% else %}dropdown-menu-end{% endif %}" aria-labelledby="navbarLanguageDropdown">
|
||||
|
||||
{% comment %} English Button {% endcomment %}
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇺🇸</span> English
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
{% comment %} Arabic Button {% endcomment %}
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇸🇦</span> العربية (Arabic)
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% endblock content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,19 +1,47 @@
|
||||
{% load i18n %}
|
||||
{{ form.media }}
|
||||
<div class="row">
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-12">
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
{% for message in messages %}
|
||||
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>
|
||||
{{ message }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-envelope me-2"></i>
|
||||
{% trans "Compose Email" %}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form method="post" id="email-compose-form" action="{% url 'compose_candidate_email' job.slug candidate.slug %}">
|
||||
<form hx-boost="true" method="post" id="email-compose-form" action="{% url 'compose_candidate_email' job.slug %}" hx-include="#candidate-form">
|
||||
{% csrf_token %}
|
||||
<!-- Recipients Field -->
|
||||
<!-- Recipients Field -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">
|
||||
{% trans "To" %}
|
||||
</label>
|
||||
<div class="border rounded p-3 bg-light" style="max-height: 200px; overflow-y: auto;">
|
||||
{% for choice in form.to %}
|
||||
<div class="form-check mb-2">
|
||||
{{ choice }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.to.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.to.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Subject Field -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
|
||||
@ -29,26 +57,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Recipients Field -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">
|
||||
{% trans "Recipients" %}
|
||||
</label>
|
||||
<div class="border rounded p-3 bg-light" style="max-height: 200px; overflow-y: auto;">
|
||||
{% for choice in form.recipients %}
|
||||
<div class="form-check mb-2">
|
||||
{{ choice }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.recipients.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for error in form.recipients.errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Message Field -->
|
||||
<div class="mb-3">
|
||||
@ -65,37 +74,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Options Checkboxes -->
|
||||
<div class="mb-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
name="{{ form.include_candidate_info.name }}"
|
||||
id="{{ form.include_candidate_info.id_for_label }}"
|
||||
{% if form.include_candidate_info.value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ form.include_candidate_info.id_for_label }}">
|
||||
{{ form.include_candidate_info.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
name="{{ form.include_meeting_details.name }}"
|
||||
id="{{ form.include_meeting_details.id_for_label }}"
|
||||
{% if form.include_meeting_details.value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ form.include_meeting_details.id_for_label }}">
|
||||
{{ form.include_meeting_details.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="text-muted small">
|
||||
|
||||
@ -1,27 +1,61 @@
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link text-primary-theme" href="?page=1{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">First</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link text-primary-theme" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if page_obj.has_previous or page_obj.has_next %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
|
||||
{# Helper to build the query string while excluding the 'page' parameter #}
|
||||
{% load url_extras %}
|
||||
|
||||
{# Build a string of all current filters (e.g., &department=IT&type=FULL_TIME) #}
|
||||
{% add_get_params request.GET as filter_params %}
|
||||
{% with filter_params=filter_params %}
|
||||
|
||||
{% if page_obj.has_previous %}
|
||||
|
||||
{# First Page Link #}
|
||||
<li class="page-item">
|
||||
<span class="page-link bg-primary-theme text-white">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
<a class="page-link text-primary-theme"
|
||||
href="?page=1{{ filter_params }}">
|
||||
First
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{# Previous Page Link #}
|
||||
<li class="page-item">
|
||||
<a class="page-link text-primary-theme"
|
||||
href="?page={{ page_obj.previous_page_number }}{{ filter_params }}">
|
||||
Previous
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link text-primary-theme" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Next</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link text-primary-theme" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Last</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{# Current Page Status - Use your teal/custom background class here #}
|
||||
<li class="page-item active" aria-current="page">
|
||||
<span class="page-link bg-kaauh-teal text-white">
|
||||
{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
|
||||
{# Next Page Link #}
|
||||
<li class="page-item">
|
||||
<a class="page-link text-primary-theme"
|
||||
href="?page={{ page_obj.next_page_number }}{{ filter_params }}">
|
||||
Next
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{# Last Page Link #}
|
||||
<li class="page-item">
|
||||
<a class="page-link text-primary-theme"
|
||||
href="?page={{ page_obj.paginator.num_pages }}{{ filter_params }}">
|
||||
Last
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
0
templates/interviews/delete_interview.html
Normal file
0
templates/interviews/delete_interview.html
Normal file
708
templates/interviews/detail_interview.html
Normal file
708
templates/interviews/detail_interview.html
Normal file
@ -0,0 +1,708 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
{% load widget_tweaks %}
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* KAAT-S Redesign CSS - Compacted and Reordered Layout */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--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;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f0f2f5;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* ------------------ Card & Header Styles ------------------ */
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
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: 1rem 1.25rem; /* Reduced padding */
|
||||
}
|
||||
#comments-card .card-header {
|
||||
background-color: white;
|
||||
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 & Status ------------------ */
|
||||
.main-title-container {
|
||||
padding: 0 0 1rem 0; /* Space below the main title */
|
||||
}
|
||||
.main-title-container h1 {
|
||||
font-size: 1.75rem; /* Reduced size */
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
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 (Made Smaller) ------------------ */
|
||||
|
||||
.detail-section h2, .card h2 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem; /* Reduced size */
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
}
|
||||
|
||||
.detail-row-simple {
|
||||
display: flex;
|
||||
padding: 0.4rem 0; /* Reduced vertical padding */
|
||||
border-bottom: 1px dashed var(--kaauh-border);
|
||||
font-size: 0.85rem; /* Smaller text */
|
||||
}
|
||||
.detail-label-simple {
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-teal-dark);
|
||||
flex-basis: 40%;
|
||||
}
|
||||
.detail-value-simple {
|
||||
color: var(--kaauh-primary-text);
|
||||
font-weight: 500;
|
||||
flex-basis: 60%;
|
||||
}
|
||||
|
||||
/* ------------------ Join Info & Copy Button ------------------ */
|
||||
|
||||
.btn-primary-teal {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.95rem; /* Slightly smaller button */
|
||||
border-radius: 6px;
|
||||
color: white; /* Ensure text color is white for teal primary */
|
||||
}
|
||||
.btn-primary-teal:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.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;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.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;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-copy-simple:hover {
|
||||
background-color: var(--kaauh-teal);
|
||||
}
|
||||
|
||||
/* ------------------ Simple Table Styles ------------------ */
|
||||
.simple-table {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.simple-table th {
|
||||
background-color: var(--kaauh-teal-light);
|
||||
color: var(--kaauh-teal-dark);
|
||||
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 %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% comment %}
|
||||
NOTE: The variable 'meeting' has been renamed to 'interview' (ScheduledInterview)
|
||||
NOTE: The variable 'meeting.slug' has been renamed to 'interview.slug'
|
||||
NOTE: All 'meeting' URL names (update_meeting, delete_meeting, etc.) have been renamed
|
||||
{% endcomment %}
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
{# --- TOP BAR / BACK BUTTON & ACTIONS (EDIT/DELETE) --- #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
{# Back Button #}
|
||||
<a href="{% url 'interview_list' %}" class="btn btn-secondary-back">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Interviews" %}
|
||||
</a>
|
||||
|
||||
{# Edit and Delete Buttons #}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'update_scheduled_interview' interview.slug %}" class="btn btn-primary-teal btn-sm">
|
||||
<i class="fas fa-edit me-1"></i> {% trans "Edit Interview" %}
|
||||
</a>
|
||||
{# DELETE MEETING FORM #}
|
||||
<form method="post" action="{% url 'delete_scheduled_interview' interview.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 interview? This action is permanent." %}')">
|
||||
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete Interview" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ========================================================= #}
|
||||
{# --- MAIN TITLE AT TOP --- #}
|
||||
{# ========================================================= #}
|
||||
{% with zoom_details=interview.zoom_details.0 %}
|
||||
<div class="main-title-container mb-4">
|
||||
<h1 class="text-start" style="color: var(--kaauh-teal-dark);">
|
||||
{% if interview.schedule.interview_type == 'Remote' %}
|
||||
<i class="fas fa-video me-2" style="color: var(--kaauh-teal);"></i>
|
||||
{{ zoom_details.topic|default:"[Remote Interview]" }}
|
||||
{% else %}
|
||||
<i class="fas fa-building me-2" style="color: var(--kaauh-teal);"></i>
|
||||
{{ interview.schedule.location|default:"[Onsite Interview]" }}
|
||||
{% endif %}
|
||||
|
||||
<span class="status-badge bg-{{ interview.status|lower|default:'bg-secondary' }} ms-3">
|
||||
{{ interview.status|title|default:'N/A' }} ({{ interview.schedule.interview_type }})
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# ========================================================= #}
|
||||
{# --- SECTION 1: INTERVIEW & CONNECTION/LOCATION CARDS SIDE BY SIDE --- #}
|
||||
{# ========================================================= #}
|
||||
<div class="row g-4 mb-5 align-items-stretch">
|
||||
|
||||
{# --- LEFT HALF: INTERVIEW DETAIL CARD --- #}
|
||||
<div class="col-lg-6">
|
||||
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
|
||||
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Candidate & Job" %}</h2>
|
||||
<div class="detail-row-group flex-grow-1">
|
||||
{# NOTE: Assuming ScheduledInterview has direct relations to candidate and job #}
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title|default:"N/A" }}</a></div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'candidate_detail' interview.candidate.slug %}">{{ interview.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 class="text-decoration-none text-dark" href="{% url 'candidate_detail' interview.candidate.slug %}">{{ interview.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">{{ interview.job.job_type|default:"N/A" }}</div></div>
|
||||
{% if interview.candidate.belong_to_agency %}
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Agency" %}:</div><div class="detail-value-simple"><a href="">{{ interview.candidate.hiring_agency.name|default:"N/A" }}</a></div></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- RIGHT HALF: CONNECTION/LOCATION DETAILS CARD --- #}
|
||||
<div class="col-lg-6">
|
||||
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
|
||||
<h2 class="text-start"><i class="fas fa-map-marker-alt me-2"></i> {% trans "Time & Location" %}</h2>
|
||||
<div class="detail-row-group flex-grow-1">
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Date & Time" %}:</div><div class="detail-value-simple">{{ interview.interview_date|date:"M d, Y"|default:"N/A" }} @ {{ interview.interview_time|time:"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">{{ interview.schedule.interview_duration|default:"N/A" }} {% trans "minutes" %}</div></div>
|
||||
|
||||
{% if interview.schedule.interview_type == 'Onsite' %}
|
||||
{# --- Onsite Details --- #}
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Location" %}:</div><div class="detail-value-simple">{{ interview.schedule.location|default:"TBD" }}</div></div>
|
||||
|
||||
{% elif interview.schedule.interview_type == 'Remote' and zoom_details %}
|
||||
{# --- Remote/Zoom Details --- #}
|
||||
<h3 class="mt-3" style="font-size: 1.05rem; color: var(--kaauh-teal); font-weight: 600;">{% trans "Remote Details" %}</h3>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Meeting ID" %}:</div><div class="detail-value-simple">{{ zoom_details.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">{{ zoom_details.host_email|default:"N/A" }}</div></div>
|
||||
|
||||
{% if zoom_details.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">{{ zoom_details.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 %}
|
||||
{% else %}
|
||||
<p class="text-muted">{% trans "Location/Connection details are not available for this interview type." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
|
||||
{# ========================================================= #}
|
||||
{# --- 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">
|
||||
<div class="d-flex justify-content-between align-item-center" >
|
||||
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
|
||||
<div class="d-flex justify-content-center align-item-center">
|
||||
<button type="button" class="btn btn-primary-teal btn-sm me-2"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#assignParticipants">
|
||||
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %} ({{ interview.participants.count|add:interview.system_users.count }})
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-info"
|
||||
data-bs-toggle="modal"
|
||||
title="Send Interview Emails"
|
||||
data-bs-target="#emailModal">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{# External Participants #}
|
||||
{% for participant in interview.participants.all %}
|
||||
<tr>
|
||||
<td>{{participant.name}}</td>
|
||||
<td>{{participant.designation}}</td>
|
||||
<td>{{participant.email}}</td>
|
||||
<td>{{participant.phone}}</td>
|
||||
<td>{% trans "External Participants" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{# System Users (Internal Participants) #}
|
||||
{% for user in interview.system_users.all %}
|
||||
<tr>
|
||||
<td>{{user.get_full_name}}</td>
|
||||
<td>{% trans "System User" %}</td>
|
||||
<td>{{user.email}}</td>
|
||||
<td>{{user.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" 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" %} ({% if interview.comments %}{{ interview.comments.count }}{% else %}0{% endif %})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body overflow-auto">
|
||||
|
||||
{# 1. COMMENT DISPLAY & IN-PAGE EDIT FORMS #}
|
||||
<div id="comment-section" class="mb-4">
|
||||
{# NOTE: Assuming comment model has a ForeignKey to ScheduledInterview called 'interview' #}
|
||||
{% if interview.comments.all %}
|
||||
{% for comment in interview.comments.all|dictsortreversed:"created_at" %}
|
||||
|
||||
<div class="comment-item mb-3 p-3">
|
||||
|
||||
{# Read-Only Comment View #}
|
||||
<div id="comment-view-{{ comment.pk }}">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div class="comment-metadata" style="font-size: 0.9rem;">
|
||||
<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>
|
||||
|
||||
{% if comment.author == user or user.is_staff %}
|
||||
<div class="comment-actions d-flex align-items-center gap-1">
|
||||
{# Edit Button: Toggles the hidden form #}
|
||||
<button type="button" class="btn btn-edit-comment py-0 px-1" 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' interview.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 %}
|
||||
</div>
|
||||
<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' interview.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>
|
||||
|
||||
<hr>
|
||||
|
||||
{# 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' interview.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>
|
||||
{% endif %}
|
||||
<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>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-muted small">{% trans "You must be logged in to add a comment." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{# --- MODALS (Updated to use interview.slug) --- #}
|
||||
|
||||
<div class="modal fade" id="assignParticipants" tabindex="-1" aria-labelledby="assignParticipantsLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" 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 'create_interview_participants' interview.slug %}">
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
<div class="modal-body table-responsive">
|
||||
|
||||
{{ interview.name }} {# This might need checking - ScheduledInterview usually doesn't have a 'name' field #}
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
|
||||
<table class="table tab table-bordered mt-3">
|
||||
<thead>
|
||||
<th class="col">👥 {% trans "Participants" %}</th>
|
||||
<th class="col">🧑💼 {% trans "Users" %}</th>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.participants.errors }}
|
||||
{{ form.participants }}
|
||||
</td>
|
||||
<td> {{ form.system_users.errors }}
|
||||
{{ form.system_users }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||
<button type="submit" class="btn btn-primary-teal btn-sm">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-light">
|
||||
<h5 class="modal-title" id="emailModalLabel">📧 {% trans "Compose Interview Invitation" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'send_interview_email' interview.slug %}">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ email_form.subject.id_for_label }}" class="form-label fw-bold">Subject</label>
|
||||
{{ email_form.subject | add_class:"form-control" }}
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs" id="messageTabs" role="tablist">
|
||||
{# Candidate/Agency Tab - Active by default #}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="candidate-tab" data-bs-toggle="tab" data-bs-target="#candidate-pane" type="button" role="tab" aria-controls="candidate-pane" aria-selected="true">
|
||||
{% if interview.candidate.belong_to_an_agency %}
|
||||
{% trans "Agency Message" %}
|
||||
{% else %}
|
||||
{% trans "Candidate Message" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
{# Participants Tab #}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="participants-tab" data-bs-toggle="tab" data-bs-target="#participants-pane" type="button" role="tab" aria-controls="participants-pane" aria-selected="false">
|
||||
{% trans "Panel Message (Interviewers)" %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content border border-top-0 p-3 bg-light-subtle">
|
||||
|
||||
{# --- Candidate/Agency Pane --- #}
|
||||
<div class="tab-pane fade show active" id="candidate-pane" role="tabpanel" aria-labelledby="candidate-tab">
|
||||
<p class="text-muted small">{% trans "This email will be sent to the candidate or their hiring agency." %}</p>
|
||||
|
||||
{% if not interview.candidate.belong_to_an_agency %}
|
||||
<div class="form-group">
|
||||
<label for="{{ email_form.message_for_candidate.id_for_label }}" class="form-label d-none">{% trans "Candidate Message" %}</label>
|
||||
{{ email_form.message_for_candidate | add_class:"form-control" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if interview.candidate.belong_to_an_agency %}
|
||||
<div class="form-group">
|
||||
<label for="{{ email_form.message_for_agency.id_for_label }}" class="form-label d-none">{% trans "Agency Message" %}</label>
|
||||
{{ email_form.message_for_agency | add_class:"form-control" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# --- Participants Pane --- #}
|
||||
<div class="tab-pane fade" id="participants-pane" role="tabpanel" aria-labelledby="participants-tab">
|
||||
<p class="text-muted small">{% trans "This email will be sent to the internal and external interview participants." %}</p>
|
||||
<div class="form-group">
|
||||
<label for="{{ email_form.message_for_participants.id_for_label }}" class="form-label d-none">{% trans "Participants Message" %}</label>
|
||||
{{ email_form.message_for_participants | add_class:"form-control" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||
<button type="submit" class="btn btn-primary-teal">{% trans "Send Invitation" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block customJS %}
|
||||
<script>
|
||||
// --- 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 {
|
||||
// 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');
|
||||
const displayContainer = urlElement.closest('.join-url-display');
|
||||
const messageElement = document.getElementById('copy-message');
|
||||
const textToCopy = urlElement.textContent || urlElement.innerText;
|
||||
|
||||
clearTimeout(window.copyMessageTimeout);
|
||||
|
||||
function showMessage(success) {
|
||||
messageElement.textContent = success ? '{% trans "Copied!" %}' : '{% trans "Copy Failed." %}';
|
||||
messageElement.style.backgroundColor = success ? 'var(--kaauh-success)' : 'var(--kaauh-danger)';
|
||||
messageElement.style.opacity = '1';
|
||||
|
||||
// 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';
|
||||
|
||||
window.copyMessageTimeout = setTimeout(() => {
|
||||
messageElement.style.opacity = '0';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
showMessage(true);
|
||||
}).catch(err => {
|
||||
console.error('Could not copy text: ', err);
|
||||
fallbackCopyTextToClipboard(textToCopy, showMessage);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(textToCopy, showMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopyTextToClipboard(text, callback) {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand('copy');
|
||||
} catch (err) {
|
||||
console.error('Fallback: Oops, unable to copy', err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
callback(success);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
267
templates/interviews/interview_list.html
Normal file
267
templates/interviews/interview_list.html
Normal file
@ -0,0 +1,267 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% trans "Scheduled Interviews List" %} - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
{# (Your existing CSS is kept here, as it is perfect for the theme) #}
|
||||
<style>
|
||||
/* ... (Your CSS styles) ... */
|
||||
</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-calendar-alt me-2"></i> {% trans "Scheduled Interviews" %}
|
||||
</h1>
|
||||
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #}
|
||||
{# Replace '#' with {% url 'create_scheduled_interview' %} once the URL name is defined in urls.py #}
|
||||
<a href="#" class="btn btn-main-action">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Schedule Interview" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 shadow-sm no-hover">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3 align-items-end">
|
||||
{# Search field #}
|
||||
<div class="col-md-4">
|
||||
<label for="q" class="form-label small text-muted">{% trans "Search (Candidate/Job)" %}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control form-control-sm" id="q" name="q" placeholder="{% trans 'Search...' %}" value="{{ search_query }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter by Status #}
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
|
||||
<select name="status" id="status" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Statuses" %}</option>
|
||||
<option value="scheduled" {% if status_filter == 'scheduled' %}selected{% endif %}>{% trans "Scheduled" %}</option>
|
||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans "Confirmed" %}</option>
|
||||
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
|
||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Filter by Interview Type (ONSITE/REMOTE) - This list now correctly populated #}
|
||||
<div class="col-md-3">
|
||||
<label for="interview_type" class="form-label small text-muted">{% trans "Interview Type" %}</label>
|
||||
<select name="interview_type" id="interview_type" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Types" %}</option>
|
||||
{% for type_value, type_label in interview_types %}
|
||||
<option value="{{ type_value }}" {% if type_filter == type_value %}selected{% endif %}>
|
||||
{{ type_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<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 status_filter or search_query or type_filter %}
|
||||
{# Assuming 'interview_list' is the URL name for this view #}
|
||||
<a href="{% url 'interview_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>
|
||||
{{meetings}}
|
||||
{# Using 'meetings' based on the context_object_name provided #}
|
||||
{% if meetings %}
|
||||
<div id="meetings-list">
|
||||
{# View Switcher (kept the name for simplicity) #}
|
||||
{% include "includes/_list_view_switcher.html" with list_id="meetings-list" %}
|
||||
|
||||
{# Card View #}
|
||||
<div class="card-view active row">
|
||||
{% for interview in meetings %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card meeting-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 'candidate_detail' interview.candidate.slug %}" class="text-decoration-none text-primary-theme">{{ interview.candidate.name }}</a>
|
||||
</h5>
|
||||
<span class="status-badge bg-{{ interview.status }}">
|
||||
{{ interview.status|title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text text-muted small mb-3">
|
||||
<i class="fas fa-briefcase"></i> {% trans "Job" %}:
|
||||
<a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a><br>
|
||||
|
||||
{# --- Remote/Onsite Logic - Handles both cases safely --- #}
|
||||
<i class="fas {% if interview.schedule.interview_type == 'Remote' %}fa-globe{% else %}fa-map-marker-alt{% endif %}"></i>
|
||||
{% trans "Type" %}: {{ interview.schedule.get_interview_type_display }}
|
||||
{% if interview.schedule.interview_type == 'Remote' %}<br>
|
||||
{# CRITICAL FIX: Safe access to zoom_meeting details #}
|
||||
<i class="fas fa-hashtag"></i> {% trans "Zoom ID" %}: {{ interview.zoom_meeting.meeting_id|default:"N/A" }}
|
||||
{% else %}<br>
|
||||
<i class="fas fa-building"></i> {% trans "Location" %}: {{ interview.schedule.location }}
|
||||
{% endif %}<br>
|
||||
|
||||
<i class="fas fa-clock"></i> {% trans "Date" %}: {{ interview.interview_date|date:"M d, Y" }}<br>
|
||||
<i class="fas fa-clock"></i> {% trans "Time" %}: {{ interview.interview_time|time:"H:i" }}<br>
|
||||
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ interview.schedule.interview_duration }} minutes
|
||||
</p>
|
||||
|
||||
<div class="mt-auto pt-2 border-top">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'scheduled_interview_detail' interview.slug %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-eye"></i> {% trans "View" %}
|
||||
</a>
|
||||
|
||||
{# CRITICAL FIX: Safe access to join URL #}
|
||||
{% if interview.schedule.interview_type == 'Remote' and interview.zoom_meeting and interview.zoom_meeting.join_url %}
|
||||
<a href="{{ interview.zoom_meeting.join_url }}" target="_blank" class="btn btn-sm btn-main-action">
|
||||
<i class="fas fa-link"></i> {% trans "Join" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'update_scheduled_interview' interview.slug %}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
hx-post="{% url 'delete_scheduled_interview' interview.slug %}"
|
||||
hx-target="#deleteModalBody"
|
||||
hx-swap="outerHTML"
|
||||
data-item-name="{{ interview.candidate.name }} Interview">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Table View (Logic is identical, safe access applied) #}
|
||||
<div class="table-view">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Candidate" %}</th>
|
||||
<th scope="col">{% trans "Job" %}</th>
|
||||
<th scope="col">{% trans "Type" %}</th>
|
||||
<th scope="col">{% trans "Date/Time" %}</th>
|
||||
<th scope="col">{% trans "Duration" %}</th>
|
||||
<th scope="col">{% trans "Status" %}</th>
|
||||
<th scope="col" class="text-end">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for interview in meetings %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="text-primary-theme">
|
||||
<a href="{% url 'candidate_detail' interview.candidate.slug %}" class="text-decoration-none text-primary-theme">{{ interview.candidate.name }}</a>
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ interview.schedule.get_interview_type_display }}
|
||||
</td>
|
||||
<td>{{ interview.interview_date|date:"M d, Y" }} <br>({{ interview.interview_time|time:"H:i" }})</td>
|
||||
<td>{{ interview.schedule.interview_duration }} min</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ interview.status }}">
|
||||
{% if interview.status == 'confirmed' %}
|
||||
<i class="fas fa-circle me-1 text-white"></i>
|
||||
{% endif %}
|
||||
{{ interview.status|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
|
||||
{# CRITICAL FIX: Safe access to join URL #}
|
||||
{% if interview.schedule.interview_type == 'Remote' and interview.zoom_meeting and interview.zoom_meeting.join_url %}
|
||||
<a href="{{ interview.zoom_meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'scheduled_interview_detail' interview.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'update_scheduled_interview' interview.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
||||
<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="#meetingModal"
|
||||
hx-post="{% url 'delete_scheduled_interview' interview.slug %}"
|
||||
hx-target="#meetingModalBody"
|
||||
hx-swap="outerHTML"
|
||||
data-item-name="{{ interview.candidate.name }} Interview">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">First</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Next</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Last</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5 card shadow-sm">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-calendar-alt fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
||||
<h3>{% trans "No Interviews found" %}</h3>
|
||||
<p class="text-muted">{% trans "Schedule your first interview or adjust your filters." %}</p>
|
||||
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #}
|
||||
<a href="#" class="btn btn-main-action mt-3">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Schedule an Interview" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
5
templates/interviews/interview_participants_form.html
Normal file
5
templates/interviews/interview_participants_form.html
Normal file
@ -0,0 +1,5 @@
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Save Participants</button>
|
||||
</form>
|
||||
@ -1,82 +1,168 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{%load i18n %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* Custom Teal Theme Variables (Adapt these if defined globally) */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-light: #e0f2f4; /* Very light background accent */
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border-color: #e3e8ed;
|
||||
--kaauh-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Primary Theme Utilities */
|
||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||
.bg-primary-theme-light { background-color: var(--kaauh-teal-light) !important; }
|
||||
.border-primary-theme { border-color: var(--kaauh-teal) !important; }
|
||||
|
||||
/* Custom Button Style */
|
||||
.btn-teal-primary {
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
border-color: var(--kaauh-teal);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-teal-primary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Layout and Typography */
|
||||
.page-header {
|
||||
font-weight: 700;
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-left: 5px solid var(--kaauh-teal);
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
/* Card Styling */
|
||||
.schedule-card {
|
||||
border: 1px solid var(--kaauh-border-color);
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--kaauh-shadow);
|
||||
transition: box-shadow 0.3s ease;
|
||||
padding: 0; /* Control padding inside body */
|
||||
}
|
||||
.schedule-card:hover {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.card-title-border {
|
||||
font-weight: 600;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid var(--kaauh-teal);
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--kaauh-teal);
|
||||
}
|
||||
|
||||
/* Break Times Display */
|
||||
.break-time-container {
|
||||
border-left: 3px solid var(--kaauh-teal);
|
||||
border-radius: 0.5rem;
|
||||
padding: 10px 15px;
|
||||
background-color: var(--kaauh-teal-light);
|
||||
}
|
||||
|
||||
/* FullCalendar Customization */
|
||||
#calendar {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.fc-event-main-frame {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Event bar color (Candidates) */
|
||||
.fc-event-title-container {
|
||||
background-color: var(--kaauh-teal-light);
|
||||
border-left: 3px solid var(--kaauh-teal);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* Break background color is set in JS events */
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="container py-5">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||
<h1 class="h3 page-header">
|
||||
<i class="fas fa-calendar-check me-2"></i> Interview Schedule Preview for {{ job.title }}
|
||||
<i class="fas fa-calendar-alt me-2 text-primary-theme"></i> Interview Schedule Preview: **{{ job.title }}**
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title pb-2 border-bottom">Schedule Details</h5>
|
||||
<div class="row">
|
||||
<div class="card schedule-card mb-5">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<h4 class="card-title-border">{% trans "Schedule Parameters" %}</h4>
|
||||
<div class="row g-4">
|
||||
|
||||
<div class="col-md-6">
|
||||
<p><strong>Period:</strong> {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}</p>
|
||||
<p>
|
||||
<strong>Working Days:</strong>
|
||||
<p class="mb-2"><strong><i class="fas fa-clock me-2 text-primary-theme"></i> Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
|
||||
<p class="mb-2"><strong><i class="fas fa-hourglass-half me-2 text-primary-theme"></i> Interview Duration:</strong> {{ interview_duration }} minutes</p>
|
||||
<p class="mb-2"><strong><i class="fas fa-shield-alt me-2 text-primary-theme"></i> Buffer Time:</strong> {{ buffer_time }} minutes</p>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Period:</strong> {{ start_date|date:"F j, Y" }} — {{ end_date|date:"F j, Y" }}</p>
|
||||
<p class="mb-2"><strong><i class="fas fa-list-check me-2 text-primary-theme"></i> Active Days:</strong>
|
||||
{% for day_id in working_days %}
|
||||
{% if day_id == 0 %}Monday{% endif %}
|
||||
{% if day_id == 1 %}Tuesday{% endif %}
|
||||
{% if day_id == 2 %}Wednesday{% endif %}
|
||||
{% if day_id == 3 %}Thursday{% endif %}
|
||||
{% if day_id == 4 %}Friday{% endif %}
|
||||
{% if day_id == 5 %}Saturday{% endif %}
|
||||
{% if day_id == 6 %}Sunday{% endif %}
|
||||
{% if day_id == 0 %}Mon{% endif %}
|
||||
{% if day_id == 1 %}Tue{% endif %}
|
||||
{% if day_id == 2 %}Wed{% endif %}
|
||||
{% if day_id == 3 %}Thu{% endif %}
|
||||
{% if day_id == 4 %}Fri{% endif %}
|
||||
{% if day_id == 5 %}Sat{% endif %}
|
||||
{% if day_id == 6 %}Sun{% endif %}
|
||||
{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p><strong>Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
|
||||
<p><strong>Interview Duration:</strong> {{ interview_duration }} minutes</p>
|
||||
<p><strong>Buffer Time:</strong> {{ buffer_time }} minutes</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="mb-2"><strong>Daily Break Times:</strong></p>
|
||||
{% if breaks %}
|
||||
<!-- New structured display for breaks -->
|
||||
<div class="d-flex flex-column gap-1 mb-3 p-3 border rounded bg-light">
|
||||
{% for break in breaks %}
|
||||
<small class="text-dark">
|
||||
<i class="far fa-clock me-1 text-muted"></i>
|
||||
{{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }}
|
||||
</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mb-3"><small class="text-muted">No daily breaks scheduled.</small></p>
|
||||
{% endif %}
|
||||
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4 pt-3 border-top">{% trans "Daily Break Times" %}</h5>
|
||||
{% if breaks %}
|
||||
<div class="d-flex flex-wrap gap-3 mt-3">
|
||||
{% for break in breaks %}
|
||||
<span class="badge rounded-pill bg-primary-theme-light text-primary-theme p-2 px-3 fw-normal shadow-sm">
|
||||
<i class="far fa-mug-hot me-1"></i>
|
||||
{{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mt-3"><small class="text-muted"><i class="fas fa-exclamation-circle me-1"></i> No daily breaks scheduled.</small></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title pb-2 border-bottom">Scheduled Interviews</h5>
|
||||
<div class="card schedule-card">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<h4 class="card-title-border">{% trans "Scheduled Interviews Overview" %}</h4>
|
||||
|
||||
<!-- Calendar View -->
|
||||
<div id="calendar-container">
|
||||
<div id="calendar-container" class="mb-5 p-3 border rounded bg-light">
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div class="table-responsive mt-4">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<h5 class="pb-2 border-bottom mb-3 text-primary-theme">{% trans "Detailed List" %}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped">
|
||||
<thead class="bg-primary-theme-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Candidate</th>
|
||||
<th>Email</th>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Candidate</th>
|
||||
<th scope="col">Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in schedule %}
|
||||
<tr>
|
||||
<td>{{ item.date|date:"F j, Y" }}</td>
|
||||
<td>{{ item.time|time:"g:i A" }}</td>
|
||||
<td class="fw-bold text-primary-theme">{{ item.time|time:"g:i A" }}</td>
|
||||
<td>{{ item.candidate.name }}</td>
|
||||
<td>{{ item.candidate.email }}</td>
|
||||
</tr>
|
||||
@ -85,20 +171,19 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4">
|
||||
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="confirm_schedule" class="btn btn-success">
|
||||
<i class="fas fa-check"></i> Confirm Schedule
|
||||
</button>
|
||||
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Edit
|
||||
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
|
||||
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
|
||||
</a>
|
||||
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
|
||||
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include FullCalendar CSS and JS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
|
||||
|
||||
@ -118,6 +203,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
title: '{{ item.candidate.name }}',
|
||||
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
|
||||
url: '#',
|
||||
// Use the theme color for candidate events
|
||||
color: 'var(--kaauh-teal-dark)',
|
||||
extendedProps: {
|
||||
email: '{{ item.candidate.email }}',
|
||||
time: '{{ item.time|time:"g:i A" }}'
|
||||
@ -127,27 +214,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
{% for break in breaks %}
|
||||
{
|
||||
title: 'Break',
|
||||
// FullCalendar requires a specific date for breaks, using start_date as a placeholder for daily breaks.
|
||||
// Note: Breaks displayed on the monthly grid will only show on start_date, but weekly/daily view should reflect it daily if implemented correctly in the backend or using recurring events.
|
||||
start: '{{ start_date|date:"Y-m-d" }}T{{ break.start_time|time:"H:i:s" }}',
|
||||
end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}',
|
||||
color: '#ff9f89',
|
||||
color: '#ff9f89', // A nice soft orange/salmon color for breaks
|
||||
display: 'background'
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
eventClick: function(info) {
|
||||
// Show candidate details in a modal or alert
|
||||
// Log details to console instead of using alert()
|
||||
if (info.event.title !== 'Break') {
|
||||
// IMPORTANT: Since alert() is forbidden, using console log as a fallback.
|
||||
// In a production environment, this would be a custom modal dialog.
|
||||
console.log('Candidate: ' + info.event.title +
|
||||
'\nDate: ' + info.event.start.toLocaleDateString() +
|
||||
'\nTime: ' + info.event.extendedProps.time +
|
||||
'\nEmail: ' + info.event.extendedProps.email);
|
||||
console.log('--- Candidate Interview Details ---');
|
||||
console.log('Candidate: ' + info.event.title);
|
||||
console.log('Date: ' + info.event.start.toLocaleDateString());
|
||||
console.log('Time: ' + info.event.extendedProps.time);
|
||||
console.log('Email: ' + info.event.extendedProps.email);
|
||||
console.log('-----------------------------------');
|
||||
// You would typically open a Bootstrap modal here instead of using console.log
|
||||
}
|
||||
info.jsEvent.preventDefault();
|
||||
},
|
||||
eventDidMount: function(info) {
|
||||
// Darken the text for background events (breaks) for better contrast
|
||||
if (info.event.display === 'background') {
|
||||
info.el.style.backgroundColor = '#ff9f89';
|
||||
}
|
||||
}
|
||||
});
|
||||
calendar.render();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
34
templates/interviews/schedule_interview_location_form.html
Normal file
34
templates/interviews/schedule_interview_location_form.html
Normal file
@ -0,0 +1,34 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 col-md-8">
|
||||
<div class="card shadow-lg border-0 rounded-lg mt-5">
|
||||
<div class="card-header bg-main-action text-white">
|
||||
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'schedule_interview_location_form' schedule.slug %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{# Renders the single 'location' field using the crispy filter #}
|
||||
{{ form|crispy }}
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
|
||||
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary me-2">
|
||||
<i class="fas fa-times me-1"></i> Close
|
||||
</a>
|
||||
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i> Save Location
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@ -139,7 +139,17 @@
|
||||
|
||||
<div class="col-md-8">
|
||||
<h5 class="section-header">{% trans "Schedule Details" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.start_date.id_for_label }}">{% trans "Interview Type" %}</label>
|
||||
{{ form.interview_type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
|
||||
0
templates/interviews/update_interview.html
Normal file
0
templates/interviews/update_interview.html
Normal file
@ -321,6 +321,9 @@
|
||||
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-layer-group me-1"></i> {% trans "Manage Applicants" %}
|
||||
</a>
|
||||
<a href="{% url 'job_cvs_download' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fa-solid fa-download me-1"></i> {% trans "Download All CVs" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
{% load widget_tweaks %}
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@ -248,10 +249,14 @@ body {
|
||||
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
|
||||
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
|
||||
<div class="detail-row-group flex-grow-1">
|
||||
<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 Title" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'job_detail' meeting.get_job.slug %}">{{ meeting.get_job.title|default:"N/A" }}</a></div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'candidate_detail' meeting.get_candidate.slug %}">{{ 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 class="text-decoration-none text-dark" href="{% url 'candidate_detail' meeting.get_candidate.slug %}">{{ 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>
|
||||
{% if meeting.get_candidate.belong_to_agency %}
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Agency" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.hiring_agency.name|default:"N/A" }}</a></div></div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -295,7 +300,24 @@ body {
|
||||
{# --- 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>
|
||||
<div class="d-flex justify-content-between align-item-center" >
|
||||
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
|
||||
<!--manage participants for interview-->
|
||||
<div class="d-flex justify-content-center align-item-center">
|
||||
<button type="button" class="btn btn-primary-teal btn-sm me-2"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#assignParticipants">
|
||||
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %} ({{total_participants}})
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-info"
|
||||
data-bs-toggle="modal"
|
||||
title="Send Interview Emails"
|
||||
data-bs-target="#emailModal">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="simple-table">
|
||||
<thead>
|
||||
@ -436,6 +458,139 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal fade" id="assignParticipants" tabindex="-1" aria-labelledby="assignParticipantsLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" 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 'create_interview_participants' meeting.interview.slug %}">
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
<div class="modal-body table-responsive">
|
||||
|
||||
{{ meeting.name }}
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
|
||||
<table class="table tab table-bordered mt-3">
|
||||
<thead>
|
||||
<th class="col">👥 {% trans "Participants" %}</th>
|
||||
<th class="col">🧑💼 {% trans "Users" %}</th>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.participants.errors }}
|
||||
{{ form.participants }}
|
||||
</td>
|
||||
<td> {{ form.system_users.errors }}
|
||||
{{ form.system_users }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||
<button type="submit" class="btn btn-primary-teal btn-sm">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--email modal class-->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-light">
|
||||
<h5 class="modal-title" id="emailModalLabel">📧 {% trans "Compose Interview Invitation" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'send_interview_email' meeting.interview.slug %}">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ email_form.subject.id_for_label }}" class="form-label fw-bold">Subject</label>
|
||||
{{ email_form.subject | add_class:"form-control" }}
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs" id="messageTabs" role="tablist">
|
||||
{# Candidate/Agency Tab - Active by default #}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="candidate-tab" data-bs-toggle="tab" data-bs-target="#candidate-pane" type="button" role="tab" aria-controls="candidate-pane" aria-selected="true">
|
||||
{% if candidate.belong_to_an_agency %}
|
||||
{% trans "Agency Message" %}
|
||||
{% else %}
|
||||
{% trans "Candidate Message" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
{# Participants Tab #}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="participants-tab" data-bs-toggle="tab" data-bs-target="#participants-pane" type="button" role="tab" aria-controls="participants-pane" aria-selected="false">
|
||||
{% trans "Panel Message (Interviewers)" %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content border border-top-0 p-3 bg-light-subtle">
|
||||
|
||||
{# --- Candidate/Agency Pane --- #}
|
||||
<div class="tab-pane fade show active" id="candidate-pane" role="tabpanel" aria-labelledby="candidate-tab">
|
||||
<p class="text-muted small">{% trans "This email will be sent to the candidate or their hiring agency." %}</p>
|
||||
|
||||
{% if not candidate.belong_to_an_agency %}
|
||||
<div class="form-group">
|
||||
<label for="{{ email_form.message_for_candidate.id_for_label }}" class="form-label d-none">{% trans "Candidate Message" %}</label>
|
||||
{{ email_form.message_for_candidate | add_class:"form-control" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if candidate.belong_to_an_agency %}
|
||||
<div class="form-group">
|
||||
<label for="{{ email_form.message_for_agency.id_for_label }}" class="form-label d-none">{% trans "Agency Message" %}</label>
|
||||
{{ email_form.message_for_agency | add_class:"form-control" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# --- Participants Pane --- #}
|
||||
<div class="tab-pane fade" id="participants-pane" role="tabpanel" aria-labelledby="participants-tab">
|
||||
<p class="text-muted small">{% trans "This email will be sent to the internal and external interview participants." %}</p>
|
||||
<div class="form-group">
|
||||
<label for="{{ email_form.message_for_participants.id_for_label }}" class="form-label d-none">{% trans "Participants Message" %}</label>
|
||||
{{ email_form.message_for_participants | add_class:"form-control" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||
<button type="submit" class="btn btn-primary-teal">{% trans "Send Invitation" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block customJS %}
|
||||
<script>
|
||||
|
||||
@ -229,7 +229,7 @@
|
||||
<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' %}">
|
||||
<a href="{% url 'participants_detail' participant.slug%}" class="btn btn-outline-secondary" title="{% trans 'View' %}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
|
||||
@ -390,10 +390,10 @@
|
||||
<i class="fas fa-users me-2"></i>
|
||||
{% trans "Recent Candidates" %}
|
||||
</h5>
|
||||
<a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-main-action btn-sm">
|
||||
{% comment %} <a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-main-action btn-sm">
|
||||
{% trans "View All Candidates" %}
|
||||
<i class="fas fa-arrow-right ms-1"></i>
|
||||
</a>
|
||||
</a> {% endcomment %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@ -196,7 +196,7 @@
|
||||
<tr>
|
||||
<td class="fw-medium">
|
||||
<a href="{% url 'agency_detail' agency.slug %}"
|
||||
class="text-decoration-none text-primary-theme">
|
||||
class="text-decoration-none text-secondary">
|
||||
{{ agency.name }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@ -91,18 +91,19 @@
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'agency_portal_dashboard' %}" class="text-decoration-none">
|
||||
<a href="{% url 'agency_portal_dashboard' %}" class="text-decoration-none text-secondary">
|
||||
<i class="fas fa-home me-1"></i>{% trans "Dashboard" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'agency_assignment_detail' assignment.slug %}" class="text-decoration-none">
|
||||
{{ assignment.job.title }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">
|
||||
|
||||
{% comment %} <li class="breadcrumb-item active" aria-current="page">
|
||||
{% trans "Submit Candidate" %}
|
||||
</li>
|
||||
</li> {% endcomment %}
|
||||
|
||||
<li class="breadcrumb-item active" aria-current="page" style="
|
||||
color: #F43B5E; /* Rosy Accent Color */
|
||||
font-weight: 600; ">
|
||||
{% trans "Submit Candidate" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@ -114,7 +115,10 @@
|
||||
{% trans "Submit New Candidate" %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0">
|
||||
{% trans "Submit a candidate for" %} {{ assignment.job.title }}
|
||||
<!-- Button trigger modal -->
|
||||
{% trans "Submit a candidate for" %}
|
||||
{{ assignment.job.title }}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@ -386,6 +390,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
|
||||
@ -229,6 +229,17 @@
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
hx-get="{% url 'compose_candidate_email' job.slug %}"
|
||||
hx-target="#emailModalBody"
|
||||
hx-include="#candidate-form"
|
||||
title="Email Participants">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -358,6 +369,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Email Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="emailModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-envelope me-2"></i>{% trans "Compose Email" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="emailModalBody" class="modal-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading email form..." %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@ -225,10 +225,63 @@
|
||||
</div>
|
||||
|
||||
<div class="kaauh-card shadow-sm p-3">
|
||||
{% if candidates %}
|
||||
<div class="bulk-action-bar p-3 bg-light border-bottom">
|
||||
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post" class="action-group">
|
||||
{% csrf_token %}
|
||||
|
||||
{# MODIFIED: Using d-flex for horizontal alignment and align-items-end to align everything based on the baseline of the button/select #}
|
||||
<div class="d-flex align-items-end gap-3">
|
||||
|
||||
{# Select Input Group #}
|
||||
<div>
|
||||
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;">
|
||||
<option selected>
|
||||
----------
|
||||
</option>
|
||||
<option value="Offer">
|
||||
{% trans "Offer Stage" %}
|
||||
</option>
|
||||
{# Include other options here, such as Interview, Offer, Rejected, etc. #}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Button #}
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
{# email button#}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
hx-get="{% url 'compose_candidate_email' job.slug %}"
|
||||
hx-target="#emailModalBody"
|
||||
hx-include="#candidate-form"
|
||||
title="Email Participants">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="get">
|
||||
{% csrf_token %}
|
||||
<table class="table candidate-table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2%">
|
||||
{% if candidates %}
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
||||
</div>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th style="width: 15%"><i class="fas fa-user me-1"></i> {% trans "Name" %}</th>
|
||||
<th style="width: 15%"><i class="fas fa-phone me-1"></i> {% trans "Contact" %}</th>
|
||||
<th style="width: 15%"><i class="fas fa-briefcase me-1"></i> {% trans "Applied Position" %}</th>
|
||||
@ -240,6 +293,12 @@
|
||||
<tbody>
|
||||
{% for candidate in candidates %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input name="candidate_ids" value="{{ candidate.id }}" type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<div class="candidate-name">
|
||||
{{ candidate.name }}
|
||||
@ -288,12 +347,15 @@
|
||||
title="View Resume Template">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</a>
|
||||
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{% if not candidates %}
|
||||
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
@ -347,13 +409,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Email Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="emailModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-envelope me-2"></i>{% trans "Compose Email" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="emailModalBody" class="modal-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading email form..." %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Add any specific JavaScript for hired candidates view if needed
|
||||
console.log('Hired candidates view loaded');
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
// Function to safely update the header checkbox state
|
||||
function updateSelectAllState() {
|
||||
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
|
||||
const totalCount = rowCheckboxes.length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === totalCount) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
|
||||
selectAllCheckbox.addEventListener('change', function () {
|
||||
const isChecked = selectAllCheckbox.checked;
|
||||
|
||||
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||
updateSelectAllState();
|
||||
});
|
||||
|
||||
// 2. Logic to update 'Select All' state based on row checkboxes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.addEventListener('change', updateSelectAllState);
|
||||
});
|
||||
|
||||
// Initial check to set the correct state on load (in case items are pre-checked)
|
||||
updateSelectAllState();
|
||||
}
|
||||
});
|
||||
|
||||
function syncHiredCandidates() {
|
||||
|
||||
@ -233,12 +233,18 @@
|
||||
</button>
|
||||
</form>
|
||||
<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" %} ({{participants_count}})
|
||||
</button>
|
||||
|
||||
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
hx-get="{% url 'compose_candidate_email' job.slug %}"
|
||||
hx-target="#emailModalBody"
|
||||
hx-include="#candidate-form"
|
||||
title="Email Participants">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -374,22 +380,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% comment %} <button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
title="View Profile">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button> {% endcomment %}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#emailModal"
|
||||
hx-get="{% url 'compose_candidate_email' job.slug candidate.slug %}"
|
||||
hx-target="#emailModalBody"
|
||||
title="Email Participants">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
|
||||
{% if candidate.get_latest_meeting %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
@ -399,6 +390,7 @@
|
||||
title="Reschedule">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
@ -407,6 +399,7 @@
|
||||
title="Delete Meeting">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-main-action btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
@ -416,7 +409,7 @@
|
||||
data-modal-title="{% trans 'Schedule Interview' %}"
|
||||
title="Schedule Interview">
|
||||
<i class="fas fa-calendar-plus"></i>
|
||||
</button>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
@ -455,59 +448,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="jobAssignmentModal" tabindex="-1" aria-labelledby="jobAssignmentLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" 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 table-responsive">
|
||||
|
||||
{{ job.internal_job_id }} {{ job.title}}
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
|
||||
<table class="table tab table-bordered mt-3">
|
||||
<thead>
|
||||
<th class="col">👥 {% trans "Participants" %}</th>
|
||||
<th class="col">🧑💼 {% trans "Users" %}</th>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.participants.errors }}
|
||||
{{ form.participants }}
|
||||
</td>
|
||||
<td> {{ form.users.errors }}
|
||||
{{ form.users }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
</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>
|
||||
|
||||
<!-- Email Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
@ -523,6 +464,7 @@
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading email form..." %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -320,7 +320,7 @@
|
||||
<td>{{ candidate.created_at|date:"d-m-Y" }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{% url 'candidate_detail' candidate.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||
<a href="{% url 'candidate_detail' candidate.slug %}" class="btn btn-outline-secondary" title="{% trans 'View' %}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
|
||||
@ -212,8 +212,8 @@
|
||||
<option value="Hired">
|
||||
{% trans "To Hired" %}
|
||||
</option>
|
||||
<option value="Rejected">
|
||||
{% trans "To Rejected" %}
|
||||
<option value="Interview">
|
||||
{% trans "To Interview" %}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
@ -221,10 +221,24 @@
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
{# Separator (Vertical Rule) #}
|
||||
<div class="vr" style="height: 28px;"></div>
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
hx-get="{% url 'compose_candidate_email' job.slug %}"
|
||||
hx-target="#emailModalBody"
|
||||
hx-include="#candidate-form"
|
||||
title="Email Participants">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -338,6 +352,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Email Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="emailModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-envelope me-2"></i>{% trans "Compose Email" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="emailModalBody" class="modal-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading email form..." %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
|
||||
@ -162,18 +162,6 @@
|
||||
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 */
|
||||
@ -340,6 +328,17 @@
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
{# email button#}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
hx-get="{% url 'compose_candidate_email' job.slug %}"
|
||||
hx-target="#emailModalBody"
|
||||
hx-include="#candidate-form"
|
||||
title="Email Participants">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
@ -498,6 +497,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Email Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="emailModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-envelope me-2"></i>{% trans "Compose Email" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="emailModalBody" class="modal-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading email form..." %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user