This commit is contained in:
Faheed 2025-10-07 17:55:26 +03:00
commit 7a0bf3262d
24 changed files with 986 additions and 447 deletions

BIN
db.sqlite3 Normal file

Binary file not shown.

View File

@ -4,7 +4,7 @@ from crispy_forms.helper import FormHelper
from django.core.validators import URLValidator
from django.utils.translation import gettext_lazy as _
from crispy_forms.layout import Layout, Submit, HTML, Div, Field
from .models import ZoomMeeting, Candidate,TrainingMaterial,JobPosting,FormTemplate
from .models import ZoomMeeting, Candidate,TrainingMaterial,JobPosting,FormTemplate,InterviewSchedule
class CandidateForm(forms.ModelForm):
class Meta:
@ -408,4 +408,48 @@ class FormTemplateForm(forms.ModelForm):
Field('description', css_class='form-control'),
Field('is_active', css_class='form-check-input'),
Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3')
)
class InterviewScheduleForm(forms.ModelForm):
candidates = forms.ModelMultipleChoiceField(
queryset=Candidate.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=True
)
working_days = forms.MultipleChoiceField(
choices=[
(0, 'Monday'),
(1, 'Tuesday'),
(2, 'Wednesday'),
(3, 'Thursday'),
(4, 'Friday'),
(5, 'Saturday'),
(6, 'Sunday'),
],
widget=forms.CheckboxSelectMultiple,
required=True
)
class Meta:
model = InterviewSchedule
fields = [
'candidates', 'start_date', 'end_date', 'working_days',
'start_time', 'end_time', 'break_start_time', 'break_end_time',
'interview_duration', 'buffer_time'
]
widgets = {
'start_date': forms.DateInput(attrs={'type': 'date'}),
'end_date': forms.DateInput(attrs={'type': 'date'}),
'start_time': forms.TimeInput(attrs={'type': 'time'}),
'end_time': forms.TimeInput(attrs={'type': 'time'}),
'break_start_time': forms.TimeInput(attrs={'type': 'time'}),
'break_end_time': forms.TimeInput(attrs={'type': 'time'}),
}
def __init__(self, slug, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filter candidates based on the selected job
self.fields['candidates'].queryset = Candidate.objects.filter(
job_slug=slug,
stage='Interview'
)

View File

@ -0,0 +1,60 @@
# Generated by Django 5.2.6 on 2025-10-07 14:12
import django.db.models.deletion
import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0025_formfield_max_files_formfield_multiple_files'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='InterviewSchedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('start_date', models.DateField(verbose_name='Start Date')),
('end_date', models.DateField(verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')),
('start_time', models.TimeField(verbose_name='Start Time')),
('end_time', models.TimeField(verbose_name='End Time')),
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('candidates', models.ManyToManyField(related_name='interview_schedules', to='recruitment.candidate')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ScheduledInterview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_date', models.DateField(verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], default='scheduled', max_length=20)),
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
],
options={
'abstract': False,
},
),
]

View File

@ -714,3 +714,46 @@ class HiringAgency(Base):
verbose_name = _('Hiring Agency')
verbose_name_plural = _('Hiring Agencies')
ordering = ['name']
class InterviewSchedule(Base):
"""Stores the scheduling criteria for interviews"""
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='interview_schedules')
candidates = models.ManyToManyField(Candidate, related_name='interview_schedules')
start_date = models.DateField(verbose_name=_('Start Date'))
end_date = models.DateField(verbose_name=_('End Date'))
working_days = models.JSONField(verbose_name=_('Working Days')) # 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'))
break_start_time = models.TimeField(verbose_name=_('Break Start Time'), null=True, blank=True)
break_end_time = models.TimeField(verbose_name=_('Break End Time'), null=True, blank=True)
interview_duration = models.PositiveIntegerField(verbose_name=_('Interview Duration (minutes)'))
buffer_time = models.PositiveIntegerField(verbose_name=_('Buffer Time (minutes)'), default=0)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
return f"Interview Schedule for {self.job.title}"
class ScheduledInterview(Base):
"""Stores individual scheduled interviews"""
candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name='scheduled_interviews')
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='scheduled_interviews')
zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name='interview')
schedule = models.ForeignKey(InterviewSchedule, on_delete=models.CASCADE, related_name='interviews')
interview_date = models.DateField(verbose_name=_('Interview Date'))
interview_time = models.TimeField(verbose_name=_('Interview Time'))
status = models.CharField(
max_length=20,
choices=[
('scheduled', _('Scheduled')),
('confirmed', _('Confirmed')),
('cancelled', _('Cancelled')),
('completed', _('Completed')),
],
default='scheduled'
)
def __str__(self):
return f"Interview with {self.candidate.name} for {self.job.title}"

View File

