meetings list

This commit is contained in:
Faheed 2025-11-09 19:13:08 +03:00
parent 517ee1d54d
commit 4148c7eb16
21 changed files with 771 additions and 34 deletions

View File

@ -584,11 +584,12 @@ class InterviewScheduleForm(forms.ModelForm):
class Meta:
model = InterviewSchedule
fields = [
'candidates', 'start_date', 'end_date', 'working_days',
'candidates', 'interview_type', 'start_date', 'end_date', 'working_days',
'start_time', 'end_time', 'interview_duration', 'buffer_time',
'break_start_time', 'break_end_time'
]
widgets = {
'interview_type': forms.Select(attrs={'class': 'form-control'}),
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
@ -1326,7 +1327,8 @@ class CandidateEmailForm(forms.Form):
"""Generate initial message with candidate and meeting information"""
candidate=self.candidates.first()
message_parts=[]
if candidate.stage == 'Applied':
if candidate and candidate.stage == 'Applied':
message_parts = [
f"Than you, for your interest in the {self.job.title} role.",
f"We regret to inform you that you were not selected to move forward to the exam round at this time.",
@ -1335,7 +1337,7 @@ class CandidateEmailForm(forms.Form):
f"Wishing you the best in your job search,",
f"The KAAUH Hiring team"
]
elif candidate.stage == 'Exam':
elif candidate and candidate.stage == 'Exam':
message_parts = [
f"Than you,for your interest in the {self.job.title} role.",
f"We're pleased to inform you that your initial screening was successful!",
@ -1346,7 +1348,7 @@ class CandidateEmailForm(forms.Form):
f"Best regards, The KAAUH Hiring team"
]
elif candidate.stage == 'Interview':
elif candidate and candidate.stage == 'Interview':
message_parts = [
f"Than you, for your interest in the {self.job.title} role.",
f"We're pleased to inform you that your initial screening was successful!",
@ -1357,7 +1359,7 @@ class CandidateEmailForm(forms.Form):
f"Best regards, The KAAUH Hiring team"
]
elif candidate.stage == 'Offer':
elif candidate and candidate.stage == 'Offer':
message_parts = [
f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.",
f"This is an exciting moment, and we look forward to having you join the KAAUH team.",
@ -1366,7 +1368,7 @@ class CandidateEmailForm(forms.Form):
f"Welcome to the team!",
f"Best regards, The KAAUH Hiring team"
]
elif candidate.stage == 'Hired':
elif candidate and candidate.stage == 'Hired':
message_parts = [
f"Welcome aboard,!",
f"We are thrilled to officially confirm your employment as our new {self.job.title}.",
@ -1589,6 +1591,17 @@ KAAUH HIRING TEAM
self.initial['message_for_agency'] = agency_message.strip()
self.initial['message_for_participants'] = participants_message.strip()
class InterviewScheduleLocationForm(forms.ModelForm):
class Meta:
model=InterviewSchedule
fields=['location']
widgets={
'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
}

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-09 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_remove_jobposting_participants_and_more'),
]
operations = [
migrations.AddField(
model_name='scheduledinterview',
name='meeting_type',
field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-11-09 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_scheduledinterview_meeting_type'),
]
operations = [
migrations.RemoveField(
model_name='scheduledinterview',
name='meeting_type',
),
migrations.AddField(
model_name='interviewschedule',
name='meeting_type',
field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-09 11:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_remove_scheduledinterview_meeting_type_and_more'),
]
operations = [
migrations.RenameField(
model_name='interviewschedule',
old_name='meeting_type',
new_name='interview_type',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-09 12:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_rename_meeting_type_interviewschedule_interview_type'),
]
operations = [
migrations.AddField(
model_name='interviewschedule',
name='location',
field=models.CharField(blank=True, default='Remote', null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-09 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_interviewschedule_location'),
]
operations = [
migrations.AlterField(
model_name='zoommeeting',
name='meeting_id',
field=models.CharField(blank=True, db_index=True, max_length=20, null=True, unique=True, verbose_name='Meeting ID'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-09 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_alter_zoommeeting_meeting_id'),
]
operations = [
migrations.AlterField(
model_name='zoommeeting',
name='meeting_id',
field=models.CharField(db_index=True, default=1, max_length=20, unique=True, verbose_name='Meeting ID'),
preserve_default=False,
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-09 13:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_alter_zoommeeting_meeting_id'),
]
operations = [
migrations.AlterField(
model_name='scheduledinterview',
name='zoom_meeting',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting'),
),
]

View File

@ -742,6 +742,7 @@ class ZoomMeeting(Base):
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
meeting_id = models.CharField(
db_index=True, max_length=20, unique=True, verbose_name=_("Meeting ID") # Added index
) # Unique identifier for the meeting
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index
duration = models.PositiveIntegerField(
@ -1599,6 +1600,20 @@ class BreakTime(models.Model):
class InterviewSchedule(Base):
"""Stores the scheduling criteria for interviews"""
"""Stores individual scheduled interviews"""
class InterviewType(models.TextChoices):
REMOTE = 'Remote', 'Remote Interview'
ONSITE = 'Onsite', 'In-Person Interview'
interview_type = models.CharField(
max_length=10,
choices=InterviewType.choices,
default=InterviewType.REMOTE,
verbose_name="Interview Meeting Type"
)
location=models.CharField(null=True,blank=True,default='Remote')
job = models.ForeignKey(
JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True
@ -1636,14 +1651,16 @@ class InterviewSchedule(Base):
class ScheduledInterview(Base):
"""Stores individual scheduled interviews"""
#for one candidate
candidate = models.ForeignKey(
Candidate,
on_delete=models.CASCADE,
related_name="scheduled_interviews",
db_index=True
)
participants = models.ManyToManyField('Participants', blank=True)
system_users=models.ManyToManyField(User,blank=True)
@ -1653,7 +1670,8 @@ class ScheduledInterview(Base):
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True
)
zoom_meeting = models.OneToOneField(
ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True
ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True,
null=True, blank=True
)
schedule = models.ForeignKey(
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True

View File

@ -461,6 +461,7 @@ def create_interview_and_meeting(
meeting_topic = f"Interview for {job.title} - {candidate.name}"
# 1. External API Call (Slow)
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success":

View File

@ -234,5 +234,8 @@ urlpatterns = [
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
path('interview/schedule/location/<slug:slug>/',views.schedule_interview_location_form,name='schedule_interview_location_form'),
path('interview/list',views.InterviewListView,name='interview_list')
]

View File

@ -43,7 +43,8 @@ from .forms import (
LinkedPostContentForm,
CandidateEmailForm,
SourceForm,
InterviewEmailForm
InterviewEmailForm,
InterviewScheduleLocationForm
)
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
from rest_framework import viewsets
@ -192,6 +193,43 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView):
context["status_filter"] = self.request.GET.get("status", "")
context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
return context
@login_required
def InterviewListView(request):
interview_type=request.GET.get('interview_type','Remote')
print(interview_type)
if interview_type=='Onsite':
meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type)
else:
meetings=ZoomMeeting.objects.all()
print(meetings)
return render(request, "meetings/list_meetings.html",{
'meetings':meetings,
'current_interview_type':interview_type
})
# search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency
# if search_query:
# interviews = interviews.filter(
# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query)
# )
# # Handle filter by status
# status_filter = request.GET.get("status", "")
# if status_filter:
# queryset = queryset.filter(status=status_filter)
# # Handle search by candidate name
# candidate_name = request.GET.get("candidate_name", "")
# if candidate_name:
# # Filter based on the name of the candidate associated with the meeting's interview
# queryset = queryset.filter(
# Q(interview__candidate__first_name__icontains=candidate_name) |
# Q(interview__candidate__last_name__icontains=candidate_name)
# )
class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView):
@ -1126,6 +1164,7 @@ def _handle_get_request(request, slug, job):
def _handle_preview_submission(request, slug, job):
"""
Handles the initial POST request (Preview Schedule).
Validates forms, calculates slots, saves data to session, and renders preview.
@ -1137,6 +1176,7 @@ def _handle_preview_submission(request, slug, job):
if form.is_valid():
# Get the form data
candidates = form.cleaned_data["candidates"]
interview_type=form.cleaned_data["interview_type"]
start_date = form.cleaned_data["start_date"]
end_date = form.cleaned_data["end_date"]
working_days = form.cleaned_data["working_days"]
@ -1197,6 +1237,7 @@ def _handle_preview_submission(request, slug, job):
# Save the form data to session for later use
schedule_data = {
"interview_type":interview_type,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"working_days": working_days,
@ -1217,6 +1258,7 @@ def _handle_preview_submission(request, slug, job):
{
"job": job,
"schedule": preview_schedule,
"interview_type":interview_type,
"start_date": start_date,
"end_date": end_date,
"working_days": working_days,
@ -1260,6 +1302,7 @@ def _handle_confirm_schedule(request, slug, job):
schedule = InterviewSchedule.objects.create(
job=job,
created_by=request.user,
interview_type=schedule_data["interview_type"],
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
working_days=schedule_data["working_days"],
@ -1286,34 +1329,59 @@ def _handle_confirm_schedule(request, slug, job):
available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast
# 4. Queue scheduled interviews asynchronously (FAST RESPONSE)
queued_count = 0
for i, candidate in enumerate(candidates):
if i < len(available_slots):
slot = available_slots[i]
if schedule.interview_type=='Remote':
queued_count = 0
for i, candidate in enumerate(candidates):
if i < len(available_slots):
slot = available_slots[i]
# Dispatch the individual creation task to the background queue
async_task(
"recruitment.tasks.create_interview_and_meeting",
candidate.pk,
job.pk,
schedule.pk,
slot['date'],
slot['time'],
schedule.interview_duration,
)
queued_count += 1
# Dispatch the individual creation task to the background queue
async_task(
"recruitment.tasks.create_interview_and_meeting",
candidate.pk,
job.pk,
schedule.pk,
slot['date'],
slot['time'],
schedule.interview_duration,
)
queued_count += 1
# 5. Success and Cleanup (IMMEDIATE RESPONSE)
messages.success(
request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!"
)
# 5. Success and Cleanup (IMMEDIATE RESPONSE)
messages.success(
request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!"
)
# Clear both session data keys upon successful completion
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
# Clear both session data keys upon successful completion
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect("job_detail", slug=slug)
else:
for i, candidate in enumerate(candidates):
if i < len(available_slots):
slot = available_slots[i]
ScheduledInterview.objects.create(
candidate=candidate,
job=job,
# zoom_meeting=None,
schedule=schedule,
interview_date=slot['date'],
interview_time= slot['time']
)
messages.success(
request,
f"Onsite schedule Interview Create succesfully"
)
# Clear both session data keys upon successful completion
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect('schedule_interview_location_form',slug=schedule.slug)
return redirect("job_detail", slug=slug)
def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
@ -1322,6 +1390,7 @@ def schedule_interviews_view(request, slug):
return _handle_preview_submission(request, slug, job)
else:
return _handle_get_request(request, slug, job)
def confirm_schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
@ -4062,4 +4131,18 @@ def send_interview_email(request, slug):
def schedule_interview_location_form(request,slug):
schedule=get_object_or_404(InterviewSchedule,slug=slug)
if request.method=='POST':
form=InterviewScheduleLocationForm(request.POST,instance=schedule)
form.save()
return redirect('list_meetings')
else:
form=InterviewScheduleLocationForm(instance=schedule)
return render(request,'interviews/schedule_interview_location_form.html',{'form':form,'schedule':schedule})
def onsite_interview_list_view(request):
onsite_interviews=ScheduledInterview.objects.filter(schedule__interview_type='Onsite')
return render(request,'interviews/onsite_interview_list.html',{'onsite_interviews':onsite_interviews})

View File

@ -255,6 +255,16 @@
</span>
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'interview_list' %}active{% endif %}" href="{% url 'interview_list' %}">
<span class="d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
{% trans "Onsite Interviews" %}
</span>
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'participants_list' %}active{% endif %}" href="{% url 'participants_list' %}">
<span class="d-flex align-items-center gap-2">

View File

@ -0,0 +1,432 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% trans "Zoom Meetings" %} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme (Consistent with Reference) */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa;
}
/* Enhanced Card Styling (Consistent) */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
transition: transform 0.2s, box-shadow 0.2s;
background-color: white;
}
.card:not(.no-hover):hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
}
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style (Teal Theme) */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style (For Edit/Outline - Consistent) */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Primary Outline for View/Join */
.btn-outline-primary {
color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
}
.btn-outline-primary:hover {
background-color: var(--kaauh-teal);
color: white;
}
/* Meeting Card Specifics (Adapted to Standard Card View) */
.meeting-card .card-title {
color: var(--kaauh-teal-dark);
font-weight: 600;
font-size: 1.15rem;
}
.meeting-card .card-text i {
color: var(--kaauh-teal);
width: 1.25rem;
}
/* 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; }
/* Status Badges (Standardized) */
.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;
}
/* Status Badge Mapping */
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
.bg-scheduled { 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;}
/* Table Styling (Consistent with Reference) */
.table-view .table thead th {
background-color: var(--kaauh-teal-dark);
color: white;
font-weight: 600;
border-color: var(--kaauh-border);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
padding: 1rem;
}
.table-view .table tbody td {
vertical-align: middle;
padding: 1rem;
border-color: var(--kaauh-border);
}
.table-view .table tbody tr:hover {
background-color: var(--kaauh-gray-light);
}
/* Pagination Link Styling (Consistent) */
.pagination .page-item .page-link {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-border);
}
.pagination .page-item.active .page-link {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
}
.pagination .page-item:hover .page-link:not(.active) {
background-color: #e9ecef;
}
/* Filter & Search Layout Adjustments */
.filter-buttons {
display: flex;
gap: 0.5rem;
}
/* Icon color for empty state */
.text-muted.fa-3x {
color: var(--kaauh-teal-dark) !important;
}
@keyframes svg-pulse {
0% {
transform: scale(0.9);
opacity: 0.8;
}
50% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(0.9);
opacity: 0.8;
}
}
/* Apply the animation to the custom class */
.svg-pulse {
animation: svg-pulse 2s infinite ease-in-out;
transform-origin: center; /* Ensure scaling is centered */
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-video me-2"></i> {% trans "Zoom Meetings" %}
</h1>
<a href="{% url 'create_meeting' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Create Meeting" %}
</a>
</div>
<div class="card mb-4 shadow-sm no-hover">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="search" class="form-label small text-muted">{% trans "Search by Topic" %}</label>
<div class="input-group input-group-lg mb-3">
<form method="get" action="" class="w-100">
{% include "includes/search_form.html" with search_query=search_query %}
</form>
</div>
</div>
<div class="col-md-6">
<form method="GET" class="row g-3 align-items-end" >
{% if search_query %}<input class="form-control form-control-sm" type="hidden" name="q" value="{{ search_query }}">{% endif %}
{% if status_filter %}<input class="form-control form-control-sm" type="hidden" name="status" value="{{ status_filter }}">{% endif %}
<div class="col-md-4">
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
<select name="status" id="status" class="form-select form-select-sm">
<option value="">{% trans "All Statuses" %}</option>
<option value="waiting" {% if status_filter == 'waiting' %}selected{% endif %}>{% trans "Waiting" %}</option>
<option value="started" {% if status_filter == 'started' %}selected{% endif %}>{% trans "Started" %}</option>
<option value="ended" {% if status_filter == 'ended' %}selected{% endif %}>{% trans "Ended" %}</option>
</select>
</div>
<div class="col-md-4">
<label for="candidate_name" class="form-label small text-muted">{% trans "Candidate Name" %}</label>
<input type="text" class="form-control form-control-sm" id="candidate_name" name="candidate_name" placeholder="{% trans 'Search by candidate...' %}" value="{{ candidate_name_filter }}">
</div>
<div class="col-md-5">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button>
{% if status_filter or search_query or candidate_name_filter %}
<a href="{% url 'list_meetings' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a>
{% endif %}
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% if meetings %}
<div id="meetings-list">
{# View Switcher #}
{% include "includes/_list_view_switcher.html" with list_id="meetings-list" %}
{# Card View #}
<div class="card-view active row">
{% for meeting in meetings %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card meeting-card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title flex-grow-1 me-3"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-primary-theme">{{ meeting.topic }}</a></h5>
<span class="status-badge bg-{{ meeting.status }}">
{{ meeting.status|title }}
</span>
</div>
<p class="card-text text-muted small mb-3">
<i class="fas fa-user"></i> {% trans "Candidate" %}: {% if meeting.interview %}{{ meeting.interview.candidate.name }}{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn text-primary-theme btn-link btn-sm">Set Candidate</button>
{% endif %}<br>
<i class="fas fa-briefcase"></i> {% trans "Job" %}: {% if meeting.interview %}{{ meeting.interview.job.title }}{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn text-primary-theme btn-link btn-sm">Set Job</button>
{% endif %}<br>
<i class="fas fa-hashtag"></i> {% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}<br>
<i class="fas fa-clock"></i> {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}<br>
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}<br><i class="fas fa-lock"></i> {% trans "Password" %}: Yes{% endif %}
</p>
<div class="mt-auto pt-2 border-top">
<div class="d-flex gap-2">
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> {% trans "View" %}
</a>
{% if meeting.join_url %}
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-sm btn-main-action">
<i class="fas fa-link"></i> {% trans "Join" %}
</a>
{% endif %}
<a href="{% url 'update_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"
hx-post="{% url 'delete_meeting' meeting.slug %}"
hx-target="#deleteModalBody"
hx-swap="outerHTML"
data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# Table View #}
<div class="table-view">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans "Topic" %}</th>
<th scope="col">{% trans "Candidate" %}</th>
<th scope="col">{% trans "Job" %}</th>
<th scope="col">{% trans "ID" %}</th>
<th scope="col">{% trans "Start Time" %}</th>
<th scope="col">{% trans "Duration" %}</th>
<th scope="col">{% trans "Status" %}</th>
<th scope="col" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for meeting in meetings %}
<tr>
<td><strong class="text-primary"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td>
<td>
{% if meeting.interview %}
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.candidate.slug %}">{{ meeting.interview.candidate.name }} <i class="fas fa-link"></i></a>
{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn btn-outline-primary btn-sm">Set Candidate</button>
{% endif %}
</td>
<td>
{% if meeting.interview %}
<a class="text-primary text-decoration-none" href="{% url 'job_detail' meeting.interview.job.slug %}">{{ meeting.interview.job.title }} <i class="fas fa-link"></i></a>
{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn btn-outline-primary btn-sm">Set Job</button>
{% endif %}
</td>
<td>{{ meeting.meeting_id|default:meeting.id }}</td>
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
<td>{{ meeting.duration }} min</td>
<td>
{% if meeting %}
<span class="badge {% if meeting.status == 'waiting' %}bg-warning{% elif meeting.status == 'started' %}bg-success{% elif meeting.status == 'ended' %}bg-danger{% endif %}">
{% if meeting.status == 'started' %}
<i class="fas fa-circle me-1 text-success"></i>
{% endif %}
{{ meeting.status|title }}
</span>
{% else %}
<span class="text-muted">--</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
{% if meeting.join_url %}
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}">
<i class="fas fa-sign-in-alt"></i>
</a>
{% endif %}
<a href="{% url '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.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-post="{% url 'delete_meeting' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# Pagination (Standardized) #}
{% if is_paginated %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">First</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Previous</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Next</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Last</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5 card shadow-sm">
<div class="card-body">
<i class="fas fa-video fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
<h3>{% trans "No Zoom meetings found" %}</h3>
<p class="text-muted">{% trans "Create your first meeting or adjust your filters." %}</p>
<a href="{% url 'create_meeting' %}" class="btn btn-main-action mt-3">
<i class="fas fa-plus me-1"></i> {% trans "Create Your First Meeting" %}
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -119,6 +119,7 @@
{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p>
</div>
</div>

View File

@ -0,0 +1,34 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card shadow-lg border-0 rounded-lg mt-5">
<div class="card-header bg-main-action text-white">
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
</div>
<div class="card-body">
<form method="post" action="{% url 'schedule_interview_location_form' schedule.slug %}" enctype="multipart/form-data">
{% csrf_token %}
{# Renders the single 'location' field using the crispy filter #}
{{ form|crispy }}
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a href="{% url 'list_meetings' %}" class="btn btn-secondary me-2">
<i class="fas fa-times me-1"></i> Close
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> Save Location
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock content %}

View File

@ -139,7 +139,17 @@
<div class="col-md-8">
<h5 class="section-header">{% trans "Schedule Details" %}</h5>
<div class="row">
<div class="col-md-12">
<div class="form-group mb-3">
<label for="{{ form.start_date.id_for_label }}">{% trans "Interview Type" %}</label>
{{ form.interview_type }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group mb-3">