chnages to dashobard and meetings details

This commit is contained in:
Faheed 2025-10-29 19:59:56 +03:00
parent 8330446864
commit 8851e591da
22 changed files with 551 additions and 707 deletions

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2025-10-23 14:08
# Generated by Django 5.2.7 on 2025-10-29 11:45
import django.core.validators
import django.db.models.deletion
@ -66,6 +66,22 @@ class Migration(migrations.Migration):
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Participants',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Participant Name')),
('email', models.EmailField(max_length=254, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')),
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Source',
fields=[
@ -211,6 +227,7 @@ class Migration(migrations.Migration):
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')),
],
options={
@ -248,6 +265,7 @@ class Migration(migrations.Migration):
('posted_to_linkedin', models.BooleanField(default=False)),
('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
('linkedin_post_formated_data', models.TextField(blank=True, null=True)),
('published_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
@ -257,6 +275,8 @@ class Migration(migrations.Migration):
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('users', models.ManyToManyField(blank=True, help_text='Internal staff involved in the recruitment process for this job', related_name='jobs_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Internal Participant')),
('participants', models.ManyToManyField(blank=True, help_text='External participants involved in the recruitment process for this job', related_name='jobs_participating', to='recruitment.participants', verbose_name='External Participant')),
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
],
options={
@ -308,9 +328,17 @@ class Migration(migrations.Migration):
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])),
('designation', models.CharField(blank=True, max_length=100, null=True)),
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='SharedFormTemplate',

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-26 11:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='retry',
field=models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry'),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2025-10-29 12:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='profile',
name='created_at',
),
migrations.RemoveField(
model_name='profile',
name='slug',
),
migrations.RemoveField(
model_name='profile',
name='updated_at',
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-27 10:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_candidate_retry'),
]
operations = [
migrations.AddField(
model_name='jobposting',
name='linkedin_post_formated_data',
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-27 11:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_jobposting_linkedin_post_formated_data'),
]
operations = [
migrations.AlterField(
model_name='jobposting',
name='linkedin_post_formated_data',
field=models.CharField(blank=True, null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-27 11:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_alter_jobposting_linkedin_post_formated_data'),
]
operations = [
migrations.AlterField(
model_name='jobposting',
name='linkedin_post_formated_data',
field=models.TextField(blank=True, null=True),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 12:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_alter_jobposting_linkedin_post_formated_data'),
]
operations = [
migrations.CreateModel(
name='Participants',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Participant Name')),
('email', models.EmailField(max_length=254, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=20, verbose_name='Phone')),
('designation', models.CharField(blank=True, max_length=100, verbose_name='Designation')),
('job', models.ManyToManyField(blank=True, related_name='participants', to='recruitment.jobposting')),
],
),
]

View File

@ -1,30 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 12:14
import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_participants'),
]
operations = [
migrations.AddField(
model_name='participants',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=None, verbose_name='Created at'),
preserve_default=False,
),
migrations.AddField(
model_name='participants',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='participants',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 13:06
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_participants_created_at_participants_slug_and_more'),
]
operations = [
migrations.RenameField(
model_name='participants',
old_name='job',
new_name='jobs',
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 16:41
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_rename_job_participants_jobs'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='jobposting',
name='assigned_users',
field=models.ManyToManyField(blank=True, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 17:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_jobposting_assigned_users'),
]
operations = [
migrations.RemoveField(
model_name='jobposting',
name='assigned_users',
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 20:42
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_remove_jobposting_assigned_users'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='jobposting',
name='internal_participant',
field=models.ManyToManyField(blank=True, help_text='Internal staff involved in the recruitment process for this job', related_name='internal_participant_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Internal Participant'),
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 21:30
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0011_jobposting_internal_participant'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='participants',
name='jobs',
),
migrations.AddField(
model_name='jobposting',
name='external_participant',
field=models.ManyToManyField(blank=True, help_text='External participants involved in the recruitment process for this job', related_name='jobs', to='recruitment.participants', verbose_name='External Participant'),
),
migrations.AlterField(
model_name='jobposting',
name='internal_participant',
field=models.ManyToManyField(blank=True, help_text='Internal staff involved in the recruitment process for this job', related_name='jobs', to=settings.AUTH_USER_MODEL, verbose_name='Internal Participant'),
),
]

View File

@ -1,33 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 22:20
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0012_remove_participants_jobs_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='jobposting',
name='external_participant',
),
migrations.RemoveField(
model_name='jobposting',
name='internal_participant',
),
migrations.AddField(
model_name='jobposting',
name='participants',
field=models.ManyToManyField(blank=True, help_text='External participants involved in the recruitment process for this job', related_name='jobs_participating', to='recruitment.participants', verbose_name='External Participant'),
),
migrations.AddField(
model_name='jobposting',
name='users',
field=models.ManyToManyField(blank=True, help_text='Internal staff involved in the recruitment process for this job', related_name='jobs_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Internal Participant'),
),
]

View File

@ -28,6 +28,8 @@ class Base(models.Model):
class Profile(models.Model):
profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/",validators=[validate_image_size])
designation = models.CharField(max_length=100, blank=True,null=True)
phone=models.CharField(blank=True,null=True,verbose_name=_("Phone Number"),max_length=12)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
def __str__(self):
@ -735,6 +737,41 @@ class ZoomMeeting(Base):
def __str__(self):
return self.topic
@property
def get_job(self):
try:
job=self.interview.job.first()
return job
except:
return None
@property
def get_candidate(self):
try:
candidate=self.interview.candidate.first()
return candidate
except:
return None
@property
def get_external_participants(self):
try:
interview=self.interview.first()
if interview:
return interview.job.participants.all()
return None
except:
return None
@property
def get_users_participants(self):
try:
interview=self.interview.first()
if interview:
return interview.job.users.all()
return None
except:
return None
class MeetingComment(Base):
@ -1267,7 +1304,8 @@ class InterviewSchedule(Base):
models.Index(fields=['end_date']),
models.Index(fields=['created_by']),
]
class ScheduledInterview(Base):
"""Stores individual scheduled interviews"""
@ -1319,11 +1357,11 @@ class ScheduledInterview(Base):
class Participants(Base):
"""Model to store Participants details"""
name = models.CharField(max_length=255, verbose_name=_("Participant Name"))
name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True)
email= models.EmailField(verbose_name=_("Email"))
phone = models.CharField(max_length=20, blank=True, verbose_name=_("Phone"))
phone = models.CharField(max_length=12,verbose_name=_("Phone Number"),null=True,blank=True)
designation = models.CharField(
max_length=100, blank=True, verbose_name=_("Designation")
max_length=100, blank=True, verbose_name=_("Designation"),null=True
)
def __str__(self):

View File

@ -336,7 +336,8 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
success_url = reverse_lazy('training_list')
success_message = 'Training material deleted successfully.'
from django.db.models import F, IntegerField, Count, Avg
from django.db.models.functions import Cast, Coalesce
@login_required
def dashboard_view(request):
all_candidates_count=0
@ -362,39 +363,39 @@ def dashboard_view(request):
# Assuming 'match_score' is a direct IntegerField/FloatField on the Candidate model
# (based on the final, optimized version of handle_reume_parsing_and_scoring)
# The path to your score: ai_analysis_data['analysis_data']['match_score']
SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
# Average Match Score (Overall Quality)
candidates_with_score = models.Candidate.objects.filter(
# Filter only candidates that have been parsed/scored
# --- The Annotate Step ---
candidates_with_score_query = models.Candidate.objects.filter(
is_resume_parsed=True
).annotate(
score_as_text=KeyTextTransform(
'match_score',
KeyTextTransform('scoring_data', F('ai_analysis_data'))
# 1. Use Coalesce to handle cases where the score might be missing or NULL
# (It defaults the value to 0 if missing).
# 2. Use Cast to convert the JSON value (which is often returned as text/string by the DB)
# into a proper IntegerField so we can perform math on it.
annotated_match_score=Coalesce(
Cast(SCORE_PATH, output_field=IntegerField()),
0
)
).annotate(
# Cast the extracted text score to a FloatField so AVG() can operate on it.
sortable_score=Cast('score_as_text', output_field=FloatField())
)
# Now calculate the average match score
avg_match_score_result = candidates_with_score_query.aggregate(
avg_score=Avg('annotated_match_score')
)['avg_score']
avg_match_score = round(avg_match_score_result or 0, 1)
# 2b. AGGREGATE using the newly created 'sortable_score' field
avg_match_score_result = candidates_with_score.aggregate(
avg_score=Avg('sortable_score')
)['avg_score']
hight_potential_count=0
# --- The Filter Step for High Potential Candidates ---
candidates_with_score_gte_75 = candidates_with_score_query.filter(
annotated_match_score__gte=75
)
high_potential_count=candidates_with_score_gte_75.count()
high_potential_ratio = round((hight_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0
avg_match_score = round(avg_match_score_result or 0, 1)
# 2c. Use the annotated QuerySet for other metrics
# Scored Candidates Ratio (Now simpler, as we filtered the QuerySet)
total_scored = candidates_with_score.count()
scored_ratio = round((total_scored / total_candidates) * 100, 1) if total_candidates > 0 else 0
# High Potential Candidates (Filter the annotated QuerySet)
high_potential_count = candidates_with_score.filter(
sortable_score__gte=75
).count()
high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0
# Scored Candidates Ratio
total_scored_candidates = candidates_with_score_query.count()
scored_ratio = round((total_scored_candidates / total_candidates) * 100, 1) if total_candidates > 0 else 0
jobs=models.JobPosting.objects.all().order_by('internal_job_id')
selected_job_pk=request.GET.get('selected_job_pk','')

View File

@ -3,199 +3,199 @@
{% block customCSS %}
<style>
/* -------------------------------------------------------------------------- */
/* KAAT-S Redesign CSS - Optimized Compact Detail View (Comments Left) */
/* KAAT-S Redesign CSS - Compacted and Reordered Layout */
/* -------------------------------------------------------------------------- */
:root {
--kaauh-teal: #00636e; /* Primary Brand Teal */
--kaauh-teal-dark: #004a53; /* Darker Teal for Text/Hover */
--kaauh-teal-light: #e0f7f9; /* Lightest Teal for background accents */
--kaauh-border: #e9ecef; /* Soft Border Gray */
--kaauh-primary-text: #212529; /* Dark Text */
--kaauh-secondary-text: #6c757d;/* Muted Text */
--kaauh-gray-light: #f8f9fa; /* Card Header/Footer Background */
--kaauh-success: #198754; /* Success Green */
--kaauh-danger: #dc3545; /* Danger Red */
/* New CRM/ATS Specific Colors */
--kaauh-link: #007bff; /* Standard Blue Link */
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-teal-light: #e0f7f9;
--kaauh-border: #e9ecef;
--kaauh-primary-text: #212529;
--kaauh-secondary-text: #6c757d;
--kaauh-gray-light: #f8f9fa;
--kaauh-success: #198754;
--kaauh-danger: #dc3545;
--kaauh-link: #007bff;
--kaauh-link-hover: #0056b3;
--kaauh-accent-bg: #fff3cd; /* Light Yellow for Attention/Context */
--kaauh-accent-text: #664d03; /* Dark Yellow Text */
}
body {
background-color: #f0f2f5;
background-color: #f0f2f5;
font-family: 'Inter', sans-serif;
}
/* ------------------ General Layout & Card Styles ------------------ */
/* ------------------ Card & Header Styles ------------------ */
.card {
border: none;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05), 0 2px 5px rgba(0,0,0,0.03);
margin-bottom: 1.5rem;
transition: all 0.2s ease;
}
.card:not(.no-hover):hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.08);
border-radius: 8px; /* Slightly smaller radius */
box-shadow: 0 3px 10px rgba(0,0,0,0.04); /* Lighter shadow */
margin-bottom: 1rem;
}
.card-body {
padding: 1.5rem;
padding: 1rem 1.25rem; /* Reduced padding */
}
/* ------------------ Main Header & Title Styles ------------------ */
.main-title-card {
padding: 1.5rem 2rem;
#comments-card .card-header {
background-color: white;
border-bottom: 3px solid var(--kaauh-teal);
border-radius: 12px 12px 0 0;
color: var(--kaauh-teal-dark);
padding: 0.75rem 1.25rem; /* Reduced header padding */
font-weight: 600;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid var(--kaauh-border);
}
.main-title-card h1 {
color: var(--kaauh-teal-dark);
font-weight: 800;
margin: 0;
font-size: 2rem;
/* ------------------ Main Title & Status ------------------ */
.main-title-container {
padding: 0 0 1rem 0; /* Space below the main title */
}
.main-title-card .heroicon {
width: 2rem;
height: 2rem;
color: var(--kaauh-teal);
.main-title-container h1 {
font-size: 1.75rem; /* Reduced size */
font-weight: 700;
}
.status-badge {
font-size: 0.75rem;
padding: 0.35em 0.8em;
border-radius: 15px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 0.7rem; /* Smaller badge */
padding: 0.3em 0.7em;
border-radius: 12px;
}
.bg-scheduled { background-color: #00636e !important; color: white !important;}
.bg-completed { background-color: #198754 !important; color: white !important;}
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
.bg-ended { background-color: var(--kaauh-danger) !important; color: white !important;}
/* ------------------ Detail Row & Content Styles ------------------ */
/* ------------------ Detail Row & Content Styles (Made Smaller) ------------------ */
.detail-section h2 {
.detail-section h2, .card h2 {
color: var(--kaauh-teal-dark);
font-weight: 700;
font-size: 1.25rem;
margin-bottom: 1rem;
border-bottom: 2px solid var(--kaauh-teal-light);
font-size: 1.25rem; /* Reduced size */
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--kaauh-border);
}
.detail-row {
.detail-row-simple {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
padding: 0.4rem 0; /* Reduced vertical padding */
border-bottom: 1px dashed var(--kaauh-border);
align-items: center;
font-size: 0.85rem; /* Smaller text */
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
.detail-label-simple {
font-weight: 600;
color: var(--kaauh-teal);
font-size: 0.9rem;
flex-basis: 45%;
color: var(--kaauh-teal-dark);
flex-basis: 40%;
}
.detail-value {
.detail-value-simple {
color: var(--kaauh-primary-text);
word-wrap: break-word;
font-weight: 500;
text-align: right;
font-size: 0.9rem;
flex-basis: 55%;
flex-basis: 60%;
}
/* --- CRM ASSOCIATED RECORD STYLING --- */
.associated-record-card {
background-color: var(--kaauh-accent-bg);
color: var(--kaauh-accent-text);
border: 1px solid var(--kaauh-accent-text);
}
.associated-record-card a {
color: var(--kaauh-link);
font-weight: 700;
}
.associated-record-card a:hover {
color: var(--kaauh-link-hover);
}
/* ------------------ Join Info & Copy Button ------------------ */
.join-info-card {
border-left: 5px solid var(--kaauh-teal); /* Highlight join info */
}
/* Consolidated primary button style */
.btn-primary {
.btn-primary-teal {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
font-weight: 600;
padding: 0.6rem 1.25rem;
padding: 0.6rem 1.2rem;
font-size: 0.95rem; /* Slightly smaller button */
border-radius: 6px;
transition: background-color 0.2s;
color: white; /* Ensure text color is white for teal primary */
}
.btn-primary:hover {
.btn-primary-teal:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
}
.join-url-container {
margin-top: 1rem;
/* Added Danger Button Style for main delete */
.btn-danger-red {
background-color: var(--kaauh-danger);
border-color: var(--kaauh-danger);
color: white;
padding: 0.6rem 1.2rem;
font-size: 0.95rem;
border-radius: 6px;
font-weight: 600;
}
.join-url-display {
background-color: var(--kaauh-gray-light);
border: 1px solid var(--kaauh-border);
.btn-danger-red:hover {
background-color: #c82333;
border-color: #bd2130;
}
.btn-secondary-back {
/* Subtle Back Button */
background-color: transparent;
border: none;
color: var(--kaauh-secondary-text);
font-weight: 600;
font-size: 1rem;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
transition: color 0.2s;
}
.btn-copy {
.btn-secondary-back:hover {
color: var(--kaauh-teal);
text-decoration: underline;
}
.join-url-display {
background-color: white;
border: 1px solid var(--kaauh-border);
padding: 0.5rem; /* Reduced padding */
font-size: 0.85rem; /* Smaller text */
}
.btn-copy-simple {
padding: 0.5rem 0.75rem;
background-color: var(--kaauh-teal-dark);
border: none;
color: white; /* Ensure copy button icon is white */
color: white;
border-radius: 4px;
}
.btn-copy:hover {
.btn-copy-simple:hover {
background-color: var(--kaauh-teal);
}
/* ------------------ Footer & Actions ------------------ */
.action-bar-footer {
border-top: 1px solid var(--kaauh-border);
padding: 1rem 1.5rem;
background-color: var(--kaauh-gray-light);
border-radius: 0 0 12px 12px;
/* Explicitly use flex for layout control */
display: flex;
justify-content: space-between; /* Separate the left/right groups */
align-items: center;
/* ------------------ Simple Table Styles ------------------ */
.simple-table {
width: 100%;
margin-top: 0.5rem;
border-collapse: collapse;
}
.btn-footer-action {
font-weight: 600;
/* Made buttons smaller and consistent */
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.85rem;
}
/* --- Comment Card Header Style --- */
#comments-card .card-header {
background-color: white;
.simple-table th {
background-color: var(--kaauh-teal-light);
color: var(--kaauh-teal-dark);
padding: 1rem 1.5rem;
font-weight: 600;
border-radius: 12px 12px 0 0;
font-weight: 700;
padding: 8px 12px; /* Reduced padding */
border: 1px solid var(--kaauh-border);
font-size: 0.8rem; /* Smaller table header text */
}
.simple-table td {
padding: 8px 12px; /* Reduced padding */
border: 1px solid var(--kaauh-border);
background-color: white;
font-size: 0.85rem; /* Smaller table body text */
}
/* ------------------ Comment Specific Styles ------------------ */
.comment-item {
border: 1px solid var(--kaauh-border);
background-color: var(--kaauh-gray-light);
border-radius: 6px;
}
/* Style for in-page edit button */
.btn-edit-comment {
background-color: transparent;
border: 1px solid var(--kaauh-teal);
color: var(--kaauh-teal);
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
font-weight: 500;
}
.btn-edit-comment:hover {
background-color: var(--kaauh-teal-light);
}
</style>
{% endblock %}
@ -203,251 +203,263 @@ body {
{% block content %}
<div class="container-fluid py-4">
{# --- TOP BAR / BACK BUTTON --- #}
<div class="d-flex justify-content-between align-items-center mb-3">
{# --- TOP BAR / BACK BUTTON & ACTIONS (EDIT/DELETE) --- #}
<div class="d-flex justify-content-between align-items-center mb-4">
{# Back Button #}
<a href="{% url 'list_meetings' %}" class="btn btn-secondary-back">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Meetings" %}
</a>
{# Edit and Delete Buttons #}
<div class="d-flex gap-2">
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary-teal btn-sm">
<i class="fas fa-edit me-1"></i> {% trans "Edit Meeting" %}
</a>
{# DELETE MEETING FORM #}
<form method="post" action="{% url 'delete_meeting' meeting.slug %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-danger-red btn-sm" onclick="return confirm('{% trans "Are you sure you want to delete this meeting? This action is permanent." %}')">
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete Meeting" %}
</button>
</form>
</div>
</div>
<div class="row g-4">
{# ========================================================= #}
{# --- SECTION 1: PROMINENT TOP DETAILS & JOIN INFO --- #}
{# ========================================================= #}
<div class="row g-4 mb-5">
{# --- LEFT COLUMN (COMMENTS & INTERNAL CONTEXT) - Takes 50% of the screen #}
<div class="col-lg-6 d-flex flex-column">
{# --- 1. INTERNAL NOTES / DESCRIPTION CARD (New CRM Feature) --- #}
{% if meeting.description %}
<div class="card no-hover mb-4 flex-shrink-0">
<div class="card-body detail-section">
<h2 class="d-flex align-items-center"><i class="fas fa-clipboard-list me-2"></i> {% trans "Internal Context" %}</h2>
<p class="text-muted small">{% trans "Meeting agenda, purpose, or interview details for internal team use." %}</p>
<div class="p-3 bg-light rounded border">
<p class="mb-0">{{ meeting.description|safe }}</p>
</div>
{# --- LEFT HALF: MAIN TOPIC & JOB CONTEXT --- #}
<div class="col-lg-6">
<div class="main-title-container">
<h1 class="text-start" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-video me-2" style="color: var(--kaauh-teal);"></i>
{{ meeting.topic|default:"[Meeting Topic N/A]" }}
<span class="status-badge bg-{{ meeting.status|lower|default:'bg-secondary' }} ms-3">
{{ meeting.status|title|default:'N/A' }}
</span>
</h1>
</div>
{# JOB CONTEXT DETAILS (Simple Divs) #}
<div class="p-3 bg-white rounded shadow-sm">
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
<div class="detail-row-group">
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple">{{ meeting.get_job.title|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Type" %}:</div><div class="detail-value-simple">{{ meeting.get_job.job_type|default:"N/A" }}</div></div>
</div>
</div>
{% endif %}
</div>
{# --- 2. Comments Section (Now in the Left Column) --- #}
<div class="card no-hover flex-grow-1" id="comments-card">
{# --- RIGHT HALF: ZOOM LINK / ACTIONS --- #}
<div class="col-lg-6">
<div class="p-3 bg-white rounded shadow-sm">
<h2 class="text-start"><i class="fas fa-info-circle me-2"></i> {% trans "Connection Details" %}</h2>
<div class="detail-row-group">
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Date & Time" %}:</div><div class="detail-value-simple">{{ meeting.start_time|date:"M d, Y H:i"|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Duration" %}:</div><div class="detail-value-simple">{{ meeting.duration|default:"N/A" }} {% trans "minutes" %}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Meeting ID" %}:</div><div class="detail-value-simple">{{ meeting.meeting_id|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Host Email" %}:</div><div class="detail-value-simple">{{ meeting.host_email|default:"N/A" }}</div></div>
{% if meeting.join_url %}
<div class="join-url-container pt-3">
<div id="copy-message" class="text-white rounded px-2 py-1 small fw-bold mb-2 text-center" style="opacity: 0; transition: opacity 0.3s; position: absolute; right: 0; top: 5px; background-color: var(--kaauh-success); z-index: 10;">{% trans "Copied!" %}</div>
<div class="join-url-display d-flex justify-content-between align-items-center position-relative">
<div class="text-truncate me-2">
<strong>{% trans "Join URL" %}:</strong>
<span id="meeting-join-url">{{ meeting.join_url }}</span>
</div>
<button class="btn-copy-simple ms-2 flex-shrink-0" onclick="copyLink()" title="{% trans 'Copy URL' %}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# ========================================================= #}
{# --- SECTION 2: PERSONNEL TABLES --- #}
{# ========================================================= #}
<div class="row g-4 mt-1 mb-5">
{# --- PARTICIPANTS TABLE --- #}
<div class="col-lg-12">
<div class="p-3 bg-white rounded shadow-sm">
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
<table class="simple-table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Role/Designation" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Phone Number" %}</th>
<th>{% trans "Source Type" %}</th>
</tr>
</thead>
<tbody>
{% for participant in meeting.get_external_participants %}
<tr>
<td>{{participant.name}}</td>
<td>{{participant.designation}}</td>
<td>{{participant.email}}</td>
<td>{{participant.phone}}</td>
<td>{% trans "External Participants" %}</td>
</tr>
{% endfor %}
{% for participant in meeting.get_users_participants %}
<tr>
<td>{{participant.name}}</td>
<td>{{participant.designation}}</td>
<td>{{participant.email}}</td>
<td>{{participant.phone}}</td>
<td>{% trans "System User" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ========================================================= #}
{# --- SECTION 3: COMMENTS (CORRECTED) --- #}
{# ========================================================= #}
<div class="row g-4 mt-1">
<div class="col-lg-12">
<div class="card flex-grow-1" id="comments-card" style="height: 100%;">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-comments me-2"></i>
{% trans "Comments" %} ({{ meeting.comments.count }})
{% trans "Comments" %} ({% if meeting.comments %}{{ meeting.comments.count }}{% else %}0{% endif %})
</h5>
{% if user.is_authenticated %}
<button type="button" class="btn btn-primary btn-sm"
hx-get="{% url 'add_meeting_comment' meeting.slug %}"
hx-target="#comment-section"
>
<i class="fas fa-plus me-1"></i> {% trans "Add Comment" %}
</button>
{% endif %}
</div>
<div class="card-body overflow-auto">
<div id="comment-section">
{# 1. COMMENT DISPLAY & IN-PAGE EDIT FORMS #}
<div id="comment-section" class="mb-4">
{% if meeting.comments.all %}
{% for comment in meeting.comments.all|dictsortreversed:"created_at" %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong class="me-2">{{ comment.author.get_full_name|default:comment.author.username }}</strong>
{% if comment.author != user %}
<span class="badge bg-secondary ms-1">{% trans "Comment" %}</span>
{% endif %}
</div>
<small class="text-muted">{{ comment.created_at|date:"M d, Y P" }}</small>
</div>
<div class="card-body">
<p class="card-text">{{ comment.content|safe }}</p>
</div>
<div class="card-footer">
{% if comment.author == user or user.is_staff %}
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary"
hx-get="{% url 'edit_meeting_comment' meeting.slug comment.id %}"
hx-target="#comment-section"
title="{% trans 'Edit Comment' %}">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-outline-danger"
hx-get="{% url 'delete_meeting_comment' meeting.slug comment.id %}"
hx-target="#comment-section"
title="{% trans 'Delete Comment' %}">
<i class="fas fa-trash"></i>
</button>
<div class="comment-item mb-3 p-3">
{# Read-Only Comment View #}
<div id="comment-view-{{ comment.pk }}">
<p class="mb-1 d-flex justify-content-between align-items-start" style="font-size: 0.9rem;">
<div>
<strong>{{ comment.author.get_full_name|default:comment.author.username }}</strong>
<span class="text-muted small ms-2">{{ comment.created_at|date:"M d, Y H:i" }}</span>
</div>
{% endif %}
{% if comment.author == user or user.is_staff %}
<div class="btn-group btn-group-sm">
{# Edit Button: Toggles the hidden form #}
<button type="button" class="btn btn-edit-comment py-0 px-1 me-2" onclick="toggleCommentEdit('{{ comment.pk }}')" id="edit-btn-{{ comment.pk }}" title="{% trans 'Edit Comment' %}">
<i class="fas fa-edit"></i>
</button>
{# Delete Form: Submits a POST request #}
<form method="post" action="{% url 'delete_meeting_comment' meeting.slug comment.pk %}" style="display: inline;" id="delete-form-{{ comment.pk }}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger py-0 px-1" title="{% trans 'Delete Comment' %}" onclick="return confirm('{% trans "Are you sure you want to delete this comment?" %}')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
{% endif %}
</p>
<p class="mb-0 comment-content" style="font-size: 0.85rem; white-space: pre-wrap;">{{ comment.content|linebreaksbr }}</p>
</div>
{# Hidden Edit Form #}
<div id="comment-edit-form-{{ comment.pk }}" style="display: none; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--kaauh-border);">
<form method="POST" action="{% url 'edit_meeting_comment' meeting.slug comment.pk %}" id="form-{{ comment.pk }}">
{% csrf_token %}
<div class="mb-2">
<label for="id_content_{{ comment.pk }}" class="form-label small">{% trans "Edit Comment" %}</label>
{# NOTE: The textarea name must match your Comment model field (usually 'content') #}
<textarea name="content" id="id_content_{{ comment.pk }}" rows="3" class="form-control" required>{{ comment.content }}</textarea>
</div>
<button type="submit" class="btn btn-sm btn-success me-2">
<i class="fas fa-save me-1"></i> {% trans "Save Changes" %}
</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleCommentEdit('{{ comment.pk }}')">
{% trans "Cancel" %}
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">{% trans "No comments yet. Be the first to comment!" %}</p>
{% endif %}
</div>
</div>
</div>
</div>
<hr>
{# --- RIGHT COLUMN (MAIN DETAILS & JOIN INFO) - Takes 50% of the screen #}
<div class="col-lg-6">
<div class="d-flex flex-column h-100">
{# --- CRM ASSOCIATED RECORD CARD (Elevated Importance) --- #}
{% if meeting.interview %}
<div class="card associated-record-card mb-4 flex-shrink-0">
<div class="card-body pt-3 pb-3">
<div class="d-flex align-items-center">
<i class="fas fa-user-tag fa-2x me-3"></i>
<div>
<h6 class="mb-0 text-uppercase small fw-bold">{% trans "Associated Record" %}</h6>
<span class="fw-bold fs-5 me-2">
<a href="{% url 'candidate_detail' meeting.interview.candidate.slug %}" class="text-decoration-none">
{{ meeting.interview.candidate.name }}
</a>
</span>
<span class="badge bg-secondary-subtle text-secondary small fw-normal">{{ meeting.interview.job_position }}</span>
{# 2. NEW COMMENT SUBMISSION (Remains the same) #}
<h6 class="mb-3" style="color: var(--kaauh-teal-dark);">{% trans "Add a New Comment" %}</h6>
{% if user.is_authenticated %}
<form method="POST" action="{% url 'add_meeting_comment' meeting.slug %}">
{% csrf_token %}
{% if comment_form %}
{{ comment_form.as_p }}
{% else %}
<div class="mb-3">
<label for="id_content" class="form-label small">{% trans "Comment" %}</label>
<textarea name="content" id="id_content" rows="3" class="form-control" required></textarea>
</div>
</div>
</div>
</div>
{% endif %}
{# --- 1. MAIN DETAILS CARD --- #}
<div class="card no-hover flex-grow-1 mb-4">
{# --- CONSOLIDATED HEADER --- #}
<div class="main-title-card">
<div class="d-flex justify-content-between align-items-start">
<div class="card-header-title-group">
<h1 class="mb-1">
<svg class="heroicon me-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
{{ meeting.topic }}
</h1>
<div class="d-flex align-items-center gap-3">
<span class="status-badge bg-{{ meeting.status }}">
{{ meeting.status|title }}
</span>
</div>
</div>
</div>
</div>
{# --- CONNECTION DETAIL BODY (Renamed from Core Details) --- #}
<div class="card-body detail-section">
<h2><i class="fas fa-calendar-alt me-2"></i> {% trans "Connection Details" %}</h2>
<div class="detail-row-group">
<div class="detail-row"><div class="detail-label">{% trans "Meeting ID" %}:</div><div class="detail-value">{{ meeting.meeting_id }}</div></div>
<div class="detail-row"><div class="detail-label">{% trans "Start Time" %}:</div><div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div></div>
<div class="detail-row"><div class="detail-label">{% trans "Duration" %}:</div><div class="detail-value">{{ meeting.duration }} {% trans "minutes" %}</div></div>
<div class="detail-row"><div class="detail-label">{% trans "Timezone" %}:</div><div class="detail-value">{{ meeting.timezone|default:"UTC" }}</div></div>
<div class="detail-row"><div class="detail-label">{% trans "Host Email" %}:</div><div class="detail-value">{{ meeting.host_email|default:"N/A" }}</div></div>
</div>
</div>
{# --- ACTION BAR AT THE BOTTOM OF THE MAIN CARD --- #}
<div class="card-footer action-bar-footer">
<div>
{# Placeholder for future left-aligned button #}
</div>
<div class="d-flex gap-2">
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary btn-footer-action">
<i class="fas fa-edit me-1"></i> {% trans "Update" %}
</a>
{% if meeting.zoom_gateway_response %}
<button type="button" class="btn btn-secondary btn-footer-action" onclick="toggleGateway()">
<i class="fas fa-code me-1"></i> {% trans "API Response" %}
</button>
{% endif %}
<button type="button" class="btn btn-danger btn-footer-action ms-3" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt me-1"></i>
Delete
<button type="submit" class="btn btn-primary-teal btn-sm mt-2">
<i class="fas fa-paper-plane me-1"></i> {% trans "Submit Comment" %}
</button>
</div>
</div>
</form>
{% else %}
<p class="text-muted small">{% trans "You must be logged in to add a comment." %}</p>
{% endif %}
</div>
{# --- 2. JOIN INFO CARD (Separate from details, but in the same column) --- #}
{% if meeting.join_url %}
<div class="card no-hover join-info-card detail-section flex-shrink-0">
<div class="card-body">
<h2><i class="fas fa-link me-2"></i> {% trans "Join Information" %}</h2>
<a href="{{ meeting.join_url }}" class="btn btn-primary w-100 mb-4" target="_blank">
<i class="fas fa-video me-1"></i> {% trans "Join Meeting Now" %}
</a>
<div class="join-url-container">
{# Message should not be display: none; but opacity: 0; for smooth transition #}
<div id="copy-message" class="text-white rounded px-2 py-1 small fw-bold mb-2 text-center" style="opacity: 0; transition: opacity 0.3s; position: absolute; right: 0; top: -30px; background-color: var(--kaauh-success); z-index: 10;">{% trans "Copied!" %}</div>
<div class="join-url-display d-flex justify-content-between align-items-center position-relative">
<div class="text-truncate me-2">
<strong>{% trans "Join URL" %}:</strong>
<span id="meeting-join-url">{{ meeting.join_url }}</span>
</div>
<button class="btn-copy ms-2 flex-shrink-0" onclick="copyLink()" title="{% trans 'Copy URL' %}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
{% if meeting.password %}
<div class="detail-row" style="border: none; padding-top: 1rem;">
<div class="detail-label" style="font-size: 1rem;">{% trans "Password" %}:</div>
<div class="detail-value fw-bolder text-danger">{{ meeting.password }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
{# --- API RESPONSE CARD (Full width, hidden by default) --- #}
{% if meeting.zoom_gateway_response %}
<div id="gateway-response-card" class="card mt-4" style="display: none;">
<div class="card-body">
<h3>{% trans "API Gateway Response" %}</h3>
<pre>{{ meeting.zoom_gateway_response|safe }}</pre>
</div>
</div>
{% endif %}
</div>
{# MODALS (KEEP OUTSIDE OF THE MAIN LAYOUT ROWS) #}
{% comment %} {% include 'modals/delete_modal.html' with item_name="Meeting" delete_url_name='delete_meeting' %} {% endcomment %}
<div class="modal fade" id="commentModal" tabindex="-1" aria-labelledby="commentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="commentModalLabel">Add Comment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="commentModalBody">
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
function toggleGateway() {
const element = document.getElementById('gateway-response-card');
if (element.style.display === 'none' || element.style.display === '') {
element.style.display = 'block';
// --- COMMENT EDITING FUNCTION ---
function toggleCommentEdit(commentPk) {
const viewDiv = document.getElementById(`comment-view-${commentPk}`);
const editFormDiv = document.getElementById(`comment-edit-form-${commentPk}`);
const editButton = document.getElementById(`edit-btn-${commentPk}`);
const deleteForm = document.getElementById(`delete-form-${commentPk}`);
if (viewDiv.style.display !== 'none') {
// Switch to Edit Mode
viewDiv.style.display = 'none';
editFormDiv.style.display = 'block';
if (editButton) editButton.style.display = 'none'; // Hide edit button
if (deleteForm) deleteForm.style.display = 'none'; // Hide delete button
} else {
element.style.display = 'none';
// Switch back to View Mode (Cancel)
viewDiv.style.display = 'block';
editFormDiv.style.display = 'none';
if (editButton) editButton.style.display = 'inline-block'; // Show edit button
if (deleteForm) deleteForm.style.display = 'inline'; // Show delete button
}
}
// --- COPY LINK FUNCTION ---
// CopyLink function implementation (slightly improved for message placement)
function copyLink() {
const urlElement = document.getElementById('meeting-join-url');
@ -464,6 +476,7 @@ body {
// Position the message relative to the display container
const rect = displayContainer.getBoundingClientRect();
// Note: This positioning logic relies on the .join-url-container being position:relative or position:absolute
messageElement.style.left = (rect.width / 2) - (messageElement.offsetWidth / 2) + 'px';
messageElement.style.top = '-35px';

View File

@ -212,7 +212,7 @@
<th scope="col" style="width: 15%;">{% trans "Name" %}</th>
<th scope="col" style="width: 15%;">{% trans "Email" %}</th>
<th scope="col" style="width: 10%;">{% trans "Phone" %}</th>
<th scope="col" style="width: 25%;">{% trans "Assigned Jobs" %}</th>
<th scope="col" style="width: 15%;">{% trans "Designation" %}</th>
<th scope="col" style="width: 15%;">{% trans "Created At" %}</th>
<th scope="col" style="width: 5%;" class="text-end">{% trans "Actions" %}</th>
@ -224,16 +224,7 @@
<td class="fw-medium"><a href="{% url 'participants_detail' participant.slug %}" class="text-decoration-none link-secondary">{{ participant.name }}<a></td>
<td>{{ participant.email }}</td>
<td>{{ participant.phone|default:"N/A" }}</td>
<td>
{# Iterate over the many-to-many relationship (jobs) #}
{% for job in participant.jobs_participating.all %}
<span class="badge bg-primary me-1 mb-1">
<a href="{% url 'job_detail' job.slug %}" class="text-decoration-none text-white">{{ job.title }}</a>
</span>
{% empty %}
<span class="text-muted small">{% trans "None Assigned" %}</span>
{% endfor %}
</td>
<td>{{ participant.designation|default:"N/A" }}</td>
<td>{{ participant.created_at|date:"d-m-Y" }}</td>
<td class="text-end">
@ -277,16 +268,7 @@
<i class="fas fa-briefcase"></i> {{ participant.designation|default:"N/A" }}
</p>
<div class="mb-2">
<strong class="small text-muted">{% trans "Assigned Jobs:" %}</strong><br>
{% for job in participant.jobs.all %}
<span class="badge bg-primary me-1 mb-1">
<a href="{% url 'job_detail' job.slug %}" class="text-decoration-none text-white small">{{ job.title }}</a>
</span>
{% empty %}
<span class="text-muted small">{% trans "None" %}</span>
{% endfor %}
</div>
<div class="mt-auto pt-2 border-top">
<div class="d-flex gap-2">

View File

@ -199,7 +199,7 @@
</div>
</div>
{# DONUT CHART - Candidate Pipeline Status #}
{# HORIZONTAL BAR CHART - Candidate Pipeline Status (NOW FUNNEL EFFECT) #}
<div class="col-lg-12">
<div class="card shadow-lg h-100">
<div class="card-header">
@ -225,7 +225,8 @@
</div>
<div class="chart-container d-flex justify-content-center align-items-center">
<canvas id="candidate_donout_chart"></canvas>
{# Changed ID to reflect the funnel appearance #}
<canvas id="candidate_funnel_chart"></canvas>
</div>
</div>
</div>
@ -238,45 +239,11 @@
// Get the all_candidates_count value from Django context
const ALL_CANDIDATES_COUNT = '{{ all_candidates_count|default:0 }}';
// --- 1. DONUT CHART CENTER TEXT PLUGIN (Custom) ---
const centerTextPlugin = {
id: 'centerText',
beforeDraw: (chart) => {
const { ctx } = chart;
// Convert to integer (handle case where all_candidates_count might be missing)
const total = parseInt(ALL_CANDIDATES_COUNT) || 0;
if (total === 0) return; // Don't draw if count is zero
// Get chart center coordinates
const xCenter = chart.getDatasetMeta(0).data[0].x;
const yCenter = chart.getDatasetMeta(0).data[0].y;
ctx.restore();
// --- First Line: The Total Count (Bold Number) ---
ctx.font = 'bold 28px sans-serif';
ctx.fillStyle = 'var(--kaauh-teal-dark)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(total.toString(), xCenter, yCenter - 10);
// --- Second Line: The Label ---
const labelText = '{% trans "Total" %}';
ctx.font = '12px sans-serif';
ctx.fillStyle = '#6c757d';
ctx.fillText(labelText, xCenter, yCenter + 20);
ctx.save();
}
};
// ------------------------------------------------
// Pass context data safely to JavaScript
const jobTitles = JSON.parse('{{ job_titles|escapejs }}').slice(0, 5); // Take top 5
const jobAppCounts = JSON.parse('{{ job_app_counts|escapejs }}').slice(0, 5); // Take top 5
// BAR CHART configuration
// BAR CHART configuration (Top 5 Applications)
const ctxBar = document.getElementById('applicationsChart').getContext('2d');
new Chart(ctxBar, {
type: 'bar',
@ -319,43 +286,96 @@
});
// DONUT CHART configuration
const ctxDonut = document.getElementById('candidate_donout_chart').getContext('2d');
// --- 2. CANDIDATE PIPELINE CENTERED FUNNEL CHART ---
const stages = JSON.parse('{{ candidate_stage|safe }}');
const counts = JSON.parse('{{ candidates_count|safe }}');
// 1. Find the maximum count (for the widest bar)
const maxCount = Math.max(...counts);
new Chart(ctxDonut, {
type: 'doughnut',
// 2. Calculate the transparent "spacer" data needed to center each bar
// spacer = (maxCount - currentCount) / 2
const spacerData = counts.map(count => (maxCount - count) / 2);
// VITAL CHANGE: Define the dark-to-light teal shades
const tealShades = [
'#004a53', // Darkest Teal (var(--kaauh-teal-dark))
'#00636e', // Medium Teal (var(--kaauh-teal))
'#007a88', // Medium-Light Teal
'#0093a3', // Light Teal (var(--kaauh-teal-light))
'#00acc0', // Lighter Teal
'#18c5e0' // Lightest Teal (Add more shades if you expect more stages)
];
// Assign the first N shades based on the number of stages
const stageColors = tealShades.slice(0, stages.length);
const ctxFunnel = document.getElementById('candidate_funnel_chart').getContext('2d');
new Chart(ctxFunnel, {
type: 'bar',
data: {
// Ensure these contexts are always output as valid JSON arrays
labels: JSON.parse('{{ candidate_stage|safe }}'),
datasets: [{
label: '{% trans "Candidate Count" %}',
data: JSON.parse('{{ candidates_count|safe }}'),
backgroundColor: [
'#00636e', // Applied (Primary)
'rgb(255, 159, 64)', // Exam (Orange)
'rgb(54, 162, 235)', // Interview (Blue)
'rgb(75, 192, 192)' // Offer (Green)
],
hoverOffset: 4
}]
labels: stages,
datasets: [
// 1. TRANSPARENT SPACER DATASET (Pushes the bar to the center)
{
label: 'Spacer',
data: spacerData,
backgroundColor: 'transparent', // Makes this invisible
hoverBackgroundColor: 'transparent', // Ensures hover is also invisible
barThickness: 50,
// Hide the data label/tooltip for the spacer
datalabels: { display: false },
tooltip: { enabled: false }
},
// 2. VISIBLE CANDIDATE COUNT DATASET
{
label: '{% trans "Candidate Count" %}',
data: counts,
backgroundColor: stageColors, // <-- Now using the teal gradient
barThickness: 50
}
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
// Key to funnel effect: allows the transparent and colored bars to sit next to each other
// Note: Chart.js treats stacked horizontal bars as stacked vertically unless indexAxis is 'y'
scales: {
x: {
beginAtZero: true,
stacked: true, // MUST be stacked
display: false, // Hides the value axis for a clean look
max: maxCount
},
y: {
stacked: true, // MUST be stacked
grid: { display: false },
ticks: {
color: 'var(--kaauh-primary-text)',
font: { size: 12, weight: 'bold' }
}
}
},
plugins: {
legend: {
position: 'bottom',
labels: { padding: 20 }
display: false,
},
title: {
display: true,
text: '{% trans "Pipeline Status Breakdown" %}',
text: '{% trans "Pipeline Status Funnel" %}',
font: { size: 16 }
},
tooltip: {
// Only show the tooltip for the visible data
filter: (tooltipItem) => {
return tooltipItem.datasetIndex === 1;
}
}
}
},
// --- Register the custom plugin here ---
plugins: [centerTextPlugin]
}
});
</script>