@ -22,31 +22,31 @@ def get_all_responses_flat(stage_responses):
"""
all_responses = []
if stage_responses:
print(stage_responses.get(9).get("responses")[0].value)
for stage_id, responses in stage_responses.items():
for response in responses:
# Check if response is an object or string
if hasattr(response, 'stage') and hasattr(response, 'field'):
stage_name = response.stage.name if hasattr(response.stage, 'name') else f"Stage {stage_id}"
field_label = response.field.label if hasattr(response.field, 'label') else "Unknown Field"
field_type = response.field.get_field_type_display() if hasattr(response.field, 'get_field_type_display') else "Unknown Type"
required = response.field.required if hasattr(response.field, 'required') else False
value = response.value if hasattr(response, 'value') else response
uploaded_file = response.uploaded_file if hasattr(response, 'uploaded_file') else None
else:
stage_name = f"Stage {stage_id}"
field_label = "Unknown Field"
field_type = "Text"
required = False
value = response
uploaded_file = None
if responses: # Check if responses list exists and is not empty
for response in responses:
# Check if response is an object or string
if hasattr(response, 'stage') and hasattr(response, 'field'):
stage_name = response.stage.name if hasattr(response.stage, 'name') else f"Stage {stage_id}"
field_label = response.field.label if hasattr(response.field, 'label') else "Unknown Field"
field_type = response.field.get_field_type_display() if hasattr(response.field, 'get_field_type_display') else "Unknown Type"
required = response.field.required if hasattr(response.field, 'required') else False
value = response.value if hasattr(response, 'value') else response
uploaded_file = response.uploaded_file if hasattr(response, 'uploaded_file') else None
else:
stage_name = f"Stage {stage_id}"
field_label = "Unknown Field"
field_type = "Text"
required = False
value = response
uploaded_file = None
all_responses.append({
'stage_name': stage_name,
'field_label': field_label,
'field_type': field_type,
'required': required,
'value': value,
'uploaded_file': uploaded_file
})
all_responses.append({
'stage_name': stage_name,
'field_label': field_label,
'field_type': field_type,
'required': required,
'value': value,
'uploaded_file': uploaded_file
})
return all_responses

View File

@ -19,6 +19,7 @@ urlpatterns = [
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'),
path('jobs/<slug:slug>/schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'),
# Candidate URLs
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'),
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'),

View File

@ -4,7 +4,11 @@
# import requests
from recruitment import models
from django.conf import settings
from datetime import datetime, timedelta, time, date
from django.utils import timezone
from .models import ScheduledInterview
from django.template.loader import render_to_string
from django.core.mail import send_mail
# nlp = spacy.load("en_core_web_sm")
# def extract_text_from_pdf(pdf_path):
@ -53,7 +57,7 @@ def extract_text_from_pdf(file_path):
raise
return text.strip()
def score_resume_with_openrouter(prompt):
def score_resume_with_openrouter(prompt):
print("model call")
response = requests.post(
url="https://openrouter.ai/api/v1/chat/completions",
@ -63,7 +67,7 @@ def score_resume_with_openrouter(prompt):
},
data=json.dumps({
"model": OPENROUTER_MODEL,
"messages": [{"role": "user", "content": prompt}],
"messages": [{"role": "user", "content": prompt}],
},
)
)
@ -75,15 +79,15 @@ def score_resume_with_openrouter(prompt):
res = response.json()
content = res["choices"][0]['message']['content']
try:
content = content.replace("```json","").replace("```","")
res = json.loads(content)
except Exception as e:
print(e)
# res = raw_output["choices"][0]["message"]["content"]
# res = raw_output["choices"][0]["message"]["content"]
else:
print("error response")
return res
@ -381,4 +385,131 @@ def delete_zoom_meeting(meeting_id):
return {
"status": "error",
"message": str(e)
}
}
def schedule_interviews(schedule):
"""
Schedule interviews for all candidates in the schedule based on the criteria.
Returns the number of interviews successfully scheduled.
"""
candidates = list(schedule.candidates.all())
if not candidates:
return 0
# Calculate available time slots
available_slots = get_available_time_slots(schedule)
if len(available_slots) < len(candidates):
raise ValueError(f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}")
# Schedule interviews
scheduled_count = 0
for i, candidate in enumerate(candidates):
slot = available_slots[i]
interview_datetime = datetime.combine(slot['date'], slot['time'])
# Create Zoom meeting
meeting_topic = f"Interview for {schedule.job.title} - {candidate.name}"
meeting = create_zoom_meeting(
topic=meeting_topic,
start_time=interview_datetime,
duration=schedule.interview_duration,
timezone=timezone.get_current_timezone_name()
)
# Create scheduled interview record
scheduled_interview = ScheduledInterview.objects.create(
candidate=candidate,
job=schedule.job,
zoom_meeting=meeting,
schedule=schedule,
interview_date=slot['date'],
interview_time=slot['time']
)
# Send email to candidate
send_interview_email(scheduled_interview)
scheduled_count += 1
return scheduled_count
def send_interview_email(scheduled_interview):
"""
Send an interview invitation email to the candidate.
"""
subject = f"Interview Invitation for {scheduled_interview.job.title}"
context = {
'candidate_name': scheduled_interview.candidate.name,
'job_title': scheduled_interview.job.title,
'company_name': scheduled_interview.job.company.name,
'interview_date': scheduled_interview.interview_date,
'interview_time': scheduled_interview.interview_time,
'join_url': scheduled_interview.zoom_meeting.join_url,
'meeting_id': scheduled_interview.zoom_meeting.meeting_id,
}
# Render email templates
text_message = render_to_string('interviews/email/interview_invitation.txt', context)
html_message = render_to_string('interviews/email/interview_invitation.html', context)
# Send email
send_mail(
subject=subject,
message=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[scheduled_interview.candidate.email],
html_message=html_message,
fail_silently=False,
)
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.
"""
slots = []
current_date = schedule.start_date
end_date = schedule.end_date
# Convert working days to a set for quick lookup
working_days_set = set(schedule.working_days)
# Parse times
start_time = schedule.start_time
end_time = schedule.end_time
break_start = schedule.break_start_time
break_end = schedule.break_end_time
# Calculate slot duration (interview duration + buffer time)
slot_duration = timedelta(minutes=schedule.interview_duration + schedule.buffer_time)
while current_date <= end_date:
# Check if current day is a working day
if current_date.weekday() in working_days_set:
# Generate slots for this day
current_time = start_time
while current_time + slot_duration <= end_time:
# Check if slot is during break time
if break_start and break_end:
if current_time >= break_start and current_time < break_end:
current_time = break_end
continue
# Add this slot to available slots
slots.append({
'date': current_date,
'time': current_time
})
# Move to next slot
current_datetime = datetime.combine(current_date, current_time)
current_datetime += slot_duration
current_time = current_datetime.time()
# Move to next day
current_date += timedelta(days=1)
return slots

