Compare commits
5 Commits
b9904b3ec8
...
2ff04cfa6e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff04cfa6e | |||
| 302aa8d0bf | |||
| 92013dd4f9 | |||
| d0db3d1323 | |||
| 671ac1a5d7 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -491,4 +491,18 @@ class CandidateExamDateForm(forms.ModelForm):
|
||||
fields = ['exam_date']
|
||||
widgets = {
|
||||
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||
}
|
||||
|
||||
|
||||
class ScheduleInterviewForCandiateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InterviewSchedule
|
||||
fields = ['start_date', 'end_date', 'start_time', 'end_time', 'interview_duration', 'buffer_time']
|
||||
widgets = {
|
||||
'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'}),
|
||||
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 19:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0009_merge_20251013_1714'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='scheduledinterview',
|
||||
name='schedule',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule'),
|
||||
),
|
||||
]
|
||||
14
recruitment/migrations/0012_merge_20251014_1403.py
Normal file
14
recruitment/migrations/0012_merge_20251014_1403.py
Normal file
@ -0,0 +1,14 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-14 11:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0010_alter_scheduledinterview_schedule'),
|
||||
('recruitment', '0011_alter_jobpostingimage_job_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
21
recruitment/migrations/0013_alter_formtemplate_created_by.py
Normal file
21
recruitment/migrations/0013_alter_formtemplate_created_by.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-14 11:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0012_merge_20251014_1403'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='formtemplate',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -273,12 +273,12 @@ class Candidate(Base):
|
||||
CANDIDATE = "Candidate", _("Candidate")
|
||||
|
||||
# Stage transition validation constants
|
||||
STAGE_SEQUENCE = {
|
||||
"Applied": ["Exam", "Interview", "Offer"],
|
||||
"Exam": ["Interview", "Offer"],
|
||||
"Interview": ["Offer"],
|
||||
"Offer": [], # Final stage - no further transitions
|
||||
}
|
||||
# STAGE_SEQUENCE = {
|
||||
# "Applied": ["Exam", "Interview", "Offer"],
|
||||
# "Exam": ["Interview", "Offer"],
|
||||
# "Interview": ["Offer"],
|
||||
# "Offer": [], # Final stage - no further transitions
|
||||
# }
|
||||
|
||||
job = models.ForeignKey(
|
||||
JobPosting,
|
||||
@ -375,50 +375,50 @@ class Candidate(Base):
|
||||
return self.resume.size
|
||||
return 0
|
||||
|
||||
def clean(self):
|
||||
"""Validate stage transitions"""
|
||||
# Only validate if this is an existing record (not being created)
|
||||
if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
# def clean(self):
|
||||
# """Validate stage transitions"""
|
||||
# # Only validate if this is an existing record (not being created)
|
||||
# if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
||||
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
if self.stage not in allowed_next_stages:
|
||||
raise ValidationError(
|
||||
{
|
||||
"stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
||||
f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
|
||||
}
|
||||
)
|
||||
# if self.stage not in allowed_next_stages:
|
||||
# raise ValidationError(
|
||||
# {
|
||||
# "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
||||
# f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
|
||||
# }
|
||||
# )
|
||||
|
||||
# Validate that the stage is a valid choice
|
||||
if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
||||
raise ValidationError(
|
||||
{
|
||||
"stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
|
||||
}
|
||||
)
|
||||
# # Validate that the stage is a valid choice
|
||||
# if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
||||
# raise ValidationError(
|
||||
# {
|
||||
# "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
|
||||
# }
|
||||
# )
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to ensure validation is called"""
|
||||
self.clean() # Call validation before saving
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def can_transition_to(self, new_stage):
|
||||
"""Check if a stage transition is allowed"""
|
||||
if not self.pk: # New record - can be in Applied stage
|
||||
return new_stage == "Applied"
|
||||
# def can_transition_to(self, new_stage):
|
||||
# """Check if a stage transition is allowed"""
|
||||
# if not self.pk: # New record - can be in Applied stage
|
||||
# return new_stage == "Applied"
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
return new_stage in allowed_next_stages
|
||||
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
# return new_stage in allowed_next_stages
|
||||
|
||||
def get_available_stages(self):
|
||||
"""Get list of stages this candidate can transition to"""
|
||||
if not self.pk: # New record
|
||||
return ["Applied"]
|
||||
# def get_available_stages(self):
|
||||
# """Get list of stages this candidate can transition to"""
|
||||
# if not self.pk: # New record
|
||||
# return ["Applied"]
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
# return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
@property
|
||||
def submission(self):
|
||||
@ -441,6 +441,29 @@ class Candidate(Base):
|
||||
return schedule.zoom_meeting
|
||||
return None
|
||||
|
||||
@property
|
||||
def has_future_meeting(self):
|
||||
"""
|
||||
Checks if the candidate has any scheduled interviews for a future date/time.
|
||||
"""
|
||||
# Ensure timezone.now() is used for comparison
|
||||
now = timezone.now()
|
||||
# Check if any related ScheduledInterview has a future interview_date and interview_time
|
||||
# We need to combine date and time for a proper datetime comparison if they are separate fields
|
||||
future_meetings = self.scheduled_interviews.filter(
|
||||
interview_date__gt=now.date()
|
||||
).filter(
|
||||
interview_time__gte=now.time()
|
||||
).exists()
|
||||
|
||||
# Also check for interviews happening later today
|
||||
today_future_meetings = self.scheduled_interviews.filter(
|
||||
interview_date=now.date(),
|
||||
interview_time__gte=now.time()
|
||||
).exists()
|
||||
|
||||
return future_meetings or today_future_meetings
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||
@ -521,7 +544,7 @@ class FormTemplate(Base):
|
||||
blank=True, help_text="Description of the form template"
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="form_templates"
|
||||
User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=False, help_text="Whether this template is active"
|
||||
@ -1005,7 +1028,7 @@ class ScheduledInterview(Base):
|
||||
ZoomMeeting, on_delete=models.CASCADE, related_name="interview"
|
||||
)
|
||||
schedule = models.ForeignKey(
|
||||
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews"
|
||||
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True
|
||||
)
|
||||
interview_date = models.DateField(verbose_name=_("Interview Date"))
|
||||
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
||||
|
||||
@ -3,10 +3,14 @@ from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
from django_q.tasks import async_task
|
||||
from django.db.models.signals import post_save
|
||||
from .models import FormField,FormStage,FormTemplate,Candidate
|
||||
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# @receiver(post_save, sender=JobPosting)
|
||||
# def create_form_for_job(sender, instance, created, **kwargs):
|
||||
# if created:
|
||||
# FormTemplate.objects.create(job=instance, is_active=True, name=instance.title)
|
||||
@receiver(post_save, sender=Candidate)
|
||||
def score_candidate_resume(sender, instance, created, **kwargs):
|
||||
if not instance.is_resume_parsed:
|
||||
|
||||
@ -17,7 +17,6 @@ urlpatterns = [
|
||||
path('jobs/<slug:slug>/candidate/application/success', views.application_success, name='application_success'),
|
||||
path('careers/',views.kaauh_career,name='kaauh_career'),
|
||||
|
||||
|
||||
# LinkedIn Integration URLs
|
||||
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
|
||||
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
|
||||
@ -34,7 +33,6 @@ urlpatterns = [
|
||||
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
||||
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
|
||||
|
||||
|
||||
# Training URLs
|
||||
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
|
||||
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
|
||||
@ -75,7 +73,8 @@ urlpatterns = [
|
||||
|
||||
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'),
|
||||
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
|
||||
path('htmx/bulk_candidate_move_to_exam/', views.bulk_candidate_move_to_exam, name='bulk_candidate_move_to_exam'),
|
||||
|
||||
path('htmx/<slug:slug>/candidate_update_status/', views.candidate_update_status, name='candidate_update_status'),
|
||||
|
||||
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
||||
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
||||
@ -94,4 +93,12 @@ urlpatterns = [
|
||||
|
||||
path('jobs/<slug:slug>/calendar/', views.interview_calendar_view, name='interview_calendar'),
|
||||
path('jobs/<slug:slug>/calendar/interview/<int:interview_id>/', views.interview_detail_view, name='interview_detail'),
|
||||
|
||||
# Candidate Meeting Scheduling/Rescheduling URLs
|
||||
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/', views.schedule_candidate_meeting, name='schedule_candidate_meeting'),
|
||||
path('api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/', views.api_schedule_candidate_meeting, name='api_schedule_candidate_meeting'),
|
||||
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/', views.reschedule_candidate_meeting, name='reschedule_candidate_meeting'),
|
||||
path('api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/', views.api_reschedule_candidate_meeting, name='api_reschedule_candidate_meeting'),
|
||||
# New URL for simple page-based meeting scheduling
|
||||
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting-page/', views.schedule_meeting_for_candidate, name='schedule_meeting_for_candidate'),
|
||||
]
|
||||
|
||||
@ -273,7 +273,7 @@ def get_zoom_meeting_details(meeting_id):
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the meeting details or an error message.
|
||||
The 'start_time' in 'meeting_details' will be a Python datetime object.
|
||||
Date/datetime fields in 'meeting_details' will be ISO format strings.
|
||||
"""
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
@ -289,19 +289,26 @@ def get_zoom_meeting_details(meeting_id):
|
||||
|
||||
if response.status_code == 200:
|
||||
meeting_data = response.json()
|
||||
if 'start_time' in meeting_data and meeting_data['start_time']:
|
||||
try:
|
||||
# Convert ISO 8601 string (with 'Z' for UTC) to datetime object
|
||||
meeting_data['start_time'] = str(datetime.fromisoformat(
|
||||
meeting_data['start_time'].replace('Z', '+00:00')
|
||||
))
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(
|
||||
f"Failed to parse start_time '{meeting_data['start_time']}' for meeting {meeting_id}: {e}"
|
||||
)
|
||||
meeting_data['start_time'] = None # Ensure it's None on failure
|
||||
else:
|
||||
meeting_data['start_time'] = None # Explicitly set to None if not present
|
||||
datetime_fields = [
|
||||
'start_time', 'created_at', 'updated_at',
|
||||
'password_changed_at', 'host_join_before_start_time',
|
||||
'audio_recording_start', 'recording_files_end' # Add any other known datetime fields
|
||||
]
|
||||
for field_name in datetime_fields:
|
||||
if field_name in meeting_data and meeting_data[field_name] is not None:
|
||||
try:
|
||||
# Convert ISO 8601 string to datetime object, then back to ISO string
|
||||
# This ensures consistent string format, handling 'Z' for UTC
|
||||
dt_obj = datetime.fromisoformat(meeting_data[field_name].replace('Z', '+00:00'))
|
||||
meeting_data[field_name] = dt_obj.isoformat()
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(
|
||||
f"Could not parse or re-serialize datetime field '{field_name}' "
|
||||
f"for meeting {meeting_id}: {e}. Original value: '{meeting_data[field_name]}'"
|
||||
)
|
||||
# Keep original string if re-serialization fails, or set to None
|
||||
# meeting_data[field_name] = None
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Meeting details retrieved successfully.",
|
||||
@ -563,3 +570,12 @@ def json_to_markdown_table(data_list):
|
||||
values = [str(row.get(header, "")) for header in headers]
|
||||
markdown += "| " + " | ".join(values) + " |\n"
|
||||
return markdown
|
||||
|
||||
|
||||
def get_candidates_from_request(request):
|
||||
for c in request.POST.items():
|
||||
try:
|
||||
yield models.Candidate.objects.get(pk=c[0])
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
yield None
|
||||
|
||||
@ -5,7 +5,7 @@ from rich import print
|
||||
from django.template.loader import render_to_string
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.http import JsonResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from datetime import datetime,time,timedelta
|
||||
from django.views import View
|
||||
from django.db.models import Q
|
||||
@ -31,6 +31,7 @@ from django.views.generic import CreateView, UpdateView, DetailView, ListView
|
||||
from .utils import (
|
||||
create_zoom_meeting,
|
||||
delete_zoom_meeting,
|
||||
get_candidates_from_request,
|
||||
update_zoom_meeting,
|
||||
get_zoom_meeting_details,
|
||||
schedule_interviews,
|
||||
@ -261,12 +262,13 @@ def create_job(request):
|
||||
else:
|
||||
job.created_by = request.POST.get("created_by", "").strip()
|
||||
if not job.created_by:
|
||||
job.created_by = "University Administrator"
|
||||
job.created_by = request.user.username
|
||||
|
||||
job.save()
|
||||
job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug})
|
||||
job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative)
|
||||
job.application_url=job_apply_url_absolute
|
||||
FormTemplate.objects.create(job=job, is_active=True, name=job.title,created_by=request.user)
|
||||
job.save()
|
||||
messages.success(request, f'Job "{job.title}" created successfully!')
|
||||
return redirect("job_list")
|
||||
@ -327,15 +329,15 @@ def job_detail(request, slug):
|
||||
|
||||
# Count candidates by stage for summary statistics
|
||||
total_applicant = applicants.count()
|
||||
|
||||
|
||||
applied_count = applicants.filter(stage="Applied").count()
|
||||
|
||||
exam_count=applicants.filter(stage="Exam").count
|
||||
|
||||
|
||||
interview_count = applicants.filter(stage="Interview").count()
|
||||
|
||||
|
||||
offer_count = applicants.filter(stage="Offer").count()
|
||||
|
||||
|
||||
|
||||
status_form = JobPostingStatusForm(instance=job)
|
||||
image_upload_form=JobPostingImageForm(instance=job)
|
||||
@ -1009,6 +1011,7 @@ def submit_form(request, template_id):
|
||||
)
|
||||
submission.applicant_email = email.display_value
|
||||
submission.save()
|
||||
time=timezone.now()
|
||||
Candidate.objects.create(
|
||||
first_name=first_name.display_value,
|
||||
last_name=last_name.display_value,
|
||||
@ -1017,6 +1020,7 @@ def submit_form(request, template_id):
|
||||
address=address.display_value,
|
||||
resume=resume.get_file if resume.is_file else None,
|
||||
job=submission.template.job,
|
||||
|
||||
)
|
||||
return redirect('application_success')
|
||||
|
||||
@ -1122,6 +1126,16 @@ def form_submission_details(request, template_id, slug):
|
||||
def schedule_interviews_view(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
# if request.method == "POST" and "Datastar-Request" in request.headers:
|
||||
# form = InterviewScheduleForm(slug=slug)
|
||||
# break_formset = BreakTimeFormSet()
|
||||
# form.initial["candidates"] = get_candidates_from_request(request)
|
||||
# def response():
|
||||
# html = render_to_string("includes/schedule_interview_div.html",{"form": form, "break_formset": break_formset, "job": job})
|
||||
# yield SSE.patch_elements(html,"#candidateviewModalBody")
|
||||
# return DatastarResponse(response())
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
form = InterviewScheduleForm(slug, request.POST)
|
||||
break_formset = BreakTimeFormSet(request.POST)
|
||||
@ -1340,6 +1354,9 @@ def schedule_interviews_view(request, slug):
|
||||
else:
|
||||
form = InterviewScheduleForm(slug=slug)
|
||||
break_formset = BreakTimeFormSet()
|
||||
print(request.headers)
|
||||
if "Hx-Request" in request.headers:
|
||||
form.initial["candidates"] = [Candidate.objects.get(pk=c[0]) for c in request.GET.items()]
|
||||
|
||||
return render(
|
||||
request,
|
||||
@ -1507,7 +1524,7 @@ def candidate_screening_view(request, slug):
|
||||
offer_count=job.candidates.filter(stage='Offer').count()
|
||||
# Get all candidates for this job, ordered by match score (descending)
|
||||
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
|
||||
|
||||
|
||||
|
||||
|
||||
# Get tier categorization parameters
|
||||
@ -1615,33 +1632,31 @@ def candidate_screening_view(request, slug):
|
||||
|
||||
min_ai_score_str = request.GET.get('min_ai_score')
|
||||
tier1_count_str = request.GET.get('tier1_count')
|
||||
|
||||
|
||||
try:
|
||||
# Check if the string value exists and is not an empty string before conversion
|
||||
if min_ai_score_str:
|
||||
min_ai_score = int(min_ai_score_str)
|
||||
else:
|
||||
min_ai_score = 0
|
||||
|
||||
|
||||
if tier1_count_str:
|
||||
tier1_count = int(tier1_count_str)
|
||||
else:
|
||||
tier1_count = 0
|
||||
|
||||
|
||||
except ValueError:
|
||||
# This catches if the user enters non-numeric text (e.g., "abc")
|
||||
min_ai_score = 0
|
||||
tier1_count = 0
|
||||
print(min_ai_score)
|
||||
print(tier1_count)
|
||||
|
||||
# You can now safely use min_ai_score and tier1_count as integers (0 or greater)
|
||||
if min_ai_score > 0:
|
||||
candidates = candidates.filter(match_score__gte=min_ai_score)
|
||||
print(candidates)
|
||||
|
||||
|
||||
if tier1_count > 0:
|
||||
candidates = candidates[:tier1_count]
|
||||
|
||||
|
||||
context = {
|
||||
"job": job,
|
||||
"candidates": candidates,
|
||||
@ -1660,13 +1675,7 @@ def candidate_screening_view(request, slug):
|
||||
return render(request, "recruitment/candidate_screening_view.html", context)
|
||||
|
||||
|
||||
def get_candidates_from_request(request):
|
||||
for c in request.POST.items():
|
||||
try:
|
||||
yield Candidate.objects.get(pk=c[0])
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
yield None
|
||||
|
||||
def candidate_exam_view(request, slug):
|
||||
"""
|
||||
Manage candidate tiers and stage transitions
|
||||
@ -1689,7 +1698,6 @@ def update_candidate_exam_status(request, slug):
|
||||
def bulk_update_candidate_exam_status(request,slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
status = request.headers.get('status')
|
||||
|
||||
if status:
|
||||
for candidate in get_candidates_from_request(request):
|
||||
try:
|
||||
@ -1716,19 +1724,18 @@ def candidate_set_exam_date(request, slug):
|
||||
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
|
||||
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
||||
|
||||
def bulk_candidate_move_to_exam(request):
|
||||
for candidate in get_candidates_from_request(request):
|
||||
candidate.stage = "Exam"
|
||||
candidate.applicant_status = "Candidate"
|
||||
candidate.exam_date = timezone.now()
|
||||
candidate.save()
|
||||
def candidate_update_status(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
mark_as = request.POST.get('mark_as')
|
||||
candidate_ids = request.POST.getlist("candidate_ids")
|
||||
|
||||
messages.success(request, f"Candidates Moved to Exam stage")
|
||||
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
||||
# def response():
|
||||
# yield SSE.patch_elements("","")
|
||||
# yield SSE.execute_script("console.log('hello world');")
|
||||
# return DatastarResponse(response())
|
||||
if c := Candidate.objects.filter(pk__in = candidate_ids):
|
||||
c.update(stage=mark_as,exam_date=timezone.now(),applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant")
|
||||
|
||||
messages.success(request, f"Candidates Updated")
|
||||
response = HttpResponse(redirect("candidate_screening_view", slug=job.slug))
|
||||
response.headers["HX-Refresh"] = "true"
|
||||
return response
|
||||
|
||||
def candidate_interview_view(request,slug):
|
||||
job = get_object_or_404(JobPosting,slug=slug)
|
||||
@ -1748,7 +1755,7 @@ def interview_calendar_view(request, slug):
|
||||
scheduled_interviews = ScheduledInterview.objects.filter(
|
||||
job=job
|
||||
).select_related('candidate', 'zoom_meeting')
|
||||
print(scheduled_interviews)
|
||||
|
||||
# Convert interviews to calendar events
|
||||
events = []
|
||||
for interview in scheduled_interviews:
|
||||
@ -1808,3 +1815,515 @@ def interview_detail_view(request, slug, interview_id):
|
||||
}
|
||||
|
||||
return render(request, 'recruitment/interview_detail.html', context)
|
||||
|
||||
# Candidate Meeting Scheduling/Rescheduling Views
|
||||
@require_POST
|
||||
def api_schedule_candidate_meeting(request, job_slug, candidate_pk):
|
||||
"""
|
||||
Handle POST request to schedule a Zoom meeting for a candidate via HTMX.
|
||||
Returns JSON response for modal update.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||
|
||||
topic = f"Interview: {job.title} with {candidate.name}"
|
||||
start_time_str = request.POST.get('start_time')
|
||||
duration = int(request.POST.get('duration', 60))
|
||||
|
||||
if not start_time_str:
|
||||
return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400)
|
||||
|
||||
try:
|
||||
# Parse datetime from datetime-local input (YYYY-MM-DDTHH:MM)
|
||||
# This will be in server's timezone, create_zoom_meeting will handle UTC conversion
|
||||
naive_start_time = datetime.fromisoformat(start_time_str)
|
||||
# Ensure it's timezone-aware if your system requires it, or let create_zoom_meeting handle it.
|
||||
# For simplicity, assuming create_zoom_meeting handles naive datetimes or they are in UTC.
|
||||
# If start_time is expected to be in a specific timezone, convert it here.
|
||||
# e.g., start_time = timezone.make_aware(naive_start_time, timezone.get_current_timezone())
|
||||
start_time = naive_start_time # Or timezone.make_aware(naive_start_time)
|
||||
except ValueError:
|
||||
return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400)
|
||||
|
||||
if start_time <= timezone.now():
|
||||
return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400)
|
||||
|
||||
result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
zoom_meeting_details = result["meeting_details"]
|
||||
zoom_meeting = ZoomMeeting.objects.create(
|
||||
topic=topic,
|
||||
start_time=start_time, # Store in local timezone
|
||||
duration=duration,
|
||||
meeting_id=zoom_meeting_details["meeting_id"],
|
||||
join_url=zoom_meeting_details["join_url"],
|
||||
password=zoom_meeting_details["password"],
|
||||
# host_email=zoom_meeting_details["host_email"],
|
||||
status=result["zoom_gateway_response"].get("status", "waiting"),
|
||||
zoom_gateway_response=result["zoom_gateway_response"],
|
||||
)
|
||||
scheduled_interview = ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
zoom_meeting=zoom_meeting,
|
||||
interview_date=start_time.date(),
|
||||
interview_time=start_time.time(),
|
||||
status='scheduled' # Or 'confirmed' depending on your workflow
|
||||
)
|
||||
messages.success(request, f"Meeting scheduled with {candidate.name}.")
|
||||
|
||||
# Return updated table row or a success message
|
||||
# For HTMX, you might want to return a fragment of the updated table
|
||||
# For now, returning JSON to indicate success and close modal
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': 'Meeting scheduled successfully!',
|
||||
'join_url': zoom_meeting.join_url,
|
||||
'meeting_id': zoom_meeting.meeting_id,
|
||||
'candidate_name': candidate.name,
|
||||
'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M")
|
||||
})
|
||||
else:
|
||||
messages.error(request, result["message"])
|
||||
return JsonResponse({'success': False, 'error': result["message"]}, status=400)
|
||||
|
||||
|
||||
def schedule_candidate_meeting(request, job_slug, candidate_pk):
|
||||
"""
|
||||
GET: Render modal form to schedule a meeting. (For HTMX)
|
||||
POST: Handled by api_schedule_candidate_meeting.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||
|
||||
if request.method == "POST":
|
||||
return api_schedule_candidate_meeting(request, job_slug, candidate_pk)
|
||||
|
||||
# GET request - render the form snippet for HTMX
|
||||
context = {
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}),
|
||||
'scheduled_interview': None, # Explicitly None for schedule
|
||||
}
|
||||
# Render just the form part, or the whole modal body content
|
||||
return render(request, "includes/meeting_form.html", context)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def api_schedule_candidate_meeting(request, job_slug, candidate_pk):
|
||||
"""
|
||||
Handles GET to render form and POST to process scheduling.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||
|
||||
if request.method == "GET":
|
||||
# This GET is for HTMX to fetch the form
|
||||
context = {
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}),
|
||||
'scheduled_interview': None,
|
||||
}
|
||||
return render(request, "includes/meeting_form.html", context)
|
||||
|
||||
# POST logic (remains the same)
|
||||
topic = f"Interview: {job.title} with {candidate.name}"
|
||||
start_time_str = request.POST.get('start_time')
|
||||
duration = int(request.POST.get('duration', 60))
|
||||
|
||||
if not start_time_str:
|
||||
return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400)
|
||||
|
||||
try:
|
||||
naive_start_time = datetime.fromisoformat(start_time_str)
|
||||
start_time = naive_start_time
|
||||
except ValueError:
|
||||
return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400)
|
||||
|
||||
if start_time <= timezone.now():
|
||||
return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400)
|
||||
|
||||
result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
zoom_meeting_details = result["meeting_details"]
|
||||
zoom_meeting = ZoomMeeting.objects.create(
|
||||
topic=topic,
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
meeting_id=zoom_meeting_details["meeting_id"],
|
||||
join_url=zoom_meeting_details["join_url"],
|
||||
password=zoom_meeting_details["password"],
|
||||
host_email=zoom_meeting_details["host_email"],
|
||||
status=result["zoom_gateway_response"].get("status", "waiting"),
|
||||
zoom_gateway_response=result["zoom_gateway_response"],
|
||||
)
|
||||
scheduled_interview = ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
zoom_meeting=zoom_meeting,
|
||||
interview_date=start_time.date(),
|
||||
interview_time=start_time.time(),
|
||||
status='scheduled'
|
||||
)
|
||||
messages.success(request, f"Meeting scheduled with {candidate.name}.")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': 'Meeting scheduled successfully!',
|
||||
'join_url': zoom_meeting.join_url,
|
||||
'meeting_id': zoom_meeting.meeting_id,
|
||||
'candidate_name': candidate.name,
|
||||
'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M")
|
||||
})
|
||||
else:
|
||||
messages.error(request, result["message"])
|
||||
return JsonResponse({'success': False, 'error': result["message"]}, status=400)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk):
|
||||
"""
|
||||
Handles GET to render form and POST to process rescheduling.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
scheduled_interview = get_object_or_404(
|
||||
ScheduledInterview.objects.select_related('zoom_meeting'),
|
||||
pk=interview_pk,
|
||||
candidate__pk=candidate_pk,
|
||||
job=job
|
||||
)
|
||||
zoom_meeting = scheduled_interview.zoom_meeting
|
||||
|
||||
if request.method == "GET":
|
||||
# This GET is for HTMX to fetch the form
|
||||
initial_data = {
|
||||
'topic': zoom_meeting.topic,
|
||||
'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'),
|
||||
'duration': zoom_meeting.duration,
|
||||
}
|
||||
context = {
|
||||
'job': job,
|
||||
'candidate': scheduled_interview.candidate,
|
||||
'scheduled_interview': scheduled_interview, # Pass for conditional logic in template
|
||||
'initial_data': initial_data,
|
||||
'action_url': reverse('api_reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk})
|
||||
}
|
||||
return render(request, "includes/meeting_form.html", context)
|
||||
|
||||
# POST logic (remains the same)
|
||||
new_start_time_str = request.POST.get('start_time')
|
||||
new_duration = int(request.POST.get('duration', zoom_meeting.duration))
|
||||
|
||||
if not new_start_time_str:
|
||||
return JsonResponse({'success': False, 'error': 'New start time is required.'}, status=400)
|
||||
|
||||
try:
|
||||
naive_new_start_time = datetime.fromisoformat(new_start_time_str)
|
||||
new_start_time = naive_new_start_time
|
||||
except ValueError:
|
||||
return JsonResponse({'success': False, 'error': 'Invalid date/time format for new start time.'}, status=400)
|
||||
|
||||
if new_start_time <= timezone.now():
|
||||
return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400)
|
||||
|
||||
updated_data = {
|
||||
"topic": f"Interview: {job.title} with {scheduled_interview.candidate.name}",
|
||||
"start_time": new_start_time.isoformat() + "Z",
|
||||
"duration": new_duration,
|
||||
}
|
||||
|
||||
result = update_zoom_meeting(zoom_meeting.meeting_id, updated_data)
|
||||
|
||||
if result["status"] == "success":
|
||||
details_result = get_zoom_meeting_details(zoom_meeting.meeting_id)
|
||||
if details_result["status"] == "success":
|
||||
updated_zoom_details = details_result["meeting_details"]
|
||||
zoom_meeting.topic = updated_zoom_details.get("topic", zoom_meeting.topic)
|
||||
zoom_meeting.start_time = new_start_time
|
||||
zoom_meeting.duration = new_duration
|
||||
zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url)
|
||||
zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password)
|
||||
zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status)
|
||||
zoom_meeting.zoom_gateway_response = updated_zoom_details
|
||||
zoom_meeting.save()
|
||||
|
||||
scheduled_interview.interview_date = new_start_time.date()
|
||||
scheduled_interview.interview_time = new_start_time.time()
|
||||
scheduled_interview.status = 'rescheduled'
|
||||
scheduled_interview.save()
|
||||
messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled.")
|
||||
else:
|
||||
logger.warning(f"Zoom meeting {zoom_meeting.meeting_id} updated, but failed to fetch latest details.")
|
||||
zoom_meeting.start_time = new_start_time
|
||||
zoom_meeting.duration = new_duration
|
||||
zoom_meeting.save()
|
||||
scheduled_interview.interview_date = new_start_time.date()
|
||||
scheduled_interview.interview_time = new_start_time.time()
|
||||
scheduled_interview.save()
|
||||
messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)")
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': 'Meeting rescheduled successfully!',
|
||||
'join_url': zoom_meeting.join_url,
|
||||
'new_interview_datetime': new_start_time.strftime("%Y-%m-%d %H:%M")
|
||||
})
|
||||
else:
|
||||
messages.error(request, result["message"])
|
||||
return JsonResponse({'success': False, 'error': result["message"]}, status=400)
|
||||
|
||||
# The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix)
|
||||
# can be removed if their only purpose was to be called by the JS onclicks.
|
||||
# If they were intended for other direct URL access, they can be kept as simple redirects
|
||||
# or wrappers to the api_ versions.
|
||||
# For now, let's assume the api_ versions are the primary ones for HTMX.
|
||||
|
||||
|
||||
def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk):
|
||||
"""
|
||||
Handles GET to display a form for rescheduling a meeting.
|
||||
Handles POST to process the rescheduling of a meeting.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||
scheduled_interview = get_object_or_404(
|
||||
ScheduledInterview.objects.select_related('zoom_meeting'),
|
||||
pk=interview_pk,
|
||||
candidate=candidate,
|
||||
job=job
|
||||
)
|
||||
zoom_meeting = scheduled_interview.zoom_meeting
|
||||
|
||||
# Determine if the candidate has other future meetings
|
||||
# This helps in providing context in the template
|
||||
# Note: This checks for *any* future meetings for the candidate, not just the one being rescheduled.
|
||||
# If candidate.has_future_meeting is True, it implies they have at least one other upcoming meeting,
|
||||
# or the specific meeting being rescheduled is itself in the future.
|
||||
# We can refine this logic if needed, e.g., check for meetings *other than* the current `interview_pk`.
|
||||
has_other_future_meetings = candidate.has_future_meeting
|
||||
# More precise check: if the current meeting being rescheduled is in the future, then by definition
|
||||
# the candidate will have a future meeting (this one). The UI might want to know if there are *others*.
|
||||
# For now, `candidate.has_future_meeting` is a good general indicator.
|
||||
|
||||
if request.method == "POST":
|
||||
form = ZoomMeetingForm(request.POST)
|
||||
if form.is_valid():
|
||||
new_topic = form.cleaned_data.get('topic')
|
||||
new_start_time = form.cleaned_data.get('start_time')
|
||||
new_duration = form.cleaned_data.get('duration')
|
||||
|
||||
# Use a default topic if not provided, keeping the original structure
|
||||
if not new_topic:
|
||||
new_topic = f"Interview: {job.title} with {candidate.name}"
|
||||
|
||||
# Ensure new_start_time is in the future
|
||||
if new_start_time <= timezone.now():
|
||||
messages.error(request, "Start time must be in the future.")
|
||||
# Re-render form with error and initial data
|
||||
return render(request, "recruitment/schedule_meeting_form.html", { # Reusing the same form template
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'scheduled_interview': scheduled_interview,
|
||||
'initial_topic': new_topic,
|
||||
'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '',
|
||||
'initial_duration': new_duration,
|
||||
'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}),
|
||||
'has_future_meeting': has_other_future_meetings # Pass status for template
|
||||
})
|
||||
|
||||
# Prepare data for Zoom API update
|
||||
# The update_zoom_meeting expects start_time as ISO string with 'Z'
|
||||
zoom_update_data = {
|
||||
"topic": new_topic,
|
||||
"start_time": new_start_time.isoformat() + "Z",
|
||||
"duration": new_duration,
|
||||
}
|
||||
|
||||
# Update Zoom meeting using utility function
|
||||
zoom_update_result = update_zoom_meeting(zoom_meeting.meeting_id, zoom_update_data)
|
||||
|
||||
if zoom_update_result["status"] == "success":
|
||||
# Fetch the latest details from Zoom after successful update
|
||||
details_result = get_zoom_meeting_details(zoom_meeting.meeting_id)
|
||||
|
||||
if details_result["status"] == "success":
|
||||
updated_zoom_details = details_result["meeting_details"]
|
||||
# Update local ZoomMeeting record
|
||||
zoom_meeting.topic = updated_zoom_details.get("topic", new_topic)
|
||||
zoom_meeting.start_time = new_start_time # Store the original datetime
|
||||
zoom_meeting.duration = new_duration
|
||||
zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url)
|
||||
zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password)
|
||||
zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status)
|
||||
zoom_meeting.zoom_gateway_response = details_result.get("meeting_details")
|
||||
zoom_meeting.save()
|
||||
|
||||
# Update ScheduledInterview record
|
||||
scheduled_interview.interview_date = new_start_time.date()
|
||||
scheduled_interview.interview_time = new_start_time.time()
|
||||
scheduled_interview.status = 'rescheduled' # Or 'scheduled' if you prefer
|
||||
scheduled_interview.save()
|
||||
messages.success(request, f"Meeting for {candidate.name} rescheduled successfully.")
|
||||
else:
|
||||
# If fetching details fails, update with form data and log a warning
|
||||
logger.warning(
|
||||
f"Successfully updated Zoom meeting {zoom_meeting.meeting_id}, but failed to fetch updated details. "
|
||||
f"Error: {details_result.get('message', 'Unknown error')}"
|
||||
)
|
||||
# Update with form data as a fallback
|
||||
zoom_meeting.topic = new_topic
|
||||
zoom_meeting.start_time = new_start_time
|
||||
zoom_meeting.duration = new_duration
|
||||
zoom_meeting.save()
|
||||
scheduled_interview.interview_date = new_start_time.date()
|
||||
scheduled_interview.interview_time = new_start_time.time()
|
||||
scheduled_interview.save()
|
||||
messages.success(request, f"Meeting for {candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)")
|
||||
|
||||
return redirect('candidate_interview_view', slug=job.slug)
|
||||
else:
|
||||
messages.error(request, f"Failed to update Zoom meeting: {zoom_update_result['message']}")
|
||||
# Re-render form with error
|
||||
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'scheduled_interview': scheduled_interview,
|
||||
'initial_topic': new_topic,
|
||||
'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '',
|
||||
'initial_duration': new_duration,
|
||||
'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}),
|
||||
'has_future_meeting': has_other_future_meetings
|
||||
})
|
||||
else:
|
||||
# Form validation errors
|
||||
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'scheduled_interview': scheduled_interview,
|
||||
'initial_topic': request.POST.get('topic', new_topic),
|
||||
'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''),
|
||||
'initial_duration': request.POST.get('duration', new_duration),
|
||||
'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}),
|
||||
'has_future_meeting': has_other_future_meetings
|
||||
})
|
||||
else: # GET request
|
||||
# Pre-populate form with existing meeting details
|
||||
initial_data = {
|
||||
'topic': zoom_meeting.topic,
|
||||
'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'),
|
||||
'duration': zoom_meeting.duration,
|
||||
}
|
||||
form = ZoomMeetingForm(initial=initial_data)
|
||||
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'scheduled_interview': scheduled_interview, # Pass to template for title/differentiation
|
||||
'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}),
|
||||
'has_future_meeting': has_other_future_meetings # Pass status for template
|
||||
})
|
||||
|
||||
|
||||
def schedule_meeting_for_candidate(request, job_slug, candidate_pk):
|
||||
"""
|
||||
Handles GET to display a simple form for scheduling a meeting for a candidate.
|
||||
Handles POST to process the form, create the meeting, and redirect back.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ZoomMeetingForm(request.POST)
|
||||
if form.is_valid():
|
||||
topic_val = form.cleaned_data.get('topic')
|
||||
start_time_val = form.cleaned_data.get('start_time')
|
||||
duration_val = form.cleaned_data.get('duration')
|
||||
|
||||
# Use a default topic if not provided
|
||||
if not topic_val:
|
||||
topic_val = f"Interview: {job.title} with {candidate.name}"
|
||||
|
||||
# Ensure start_time is in the future
|
||||
if start_time_val <= timezone.now():
|
||||
messages.error(request, "Start time must be in the future.")
|
||||
# Re-render form with error and initial data
|
||||
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'initial_topic': topic_val,
|
||||
'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '',
|
||||
'initial_duration': duration_val
|
||||
})
|
||||
|
||||
# Create Zoom meeting using utility function
|
||||
# The create_zoom_meeting expects start_time as a datetime object
|
||||
# and handles its own conversion to UTC for the API call.
|
||||
zoom_creation_result = create_zoom_meeting(
|
||||
topic=topic_val,
|
||||
start_time=start_time_val, # Pass the datetime object
|
||||
duration=duration_val
|
||||
)
|
||||
|
||||
if zoom_creation_result["status"] == "success":
|
||||
zoom_details = zoom_creation_result["meeting_details"]
|
||||
zoom_meeting_instance = ZoomMeeting.objects.create(
|
||||
topic=topic_val,
|
||||
start_time=start_time_val, # Store the original datetime
|
||||
duration=duration_val,
|
||||
meeting_id=zoom_details["meeting_id"],
|
||||
join_url=zoom_details["join_url"],
|
||||
password=zoom_details.get("password"), # password might be None
|
||||
status=zoom_creation_result["zoom_gateway_response"].get("status", "waiting"),
|
||||
zoom_gateway_response=zoom_creation_result["zoom_gateway_response"],
|
||||
)
|
||||
# Create a ScheduledInterview record
|
||||
ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
zoom_meeting=zoom_meeting_instance,
|
||||
interview_date=start_time_val.date(),
|
||||
interview_time=start_time_val.time(),
|
||||
status='scheduled'
|
||||
)
|
||||
messages.success(request, f"Meeting scheduled with {candidate.name}.")
|
||||
return redirect('candidate_interview_view', slug=job.slug)
|
||||
else:
|
||||
messages.error(request, f"Failed to create Zoom meeting: {zoom_creation_result['message']}")
|
||||
# Re-render form with error
|
||||
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'initial_topic': topic_val,
|
||||
'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '',
|
||||
'initial_duration': duration_val
|
||||
})
|
||||
else:
|
||||
# Form validation errors
|
||||
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate,
|
||||
'initial_topic': request.POST.get('topic', f"Interview: {job.title} with {candidate.name}"),
|
||||
'initial_start_time': request.POST.get('start_time', ''),
|
||||
'initial_duration': request.POST.get('duration', 60)
|
||||
})
|
||||
else: # GET request
|
||||
initial_data = {
|
||||
'topic': f"Interview: {job.title} with {candidate.name}",
|
||||
'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'), # Default to 1 hour from now
|
||||
'duration': 60, # Default duration
|
||||
}
|
||||
form = ZoomMeetingForm(initial=initial_data)
|
||||
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'candidate': candidate
|
||||
})
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
padding-right: var(--bs-gutter-x, 0.75rem); /* Add Bootstrap padding for responsiveness */
|
||||
padding-left: var(--bs-gutter-x, 0.75rem);
|
||||
}
|
||||
|
||||
|
||||
/* === Top Bar === */
|
||||
.top-bar {
|
||||
background-color: white;
|
||||
@ -77,7 +77,7 @@
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
|
||||
}
|
||||
/* Change the outer navbar container to fluid, rely on inner max-width */
|
||||
.navbar-dark > .container {
|
||||
.navbar-dark > .container {
|
||||
max-width: 100%; /* Override default container width */
|
||||
}
|
||||
.nav-link {
|
||||
@ -276,7 +276,7 @@
|
||||
<div class="en small">Princess Nourah bint Abdulrahman University</div>
|
||||
<div class="en small">King Abdullah bin Abdulaziz University Hospital</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 100px;max-width:100px;">
|
||||
</div>
|
||||
@ -325,7 +325,7 @@
|
||||
|
||||
{% trans "Form Templates" %}
|
||||
</span>
|
||||
|
||||
|
||||
</a>
|
||||
</li> {% endcomment %}
|
||||
|
||||
@ -349,8 +349,8 @@
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="nav-item me-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
@ -362,7 +362,7 @@
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="nav-item dropdown ms-2">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
||||
data-bs-offset="0, 8" data-bs-auto-close="outside">
|
||||
@ -390,9 +390,9 @@
|
||||
<span class="d-none d-lg-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" data-bs-popper="static">
|
||||
|
||||
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
@ -401,7 +401,7 @@
|
||||
</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 }}">
|
||||
@ -476,13 +476,13 @@
|
||||
</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<i class="fab fa-linkedin text-primary me-1"></i>
|
||||
<i class="fab fa-linkedin text-primary me-1"></i>
|
||||
<span class="text-primary d-none d-lg-inline ms-auto me-3">
|
||||
{% trans "LinkedIn Connected" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a></li>
|
||||
|
||||
</a></li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li>
|
||||
<form method="post" action="" class="d-inline">
|
||||
@ -518,13 +518,13 @@
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
|
||||
<footer class="mt-auto">
|
||||
<div class="footer-bottom py-3 small text-muted" style="background-color: #00363a;">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap max-width-1600">
|
||||
<p class="mb-0 text-white-50">
|
||||
© {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
|
||||
© {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
|
||||
{% trans "All rights reserved." %}
|
||||
</p>
|
||||
<a class="text-decoration-none" href="https://tenhal.sa/" target='_blank'>
|
||||
@ -588,7 +588,7 @@
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script>
|
||||
{% comment %} <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script> {% endcomment %}
|
||||
|
||||
{% block customJS %}{% endblock %}
|
||||
|
||||
|
||||
150
templates/includes/meeting_form.html
Normal file
150
templates/includes/meeting_form.html
Normal file
@ -0,0 +1,150 @@
|
||||
<!-- This snippet is loaded by HTMX into #meetingModalBody -->
|
||||
<form id="meetingForm" method="post" action="{{ action_url }}?_target=modal" data-bs-theme="light">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="candidate_pk" value="{{ candidate.pk }}">
|
||||
{% if scheduled_interview %}
|
||||
<input type="hidden" name="interview_pk" value="{{ scheduled_interview.pk }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_topic" class="form-label">{% trans "Topic" %}</label>
|
||||
<input type="text" class="form-control" id="id_topic" name="topic" value="{{ initial_data.topic|default:'' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_start_time" class="form-label">{% trans "Start Time and Date" %}</label>
|
||||
<input type="datetime-local" class="form-control" id="id_start_time" name="start_time" value="{{ initial_data.start_time|default:'' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_duration" class="form-label">{% trans "Duration (minutes)" %}</label>
|
||||
<input type="number" class="form-control" id="id_duration" name="duration" value="{{ initial_data.duration|default:60 }}" min="15" step="15" required>
|
||||
</div>
|
||||
|
||||
<div id="meetingDetails" class="alert alert-info" style="display: none;">
|
||||
<strong>{% trans "Meeting Details (will appear after scheduling):" %}</strong>
|
||||
<p><strong>{% trans "Join URL:" %}</strong> <a id="joinUrlDisplay" href="#" target="_blank"></a></p>
|
||||
<p><strong>{% trans "Meeting ID:" %}</strong> <span id="meetingIdDisplay"></span></p>
|
||||
</div>
|
||||
|
||||
<div id="successMessage" class="alert alert-success" style="display: none;">
|
||||
<span id="successText"></span>
|
||||
<small><a id="joinLinkSuccess" href="#" target="_blank" style="color: inherit; text-decoration: underline;">{% trans "Click here to join meeting" %}</a></small>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="alert alert-danger" style="display: none;">
|
||||
<span id="errorText"></span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<button type="submit" class="btn btn-primary" id="scheduleBtn">
|
||||
{% if scheduled_interview %}{% trans "Reschedule Meeting" %}{% else %}{% trans "Schedule Meeting" %}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('meetingForm');
|
||||
const scheduleBtn = document.getElementById('scheduleBtn');
|
||||
const meetingDetailsDiv = document.getElementById('meetingDetails');
|
||||
const joinUrlDisplay = document.getElementById('joinUrlDisplay');
|
||||
const meetingIdDisplay = document.getElementById('meetingIdDisplay');
|
||||
const successMessageDiv = document.getElementById('successMessage');
|
||||
const successText = document.getElementById('successText');
|
||||
const joinLinkSuccess = document.getElementById('joinLinkSuccess');
|
||||
const errorMessageDiv = document.getElementById('errorMessage');
|
||||
const errorText = document.getElementById('errorText');
|
||||
const modalElement = document.getElementById('meetingModal'); // This should be on the parent page
|
||||
const modalTitle = document.querySelector('#meetingModal .modal-title'); // Parent page element
|
||||
|
||||
// Update modal title based on data attributes from the triggering button (if available)
|
||||
// This is a fallback, ideally the parent page JS updates the title before fetching.
|
||||
// Or, the view context could set a variable for the title.
|
||||
// For simplicity, we'll assume parent page JS or rely on initial context.
|
||||
const modalTitleText = modalTitle.getAttribute('data-current-title') || "{% trans 'Schedule Interview' %}";
|
||||
if (modalTitle) {
|
||||
modalTitle.textContent = modalTitleText;
|
||||
}
|
||||
const submitBtnText = scheduleBtn.getAttribute('data-current-submit-text') || "{% trans 'Schedule Meeting' %}";
|
||||
scheduleBtn.textContent = submitBtnText;
|
||||
|
||||
|
||||
form.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
meetingDetailsDiv.style.display = 'none';
|
||||
successMessageDiv.style.display = 'none';
|
||||
errorMessageDiv.style.display = 'none';
|
||||
|
||||
scheduleBtn.disabled = true;
|
||||
scheduleBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> {% trans "Processing..." %}';
|
||||
|
||||
const formData = new FormData(form);
|
||||
// Check if the target is modal, if so, we might want to close it on success
|
||||
const isModalTarget = new URLSearchParams(window.location.search).get('_target') === 'modal';
|
||||
const url = form.action.replace(/\?.*$/, ""); // Remove any existing query params like _target
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': formData.get('csrfmiddlewaretoken'),
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json()) // Always expect JSON for HTMX success/error
|
||||
.then(data => {
|
||||
scheduleBtn.disabled = false;
|
||||
scheduleBtn.innerHTML = submitBtnText; // Reset to original text
|
||||
|
||||
if (data.success) {
|
||||
successText.textContent = data.message;
|
||||
successMessageDiv.style.display = 'block';
|
||||
if (data.join_url) {
|
||||
joinUrlDisplay.textContent = data.join_url;
|
||||
joinUrlDisplay.href = data.join_url;
|
||||
joinLinkSuccess.href = data.join_url;
|
||||
meetingDetailsDiv.style.display = 'block'; // Keep meeting details shown
|
||||
}
|
||||
if (data.meeting_id) {
|
||||
meetingIdDisplay.textContent = data.meeting_id;
|
||||
}
|
||||
|
||||
if (isModalTarget && modalElement) {
|
||||
const bsModal = bootstrap.Modal.getInstance(modalElement);
|
||||
if (bsModal) bsModal.hide();
|
||||
}
|
||||
// Optionally, trigger an event on the parent page to update its list
|
||||
if (window.parent && window.parent.dispatchEvent) {
|
||||
window.parent.dispatchEvent(new CustomEvent('meetingUpdated', { detail: data }));
|
||||
} else {
|
||||
// Fallback: reload the page if it's not in an iframe or parent dispatch is not available
|
||||
// window.location.reload();
|
||||
}
|
||||
|
||||
} else {
|
||||
errorText.textContent = data.error || '{% trans "An unknown error occurred." %}';
|
||||
errorMessageDiv.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
scheduleBtn.disabled = false;
|
||||
scheduleBtn.innerHTML = submitBtnText;
|
||||
errorText.textContent = '{% trans "An error occurred while processing your request." %}';
|
||||
errorMessageDiv.style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
// Repopulate if initial_data was passed (for rescheduling)
|
||||
{% if initial_data %}
|
||||
document.getElementById('id_topic').value = '{{ initial_data.topic }}';
|
||||
document.getElementById('id_start_time').value = '{{ initial_data.start_time }}';
|
||||
document.getElementById('id_duration').value = '{{ initial_data.duration }}';
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
148
templates/includes/schedule_interview_div.html
Normal file
148
templates/includes/schedule_interview_div.html
Normal file
@ -0,0 +1,148 @@
|
||||
<div class="container mt-4">
|
||||
<h1>Schedule Interviews for {{ job.title }}</h1>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<form method="post" id="schedule-form">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>Select Candidates</h5>
|
||||
<div class="form-group">
|
||||
{{ form.candidates }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h5>Schedule Details</h5>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.start_date.id_for_label }}">Start Date</label>
|
||||
{{ form.start_date }}
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.end_date.id_for_label }}">End Date</label>
|
||||
{{ form.end_date }}
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label>Working Days</label>
|
||||
{{ form.working_days }}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.start_time.id_for_label }}">Start Time</label>
|
||||
{{ form.start_time }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.end_time.id_for_label }}">End Time</label>
|
||||
{{ form.end_time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.interview_duration.id_for_label }}">Interview Duration (minutes)</label>
|
||||
{{ form.interview_duration }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.buffer_time.id_for_label }}">Buffer Time (minutes)</label>
|
||||
{{ form.buffer_time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<h5>Break Times</h5>
|
||||
<div id="break-times-container">
|
||||
{{ break_formset.management_form }}
|
||||
{% for form in break_formset %}
|
||||
<div class="break-time-form row mb-2">
|
||||
<div class="col-md-5">
|
||||
<label>Start Time</label>
|
||||
{{ form.start_time }}
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label>End Time</label>
|
||||
{{ form.end_time }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label> </label><br>
|
||||
{{ form.DELETE }}
|
||||
<button type="button" class="btn btn-danger btn-sm remove-break">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" id="add-break" class="btn btn-secondary btn-sm mt-2">Add Break</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">Preview Schedule</button>
|
||||
<a href="{% url 'job_detail' slug=job.slug %}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const addBreakBtn = document.getElementById('add-break');
|
||||
const breakTimesContainer = document.getElementById('break-times-container');
|
||||
const totalFormsInput = document.getElementById('id_breaks-TOTAL_FORMS');
|
||||
|
||||
addBreakBtn.addEventListener('click', function() {
|
||||
const formCount = parseInt(totalFormsInput.value);
|
||||
const newFormHtml = `
|
||||
<div class="break-time-form row mb-2">
|
||||
<div class="col-md-5">
|
||||
<label>Start Time</label>
|
||||
<input type="time" name="breaks-${formCount}-start_time" class="form-control" id="id_breaks-${formCount}-start_time">
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label>End Time</label>
|
||||
<input type="time" name="breaks-${formCount}-end_time" class="form-control" id="id_breaks-${formCount}-end_time">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label> </label><br>
|
||||
<input type="checkbox" name="breaks-${formCount}-DELETE" id="id_breaks-${formCount}-DELETE" style="display:none;">
|
||||
<button type="button" class="btn btn-danger btn-sm remove-break">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newFormHtml;
|
||||
const newForm = tempDiv.firstChild;
|
||||
|
||||
breakTimesContainer.appendChild(newForm);
|
||||
totalFormsInput.value = formCount + 1;
|
||||
});
|
||||
|
||||
// Handle remove button clicks
|
||||
breakTimesContainer.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('remove-break')) {
|
||||
const form = e.target.closest('.break-time-form');
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
deleteCheckbox.checked = true;
|
||||
form.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@ -2,7 +2,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="container mt-4 interview-schedule">
|
||||
<h1>Schedule Interviews for {{ job.title }}</h1>
|
||||
|
||||
<div class="card mt-4">
|
||||
|
||||
@ -22,7 +22,18 @@
|
||||
--stage-interview: #ffc107; /* Warning Yellow */
|
||||
--stage-offer: #28a745; /* Success Green */
|
||||
--stage-inactive: #6c757d; /* Secondary Gray */
|
||||
}
|
||||
|
||||
--kaauh-teal: #00636e; /* Primary Theme / Active Stage */
|
||||
--kaauh-teal-light: #4bb3be; /* For active glow */
|
||||
--color-created: #6c757d; /* Muted Initial State */
|
||||
--color-active: #00636e; /* Teal for Active Flow */
|
||||
--color-posted: #17a2b8; /* Info Blue for External Posting */
|
||||
--color-closed: #ffc107; /* Warning Yellow for Soft End/Review */
|
||||
--color-archived: #343a40; /* Darkest for Final Storage */
|
||||
--color-canceled: #dc3545; /* Red for Negative/Canceled */
|
||||
--color-line-default: #e9ecef; /* Light Gray for all inactive markers */
|
||||
}
|
||||
|
||||
|
||||
/* Primary Color Overrides */
|
||||
.text-primary { color: var(--kaauh-teal) !important; }
|
||||
@ -160,6 +171,132 @@
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.job-timeline-container {
|
||||
padding: 10px 0;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.job-timeline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
list-style: none;
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
/* 🛑 NO CONNECTING LINE: Removed .job-timeline::before and .timeline-stage.is-complete + .timeline-stage::before */
|
||||
|
||||
/* ------------------ Stage & Marker Basics ------------------ */
|
||||
|
||||
.timeline-stage {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
.status-marker {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
|
||||
/* DEFAULT (Inactive/Not Current Status) */
|
||||
background-color: white;
|
||||
border: 3px solid var(--color-line-default);
|
||||
color: var(--color-line-default);
|
||||
|
||||
font-size: 18px;
|
||||
line-height: 34px;
|
||||
z-index: 3;
|
||||
transition: all 0.3s ease, box-shadow 0.3s ease;
|
||||
box-shadow: 0 0 0 2px white;
|
||||
}
|
||||
|
||||
/* ------------------ ACTIVE/CURRENT Status Indicator ------------------ */
|
||||
|
||||
/* The 'is-active' class now completely defines the current status color and highlight */
|
||||
|
||||
.timeline-stage.is-active .status-marker {
|
||||
transform: translateX(-50%) scale(1.15); /* Keep the pop effect */
|
||||
color: white; /* Default text color for all active markers */
|
||||
}
|
||||
|
||||
/* 1. CREATED (Active) */
|
||||
.timeline-stage[data-stage="created"].is-active .status-marker {
|
||||
background-color: var(--color-created);
|
||||
border-color: var(--color-created);
|
||||
box-shadow: 0 0 0 4px rgba(108, 117, 125, 0.5);
|
||||
}
|
||||
|
||||
/* 2. MADE ACTIVE (Active) */
|
||||
.timeline-stage[data-stage="active"].is-active .status-marker {
|
||||
background-color: var(--color-active);
|
||||
border-color: var(--color-active);
|
||||
box-shadow: 0 0 0 4px var(--kaauh-teal-light);
|
||||
}
|
||||
|
||||
/* 3. POSTED TO LINKEDIN (Active) */
|
||||
.timeline-stage[data-stage="posted"].is-active .status-marker {
|
||||
background-color: var(--color-posted);
|
||||
border-color: var(--color-posted);
|
||||
box-shadow: 0 0 0 4px rgba(23, 162, 184, 0.5);
|
||||
}
|
||||
|
||||
/* 4. CANCELED (Active/Terminal) */
|
||||
.timeline-stage[data-stage="canceled"].is-active .status-marker {
|
||||
background-color: var(--color-canceled);
|
||||
border-color: var(--color-canceled);
|
||||
box-shadow: 0 0 0 4px rgba(220, 53, 69, 0.5);
|
||||
}
|
||||
|
||||
/* 5. CLOSED (Active/Terminal) */
|
||||
.timeline-stage[data-stage="closed"].is-active .status-marker {
|
||||
background-color: var(--color-closed);
|
||||
border-color: var(--color-closed);
|
||||
color: #343a40; /* Dark icon for contrast on yellow */
|
||||
box-shadow: 0 0 0 4px rgba(255, 193, 7, 0.5);
|
||||
}
|
||||
|
||||
/* 6. ARCHIVED (Active/Terminal) */
|
||||
.timeline-stage[data-stage="archived"].is-active .status-marker {
|
||||
background-color: var(--color-archived);
|
||||
border-color: var(--color-archived);
|
||||
box-shadow: 0 0 0 4px rgba(52, 58, 64, 0.5);
|
||||
}
|
||||
|
||||
|
||||
/* ------------------ Text Labels ------------------ */
|
||||
|
||||
.status-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #343a40;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Highlight text label for the active status */
|
||||
.timeline-stage.is-active .status-label {
|
||||
color: var(--kaauh-teal);
|
||||
}
|
||||
|
||||
.status-date {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@ -168,8 +305,50 @@
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="job-timeline-container shadow-sm">
|
||||
<div class="card-body">
|
||||
<ul class="job-timeline">
|
||||
|
||||
<li class="timeline-stage" data-stage="created">
|
||||
<div class="status-marker"><i class="fas fa-hammer"></i></div>
|
||||
<div class="status-label">Created</div>
|
||||
<div class="status-date">Jan 1</div>
|
||||
</li>
|
||||
|
||||
<li class="timeline-stage" data-stage="active">
|
||||
<div class="status-marker"><i class="fas fa-play-circle"></i></div>
|
||||
<div class="status-label">Made Active</div>
|
||||
<div class="status-date">Jan 5</div>
|
||||
</li>
|
||||
|
||||
<li class="timeline-stage" data-stage="posted">
|
||||
<div class="status-marker"><i class="fab fa-linkedin"></i></div>
|
||||
<div class="status-label">Posted to LinkedIn</div>
|
||||
<div class="status-date">Jan 6</div>
|
||||
</li>
|
||||
|
||||
<li class="timeline-stage is-active" data-stage="canceled">
|
||||
<div class="status-marker"><i class="fas fa-times-circle"></i></div>
|
||||
<div class="status-label">Canceled</div>
|
||||
<div class="status-date">Jan 15</div>
|
||||
</li>
|
||||
|
||||
<li class="timeline-stage" data-stage="closed">
|
||||
<div class="status-marker"><i class="fas fa-lock"></i></div>
|
||||
<div class="status-label">Closed</div>
|
||||
<div class="status-date"></div>
|
||||
</li>
|
||||
|
||||
<li class="timeline-stage" data-stage="archived">
|
||||
<div class="status-marker"><i class="fas fa-archive"></i></div>
|
||||
<div class="status-label">Archived</div>
|
||||
<div class="status-date"></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'dashboard' %}" class="text-secondary">Home</a></li>
|
||||
|
||||
@ -290,7 +290,7 @@
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td class="fw-medium text-primary-theme">
|
||||
{{ job.title }}
|
||||
<a href="{% url 'job_detail' job.slug %}" class="text-decoration-none">{{ job.title }}</a>
|
||||
<br>
|
||||
<small class="text-muted">{{ job.pk }} / </small>
|
||||
<span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span>
|
||||
@ -345,7 +345,7 @@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">{{ job.title }}</h5>
|
||||
<h5 class="card-title mb-0"><a href="{% url 'job_detail' job.slug %}" class="text-decoration-none text-primary-theme">{{ job.title }}</a></h5>
|
||||
<span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">ID: {{ job.pk }} | Source: {{ job.get_source }}</p>
|
||||
|
||||
@ -1,306 +1,476 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% trans "Meeting Details" %} - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* UI Variables for the KAAT-S Theme (Consistent with list_meetings.html) */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
}
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* KAAT-S Redesign CSS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/* Main Container and Card Styling */
|
||||
.container {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.card:not(.no-hover):hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.card.no-hover:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
:root {
|
||||
--kaauh-teal: #00636e; /* Primary Brand Teal */
|
||||
--kaauh-teal-dark: #004a53; /* Darker Teal for Text/Hover */
|
||||
--kaauh-teal-light: #e0f7f9; /* Lightest Teal for background accents */
|
||||
--kaauh-border: #e9ecef; /* Soft Border Gray */
|
||||
--kaauh-primary-text: #212529; /* Dark Text */
|
||||
--kaauh-secondary-text: #6c757d;/* Muted Text */
|
||||
--kaauh-gray-light: #f8f9fa; /* Card Header/Footer Background */
|
||||
--kaauh-success: #198754; /* Success Green */
|
||||
--kaauh-danger: #dc3545; /* Danger Red */
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f0f2f5; /* Off-white page background */
|
||||
font-family: 'Inter', sans-serif; /* Use a modern font stack */
|
||||
}
|
||||
|
||||
/* Header Card Styling */
|
||||
.card-header {
|
||||
background-color: var(--kaauh-gray-light);
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem 0.75rem 0 0; /* Match top border radius of card */
|
||||
}
|
||||
.card-header h1 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0; /* Space below title */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.card-header .heroicon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--kaauh-teal);
|
||||
}
|
||||
.card-header .btn-secondary {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.card-header .btn-secondary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
}
|
||||
/* ------------------ General Layout & Card Styles ------------------ */
|
||||
|
||||
/* Status Badge Styling (from list_meetings.html) */
|
||||
.status-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4em 0.8em;
|
||||
border-radius: 0.4rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
|
||||
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
|
||||
.bg-ended { background-color: #dc3545 !important; color: white !important;}
|
||||
.container {
|
||||
width:auto;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
.card {
|
||||
border: none; /* Remove default border */
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.08), 0 4px 10px rgba(0,0,0,0.05); /* Deep, soft shadow */
|
||||
background-color: white;
|
||||
margin-bottom: 2.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card:not(.no-hover):hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 15px 40px rgba(0,0,0,0.1), 0 6px 15px rgba(0,0,0,0.08);
|
||||
}
|
||||
.card.no-hover:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.08), 0 4px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
.card-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* ------------------ Header & Title Styles ------------------ */
|
||||
|
||||
/* Detail Row Styling */
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-teal-dark);
|
||||
min-width: 40%; /* Ensure labels align */
|
||||
}
|
||||
.detail-value {
|
||||
text-align: right;
|
||||
color: var(--kaauh-primary-text);
|
||||
word-wrap: break-word; /* Long URLs or text wrap */
|
||||
max-width: 60%; /* Ensure values align */
|
||||
}
|
||||
.card-header {
|
||||
background-color: var(--kaauh-gray-light);
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
padding: 2rem;
|
||||
border-radius: 12px 12px 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start; /* Align title group to the top */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-header-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.card-header h1 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 800; /* Extra bold for prominence */
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.card-header .heroicon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--kaauh-teal);
|
||||
}
|
||||
.card-header .btn-secondary-back {
|
||||
/* Subtle Back Button */
|
||||
align-self: flex-start;
|
||||
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;
|
||||
}
|
||||
.card-header .btn-secondary-back:hover {
|
||||
color: var(--kaauh-teal);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Card Title Styling within content cards */
|
||||
.card h2 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 600;
|
||||
padding: 1.5rem 1.5rem 0.75rem;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
}
|
||||
/* ------------------ Status Badge Styles ------------------ */
|
||||
|
||||
/* Join URL Styling */
|
||||
.join-url-display {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-top: 0.75rem;
|
||||
word-break: break-all; /* Force long URLs to break */
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 20px; /* Pill shape */
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.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;}
|
||||
|
||||
/* Actions Styling */
|
||||
.actions {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem; /* Space between buttons */
|
||||
justify-content: flex-start; /* Align buttons to the left */
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
color: white;
|
||||
}
|
||||
/* ------------------ Detail Row & Content Styles ------------------ */
|
||||
|
||||
/* API Response Styling */
|
||||
#gateway-response {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
#gateway-response .card {
|
||||
border-left: 4px solid var(--kaauh-teal); /* Indicate it's different content */
|
||||
}
|
||||
#gateway-response pre {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
overflow-x: auto; /* For long JSON lines */
|
||||
font-size: 0.9rem;
|
||||
color: var(--kaauh-primary-text);
|
||||
}
|
||||
.card h2 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 700;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
}
|
||||
|
||||
.detail-row-group {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 40%) 1fr;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
align-items: center;
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-teal-dark);
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.detail-value {
|
||||
text-align: right;
|
||||
color: var(--kaauh-primary-text);
|
||||
word-wrap: break-word;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ------------------ Join Info & Copy Button ------------------ */
|
||||
|
||||
.join-info-card .card-body {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
box-shadow: 0 4px 8px rgba(0, 99, 110, 0.3);
|
||||
}
|
||||
|
||||
.join-url-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
position: relative;
|
||||
padding: 1rem 0; /* Add padding for clear space around the copy area */
|
||||
}
|
||||
.join-url-display {
|
||||
flex-grow: 1;
|
||||
background-color: var(--kaauh-gray-light);
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
word-break: break-all;
|
||||
font-size: 0.9rem;
|
||||
color: var(--kaauh-secondary-text);
|
||||
font-family: monospace; /* Monospace for links/code */
|
||||
}
|
||||
.join-url-display strong {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
flex-shrink: 0;
|
||||
background-color: var(--kaauh-teal-dark); /* Darker teal for a clean utility look */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-copy:hover {
|
||||
background-color: var(--kaauh-teal);
|
||||
}
|
||||
.btn-copy i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* 🎯 Copy Message Pill Style */
|
||||
#copy-message {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: 0;
|
||||
background-color: var(--kaauh-success);
|
||||
color: white;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 20px; /* Pill shape */
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease-in-out;
|
||||
z-index: 10;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* ------------------ Footer & Actions ------------------ */
|
||||
|
||||
.card-footer {
|
||||
border-top: 1px solid var(--kaauh-border);
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
justify-content: flex-start;
|
||||
background-color: var(--kaauh-gray-light);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: var(--kaauh-danger);
|
||||
border-color: var(--kaauh-danger);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
|
||||
/* ------------------ API Response Styling ------------------ */
|
||||
#gateway-response-card {
|
||||
border-left: 5px solid var(--kaauh-teal); /* Prominent left border */
|
||||
}
|
||||
#gateway-response-card .card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
#gateway-response-card h3 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 700;
|
||||
font-size: 1.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
#gateway-response-card pre {
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--kaauh-primary-text);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="container">
|
||||
|
||||
<div class="card no-hover">
|
||||
<div class="card-header">
|
||||
<h1>
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{{ meeting.topic }}
|
||||
</h1>
|
||||
<span class="status-badge bg-{{ meeting.status }}">
|
||||
{{ meeting.status|title }}
|
||||
</span>
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary mt-3">{% trans "Back to Meetings" %}</a>
|
||||
<div class="card-header-title-group">
|
||||
<h1>
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)">
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{{ meeting.topic }}
|
||||
</h1>
|
||||
<span class="status-badge bg-{{ meeting.status }}">
|
||||
{{ meeting.status|title }}
|
||||
</span>
|
||||
</div>
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary-back">
|
||||
<i class="fas fa-arrow-left"></i> {% trans "Back to Meetings" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card no-hover">
|
||||
<h2>{% trans "Meeting Information" %}</h2>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Meeting ID" %}:</div>
|
||||
<div class="detail-value">{{ meeting.meeting_id }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Topic" %}:</div>
|
||||
<div class="detail-value">{{ meeting.topic }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Start Time" %}:</div>
|
||||
<div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Duration" %}:</div>
|
||||
<div class="detail-value">{{ meeting.duration }} {% trans "minutes" %}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Timezone" %}:</div>
|
||||
<div class="detail-value">{{ meeting.timezone|default:"UTC" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Host Email" %}:</div>
|
||||
<div class="detail-value">{{ meeting.host_email|default:"N/A" }}</div>
|
||||
<div class="card-body detail-row-group">
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Meeting ID" %}:</div><div class="detail-value">{{ meeting.meeting_id }}</div></div>
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Topic" %}:</div><div class="detail-value">{{ meeting.topic }}</div></div>
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Start Time" %}:</div><div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div></div>
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Duration" %}:</div><div class="detail-value">{{ meeting.duration }} {% trans "minutes" %}</div></div>
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Timezone" %}:</div><div class="detail-value">{{ meeting.timezone|default:"UTC" }}</div></div>
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Host Email" %}:</div><div class="detail-value">{{ meeting.host_email|default:"N/A" }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if meeting.join_url %}
|
||||
<div class="card no-hover">
|
||||
<div class="card no-hover join-info-card">
|
||||
<h2>{% trans "Join Information" %}</h2>
|
||||
<a href="{{ meeting.join_url }}" class="btn btn-primary" target="_blank">{% trans "Join Meeting" %}</a>
|
||||
{% comment %} <div class="join-url-display">
|
||||
<strong>{% trans "Join URL:" %}</strong> {{ meeting.join_url }}
|
||||
</div> {% endcomment %}
|
||||
{% if meeting.password %}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Password" %}: {{ meeting.password }}</div>
|
||||
<div class="detail-value"></div>
|
||||
<div class="card-body">
|
||||
<a href="{{ meeting.join_url }}" class="btn btn-primary" target="_blank">
|
||||
<i class="fas fa-video"></i> {% trans "Join Meeting Now" %}
|
||||
</a>
|
||||
|
||||
<div class="join-url-container">
|
||||
<div id="copy-message" style="opacity: 0;">{% trans "Copied!" %}</div>
|
||||
|
||||
<div class="join-url-display" id="join-url-display">
|
||||
<strong>{% trans "Join URL" %}:</strong> <span id="meeting-join-url">{{ meeting.join_url }}</span>
|
||||
</div>
|
||||
|
||||
<button class="btn-copy" onclick="copyLink()">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if meeting.password %}
|
||||
<div class="detail-row" style="border: none; padding: 1rem 0 0 0;">
|
||||
<div class="detail-label" style="font-size: 1rem;">{% trans "Password" %}:</div>
|
||||
<div class="detail-value" style="font-weight: 700; color: var(--kaauh-danger);">{{ meeting.password }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card no-hover">
|
||||
<h2>{% trans "Settings" %}</h2>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Participant Video" %}:</div>
|
||||
<div class="detail-value">{{ meeting.participant_video|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Join Before Host" %}:</div>
|
||||
<div class="detail-value">{{ meeting.join_before_host|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Mute Upon Entry" %}:</div>
|
||||
<div class="detail-value">{{ meeting.mute_upon_entry|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Waiting Room" %}:</div>
|
||||
<div class="detail-value">{{ meeting.waiting_room|yesno:"Yes,No" }}</div>
|
||||
<div class="card-body detail-row-group">
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Participant Video" %}:</div><div class="detail-value">{{ meeting.participant_video|yesno:"Yes,No" }}</div></div>
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Join Before Host" %}:</div><div class="detail-value">{{ meeting.join_before_host|yesno:"Yes,No" }}</div></div>
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Mute Upon Entry" %}:</div><div class="detail-value">{{ meeting.mute_upon_entry|yesno:"Yes,No" }}</div></div>
|
||||
<div class="detail-row"><div class="detail-label">{% trans "Waiting Room" %}:</div><div class="detail-value">{{ meeting.waiting_room|yesno:"Yes,No" }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card no-hover">
|
||||
<div class="actions">
|
||||
<div class="card-footer">
|
||||
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary">
|
||||
<i class="fas fa-edit"></i> {% trans "Update Meeting" %}
|
||||
</a>
|
||||
|
||||
{% if meeting.zoom_gateway_response %}
|
||||
<a href="#" class="btn btn-secondary" onclick="toggleGateway()">{% trans "View API Response" %}</a>
|
||||
<button type="button" class="btn btn-secondary" onclick="toggleGateway()">
|
||||
<i class="fas fa-code"></i> {% trans "View API Response" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary">{% trans "Update Meeting" %}</a>
|
||||
|
||||
<form method="post" action="{% url 'delete_meeting' meeting.pk %}" style="display: inline;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('{% trans "Are you sure?" %}')">{% trans "Delete Meeting" %}</button>
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('{% trans "Are you sure you want to delete this meeting? This action is permanent." %}')">
|
||||
<i class="fas fa-trash-alt"></i> {% trans "Delete Meeting" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if meeting.zoom_gateway_response %}
|
||||
{% comment %} <div id="gateway-response">
|
||||
<div class="card">
|
||||
<h3>{% trans "Zoom API Response" %}</h3>
|
||||
<div id="gateway-response-card" class="card" style="display: none;">
|
||||
<div class="card-body">
|
||||
<h3>{% trans "API Gateway Response" %}</h3>
|
||||
<pre>{{ meeting.zoom_gateway_response|safe }}</pre>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
<script>
|
||||
function toggleGateway() {
|
||||
const element = document.getElementById('gateway-response');
|
||||
if (element.style.display === 'none' || !element.style.display) {
|
||||
element.style.display = 'block';
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block customJS %}
|
||||
<script>
|
||||
function toggleGateway() {
|
||||
const element = document.getElementById('gateway-response-card');
|
||||
if (element.style.display === 'none' || element.style.display === '') {
|
||||
element.style.display = 'block';
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
const urlElement = document.getElementById('meeting-join-url');
|
||||
const messageElement = document.getElementById('copy-message');
|
||||
const textToCopy = urlElement.textContent || urlElement.innerText;
|
||||
|
||||
// Clear any existing message
|
||||
clearTimeout(window.copyMessageTimeout);
|
||||
|
||||
// Function to show the message
|
||||
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';
|
||||
|
||||
// Hide the message after 2 seconds
|
||||
window.copyMessageTimeout = setTimeout(() => {
|
||||
messageElement.style.opacity = '0';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Use the modern clipboard API
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
showMessage(true); // Show success message
|
||||
}).catch(err => {
|
||||
console.error('Could not copy text: ', err);
|
||||
fallbackCopyTextToClipboard(textToCopy, showMessage); // Try fallback on failure
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
fallbackCopyTextToClipboard(textToCopy, showMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback function for older browsers
|
||||
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); // Call the message function with the result
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -369,14 +369,53 @@
|
||||
<i class="far fa-clock ms-2 me-1"></i> {{ candidate.created_at|date:"h:i A" }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% if candidate.exam_date %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-icon timeline-bg-applied"><i class="fas fa-clipboard-check"></i></div>
|
||||
<div class="timeline-content">
|
||||
<p class="timeline-stage fw-bold mb-0 ms-2">{% trans "Exam" %}</p>
|
||||
<small class="text-muted">
|
||||
<i class="far fa-calendar-alt me-1"></i> {{ candidate.exam_date|date:"M d, Y" }}
|
||||
<span class="ms-2">|</span>
|
||||
<i class="far fa-clock ms-2 me-1"></i> {{ candidate.exam_date|date:"h:i A" }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# Fallback if no history is explicitly logged #}
|
||||
{% if not candidate_stage_history %}
|
||||
<p class="text-muted mt-3 mb-0 ps-3">
|
||||
{% trans "Detailed stage history logs are currently unavailable." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if candidate.interview_date %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-icon timeline-bg-applied"><i class="fas fas fa-comments"></i></div>
|
||||
<div class="timeline-content">
|
||||
<p class="timeline-stage fw-bold mb-0 ms-2">{% trans "Interview" %}</p>
|
||||
<small class="text-muted">
|
||||
<i class="far fa-calendar-alt me-1"></i> {{ candidate.interview_date|date:"M d, Y" }}
|
||||
<span class="ms-2">|</span>
|
||||
<i class="far fa-clock ms-2 me-1"></i> {{ candidate.interview_date|date:"h:i A" }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if candidate.offer_date %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-icon timeline-bg-applied"><i class="fas fas fa-handshake"></i></div>
|
||||
<div class="timeline-content">
|
||||
<p class="timeline-stage fw-bold mb-0 ms-2">{% trans "Offer" %}</p>
|
||||
<small class="text-muted">
|
||||
<i class="far fa-calendar-alt me-1"></i> {{ candidate.offer_date|date:"M d, Y" }}
|
||||
<span class="ms-2">|</span>
|
||||
<i class="far fa-clock ms-2 me-1"></i> {{ candidate.offer_date|date:"h:i A" }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-info: #17a2b8; /* Used for Exam stages (Pending status) */
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-warning: #ffc107;
|
||||
@ -28,7 +28,7 @@
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
|
||||
/* Dedicated style for the tier control block (consistent with .filter-controls) */
|
||||
.tier-controls {
|
||||
background-color: var(--kaauh-border); /* Light background for control sections */
|
||||
@ -96,14 +96,14 @@
|
||||
.form-control-sm,
|
||||
.btn-sm {
|
||||
/* Reduce vertical padding even more than default Bootstrap 'sm' */
|
||||
padding-top: 0.2rem !important;
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
/* Ensure a consistent, small height for inputs and buttons */
|
||||
height: 28px !important;
|
||||
font-size: 0.8rem !important;
|
||||
height: 28px !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
.btn-main-action.btn-sm { font-weight: 600 !important; }
|
||||
|
||||
|
||||
/* Container for the timeline include */
|
||||
.applicant-tracking-timeline {
|
||||
margin-bottom: 2rem;
|
||||
@ -111,7 +111,7 @@
|
||||
|
||||
/* 4. Candidate Table Styling (KAAT-S Look) */
|
||||
.candidate-table {
|
||||
table-layout: fixed;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
@ -172,7 +172,7 @@
|
||||
.bg-success { background-color: var(--kaauh-success) !important; color: white; }
|
||||
.bg-danger { background-color: var(--kaauh-danger) !important; color: white; }
|
||||
.bg-info-pending { background-color: var(--kaauh-info) !important; color: white; }
|
||||
|
||||
|
||||
.tier-badge { /* Used for Tier labels */
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
@ -191,7 +191,7 @@
|
||||
.candidate-table th:nth-child(5) { width: 12%; } /* Exam Status */
|
||||
.candidate-table th:nth-child(6) { width: 15%; } /* Exam Date */
|
||||
.candidate-table th:nth-child(7) { width: 220px; } /* Actions */
|
||||
|
||||
|
||||
.cd_exam{
|
||||
color: #00636e;
|
||||
}
|
||||
@ -253,32 +253,23 @@
|
||||
|
||||
<div class="kaauh-card shadow-sm p-3">
|
||||
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
|
||||
{% url "bulk_update_candidate_exam_status" job.slug as bulk_update_candidate_exam_status_url %}
|
||||
|
||||
<div class="mb-3 d-flex gap-2">
|
||||
<div class="col-md-3 col-sm-6 mb-3 d-flex gap-2">
|
||||
{% if candidates %}
|
||||
<button class="btn btn-bulk-pass btn-sm"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#candidate-form',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Passed'}
|
||||
})"
|
||||
>
|
||||
<i class="fas fa-check-circle me-1"></i>
|
||||
{% trans "Bulk Mark Passed (-> Interview)" %}
|
||||
</button>
|
||||
<button class="btn btn-bulk-fail btn-sm"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#candidate-form',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Failed'}
|
||||
})"
|
||||
>
|
||||
<i class="fas fa-times-circle me-1"></i>
|
||||
{% trans "Bulk Mark Failed" %}
|
||||
</button>
|
||||
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||
<div class="d-flex align-items-center">
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
|
||||
<option value="Applied">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Apply" %}
|
||||
</option>
|
||||
<option value="Interview">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Interview" %}
|
||||
</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-main-action btn-mds ms-2">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -291,11 +282,7 @@
|
||||
{% if candidates %}
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-_all
|
||||
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
||||
data-effect="$selections; $_all = $selections.every(Boolean)"
|
||||
data-attr-disabled="$_fetching"
|
||||
type="checkbox" class="form-check-input" id="checkAll">
|
||||
type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
||||
</div>
|
||||
{% endif %}
|
||||
</th>
|
||||
@ -312,13 +299,11 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-selections
|
||||
data-attr-disabled="$_fetching"
|
||||
name="candidate_ids"
|
||||
value="{{ candidate.id }}"
|
||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
<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">
|
||||
@ -378,14 +363,14 @@
|
||||
</table>
|
||||
{% if not candidates %}
|
||||
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
{% trans "No candidates are currently in the Exam stage for this job." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||
@ -411,4 +396,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
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 {
|
||||
// Set to indeterminate state (partially checked)
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
|
||||
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
|
||||
// Your existing data-bind-_all logic should handle the bulk action status.
|
||||
}
|
||||
|
||||
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
|
||||
selectAllCheckbox.addEventListener('change', function () {
|
||||
const isChecked = selectAllCheckbox.checked;
|
||||
|
||||
// Temporarily disable the change listener on rows to prevent cascading events
|
||||
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||
|
||||
// Update all row checkboxes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
|
||||
// You must still dispatch the event here so your framework's data-bind-selections
|
||||
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Re-attach the change listeners to the rows
|
||||
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||
|
||||
// Ensure the header state is correct after forcing all changes
|
||||
updateSelectAllState();
|
||||
});
|
||||
|
||||
// 2. Logic to update 'Select All' state based on row checkboxes
|
||||
// Attach the function to be called whenever a row checkbox changes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.addEventListener('change', updateSelectAllState);
|
||||
});
|
||||
|
||||
// Initial check to set the correct state on load (in case items are pre-checked)
|
||||
updateSelectAllState();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -214,15 +214,16 @@
|
||||
<!-- Tier Display -->
|
||||
<h2 class="h4 mb-3 mt-5">{% trans "Candidate Tiers" %}</h2>
|
||||
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
|
||||
{% url "candidate_interview_view" job.slug as bulk_update_candidate_exam_status_url %}
|
||||
{% url "schedule_interviews" job.slug as bulk_update_candidate_exam_status_url %}
|
||||
{% if candidates %}
|
||||
<button class="btn btn-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#myform',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'pass'}
|
||||
})"
|
||||
hx-get="{{bulk_update_candidate_exam_status_url}}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
hx-include="#myform"
|
||||
hx-select=".interview-schedule"
|
||||
>Mark as Pass and move to Interview</button>
|
||||
<button class="btn btn-danger"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
@ -233,7 +234,7 @@
|
||||
})"
|
||||
>Mark as Failed</button>
|
||||
{% endif %}
|
||||
<form id="myform" action="{{move_to_exam_url}}" method="post">
|
||||
<form id="myform" action="{{bulk_update_candidate_exam_status_url}}" method="post">
|
||||
<table class="candidate-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -282,14 +283,9 @@
|
||||
<td>{{candidate.get_latest_meeting.start_time|date:"m-d-Y h:i A"}}</td>
|
||||
<td><a href="{{candidate.get_latest_meeting.join_url}}">{% include "icons/link.html" %}</a></td>
|
||||
<td>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
>
|
||||
{% include "icons/view.html" %}
|
||||
{% trans "View" %}</button>
|
||||
<a href="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}" class="btn btn-primary btn-sm me-1" title="{% trans 'Schedule Interview' %}">
|
||||
<i class="fas fa-calendar-plus"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -299,7 +295,7 @@
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
|
||||
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||
<div class="modal fade modal-xl" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@ -315,4 +311,75 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Main Meeting Schedule/Reschedule Modal -->
|
||||
<div class="modal fade" id="meetingModal" tabindex="-1" aria-labelledby="meetingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="meetingModalLabel">
|
||||
{% trans "Schedule Interview" %} <!-- Default title -->
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="meetingModalBody" class="modal-body">
|
||||
<!-- HTMX will load the form here -->
|
||||
<p class="text-center text-muted">{% trans "Loading form..." %}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<!-- HTMX might update the button text or add specific actions here if needed -->
|
||||
<!-- For now, the form itself has its own submit and cancel buttons -->
|
||||
<!-- The cancel button inside the form will close the modal -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const meetingModalElement = document.getElementById('meetingModal');
|
||||
const meetingModalTitle = meetingModalElement.querySelector('#meetingModalLabel');
|
||||
// The submit button is now inside the form snippet loaded by HTMX.
|
||||
// We will pass title info via data-* attributes or rely on the snippet's initial rendering.
|
||||
|
||||
// Add event listeners to buttons that trigger this modal
|
||||
// Using event delegation for dynamically added buttons
|
||||
document.addEventListener('click', function(event) {
|
||||
const button = event.target.closest('button[data-bs-toggle="modal"][data-bs-target="#meetingModal"]');
|
||||
if (button) {
|
||||
event.preventDefault(); // Prevent default if button has an href or type="submit"
|
||||
|
||||
const modalTitle = button.getAttribute('data-modal-title');
|
||||
const modalSubmitText = button.getAttribute('data-modal-submit-text');
|
||||
|
||||
if (meetingModalTitle) {
|
||||
meetingModalTitle.textContent = modalTitle || "{% trans 'Schedule Interview' %}";
|
||||
}
|
||||
// The submit button text is now handled by the meeting_form.html snippet itself,
|
||||
// based on whether 'scheduled_interview' context is passed.
|
||||
|
||||
// Show the modal first, then HTMX will load the content into #meetingModalBody
|
||||
const modal = new bootstrap.Modal(meetingModalElement);
|
||||
modal.show();
|
||||
|
||||
// HTMX attributes (hx-get, hx-target, hx-swap) on the button will trigger the fetch
|
||||
// after the modal is shown. HTMX handles this automatically.
|
||||
}
|
||||
});
|
||||
|
||||
// Optional: Clear HTMX target content if modal is hidden without submission
|
||||
meetingModalElement.addEventListener('hidden.bs.modal', function () {
|
||||
const modalBody = meetingModalElement.querySelector('#meetingModalBody');
|
||||
if (modalBody) {
|
||||
// Reset to a loading message or clear, so next open fetches fresh
|
||||
modalBody.innerHTML = '<p class="text-center text-muted">{% trans "Loading form..." %}</p>';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The old JS functions (loadScheduleMeetingForm, loadRescheduleMeetingForm) are no longer needed
|
||||
// as HTMX handles fetching the form. They can be removed if not used elsewhere.
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -14,6 +14,13 @@
|
||||
--kaauh-gray-light: #f8f9fa; /* Added for hover/background consistency */
|
||||
}
|
||||
|
||||
/* Primary Color Overrides */
|
||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
||||
.text-success { color: var(--kaauh-success) !important; }
|
||||
.text-danger { color: var(--kaauh-danger) !important; }
|
||||
.text-info { color: #17a2b8 !important; }
|
||||
|
||||
/* Enhanced Card Styling (Consistent) */
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
@ -208,10 +215,10 @@
|
||||
<tbody>
|
||||
{% for candidate in candidates %}
|
||||
<tr>
|
||||
<td class="fw-medium">{{ candidate.name }}</td>
|
||||
<td class="fw-medium"><a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none link-secondary">{{ candidate.name }}<a></td>
|
||||
<td>{{ candidate.email }}</td>
|
||||
<td>{{ candidate.phone }}</td>
|
||||
<td> <span class="badge bg-primary">{{ candidate.job.title }}</span></td>
|
||||
<td> <span class="badge bg-primary"><a href="{% url 'job_detail' candidate.job.slug %}" class="text-decoration-none text-white">{{ candidate.job.title }}</a></span></td>
|
||||
<td>
|
||||
<span class="badge bg-primary">
|
||||
{{ candidate.stage }}
|
||||
@ -250,14 +257,14 @@
|
||||
<div class="card candidate-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">{{ candidate.name }}</h5>
|
||||
<h5 class="card-title flex-grow-1 me-3"><a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none text-primary-theme ">{{ candidate.name }}</a></h5>
|
||||
<span class="badge bg-primary">{{ candidate.stage }}</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text text-muted small">
|
||||
<i class="fas fa-envelope"></i> {{ candidate.email }}<br>
|
||||
<i class="fas fa-phone-alt"></i> {{ candidate.phone|default:"N/A" }}<br>
|
||||
<i class="fas fa-briefcase"></i> <span class="badge bg-primary">{{ candidate.job.title }}</span>
|
||||
<i class="fas fa-briefcase"></i> <span class="badge bg-primary"><a href="{% url 'job_detail' candidate.job.slug %}" class="text-decoration-none text-white">{{ candidate.job.title }}</a></span>
|
||||
</p>
|
||||
|
||||
<div class="mt-auto pt-2 border-top">
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-info: #17a2b8;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-info: #17a2b8;
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-warning: #ffc107;
|
||||
}
|
||||
@ -28,16 +28,16 @@
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
|
||||
/* Dedicated style for the filter block */
|
||||
.filter-controls {
|
||||
background-color: #f8f9fa;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
}
|
||||
|
||||
|
||||
/* 2. Button Styling (Themed for Main Actions) */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
@ -74,7 +74,7 @@
|
||||
|
||||
/* 3. Candidate Table Styling (Aligned with KAAT-S) */
|
||||
.candidate-table {
|
||||
table-layout: fixed;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
@ -102,10 +102,10 @@
|
||||
.candidate-table tbody tr:hover {
|
||||
background-color: #f1f3f4;
|
||||
}
|
||||
.candidate-table thead th:nth-child(1) { width: 40px; }
|
||||
.candidate-table thead th:nth-child(4) { width: 10%; }
|
||||
.candidate-table thead th:nth-child(7) { width: 100px; }
|
||||
|
||||
.candidate-table thead th:nth-child(1) { width: 40px; }
|
||||
.candidate-table thead th:nth-child(4) { width: 10%; }
|
||||
.candidate-table thead th:nth-child(7) { width: 100px; }
|
||||
|
||||
.candidate-name {
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-primary-text);
|
||||
@ -114,7 +114,7 @@
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
|
||||
/* 4. Badges and Statuses */
|
||||
.ai-score-badge {
|
||||
background-color: var(--kaauh-teal-dark) !important;
|
||||
@ -142,24 +142,24 @@
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.stage-Applied { background-color: #e9ecef; color: #495057; }
|
||||
.stage-Screening { background-color: var(--kaauh-info); color: white; }
|
||||
.stage-Exam { background-color: var(--kaauh-warning); color: #856404; }
|
||||
.stage-Screening { background-color: var(--kaauh-info); color: white; }
|
||||
.stage-Exam { background-color: var(--kaauh-warning); color: #856404; }
|
||||
.stage-Interview { background-color: #17a2b8; color: white; }
|
||||
.stage-Offer { background-color: var(--kaauh-success); color: white; }
|
||||
|
||||
|
||||
/* Timeline specific container */
|
||||
.applicant-tracking-timeline {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
|
||||
/* --- CUSTOM HEIGHT OPTIMIZATION (MAKING INPUTS/BUTTONS SMALLER) --- */
|
||||
.form-control-sm,
|
||||
.btn-sm {
|
||||
/* Reduce vertical padding even more than default Bootstrap 'sm' */
|
||||
padding-top: 0.2rem !important;
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
/* Ensure a consistent, small height for both */
|
||||
height: 28px !important;
|
||||
height: 28px !important;
|
||||
font-size: 0.8rem !important; /* Slightly smaller font */
|
||||
}
|
||||
|
||||
@ -177,7 +177,7 @@
|
||||
<div class="applicant-tracking-timeline">
|
||||
{% include 'jobs/partials/applicant_tracking.html' %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
@ -185,7 +185,7 @@
|
||||
{% trans "Applicant Screening" %}
|
||||
</h1>
|
||||
<h2 class="h5 text-muted mb-0">
|
||||
{% trans "Job:" %} {{ job.title }}
|
||||
{% trans "Job:" %} {{ job.title }}
|
||||
<span class="badge bg-secondary ms-2 fw-normal">{{ job.internal_job_id }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
@ -194,68 +194,65 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="filter-controls shadow-sm">
|
||||
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-primary-text);">
|
||||
<i class="fas fa-sort-numeric-up me-1"></i> {% trans "AI Scoring & Top Candidate Filter" %}
|
||||
</h4>
|
||||
|
||||
<form method="GET" class="mb-0">
|
||||
{% csrf_token %}
|
||||
<div class="row g-3 align-items-end">
|
||||
|
||||
<div class="col-md-2 col-sm-6">
|
||||
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
||||
{% trans "Min AI Score" %}
|
||||
</label>
|
||||
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
|
||||
value="{{ min_ai_score}}" min="0" max="100" step="1"
|
||||
placeholder="e.g., 75">
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 col-sm-6">
|
||||
<label for="tier1_count" class="form-label small text-muted mb-1">
|
||||
{% trans "Top N" %}
|
||||
</label>
|
||||
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
|
||||
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% comment %} Empty col for spacing (2 + 2 + 3 + 5 = 12) {% endcomment %}
|
||||
<div class="col-md-5 d-none d-md-block"></div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="GET" class="mb-0 pb-3">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex flex-nowrap g-3 align-items-end" style="overflow-x: auto;">
|
||||
|
||||
<div class="p-2">
|
||||
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
||||
{% trans "Min AI Score" %}
|
||||
</label>
|
||||
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
|
||||
value="{{ min_ai_score}}" min="0" max="100" step="1"
|
||||
placeholder="e.g., 75" style="min-width: 120px;">
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<label for="tier1_count" class="form-label small text-muted mb-1">
|
||||
{% trans "Top N" %}
|
||||
</label>
|
||||
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
|
||||
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}" style="min-width: 100px;">
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<label class="form-label small text-muted mb-1 d-block"> </label>
|
||||
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100" style="min-width: 150px;">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
|
||||
<i class="fas fa-users me-1"></i> {% trans "Candidate List" %}
|
||||
<i class="fas fa-users me-1"></i> {% trans "Candidate List" %}
|
||||
<span class="badge bg-primary-theme ms-2">{{ candidates|length }} / {{ total_candidates }} Total</span>
|
||||
</h2>
|
||||
|
||||
<div class="kaauh-card shadow-sm p-3">
|
||||
{% url "bulk_candidate_move_to_exam" as move_to_exam_url %}
|
||||
|
||||
{% if candidates %}
|
||||
<button class="btn btn-bulk-action btn-sm mb-3"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{move_to_exam_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#candidate-form',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}'}})"
|
||||
>
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Bulk Move to Exam" %}
|
||||
</button>
|
||||
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||
<div class="d-flex align-items-center">
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
|
||||
<option value="Exam">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Exam" %}
|
||||
</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-main-action btn-mds ms-2">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<form id="candidate-form" action="{{move_to_exam_url}}" method="post">
|
||||
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||
{% csrf_token %}
|
||||
<table class="table candidate-table align-middle">
|
||||
<thead>
|
||||
@ -264,11 +261,7 @@
|
||||
{% if candidates %}
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-_all
|
||||
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
||||
data-effect="$selections; $_all = $selections.every(Boolean)"
|
||||
data-attr-disabled="$_fetching"
|
||||
type="checkbox" class="form-check-input" id="checkAll">
|
||||
type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
||||
</div>
|
||||
{% endif %}
|
||||
</th>
|
||||
@ -286,11 +279,9 @@
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-selections
|
||||
data-attr-disabled="$_fetching"
|
||||
name="candidate_ids"
|
||||
name="candidate_ids"
|
||||
value="{{ candidate.id }}"
|
||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
||||
type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@ -341,14 +332,14 @@
|
||||
</table>
|
||||
{% if not candidates %}
|
||||
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
{% trans "No candidates match the current stage and filter criteria." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||
@ -374,4 +365,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
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 {
|
||||
// Set to indeterminate state (partially checked)
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
|
||||
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
|
||||
// Your existing data-bind-_all logic should handle the bulk action status.
|
||||
}
|
||||
|
||||
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
|
||||
selectAllCheckbox.addEventListener('change', function () {
|
||||
const isChecked = selectAllCheckbox.checked;
|
||||
|
||||
// Temporarily disable the change listener on rows to prevent cascading events
|
||||
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||
|
||||
// Update all row checkboxes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
|
||||
// You must still dispatch the event here so your framework's data-bind-selections
|
||||
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Re-attach the change listeners to the rows
|
||||
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||
|
||||
// Ensure the header state is correct after forcing all changes
|
||||
updateSelectAllState();
|
||||
});
|
||||
|
||||
// 2. Logic to update 'Select All' state based on row checkboxes
|
||||
// Attach the function to be called whenever a row checkbox changes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.addEventListener('change', updateSelectAllState);
|
||||
});
|
||||
|
||||
// Initial check to set the correct state on load (in case items are pre-checked)
|
||||
updateSelectAllState();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
96
templates/recruitment/schedule_meeting_form.html
Normal file
96
templates/recruitment/schedule_meeting_form.html
Normal file
@ -0,0 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% trans "Schedule Meeting" %} - {{ job.title }} - ATS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="fas fa-calendar-plus me-2"></i>
|
||||
{% if has_future_meeting %}
|
||||
{% trans "Update Interview" %} for {{ candidate.name }}
|
||||
{% else %}
|
||||
{% trans "Schedule Interview" %} for {{ candidate.name }}
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0">{% trans "Job" %}: {{ job.title }}</p>
|
||||
{% if has_future_meeting %}
|
||||
<div class="alert alert-info mt-2 mb-0" role="alert">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
{% trans "This candidate has upcoming interviews. You are updating an existing schedule." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{% url 'candidate_interview_view' job.slug %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Candidates" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.topic.id_for_label }}" class="form-label">
|
||||
{% trans "Meeting Topic" %}
|
||||
</label>
|
||||
{{ form.topic }}
|
||||
{% if form.topic.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.topic.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text">
|
||||
{% trans "Default topic will be 'Interview: [Job Title] with [Candidate Name]' if left empty." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.start_time.id_for_label }}" class="form-label">
|
||||
{% trans "Start Time" %}
|
||||
</label>
|
||||
{{ form.start_time }}
|
||||
{% if form.start_time.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.start_time.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-text">
|
||||
{% trans "Please select a date and time for the interview." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="{{ form.duration.id_for_label }}" class="form-label">
|
||||
{% trans "Duration (minutes)" %}
|
||||
</label>
|
||||
{{ form.duration }}
|
||||
{% if form.duration.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.duration.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i> {% trans "Schedule Meeting" %}
|
||||
</button>
|
||||
<a href="{% url 'candidate_interview_view' job.slug %}" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user