Compare commits

...

5 Commits

31 changed files with 2122 additions and 521 deletions

View File

@ -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'}),
}

View File

@ -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'),
),
]

View 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 = [
]

View 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),
),
]

View File

@ -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"))

View File

@ -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:

View File

@ -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'),
]

View File

@ -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

View File

@ -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
})

View File

@ -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">
&copy; {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
&copy; {% 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 %}

View 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 %}

View 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>&nbsp;</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>&nbsp;</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>

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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">

View File

@ -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">&nbsp;</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 %}

View 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 %}