View File

@ -9,17 +9,17 @@ from django.db.models import Q
from django.urls import reverse
from django.conf import settings
from django.utils import timezone
from .forms import ZoomMeetingForm,JobPostingForm,FormTemplateForm
from .forms import ZoomMeetingForm,JobPostingForm,FormTemplateForm,InterviewScheduleForm
from rest_framework import viewsets
from django.contrib import messages
from django.core.paginator import Paginator
from .linkedin_service import LinkedInService
from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission
from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission,InterviewSchedule
from .models import ZoomMeeting, Candidate, JobPosting
from .serializers import JobPostingSerializer, CandidateSerializer
from django.shortcuts import get_object_or_404, render, redirect
from django.views.generic import CreateView,UpdateView,DetailView,ListView
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting,schedule_interviews,get_available_time_slots
from django.views.decorators.csrf import ensure_csrf_cookie
import logging
@ -843,3 +843,136 @@ def form_submission_details(request, form_id, submission_id):
'responses': responses,
'stage_responses': stage_responses
})
def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == 'POST':
form = InterviewScheduleForm(slug, request.POST)
# 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,
**schedule_data
)
# 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():
# 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']
break_start_time = form.cleaned_data['break_start_time']
break_end_time = form.cleaned_data['break_end_time']
interview_duration = form.cleaned_data['interview_duration']
buffer_time = form.cleaned_data['buffer_time']
# 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,
break_start_time=break_start_time,
break_end_time=break_end_time,
interview_duration=interview_duration,
buffer_time=buffer_time
)
# 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,
'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(),
'break_start_time': break_start_time.isoformat() if break_start_time else None,
'break_end_time': break_end_time.isoformat() if break_end_time else None,
'interview_duration': interview_duration,
'buffer_time': buffer_time,
'candidate_ids': [c.id for c in candidates]
}
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,
'break_start_time': break_start_time,
'break_end_time': break_end_time,
'interview_duration': interview_duration,
'buffer_time': buffer_time
})
else:
form = InterviewScheduleForm(slug=slug)
return render(request, 'interviews/schedule_interviews.html', {
'form': form,
'job': job
})

View File

