Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Company Dashboard | Campus Recruitment Portal</title> | |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> | |
| <!-- Link to your EXTERNAL static CSS file --> | |
| <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | |
| <style> | |
| /* Minimal page-specific overrides */ | |
| .chart-container { | |
| position: relative; | |
| height: 280px; /* Default height */ | |
| width: 100%; | |
| min-height: 200px; /* Ensure minimum height */ | |
| } | |
| canvas { | |
| display: block; /* Prevents extra space below canvas */ | |
| max-width: 100%; /* Ensure canvas doesn't overflow container */ | |
| } | |
| /* Make table header sticky */ | |
| .table-responsive thead { | |
| position: sticky; | |
| top: 0; | |
| z-index: 1; | |
| /* Background applied by .table-light in the thead tag */ | |
| } | |
| .filter-section .card-body { | |
| max-height: calc(100vh - 150px); /* Adjust based on nav height etc. */ | |
| overflow-y: auto; | |
| } | |
| @media (max-width: 992px) { | |
| .filter-section .card-body { | |
| max-height: none; | |
| overflow-y: visible; | |
| } | |
| } | |
| /* Style for AI Query section */ | |
| #nl-query-error { | |
| font-size: 0.85rem; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Navigation --> | |
| <nav class="navbar navbar-expand-lg navbar-dark bg-primary sticky-top shadow-sm"> | |
| <div class="container-fluid"> | |
| <a class="navbar-brand" href="{{ url_for('dashboard') }}"> | |
| <i class="bi bi-building"></i> {{ company_name }} - Portal | |
| </a> | |
| <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> | |
| <span class="navbar-toggler-icon"></span> | |
| </button> | |
| <div class="collapse navbar-collapse" id="navbarNav"> | |
| <ul class="navbar-nav me-auto mb-2 mb-lg-0"> | |
| <li class="nav-item"> | |
| <a class="nav-link active" aria-current="page" href="{{ url_for('dashboard') }}"><i class="bi bi-speedometer2"></i> Dashboard</a> | |
| </li> | |
| <li class="nav-item"> | |
| <a class="nav-link" href="{{ url_for('jobs') }}"><i class="bi bi-briefcase-fill"></i> Job Postings</a> | |
| </li> | |
| </ul> | |
| <ul class="navbar-nav"> | |
| <!-- Display Flash Messages Dropdown --> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% if messages %} | |
| <li class="nav-item dropdown"> | |
| <a class="nav-link dropdown-toggle text-warning position-relative" href="#" id="messagesDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> | |
| <i class="bi bi-bell-fill"></i> | |
| <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger" style="font-size: 0.6em;"> | |
| {{ messages|length }} | |
| <span class="visually-hidden">notifications</span> | |
| </span> | |
| </a> | |
| <ul class="dropdown-menu dropdown-menu-end shadow border-0 mt-2" aria-labelledby="messagesDropdown" style="min-width: 250px;"> | |
| <li><h6 class="dropdown-header small text-muted">Notifications</h6></li> | |
| {% for category, message in messages %} | |
| <li> | |
| <div class="dropdown-item small py-2"> | |
| <span class="badge bg-{{ category if category in ['danger', 'warning', 'info', 'success'] else 'secondary' }} me-1"> </span> | |
| {{ message }} | |
| </div> | |
| </li> | |
| {% endfor %} | |
| <li><hr class="dropdown-divider my-1"></li> | |
| <li><a class="dropdown-item small text-muted text-center" href="#">View All (Not implemented)</a></li> | |
| </ul> | |
| </li> | |
| {% endif %} | |
| {% endwith %} | |
| <li class="nav-item"> | |
| <a class="nav-link" href="{{ url_for('logout') }}" title="Logout"> | |
| <i class="bi bi-box-arrow-right"></i> Logout | |
| </a> | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| </nav> | |
| <!-- Main Content --> | |
| <div class="container-fluid mt-3 main-container"> | |
| <!-- Display top-level error if passed --> | |
| {% if error %} | |
| <div class="alert alert-danger alert-dismissible fade show" role="alert"> | |
| {{ error }} | |
| <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | |
| </div> | |
| {% endif %} | |
| <div class="row"> | |
| <!-- Filters Column --> | |
| <div class="col-lg-3"> | |
| <!-- Filters Card --> | |
| <div class="card mb-3 filter-section shadow-sm"> | |
| <div class="card-header bg-secondary text-white"> | |
| <h5 class="card-title mb-0"><i class="bi bi-filter"></i> Filter Candidates</h5> | |
| </div> | |
| <div class="card-body"> | |
| <form id="filter-form"> | |
| <!-- Row for Job Filter (Spans full width initially for emphasis) --> | |
| <div class="row mb-3"> | |
| <div class="col-12 filter-group border-bottom pb-3"> | |
| <label for="job_id_filter" class="form-label filter-heading">Filter by Job</label> | |
| <select class="form-select form-select-sm" name="job_id" id="job_id_filter"> | |
| <option value="">(All Candidates / Use Manual Filters)</option> | |
| {% for job in jobs %} | |
| <option value="{{ job.id }}">{{ job.title }}</option> | |
| {% endfor %} | |
| </select> | |
| <small class="form-text text-muted">Applies job's requirements & overrides manual filters below if selected.</small> | |
| </div> | |
| </div> | |
| <!-- Row for the rest of the filters (2 columns on large screens) --> | |
| <div class="row"> | |
| <!-- Column 1: Basic Criteria & Experience --> | |
| <div class="col-12 col-lg-6"> | |
| <div class="mb-3 filter-group"> | |
| <label for="min_cgpa_filter" class="form-label filter-heading">Min CGPA</label> | |
| <input type="number" class="form-control form-control-sm" name="min_cgpa" id="min_cgpa_filter" min="0" max="10" step="0.1" placeholder="e.g., 7.5"> | |
| </div> | |
| <div class="mb-3 filter-group"> | |
| <label for="max_backlogs_filter" class="form-label filter-heading">Max Backlogs</label> | |
| <input type="number" class="form-control form-control-sm" name="max_backlogs" id="max_backlogs_filter" min="0" step="1" placeholder="e.g., 0"> | |
| </div> | |
| <div class="mb-3 filter-group"> | |
| <label class="form-label filter-heading">Experience Flags</label> | |
| <div class="form-check form-switch"> | |
| <input class="form-check-input" type="checkbox" role="switch" name="has_hackathons" id="hasHackathons"> | |
| <label class="form-check-label" for="hasHackathons">Has Hackathon</label> | |
| </div> | |
| <div class="form-check form-switch"> | |
| <input class="form-check-input" type="checkbox" role="switch" name="has_experience" id="hasExperience"> | |
| <label class="form-check-label" for="hasExperience">Has Project/Internship</label> | |
| </div> | |
| <div class="form-check form-switch"> | |
| <input class="form-check-input" type="checkbox" role="switch" name="has_research" id="hasResearch"> | |
| <label class="form-check-label" for="hasResearch">Has Research/Pubs</label> | |
| </div> | |
| </div> | |
| </div><!-- End Column 1 --> | |
| <!-- Column 2: Skills, Roles, Certifications --> | |
| <div class="col-12 col-lg-6"> | |
| <div class="mb-3 filter-group"> | |
| <label for="skills_filter" class="form-label filter-heading">Skills (any)</label> | |
| <select class="form-select form-select-sm" name="skills" id="skills_filter" multiple size="5" aria-label="Select skills"> | |
| <!-- Combine programming languages and tools --> | |
| {% set all_skills = filter_options.programming_languages + filter_options.tools %} | |
| {% for skill in all_skills | unique | sort %} | |
| <option value="{{ skill }}">{{ skill }}</option> | |
| {% endfor %} | |
| </select> | |
| <small class="form-text text-muted">Hold Ctrl/Cmd to select multiple</small> | |
| </div> | |
| <div class="mb-3 filter-group"> | |
| <label for="roles_filter" class="form-label filter-heading">Preferred Roles (any)</label> | |
| <select class="form-select form-select-sm" name="roles" id="roles_filter" multiple size="5" aria-label="Select preferred roles"> | |
| {% for role in filter_options.roles %} | |
| <option value="{{ role }}">{{ role }}</option> | |
| {% endfor %} | |
| </select> | |
| <small class="form-text text-muted">Hold Ctrl/Cmd to select multiple</small> | |
| </div> | |
| <div class="mb-3 filter-group"> | |
| <label for="certifications_filter" class="form-label filter-heading">Certifications (any)</label> | |
| <select class="form-select form-select-sm" name="certifications" id="certifications_filter" multiple size="5" aria-label="Select certifications"> | |
| {% for cert in filter_options.certifications %} | |
| <option value="{{ cert }}">{{ cert }}</option> | |
| {% endfor %} | |
| </select> | |
| <small class="form-text text-muted">Hold Ctrl/Cmd to select multiple</small> | |
| </div> | |
| </div><!-- End Column 2 --> | |
| </div><!-- End Row for columns --> | |
| <!-- Buttons (Placed after the columns row) --> | |
| <div class="d-grid gap-2 mt-3"> | |
| <button type="submit" class="btn btn-primary"><i class="bi bi-funnel-fill"></i> Apply Filters</button> | |
| <button type="reset" class="btn btn-outline-secondary" id="reset-filters"><i class="bi bi-arrow-clockwise"></i> Reset Filters</button> | |
| </div> | |
| </form> | |
| </div><!-- End card-body --> | |
| </div><!-- End Filters Card --> | |
| <!-- Export Options Card --> | |
| <div class="card mb-3 shadow-sm"> | |
| <div class="card-header bg-secondary text-white"> | |
| <h5 class="card-title mb-0"><i class="bi bi-download"></i> Export Options</h5> | |
| </div> | |
| <div class="card-body"> | |
| <div class="d-grid"> | |
| <button type="button" class="btn btn-success" id="export-csv-btn" disabled> | |
| <i class="bi bi-file-earmark-spreadsheet"></i> Export Filtered to CSV | |
| </button> | |
| </div> | |
| <small class="form-text text-muted d-block mt-2">Exports the students currently shown in the table.</small> | |
| <form id="export-form" class="d-none" action="{{ url_for('export_filtered') }}" method="post" target="_blank"> | |
| <input type="hidden" name="filtered_students" id="filtered-students-data-hidden"> | |
| </form> | |
| </div> | |
| </div> | |
| </div> <!-- End Filters Column --> | |
| <!-- Main Content Area --> | |
| <div class="col-lg-9"> | |
| <!-- Natural Language Query Section --> | |
| <div class="card mb-3 shadow-sm"> | |
| <div class="card-header bg-light text-dark"> | |
| <h5 class="card-title mb-0"><i class="bi bi-robot"></i> Query Candidates with AI</h5> | |
| </div> | |
| <div class="card-body"> | |
| <div id="nl-query-error" class="alert alert-danger d-none p-2" role="alert"></div> | |
| <div class="mb-2"> | |
| <label for="nl-query-input" class="form-label small">Enter your query (e.g., "Show Computer Science students with CGPA over 8 and Python skill, must have internships")</label> | |
| <textarea class="form-control form-control-sm" id="nl-query-input" rows="2" placeholder="Describe the candidates you're looking for..."></textarea> | |
| </div> | |
| <button type="button" class="btn btn-info btn-sm" id="nl-query-submit"> | |
| <i class="bi bi-magic"></i> Generate & Apply Filters | |
| </button> | |
| <span id="nl-query-spinner" class="spinner-border spinner-border-sm text-info ms-2 d-none" role="status" aria-hidden="true"></span> | |
| <small class="text-muted d-block mt-1">AI will attempt to translate your query into the filters on the left.</small> | |
| </div> | |
| </div> | |
| <!-- End Natural Language Query Section --> | |
| <!-- Visualization Section --> | |
| <div class="card mb-3 shadow-sm"> | |
| <div class="card-header bg-light text-dark d-flex justify-content-between align-items-center"> | |
| <h5 class="card-title mb-0"><i class="bi bi-bar-chart-line-fill"></i> Data Visualizations <small class="text-muted">(Updates with filters)</small></h5> | |
| <button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#visualizationsCollapse" aria-expanded="true" aria-controls="visualizationsCollapse" id="collapseVizButton"> | |
| <i class="bi bi-chevron-up"></i> <span>Collapse</span> | |
| </button> | |
| </div> | |
| <div class="collapse show" id="visualizationsCollapse"> | |
| <div class="card-body"> | |
| <div class="row"> | |
| <div class="col-xl-6 col-lg-12 mb-3"> | |
| <div class="card visualization-card h-100 border"> | |
| <div class="card-header small">CGPA Distribution</div> | |
| <div class="card-body chart-container"> | |
| <canvas id="cgpaChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-xl-6 col-lg-12 mb-3"> | |
| <div class="card visualization-card h-100 border"> | |
| <div class="card-header small">Department Distribution</div> | |
| <div class="card-body chart-container"> | |
| <canvas id="deptChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-xl-6 col-lg-12 mb-3"> | |
| <div class="card visualization-card h-100 border"> | |
| <div class="card-header small">Top Programming Skills</div> | |
| <div class="card-body chart-container"> | |
| <canvas id="skillsChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-xl-6 col-lg-12 mb-3"> | |
| <div class="card visualization-card h-100 border"> | |
| <div class="card-header small">Top Preferred Roles</div> | |
| <div class="card-body chart-container"> | |
| <canvas id="rolesChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Students Table Card --> | |
| <div class="card mb-3 shadow-sm"> | |
| <div class="card-header bg-light text-dark d-flex justify-content-between align-items-center"> | |
| <h5 class="card-title mb-0"> | |
| <i class="bi bi-people-fill"></i> Students List <span id="student-count" class="badge bg-primary rounded-pill">{{ students|length }}</span> | |
| </h5> | |
| <div class="btn-group btn-group-sm"> | |
| <button class="btn btn-outline-primary" id="select-all" title="Select all visible students"><i class="bi bi-check-square"></i> Select All</button> | |
| <button class="btn btn-outline-primary" id="deselect-all" title="Deselect all visible students"><i class="bi bi-square"></i> Deselect All</button> | |
| </div> | |
| </div> | |
| <div class="card-body p-0"> | |
| <div class="table-responsive" style="max-height: 60vh; overflow-y: auto;"> | |
| <table class="table table-striped table-hover table-sm mb-0"> | |
| <thead class="table-light"> | |
| <tr> | |
| <th class="text-center" style="width: 3%;"><input type="checkbox" id="select-all-checkbox" class="form-check-input"></th> | |
| <th style="width: 20%;">Name</th> | |
| <th style="width: 15%;">Department</th> | |
| <th style="width: 7%;">CGPA</th> | |
| <th style="width: 7%;">Backlogs</th> | |
| <th style="width: 28%;">Top Skills</th> | |
| <th style="width: 10%;">Experience</th> | |
| </tr> | |
| </thead> | |
| <tbody id="students-tbody"> | |
| <!-- Student rows populated by initial Flask render AND JS updates --> | |
| {% for student in students %} | |
| <tr data-email="{{ student.email }}"> | |
| <td class="text-center"><input type="checkbox" class="form-check-input student-checkbox" value="{{ student.email }}"></td> | |
| <td>{{ student.full_name | default('N/A', true) }}</td> | |
| <td>{{ student.department | default('N/A', true) }}</td> | |
| <td>{{ "%.2f"|format(student.cgpa) if student.cgpa is not none else 'N/A' }}</td> | |
| <td>{{ student.backlogs | default(0, true) }}</td> | |
| <td> | |
| {# --- CORRECTED SKILL PROCESSING --- #} | |
| {% set raw_skills = [] %} | |
| {% if student.programming_languages %} | |
| {% if student.programming_languages is string %} | |
| {% set raw_skills = student.programming_languages.split(',') %} | |
| {% elif student.programming_languages is iterable and student.programming_languages is not string %} {# Check if it's list-like but not a string #} | |
| {% set raw_skills = student.programming_languages %} | |
| {% endif %} | |
| {% endif %} | |
| {# Use namespace to collect unique, cleaned skills #} | |
| {% set ns = namespace(unique_cleaned_skills=[]) %} | |
| {% for skill in raw_skills %} | |
| {% set trimmed_skill = skill | trim %} | |
| {% if trimmed_skill %} | |
| {# Basic removal of "(...)" suffix #} | |
| {% set base_skill = trimmed_skill.split('(')[0] | trim %} | |
| {% if base_skill and base_skill not in ns.unique_cleaned_skills %} | |
| {# Append to the list within the namespace #} | |
| {% set ns.unique_cleaned_skills = ns.unique_cleaned_skills + [base_skill] %} | |
| {% endif %} | |
| {% endif %} | |
| {% endfor %} | |
| <small> | |
| {# Display top 3 unique, cleaned skills #} | |
| {{ ns.unique_cleaned_skills[:3] | join(', ') }} | |
| {% if ns.unique_cleaned_skills | length > 3 %}...{% endif %} | |
| {% if not ns.unique_cleaned_skills %}N/A{% endif %} {# Show N/A if no skills found #} | |
| </small> | |
| {# --- END OF CORRECTION --- #} | |
| </td> | |
| <td> | |
| {% if student.internships %}<span class="badge bg-success" title="Internship">I</span>{% endif %} | |
| {% if student.projects %}<span class="badge bg-primary" title="Projects">P</span>{% endif %} | |
| {% if student.hackathons %}<span class="badge bg-warning text-dark" title="Hackathon">H</span>{% endif %} | |
| {% if student.publications %}<span class="badge bg-info text-dark" title="Publication/Research">R</span>{% endif %} | |
| {% if not student.internships and not student.projects and not student.hackathons and not student.publications %} | |
| <span class="text-muted small">None</span> | |
| {% endif %} | |
| </td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div id="no-students-message" class="alert alert-info m-3 {% if students %}d-none{% endif %}"> | |
| No students match the current filters. Try resetting or modifying filters. | |
| </div> | |
| </div> | |
| </div> | |
| <!-- AI Insights Section --> | |
| <div class="card mb-3 shadow-sm"> | |
| <div class="card-header bg-light text-dark"> | |
| <h5 class="card-title mb-0"><i class="bi bi-robot"></i> Gemini-Powered Candidate Insights</h5> | |
| </div> | |
| <div class="card-body"> | |
| <form id="ai-insights-form" class="mb-3"> | |
| <div class="row g-2 align-items-end"> | |
| <div class="col-md-6"> | |
| <label for="ai-role-input" class="form-label small mb-1">Job Role for Analysis</label> | |
| <input type="text" class="form-control form-control-sm" id="ai-role-input" name="role" placeholder="e.g., Software Engineer Intern" required> | |
| </div> | |
| <div class="col-md-6"> | |
| <div class="d-grid"> | |
| <button type="submit" class="btn btn-primary btn-sm" id="generate-insights-btn" disabled> | |
| <i class="bi bi-magic"></i> Generate Insights for Selected (<span id="selected-count">0</span>) | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <small class="text-muted d-block mt-1">Select students from the table above first.</small> | |
| </form> | |
| <div id="insights-result" class="p-3 border rounded bg-light d-none"> | |
| <h6 class="mb-2">AI Analysis Results <span id="analysis-role" class="text-muted small"></span></h6> | |
| <div id="insights-spinner" class="text-center d-none my-3"> | |
| <div class="spinner-border text-primary spinner-border-sm" role="status"> | |
| <span class="visually-hidden">Loading...</span> | |
| </div> | |
| <p class="mt-1 mb-0 small text-muted">Generating insights...</p> | |
| </div> | |
| <div id="insights-content" class="overflow-auto" style="max-height: 450px; white-space: pre-wrap; font-size: 0.9rem; line-height: 1.6;"> | |
| <!-- Insights will be loaded here --> | |
| </div> | |
| <div id="insights-error" class="alert alert-danger small p-2 d-none"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> <!-- End Main Content Area Col --> | |
| </div> <!-- End Row --> | |
| </div> <!-- End Main Container --> | |
| <!-- JavaScript --> | |
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script> | |
| <!-- Store initial data passed from Flask --> | |
| <script> | |
| </script> | |
| <!-- Custom JS --> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| console.log("Dashboard DOM Loaded."); | |
| // --- DOM Elements --- | |
| const filterForm = document.getElementById('filter-form'); | |
| const resetButton = document.getElementById('reset-filters'); | |
| const studentsTableBody = document.getElementById('students-tbody'); | |
| const studentCountBadge = document.getElementById('student-count'); | |
| const noStudentsMessage = document.getElementById('no-students-message'); | |
| const exportCsvBtn = document.getElementById('export-csv-btn'); | |
| const aiInsightsForm = document.getElementById('ai-insights-form'); | |
| const aiRoleInput = document.getElementById('ai-role-input'); | |
| const insightsResultDiv = document.getElementById('insights-result'); | |
| const insightsContentDiv = document.getElementById('insights-content'); | |
| const insightsErrorDiv = document.getElementById('insights-error'); // Added error div | |
| const insightsSpinner = document.getElementById('insights-spinner'); | |
| const generateInsightsBtn = document.getElementById('generate-insights-btn'); | |
| const selectedCountSpan = document.getElementById('selected-count'); | |
| const analysisRoleSpan = document.getElementById('analysis-role'); | |
| const selectAllBtn = document.getElementById('select-all'); | |
| const deselectAllBtn = document.getElementById('deselect-all'); | |
| const selectAllCheckbox = document.getElementById('select-all-checkbox'); | |
| const collapseVizButton = document.getElementById('collapseVizButton'); | |
| const collapseVizIcon = collapseVizButton.querySelector('i'); | |
| const collapseVizText = collapseVizButton.querySelector('span'); | |
| const visualizationsCollapse = document.getElementById('visualizationsCollapse'); | |
| // NL Query Elements | |
| const nlQueryInput = document.getElementById('nl-query-input'); | |
| const nlQuerySubmitBtn = document.getElementById('nl-query-submit'); | |
| const nlQuerySpinner = document.getElementById('nl-query-spinner'); | |
| const nlQueryErrorDiv = document.getElementById('nl-query-error'); | |
| // --- Chart Variables --- | |
| const chartInstances = {}; // Store instances by ID | |
| // --- Chart Options --- | |
| const commonChartOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(0, 0, 0, 0.7)', titleFont: { size: 14 }, bodyFont: { size: 12 }, padding: 10 } }, layout: { padding: { top: 5, bottom: 5 } } }; | |
| const verticalBarOptions = { ...commonChartOptions, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } }; | |
| const horizontalBarOptions = { ...commonChartOptions, indexAxis: 'y', scales: { x: { beginAtZero: true, ticks: { precision: 0 } } } }; | |
| const pieChartOptions = { ...commonChartOptions, plugins: { legend: { position: 'right', labels: { font: { size: 10 }, boxWidth: 10 } }, tooltip: commonChartOptions.plugins.tooltip } }; | |
| const chartColors = ['rgba(0, 123, 255, 0.7)', 'rgba(255, 193, 7, 0.7)', 'rgba(40, 167, 69, 0.7)', 'rgba(220, 53, 69, 0.7)', 'rgba(23, 162, 184, 0.7)', 'rgba(108, 117, 125, 0.7)', 'rgba(52, 58, 64, 0.7)', 'rgba(111, 66, 193, 0.7)', 'rgba(253, 126, 20, 0.7)', 'rgba(32, 201, 151, 0.7)']; | |
| const chartBorderColors = chartColors.map(color => color.replace('0.7', '1')); | |
| // --- State --- | |
| let currentFilteredStudentsData = []; // Stores full data for visible students | |
| // --- Utility Functions --- | |
| const getSafe = (fn, defaultVal) => { try { const result = fn(); return (result === undefined || result === null) ? defaultVal : result; } catch (e) { return defaultVal; } }; | |
| function showCanvasMessage(canvasId, message) { | |
| const ctx = document.getElementById(canvasId)?.getContext('2d'); | |
| if (ctx) { | |
| ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); | |
| ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#aaa'; ctx.font = '14px Arial'; | |
| ctx.fillText(message, ctx.canvas.width / 2, ctx.canvas.height / 2); ctx.restore(); | |
| } | |
| } | |
| function createOrUpdateChart(canvasId, type, chartDataObj, options) { | |
| const ctx = document.getElementById(canvasId)?.getContext('2d'); | |
| if (!ctx) { console.error(`Canvas element "${canvasId}" not found.`); return; } | |
| if (chartInstances[canvasId]) { chartInstances[canvasId].destroy(); } | |
| const labels = getSafe(() => chartDataObj.labels, []); | |
| const dataValues = getSafe(() => chartDataObj.data, []); | |
| const numericData = dataValues.map(Number); | |
| if (labels.length === 0 || numericData.length === 0 || numericData.every(v => v === 0 && !isNaN(v))) { | |
| showCanvasMessage(canvasId, 'No data available'); chartInstances[canvasId] = null; return; | |
| } | |
| try { | |
| chartInstances[canvasId] = new Chart(ctx, { type: type, data: { labels: labels, datasets: [{ label: canvasId.replace('Chart', ''), data: numericData, backgroundColor: chartColors, borderColor: chartBorderColors, borderWidth: 1 }] }, options: options }); | |
| console.log(`Chart updated: ${canvasId}`); | |
| } catch (error) { | |
| console.error(`Error creating chart ${canvasId}:`, error); showCanvasMessage(canvasId, 'Error loading chart'); chartInstances[canvasId] = null; | |
| } | |
| } | |
| function updateAllCharts(chartData) { | |
| console.log("Updating All Charts (Frontend) with:", chartData); | |
| if (!chartData) { console.error("updateAllCharts called with null/undefined data."); return; } | |
| const defaultData = { labels: [], data: [] }; | |
| createOrUpdateChart('cgpaChart', 'bar', chartData.cgpa || defaultData, verticalBarOptions); | |
| createOrUpdateChart('deptChart', 'pie', chartData.department || defaultData, pieChartOptions); | |
| createOrUpdateChart('skillsChart', 'bar', chartData.skills || defaultData, horizontalBarOptions); | |
| createOrUpdateChart('rolesChart', 'bar', chartData.roles || defaultData, horizontalBarOptions); | |
| } | |
| function updateStudentsTable(students) { | |
| console.log(`Updating table with ${students ? students.length : 0} students.`); | |
| studentsTableBody.innerHTML = ''; | |
| currentFilteredStudentsData = students || []; // Store full data for export/AI | |
| if (!currentFilteredStudentsData || currentFilteredStudentsData.length === 0) { | |
| noStudentsMessage.classList.remove('d-none'); | |
| exportCsvBtn.disabled = true; | |
| } else { | |
| noStudentsMessage.classList.add('d-none'); | |
| exportCsvBtn.disabled = false; | |
| currentFilteredStudentsData.forEach(student => { | |
| const row = document.createElement('tr'); | |
| row.dataset.email = student.email; | |
| const fullName = getSafe(() => student.full_name, 'N/A'); | |
| const department = getSafe(() => student.department, 'N/A'); | |
| const cgpa = getSafe(() => student.cgpa, null); | |
| const cgpaFormatted = cgpa !== null ? cgpa.toFixed(2) : 'N/A'; | |
| const backlogs = getSafe(() => student.backlogs, 0); | |
| // Simplified Skill Display Logic (handles list or string) | |
| let skillsRaw = getSafe(() => student.programming_languages, []); | |
| let skillsArray = []; | |
| if (typeof skillsRaw === 'string') { | |
| skillsArray = skillsRaw.split(',').map(s => s.trim()).filter(Boolean); | |
| } else if (Array.isArray(skillsRaw)) { | |
| skillsArray = skillsRaw.map(s => String(s).trim()).filter(Boolean); | |
| } | |
| // Remove proficiency in brackets for display | |
| const skillsCleaned = skillsArray.map(s => s.replace(/\s*\(.*\)$/, '')); | |
| const topSkills = skillsCleaned.slice(0, 3).join(', ') + (skillsCleaned.length > 3 ? '...' : ''); | |
| let experienceHtml = ''; | |
| if (getSafe(() => student.internships, null)) experienceHtml += '<span class="badge bg-success" title="Internship">I</span> '; | |
| if (getSafe(() => student.projects, null)) experienceHtml += '<span class="badge bg-primary" title="Projects">P</span> '; | |
| if (getSafe(() => student.hackathons, null)) experienceHtml += '<span class="badge bg-warning text-dark" title="Hackathon">H</span> '; | |
| if (getSafe(() => student.publications, null)) experienceHtml += '<span class="badge bg-info text-dark" title="Publication/Research">R</span> '; | |
| if (!experienceHtml) experienceHtml = '<span class="text-muted small">None</span>'; | |
| row.innerHTML = ` | |
| <td class="text-center"><input type="checkbox" class="form-check-input student-checkbox" value="${student.email}"></td> | |
| <td>${fullName}</td> | |
| <td>${department}</td> | |
| <td>${cgpaFormatted}</td> | |
| <td>${backlogs}</td> | |
| <td><small>${topSkills || 'N/A'}</small></td> | |
| <td>${experienceHtml.trim()}</td> | |
| `; | |
| studentsTableBody.appendChild(row); | |
| }); | |
| } | |
| studentCountBadge.textContent = currentFilteredStudentsData.length; | |
| selectAllCheckbox.checked = false; | |
| updateAiButtonState(); | |
| } | |
| function updateAiButtonState() { | |
| const selectedCheckboxes = studentsTableBody.querySelectorAll('.student-checkbox:checked'); | |
| const count = selectedCheckboxes.length; | |
| selectedCountSpan.textContent = count; | |
| generateInsightsBtn.disabled = count === 0; | |
| } | |
| function exportToCsv(filename, rows) { | |
| console.log(`Exporting ${rows.length} rows to ${filename}`); | |
| if (rows.length === 0) { alert("No data to export."); return; } | |
| // Use the actual headers from the first row object keys for flexibility | |
| const headers = Object.keys(rows[0]); | |
| const processRow = (row) => headers.map(header => { | |
| let value = row[header]; if (value === null || value === undefined) return ''; if (Array.isArray(value)) value = value.join('; '); let stringValue = String(value); if (stringValue.includes('"') || stringValue.includes(',') || stringValue.includes('\n')) stringValue = `"${stringValue.replace(/"/g, '""')}"`; return stringValue; | |
| }).join(','); | |
| const csvContent = [headers.join(','), ...rows.map(processRow)].join('\n'); | |
| const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' }); // Add BOM | |
| const link = document.createElement("a"); | |
| if (link.download !== undefined) { const url = URL.createObjectURL(blob); link.setAttribute("href", url); link.setAttribute("download", filename); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } else { alert("CSV export is not supported."); } | |
| } | |
| // --- Function to update filter form from AI results --- | |
| function updateFilterForm(filters) { | |
| console.log("Updating filter form with:", filters); | |
| // Resetting form ensures clean slate | |
| filterForm.reset(); | |
| // Reset multi-selects explicitly | |
| filterForm.querySelectorAll('select[multiple]').forEach(select => { | |
| Array.from(select.options).forEach(option => option.selected = false); | |
| }); | |
| // Clear Job ID filter if applying manual/AI filters | |
| document.getElementById('job_id_filter').value = ''; | |
| // Apply filters from AI | |
| if (filters.min_cgpa !== undefined && filters.min_cgpa !== null) { | |
| document.getElementById('min_cgpa_filter').value = filters.min_cgpa; | |
| } else { | |
| document.getElementById('min_cgpa_filter').value = ''; // Explicitly clear if null/undefined | |
| } | |
| if (filters.max_backlogs !== undefined && filters.max_backlogs !== null) { | |
| document.getElementById('max_backlogs_filter').value = filters.max_backlogs; | |
| } else { | |
| document.getElementById('max_backlogs_filter').value = ''; // Explicitly clear | |
| } | |
| // Boolean flags | |
| document.getElementById('hasHackathons').checked = filters.has_hackathons === true; | |
| document.getElementById('hasExperience').checked = filters.has_experience === true; | |
| document.getElementById('hasResearch').checked = filters.has_research === true; | |
| // Handle multi-selects (Skills, Roles, Certifications) | |
| function applyMultiSelect(selectId, values) { | |
| if (values && Array.isArray(values) && values.length > 0) { | |
| const selectElement = document.getElementById(selectId); | |
| if (selectElement) { | |
| Array.from(selectElement.options).forEach(option => { | |
| option.selected = values.some(val => | |
| option.value.toLowerCase() === String(val).toLowerCase().trim() || | |
| option.text.toLowerCase() === String(val).toLowerCase().trim() | |
| ); | |
| }); | |
| } else { | |
| console.warn(`Select element #${selectId} not found.`); | |
| } | |
| } | |
| } | |
| applyMultiSelect('skills_filter', filters.skills); | |
| applyMultiSelect('roles_filter', filters.roles); | |
| applyMultiSelect('certifications_filter', filters.certifications); | |
| // Handle department (if mentioned by AI and a filter field exists) | |
| // Note: 'department' is not currently a standard filter field in the form | |
| if (filters.department) { | |
| console.log(`AI suggested department filter: ${filters.department} (No dedicated form field yet)`); | |
| // If you add a department filter input/select later, update it here: | |
| // const deptSelect = document.getElementById('department_filter'); | |
| // if (deptSelect) deptSelect.value = filters.department; | |
| } | |
| console.log("Filter form updated by AI result."); | |
| } | |
| // --- Event Listeners --- | |
| filterForm.addEventListener('submit', function(e) { | |
| e.preventDefault(); const formData = new FormData(filterForm); | |
| console.log("Applying Filters (Manual or AI Triggered)..."); | |
| filterForm.querySelector('button[type="submit"]').disabled = true; | |
| filterForm.querySelector('button[type="submit"]').innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Filtering...'; | |
| noStudentsMessage.classList.add('d-none'); // Hide message initially | |
| // Clear Job ID if manual filters are being changed (except job ID itself) | |
| const jobIdFilter = document.getElementById('job_id_filter'); | |
| if (e.submitter !== jobIdFilter && jobIdFilter.value !== '') { | |
| // Check if the submit was triggered by something other than changing the job filter itself | |
| // AND if a job ID was previously selected. This logic might need refinement depending on desired interaction. | |
| // A simpler approach might be to always clear job ID if any manual filter is touched. | |
| // console.log("Manual filter change detected, clearing Job ID filter."); | |
| // jobIdFilter.value = ''; | |
| // formData.set('job_id', ''); // Update formData as well | |
| } | |
| fetch('{{ url_for("filter_students_route") }}', { method: 'POST', body: formData }) | |
| .then(response => { if (!response.ok) { return response.json().then(errData => { throw new Error(getSafe(() => errData.error, `HTTP ${response.status}`)); }).catch(() => { throw new Error(`HTTP ${response.status}`); }); } return response.json(); }) | |
| .then(data => { | |
| console.log("Filter Response:", data); | |
| if (data.error) { throw new Error(data.error); } | |
| updateStudentsTable(data.students); // Update table with full data | |
| if (data.chart_data) { | |
| updateAllCharts(data.chart_data); | |
| } else { console.error("Chart data missing in filter response!"); } | |
| exportCsvBtn.disabled = data.students.length === 0; | |
| }) | |
| .catch(error => { | |
| console.error('Filter Error:', error); | |
| noStudentsMessage.textContent = `Error applying filters: ${error.message}`; | |
| noStudentsMessage.classList.remove('d-none', 'alert-info'); | |
| noStudentsMessage.classList.add('alert-danger'); | |
| studentsTableBody.innerHTML = ''; // Clear table on error | |
| studentCountBadge.textContent = 0; | |
| updateAllCharts({ cgpa: {}, department: {}, skills: {}, roles: {} }); // Clear charts | |
| exportCsvBtn.disabled = true; | |
| }) | |
| .finally(() => { | |
| filterForm.querySelector('button[type="submit"]').disabled = false; | |
| filterForm.querySelector('button[type="submit"]').innerHTML = '<i class="bi bi-funnel-fill"></i> Apply Filters'; | |
| }); | |
| }); | |
| resetButton.addEventListener('click', function() { | |
| console.log("Resetting filters..."); | |
| filterForm.reset(); | |
| // Explicitly reset multi-selects | |
| filterForm.querySelectorAll('select[multiple]').forEach(select => { | |
| Array.from(select.options).forEach(option => option.selected = false); | |
| }); | |
| // Reset Job ID filter | |
| document.getElementById('job_id_filter').value = ''; | |
| // Automatically trigger a filter update after reset | |
| setTimeout(() => filterForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })), 50); | |
| }); | |
| exportCsvBtn.addEventListener('click', () => { | |
| const timestamp = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, ""); | |
| const filename = `filtered_students_${timestamp}.csv`; | |
| // Export the stored full data of currently filtered students | |
| exportToCsv(filename, currentFilteredStudentsData); | |
| }); | |
| selectAllBtn.addEventListener('click', () => { studentsTableBody.querySelectorAll('.student-checkbox:not(:disabled)').forEach(cb => cb.checked = true); selectAllCheckbox.checked = true; updateAiButtonState(); }); | |
| deselectAllBtn.addEventListener('click', () => { studentsTableBody.querySelectorAll('.student-checkbox:not(:disabled)').forEach(cb => cb.checked = false); selectAllCheckbox.checked = false; updateAiButtonState(); }); | |
| selectAllCheckbox.addEventListener('change', function() { studentsTableBody.querySelectorAll('.student-checkbox:not(:disabled)').forEach(cb => cb.checked = this.checked); updateAiButtonState(); }); | |
| studentsTableBody.addEventListener('change', function(event) { | |
| if (event.target.classList.contains('student-checkbox')) { | |
| updateAiButtonState(); | |
| const allVisible = studentsTableBody.querySelectorAll('.student-checkbox:not(:disabled)'); | |
| const checkedVisible = studentsTableBody.querySelectorAll('.student-checkbox:checked:not(:disabled)'); | |
| selectAllCheckbox.checked = allVisible.length > 0 && allVisible.length === checkedVisible.length; | |
| } | |
| }); | |
| aiInsightsForm.addEventListener('submit', function(e) { | |
| e.preventDefault(); | |
| const selectedStudents = Array.from(studentsTableBody.querySelectorAll('.student-checkbox:checked')).map(cb => cb.value); | |
| const role = aiRoleInput.value.trim(); | |
| if (selectedStudents.length === 0) { alert('Please select one or more students from the table first.'); return; } | |
| if (!role) { alert('Please enter the Job Role for analysis.'); aiRoleInput.focus(); return; } | |
| const formData = new FormData(); | |
| selectedStudents.forEach(email => formData.append('selected_students', email)); | |
| formData.append('role', role); | |
| insightsResultDiv.classList.remove('d-none'); | |
| insightsContentDiv.innerHTML = ''; | |
| insightsErrorDiv.classList.add('d-none'); // Hide previous errors | |
| insightsSpinner.classList.remove('d-none'); | |
| generateInsightsBtn.disabled = true; | |
| generateInsightsBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Generating...'; | |
| analysisRoleSpan.textContent = `(for ${role})`; | |
| fetch('{{ url_for("ai_insights_route") }}', { method: 'POST', body: formData }) | |
| .then(response => { if (!response.ok) { return response.json().then(errData => { throw new Error(getSafe(() => errData.error, `HTTP ${response.status}`)); }).catch(() => { throw new Error(`HTTP ${response.status}`); }); } return response.json(); }) | |
| .then(data => { | |
| console.log("AI Insights Response:", data); | |
| if (data.error) { | |
| throw new Error(data.error); // Throw error to be caught below | |
| } else { | |
| let formatted = data.insights || "No insights generated."; | |
| // Basic Markdown-like formatting | |
| formatted = formatted | |
| .replace(/</g, "<").replace(/>/g, ">") // Basic HTML escaping first | |
| .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') // Bold | |
| .replace(/\*(.*?)\*/g, '<em>$1</em>') // Italics | |
| .replace(/^### (.*$)/gim, '<h6 class="mt-2 mb-1">$1</h6>') // Headings | |
| .replace(/^## (.*$)/gim, '<h5 class="mt-2 mb-1">$1</h5>') | |
| .replace(/^# (.*$)/gim, '<h4 class="mt-2 mb-1">$1</h4>') | |
| .replace(/^\s*[-*+] (.*$)/gim, '<li>$1</li>'); // List items | |
| // Wrap consecutive list items in <ul> | |
| formatted = formatted.replace(/(<li>.*<\/li>\s*)+/g, (match) => `<ul class="list-unstyled mb-2 ms-3">${match.replace(/<\/li>\s*<li>/g, '</li><li>')}</ul>`); | |
| // Replace newlines with <br>, but not inside lists or after headings | |
| formatted = formatted.replace(/(\<\/(?:ul|h[4-6])\>)\n+/g, '$1'); // Remove newlines after block elements | |
| formatted = formatted.replace(/\n/g, '<br>'); // Convert remaining newlines | |
| insightsContentDiv.innerHTML = formatted; | |
| insightsErrorDiv.classList.add('d-none'); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('AI Insights Error:', error); | |
| insightsErrorDiv.textContent = `Error: ${error.message}`; | |
| insightsErrorDiv.classList.remove('d-none'); | |
| insightsContentDiv.innerHTML = ''; // Clear content on error | |
| }) | |
| .finally(() => { | |
| insightsSpinner.classList.add('d-none'); | |
| // Re-enable button using the count from the function | |
| updateAiButtonState(); | |
| const currentCount = studentsTableBody.querySelectorAll('.student-checkbox:checked').length; | |
| generateInsightsBtn.innerHTML = `<i class="bi bi-magic"></i> Generate Insights for Selected (${currentCount})`; | |
| }); | |
| }); | |
| // Event Listener for Natural Language Query | |
| nlQuerySubmitBtn.addEventListener('click', function() { | |
| const query = nlQueryInput.value.trim(); | |
| if (!query) { | |
| nlQueryErrorDiv.textContent = 'Please enter a query.'; | |
| nlQueryErrorDiv.classList.remove('d-none'); | |
| return; | |
| } | |
| nlQuerySubmitBtn.disabled = true; | |
| nlQuerySpinner.classList.remove('d-none'); | |
| nlQueryErrorDiv.classList.add('d-none'); | |
| nlQueryErrorDiv.textContent = ''; | |
| fetch('{{ url_for("process_nl_query") }}', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ query: query }) | |
| }) | |
| .then(response => { | |
| if (!response.ok) { | |
| return response.json().then(errData => { throw new Error(errData.error || `HTTP error! Status: ${response.status}`); }) | |
| .catch(() => { throw new Error(`HTTP error! Status: ${response.status}`); }); | |
| } | |
| return response.json(); | |
| }) | |
| .then(data => { | |
| if (data.error) { throw new Error(data.error); } | |
| console.log("Received filter parameters from AI:", data.filters); | |
| updateFilterForm(data.filters); | |
| console.log("Triggering filter form submission..."); | |
| // Use setTimeout to allow the browser to update the form values before submitting | |
| setTimeout(() => { | |
| filterForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); | |
| }, 50); // Small delay | |
| }) | |
| .catch(error => { | |
| console.error('Error processing NL query:', error); | |
| nlQueryErrorDiv.textContent = `Error translating query: ${error.message}`; | |
| nlQueryErrorDiv.classList.remove('d-none'); | |
| }) | |
| .finally(() => { | |
| nlQuerySubmitBtn.disabled = false; | |
| nlQuerySpinner.classList.add('d-none'); | |
| }); | |
| }); | |
| visualizationsCollapse.addEventListener('show.bs.collapse', () => { collapseVizIcon.classList.replace('bi-chevron-down', 'bi-chevron-up'); collapseVizText.textContent = 'Collapse'; }); | |
| visualizationsCollapse.addEventListener('hide.bs.collapse', () => { collapseVizIcon.classList.replace('bi-chevron-up', 'bi-chevron-down'); collapseVizText.textContent = 'Expand'; }); | |
| // --- Initial Load --- | |
| console.log("--- Initializing Dashboard ---"); | |
| // Data is rendered server-side initially. JS updates state. | |
| currentFilteredStudentsData = allStudentsInitially; // Initialize JS state | |
| updateAiButtonState(); // Set initial AI button state | |
| exportCsvBtn.disabled = currentFilteredStudentsData.length === 0; // Set initial export button state | |
| if (initialChartData) { | |
| console.log("Initializing Charts with Initial Data (Frontend):", initialChartData); | |
| updateAllCharts(initialChartData); | |
| } else { | |
| console.error("Initial chart data is missing!"); | |
| updateAllCharts({ cgpa: {}, department: {}, skills: {}, roles: {} }); // Init empty charts | |
| } | |
| console.log("--- Dashboard Initialized ---"); | |
| }); // End DOMContentLoaded | |
| </script> | |
| </body> | |
| </html> |