update the bulk button to select
This commit is contained in:
parent
d0db3d1323
commit
302aa8d0bf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
14
recruitment/migrations/0012_merge_20251014_1403.py
Normal file
14
recruitment/migrations/0012_merge_20251014_1403.py
Normal file
@ -0,0 +1,14 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-14 11:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0010_alter_scheduledinterview_schedule'),
|
||||
('recruitment', '0011_alter_jobpostingimage_job_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
21
recruitment/migrations/0013_alter_formtemplate_created_by.py
Normal file
21
recruitment/migrations/0013_alter_formtemplate_created_by.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-14 11:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0012_merge_20251014_1403'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='formtemplate',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -273,12 +273,12 @@ class Candidate(Base):
|
||||
CANDIDATE = "Candidate", _("Candidate")
|
||||
|
||||
# Stage transition validation constants
|
||||
STAGE_SEQUENCE = {
|
||||
"Applied": ["Exam", "Interview", "Offer"],
|
||||
"Exam": ["Interview", "Offer"],
|
||||
"Interview": ["Offer"],
|
||||
"Offer": [], # Final stage - no further transitions
|
||||
}
|
||||
# STAGE_SEQUENCE = {
|
||||
# "Applied": ["Exam", "Interview", "Offer"],
|
||||
# "Exam": ["Interview", "Offer"],
|
||||
# "Interview": ["Offer"],
|
||||
# "Offer": [], # Final stage - no further transitions
|
||||
# }
|
||||
|
||||
job = models.ForeignKey(
|
||||
JobPosting,
|
||||
@ -375,50 +375,50 @@ class Candidate(Base):
|
||||
return self.resume.size
|
||||
return 0
|
||||
|
||||
def clean(self):
|
||||
"""Validate stage transitions"""
|
||||
# Only validate if this is an existing record (not being created)
|
||||
if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
# def clean(self):
|
||||
# """Validate stage transitions"""
|
||||
# # Only validate if this is an existing record (not being created)
|
||||
# if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
||||
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
if self.stage not in allowed_next_stages:
|
||||
raise ValidationError(
|
||||
{
|
||||
"stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
||||
f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
|
||||
}
|
||||
)
|
||||
# if self.stage not in allowed_next_stages:
|
||||
# raise ValidationError(
|
||||
# {
|
||||
# "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
||||
# f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
|
||||
# }
|
||||
# )
|
||||
|
||||
# Validate that the stage is a valid choice
|
||||
if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
||||
raise ValidationError(
|
||||
{
|
||||
"stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
|
||||
}
|
||||
)
|
||||
# # Validate that the stage is a valid choice
|
||||
# if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
||||
# raise ValidationError(
|
||||
# {
|
||||
# "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
|
||||
# }
|
||||
# )
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to ensure validation is called"""
|
||||
self.clean() # Call validation before saving
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def can_transition_to(self, new_stage):
|
||||
"""Check if a stage transition is allowed"""
|
||||
if not self.pk: # New record - can be in Applied stage
|
||||
return new_stage == "Applied"
|
||||
# def can_transition_to(self, new_stage):
|
||||
# """Check if a stage transition is allowed"""
|
||||
# if not self.pk: # New record - can be in Applied stage
|
||||
# return new_stage == "Applied"
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
return new_stage in allowed_next_stages
|
||||
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
# return new_stage in allowed_next_stages
|
||||
|
||||
def get_available_stages(self):
|
||||
"""Get list of stages this candidate can transition to"""
|
||||
if not self.pk: # New record
|
||||
return ["Applied"]
|
||||
# def get_available_stages(self):
|
||||
# """Get list of stages this candidate can transition to"""
|
||||
# if not self.pk: # New record
|
||||
# return ["Applied"]
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
# return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
@property
|
||||
def submission(self):
|
||||
@ -544,7 +544,7 @@ class FormTemplate(Base):
|
||||
blank=True, help_text="Description of the form template"
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="form_templates"
|
||||
User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=False, help_text="Whether this template is active"
|
||||
|
||||
@ -7,10 +7,10 @@ from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@receiver(post_save, sender=JobPosting)
|
||||
def create_form_for_job(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
FormTemplate.objects.create(job=instance, is_active=True, name=instance.title)
|
||||
# @receiver(post_save, sender=JobPosting)
|
||||
# def create_form_for_job(sender, instance, created, **kwargs):
|
||||
# if created:
|
||||
# FormTemplate.objects.create(job=instance, is_active=True, name=instance.title)
|
||||
@receiver(post_save, sender=Candidate)
|
||||
def score_candidate_resume(sender, instance, created, **kwargs):
|
||||
if not instance.is_resume_parsed:
|
||||
|
||||
@ -17,7 +17,6 @@ urlpatterns = [
|
||||
path('jobs/<slug:slug>/candidate/application/success', views.application_success, name='application_success'),
|
||||
path('careers/',views.kaauh_career,name='kaauh_career'),
|
||||
|
||||
|
||||
# LinkedIn Integration URLs
|
||||
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
|
||||
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
|
||||
@ -34,7 +33,6 @@ urlpatterns = [
|
||||
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
||||
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
|
||||
|
||||
|
||||
# Training URLs
|
||||
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
|
||||
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
|
||||
@ -75,7 +73,8 @@ urlpatterns = [
|
||||
|
||||
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'),
|
||||
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
|
||||
path('htmx/bulk_candidate_move_to_exam/', views.bulk_candidate_move_to_exam, name='bulk_candidate_move_to_exam'),
|
||||
|
||||
path('htmx/<slug:slug>/candidate_update_status/', views.candidate_update_status, name='candidate_update_status'),
|
||||
|
||||
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
||||
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
||||
|
||||
@ -5,7 +5,7 @@ from rich import print
|
||||
from django.template.loader import render_to_string
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.http import JsonResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from datetime import datetime,time,timedelta
|
||||
from django.views import View
|
||||
from django.db.models import Q
|
||||
@ -262,12 +262,13 @@ def create_job(request):
|
||||
else:
|
||||
job.created_by = request.POST.get("created_by", "").strip()
|
||||
if not job.created_by:
|
||||
job.created_by = "University Administrator"
|
||||
job.created_by = request.user.username
|
||||
|
||||
job.save()
|
||||
job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug})
|
||||
job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative)
|
||||
job.application_url=job_apply_url_absolute
|
||||
FormTemplate.objects.create(job=job, is_active=True, name=job.title,created_by=request.user)
|
||||
job.save()
|
||||
messages.success(request, f'Job "{job.title}" created successfully!')
|
||||
return redirect("job_list")
|
||||
@ -328,15 +329,15 @@ def job_detail(request, slug):
|
||||
|
||||
# Count candidates by stage for summary statistics
|
||||
total_applicant = applicants.count()
|
||||
|
||||
|
||||
applied_count = applicants.filter(stage="Applied").count()
|
||||
|
||||
exam_count=applicants.filter(stage="Exam").count
|
||||
|
||||
|
||||
interview_count = applicants.filter(stage="Interview").count()
|
||||
|
||||
|
||||
offer_count = applicants.filter(stage="Offer").count()
|
||||
|
||||
|
||||
|
||||
status_form = JobPostingStatusForm(instance=job)
|
||||
image_upload_form=JobPostingImageForm(instance=job)
|
||||
@ -1521,7 +1522,7 @@ def candidate_screening_view(request, slug):
|
||||
offer_count=job.candidates.filter(stage='Offer').count()
|
||||
# Get all candidates for this job, ordered by match score (descending)
|
||||
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
|
||||
|
||||
|
||||
|
||||
|
||||
# Get tier categorization parameters
|
||||
@ -1629,33 +1630,31 @@ def candidate_screening_view(request, slug):
|
||||
|
||||
min_ai_score_str = request.GET.get('min_ai_score')
|
||||
tier1_count_str = request.GET.get('tier1_count')
|
||||
|
||||
|
||||
try:
|
||||
# Check if the string value exists and is not an empty string before conversion
|
||||
if min_ai_score_str:
|
||||
min_ai_score = int(min_ai_score_str)
|
||||
else:
|
||||
min_ai_score = 0
|
||||
|
||||
|
||||
if tier1_count_str:
|
||||
tier1_count = int(tier1_count_str)
|
||||
else:
|
||||
tier1_count = 0
|
||||
|
||||
|
||||
except ValueError:
|
||||
# This catches if the user enters non-numeric text (e.g., "abc")
|
||||
min_ai_score = 0
|
||||
tier1_count = 0
|
||||
print(min_ai_score)
|
||||
print(tier1_count)
|
||||
|
||||
# You can now safely use min_ai_score and tier1_count as integers (0 or greater)
|
||||
if min_ai_score > 0:
|
||||
candidates = candidates.filter(match_score__gte=min_ai_score)
|
||||
print(candidates)
|
||||
|
||||
|
||||
if tier1_count > 0:
|
||||
candidates = candidates[:tier1_count]
|
||||
|
||||
|
||||
context = {
|
||||
"job": job,
|
||||
"candidates": candidates,
|
||||
@ -1697,7 +1696,6 @@ def update_candidate_exam_status(request, slug):
|
||||
def bulk_update_candidate_exam_status(request,slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
status = request.headers.get('status')
|
||||
|
||||
if status:
|
||||
for candidate in get_candidates_from_request(request):
|
||||
try:
|
||||
@ -1724,19 +1722,18 @@ def candidate_set_exam_date(request, slug):
|
||||
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
|
||||
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
||||
|
||||
def bulk_candidate_move_to_exam(request):
|
||||
for candidate in get_candidates_from_request(request):
|
||||
candidate.stage = "Exam"
|
||||
candidate.applicant_status = "Candidate"
|
||||
candidate.exam_date = timezone.now()
|
||||
candidate.save()
|
||||
def candidate_update_status(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
mark_as = request.POST.get('mark_as')
|
||||
candidate_ids = request.POST.getlist("candidate_ids")
|
||||
|
||||
messages.success(request, f"Candidates Moved to Exam stage")
|
||||
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
||||
# def response():
|
||||
# yield SSE.patch_elements("","")
|
||||
# yield SSE.execute_script("console.log('hello world');")
|
||||
# return DatastarResponse(response())
|
||||
if c := Candidate.objects.filter(pk__in = candidate_ids):
|
||||
c.update(stage=mark_as,exam_date=timezone.now(),applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant")
|
||||
|
||||
messages.success(request, f"Candidates Updated")
|
||||
response = HttpResponse(redirect("candidate_screening_view", slug=job.slug))
|
||||
response.headers["HX-Refresh"] = "true"
|
||||
return response
|
||||
|
||||
def candidate_interview_view(request,slug):
|
||||
job = get_object_or_404(JobPosting,slug=slug)
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
padding-right: var(--bs-gutter-x, 0.75rem); /* Add Bootstrap padding for responsiveness */
|
||||
padding-left: var(--bs-gutter-x, 0.75rem);
|
||||
}
|
||||
|
||||
|
||||
/* === Top Bar === */
|
||||
.top-bar {
|
||||
background-color: white;
|
||||
@ -77,7 +77,7 @@
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
|
||||
}
|
||||
/* Change the outer navbar container to fluid, rely on inner max-width */
|
||||
.navbar-dark > .container {
|
||||
.navbar-dark > .container {
|
||||
max-width: 100%; /* Override default container width */
|
||||
}
|
||||
.nav-link {
|
||||
@ -276,7 +276,7 @@
|
||||
<div class="en small">Princess Nourah bint Abdulrahman University</div>
|
||||
<div class="en small">King Abdullah bin Abdulaziz University Hospital</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 100px;max-width:100px;">
|
||||
</div>
|
||||
@ -325,7 +325,7 @@
|
||||
|
||||
{% trans "Form Templates" %}
|
||||
</span>
|
||||
|
||||
|
||||
</a>
|
||||
</li> {% endcomment %}
|
||||
|
||||
@ -349,8 +349,8 @@
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="nav-item me-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
@ -362,7 +362,7 @@
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="nav-item dropdown ms-2">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
||||
data-bs-offset="0, 8" data-bs-auto-close="outside">
|
||||
@ -390,9 +390,9 @@
|
||||
<span class="d-none d-lg-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" data-bs-popper="static">
|
||||
|
||||
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
@ -401,7 +401,7 @@
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
@ -476,13 +476,13 @@
|
||||
</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<i class="fab fa-linkedin text-primary me-1"></i>
|
||||
<i class="fab fa-linkedin text-primary me-1"></i>
|
||||
<span class="text-primary d-none d-lg-inline ms-auto me-3">
|
||||
{% trans "LinkedIn Connected" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a></li>
|
||||
|
||||
</a></li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li>
|
||||
<form method="post" action="" class="d-inline">
|
||||
@ -518,13 +518,13 @@
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
|
||||
<footer class="mt-auto">
|
||||
<div class="footer-bottom py-3 small text-muted" style="background-color: #00363a;">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap max-width-1600">
|
||||
<p class="mb-0 text-white-50">
|
||||
© {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
|
||||
© {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
|
||||
{% trans "All rights reserved." %}
|
||||
</p>
|
||||
<a class="text-decoration-none" href="https://tenhal.sa/" target='_blank'>
|
||||
@ -588,7 +588,7 @@
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script>
|
||||
{% comment %} <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script> {% endcomment %}
|
||||
|
||||
{% block customJS %}{% endblock %}
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-info: #17a2b8; /* Used for Exam stages (Pending status) */
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-warning: #ffc107;
|
||||
@ -28,7 +28,7 @@
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
|
||||
/* Dedicated style for the tier control block (consistent with .filter-controls) */
|
||||
.tier-controls {
|
||||
background-color: var(--kaauh-border); /* Light background for control sections */
|
||||
@ -96,14 +96,14 @@
|
||||
.form-control-sm,
|
||||
.btn-sm {
|
||||
/* Reduce vertical padding even more than default Bootstrap 'sm' */
|
||||
padding-top: 0.2rem !important;
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
/* Ensure a consistent, small height for inputs and buttons */
|
||||
height: 28px !important;
|
||||
font-size: 0.8rem !important;
|
||||
height: 28px !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
.btn-main-action.btn-sm { font-weight: 600 !important; }
|
||||
|
||||
|
||||
/* Container for the timeline include */
|
||||
.applicant-tracking-timeline {
|
||||
margin-bottom: 2rem;
|
||||
@ -111,7 +111,7 @@
|
||||
|
||||
/* 4. Candidate Table Styling (KAAT-S Look) */
|
||||
.candidate-table {
|
||||
table-layout: fixed;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
@ -172,7 +172,7 @@
|
||||
.bg-success { background-color: var(--kaauh-success) !important; color: white; }
|
||||
.bg-danger { background-color: var(--kaauh-danger) !important; color: white; }
|
||||
.bg-info-pending { background-color: var(--kaauh-info) !important; color: white; }
|
||||
|
||||
|
||||
.tier-badge { /* Used for Tier labels */
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
@ -191,7 +191,7 @@
|
||||
.candidate-table th:nth-child(5) { width: 12%; } /* Exam Status */
|
||||
.candidate-table th:nth-child(6) { width: 15%; } /* Exam Date */
|
||||
.candidate-table th:nth-child(7) { width: 220px; } /* Actions */
|
||||
|
||||
|
||||
.cd_exam{
|
||||
color: #00636e;
|
||||
}
|
||||
@ -253,32 +253,23 @@
|
||||
|
||||
<div class="kaauh-card shadow-sm p-3">
|
||||
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
|
||||
{% url "bulk_update_candidate_exam_status" job.slug as bulk_update_candidate_exam_status_url %}
|
||||
|
||||
<div class="mb-3 d-flex gap-2">
|
||||
<div class="col-md-3 col-sm-6 mb-3 d-flex gap-2">
|
||||
{% if candidates %}
|
||||
<button class="btn btn-bulk-pass btn-sm"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#candidate-form',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Passed'}
|
||||
})"
|
||||
>
|
||||
<i class="fas fa-check-circle me-1"></i>
|
||||
{% trans "Bulk Mark Passed (-> Interview)" %}
|
||||
</button>
|
||||
<button class="btn btn-bulk-fail btn-sm"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#candidate-form',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Failed'}
|
||||
})"
|
||||
>
|
||||
<i class="fas fa-times-circle me-1"></i>
|
||||
{% trans "Bulk Mark Failed" %}
|
||||
</button>
|
||||
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||
<div class="d-flex align-items-center">
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
|
||||
<option value="Applied">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Apply" %}
|
||||
</option>
|
||||
<option value="Interview">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Interview" %}
|
||||
</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-main-action btn-mds ms-2">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -291,11 +282,7 @@
|
||||
{% if candidates %}
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-_all
|
||||
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
||||
data-effect="$selections; $_all = $selections.every(Boolean)"
|
||||
data-attr-disabled="$_fetching"
|
||||
type="checkbox" class="form-check-input" id="checkAll">
|
||||
type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
||||
</div>
|
||||
{% endif %}
|
||||
</th>
|
||||
@ -312,13 +299,11 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-selections
|
||||
data-attr-disabled="$_fetching"
|
||||
name="candidate_ids"
|
||||
value="{{ candidate.id }}"
|
||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
<input
|
||||
name="candidate_ids"
|
||||
value="{{ candidate.id }}"
|
||||
type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="candidate-name">
|
||||
@ -378,14 +363,14 @@
|
||||
</table>
|
||||
{% if not candidates %}
|
||||
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
{% trans "No candidates are currently in the Exam stage for this job." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||
@ -411,4 +396,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
|
||||
// Function to safely update the header checkbox state
|
||||
function updateSelectAllState() {
|
||||
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
|
||||
const totalCount = rowCheckboxes.length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === totalCount) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
// Set to indeterminate state (partially checked)
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
|
||||
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
|
||||
// Your existing data-bind-_all logic should handle the bulk action status.
|
||||
}
|
||||
|
||||
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
|
||||
selectAllCheckbox.addEventListener('change', function () {
|
||||
const isChecked = selectAllCheckbox.checked;
|
||||
|
||||
// Temporarily disable the change listener on rows to prevent cascading events
|
||||
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||
|
||||
// Update all row checkboxes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
|
||||
// You must still dispatch the event here so your framework's data-bind-selections
|
||||
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Re-attach the change listeners to the rows
|
||||
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||
|
||||
// Ensure the header state is correct after forcing all changes
|
||||
updateSelectAllState();
|
||||
});
|
||||
|
||||
// 2. Logic to update 'Select All' state based on row checkboxes
|
||||
// Attach the function to be called whenever a row checkbox changes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.addEventListener('change', updateSelectAllState);
|
||||
});
|
||||
|
||||
// Initial check to set the correct state on load (in case items are pre-checked)
|
||||
updateSelectAllState();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -194,48 +194,40 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="filter-controls shadow-sm">
|
||||
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-primary-text);">
|
||||
<i class="fas fa-sort-numeric-up me-1"></i> {% trans "AI Scoring & Top Candidate Filter" %}
|
||||
</h4>
|
||||
|
||||
<form method="GET" class="mb-0">
|
||||
{% csrf_token %}
|
||||
<div class="row g-3 align-items-end">
|
||||
<form method="GET" class="mb-0 pb-3">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex flex-nowrap g-3 align-items-end" style="overflow-x: auto;">
|
||||
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<label for="min_ai_score" class="form-label small text-muted">
|
||||
{% trans "Minimum AI Score" %}
|
||||
<div class="p-2">
|
||||
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
||||
{% trans "Min AI Score" %}
|
||||
</label>
|
||||
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
|
||||
value="{{ min_ai_score}}" min="0" max="100" step="1"
|
||||
placeholder="e.g., 75" style="min-width: 120px;">
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 col-sm-6">
|
||||
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
||||
{% trans "Min AI Score" %}
|
||||
</label>
|
||||
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
|
||||
value="{{ min_ai_score}}" min="0" max="100" step="1"
|
||||
placeholder="e.g., 75">
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<label for="tier1_count" class="form-label small text-muted mb-1">
|
||||
{% trans "Top N" %}
|
||||
</label>
|
||||
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
|
||||
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}" style="min-width: 100px;">
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 col-sm-6">
|
||||
<label for="tier1_count" class="form-label small text-muted mb-1">
|
||||
{% trans "Top N" %}
|
||||
</label>
|
||||
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
|
||||
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% comment %} Empty col for spacing (2 + 2 + 3 + 5 = 12) {% endcomment %}
|
||||
<div class="col-md-5 d-none d-md-block"></div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="p-2">
|
||||
<label class="form-label small text-muted mb-1 d-block"> </label>
|
||||
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100" style="min-width: 150px;">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
|
||||
@ -244,22 +236,23 @@
|
||||
</h2>
|
||||
|
||||
<div class="kaauh-card shadow-sm p-3">
|
||||
{% url "bulk_candidate_move_to_exam" as move_to_exam_url %}
|
||||
|
||||
{% if candidates %}
|
||||
<button class="btn btn-bulk-action btn-sm mb-3"
|
||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||
data-on-click="@post('{{move_to_exam_url}}',{
|
||||
contentType: 'form',
|
||||
selector: '#candidate-form',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token }}'}})"
|
||||
>
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Bulk Move to Exam" %}
|
||||
</button>
|
||||
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||
<div class="d-flex align-items-center">
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
|
||||
<option value="Exam">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Exam" %}
|
||||
</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-main-action btn-mds ms-2">
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<form id="candidate-form" action="{{move_to_exam_url}}" method="post">
|
||||
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||
{% csrf_token %}
|
||||
<table class="table candidate-table align-middle">
|
||||
<thead>
|
||||
@ -268,11 +261,7 @@
|
||||
{% if candidates %}
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-_all
|
||||
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
||||
data-effect="$selections; $_all = $selections.every(Boolean)"
|
||||
data-attr-disabled="$_fetching"
|
||||
type="checkbox" class="form-check-input" id="checkAll">
|
||||
type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
||||
</div>
|
||||
{% endif %}
|
||||
</th>
|
||||
@ -290,11 +279,9 @@
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input
|
||||
data-bind-selections
|
||||
data-attr-disabled="$_fetching"
|
||||
name="candidate_ids"
|
||||
value="{{ candidate.id }}"
|
||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
||||
type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@ -378,4 +365,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
|
||||
// Function to safely update the header checkbox state
|
||||
function updateSelectAllState() {
|
||||
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
|
||||
const totalCount = rowCheckboxes.length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === totalCount) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
// Set to indeterminate state (partially checked)
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
|
||||
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
|
||||
// Your existing data-bind-_all logic should handle the bulk action status.
|
||||
}
|
||||
|
||||
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
|
||||
selectAllCheckbox.addEventListener('change', function () {
|
||||
const isChecked = selectAllCheckbox.checked;
|
||||
|
||||
// Temporarily disable the change listener on rows to prevent cascading events
|
||||
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||
|
||||
// Update all row checkboxes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
|
||||
// You must still dispatch the event here so your framework's data-bind-selections
|
||||
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Re-attach the change listeners to the rows
|
||||
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||
|
||||
// Ensure the header state is correct after forcing all changes
|
||||
updateSelectAllState();
|
||||
});
|
||||
|
||||
// 2. Logic to update 'Select All' state based on row checkboxes
|
||||
// Attach the function to be called whenever a row checkbox changes
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.addEventListener('change', updateSelectAllState);
|
||||
});
|
||||
|
||||
// Initial check to set the correct state on load (in case items are pre-checked)
|
||||
updateSelectAllState();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user