@ -1112,46 +1112,47 @@
state.draggedStageIndex = null;
}
// DOM Elements
const elements = {
stageNav: document.getElementById('stageNav'),
formStage: document.getElementById('formStage'),
emptyState: document.getElementById('emptyState'),
fieldEditor: document.getElementById('fieldEditor'),
currentStageTitle: document.getElementById('currentStageTitle'),
stageNameDisplay: document.getElementById('stageNameDisplay'),
stageRequiredIndicator: document.getElementById('stageRequiredIndicator'),
stagePredefinedBadge: document.getElementById('stagePredefinedBadge'),
addStageBtn: document.getElementById('addStageBtn'),
saveFormBtn: document.getElementById('saveFormBtn'),
renameStageBtn: document.getElementById('renameStageBtn'),
renameModal: document.getElementById('renameModal'),
stageName: document.getElementById('stageName'),
closeRenameModal: document.getElementById('closeRenameModal'),
cancelRenameBtn: document.getElementById('cancelRenameBtn'),
saveRenameBtn: document.getElementById('saveRenameBtn'),
fieldLabel: document.getElementById('fieldLabel'),
fieldPlaceholder: document.getElementById('fieldPlaceholder'),
placeholderGroup: document.getElementById('placeholderGroup'),
requiredField: document.getElementById('requiredField'),
optionsEditor: document.getElementById('optionsEditor'),
optionsList: document.getElementById('optionsList'),
addOptionBtn: document.getElementById('addOptionBtn'),
fileSettings: document.getElementById('fileSettings'),
fileTypes: document.getElementById('fileTypes'),
maxFileSize: document.getElementById('maxFileSize'),
closeEditorBtn: document.getElementById('closeEditorBtn'),
// Form settings elements
formTitle: document.getElementById('formTitle'),
formSettingsBtn: document.getElementById('formSettingsBtn'),
formSettingsModal: document.getElementById('formSettingsModal'),
formName: document.getElementById('formName'),
formDescription: document.getElementById('formDescription'),
formActive: document.getElementById('formActive'),
closeFormSettingsModal: document.getElementById('closeFormSettingsModal'),
cancelFormSettingsBtn: document.getElementById('cancelFormSettingsBtn'),
saveFormSettingsBtn: document.getElementById('saveFormSettingsBtn')
};
const elements = {
stageNav: document.getElementById('stageNav'),
formStage: document.getElementById('formStage'),
emptyState: document.getElementById('emptyState'),
fieldEditor: document.getElementById('fieldEditor'),
currentStageTitle: document.getElementById('currentStageTitle'),
stageNameDisplay: document.getElementById('stageNameDisplay'),
stageRequiredIndicator: document.getElementById('stageRequiredIndicator'),
stagePredefinedBadge: document.getElementById('stagePredefinedBadge'),
addStageBtn: document.getElementById('addStageBtn'),
saveFormBtn: document.getElementById('saveFormBtn'),
renameStageBtn: document.getElementById('renameStageBtn'),
renameModal: document.getElementById('renameModal'),
stageName: document.getElementById('stageName'),
closeRenameModal: document.getElementById('closeRenameModal'),
cancelRenameBtn: document.getElementById('cancelRenameBtn'),
saveRenameBtn: document.getElementById('saveRenameBtn'),
fieldLabel: document.getElementById('fieldLabel'),
fieldPlaceholder: document.getElementById('fieldPlaceholder'),
placeholderGroup: document.getElementById('placeholderGroup'),
requiredField: document.getElementById('requiredField'),
optionsEditor: document.getElementById('optionsEditor'),
optionsList: document.getElementById('optionsList'),
addOptionBtn: document.getElementById('addOptionBtn'),
fileSettings: document.getElementById('fileSettings'),
fileTypes: document.getElementById('fileTypes'),
maxFileSize: document.getElementById('maxFileSize'),
multipleFiles: document.getElementById('multipleFiles'),
maxFiles: document.getElementById('maxFiles'),
closeEditorBtn: document.getElementById('closeEditorBtn'),
// Form settings elements
formTitle: document.getElementById('formTitle'),
formSettingsBtn: document.getElementById('formSettingsBtn'),
formSettingsModal: document.getElementById('formSettingsModal'),
formName: document.getElementById('formName'),
formDescription: document.getElementById('formDescription'),
formActive: document.getElementById('formActive'),
closeFormSettingsModal: document.getElementById('closeFormSettingsModal'),
cancelFormSettingsBtn: document.getElementById('cancelFormSettingsBtn'),
saveFormSettingsBtn: document.getElementById('saveFormSettingsBtn')
};
// Utility Functions
function getFieldIcon(type) {
const icons = {
@ -1443,7 +1444,6 @@
fileUpload.appendChild(uploadedFile);
});
}
fieldContent.appendChild(fileUpload);
} else if (field.type === 'select') {
const select = document.createElement('select');
@ -1515,18 +1515,16 @@
});
});
// Make draggable
// Make draggable for reordering
fieldDiv.draggable = true;
fieldDiv.addEventListener('dragstart', (e) => {
state.draggedFieldIndex = parseInt(fieldDiv.dataset.fieldIndex);
e.dataTransfer.setData('text/plain', 'reorder');
e.dataTransfer.effectAllowed = 'move';
});
fieldDiv.addEventListener('dragover', (e) => {
e.preventDefault();
});
fieldDiv.addEventListener('drop', (e) => {
e.preventDefault();
const targetIndex = parseInt(fieldDiv.dataset.fieldIndex);
@ -1536,21 +1534,30 @@
// Add file input event listener
const fileInput = fieldDiv.querySelector('.file-input');
if (fileInput) {
fileInput.addEventListener('change', (e) => {
// Remove any existing listeners to prevent duplicates
const newFileInput = fileInput.cloneNode(true);
fileInput.parentNode.replaceChild(newFileInput, fileInput);
newFileInput.addEventListener('change', (e) => {
handleFileUpload(e, field);
});
// Make the file upload area clickable
const fileUploadArea = fieldDiv.querySelector('.file-upload-area');
if (fileUploadArea) {
fileUploadArea.addEventListener('click', () => {
fileInput.click();
// Remove any existing listeners
const newFileUploadArea = fileUploadArea.cloneNode(true);
fileUploadArea.parentNode.replaceChild(newFileUploadArea, fileUploadArea);
newFileUploadArea.addEventListener('click', () => {
newFileInput.click();
});
}
}
return fieldDiv;
}
function handleFileUpload(event, field) {
const files = Array.from(event.target.files);
if (files.length === 0) return;
@ -1860,59 +1867,106 @@ function handleFileUpload(event, field) {
// Initialize Event Listeners
function initEventListeners() {
// Sidebar drag start
document.querySelectorAll('.field-item').forEach(item => {
// Wait for DOM to be fully loaded
setTimeout(() => {
// Sidebar drag start - ensure elements exist
const fieldItems = document.querySelectorAll('.field-item');
fieldItems.forEach(item => {
if (!item.hasAttribute('data-drag-initialized')) {
item.addEventListener('dragstart', (e) => {
const type = item.dataset.type;
const label = item.dataset.label;
startDrag(e, type, label);
});
});
// Form stage drop zone
item.setAttribute('data-drag-initialized', 'true');
}
});
// Form stage drop zone
if (elements.formStage) {
elements.formStage.addEventListener('drop', drop);
elements.formStage.addEventListener('dragover', allowDrop);
elements.formStage.addEventListener('dragenter', dragEnter);
elements.formStage.addEventListener('dragleave', dragLeave);
elements.formStage.addEventListener('click', clearSelection);
// Button events
}
// Button events
if (elements.addStageBtn) {
elements.addStageBtn.addEventListener('click', addStage);
}
if (elements.saveFormBtn) {
elements.saveFormBtn.addEventListener('click', saveForm);
}
if (elements.renameStageBtn) {
elements.renameStageBtn.addEventListener('click', () => {
openRenameModal(state.stages[state.currentStage]);
if (state.stages[state.currentStage]) {
openRenameModal(state.stages[state.currentStage]);
}
});
// Form settings button
}
// Form settings button
if (elements.formSettingsBtn) {
elements.formSettingsBtn.addEventListener('click', openFormSettingsModal);
// Modal events
}
// Modal events - Stage Rename
if (elements.closeRenameModal) {
elements.closeRenameModal.addEventListener('click', closeRenameModal);
}
if (elements.cancelRenameBtn) {
elements.cancelRenameBtn.addEventListener('click', closeRenameModal);
}
if (elements.saveRenameBtn) {
elements.saveRenameBtn.addEventListener('click', saveStageName);
}
if (elements.renameModal) {
elements.renameModal.addEventListener('click', (e) => {
if (e.target === elements.renameModal) {
closeRenameModal();
}
});
// Form settings modal events
}
// Form settings modal events
if (elements.closeFormSettingsModal) {
elements.closeFormSettingsModal.addEventListener('click', closeFormSettingsModal);
}
if (elements.cancelFormSettingsBtn) {
elements.cancelFormSettingsBtn.addEventListener('click', closeFormSettingsModal);
}
if (elements.saveFormSettingsBtn) {
elements.saveFormSettingsBtn.addEventListener('click', saveFormSettings);
}
if (elements.formSettingsModal) {
elements.formSettingsModal.addEventListener('click', (e) => {
if (e.target === elements.formSettingsModal) {
closeFormSettingsModal();
}
});
// Field editor events
}
// Field editor events
if (elements.closeEditorBtn) {
elements.closeEditorBtn.addEventListener('click', clearSelection);
}
if (elements.fieldLabel) {
elements.fieldLabel.addEventListener('input', () => {
if (state.selectedField) {
state.selectedField.label = elements.fieldLabel.value;
renderCurrentStage();
}
});
}
if (elements.fieldPlaceholder) {
elements.fieldPlaceholder.addEventListener('input', () => {
if (state.selectedField) {
state.selectedField.placeholder = elements.fieldPlaceholder.value;
}
});
}
if (elements.requiredField) {
elements.requiredField.addEventListener('change', () => {
if (state.selectedField) {
state.selectedField.required = elements.requiredField.checked;
@ -1920,6 +1974,8 @@ function handleFileUpload(event, field) {
renderCurrentStage();
}
});
}
if (elements.addOptionBtn) {
elements.addOptionBtn.addEventListener('click', () => {
if (state.selectedField &&
(state.selectedField.type === 'select' ||
@ -1929,42 +1985,83 @@ function handleFileUpload(event, field) {
renderOptionsEditor(state.selectedField);
}
});
}
if (elements.fileTypes) {
elements.fileTypes.addEventListener('input', () => {
if (state.selectedField && state.selectedField.type === 'file') {
state.selectedField.fileTypes = elements.fileTypes.value;
}
});
}
if (elements.maxFileSize) {
elements.maxFileSize.addEventListener('input', () => {
if (state.selectedField && state.selectedField.type === 'file') {
state.selectedField.maxFileSize = parseInt(elements.maxFileSize.value) || 5;
}
});
}
if (elements.multipleFiles) {
elements.multipleFiles.addEventListener('change', function() {
if (elements.maxFiles) {
elements.maxFiles.disabled = !this.checked;
if (!this.checked) {
elements.maxFiles.value = 1;
if (state.selectedField) {
state.selectedField.maxFiles = 1;
}
}
}
});
}
}, 100); // Small delay to ensure DOM is ready
}
// Initialize Application
function init() {
// Initialize form title
elements.formTitle.textContent = 'Loading...';
// Initialize form title
if (elements.formTitle) {
elements.formTitle.textContent = 'Loading...';
}
// Hide the form stage initially to prevent flickering
elements.formStage.style.display = 'none';
elements.emptyState.style.display = 'block';
elements.emptyState.innerHTML = '<i class="fas fa-spinner fa-spin"></i><p>Loading form template...</p>';
if (elements.formStage) {
elements.formStage.style.display = 'none';
}
if (elements.emptyState) {
elements.emptyState.style.display = 'block';
elements.emptyState.innerHTML = '<i class="fas fa-spinner fa-spin"></i><p>Loading form template...</p>';
}
// Initialize event listeners first
initEventListeners();
// Only render navigation if we have a template to load
if (djangoConfig.loadUrl) {
loadExistingTemplate();
} else {
// For new templates, show empty state
elements.formTitle.textContent = 'New Form Template';
elements.formStage.style.display = 'block';
if (elements.formTitle) {
elements.formTitle.textContent = 'New Form Template';
}
if (elements.formStage) {
elements.formStage.style.display = 'block';
}
if (elements.emptyState) {
elements.emptyState.style.display = 'block';
elements.emptyState.innerHTML = '<i class="fas fa-cloud-upload-alt"></i><p>Drag form elements here to build your stage</p>';
}
renderStageNavigation();
renderCurrentStage();
}
}
}
// Start the application
document.addEventListener('DOMContentLoaded', init);
// Make sure the DOM is fully loaded before initializing
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
</script>
</body>
</html>

View File

@ -7,228 +7,57 @@
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1><i class="fas fa-file-alt"></i> Submission Details</h1>
<p class="text-muted mb-0">{{ form.name }}</p>
</div>
<div>
<a href="" class="btn btn-outline-primary me-2">
<i class="fas fa-arrow-left"></i> Back to Submissions
</a>
<a href="" class="btn btn-outline-primary me-2" target="_blank">
<i class="fas fa-eye"></i> Preview Form
</a>
<button class="btn btn-success" onclick="exportSubmission()">
<i class="fas fa-download"></i> Export
</button>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Submission Details</h2>
<a href="" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back
</a>
</div>
</div>
</div>
<!-- Submission Summary -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="mb-0">{{ submission.id }}</h4>
<small>Submission ID</small>
</div>
<div class="align-self-center">
<i class="fas fa-hashtag fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="mb-0">{{ submission.submitted_at|date:"M d, Y" }}</h4>
<small>Submitted</small>
</div>
<div class="align-self-center">
<i class="fas fa-calendar-check fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4 class="mb-0">{{ responses|length }}</h4>
<small>Fields Completed</small>
</div>
<div class="align-self-center">
<i class="fas fa-tasks fa-2x opacity-75"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Submission Information -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-info-circle"></i> Submission Information</h5>
</div>
<!-- Basic Information -->
<div class="card mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<table class="table table-sm">
<tr>
<td><strong>Form:</strong></td>
<td>{{ form.name }}</td>
</tr>
<tr>
<td><strong>Submitted:</strong></td>
<td>{{ submission.submitted_at|date:"F d, Y H:i" }}</td>
</tr>
{% if submission.submitted_by %}
<tr>
<td><strong>Submitted By:</strong></td>
<td>{{ submission.submitted_by.get_full_name|default:submission.submitted_by.username }}</td>
</tr>
{% endif %}
</table>
<div class="col-md-4">
<strong>Submission ID:</strong> {{ submission.id }}
</div>
<div class="col-md-6">
{% if submission.applicant_name %}
<table class="table table-sm">
<tr>
<td><strong>Applicant Name:</strong></td>
<td>{{ submission.applicant_name }}</td>
</tr>
{% endif %}
{% if submission.applicant_email %}
<tr>
<td><strong>Email:</strong></td>
<td>{{ submission.applicant_email }}</td>
</tr>
{% endif %}
</table>
<div class="col-md-4">
<strong>Submitted:</strong> {{ submission.submitted_at|date:"M d, Y H:i" }}
</div>
<div class="col-md-4">
<strong>Form:</strong> {{ form.name }}
</div>
</div>
</div>
</div>
<!-- Form Responses by Stage -->
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-clipboard-list"></i> Submitted Responses</h5>
</div>
<div class="card-body">
{% for stage in stages %}
<div class="mb-4">
<div class="card border-primary">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-layer-group"></i> {{ stage.name }}
</h5>
</div>
<div class="card-body">
{% get_stage_responses stage_responses stage.id as stage_data %}
{% if stage_data %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Field Label</th>
<th>Field Type</th>
<th>Response Value</th>
<th>File</th>
</tr>
</thead>
<tbody>
{% for response in stage_data %}
<tr>
<td>
{{ response.field.label }}
{% if response.field.required %}
<span class="text-danger">*</span>
{% endif %}
</td>
<td>{{ response.field.get_field_type_display }}</td>
<td>
{% if response.uploaded_file %}
<span class="text-primary">File: {{ response.uploaded_file.name }}</span>
{% elif response.value %}
{% if response.field.field_type == 'checkbox' and response.value|length > 0 %}
<ul class="list-unstyled mb-0">
{% for val in response.value %}
<li><i class="fas fa-check text-success"></i> {{ val }}</li>
{% endfor %}
</ul>
{% elif response.field.field_type == 'radio' %}
<span class="badge bg-info">{{ response.value }}</span>
{% elif response.field.field_type == 'select' %}
<span class="badge bg-secondary">{{ response.value }}</span>
{% else %}
<p class="mb-0">{{ response.value|linebreaksbr }}</p>
{% endif %}
{% else %}
<span class="text-muted">Not provided</span>
{% endif %}
</td>
<td>
{% if response.uploaded_file %}
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
<i class="fas fa-download"></i> Download
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-3">
<i class="fas fa-inbox fa-2x mb-2"></i>
<p>No responses submitted for this stage.</p>
</div>
{% endif %}
</div>
{% if submission.applicant_name or submission.applicant_email %}
<div class="row mt-2">
{% if submission.applicant_name %}
<div class="col-md-6">
<strong>Applicant Name:</strong> {{ submission.applicant_name }}
</div>
{% endif %}
{% if submission.applicant_email %}
<div class="col-md-6">
<strong>Email:</strong> {{ submission.applicant_email }}
</div>
{% endif %}
</div>
{% if not forloop.last %}
<hr>
{% endif %}
{% empty %}
<div class="text-center text-muted py-5">
<i class="fas fa-inbox fa-3x mb-3"></i>
<h4>No stages found</h4>
<p>This form doesn't have any stages defined.</p>
</div>
{% endfor %}
</div>
</div>
<!-- Raw Data Table -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-table"></i> All Responses (Raw Data)</h5>
</div>
<!-- Responses Table -->
<div class="card">
<div class="card-body">
<h5 class="mb-3">Responses</h5>
{% get_all_responses_flat stage_responses as all_responses %}
{% if all_responses %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<table class="table table-striped">
<thead>
<tr>
<th>Stage</th>
<th>Field Label</th>
<th>Field Type</th>
<th>Required</th>
<th>Response Value</th>
<th>File</th>
</tr>
@ -236,35 +65,24 @@
<tbody>
{% for response in all_responses %}
<tr>
<td>{{ response.stage_name }}</td>
<td>
{{ response.field_label }}
<strong>{{ response.field_label }}</strong>
{% if response.required %}
<span class="text-danger">*</span>
{% endif %}
</td>
<td>{{ response.field_type }}</td>
<td>
{% if response.required %}
<span class="badge bg-danger">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
<span class="text-danger">*</span>
{% endif %}
</td>
<td>
{% if response.uploaded_file %}
<span class="text-primary">File: {{ response.uploaded_file.name }}</span>
<span class="text-primary">File: {{ response.uploaded_file.name }}</span>
{% elif response.value %}
{% if response.field_type == 'checkbox' and response.value|length > 0 %}
<ul class="list-unstyled mb-0">
<div>
{% for val in response.value %}
<li><i class="fas fa-check text-success"></i> {{ val }}</li>
<span class="badge bg-secondary me-1">{{ val }}</span>
{% endfor %}
</ul>
{% elif response.field_type == 'radio' %}
</div>
{% elif response.field_type == 'radio' or response.field_type == 'select' %}
<span class="badge bg-info">{{ response.value }}</span>
{% elif response.field_type == 'select' %}
<span class="badge bg-secondary">{{ response.value }}</span>
{% else %}
<p class="mb-0">{{ response.value|linebreaksbr }}</p>
{% endif %}
@ -274,9 +92,9 @@
</td>
<td>
{% if response.uploaded_file %}
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
<i class="fas fa-download"></i> Download
</a>
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
<i class="fas fa-download"></i> Download
</a>
{% endif %}
</td>
</tr>
@ -285,138 +103,28 @@
</table>
</div>
{% else %}
<div class="text-center text-muted py-3">
<i class="fas fa-inbox fa-2x mb-2"></i>
<div class="text-center text-muted py-4">
<p>No responses found for this submission.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">
<i class="fas fa-exclamation-triangle text-danger"></i> Confirm Delete
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this submission? This action cannot be undone.</p>
<div class="alert alert-warning">
<strong>Submission ID:</strong> {{ submission.id }}<br>
<strong>Submitted:</strong> {{ submission.submitted_at|date:"M d, Y H:i" }}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="fas fa-trash"></i> Delete Submission
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.response-value {
max-height: 200px;
overflow-y: auto;
}
.response-value ul {
margin: 0;
padding-left: 1.5rem;
}
.card {
border: 1px solid #e9ecef;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
transition: box-shadow 0.15s ease-in-out;
}
.card:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.card-title {
font-size: 0.9rem;
/* Minimal styling */
.table th {
border-top: none;
font-weight: 600;
color: #333;
color: #495057;
}
.table td {
vertical-align: top;
}
.response-value {
max-width: 300px;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
function exportSubmission() {
// Create export options modal or direct download
const format = prompt('Export format (csv, json, pdf):', 'csv');
if (format) {
window.open(`/recruitment/api/forms/{{ form.id }}/submissions/{{ submission.id }}/export/?format=${format}`, '_blank');
}
}
// Handle delete confirmation
document.addEventListener('DOMContentLoaded', function() {
const deleteBtn = document.querySelector('a[href*="delete"]');
if (deleteBtn) {
deleteBtn.addEventListener('click', function(e) {
e.preventDefault();
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
modal.show();
});
}
// Handle confirm delete
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', function() {
fetch(`/recruitment/api/forms/{{ form.id }}/submissions/{{ submission.id }}/delete/`, {
method: 'DELETE',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Submission deleted successfully!');
} else {
alert('Error deleting submission: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error deleting submission');
});
});
}
});
function getCsrfToken() {
const cookie = document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='));
return cookie ? cookie.split('=')[1] : '';
}
// Print functionality
function printSubmission() {
window.print();
}
// Add print button to header
document.addEventListener('DOMContentLoaded', function() {
const headerActions = document.querySelector('.d-flex.justify-content-between.align-items-center.mb-4');
if (headerActions) {
const printBtn = document.createElement('button');
printBtn.className = 'btn btn-outline-secondary me-2';
printBtn.innerHTML = '<i class="fas fa-print"></i> Print';
printBtn.onclick = printSubmission;
headerActions.insertBefore(printBtn, headerActions.firstChild);
}
});
</script>
{% endblock %}

View File

@ -269,6 +269,7 @@
{# Action area - visually separated with pt-2 border-top #}
<div class="mt-auto pt-2 border-top">
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'form_wizard' template.id %}" class="btn btn-outline-secondary btn-sm action-btn">
<i class="fas fa-eye me-1"></i> {% trans "Preview" %}
</a>

View File

@ -0,0 +1,76 @@
<!-- templates/interviews/email/interview_invitation.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Interview Invitation</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
}
.content {
padding: 20px;
}
.button {
display: inline-block;
background-color: #007bff;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 5px;
margin-top: 20px;
}
.footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Interview Invitation</h1>
</div>
<div class="content">
<p>Dear {{ candidate_name }},</p>
<p>We are pleased to invite you for an interview for the position of <strong>{{ job_title }}</strong> at <strong>{{ company_name }}</strong>.</p>
<h3>Interview Details:</h3>
<ul>
<li><strong>Date:</strong> {{ interview_date|date:"l, F j, Y" }}</li>
<li><strong>Time:</strong> {{ interview_time|time:"g:i A" }}</li>
<li><strong>Platform:</strong> Zoom Video Conference</li>
<li><strong>Meeting ID:</strong> {{ meeting_id }}</li>
</ul>
<p>Please join the interview using the link below:</p>
<a href="{{ join_url }}" class="button">Join Interview</a>
<p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p>
<p>We look forward to speaking with you!</p>
<p>Best regards,<br>
The Hiring Team<br>
{{ company_name }}</p>
</div>
<div class="footer">
<p>This is an automated message. Please do not reply to this email.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,23 @@
<!-- templates/interviews/email/interview_invitation.txt -->
Interview Invitation
Dear {{ candidate_name }},
We are pleased to invite you for an interview for the position of {{ job_title }} at {{ company_name }}.
Interview Details:
- Date: {{ interview_date|date:"l, F j, Y" }}
- Time: {{ interview_time|time:"g:i A" }}
- Platform: Zoom Video Conference
- Meeting ID: {{ meeting_id }}
Please join the interview using the link below:
{{ join_url }}
If you have any questions or need to reschedule, please contact us at your earliest convenience.
We look forward to speaking with you!
Best regards,
The Hiring Team
{{ company_name }}

View File

@ -0,0 +1,125 @@
<!-- templates/interviews/preview_schedule.html -->
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container mt-4">
<h1>Interview Schedule Preview for {{ job.title }}</h1>
<div class="card mt-4">
<div class="card-body">
<h5>Schedule Details</h5>
<div class="row">
<div class="col-md-6">
<p><strong>Period:</strong> {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}</p>
<p><strong>Working Days:</strong>
{% for day_id in working_days %}
{% if day_id == 0 %}Monday{% endif %}
{% if day_id == 1 %}Tuesday{% endif %}
{% if day_id == 2 %}Wednesday{% endif %}
{% if day_id == 3 %}Thursday{% endif %}
{% if day_id == 4 %}Friday{% endif %}
{% if day_id == 5 %}Saturday{% endif %}
{% if day_id == 6 %}Sunday{% endif %}
{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
<p><strong>Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
</div>
<div class="col-md-6">
{% if break_start_time and break_end_time %}
<p><strong>Break Time:</strong> {{ break_start_time|time:"g:i A" }} to {{ break_end_time|time:"g:i A" }}</p>
{% endif %}
<p><strong>Interview Duration:</strong> {{ interview_duration }} minutes</p>
<p><strong>Buffer Time:</strong> {{ buffer_time }} minutes</p>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h5>Scheduled Interviews</h5>
<!-- Calendar View -->
<div id="calendar-container">
<div id="calendar"></div>
</div>
<!-- List View -->
<div class="table-responsive mt-4">
<table class="table table-striped">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Candidate</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{% for item in schedule %}
<tr>
<td>{{ item.date|date:"F j, Y" }}</td>
<td>{{ item.time|time:"g:i A" }}</td>
<td>{{ item.candidate.name }}</td>
<td>{{ item.candidate.email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<form method="post" class="mt-4">
{% csrf_token %}
<button type="submit" name="confirm_schedule" class="btn btn-success">
<i class="fas fa-check"></i> Confirm Schedule
</button>
<a href="{% url 'schedule_interviews' job_id=job.id %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Edit
</a>
</form>
</div>
</div>
</div>
<!-- Include FullCalendar CSS and JS -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek'
},
events: [
{% for item in schedule %}
{
title: '{{ item.candidate.name }}',
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
url: '#',
extendedProps: {
email: '{{ item.candidate.email }}',
time: '{{ item.time|time:"g:i A" }}'
}
},
{% endfor %}
],
eventClick: function(info) {
// Show candidate details in a modal or alert
alert('Candidate: ' + info.event.title +
'\nDate: ' + info.event.start.toLocaleDateString() +
'\nTime: ' + info.event.extendedProps.time +
'\nEmail: ' + info.event.extendedProps.email);
info.jsEvent.preventDefault();
}
});
calendar.render();
});
</script>
{% endblock %}

View File

@ -0,0 +1,97 @@
<!-- templates/interviews/schedule_interviews.html -->
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h1>Schedule Interviews for {{ job.title }}</h1>
<div class="card mt-4">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<h5>Select Candidates</h5>
<div class="form-group">
{{ form.candidates }}
</div>
</div>
<div class="col-md-6">
<h5>Schedule Details</h5>
<div class="form-group">
<label for="{{ form.start_date.id_for_label }}">Start Date</label>
{{ form.start_date }}
</div>
<div class="form-group">
<label for="{{ form.end_date.id_for_label }}">End Date</label>
{{ form.end_date }}
</div>
<div class="form-group">
<label>Working Days</label>
{{ form.working_days }}
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="{{ form.start_time.id_for_label }}">Start Time</label>
{{ form.start_time }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="{{ form.end_time.id_for_label }}">End Time</label>
{{ form.end_time }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="{{ form.break_start_time.id_for_label }}">Break Start Time</label>
{{ form.break_start_time }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="{{ form.break_end_time.id_for_label }}">Break End Time</label>
{{ form.break_end_time }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="{{ form.interview_duration.id_for_label }}">Interview Duration (minutes)</label>
{{ form.interview_duration }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="{{ form.buffer_time.id_for_label }}">Buffer Time (minutes)</label>
{{ form.buffer_time }}
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">Schedule Interviews</button>
<a href="{% url 'job_detail' pk=job.slug %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}