diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 68669bc..fd64805 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -209,9 +209,9 @@ ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True ACCOUNT_FORMS = {"signup": "recruitment.forms.StaffSignupForm"} -MAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" -EMAIL_HOST = "10.10.1.110" -EMAIL_PORT = 2225 +# MAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +# EMAIL_HOST = "10.10.1.110" +# EMAIL_PORT = 2225 # EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # EMAIL_HOST_PASSWORD = os.getenv("EMAIL_PASSWORD", "mssp.0Q0rSwb.zr6ke4n2k3e4on12.aHwJqnI") @@ -227,13 +227,13 @@ EMAIL_PORT = 2225 -# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -# EMAIL_HOST = 'smtp.gmail.com' -# EMAIL_PORT = 587 -# EMAIL_USE_TLS = True -# EMAIL_HOST_USER = 'faheedk215@gmail.com' # Use your actual Gmail email address -# EMAIL_HOST_PASSWORD = 'nfxf xpzo bpsb lqje' # -# DEFAULT_FROM_EMAIL='faheedlearn@gmail.com' +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'faheedk215@gmail.com' # Use your actual Gmail email address +EMAIL_HOST_PASSWORD = 'mduo mcsn lwih irkf' # +DEFAULT_FROM_EMAIL = 'faheedk215@gmail.com' # Crispy Forms Configuration CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" @@ -575,4 +575,6 @@ CACHES = { } -CSRF_TRUSTED_ORIGINS=["http://10.10.1.126","https://kaauh1.tenhal.sa",'http://127.0.0.1'] \ No newline at end of file +CSRF_TRUSTED_ORIGINS=["http://10.10.1.126","https://kaauh1.tenhal.sa",'http://127.0.0.1',] + +CAREER_PAGE_URL = "http://localhost:8000/en/careers/" \ No newline at end of file diff --git a/recruitment/forms.py b/recruitment/forms.py index ffacc67..db6da35 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -7,6 +7,7 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div from django.contrib.auth import get_user_model from django.contrib.auth.forms import UserCreationForm +from django.conf import settings from .models import ( Application, @@ -498,6 +499,7 @@ class JobPostingForm(forms.ModelForm): "class": "form-control", "min": 1, "placeholder": "Number of open positions", + "required": True, } ), "hash_tags": forms.TextInput( @@ -534,7 +536,14 @@ class JobPostingForm(forms.ModelForm): self.fields["location_city"].initial = "Riyadh" self.fields["location_state"].initial = "Riyadh Province" self.fields["location_country"].initial = "Saudi Arabia" - + def clean_open_positions(self): + open_positions = self.cleaned_data.get("open_positions") + if open_positions is None or open_positions < 1: + raise forms.ValidationError( + "Open positions must be at least 1." + ) + return open_positions + def clean_hash_tags(self): hash_tags = self.cleaned_data.get("hash_tags") if hash_tags: @@ -1029,6 +1038,40 @@ class HiringAgencyForm(forms.ModelForm): return website +class AgencyJobAssignmentCancelForm(forms.ModelForm): + """Form for cancelling agency job assignments""" + + class Meta: + model = AgencyJobAssignment + fields = ["cancel_reason"] + widgets = { + "cancel_reason": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 4, + "placeholder": "Enter reason for cancelling this assignment (optional)..." + } + ), + } + labels = { + "cancel_reason": _("Cancellation Reason"), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = "post" + self.helper.form_class = "g-3" + + self.helper.layout = Layout( + Field("cancel_reason", css_class="form-control"), + Div( + Submit("submit", _("Cancel Assignment"), css_class="btn btn-danger"), + css_class="col-12 mt-4", + ), + ) + + class AgencyJobAssignmentForm(forms.ModelForm): """Form for creating and editing agency job assignments""" @@ -1400,11 +1443,12 @@ class CandidateEmailForm(forms.Form): if candidate and candidate.stage == 'Applied': message_parts = [ + f"Dear Candidate,", f"Thank you for your interest in the {self.job.title} position at KAAUH and for taking the time to submit your application.", f"We have carefully reviewed your qualifications; however, we regret to inform you that your application was not selected to proceed to the examination round at this time.", f"The selection process was highly competitive, and we had a large number of highly qualified applicants.", f"We encourage you to review other opportunities and apply for roles that align with your skills on our career portal:", - f"[settings.CAREER_PAGE_URL]", # Use a Django setting for the URL for flexibility + f"{settings.CAREER_PAGE_URL}", # Use a Django setting for the URL for flexibility f"We wish you the best of luck in your current job search and future career endeavors.", f"Sincerely,", f"The KAAUH Recruitment Team", @@ -1457,6 +1501,7 @@ class CandidateEmailForm(forms.Form): ] elif candidate and candidate.stage == 'Document Review': message_parts = [ + f"Dear Candidate,", f"Congratulations on progressing to the final stage for the {self.job.title} role!", f"The next critical step is to complete your application by uploading the required employment verification documents.", f"**Please log into the Candidate Portal immediately** to access the 'Document Upload' section.", @@ -1467,6 +1512,7 @@ class CandidateEmailForm(forms.Form): ] elif candidate and candidate.stage == 'Hired': message_parts = [ + f"Dear Candidate,", f"Welcome aboard,!", f"We are thrilled to officially confirm your employment as our new {self.job.title}.", f"You will receive a separate email shortly with details regarding your start date, first-day instructions, and onboarding documents.", @@ -2303,4 +2349,4 @@ class InterviewResultForm(forms.Form): 'rows': 3, 'placeholder': 'Enter result comment', }) - ) \ No newline at end of file + ) diff --git a/recruitment/migrations/0002_add_cancellation_fields_to_agency_job_assignment.py b/recruitment/migrations/0002_add_cancellation_fields_to_agency_job_assignment.py new file mode 100644 index 0000000..52b2e09 --- /dev/null +++ b/recruitment/migrations/0002_add_cancellation_fields_to_agency_job_assignment.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-01-19 20:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='agencyjobassignment', + name='cancel_reason', + field=models.TextField(blank=True, help_text='Reason for cancelling this assignment', null=True, verbose_name='Cancel Reason'), + ), + migrations.AddField( + model_name='agencyjobassignment', + name='cancelled_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At'), + ), + migrations.AddField( + model_name='agencyjobassignment', + name='cancelled_by', + field=models.CharField(blank=True, help_text='Name of person who cancelled this assignment', max_length=100, null=True, verbose_name='Cancelled By'), + ), + ] diff --git a/recruitment/migrations/0003_alter_agencyjobassignment_status.py b/recruitment/migrations/0003_alter_agencyjobassignment_status.py new file mode 100644 index 0000000..c76a5db --- /dev/null +++ b/recruitment/migrations/0003_alter_agencyjobassignment_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-19 21:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_add_cancellation_fields_to_agency_job_assignment'), + ] + + operations = [ + migrations.AlterField( + model_name='agencyjobassignment', + name='status', + field=models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index f12c1b7..c847ff8 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -87,6 +87,8 @@ class CustomUser(AbstractUser): def get_unread_message_count(self): message_list = Message.objects.filter(Q(recipient=self), is_read=False) return message_list.count() or 0 + + User = get_user_model() @@ -2006,7 +2008,6 @@ class AgencyJobAssignment(Base): class AssignmentStatus(models.TextChoices): ACTIVE = "ACTIVE", _("Active") COMPLETED = "COMPLETED", _("Completed") - EXPIRED = "EXPIRED", _("Expired") CANCELLED = "CANCELLED", _("Cancelled") agency = models.ForeignKey( @@ -2069,6 +2070,26 @@ class AgencyJobAssignment(Base): help_text=_("Internal notes about this assignment"), ) + # Cancellation tracking + cancelled_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Cancelled At") + ) + cancelled_by = models.CharField( + max_length=100, + blank=True, + null=True, + verbose_name=_("Cancelled By"), + help_text=_("Name of person who cancelled this assignment") + ) + cancel_reason = models.TextField( + blank=True, + null=True, + verbose_name=_("Cancel Reason"), + help_text=_("Reason for cancelling this assignment") + ) + class Meta: verbose_name = _("Agency Job Assignment") verbose_name_plural = _("Agency Job Assignments") diff --git a/recruitment/services/email_service.py b/recruitment/services/email_service.py index 1234954..7b28d50 100644 --- a/recruitment/services/email_service.py +++ b/recruitment/services/email_service.py @@ -112,6 +112,7 @@ class EmailService: html_content=html_content, ) + print(f"Bulk email sent to {sent_count} recipients.") # Return the count of recipients if successful, or 0 if failure return len(recipient_emails) if sent_count > 0 else 0 diff --git a/recruitment/signals.py b/recruitment/signals.py index 9afbaac..ccfb7a0 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -18,6 +18,7 @@ from .models import ( HiringAgency, Person, Source, + AgencyJobAssignment, ) from .forms import generate_api_key, generate_api_secret from django.contrib.auth import get_user_model @@ -197,6 +198,41 @@ def notification_created(sender, instance, created, **kwargs): from .utils import generate_random_password + + +@receiver(post_save, sender=Application) +def trigger_erp_sync_on_hired(sender, instance, created, **kwargs): + """ + Automatically trigger ERP sync when an application is moved to 'Hired' stage. + """ + # Only trigger on updates (not new applications) + if created: + return + + # Only trigger if stage changed to 'Hired' + if instance.stage == 'Hired': + try: + # Get the previous state to check if stage actually changed + from_db = Application.objects.get(pk=instance.pk) + if from_db.stage != 'Hired': + # Stage changed to Hired - trigger sync once per job + from django_q.tasks import async_task + from .tasks import sync_hired_candidates_task + + job_slug = instance.job.slug + logger.info(f"Triggering automatic ERP sync for job {job_slug}") + + # Queue sync task for background processing + async_task( + sync_hired_candidates_task, + job_slug, + group=f"auto_sync_job_{job_slug}", + timeout=300, # 5 minutes + ) + except Application.DoesNotExist: + pass + + @receiver(post_save, sender=HiringAgency) def hiring_agency_created(sender, instance, created, **kwargs): if created: @@ -254,3 +290,42 @@ def source_created(sender, instance, created, **kwargs): logger.info(f"API keys generated successfully for Source: {instance.name} (Key: {api_key[:8]}...)") else: logger.info(f"Source {instance.name} already has API keys, skipping generation") + + +@receiver(post_save, sender=AgencyJobAssignment) +def auto_update_agency_assignment_status(sender, instance, created, **kwargs): + """ + Automatically update AgencyJobAssignment status based on conditions: + - Set to COMPLETED when candidates_submitted >= max_candidates + - Keep is_active synced with status field + """ + # Only process updates (skip new records) + if created: + return + + # Auto-complete when max candidates reached + if instance.candidates_submitted >= instance.max_candidates: + if instance.status != AgencyJobAssignment.AssignmentStatus.COMPLETED: + logger.info( + f"Auto-completing assignment {instance.pk}: " + f"Max candidates ({instance.max_candidates}) reached" + ) + # Use filter().update() to avoid triggering post_save signal again + AgencyJobAssignment.objects.filter(pk=instance.pk).update( + status=AgencyJobAssignment.AssignmentStatus.COMPLETED, + is_active=False + ) + return + + # Sync is_active with status - only if it actually changed + if instance.status == AgencyJobAssignment.AssignmentStatus.ACTIVE: + AgencyJobAssignment.objects.filter(pk=instance.pk).update( + is_active=True + ) + elif instance.status in [ + AgencyJobAssignment.AssignmentStatus.COMPLETED, + AgencyJobAssignment.AssignmentStatus.CANCELLED, + ]: + AgencyJobAssignment.objects.filter(pk=instance.pk).update( + is_active=False + ) diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 23f37a3..6cc35d7 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -1108,7 +1108,7 @@ def _task_send_individual_email( "email_message": body_message, "user_email": recipient, "logo_url": context.pop( - "logo_url", settings.MEDIA_URL + "/images/kaauh-logo.png" + "logo_url", settings.STATIC_URL + "/images/kaauh-logo.png" ), # Merge any other custom context variables **context, diff --git a/recruitment/urls.py b/recruitment/urls.py index 890dec5..d1f5821 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -87,7 +87,7 @@ urlpatterns = [ path("interviews//update_interview_result", views.update_interview_result, name="update_interview_result"), path("interviews//cancel_interview_for_application", views.cancel_interview_for_application, name="cancel_interview_for_application"), - path("interview//interview-email/",views.send_interview_email,name="send_interview_email"), + path("interviews//interview-email/",views.send_interview_email,name="send_interview_email"), # Interview Creation path("interviews/create//", views.interview_create_type_selection, name="interview_create_type_selection"), @@ -166,10 +166,11 @@ urlpatterns = [ # Agency Assignment Management path("agency-assignments/", views.agency_assignment_list, name="agency_assignment_list"), path("agency-assignments/create/", views.agency_assignment_create, name="agency_assignment_create"), - path("agency-assignments//create/", views.agency_assignment_create, name="agency_assignment_create"), + path("agency-assignments/create//", views.agency_assignment_create, name="agency_assignment_create_with_agency"), path("agency-assignments//", views.agency_assignment_detail, name="agency_assignment_detail"), path("agency-assignments//update/", views.agency_assignment_update, name="agency_assignment_update"), path("agency-assignments//extend-deadline/", views.agency_assignment_extend_deadline, name="agency_assignment_extend_deadline"), + path("agency-assignments//cancel/", views.agency_assignment_cancel, name="agency_assignment_cancel"), # Agency Access Links path("agency-access-links/create/", views.agency_access_link_create, name="agency_access_link_create"), diff --git a/recruitment/views.py b/recruitment/views.py index 590dafb..51e8d94 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -404,6 +404,10 @@ def job_detail(request, slug): interview_count = stage_stats["interview_count"] offer_count = stage_stats["offer_count"] + # Position statistics + positions_filled = job.applications.filter(stage="Hired").count() + vacant_positions = job.open_positions - positions_filled + status_form = JobPostingStatusForm(instance=job) linkedin_content_form = LinkedPostContentForm(instance=job) try: @@ -529,7 +533,7 @@ def job_detail(request, slug): context = { "job": job, "applications": applications, - "total_applications": total_applications, # This was total_candidates in the prompt, using total_applicant for consistency + "total_applications": total_applications, # This was total_candidates in the prompt, using total_applicant for consistency "applied_count": applied_count, "exam_count": exam_count, "interview_count": interview_count, @@ -545,6 +549,9 @@ def job_detail(request, slug): # "high_potential_ratio": high_potential_ratio, "avg_t2i_days": avg_t2i_days, "avg_t_in_exam_days": avg_t_in_exam_days, + # Position statistics + "positions_filled": positions_filled, + "vacant_positions": vacant_positions, "linkedin_content_form": linkedin_content_form, "staff_form": StaffAssignmentForm(), } @@ -2057,14 +2064,55 @@ def application_update_status(request, slug): else "Applicant", ) elif mark_as == "Hired": - print("hired") - c.update( - stage=mark_as, - hired_date=timezone.now(), - applicant_status="Candidate" - if mark_as in ["Exam", "Interview", "Offer"] - else "Applicant", - ) + # Check if number of hired candidates (stage="Hired") >= total open positions for the job + + current_hired_count = job.applications.filter(stage="Hired").count() + print(f"Current hired count: {current_hired_count}") + open_positions = job.open_positions if job.open_positions is not None else 0 + print(f"Open positions: {open_positions}") + total_selected = c.count() + print(f"Total selected for hiring: {total_selected}") + if current_hired_count >= open_positions or (current_hired_count + total_selected) > open_positions: + # Log warning to system and prevent action + logger.warning( + f"Attempted to hire candidates for job '{job.title}'. " + f"Current hired count ({current_hired_count}) has reached/open positions limit ({open_positions})." + + ) + messages.error( + request, + f"Cannot hire more candidates than available positions ({open_positions}). " + f"Hired count: {current_hired_count+total_selected}, Open positions: {open_positions}." + f"Increase open positions to hire more candidates in the job creation/edit page." + ) + return redirect("applications_offer_view", slug=job.slug) + # Do not update the application status + # Redirect back to the job offer view + # if request.headers.get("HX-Request"): + # # HTMX response + # response = HttpResponse(status=400) + # response["HX-Trigger"] = '{"type": "alert", "title": "Hiring Limit Reached", "body": f"You cannot hire more candidates than the available positions ({open_positions})."}' + # return response + # else: + # # Standard response + # messages.warning( + # request, + # f"Cannot hire more candidates than available positions ({open_positions}). " + # f"Hired count: {current_hired_count}, Open positions: {open_positions}." + # ) + # return redirect("applications_offer_view", slug=job.slug) + else: + + c.update( + stage=mark_as, + hired_date=timezone.now(), + applicant_status="Candidate" + if mark_as in ["Exam", "Interview", "Offer"] + else "Applicant", + ) + messages.success( + request, f"Applications Updated and marked as Hired" + ) else: print("rejected") c.update( @@ -2762,6 +2810,7 @@ def agency_assignment_list(request): """List all agency job assignments""" search_query = request.GET.get("q", "") status_filter = request.GET.get("status", "") + print(status_filter) assignments = AgencyJobAssignment.objects.select_related("agency", "job").order_by( "-created_at" @@ -2954,7 +3003,7 @@ def agency_assignment_extend_deadline(request, slug): new_deadline_dt = datetime.fromisoformat( new_deadline.replace("Z", "+00:00") ) - # Ensure the new deadline is timezone-aware + # Ensure to new deadline is timezone-aware if timezone.is_naive(new_deadline_dt): new_deadline_dt = timezone.make_aware(new_deadline_dt) @@ -2975,6 +3024,57 @@ def agency_assignment_extend_deadline(request, slug): return redirect("agency_assignment_detail", slug=assignment.slug) +@login_required +@staff_user_required +def agency_assignment_cancel(request, slug): + """Cancel an agency job assignment""" + from .forms import AgencyJobAssignmentCancelForm + from .models import AgencyAccessLink + + assignment = get_object_or_404(AgencyJobAssignment, slug=slug) + + if request.method == "POST": + form = AgencyJobAssignmentCancelForm(request.POST, instance=assignment) + if form.is_valid(): + # Update assignment fields + assignment.status = "CANCELLED" + assignment.is_active = False + assignment.cancelled_at = timezone.now() + assignment.cancelled_by = request.user.username + assignment.save() + + # Deactivate the associated access link if it exists + try: + access_link = assignment.access_link + if access_link and access_link.is_active: + access_link.is_active = False + access_link.save() + except AgencyAccessLink.DoesNotExist: + pass + + messages.success( + request, + f'Assignment for {assignment.agency.name} - {assignment.job.title} has been cancelled successfully.' + ) + return redirect("agency_assignment_detail", slug=assignment.slug) + else: + messages.error(request, "Please correct errors below.") + else: + form = AgencyJobAssignmentCancelForm(instance=assignment) + + return render( + request, + "recruitment/agency_assignment_cancel.html", + { + "form": form, + "assignment": assignment, + "title": f"Cancel Assignment: {assignment.agency.name} - {assignment.job.title}", + "message": f'Are you sure you want to cancel the assignment for {assignment.agency.name}?', + "cancel_url": reverse("agency_assignment_detail", kwargs={"slug": assignment.slug}), + }, + ) + + @require_POST def portal_password_reset(request, pk): user = get_object_or_404(User, pk=pk) @@ -6009,6 +6109,7 @@ def applications_hired_view(request, slug): @login_required @staff_user_required +@csrf_exempt def update_application_status(request, job_slug, application_slug, stage_type, status): """Handle exam/interview/offer status updates""" from django.utils import timezone @@ -6651,6 +6752,7 @@ def compose_application_email(request, slug): if form.is_valid(): # Get email addresses email_addresses = form.get_email_addresses() + print("email_addresses", email_addresses) if not email_addresses: messages.error(request, "No email selected") diff --git a/templates/base.html b/templates/base.html index c871916..c7c01fa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -43,24 +43,24 @@ - + {% trans "Jobs" %} - + {% trans "Job Bank" %} - + {% trans "Applications" %} - + {% trans "Applicants" %} - + {% trans "Agencies" %} - + {% trans "Interviews" %} @@ -68,7 +68,7 @@
{% comment %} {% trans "System" %} {% endcomment %}
- + {% trans "Messages" %} {% if request.user.get_unread_message_count > 0 %} @@ -88,7 +88,7 @@
{% comment %} {% trans "System" %} {% endcomment %}
-
+ {% trans "Settings" %} @@ -201,4 +201,4 @@ {% block customJS %}{% endblock %} - \ No newline at end of file + diff --git a/templates/emails/email_template.html b/templates/emails/email_template.html index 41f3653..6409828 100644 --- a/templates/emails/email_template.html +++ b/templates/emails/email_template.html @@ -1,117 +1,265 @@ {% load static %} - + + {{ subject }} + - -
-
- -
- -
- {% block content %} -

Hello {{ user_name }},

- -

{{ email_message|safe }}

- - {% if cta_link %} - - {% endif %} - -

If you have any questions, please reply to this email.

- -

Thank you,

-

King Abdullah bin Abdulaziz University Hospital

- {% endblock %} -
- - + + +
+ {{ email_preview_text|default:"Important message from King Abdullah bin Abdulaziz University Hospital" }}
+ + + + + + +
+ + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/templates/includes/email_compose_form.html b/templates/includes/email_compose_form.html index 61b2b5d..51c2d07 100644 --- a/templates/includes/email_compose_form.html +++ b/templates/includes/email_compose_form.html @@ -19,8 +19,7 @@
diff --git a/templates/interviews/interview_list.html b/templates/interviews/interview_list.html index 9b4c6a3..91ed3c1 100644 --- a/templates/interviews/interview_list.html +++ b/templates/interviews/interview_list.html @@ -326,14 +326,14 @@ {% trans "Time" %}: {{ interview.interview_time|time:"H:i A" }}
{# --- Type/Location --- #} - - {% trans "Type" %}: {{ interview.location_type }} - {% if interview.location_type == 'Remote' %}
+ + {% trans "Type" %}: {{ interview.interview.location_type }} + {% comment %} {% if interview.interview.location_type == 'Remote' %}
{# Using interview.join_url directly if available, assuming interview is the full object #} - {% trans "Link" %}: {% if interview.join_url %}Join Meeting{% else %}N/A{% endif %} + {% trans "Link" %}: {% if interview.interview.join_url %}Join Meeting{% else %}N/A{% endif %} {% else %}
- {% trans "Location" %}: {{ interview.location_details|default:"Onsite" }} - {% endif %} + {% trans "Location" %}: {{ interview.interview.location_details|default:"Onsite" }} + {% endif %} {% endcomment %}

@@ -342,8 +342,8 @@ {% trans "View" %} {# Join button logic simplified #} - {% if interview.location_type == 'Remote' and interview.join_url %} - + {% if interview.interview.location_type == 'Remote' and interview.interview.join_url %} + {% trans "Join" %} {% endif %} diff --git a/templates/jobs/create_job.html b/templates/jobs/create_job.html index 9bd4ecb..9a6d2a5 100644 --- a/templates/jobs/create_job.html +++ b/templates/jobs/create_job.html @@ -157,7 +157,7 @@
- + {{ form.open_positions }} {% if form.open_positions.errors %}
{{ form.open_positions.errors }}
{% endif %}
diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html index 304101e..cbfebc6 100644 --- a/templates/jobs/job_detail.html +++ b/templates/jobs/job_detail.html @@ -526,7 +526,55 @@
- {# Card 3: KPIs #} + {# Card 3: Position Stats #} +
+
+
+ + {% trans "Position Statistics" %} +
+
+
+ +
+ + {# 1. Open Positions #} +
+
+
+ +
{{ job.open_positions }}
+ {% trans "Open Positions" %} +
+
+
+ + {# 2. Positions Filled #} +
+
+
+ +
{{ positions_filled }}
+ {% trans "Positions Filled" %} +
+
+
+ + {# 3. Vacant Positions #} +
+
+
+ +
{{ vacant_positions }}
+ {% trans "Vacant Positions" %} +
+
+
+
+
+
+ + {# Card 4: KPIs #}
@@ -560,28 +608,7 @@
- {# 3. Avg. Time to Interview #} - {% comment %}
-
-
- -
{{ avg_t2i_days|floatformat:1 }}d
- {% trans "Time to Interview" %} -
-
-
- - {# 4. Avg. Exam Review Time #} -
-
-
- -
{{ avg_t_in_exam_days|floatformat:1 }}d
- {% trans "Avg. Exam Review" %} -
-
-
{% endcomment %} - + {# 3. Vacancy Fill Rate #}
diff --git a/templates/portal_base.html b/templates/portal_base.html index 3561419..d238a18 100644 --- a/templates/portal_base.html +++ b/templates/portal_base.html @@ -39,22 +39,22 @@
@@ -190,4 +190,4 @@ {% block customJS %}{% endblock %} - \ No newline at end of file + diff --git a/templates/recruitment/agency_assignment_detail.html b/templates/recruitment/agency_assignment_detail.html index e6aefb1..50c04ba 100644 --- a/templates/recruitment/agency_assignment_detail.html +++ b/templates/recruitment/agency_assignment_detail.html @@ -366,6 +366,13 @@ {% endif %} + {% if assignment.is_active and assignment.status == 'ACTIVE' %} + + {% endif %} + {% trans "Edit Assignment" %} @@ -414,6 +421,65 @@ {% endif %}
+ + +
{% include "recruitment/partials/note_modal.html" %} +{% include "recruitment/partials/stage_confirmation_modal.html" %} + {% endblock %} {% block customJS %} @@ -486,6 +488,9 @@ const changeStageButton = document.getElementById('changeStage'); const emailButton = document.getElementById('emailBotton'); const updateStatus = document.getElementById('update_status'); + const stageConfirmationModal = new bootstrap.Modal(document.getElementById('stageConfirmationModal')); + const confirmStageChangeButton = document.getElementById('confirmStageChangeButton'); + let isConfirmed = false; if (selectAllCheckbox) { @@ -547,6 +552,57 @@ // Initial check to set the correct state on load (in case items are pre-checked) updateSelectAllState(); } + + // Stage Confirmation Logic + if (changeStageButton) { + changeStageButton.addEventListener('click', function(event) { + const selectedStage = updateStatus.value; + + // Check if a stage is selected (not default empty option) + if (selectedStage && selectedStage.trim() !== '') { + // If not yet confirmed, show modal and prevent submission + if (!isConfirmed) { + event.preventDefault(); + + // Count selected candidates + const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length; + + // Update confirmation message + const messageElement = document.getElementById('stageConfirmationMessage'); + const targetStageElement = document.getElementById('targetStageName'); + if (messageElement && targetStageElement) { + if (checkedCount > 0) { + messageElement.textContent = `{% trans "Are you sure you want to move" %} ${checkedCount} {% trans "candidate(s) to this stage?" %}`; + targetStageElement.textContent = selectedStage; + } else { + messageElement.textContent = '{% trans "Please select at least one candidate." %}'; + targetStageElement.textContent = '--'; + } + } + + // Show confirmation modal + stageConfirmationModal.show(); + return false; + } + // If confirmed, let's form submit normally (reset flag for next time) + isConfirmed = false; + } + }); + + // Handle confirm button click in modal + if (confirmStageChangeButton) { + confirmStageChangeButton.addEventListener('click', function() { + // Hide modal + stageConfirmationModal.hide(); + + // Set confirmed flag + isConfirmed = true; + + // Programmatically trigger's button click to submit form + changeStageButton.click(); + }); + } + } }); {% endblock %} diff --git a/templates/recruitment/applications_exam_view.html b/templates/recruitment/applications_exam_view.html index ccaab70..1a073c5 100644 --- a/templates/recruitment/applications_exam_view.html +++ b/templates/recruitment/applications_exam_view.html @@ -406,6 +406,8 @@
{% include "recruitment/partials/note_modal.html" %} +{% include "recruitment/partials/stage_confirmation_modal.html" %} + {% endblock %} @@ -417,6 +419,9 @@ const changeStageButton = document.getElementById('changeStage'); const emailButton = document.getElementById('emailBotton'); const updateStatus = document.getElementById('update_status'); + const stageConfirmationModal = new bootstrap.Modal(document.getElementById('stageConfirmationModal')); + const confirmStageChangeButton = document.getElementById('confirmStageChangeButton'); + let isConfirmed = false; if (selectAllCheckbox) { @@ -470,6 +475,57 @@ // Initial check to set the correct state on load (in case items are pre-checked) updateSelectAllState(); } + + // Stage Confirmation Logic + if (changeStageButton) { + changeStageButton.addEventListener('click', function(event) { + const selectedStage = updateStatus.value; + + // Check if a stage is selected (not default empty option) + if (selectedStage && selectedStage.trim() !== '') { + // If not yet confirmed, show modal and prevent submission + if (!isConfirmed) { + event.preventDefault(); + + // Count selected candidates + const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length; + + // Update confirmation message + const messageElement = document.getElementById('stageConfirmationMessage'); + const targetStageElement = document.getElementById('targetStageName'); + if (messageElement && targetStageElement) { + if (checkedCount > 0) { + messageElement.textContent = `{% trans "Are you sure you want to move" %} ${checkedCount} {% trans "candidate(s) to this stage?" %}`; + targetStageElement.textContent = selectedStage; + } else { + messageElement.textContent = '{% trans "Please select at least one candidate." %}'; + targetStageElement.textContent = '--'; + } + } + + // Show confirmation modal + stageConfirmationModal.show(); + return false; + } + // If confirmed, let's form submit normally (reset flag for next time) + isConfirmed = false; + } + }); + + // Handle confirm button click in modal + if (confirmStageChangeButton) { + confirmStageChangeButton.addEventListener('click', function() { + // Hide modal + stageConfirmationModal.hide(); + + // Set confirmed flag + isConfirmed = true; + + // Programmatically trigger's button click to submit form + changeStageButton.click(); + }); + } + } }); {% endblock %} diff --git a/templates/recruitment/applications_hired_view.html b/templates/recruitment/applications_hired_view.html index 945f2c2..092596f 100644 --- a/templates/recruitment/applications_hired_view.html +++ b/templates/recruitment/applications_hired_view.html @@ -196,14 +196,6 @@ + + {% if job.source %} +
+
+
+
+ {% trans "ERP Sync Status" %} +
+
+
+ {% trans "Source:" %} + {{ job.source.name }} +
+
+ {% trans "Sync Status:" %} + + {{ job.source.get_sync_status_display }} + +
+
+ {% trans "Last Sync:" %} + + {% if job.source.last_sync_at %} + {{ job.source.last_sync_at|date:"M d, Y H:i" }} + {% else %} + {% trans "Never" %} + {% endif %} + +
+
+ {% trans "Hired Candidates:" %} + {{ applications|length }} +
+
+
+ {# Manual sync button commented out - sync is now automatic via Django signals #} + {# #} +
+
+ + {% trans "ERP sync is automatically triggered when candidates are moved to 'Hired' stage. Use the 'Sync to Sources' button for manual re-syncs if needed." %} +
+
+ {% else %} +
+ + {% trans "No ERP source configured for this job. Automatic sync is disabled." %} +
+ {% endif %} +
{% include 'jobs/partials/applicant_tracking.html' %}
- {% if applications %} + {% comment %} {% if applications %}
{% csrf_token %} @@ -268,7 +319,7 @@
- {% endif %} + {% endif %} {% endcomment %}
@@ -276,14 +327,14 @@ - + {% endcomment %} @@ -295,12 +346,12 @@ {% for application in applications %} - + {% endcomment %}
+ {% comment %} {% if applications %}
{% endif %} -
{% trans "Name" %} {% trans "Contact" %} {% trans "Applied Position" %}
+ {% comment %}
-
{{ application.name }} diff --git a/templates/recruitment/applications_interview_view.html b/templates/recruitment/applications_interview_view.html index e6fdcdf..ae4279b 100644 --- a/templates/recruitment/applications_interview_view.html +++ b/templates/recruitment/applications_interview_view.html @@ -514,6 +514,7 @@ {% include "recruitment/partials/note_modal.html" %} +{% include "recruitment/partials/stage_confirmation_modal.html" %} {% endblock %} @@ -526,6 +527,9 @@ const emailButton = document.getElementById('emailBotton'); const updateStatus = document.getElementById('update_status'); const scheduleInterviewButton = document.getElementById('scheduleInterview'); + const stageConfirmationModal = new bootstrap.Modal(document.getElementById('stageConfirmationModal')); + const confirmStageChangeButton = document.getElementById('confirmStageChangeButton'); + let isConfirmed = false; if (selectAllCheckbox) { @@ -590,6 +594,57 @@ // Initial check to set the correct state on load (in case items are pre-checked) updateSelectAllState(); } + + // Stage Confirmation Logic + if (changeStageButton) { + changeStageButton.addEventListener('click', function(event) { + const selectedStage = updateStatus.value; + + // Check if a stage is selected (not default empty option) + if (selectedStage && selectedStage.trim() !== '') { + // If not yet confirmed, show modal and prevent submission + if (!isConfirmed) { + event.preventDefault(); + + // Count selected candidates + const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length; + + // Update confirmation message + const messageElement = document.getElementById('stageConfirmationMessage'); + const targetStageElement = document.getElementById('targetStageName'); + if (messageElement && targetStageElement) { + if (checkedCount > 0) { + messageElement.textContent = `{% trans "Are you sure you want to move" %} ${checkedCount} {% trans "candidate(s) to this stage?" %}`; + targetStageElement.textContent = selectedStage; + } else { + messageElement.textContent = '{% trans "Please select at least one candidate." %}'; + targetStageElement.textContent = '--'; + } + } + + // Show confirmation modal + stageConfirmationModal.show(); + return false; + } + // If confirmed, let's form submit normally (reset flag for next time) + isConfirmed = false; + } + }); + + // Handle confirm button click in modal + if (confirmStageChangeButton) { + confirmStageChangeButton.addEventListener('click', function() { + // Hide modal + stageConfirmationModal.hide(); + + // Set confirmed flag + isConfirmed = true; + + // Programmatically trigger's button click to submit form + changeStageButton.click(); + }); + } + } }); // Handle Meeting Modal Opening (Rescheduling/Scheduling) diff --git a/templates/recruitment/applications_offer_view.html b/templates/recruitment/applications_offer_view.html index 292bf06..c0bacf4 100644 --- a/templates/recruitment/applications_offer_view.html +++ b/templates/recruitment/applications_offer_view.html @@ -200,7 +200,7 @@
{# Form: Hired/Rejected Status Update #} - + {% csrf_token %} {# Select element #} @@ -430,7 +430,43 @@
+ + + {% include "recruitment/partials/note_modal.html" %} +{% include "recruitment/partials/stage_confirmation_modal.html" %} {% endblock %} @@ -442,6 +478,9 @@ const changeStageButton = document.getElementById('changeStage'); const emailButton = document.getElementById('emailBotton'); const updateStatus = document.getElementById('update_status'); + const stageUpdateForm = document.getElementById('stageUpdateForm'); + const hiringConfirmationModal = new bootstrap.Modal(document.getElementById('hiringConfirmationModal')); + const confirmHiringButton = document.getElementById('confirmHiringButton'); if (selectAllCheckbox) { @@ -503,6 +542,105 @@ // Initial check to set the correct state on load (in case items are pre-checked) updateSelectAllState(); } + + // Generic Stage Confirmation (for non-Hired stages) + const stageConfirmationModal = new bootstrap.Modal(document.getElementById('stageConfirmationModal')); + const confirmStageChangeButton = document.getElementById('confirmStageChangeButton'); + let isStageConfirmed = false; + + // Hiring Confirmation Logic (for Hired stage) + let isHiringConfirmed = false; + + if (stageUpdateForm && changeStageButton) { + changeStageButton.addEventListener('click', function(event) { + const selectedStage = updateStatus.value; + + // Check if "Hired" stage is selected + if (selectedStage === 'Hired') { + // If not yet confirmed, show modal and prevent submission + if (!isHiringConfirmed) { + event.preventDefault(); // Prevent form submission + + // Count selected candidates + const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length; + + // Update confirmation message + const messageElement = document.getElementById('hiringConfirmationMessage'); + if (messageElement) { + if (checkedCount > 0) { + messageElement.textContent = `{% trans "Are you sure you want to hire" %} ${checkedCount} {% trans "candidate(s)? This action will be synced to the ERP source." %}`; + } else { + messageElement.textContent = '{% trans "Please select at least one candidate to hire." %}'; + } + } + + // Show hiring confirmation modal + hiringConfirmationModal.show(); + return false; + } + // If confirmed, let's form submit normally (reset flag for next time) + isHiringConfirmed = false; + } + // For other stages, show generic confirmation + else if (selectedStage && selectedStage.trim() !== '') { + // If not yet confirmed, show modal and prevent submission + if (!isStageConfirmed) { + event.preventDefault(); + + // Count selected candidates + const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length; + + // Update confirmation message + const messageElement = document.getElementById('stageConfirmationMessage'); + const targetStageElement = document.getElementById('targetStageName'); + if (messageElement && targetStageElement) { + if (checkedCount > 0) { + messageElement.textContent = `{% trans "Are you sure you want to move" %} ${checkedCount} {% trans "candidate(s) to this stage?" %}`; + targetStageElement.textContent = selectedStage; + } else { + messageElement.textContent = '{% trans "Please select at least one candidate." %}'; + targetStageElement.textContent = '--'; + } + } + + // Show generic stage confirmation modal + stageConfirmationModal.show(); + return false; + } + // If confirmed, let's form submit normally (reset flag for next time) + isStageConfirmed = false; + } + // For other stages, allow normal form submission + }); + + // Handle confirm button click in hiring modal + if (confirmHiringButton) { + confirmHiringButton.addEventListener('click', function() { + // Hide modal + hiringConfirmationModal.hide(); + + // Set confirmed flag + isHiringConfirmed = true; + + // Programmatically trigger's button click to submit form + changeStageButton.click(); + }); + } + + // Handle confirm button click in generic stage modal + if (confirmStageChangeButton) { + confirmStageChangeButton.addEventListener('click', function() { + // Hide modal + stageConfirmationModal.hide(); + + // Set confirmed flag + isStageConfirmed = true; + + // Programmatically trigger's button click to submit form + changeStageButton.click(); + }); + } + } }); {% endblock %} diff --git a/templates/recruitment/applications_screening_view.html b/templates/recruitment/applications_screening_view.html index a47df2b..06ba8f4 100644 --- a/templates/recruitment/applications_screening_view.html +++ b/templates/recruitment/applications_screening_view.html @@ -556,6 +556,7 @@ {% include "recruitment/partials/note_modal.html" %} +{% include "recruitment/partials/stage_confirmation_modal.html" %} {% endblock %} @@ -568,6 +569,9 @@ const changeStageButton = document.getElementById('changeStage'); const emailButton = document.getElementById('emailBotton'); const updateStatus = document.getElementById('update_status'); + const stageConfirmationModal = new bootstrap.Modal(document.getElementById('stageConfirmationModal')); + const confirmStageChangeButton = document.getElementById('confirmStageChangeButton'); + let isConfirmed = false; if (selectAllCheckbox) { @@ -629,6 +633,57 @@ // Initial check to set the correct state on load (in case items are pre-checked) updateSelectAllState(); } + + // Stage Confirmation Logic + if (changeStageButton) { + changeStageButton.addEventListener('click', function(event) { + const selectedStage = updateStatus.value; + + // Check if a stage is selected (not the default empty option) + if (selectedStage && selectedStage.trim() !== '') { + // If not yet confirmed, show modal and prevent submission + if (!isConfirmed) { + event.preventDefault(); + + // Count selected candidates + const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length; + + // Update confirmation message + const messageElement = document.getElementById('stageConfirmationMessage'); + const targetStageElement = document.getElementById('targetStageName'); + if (messageElement && targetStageElement) { + if (checkedCount > 0) { + messageElement.textContent = `{% trans "Are you sure you want to move" %} ${checkedCount} {% trans "candidate(s) to this stage?" %}`; + targetStageElement.textContent = selectedStage; + } else { + messageElement.textContent = '{% trans "Please select at least one candidate." %}'; + targetStageElement.textContent = '--'; + } + } + + // Show confirmation modal + stageConfirmationModal.show(); + return false; + } + // If confirmed, let the form submit normally (reset flag for next time) + isConfirmed = false; + } + }); + + // Handle confirm button click in modal + if (confirmStageChangeButton) { + confirmStageChangeButton.addEventListener('click', function() { + // Hide modal + stageConfirmationModal.hide(); + + // Set confirmed flag + isConfirmed = true; + + // Programmatically trigger the button click to submit form + changeStageButton.click(); + }); + } + } }); {% endblock %} diff --git a/templates/recruitment/partials/stage_confirmation_modal.html b/templates/recruitment/partials/stage_confirmation_modal.html new file mode 100644 index 0000000..574d092 --- /dev/null +++ b/templates/recruitment/partials/stage_confirmation_modal.html @@ -0,0 +1,34 @@ +{% load i18n %} +