update the bulk button to select

This commit is contained in:
ismail 2025-10-14 15:55:53 +03:00
parent d0db3d1323
commit 302aa8d0bf
16 changed files with 328 additions and 193 deletions

View File

@ -0,0 +1,14 @@
# Generated by Django 5.2.6 on 2025-10-14 11:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_alter_scheduledinterview_schedule'),
('recruitment', '0011_alter_jobpostingimage_job_and_more'),
]
operations = [
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.2.6 on 2025-10-14 11:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0012_merge_20251014_1403'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='formtemplate',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -273,12 +273,12 @@ class Candidate(Base):
CANDIDATE = "Candidate", _("Candidate")
# Stage transition validation constants
STAGE_SEQUENCE = {
"Applied": ["Exam", "Interview", "Offer"],
"Exam": ["Interview", "Offer"],
"Interview": ["Offer"],
"Offer": [], # Final stage - no further transitions
}
# STAGE_SEQUENCE = {
# "Applied": ["Exam", "Interview", "Offer"],
# "Exam": ["Interview", "Offer"],
# "Interview": ["Offer"],
# "Offer": [], # Final stage - no further transitions
# }
job = models.ForeignKey(
JobPosting,
@ -375,50 +375,50 @@ class Candidate(Base):
return self.resume.size
return 0
def clean(self):
"""Validate stage transitions"""
# Only validate if this is an existing record (not being created)
if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
old_stage = self.__class__.objects.get(pk=self.pk).stage
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
# def clean(self):
# """Validate stage transitions"""
# # Only validate if this is an existing record (not being created)
# if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
# old_stage = self.__class__.objects.get(pk=self.pk).stage
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
if self.stage not in allowed_next_stages:
raise ValidationError(
{
"stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
}
)
# if self.stage not in allowed_next_stages:
# raise ValidationError(
# {
# "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
# f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
# }
# )
# Validate that the stage is a valid choice
if self.stage not in [choice[0] for choice in self.Stage.choices]:
raise ValidationError(
{
"stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
}
)
# # Validate that the stage is a valid choice
# if self.stage not in [choice[0] for choice in self.Stage.choices]:
# raise ValidationError(
# {
# "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
# }
# )
def save(self, *args, **kwargs):
"""Override save to ensure validation is called"""
self.clean() # Call validation before saving
super().save(*args, **kwargs)
def can_transition_to(self, new_stage):
"""Check if a stage transition is allowed"""
if not self.pk: # New record - can be in Applied stage
return new_stage == "Applied"
# def can_transition_to(self, new_stage):
# """Check if a stage transition is allowed"""
# if not self.pk: # New record - can be in Applied stage
# return new_stage == "Applied"
old_stage = self.__class__.objects.get(pk=self.pk).stage
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
return new_stage in allowed_next_stages
# old_stage = self.__class__.objects.get(pk=self.pk).stage
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
# return new_stage in allowed_next_stages
def get_available_stages(self):
"""Get list of stages this candidate can transition to"""
if not self.pk: # New record
return ["Applied"]
# def get_available_stages(self):
# """Get list of stages this candidate can transition to"""
# if not self.pk: # New record
# return ["Applied"]
old_stage = self.__class__.objects.get(pk=self.pk).stage
return self.STAGE_SEQUENCE.get(old_stage, [])
# old_stage = self.__class__.objects.get(pk=self.pk).stage
# return self.STAGE_SEQUENCE.get(old_stage, [])
@property
def submission(self):
@ -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"

View File

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

View File

@ -17,7 +17,6 @@ urlpatterns = [
path('jobs/<slug:slug>/candidate/application/success', views.application_success, name='application_success'),
path('careers/',views.kaauh_career,name='kaauh_career'),
# LinkedIn Integration URLs
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
@ -34,7 +33,6 @@ urlpatterns = [
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
# Training URLs
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
@ -75,7 +73,8 @@ urlpatterns = [
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'),
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
path('htmx/bulk_candidate_move_to_exam/', views.bulk_candidate_move_to_exam, name='bulk_candidate_move_to_exam'),
path('htmx/<slug:slug>/candidate_update_status/', views.candidate_update_status, name='candidate_update_status'),
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),

View File

@ -5,7 +5,7 @@ from rich import print
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.http import JsonResponse
from django.http import HttpResponse, JsonResponse
from datetime import datetime,time,timedelta
from django.views import View
from django.db.models import Q
@ -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)

View File

@ -30,7 +30,7 @@
padding-right: var(--bs-gutter-x, 0.75rem); /* Add Bootstrap padding for responsiveness */
padding-left: var(--bs-gutter-x, 0.75rem);
}
/* === Top Bar === */
.top-bar {
background-color: white;
@ -77,7 +77,7 @@
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
}
/* Change the outer navbar container to fluid, rely on inner max-width */
.navbar-dark > .container {
.navbar-dark > .container {
max-width: 100%; /* Override default container width */
}
.nav-link {
@ -276,7 +276,7 @@
<div class="en small">Princess Nourah bint Abdulrahman University</div>
<div class="en small">King Abdullah bin Abdulaziz University Hospital</div>
</div>
</div>
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 100px;max-width:100px;">
</div>
@ -325,7 +325,7 @@
{% trans "Form Templates" %}
</span>
</a>
</li> {% endcomment %}
@ -349,8 +349,8 @@
</span>
</a>
</li>
<li class="nav-item me-4">
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
<span class="d-flex align-items-center gap-2">
@ -362,7 +362,7 @@
</span>
</a>
</li>
<li class="nav-item dropdown ms-2">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
data-bs-offset="0, 8" data-bs-auto-close="outside">
@ -390,9 +390,9 @@
<span class="d-none d-lg-inline">{{ LANGUAGE_CODE|upper }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" data-bs-popper="static">
{% get_current_language as LANGUAGE_CODE %}
<li>
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
<input name="next" type="hidden" value="{{ request.get_full_path }}">
@ -401,7 +401,7 @@
</button>
</form>
</li>
<li>
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
<input name="next" type="hidden" value="{{ request.get_full_path }}">
@ -476,13 +476,13 @@
</li>
</ul>
{% else %}
<i class="fab fa-linkedin text-primary me-1"></i>
<i class="fab fa-linkedin text-primary me-1"></i>
<span class="text-primary d-none d-lg-inline ms-auto me-3">
{% trans "LinkedIn Connected" %}
</span>
{% endif %}
</a></li>
</a></li>
<li><hr class="dropdown-divider my-1"></li>
<li>
<form method="post" action="" class="d-inline">
@ -518,13 +518,13 @@
{% block content %}
{% endblock %}
</main>
<footer class="mt-auto">
<div class="footer-bottom py-3 small text-muted" style="background-color: #00363a;">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center flex-wrap max-width-1600">
<p class="mb-0 text-white-50">
&copy; {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
&copy; {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
{% trans "All rights reserved." %}
</p>
<a class="text-decoration-none" href="https://tenhal.sa/" target='_blank'>
@ -588,7 +588,7 @@
});
</script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script>
{% comment %} <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script> {% endcomment %}
{% block customJS %}{% endblock %}

View File

@ -11,7 +11,7 @@
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-success: #28a745;
--kaauh-info: #17a2b8; /* Used for Exam stages (Pending status) */
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
@ -28,7 +28,7 @@
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
}
/* Dedicated style for the tier control block (consistent with .filter-controls) */
.tier-controls {
background-color: var(--kaauh-border); /* Light background for control sections */
@ -96,14 +96,14 @@
.form-control-sm,
.btn-sm {
/* Reduce vertical padding even more than default Bootstrap 'sm' */
padding-top: 0.2rem !important;
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
/* Ensure a consistent, small height for inputs and buttons */
height: 28px !important;
font-size: 0.8rem !important;
height: 28px !important;
font-size: 0.8rem !important;
}
.btn-main-action.btn-sm { font-weight: 600 !important; }
/* Container for the timeline include */
.applicant-tracking-timeline {
margin-bottom: 2rem;
@ -111,7 +111,7 @@
/* 4. Candidate Table Styling (KAAT-S Look) */
.candidate-table {
table-layout: fixed;
table-layout: fixed;
width: 100%;
border-collapse: separate;
border-spacing: 0;
@ -172,7 +172,7 @@
.bg-success { background-color: var(--kaauh-success) !important; color: white; }
.bg-danger { background-color: var(--kaauh-danger) !important; color: white; }
.bg-info-pending { background-color: var(--kaauh-info) !important; color: white; }
.tier-badge { /* Used for Tier labels */
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
@ -191,7 +191,7 @@
.candidate-table th:nth-child(5) { width: 12%; } /* Exam Status */
.candidate-table th:nth-child(6) { width: 15%; } /* Exam Date */
.candidate-table th:nth-child(7) { width: 220px; } /* Actions */
.cd_exam{
color: #00636e;
}
@ -253,32 +253,23 @@
<div class="kaauh-card shadow-sm p-3">
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
{% url "bulk_update_candidate_exam_status" job.slug as bulk_update_candidate_exam_status_url %}
<div class="mb-3 d-flex gap-2">
<div class="col-md-3 col-sm-6 mb-3 d-flex gap-2">
{% if candidates %}
<button class="btn btn-bulk-pass btn-sm"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#candidate-form',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Passed'}
})"
>
<i class="fas fa-check-circle me-1"></i>
{% trans "Bulk Mark Passed (-> Interview)" %}
</button>
<button class="btn btn-bulk-fail btn-sm"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#candidate-form',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Failed'}
})"
>
<i class="fas fa-times-circle me-1"></i>
{% trans "Bulk Mark Failed" %}
</button>
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
<div class="d-flex align-items-center">
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
<option value="Applied">
<i class="fas fa-arrow-left me-1"></i> {% trans "Apply" %}
</option>
<option value="Interview">
<i class="fas fa-arrow-right me-1"></i> {% trans "Interview" %}
</option>
</select>
<button type="submit" class="btn btn-main-action btn-mds ms-2">
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
</button>
</div>
</form>
{% endif %}
</div>
@ -291,11 +282,7 @@
{% if candidates %}
<div class="form-check">
<input
data-bind-_all
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
data-effect="$selections; $_all = $selections.every(Boolean)"
data-attr-disabled="$_fetching"
type="checkbox" class="form-check-input" id="checkAll">
type="checkbox" class="form-check-input" id="selectAllCheckbox">
</div>
{% endif %}
</th>
@ -312,13 +299,11 @@
<tr>
<td>
<div class="form-check">
<input
data-bind-selections
data-attr-disabled="$_fetching"
name="candidate_ids"
value="{{ candidate.id }}"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
</div>
<input
name="candidate_ids"
value="{{ candidate.id }}"
type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
</div>
</td>
<td>
<div class="candidate-name">
@ -378,14 +363,14 @@
</table>
{% if not candidates %}
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
<i class="fas fa-info-circle me-1"></i>
<i class="fas fa-info-circle me-1"></i>
{% trans "No candidates are currently in the Exam stage for this job." %}
</div>
{% endif %}
</form>
</div>
</div>
</div>
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
@ -411,4 +396,70 @@
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
if (selectAllCheckbox) {
// Function to safely update the header checkbox state
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
const totalCount = rowCheckboxes.length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
// Your existing data-bind-_all logic should handle the bulk action status.
}
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
// Temporarily disable the change listener on rows to prevent cascading events
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
// Update all row checkboxes
rowCheckboxes.forEach(function (checkbox) {
checkbox.checked = isChecked;
// You must still dispatch the event here so your framework's data-bind-selections
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
});
// Re-attach the change listeners to the rows
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
// Ensure the header state is correct after forcing all changes
updateSelectAllState();
});
// 2. Logic to update 'Select All' state based on row checkboxes
// Attach the function to be called whenever a row checkbox changes
rowCheckboxes.forEach(function (checkbox) {
checkbox.addEventListener('change', updateSelectAllState);
});
// Initial check to set the correct state on load (in case items are pre-checked)
updateSelectAllState();
}
});
</script>
{% endblock %}

View File

@ -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">&nbsp;</label>
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100" style="min-width: 150px;">
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
</button>
</div>
</div>
</form>
</div>
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
@ -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 %}