update the zoom meeting
This commit is contained in:
parent
ce15603802
commit
ffae8b2e64
1
ZoomMeetingAPISpec.json
Normal file
1
ZoomMeetingAPISpec.json
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-12 21:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0004_alter_candidate_interview_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='interviewschedule',
|
||||
name='breaks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interviewschedule',
|
||||
name='breaks',
|
||||
field=models.JSONField(blank=True, default=list, verbose_name='Break Times'),
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0006_zoommeeting_meeting_status.py
Normal file
18
recruitment/migrations/0006_zoommeeting_meeting_status.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 12:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0005_remove_interviewschedule_breaks_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_status',
|
||||
field=models.CharField(choices=[('scheduled', 'Scheduled'), ('started', 'Started'), ('ended', 'Ended')], default='scheduled', max_length=20, verbose_name='Meeting Status'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 12:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0006_zoommeeting_meeting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_status',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='zoommeeting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Status'),
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0008_zoommeeting_password.py
Normal file
18
recruitment/migrations/0008_zoommeeting_password.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 12:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0007_remove_zoommeeting_meeting_status_zoommeeting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='zoommeeting',
|
||||
name='password',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Password'),
|
||||
),
|
||||
]
|
||||
@ -421,7 +421,6 @@ class Candidate(Base):
|
||||
return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
@property
|
||||
|
||||
def submission(self):
|
||||
return FormSubmission.objects.filter(template__job=self.job).first()
|
||||
@property
|
||||
@ -432,6 +431,16 @@ class Candidate(Base):
|
||||
def __str__(self):
|
||||
return self.full_name
|
||||
|
||||
@property
|
||||
def get_meetings(self):
|
||||
return self.scheduled_interviews.all()
|
||||
@property
|
||||
def get_latest_meeting(self):
|
||||
schedule = self.scheduled_interviews.order_by('-created_at').first()
|
||||
if schedule:
|
||||
return schedule.zoom_meeting
|
||||
return None
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||
@ -453,6 +462,10 @@ class TrainingMaterial(Base):
|
||||
|
||||
|
||||
class ZoomMeeting(Base):
|
||||
class MeetingStatus(models.TextChoices):
|
||||
SCHEDULED = "scheduled", _("Scheduled")
|
||||
STARTED = "started", _("Started")
|
||||
ENDED = "ended", _("Ended")
|
||||
# Basic meeting details
|
||||
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
||||
meeting_id = models.CharField(
|
||||
@ -469,6 +482,9 @@ class ZoomMeeting(Base):
|
||||
participant_video = models.BooleanField(
|
||||
default=True, verbose_name=_("Participant Video")
|
||||
)
|
||||
password = models.CharField(
|
||||
max_length=20, blank=True, null=True, verbose_name=_("Password")
|
||||
)
|
||||
join_before_host = models.BooleanField(
|
||||
default=False, verbose_name=_("Join Before Host")
|
||||
)
|
||||
@ -480,6 +496,12 @@ class ZoomMeeting(Base):
|
||||
zoom_gateway_response = models.JSONField(
|
||||
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Status"),
|
||||
)
|
||||
# Timestamps
|
||||
|
||||
def __str__(self):
|
||||
@ -952,7 +974,9 @@ class InterviewSchedule(Base):
|
||||
) # Store days of week as [0,1,2,3,4] for Mon-Fri
|
||||
start_time = models.TimeField(verbose_name=_("Start Time"))
|
||||
end_time = models.TimeField(verbose_name=_("End Time"))
|
||||
breaks = models.ManyToManyField(BreakTime, blank=True, related_name="schedules")
|
||||
|
||||
breaks = models.JSONField(default=list, blank=True, verbose_name=_('Break Times'))
|
||||
|
||||
interview_duration = models.PositiveIntegerField(
|
||||
verbose_name=_("Interview Duration (minutes)")
|
||||
)
|
||||
|
||||
@ -66,8 +66,10 @@ urlpatterns = [
|
||||
path('forms/', views.form_templates_list, name='form_templates_list'),
|
||||
path('forms/create-template/', views.create_form_template, name='create_form_template'),
|
||||
|
||||
path('jobs/<slug:slug>/candidate-tiers/', views.candidate_tier_management_view, name='candidate_tier_management'),
|
||||
path('jobs/<slug:slug>/candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'),
|
||||
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
|
||||
path('jobs/<slug:slug>/candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'),
|
||||
|
||||
path('jobs/<slug:slug>/update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'),
|
||||
path('jobs/<slug:slug>/bulk_update_candidate_exam_status/', views.bulk_update_candidate_exam_status, name='bulk_update_candidate_exam_status'),
|
||||
|
||||
@ -89,5 +91,6 @@ urlpatterns = [
|
||||
# path('api/forms/save/', views.save_form_builder, name='save_form_builder'),
|
||||
# path('api/forms/<int:form_id>/load/', views.load_form, name='load_form'),
|
||||
# path('api/forms/<int:form_id>/update/', views.update_form_builder, name='update_form_builder'),
|
||||
|
||||
path('jobs/<slug:slug>/calendar/', views.interview_calendar_view, name='interview_calendar'),
|
||||
path('jobs/<slug:slug>/calendar/interview/<int:interview_id>/', views.interview_detail_view, name='interview_detail'),
|
||||
]
|
||||
|
||||
@ -161,7 +161,7 @@ def create_zoom_meeting(topic, start_time, duration):
|
||||
meeting_details = {
|
||||
"topic": topic,
|
||||
"type": 2,
|
||||
"start_time": start_time,
|
||||
"start_time": start_time.isoformat() + "Z",
|
||||
"duration": duration,
|
||||
"timezone": "UTC",
|
||||
"settings": {
|
||||
@ -273,6 +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.
|
||||
"""
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
@ -288,6 +289,19 @@ 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
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Meeting details retrieved successfully.",
|
||||
@ -325,11 +339,13 @@ def update_zoom_meeting(meeting_id, updated_data):
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.patch(
|
||||
f"https://api.zoom.us/v2/meetings/{meeting_id}",
|
||||
f"https://api.zoom.us/v2/meetings/{meeting_id}/",
|
||||
headers=headers,
|
||||
json=updated_data
|
||||
)
|
||||
|
||||
print(response.status_code)
|
||||
|
||||
if response.status_code == 204:
|
||||
return {
|
||||
"status": "success",
|
||||
@ -465,7 +481,7 @@ def send_interview_email(scheduled_interview):
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
def get_available_time_slots(schedule, breaks=None):
|
||||
def get_available_time_slots(schedule):
|
||||
"""
|
||||
Generate a list of available time slots based on the schedule criteria.
|
||||
Returns a list of dictionaries with 'date' and 'time' keys.
|
||||
@ -475,7 +491,6 @@ def get_available_time_slots(schedule, breaks=None):
|
||||
end_date = schedule.end_date
|
||||
|
||||
# Convert working days to a set for quick lookup
|
||||
# working_days should be a list of integers where 0=Monday, 1=Tuesday, etc.
|
||||
working_days_set = set(int(day) for day in schedule.working_days)
|
||||
|
||||
# Parse times
|
||||
@ -485,17 +500,12 @@ def get_available_time_slots(schedule, breaks=None):
|
||||
# Calculate slot duration (interview duration + buffer time)
|
||||
slot_duration = timedelta(minutes=schedule.interview_duration + schedule.buffer_time)
|
||||
|
||||
# Debug output - remove in production
|
||||
print(f"Working days: {working_days_set}")
|
||||
print(f"Date range: {current_date} to {end_date}")
|
||||
print(f"Time range: {start_time} to {end_time}")
|
||||
print(f"Slot duration: {slot_duration}")
|
||||
print(f"Breaks: {breaks}")
|
||||
# Get breaks from the schedule
|
||||
breaks = schedule.breaks if hasattr(schedule, 'breaks') and schedule.breaks else []
|
||||
|
||||
while current_date <= end_date:
|
||||
# Check if current day is a working day
|
||||
weekday = current_date.weekday() # Monday is 0, Sunday is 6
|
||||
print(f"Checking {current_date}, weekday: {weekday}, in working days: {weekday in working_days_set}")
|
||||
|
||||
if weekday in working_days_set:
|
||||
# Generate slots for this day
|
||||
@ -511,13 +521,18 @@ def get_available_time_slots(schedule, breaks=None):
|
||||
|
||||
# Check if slot conflicts with any break time
|
||||
conflict_with_break = False
|
||||
if breaks:
|
||||
for break_time in breaks:
|
||||
for break_data in breaks:
|
||||
# Parse break times
|
||||
try:
|
||||
break_start = datetime.strptime(break_data['start_time'], '%H:%M:%S').time()
|
||||
break_end = datetime.strptime(break_data['end_time'], '%H:%M:%S').time()
|
||||
|
||||
# Check if the slot overlaps with this break time
|
||||
if not (current_time >= break_time.end_time or slot_end_time <= break_time.start_time):
|
||||
if not (current_time >= break_end or slot_end_time <= break_start):
|
||||
conflict_with_break = True
|
||||
print(f"Slot {current_time}-{slot_end_time} conflicts with break {break_time.start_time}-{break_time.end_time}")
|
||||
break
|
||||
except (ValueError, KeyError) as e:
|
||||
continue
|
||||
|
||||
if not conflict_with_break:
|
||||
# Add this slot to available slots
|
||||
@ -525,7 +540,6 @@ def get_available_time_slots(schedule, breaks=None):
|
||||
'date': current_date,
|
||||
'time': current_time
|
||||
})
|
||||
print(f"Added slot: {current_date} {current_time}")
|
||||
|
||||
# Move to next slot
|
||||
current_datetime = datetime.combine(current_date, current_time) + slot_duration
|
||||
@ -534,11 +548,9 @@ def get_available_time_slots(schedule, breaks=None):
|
||||
# Move to next day
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
print(f"Total slots generated: {len(slots)}")
|
||||
return slots
|
||||
|
||||
|
||||
|
||||
def json_to_markdown_table(data_list):
|
||||
if not data_list:
|
||||
return ""
|
||||
@ -550,4 +562,4 @@ def json_to_markdown_table(data_list):
|
||||
for row in data_list:
|
||||
values = [str(row.get(header, "")) for header in headers]
|
||||
markdown += "| " + " | ".join(values) + " |\n"
|
||||
return markdown
|
||||
return markdown
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import json
|
||||
import requests
|
||||
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 datetime import datetime
|
||||
from datetime import datetime,time,timedelta
|
||||
from django.views import View
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
@ -31,6 +32,7 @@ from .utils import (
|
||||
create_zoom_meeting,
|
||||
delete_zoom_meeting,
|
||||
update_zoom_meeting,
|
||||
get_zoom_meeting_details,
|
||||
schedule_interviews,
|
||||
get_available_time_slots,
|
||||
)
|
||||
@ -47,6 +49,7 @@ from .models import (
|
||||
ZoomMeeting,
|
||||
Candidate,
|
||||
JobPosting,
|
||||
ScheduledInterview
|
||||
)
|
||||
import logging
|
||||
from datastar_py.django import (
|
||||
@ -81,15 +84,18 @@ class ZoomMeetingCreateView(CreateView):
|
||||
if instance.start_time < timezone.now():
|
||||
messages.error(self.request, "Start time must be in the future.")
|
||||
return redirect("/create-meeting/", status=400)
|
||||
start_time = instance.start_time.isoformat() + "Z"
|
||||
start_time = instance.start_time
|
||||
# start_time = instance.start_time.isoformat() + "Z"
|
||||
duration = instance.duration
|
||||
|
||||
result = create_zoom_meeting(topic, start_time, duration)
|
||||
|
||||
print(result)
|
||||
if result["status"] == "success":
|
||||
instance.meeting_id = result["meeting_details"]["meeting_id"]
|
||||
instance.join_url = result["meeting_details"]["join_url"]
|
||||
instance.host_email = result["meeting_details"]["host_email"]
|
||||
instance.password = result["meeting_details"]["password"]
|
||||
instance.status = result["zoom_gateway_response"]["status"]
|
||||
instance.zoom_gateway_response = result["zoom_gateway_response"]
|
||||
instance.save()
|
||||
messages.success(self.request, result["message"])
|
||||
@ -139,6 +145,21 @@ class ZoomMeetingUpdateView(UpdateView):
|
||||
template_name = "meetings/update_meeting.html"
|
||||
success_url = "/"
|
||||
|
||||
# def get_form_kwargs(self):
|
||||
# kwargs = super().get_form_kwargs()
|
||||
# # Ensure the form is initialized with the instance's current values
|
||||
# if self.object:
|
||||
# kwargs['initial'] = getattr(kwargs, 'initial', {})
|
||||
# initial_start_time = ""
|
||||
# if self.object.start_time:
|
||||
# try:
|
||||
# initial_start_time = self.object.start_time.strftime('%m-%d-%Y,T%H:%M')
|
||||
# except AttributeError:
|
||||
# print(f"Warning: start_time {self.object.start_time} is not a datetime object.")
|
||||
# initial_start_time = ""
|
||||
# kwargs['initial']['start_time'] = initial_start_time
|
||||
# return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
instance = form.save(commit=False)
|
||||
updated_data = {
|
||||
@ -149,15 +170,39 @@ class ZoomMeetingUpdateView(UpdateView):
|
||||
if instance.start_time < timezone.now():
|
||||
messages.error(self.request, "Start time must be in the future.")
|
||||
return redirect(f"/update-meeting/{instance.pk}/", status=400)
|
||||
|
||||
result = update_zoom_meeting(instance.meeting_id, updated_data)
|
||||
|
||||
if result["status"] == "success":
|
||||
instance.save()
|
||||
messages.success(self.request, result["message"])
|
||||
return redirect(reverse("meeting_details", kwargs={"pk": instance.pk}))
|
||||
# Fetch the latest details from Zoom after successful update
|
||||
details_result = get_zoom_meeting_details(instance.meeting_id)
|
||||
|
||||
if details_result["status"] == "success":
|
||||
zoom_details = details_result["meeting_details"]
|
||||
# Update instance with fetched details
|
||||
|
||||
instance.topic = zoom_details.get("topic", instance.topic)
|
||||
|
||||
instance.duration = zoom_details.get("duration", instance.duration)
|
||||
instance.join_url = zoom_details.get("join_url", instance.join_url)
|
||||
instance.password = zoom_details.get("password", instance.password)
|
||||
# Corrected status assignment: instance.status, not instance.password
|
||||
instance.status = zoom_details.get("status")
|
||||
|
||||
instance.zoom_gateway_response = details_result.get("meeting_details") # Store full response
|
||||
instance.save()
|
||||
messages.success(self.request, result["message"] + " Local data updated from Zoom.")
|
||||
else:
|
||||
# If fetching details fails, save with form data and log a warning
|
||||
logger.warning(
|
||||
f"Successfully updated Zoom meeting {instance.meeting_id}, but failed to fetch updated details. "
|
||||
f"Error: {details_result.get('message', 'Unknown error')}"
|
||||
)
|
||||
instance.save() # Save with data from the form
|
||||
messages.success(self.request, result["message"] + " (Note: Could not refresh local data from Zoom.)")
|
||||
return redirect(reverse("meeting_details", kwargs={"slug": instance.slug}))
|
||||
else:
|
||||
messages.error(self.request, result["message"])
|
||||
return redirect(reverse("meeting_details", kwargs={"pk": instance.pk}))
|
||||
return redirect(reverse("meeting_details", kwargs={"slug": instance.slug}))
|
||||
|
||||
|
||||
def ZoomMeetingDeleteView(request, pk):
|
||||
@ -1065,12 +1110,11 @@ def form_submission_details(request, template_id, slug):
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def schedule_interviews_view(request, job_id):
|
||||
job = get_object_or_404(JobPosting, id=job_id)
|
||||
def schedule_interviews_view(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
if request.method == "POST":
|
||||
form = InterviewScheduleForm(job_id, request.POST)
|
||||
form = InterviewScheduleForm(slug, request.POST)
|
||||
break_formset = BreakTimeFormSet(request.POST)
|
||||
|
||||
# Check if this is a confirmation request
|
||||
@ -1079,21 +1123,31 @@ def schedule_interviews_view(request, job_id):
|
||||
schedule_data = request.session.get("interview_schedule_data")
|
||||
if not schedule_data:
|
||||
messages.error(request, "Session expired. Please try again.")
|
||||
return redirect("schedule_interviews", job_id=job_id)
|
||||
return redirect("schedule_interviews", slug=slug)
|
||||
|
||||
# Create the interview schedule
|
||||
schedule = InterviewSchedule.objects.create(
|
||||
job=job, created_by=request.user, **schedule_data
|
||||
job=job,
|
||||
created_by=request.user,
|
||||
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
|
||||
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
|
||||
working_days=schedule_data["working_days"],
|
||||
start_time=time.fromisoformat(schedule_data["start_time"]),
|
||||
end_time=time.fromisoformat(schedule_data["end_time"]),
|
||||
interview_duration=schedule_data["interview_duration"],
|
||||
buffer_time=schedule_data["buffer_time"],
|
||||
breaks=schedule_data["breaks"],
|
||||
)
|
||||
|
||||
# Add candidates to the schedule
|
||||
candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
|
||||
schedule.candidates.set(candidates)
|
||||
|
||||
# Add break times to the schedule
|
||||
if "breaks" in schedule_data and schedule_data["breaks"]:
|
||||
for break_data in schedule_data["breaks"]:
|
||||
break_time = BreakTime.objects.create(
|
||||
# Create temporary break time objects for slot calculation
|
||||
temp_breaks = []
|
||||
for break_data in schedule_data["breaks"]:
|
||||
temp_breaks.append(
|
||||
BreakTime(
|
||||
start_time=datetime.strptime(
|
||||
break_data["start_time"], "%H:%M:%S"
|
||||
).time(),
|
||||
@ -1101,21 +1155,71 @@ def schedule_interviews_view(request, job_id):
|
||||
break_data["end_time"], "%H:%M:%S"
|
||||
).time(),
|
||||
)
|
||||
schedule.breaks.add(break_time)
|
||||
|
||||
# Schedule the interviews
|
||||
try:
|
||||
scheduled_count = schedule_interviews(schedule)
|
||||
messages.success(
|
||||
request, f"Successfully scheduled {scheduled_count} interviews."
|
||||
)
|
||||
# Clear the session data
|
||||
if "interview_schedule_data" in request.session:
|
||||
del request.session["interview_schedule_data"]
|
||||
return redirect("job_detail", pk=job_id)
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error scheduling interviews: {str(e)}")
|
||||
return redirect("schedule_interviews", job_id=job_id)
|
||||
|
||||
# Get available slots
|
||||
available_slots = get_available_time_slots(schedule)
|
||||
|
||||
# Create scheduled interviews
|
||||
scheduled_count = 0
|
||||
for i, candidate in enumerate(candidates):
|
||||
if i < len(available_slots):
|
||||
slot = available_slots[i]
|
||||
interview_datetime = datetime.combine(slot['date'], slot['time'])
|
||||
|
||||
# Create Zoom meeting
|
||||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||||
|
||||
start_time = interview_datetime.isoformat() + "Z"
|
||||
|
||||
zoom_meeting = create_zoom_meeting(
|
||||
topic=meeting_topic,
|
||||
start_time=start_time,
|
||||
duration=schedule.interview_duration
|
||||
)
|
||||
|
||||
result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
zoom_meeting = ZoomMeeting.objects.create(
|
||||
topic=meeting_topic,
|
||||
start_time=interview_datetime,
|
||||
duration=schedule.interview_duration,
|
||||
meeting_id=result["meeting_details"]["meeting_id"],
|
||||
join_url=result["meeting_details"]["join_url"],
|
||||
zoom_gateway_response=result["zoom_gateway_response"],
|
||||
)
|
||||
|
||||
# Create scheduled interview record
|
||||
scheduled_interview = ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
zoom_meeting=zoom_meeting,
|
||||
schedule=schedule,
|
||||
interview_date=slot['date'],
|
||||
interview_time=slot['time']
|
||||
)
|
||||
|
||||
# Send email to candidate
|
||||
# try:
|
||||
# send_interview_email(scheduled_interview)
|
||||
# except Exception as e:
|
||||
# messages.warning(
|
||||
# request,
|
||||
# f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}"
|
||||
# )
|
||||
|
||||
scheduled_count += 1
|
||||
|
||||
messages.success(
|
||||
request, f"Successfully scheduled {scheduled_count} interviews."
|
||||
)
|
||||
|
||||
# Clear the session data
|
||||
if "interview_schedule_data" in request.session:
|
||||
del request.session["interview_schedule_data"]
|
||||
|
||||
return redirect("job_detail", slug=slug)
|
||||
|
||||
# This is the initial form submission
|
||||
if form.is_valid() and break_formset.is_valid():
|
||||
@ -1139,8 +1243,8 @@ def schedule_interviews_view(request, job_id):
|
||||
{
|
||||
"start_time": break_form.cleaned_data[
|
||||
"start_time"
|
||||
].isoformat(),
|
||||
"end_time": break_form.cleaned_data["end_time"].isoformat(),
|
||||
].strftime("%H:%M:%S"),
|
||||
"end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
|
||||
}
|
||||
)
|
||||
|
||||
@ -1154,6 +1258,7 @@ def schedule_interviews_view(request, job_id):
|
||||
end_time=end_time,
|
||||
interview_duration=interview_duration,
|
||||
buffer_time=buffer_time,
|
||||
breaks=breaks,
|
||||
)
|
||||
|
||||
# Create temporary break time objects
|
||||
@ -1171,7 +1276,7 @@ def schedule_interviews_view(request, job_id):
|
||||
)
|
||||
|
||||
# Get available slots
|
||||
available_slots = get_available_time_slots(temp_schedule, temp_breaks)
|
||||
available_slots = get_available_time_slots(temp_schedule)
|
||||
|
||||
if len(available_slots) < len(candidates):
|
||||
messages.error(
|
||||
@ -1224,7 +1329,7 @@ def schedule_interviews_view(request, job_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = InterviewScheduleForm(job_id=job_id)
|
||||
form = InterviewScheduleForm(slug=slug)
|
||||
break_formset = BreakTimeFormSet()
|
||||
|
||||
return render(
|
||||
@ -1232,9 +1337,157 @@ def schedule_interviews_view(request, job_id):
|
||||
"interviews/schedule_interviews.html",
|
||||
{"form": form, "break_formset": break_formset, "job": job},
|
||||
)
|
||||
# def schedule_interviews_view(request, slug):
|
||||
# job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
# if request.method == "POST":
|
||||
# form = InterviewScheduleForm(slug, request.POST)
|
||||
# break_formset = BreakTimeFormSet(request.POST)
|
||||
|
||||
def candidate_tier_management_view(request, slug):
|
||||
# # Check if this is a confirmation request
|
||||
# if "confirm_schedule" in request.POST:
|
||||
# # Get the schedule data from session
|
||||
# schedule_data = request.session.get("interview_schedule_data")
|
||||
# if not schedule_data:
|
||||
# messages.error(request, "Session expired. Please try again.")
|
||||
# return redirect("schedule_interviews", slug=slug)
|
||||
|
||||
# # Create the interview schedule
|
||||
# schedule = InterviewSchedule.objects.create(
|
||||
# job=job,
|
||||
# created_by=request.user,
|
||||
# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
|
||||
# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
|
||||
# working_days=schedule_data["working_days"],
|
||||
# start_time=time.fromisoformat(schedule_data["start_time"]),
|
||||
# end_time=time.fromisoformat(schedule_data["end_time"]),
|
||||
# interview_duration=schedule_data["interview_duration"],
|
||||
# buffer_time=schedule_data["buffer_time"],
|
||||
# breaks=schedule_data["breaks"], # Direct assignment for JSON field
|
||||
# )
|
||||
|
||||
# # Add candidates to the schedule
|
||||
# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
|
||||
# schedule.candidates.set(candidates)
|
||||
|
||||
# # Schedule the interviews
|
||||
# try:
|
||||
# scheduled_count = schedule_interviews(schedule)
|
||||
# messages.success(
|
||||
# request, f"Successfully scheduled {scheduled_count} interviews."
|
||||
# )
|
||||
# # Clear the session data
|
||||
# if "interview_schedule_data" in request.session:
|
||||
# del request.session["interview_schedule_data"]
|
||||
# return redirect("job_detail", slug=slug)
|
||||
# except Exception as e:
|
||||
# messages.error(request, f"Error scheduling interviews: {str(e)}")
|
||||
# return redirect("schedule_interviews", slug=slug)
|
||||
|
||||
# # This is the initial form submission
|
||||
# if form.is_valid() and break_formset.is_valid():
|
||||
# # Get the form data
|
||||
# candidates = form.cleaned_data["candidates"]
|
||||
# start_date = form.cleaned_data["start_date"]
|
||||
# end_date = form.cleaned_data["end_date"]
|
||||
# working_days = form.cleaned_data["working_days"]
|
||||
# start_time = form.cleaned_data["start_time"]
|
||||
# end_time = form.cleaned_data["end_time"]
|
||||
# interview_duration = form.cleaned_data["interview_duration"]
|
||||
# buffer_time = form.cleaned_data["buffer_time"]
|
||||
|
||||
# # Process break times
|
||||
# breaks = []
|
||||
# for break_form in break_formset:
|
||||
# if break_form.cleaned_data and not break_form.cleaned_data.get(
|
||||
# "DELETE"
|
||||
# ):
|
||||
# breaks.append(
|
||||
# {
|
||||
# "start_time": break_form.cleaned_data[
|
||||
# "start_time"
|
||||
# ].strftime("%H:%M:%S"),
|
||||
# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
|
||||
# }
|
||||
# )
|
||||
|
||||
# # Create a temporary schedule object (not saved to DB)
|
||||
# temp_schedule = InterviewSchedule(
|
||||
# job=job,
|
||||
# start_date=start_date,
|
||||
# end_date=end_date,
|
||||
# working_days=working_days,
|
||||
# start_time=start_time,
|
||||
# end_time=end_time,
|
||||
# interview_duration=interview_duration,
|
||||
# buffer_time=buffer_time,
|
||||
# breaks=breaks, # Direct assignment for JSON field
|
||||
# )
|
||||
|
||||
# # Get available slots
|
||||
# available_slots = get_available_time_slots(temp_schedule)
|
||||
|
||||
# if len(available_slots) < len(candidates):
|
||||
# messages.error(
|
||||
# request,
|
||||
# f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}",
|
||||
# )
|
||||
# return render(
|
||||
# request,
|
||||
# "interviews/schedule_interviews.html",
|
||||
# {"form": form, "break_formset": break_formset, "job": job},
|
||||
# )
|
||||
|
||||
# # Create a preview schedule
|
||||
# preview_schedule = []
|
||||
# for i, candidate in enumerate(candidates):
|
||||
# slot = available_slots[i]
|
||||
# preview_schedule.append(
|
||||
# {"candidate": candidate, "date": slot["date"], "time": slot["time"]}
|
||||
# )
|
||||
|
||||
# # Save the form data to session for later use
|
||||
# schedule_data = {
|
||||
# "start_date": start_date.isoformat(),
|
||||
# "end_date": end_date.isoformat(),
|
||||
# "working_days": working_days,
|
||||
# "start_time": start_time.isoformat(),
|
||||
# "end_time": end_time.isoformat(),
|
||||
# "interview_duration": interview_duration,
|
||||
# "buffer_time": buffer_time,
|
||||
# "candidate_ids": [c.id for c in candidates],
|
||||
# "breaks": breaks,
|
||||
# }
|
||||
# request.session["interview_schedule_data"] = schedule_data
|
||||
|
||||
# # Render the preview page
|
||||
# return render(
|
||||
# request,
|
||||
# "interviews/preview_schedule.html",
|
||||
# {
|
||||
# "job": job,
|
||||
# "schedule": preview_schedule,
|
||||
# "start_date": start_date,
|
||||
# "end_date": end_date,
|
||||
# "working_days": working_days,
|
||||
# "start_time": start_time,
|
||||
# "end_time": end_time,
|
||||
# "breaks": breaks,
|
||||
# "interview_duration": interview_duration,
|
||||
# "buffer_time": buffer_time,
|
||||
# },
|
||||
# )
|
||||
# else:
|
||||
# form = InterviewScheduleForm(slug=slug)
|
||||
# break_formset = BreakTimeFormSet()
|
||||
|
||||
# return render(
|
||||
# request,
|
||||
# "interviews/schedule_interviews.html",
|
||||
# {"form": form, "break_formset": break_formset, "job": job},
|
||||
# )
|
||||
|
||||
def candidate_screening_view(request, slug):
|
||||
"""
|
||||
Manage candidate tiers and stage transitions
|
||||
"""
|
||||
@ -1265,7 +1518,7 @@ def candidate_tier_management_view(request, slug):
|
||||
# if "update_tiers" in request.POST:
|
||||
# tier1_count = int(request.POST.get("tier1_count", 100))
|
||||
# messages.success(request, f"Tier categorization updated. Tier 1: {tier1_count} candidates")
|
||||
# return redirect("candidate_tier_management", slug=slug)
|
||||
# return redirect("candidate_screening_view", slug=slug)
|
||||
|
||||
# # Update individual candidate stages
|
||||
# elif "update_stage" in request.POST:
|
||||
@ -1354,9 +1607,16 @@ def candidate_tier_management_view(request, slug):
|
||||
# "total_candidates": candidates.count(),
|
||||
}
|
||||
|
||||
return render(request, "recruitment/candidate_tier_management.html", context)
|
||||
return render(request, "recruitment/candidate_screening_view.html", context)
|
||||
|
||||
|
||||
def get_candidates_from_request(request):
|
||||
for c in request.POST.items():
|
||||
try:
|
||||
yield Candidate.objects.get(pk=c[0])
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
yield None
|
||||
def candidate_exam_view(request, slug):
|
||||
"""
|
||||
Manage candidate tiers and stage transitions
|
||||
@ -1378,15 +1638,16 @@ def update_candidate_exam_status(request, slug):
|
||||
return render(request, "includes/candidate_exam_status_form.html", {"candidate": candidate,"form": form})
|
||||
def bulk_update_candidate_exam_status(request,slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
print(request.headers)
|
||||
status = request.headers.get('status')
|
||||
print(status)
|
||||
|
||||
if status:
|
||||
for c in request.POST.items():
|
||||
for candidate in get_candidates_from_request(request):
|
||||
try:
|
||||
candidate = Candidate.objects.get(pk=c[0])
|
||||
candidate.exam_status = "Passed" if status == "pass" else "Failed"
|
||||
candidate.stage = "Interview"
|
||||
if status == "pass":
|
||||
candidate.exam_status = "Passed"
|
||||
candidate.stage = "Interview"
|
||||
else:
|
||||
candidate.exam_status = "Failed"
|
||||
candidate.save()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
@ -1403,20 +1664,97 @@ def candidate_set_exam_date(request, slug):
|
||||
candidate.exam_date = timezone.now()
|
||||
candidate.save()
|
||||
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
|
||||
return redirect("candidate_tier_management", slug=candidate.job.slug)
|
||||
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
||||
|
||||
def bulk_candidate_move_to_exam(request):
|
||||
for c in request.POST.items():
|
||||
try:
|
||||
candidate = Candidate.objects.get(pk=c[0])
|
||||
candidate.stage = "Exam"
|
||||
candidate.applicant_status = "Candidate"
|
||||
candidate.exam_date = timezone.now()
|
||||
candidate.save()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
messages.success(request, f"Moved {candidate.name} to Exam stage")
|
||||
return redirect("candidate_tier_management", slug=candidate.job.slug)
|
||||
for candidate in get_candidates_from_request(request):
|
||||
candidate.stage = "Exam"
|
||||
candidate.applicant_status = "Candidate"
|
||||
candidate.exam_date = timezone.now()
|
||||
candidate.save()
|
||||
|
||||
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())
|
||||
# return DatastarResponse(response())
|
||||
|
||||
def candidate_interview_view(request,slug):
|
||||
job = get_object_or_404(JobPosting,slug=slug)
|
||||
if "Datastar-Request" in request.headers:
|
||||
for candidate in get_candidates_from_request(request):
|
||||
print(candidate)
|
||||
context = {"job":job,"candidates":job.candidates.all()}
|
||||
return render(request,"recruitment/candidate_interview_view.html",context)
|
||||
|
||||
|
||||
|
||||
|
||||
def interview_calendar_view(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
# Get all scheduled interviews for this job
|
||||
scheduled_interviews = ScheduledInterview.objects.filter(
|
||||
job=job
|
||||
).select_related('candidate', 'zoom_meeting')
|
||||
print(scheduled_interviews)
|
||||
# Convert interviews to calendar events
|
||||
events = []
|
||||
for interview in scheduled_interviews:
|
||||
# Create start datetime
|
||||
start_datetime = datetime.combine(
|
||||
interview.interview_date,
|
||||
interview.interview_time
|
||||
)
|
||||
|
||||
# Calculate end datetime based on interview duration
|
||||
duration = interview.zoom_meeting.duration if interview.zoom_meeting else 60
|
||||
end_datetime = start_datetime + timedelta(minutes=duration)
|
||||
|
||||
# Determine event color based on status
|
||||
color = '#00636e' # Default color
|
||||
if interview.status == 'confirmed':
|
||||
color = '#00a86b' # Green for confirmed
|
||||
elif interview.status == 'cancelled':
|
||||
color = '#e74c3c' # Red for cancelled
|
||||
elif interview.status == 'completed':
|
||||
color = '#95a5a6' # Gray for completed
|
||||
|
||||
events.append({
|
||||
'title': f"Interview: {interview.candidate.name}",
|
||||
'start': start_datetime.isoformat(),
|
||||
'end': end_datetime.isoformat(),
|
||||
'url': f"{request.path}interview/{interview.id}/",
|
||||
'color': color,
|
||||
'extendedProps': {
|
||||
'candidate': interview.candidate.name,
|
||||
'email': interview.candidate.email,
|
||||
'status': interview.status,
|
||||
'meeting_id': interview.zoom_meeting.meeting_id if interview.zoom_meeting else None,
|
||||
'join_url': interview.zoom_meeting.join_url if interview.zoom_meeting else None,
|
||||
}
|
||||
})
|
||||
|
||||
context = {
|
||||
'job': job,
|
||||
'events': events,
|
||||
'calendar_color': '#00636e',
|
||||
}
|
||||
|
||||
return render(request, 'recruitment/interview_calendar.html', context)
|
||||
|
||||
def interview_detail_view(request, slug, interview_id):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
interview = get_object_or_404(
|
||||
ScheduledInterview,
|
||||
id=interview_id,
|
||||
job=job
|
||||
)
|
||||
|
||||
context = {
|
||||
'job': job,
|
||||
'interview': interview,
|
||||
}
|
||||
|
||||
return render(request, 'recruitment/interview_detail.html', context)
|
||||
|
||||
@ -204,7 +204,7 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
# def job_detail(request, slug):
|
||||
# job = get_object_or_404(models.JobPosting, slug=slug, status='Published')
|
||||
# form = forms.CandidateForm()
|
||||
|
||||
|
||||
# return render(request, 'jobs/job_detail.html', {'job': job, 'form': form})
|
||||
|
||||
|
||||
@ -278,6 +278,8 @@ def candidate_update_stage(request, slug):
|
||||
'new_stage_display': candidate.get_stage_display(),
|
||||
'candidate': candidate
|
||||
}
|
||||
messages.success(request,"Candidate Stage Updated")
|
||||
return redirect("candidate_detail",slug=candidate.slug)
|
||||
def response():
|
||||
stage_form = forms.CandidateStageForm(candidate=candidate)
|
||||
context['stage_form'] = stage_form
|
||||
|
||||
3
templates/icons/link.html
Normal file
3
templates/icons/link.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 340 B |
@ -80,7 +80,7 @@
|
||||
<button type="submit" name="confirm_schedule" class="btn btn-success">
|
||||
<i class="fas fa-check"></i> Confirm Schedule
|
||||
</button>
|
||||
<a href="{% url 'schedule_interviews' job_id=job.id %}" class="btn btn-secondary">
|
||||
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Edit
|
||||
</a>
|
||||
</form>
|
||||
|
||||
@ -99,7 +99,7 @@
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">Preview Schedule</button>
|
||||
<a href="{% url 'job_detail' pk=job.id %}" class="btn btn-secondary">Cancel</a>
|
||||
<a href="{% url 'job_detail' slug=job.slug %}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
.text-info { color: var(--stage-exam) !important; }
|
||||
.text-success { color: var(--stage-offer) !important; }
|
||||
.text-secondary { color: var(--stage-inactive) !important; }
|
||||
.bg-success { background-color: var(--kaauh-teal) !important; }
|
||||
.bg-success { background-color: var(--kaauh-teal) !important; }
|
||||
.bg-warning { background-color: #ffc107 !important; }
|
||||
.bg-secondary { background-color: #6c757d !important; }
|
||||
.bg-danger { background-color: #dc3545 !important; }
|
||||
@ -117,7 +117,7 @@
|
||||
border-right-color: transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
|
||||
/* Main Action Button Style */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
@ -238,7 +238,7 @@
|
||||
|
||||
/* Active State (Applies glow/scale to current stage) */
|
||||
.stage-item.active .stage-icon {
|
||||
box-shadow: 0 0 0 4px rgba(0, 99, 110, 0.4);
|
||||
box-shadow: 0 0 0 4px rgba(0, 99, 110, 0.4);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.stage-item.active .stage-count {
|
||||
@ -257,7 +257,7 @@
|
||||
background-color: #e9ecef;
|
||||
margin: 0 0.5rem;
|
||||
position: relative;
|
||||
top: -18px;
|
||||
top: -18px;
|
||||
z-index: 1;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
@ -283,7 +283,7 @@
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
@ -296,7 +296,7 @@
|
||||
<li class="breadcrumb-item"><a href="{% url 'job_list' %}" class="text-secondary">Jobs</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page" class="text-secondary">Job Detail</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</nav>
|
||||
<div class="row g-4">
|
||||
|
||||
{# LEFT COLUMN: JOB DETAILS WITH TABS #}
|
||||
@ -387,12 +387,12 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
id="copyJobLinkButton"
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
id="copyJobLinkButton"
|
||||
data-url="{{ job.application_url }}">
|
||||
<i class="fas fa-link me-1"></i>
|
||||
<i class="fas fa-link me-1"></i>
|
||||
{% trans "Copy and Share Public Link" %}
|
||||
</button>
|
||||
|
||||
@ -699,10 +699,12 @@
|
||||
<i class="fas fa-list-alt me-1"></i> {% trans "View All Existing Forms" %}
|
||||
</a> {% endcomment %}
|
||||
|
||||
{% comment %} <a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
|
||||
</a> {% endcomment %}
|
||||
|
||||
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-user-plus"></i> {% trans "Create Candidate" %}
|
||||
</a>
|
||||
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-layer-group"></i> {% trans "Manage Tiers" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -775,19 +777,19 @@
|
||||
document.getElementById('copyJobLinkButton').addEventListener('click', function() {
|
||||
// 1. Get the URL from the data attribute
|
||||
const urlToCopy = this.getAttribute('data-url');
|
||||
|
||||
|
||||
// 2. Use the modern Clipboard API
|
||||
navigator.clipboard.writeText(urlToCopy).then(() => {
|
||||
|
||||
|
||||
// 3. Show feedback message
|
||||
const feedback = document.getElementById('copyFeedback');
|
||||
feedback.style.display = 'inline';
|
||||
|
||||
|
||||
// 4. Hide feedback after 2 seconds
|
||||
setTimeout(() => {
|
||||
feedback.style.display = 'none';
|
||||
}, 2000);
|
||||
|
||||
|
||||
}).catch(err => {
|
||||
// Fallback for older browsers or security issues
|
||||
console.error('Could not copy text: ', err);
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Enhanced Card Styling (Consistent) */
|
||||
@ -114,7 +114,7 @@
|
||||
.table-view .table tbody tr:hover {
|
||||
background-color: var(--kaauh-gray-light);
|
||||
}
|
||||
|
||||
|
||||
/* Pagination Link Styling (Consistent) */
|
||||
.pagination .page-item .page-link {
|
||||
color: var(--kaauh-teal-dark);
|
||||
@ -134,7 +134,7 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* Icon color for empty state */
|
||||
.text-muted.fa-3x {
|
||||
color: var(--kaauh-teal-dark) !important;
|
||||
@ -177,7 +177,7 @@
|
||||
<option value="ended" {% if status_filter == 'ended' %}selected{% endif %}>{% trans "Ended" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="filter-buttons">
|
||||
<button type="submit" class="btn btn-main-action btn-lg">
|
||||
@ -221,7 +221,7 @@
|
||||
|
||||
<div class="mt-auto pt-2 border-top">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-eye"></i> {% trans "View" %}
|
||||
</a>
|
||||
|
||||
@ -231,12 +231,12 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-sm btn-outline-secondary">
|
||||
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-delete-url="{% url 'delete_meeting' meeting.pk %}"
|
||||
data-delete-url="{% url 'delete_meeting' meeting.slug %}"
|
||||
data-item-name="{{ meeting.topic }}">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
@ -281,15 +281,15 @@
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
||||
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-delete-url="{% url 'delete_meeting' meeting.pk %}"
|
||||
data-delete-url="{% url 'delete_meeting' meeting.slug %}"
|
||||
data-item-name="{{ meeting.topic }}">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
|
||||
@ -3,61 +3,256 @@
|
||||
|
||||
{% block title %}{% trans "Meeting Details" %} - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* UI Variables for the KAAT-S Theme (Consistent with list_meetings.html) */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Main Container and Card Styling */
|
||||
.container {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.card:not(.no-hover):hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.card.no-hover:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
|
||||
/* Header Card Styling */
|
||||
.card-header {
|
||||
background-color: var(--kaauh-gray-light);
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem 0.75rem 0 0; /* Match top border radius of card */
|
||||
}
|
||||
.card-header h1 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0; /* Space below title */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.card-header .heroicon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--kaauh-teal);
|
||||
}
|
||||
.card-header .btn-secondary {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.card-header .btn-secondary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Status Badge Styling (from list_meetings.html) */
|
||||
.status-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4em 0.8em;
|
||||
border-radius: 0.4rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
|
||||
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
|
||||
.bg-ended { background-color: #dc3545 !important; color: white !important;}
|
||||
|
||||
|
||||
/* Detail Row Styling */
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-teal-dark);
|
||||
min-width: 40%; /* Ensure labels align */
|
||||
}
|
||||
.detail-value {
|
||||
text-align: right;
|
||||
color: var(--kaauh-primary-text);
|
||||
word-wrap: break-word; /* Long URLs or text wrap */
|
||||
max-width: 60%; /* Ensure values align */
|
||||
}
|
||||
|
||||
/* Card Title Styling within content cards */
|
||||
.card h2 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 600;
|
||||
padding: 1.5rem 1.5rem 0.75rem;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
}
|
||||
|
||||
/* Join URL Styling */
|
||||
.join-url-display {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-top: 0.75rem;
|
||||
word-break: break-all; /* Force long URLs to break */
|
||||
}
|
||||
|
||||
/* Actions Styling */
|
||||
.actions {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem; /* Space between buttons */
|
||||
justify-content: flex-start; /* Align buttons to the left */
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* API Response Styling */
|
||||
#gateway-response {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
#gateway-response .card {
|
||||
border-left: 4px solid var(--kaauh-teal); /* Indicate it's different content */
|
||||
}
|
||||
#gateway-response pre {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
overflow-x: auto; /* For long JSON lines */
|
||||
font-size: 0.9rem;
|
||||
color: var(--kaauh-primary-text);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<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 status-{{ meeting.status }}">
|
||||
{{ meeting.status|title }}
|
||||
</span>
|
||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary">{% trans "Back to Meetings" %}</a>
|
||||
<div class="container py-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<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 }} minutes</div>
|
||||
<div class="detail-value">{{ meeting.duration }} {% trans "minutes" %}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Timezone" %}:</div>
|
||||
<div class="detail-value">{{ meeting.timezone|default:"UTC" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Host Email" %}:</div>
|
||||
<div class="detail-value">{{ meeting.host_email }}</div>
|
||||
<div class="detail-value">{{ meeting.host_email|default:"N/A" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if meeting.join_url %}
|
||||
<div class="card">
|
||||
<div class="card no-hover">
|
||||
<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" %}:</div>
|
||||
<div class="detail-value">{{ meeting.password }}</div>
|
||||
<div class="detail-label">{% trans "Password" %}: {{ meeting.password }}</div>
|
||||
<div class="detail-value"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card no-hover">
|
||||
<h2>{% trans "Settings" %}</h2>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Host Video" %}:</div>
|
||||
<div class="detail-value">{{ meeting.host_video|yesno:"Yes,No" }}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">{% trans "Participant Video" %}:</div>
|
||||
<div class="detail-value">{{ meeting.participant_video|yesno:"Yes,No" }}</div>
|
||||
@ -76,27 +271,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{% if meeting.zoom_gateway_response %}
|
||||
<a href="#" class="btn btn-secondary" onclick="toggleGateway()">{% trans "View API Response" %}</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'update_meeting' meeting.pk %}" 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>
|
||||
</form>
|
||||
<div class="card no-hover">
|
||||
<div class="actions">
|
||||
{% if meeting.zoom_gateway_response %}
|
||||
<a href="#" class="btn btn-secondary" onclick="toggleGateway()">{% trans "View API Response" %}</a>
|
||||
{% 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if meeting.zoom_gateway_response %}
|
||||
<div id="gateway-response" style="display: none; margin-top: 2rem;">
|
||||
{% comment %} <div id="gateway-response">
|
||||
<div class="card">
|
||||
<h3>{% trans "Zoom API Response" %}</h3>
|
||||
<pre>{{ meeting.zoom_gateway_response|safe }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
<script>
|
||||
function toggleGateway() {
|
||||
document.getElementById('gateway-response').style.display = document.getElementById('gateway-response').style.display === 'none' ? 'block' : 'none';
|
||||
const element = document.getElementById('gateway-response');
|
||||
if (element.style.display === 'none' || !element.style.display) {
|
||||
element.style.display = 'block';
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
color: var(--kaauh-gray);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
|
||||
/* CARD TITLE STYLING */
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
@ -140,9 +140,9 @@
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
/* Primary Action Button (Update) */
|
||||
.btn-main-action {
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
@ -190,24 +190,20 @@
|
||||
<div class="card">
|
||||
<h2 class="card-title">{% trans "Meeting Information" %}</h2>
|
||||
|
||||
<form method="post" action="{% url 'update_meeting' meeting.pk %}">
|
||||
<form method="post" action="{% url 'update_meeting' meeting.slug %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-row">
|
||||
<label for="topic" class="form-label">{% trans "Topic:" %}</label>
|
||||
<input type="text" id="topic" name="topic" class="form-input" value="{{ meeting.topic }}" required>
|
||||
<label class="form-label" for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label>
|
||||
{{ form.topic }}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="start_time" class="form-label">{% trans "Start Time (ISO 8601):" %}</label>
|
||||
{# Note: Django template filter for datetime-local needs to be precise Y-m-d\TH:i #}
|
||||
<input type="datetime-local" id="start_time" name="start_time" class="form-input"
|
||||
value="{{ meeting.start_time|slice:'0:16' }}" required>
|
||||
<label class="form-label" for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label>
|
||||
{{ form.start_time }}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="duration" class="form-label">{% trans "Duration (minutes):" %}</label>
|
||||
<input type="number" id="duration" name="duration" class="form-input" value="{{ meeting.duration }}" required>
|
||||
<label class="form-label" for="{{ form.duration.id_for_label }}">{% trans "Duration (minutes)" %}</label>
|
||||
{{ form.duration }}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
|
||||
318
templates/recruitment/candidate_interview_view.html
Normal file
318
templates/recruitment/candidate_interview_view.html
Normal file
@ -0,0 +1,318 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}Candidate Tier Management - {{ job.title }} - ATS{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* Minimal Tier Management Styles */
|
||||
.tier-controls {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.tier-controls .form-row {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.tier-controls .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.bulk-update-controls {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.stage-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stage-group {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage-group .stage-header {
|
||||
background-color: #495057;
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.stage-group .stage-body {
|
||||
padding: 0.75rem;
|
||||
min-height: 80px;
|
||||
}
|
||||
.stage-candidate {
|
||||
padding: 0.375rem;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
}
|
||||
.stage-candidate:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.match-score {
|
||||
font-weight: 600;
|
||||
color: #0056b3;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
/* Tab Styles for Tiers */
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.nav-tabs .nav-link {
|
||||
border: none;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-tabs .nav-link:hover {
|
||||
border: none;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border: none;
|
||||
border-bottom: 2px solid #007bff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tier-1 .nav-link {
|
||||
color: #155724;
|
||||
}
|
||||
.tier-1 .nav-link.active {
|
||||
border-bottom-color: #28a745;
|
||||
}
|
||||
.tier-2 .nav-link {
|
||||
color: #856404;
|
||||
}
|
||||
.tier-2 .nav-link.active {
|
||||
border-bottom-color: #ffc107;
|
||||
}
|
||||
.tier-3 .nav-link {
|
||||
color: #721c24;
|
||||
}
|
||||
.tier-3 .nav-link.active {
|
||||
border-bottom-color: #dc3545;
|
||||
}
|
||||
|
||||
/* Candidate Table Styles */
|
||||
.candidate-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background-color: white;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.candidate-table thead {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.candidate-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
.candidate-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.candidate-table tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.candidate-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.candidate-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.candidate-details {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
.candidate-table-responsive {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.stage-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
.stage-Applied {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
.stage-Exam {
|
||||
background-color: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
.stage-Interview {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.stage-Offer {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.exam-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.exam-controls select,
|
||||
.exam-controls input {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
.tier-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
color: #495057;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="fas fa-layer-group me-2"></i>
|
||||
{% trans "Interview" %} - {{ job.title }}
|
||||
</h1>
|
||||
|
||||
</div>
|
||||
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tier Display -->
|
||||
<h2 class="h4 mb-3 mt-5">{% trans "Candidate Tiers" %}</h2>
|
||||
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
|
||||
{% url "candidate_interview_view" job.slug as bulk_update_candidate_exam_status_url %}
|
||||
{% if candidates %}
|
||||
<button class="btn btn-primary"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#myform',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'pass'}
|
||||
})"
|
||||
>Mark as Pass and move to Interview</button>
|
||||
<button class="btn btn-danger"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#myform',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'fail'}
|
||||
})"
|
||||
>Mark as Failed</button>
|
||||
{% endif %}
|
||||
<form id="myform" action="{{move_to_exam_url}}" method="post">
|
||||
<table class="candidate-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{% if candidates %}
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-_all
|
||||
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
||||
data-effect="$selections; $_all = $selections.every(Boolean)"
|
||||
data-attr-disabled="$_fetching"
|
||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Contact" %}</th>
|
||||
<th>{% trans "Interview Date" %}</th>
|
||||
<th>{% trans "Interview Link" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in candidates %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-selections
|
||||
data-attr-disabled="$_fetching"
|
||||
name="{{ candidate.id }}"
|
||||
|
||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="candidate-name">{{ candidate.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="candidate-details">
|
||||
Email: {{ candidate.email }}<br>
|
||||
Phone: {{ candidate.phone }}<br>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>{{candidate.get_latest_meeting.start_time|date:"m-d-Y h:i A"}}</td>
|
||||
<td><a href="{{candidate.get_latest_meeting.join_url}}">{% include "icons/link.html" %}</a></td>
|
||||
<td>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
>
|
||||
{% include "icons/view.html" %}
|
||||
{% trans "View" %}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
|
||||
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="candidateviewModalLabel">Form Settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="candidateviewModalBody" class="modal-body">
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
251
templates/recruitment/interview_calendar.html
Normal file
251
templates/recruitment/interview_calendar.html
Normal file
@ -0,0 +1,251 @@
|
||||
<!-- templates/recruitment/interview_calendar.html -->
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block customCSS %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--calendar-color: #00636e;
|
||||
--calendar-light: rgba(0, 99, 110, 0.1);
|
||||
--calendar-hover: rgba(0, 99, 110, 0.2);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
background-color: var(--calendar-color);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
background-color: white;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.fc-toolbar-title {
|
||||
color: var(--calendar-color) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary {
|
||||
background-color: var(--calendar-color) !important;
|
||||
border-color: var(--calendar-color) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:hover {
|
||||
background-color: #004d56 !important;
|
||||
border-color: #004d56 !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:not(:disabled):active, .fc-button-primary:not(:disabled).fc-button-active {
|
||||
background-color: #003a40 !important;
|
||||
border-color: #003a40 !important;
|
||||
}
|
||||
|
||||
.fc-daygrid-day.fc-day-today {
|
||||
background-color: var(--calendar-light) !important;
|
||||
}
|
||||
|
||||
.fc-event-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.status-scheduled {
|
||||
background-color: #e3f2fd;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.status-confirmed {
|
||||
background-color: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background-color: #ffebee;
|
||||
color: #b71c1c;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #f5f5f5;
|
||||
color: #424242;
|
||||
}
|
||||
|
||||
.interview-details {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.interview-details .card {
|
||||
border-left: 4px solid var(--calendar-color);
|
||||
}
|
||||
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="calendar-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">Interview Calendar</h1>
|
||||
<div>
|
||||
<span class="h5">{{ job.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-container">
|
||||
<div id="calendar"></div>
|
||||
|
||||
<div class="calendar-legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #00636e;"></div>
|
||||
<span>Scheduled</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #00a86b;"></div>
|
||||
<span>Confirmed</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #e74c3c;"></div>
|
||||
<span>Cancelled</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #95a5a6;"></div>
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="interview-details" id="interview-details" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Interview Details</h5>
|
||||
<button type="button" class="btn-close" id="close-details"></button>
|
||||
</div>
|
||||
<div class="card-body" id="interview-info">
|
||||
<!-- Interview details will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include FullCalendar JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
var events = {{ events|safe }};
|
||||
|
||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||
},
|
||||
events: events,
|
||||
eventClick: function(info) {
|
||||
// Prevent default browser behavior
|
||||
info.jsEvent.preventDefault();
|
||||
|
||||
// Show interview details
|
||||
showInterviewDetails(info.event);
|
||||
},
|
||||
eventMouseEnter: function(info) {
|
||||
// Change cursor to pointer on hover
|
||||
document.body.style.cursor = 'pointer';
|
||||
},
|
||||
eventMouseLeave: function() {
|
||||
// Reset cursor
|
||||
document.body.style.cursor = 'default';
|
||||
},
|
||||
height: 'auto',
|
||||
aspectRatio: 2,
|
||||
eventDisplay: 'block',
|
||||
displayEventTime: true,
|
||||
displayEventEnd: true,
|
||||
});
|
||||
|
||||
calendar.render();
|
||||
|
||||
// Function to show interview details
|
||||
function showInterviewDetails(event) {
|
||||
const detailsContainer = document.getElementById('interview-details');
|
||||
const infoContainer = document.getElementById('interview-info');
|
||||
|
||||
const statusClass = `status-${event.extendedProps.status}`;
|
||||
const statusText = event.extendedProps.status.charAt(0).toUpperCase() + event.extendedProps.status.slice(1);
|
||||
|
||||
let meetingInfo = '';
|
||||
if (event.extendedProps.meeting_id) {
|
||||
meetingInfo = `
|
||||
<div class="mb-3">
|
||||
<h6>Meeting Information</h6>
|
||||
<p><strong>Meeting ID:</strong> ${event.extendedProps.meeting_id}</p>
|
||||
<p><strong>Join URL:</strong> <a href="${event.extendedProps.join_url}" target="_blank">${event.extendedProps.join_url}</a></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
infoContainer.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Candidate Information</h6>
|
||||
<p><strong>Name:</strong> ${event.extendedProps.candidate}</p>
|
||||
<p><strong>Email:</strong> ${event.extendedProps.email}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Interview Details</h6>
|
||||
<p><strong>Date:</strong> ${event.start.toLocaleDateString()}</p>
|
||||
<p><strong>Time:</strong> ${event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - ${event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</p>
|
||||
<p><strong>Status:</strong> <span class="status-badge ${statusClass}">${statusText}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
${meetingInfo}
|
||||
<div class="mt-3">
|
||||
<a href="${event.url}" class="btn btn-primary btn-sm">View Full Details</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
detailsContainer.style.display = 'block';
|
||||
|
||||
// Scroll to details
|
||||
detailsContainer.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Close details button
|
||||
document.getElementById('close-details').addEventListener('click', function() {
|
||||
document.getElementById('interview-details').style.display = 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
149
templates/recruitment/interview_detail.html
Normal file
149
templates/recruitment/interview_detail.html
Normal file
@ -0,0 +1,149 @@
|
||||
<!-- templates/recruitment/interview_detail.html -->
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
:root {
|
||||
--calendar-color: #00636e;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
background-color: var(--calendar-color);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
border-left: 4px solid var(--calendar-color);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.status-scheduled {
|
||||
background-color: #e3f2fd;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.status-confirmed {
|
||||
background-color: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background-color: #ffebee;
|
||||
color: #b71c1c;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #f5f5f5;
|
||||
color: #424242;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="detail-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">Interview Details</h1>
|
||||
<div>
|
||||
<span class="h5">{{ job.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card detail-card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>Candidate Information</h5>
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td><strong>Name:</strong></td>
|
||||
<td>{{ interview.candidate.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Email:</strong></td>
|
||||
<td>{{ interview.candidate.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Phone:</strong></td>
|
||||
<td>{{ interview.candidate.phone|default:"Not provided" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5>Interview Details</h5>
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td><strong>Date:</strong></td>
|
||||
<td>{{ interview.interview_date|date:"l, F j, Y" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Time:</strong></td>
|
||||
<td>{{ interview.interview_time|time:"g:i A" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Status:</strong></td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ interview.status }}">
|
||||
{{ interview.status|title }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if interview.zoom_meeting %}
|
||||
<div class="mt-4">
|
||||
<h5>Meeting Information</h5>
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td><strong>Meeting ID:</strong></td>
|
||||
<td>{{ interview.zoom_meeting.meeting_id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Topic:</strong></td>
|
||||
<td>{{ interview.zoom_meeting.topic }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Duration:</strong></td>
|
||||
<td>{{ interview.zoom_meeting.duration }} minutes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Join URL:</strong></td>
|
||||
<td><a href="{{ interview.zoom_meeting.join_url }}" target="_blank">{{ interview.zoom_meeting.join_url }}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'interview_calendar' slug=job.slug %}" class="btn btn-secondary">
|
||||
<i class="fas fa-calendar"></i> Back to Calendar
|
||||
</a>
|
||||
{% if interview.status == 'scheduled' %}
|
||||
<button class="btn btn-success">
|
||||
<i class="fas fa-check"></i> Confirm Interview
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interview.status != 'cancelled' and interview.status != 'completed' %}
|
||||
<button class="btn btn-danger">
|
||||
<i class="fas fa-times"></i> Cancel Interview
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user