1040 lines
36 KiB
HTML
1040 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Project Margin Dashboard - CSV</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;600;700;800&display=swap" rel="stylesheet">
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #000000;
|
|
color: #333;
|
|
padding: 20px;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h1 {
|
|
color: #FFC407;
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
font-size: 2.5em;
|
|
font-weight: 800;
|
|
text-shadow: 2px 2px 4px rgba(255, 196, 7, 0.3);
|
|
}
|
|
|
|
.upload-section {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 40px;
|
|
margin-bottom: 30px;
|
|
box-shadow: 0 4px 6px rgba(255, 196, 7, 0.2);
|
|
text-align: center;
|
|
}
|
|
|
|
.upload-section h2 {
|
|
color: #FFC407;
|
|
margin-bottom: 20px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.upload-area {
|
|
border: 3px dashed #FFC407;
|
|
border-radius: 12px;
|
|
padding: 60px 40px;
|
|
background: #fffbf0;
|
|
transition: all 0.3s;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
|
|
.upload-area.dragover {
|
|
background: #fff5cc;
|
|
border-color: #e6b006;
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.upload-area:hover {
|
|
background: #fff9e6;
|
|
}
|
|
|
|
.upload-icon {
|
|
font-size: 4em;
|
|
color: #FFC407;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.upload-text {
|
|
font-size: 1.2em;
|
|
color: #555;
|
|
margin-bottom: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.upload-subtext {
|
|
color: #999;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
#fileInput {
|
|
display: none;
|
|
}
|
|
|
|
.btn {
|
|
padding: 12px 30px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 1em;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
font-family: 'Montserrat', sans-serif;
|
|
margin: 10px 5px;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #FFC407;
|
|
color: #000;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: #e6b006;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 8px rgba(255, 196, 7, 0.4);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #e2e8f0;
|
|
color: #555;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #cbd5e0;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.processing-status {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: #e6f7ff;
|
|
border-radius: 8px;
|
|
display: none;
|
|
}
|
|
|
|
.processing-status.show {
|
|
display: block;
|
|
}
|
|
|
|
.processing-status.error {
|
|
background: #ffe6e6;
|
|
color: #c53030;
|
|
}
|
|
|
|
.processing-status.success {
|
|
background: #e6ffe6;
|
|
color: #22543d;
|
|
}
|
|
|
|
.dashboard-content {
|
|
display: none;
|
|
}
|
|
|
|
.dashboard-content.show {
|
|
display: block;
|
|
}
|
|
|
|
.kpi-container {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.kpi-card {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 25px;
|
|
box-shadow: 0 4px 6px rgba(255, 196, 7, 0.2);
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.kpi-card:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 6px 12px rgba(255, 196, 7, 0.4);
|
|
}
|
|
|
|
.kpi-label {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-bottom: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.kpi-value {
|
|
font-size: 2.5em;
|
|
font-weight: 800;
|
|
color: #FFC407;
|
|
}
|
|
|
|
.kpi-card.danger .kpi-value {
|
|
color: #e53e3e;
|
|
}
|
|
|
|
.kpi-card.warning .kpi-value {
|
|
color: #ed8936;
|
|
}
|
|
|
|
.kpi-card.success .kpi-value {
|
|
color: #38a169;
|
|
}
|
|
|
|
.filters {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 25px;
|
|
margin-bottom: 30px;
|
|
box-shadow: 0 4px 6px rgba(255, 196, 7, 0.2);
|
|
}
|
|
|
|
.filters h2 {
|
|
margin-bottom: 20px;
|
|
color: #FFC407;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.filter-group {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.filter-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.filter-item label {
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
color: #555;
|
|
}
|
|
|
|
.filter-item select,
|
|
.filter-item input {
|
|
padding: 10px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
font-size: 1em;
|
|
font-family: 'Montserrat', sans-serif;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.filter-item select:focus,
|
|
.filter-item input:focus {
|
|
outline: none;
|
|
border-color: #FFC407;
|
|
}
|
|
|
|
.charts-container {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.chart-card {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 25px;
|
|
box-shadow: 0 4px 6px rgba(255, 196, 7, 0.2);
|
|
}
|
|
|
|
.chart-card h3 {
|
|
margin-bottom: 20px;
|
|
color: #FFC407;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.table-container {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 25px;
|
|
box-shadow: 0 4px 6px rgba(255, 196, 7, 0.2);
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.table-container h2 {
|
|
margin-bottom: 20px;
|
|
color: #FFC407;
|
|
font-weight: 700;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
thead {
|
|
background: #FFC407;
|
|
color: #000;
|
|
}
|
|
|
|
th {
|
|
padding: 15px;
|
|
text-align: left;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
font-size: 0.85em;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
th:hover {
|
|
background: #e6b006;
|
|
}
|
|
|
|
th .sort-arrow {
|
|
margin-left: 4px;
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
td {
|
|
padding: 12px 15px;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
tbody tr:hover {
|
|
background: #fffbf0;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85em;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.badge-danger {
|
|
background: #fed7d7;
|
|
color: #c53030;
|
|
}
|
|
|
|
.badge-warning {
|
|
background: #feebc8;
|
|
color: #c05621;
|
|
}
|
|
|
|
.badge-info {
|
|
background: #e9d8fd;
|
|
color: #6b46c1;
|
|
}
|
|
|
|
.badge-success {
|
|
background: #c6f6d5;
|
|
color: #22543d;
|
|
}
|
|
|
|
.margin-negative {
|
|
color: #e53e3e;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.margin-positive {
|
|
color: #38a169;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.no-data {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #999;
|
|
font-size: 1.2em;
|
|
}
|
|
|
|
a.job-link {
|
|
color: #FFC407;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
|
|
a.job-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
h1 {
|
|
font-size: 1.8em;
|
|
}
|
|
|
|
.upload-area {
|
|
padding: 40px 20px;
|
|
}
|
|
|
|
.charts-container {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Project Margin Dashboard</h1>
|
|
|
|
<div class="upload-section" id="uploadSection">
|
|
<h2>Upload CSV File</h2>
|
|
<div class="upload-area" id="uploadArea">
|
|
<div class="upload-icon">CSV</div>
|
|
<div class="upload-text">Drag & Drop a .csv file here</div>
|
|
<div class="upload-subtext">or click to browse</div>
|
|
</div>
|
|
<input type="file" id="fileInput" accept=".csv">
|
|
|
|
<div class="processing-status" id="processingStatus"></div>
|
|
</div>
|
|
|
|
<div class="dashboard-content" id="dashboardContent">
|
|
<div class="kpi-container" id="kpiContainer"></div>
|
|
|
|
<div class="filters">
|
|
<h2>Filters</h2>
|
|
<div class="filter-group">
|
|
<div class="filter-item">
|
|
<label for="statusFilter">Status</label>
|
|
<select id="statusFilter">
|
|
<option value="all">All</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-item">
|
|
<label for="leadFilter">Lead</label>
|
|
<select id="leadFilter">
|
|
<option value="all">All Leads</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-item">
|
|
<label for="clientFilter">Client</label>
|
|
<select id="clientFilter">
|
|
<option value="all">All Clients</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-item">
|
|
<label for="completionFilter">Completion Status</label>
|
|
<select id="completionFilter">
|
|
<option value="all">All</option>
|
|
<option value="complete">100% Complete</option>
|
|
<option value="incomplete">In Progress</option>
|
|
<option value="zero">0% Complete</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-item">
|
|
<label for="searchFilter">Search Projects</label>
|
|
<input type="text" id="searchFilter" placeholder="Search by job number or name...">
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-primary" onclick="applyFilters()">Apply Filters</button>
|
|
<button class="btn btn-secondary" onclick="resetFilters()">Reset</button>
|
|
<button class="btn btn-secondary" onclick="resetApp()">Upload New File</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="charts-container">
|
|
<div class="chart-card">
|
|
<h3>Status Distribution</h3>
|
|
<canvas id="alertTypeChart"></canvas>
|
|
</div>
|
|
<div class="chart-card">
|
|
<h3>Project Completion Status</h3>
|
|
<canvas id="completionChart"></canvas>
|
|
</div>
|
|
<div class="chart-card">
|
|
<h3>Top 10 Projects by Margin Gap</h3>
|
|
<canvas id="marginGapChart"></canvas>
|
|
</div>
|
|
<div class="chart-card">
|
|
<h3>Margin Performance Overview</h3>
|
|
<canvas id="marginOverviewChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<h2>Project Details (<span id="projectCount">0</span> projects)</h2>
|
|
<table id="projectTable">
|
|
<thead>
|
|
<tr>
|
|
<th onclick="sortTable('status')">Status <span class="sort-arrow" id="sort-status"></span></th>
|
|
<th onclick="sortTable('job_number')">Job Number <span class="sort-arrow" id="sort-job_number"></span></th>
|
|
<th onclick="sortTable('job_name')">Job Name <span class="sort-arrow" id="sort-job_name"></span></th>
|
|
<th onclick="sortTable('lead_name')">Lead <span class="sort-arrow" id="sort-lead_name"></span></th>
|
|
<th onclick="sortTable('client')">Client <span class="sort-arrow" id="sort-client"></span></th>
|
|
<th onclick="sortTable('margin_target')">Target % <span class="sort-arrow" id="sort-margin_target"></span></th>
|
|
<th onclick="sortTable('margin_to_date')">To Date % <span class="sort-arrow" id="sort-margin_to_date"></span></th>
|
|
<th onclick="sortTable('margin_eac')">EAC % <span class="sort-arrow" id="sort-margin_eac"></span></th>
|
|
<th onclick="sortTable('pct_complete')">Complete % <span class="sort-arrow" id="sort-pct_complete"></span></th>
|
|
<th>Link</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="projectTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let allData = [];
|
|
let filteredData = [];
|
|
let charts = {};
|
|
let currentSort = { column: 'status', direction: 'asc' };
|
|
|
|
// Status priority for sorting: "% complete is zero" first
|
|
const STATUS_PRIORITY = {
|
|
'Project % complete is zero': 0,
|
|
'Project overburning': 1,
|
|
'Project Tracking Behind': 2,
|
|
'Project Management on Target': 3
|
|
};
|
|
|
|
// File upload handling
|
|
const uploadArea = document.getElementById('uploadArea');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const processingStatus = document.getElementById('processingStatus');
|
|
|
|
uploadArea.addEventListener('click', () => fileInput.click());
|
|
|
|
uploadArea.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
uploadArea.classList.add('dragover');
|
|
});
|
|
|
|
uploadArea.addEventListener('dragleave', () => {
|
|
uploadArea.classList.remove('dragover');
|
|
});
|
|
|
|
uploadArea.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
uploadArea.classList.remove('dragover');
|
|
const files = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.csv'));
|
|
if (files.length > 0) {
|
|
processCSV(files[0]);
|
|
} else {
|
|
showStatus('Please select a .csv file', 'error');
|
|
}
|
|
});
|
|
|
|
fileInput.addEventListener('change', (e) => {
|
|
if (e.target.files.length > 0) {
|
|
processCSV(e.target.files[0]);
|
|
}
|
|
});
|
|
|
|
function showStatus(message, type = 'info') {
|
|
processingStatus.textContent = message;
|
|
processingStatus.className = 'processing-status show ' + type;
|
|
}
|
|
|
|
function parseCSVLine(line) {
|
|
const result = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i];
|
|
if (char === '"') {
|
|
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
|
|
current += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = !inQuotes;
|
|
}
|
|
} else if (char === ',' && !inQuotes) {
|
|
result.push(current.trim());
|
|
current = '';
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
result.push(current.trim());
|
|
return result;
|
|
}
|
|
|
|
async function processCSV(file) {
|
|
showStatus('Processing CSV...', 'info');
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
|
|
|
|
if (lines.length < 2) {
|
|
showStatus('CSV file is empty or has no data rows', 'error');
|
|
return;
|
|
}
|
|
|
|
// Parse header
|
|
const headers = parseCSVLine(lines[0]);
|
|
console.log('CSV Headers:', headers);
|
|
|
|
allData = [];
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const fields = parseCSVLine(lines[i]);
|
|
if (fields.length < 11) continue;
|
|
|
|
const row = {
|
|
status: fields[0] || '',
|
|
lead_name: fields[1] || '',
|
|
lead_email: fields[2] || '',
|
|
job_id: fields[3] || '',
|
|
job_number: fields[4] || '',
|
|
client: fields[5] || '',
|
|
job_name: fields[6] || '',
|
|
pct_complete: parseFloat(fields[7]) || 0,
|
|
margin_target: parseFloat(fields[8]) || 0,
|
|
margin_to_date: parseFloat(fields[9]) || 0,
|
|
margin_eac: parseFloat(fields[10]) || 0,
|
|
job_url: fields[11] || ''
|
|
};
|
|
|
|
allData.push(row);
|
|
}
|
|
|
|
if (allData.length === 0) {
|
|
showStatus('No valid data rows found in CSV', 'error');
|
|
return;
|
|
}
|
|
|
|
// Default sort: "Project % complete is zero" first
|
|
sortDataBy('status', 'asc');
|
|
filteredData = [...allData];
|
|
|
|
showStatus(`Successfully loaded ${allData.length} projects from ${file.name}`, 'success');
|
|
|
|
setTimeout(() => {
|
|
document.getElementById('uploadSection').style.display = 'none';
|
|
document.getElementById('dashboardContent').classList.add('show');
|
|
initializeDashboard();
|
|
}, 800);
|
|
|
|
} catch (error) {
|
|
showStatus('Error reading CSV file: ' + error.message, 'error');
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
function resetApp() {
|
|
if (confirm('This will clear all current data and return to upload. Continue?')) {
|
|
allData = [];
|
|
filteredData = [];
|
|
fileInput.value = '';
|
|
processingStatus.classList.remove('show');
|
|
|
|
Object.values(charts).forEach(chart => chart.destroy());
|
|
charts = {};
|
|
|
|
document.getElementById('uploadSection').style.display = 'block';
|
|
document.getElementById('dashboardContent').classList.remove('show');
|
|
}
|
|
}
|
|
|
|
function initializeDashboard() {
|
|
populateFilters();
|
|
updateKPIs();
|
|
updateCharts();
|
|
updateTable();
|
|
}
|
|
|
|
function populateFilters() {
|
|
// Status filter
|
|
const statuses = [...new Set(allData.map(d => d.status))].sort((a, b) => {
|
|
return (STATUS_PRIORITY[a] ?? 99) - (STATUS_PRIORITY[b] ?? 99);
|
|
});
|
|
const statusSelect = document.getElementById('statusFilter');
|
|
statusSelect.innerHTML = '<option value="all">All Statuses</option>';
|
|
statuses.forEach(s => {
|
|
const option = document.createElement('option');
|
|
option.value = s;
|
|
option.textContent = s;
|
|
statusSelect.appendChild(option);
|
|
});
|
|
|
|
// Lead filter
|
|
const leads = [...new Set(allData.map(d => d.lead_name))].sort();
|
|
const leadSelect = document.getElementById('leadFilter');
|
|
leadSelect.innerHTML = '<option value="all">All Leads</option>';
|
|
leads.forEach(l => {
|
|
const option = document.createElement('option');
|
|
option.value = l;
|
|
option.textContent = l;
|
|
leadSelect.appendChild(option);
|
|
});
|
|
|
|
// Client filter
|
|
const clients = [...new Set(allData.map(d => d.client))].sort();
|
|
const clientSelect = document.getElementById('clientFilter');
|
|
clientSelect.innerHTML = '<option value="all">All Clients</option>';
|
|
clients.forEach(c => {
|
|
const option = document.createElement('option');
|
|
option.value = c;
|
|
option.textContent = c;
|
|
clientSelect.appendChild(option);
|
|
});
|
|
}
|
|
|
|
function updateKPIs() {
|
|
const data = filteredData;
|
|
const totalProjects = data.length;
|
|
const pctZero = data.filter(d => d.status === 'Project % complete is zero').length;
|
|
const overburning = data.filter(d => d.status === 'Project overburning').length;
|
|
const trackingBehind = data.filter(d => d.status === 'Project Tracking Behind').length;
|
|
const onTarget = data.filter(d => d.status === 'Project Management on Target').length;
|
|
const avgCompletion = totalProjects > 0 ? data.reduce((sum, d) => sum + d.pct_complete, 0) / totalProjects : 0;
|
|
const criticalProjects = data.filter(d => d.margin_to_date < -50).length;
|
|
|
|
document.getElementById('kpiContainer').innerHTML = `
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">Total Projects</div>
|
|
<div class="kpi-value">${totalProjects}</div>
|
|
</div>
|
|
<div class="kpi-card warning">
|
|
<div class="kpi-label">% Complete is 0</div>
|
|
<div class="kpi-value">${pctZero}</div>
|
|
</div>
|
|
<div class="kpi-card danger">
|
|
<div class="kpi-label">Overburning</div>
|
|
<div class="kpi-value">${overburning}</div>
|
|
</div>
|
|
<div class="kpi-card warning">
|
|
<div class="kpi-label">Tracking Behind</div>
|
|
<div class="kpi-value">${trackingBehind}</div>
|
|
</div>
|
|
<div class="kpi-card success">
|
|
<div class="kpi-label">On Target</div>
|
|
<div class="kpi-value">${onTarget}</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">Avg Completion</div>
|
|
<div class="kpi-value">${avgCompletion.toFixed(1)}%</div>
|
|
</div>
|
|
<div class="kpi-card danger">
|
|
<div class="kpi-label">Critical (<-50%)</div>
|
|
<div class="kpi-value">${criticalProjects}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateCharts() {
|
|
updateAlertTypeChart();
|
|
updateCompletionChart();
|
|
updateMarginGapChart();
|
|
updateMarginOverviewChart();
|
|
}
|
|
|
|
function updateAlertTypeChart() {
|
|
const ctx = document.getElementById('alertTypeChart');
|
|
const counts = {};
|
|
filteredData.forEach(d => {
|
|
counts[d.status] = (counts[d.status] || 0) + 1;
|
|
});
|
|
|
|
const colorMap = {
|
|
'Project % complete is zero': '#805ad5',
|
|
'Project overburning': '#e53e3e',
|
|
'Project Tracking Behind': '#ed8936',
|
|
'Project Management on Target': '#38a169'
|
|
};
|
|
|
|
const labels = Object.keys(counts);
|
|
const data = Object.values(counts);
|
|
const colors = labels.map(l => colorMap[l] || '#a0aec0');
|
|
|
|
if (charts.alertType) charts.alertType.destroy();
|
|
charts.alertType = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: data,
|
|
backgroundColor: colors,
|
|
borderWidth: 0
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: {
|
|
font: { family: 'Montserrat', weight: '600' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateCompletionChart() {
|
|
const ctx = document.getElementById('completionChart');
|
|
const ranges = { '0%': 0, '1-25%': 0, '26-50%': 0, '51-75%': 0, '76-99%': 0, '100%': 0 };
|
|
|
|
filteredData.forEach(d => {
|
|
const c = d.pct_complete;
|
|
if (c === 0) ranges['0%']++;
|
|
else if (c <= 25) ranges['1-25%']++;
|
|
else if (c <= 50) ranges['26-50%']++;
|
|
else if (c <= 75) ranges['51-75%']++;
|
|
else if (c < 100) ranges['76-99%']++;
|
|
else ranges['100%']++;
|
|
});
|
|
|
|
if (charts.completion) charts.completion.destroy();
|
|
charts.completion = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: Object.keys(ranges),
|
|
datasets: [{
|
|
label: 'Projects',
|
|
data: Object.values(ranges),
|
|
backgroundColor: '#FFC407',
|
|
borderRadius: 8
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
y: { beginAtZero: true, ticks: { stepSize: 1, font: { family: 'Montserrat' } } },
|
|
x: { ticks: { font: { family: 'Montserrat' } } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateMarginGapChart() {
|
|
const ctx = document.getElementById('marginGapChart');
|
|
const sorted = [...filteredData].sort((a, b) => {
|
|
const gapA = a.margin_target - a.margin_to_date;
|
|
const gapB = b.margin_target - b.margin_to_date;
|
|
return gapB - gapA;
|
|
}).slice(0, 10);
|
|
|
|
const labels = sorted.map(d => d.job_number);
|
|
const gaps = sorted.map(d => d.margin_target - d.margin_to_date);
|
|
|
|
if (charts.marginGap) charts.marginGap.destroy();
|
|
charts.marginGap = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Margin Gap (%)',
|
|
data: gaps,
|
|
backgroundColor: gaps.map(g => g > 50 ? '#e53e3e' : g > 20 ? '#ed8936' : '#f6ad55'),
|
|
borderRadius: 8
|
|
}]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { beginAtZero: true, ticks: { font: { family: 'Montserrat' } } },
|
|
y: { ticks: { font: { family: 'Montserrat', size: 10 } } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateMarginOverviewChart() {
|
|
const ctx = document.getElementById('marginOverviewChart');
|
|
const data = filteredData.slice(0, 15).map(d => ({
|
|
x: d.job_number,
|
|
target: d.margin_target,
|
|
toDate: d.margin_to_date
|
|
}));
|
|
|
|
if (charts.marginOverview) charts.marginOverview.destroy();
|
|
charts.marginOverview = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.map(d => d.x),
|
|
datasets: [
|
|
{
|
|
label: 'Target',
|
|
data: data.map(d => d.target),
|
|
borderColor: '#38a169',
|
|
backgroundColor: 'rgba(56, 161, 105, 0.1)',
|
|
tension: 0.4
|
|
},
|
|
{
|
|
label: 'To Date',
|
|
data: data.map(d => d.toDate),
|
|
borderColor: '#e53e3e',
|
|
backgroundColor: 'rgba(229, 62, 62, 0.1)',
|
|
tension: 0.4
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: { font: { family: 'Montserrat', weight: '600' } }
|
|
}
|
|
},
|
|
scales: {
|
|
y: { beginAtZero: false, ticks: { font: { family: 'Montserrat' } } },
|
|
x: { ticks: { font: { family: 'Montserrat', size: 9 } } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateTable() {
|
|
const tbody = document.getElementById('projectTableBody');
|
|
document.getElementById('projectCount').textContent = filteredData.length;
|
|
|
|
if (filteredData.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="10" class="no-data">No projects match the current filters</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const rows = filteredData.map(d => {
|
|
const marginClass = d.margin_to_date < 0 ? 'margin-negative' : 'margin-positive';
|
|
const eacClass = d.margin_eac < 0 ? 'margin-negative' : 'margin-positive';
|
|
|
|
let badgeClass = 'badge-info';
|
|
if (d.status === 'Project overburning') badgeClass = 'badge-danger';
|
|
else if (d.status === 'Project Tracking Behind') badgeClass = 'badge-warning';
|
|
else if (d.status === 'Project Management on Target') badgeClass = 'badge-success';
|
|
|
|
const linkCell = d.job_url
|
|
? `<a class="job-link" href="${d.job_url}" target="_blank" rel="noopener">Open</a>`
|
|
: '';
|
|
|
|
return `
|
|
<tr>
|
|
<td><span class="badge ${badgeClass}">${d.status}</span></td>
|
|
<td><strong>${d.job_number}</strong></td>
|
|
<td>${d.job_name}</td>
|
|
<td>${d.lead_name}</td>
|
|
<td>${d.client}</td>
|
|
<td>${d.margin_target.toFixed(2)}%</td>
|
|
<td class="${marginClass}">${d.margin_to_date.toFixed(2)}%</td>
|
|
<td class="${eacClass}">${d.margin_eac.toFixed(2)}%</td>
|
|
<td>${d.pct_complete}%</td>
|
|
<td>${linkCell}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
tbody.innerHTML = rows;
|
|
}
|
|
|
|
function applyFilters() {
|
|
const status = document.getElementById('statusFilter').value;
|
|
const lead = document.getElementById('leadFilter').value;
|
|
const client = document.getElementById('clientFilter').value;
|
|
const completion = document.getElementById('completionFilter').value;
|
|
const search = document.getElementById('searchFilter').value.toLowerCase();
|
|
|
|
filteredData = allData.filter(d => {
|
|
if (status !== 'all' && d.status !== status) return false;
|
|
if (lead !== 'all' && d.lead_name !== lead) return false;
|
|
if (client !== 'all' && d.client !== client) return false;
|
|
if (completion === 'complete' && d.pct_complete !== 100) return false;
|
|
if (completion === 'incomplete' && d.pct_complete === 100) return false;
|
|
if (completion === 'zero' && d.pct_complete !== 0) return false;
|
|
if (search && !d.job_number.toLowerCase().includes(search) && !d.job_name.toLowerCase().includes(search) && !d.lead_name.toLowerCase().includes(search) && !d.client.toLowerCase().includes(search)) return false;
|
|
return true;
|
|
});
|
|
|
|
// Re-apply current sort
|
|
sortDataBy(currentSort.column, currentSort.direction);
|
|
updateKPIs();
|
|
updateCharts();
|
|
updateTable();
|
|
}
|
|
|
|
function resetFilters() {
|
|
document.getElementById('statusFilter').value = 'all';
|
|
document.getElementById('leadFilter').value = 'all';
|
|
document.getElementById('clientFilter').value = 'all';
|
|
document.getElementById('completionFilter').value = 'all';
|
|
document.getElementById('searchFilter').value = '';
|
|
filteredData = [...allData];
|
|
currentSort = { column: 'status', direction: 'asc' };
|
|
sortDataBy('status', 'asc');
|
|
updateKPIs();
|
|
updateCharts();
|
|
updateTable();
|
|
}
|
|
|
|
function sortDataBy(column, direction) {
|
|
filteredData.sort((a, b) => {
|
|
let valA, valB;
|
|
|
|
if (column === 'status') {
|
|
valA = STATUS_PRIORITY[a.status] ?? 99;
|
|
valB = STATUS_PRIORITY[b.status] ?? 99;
|
|
} else if (['margin_target', 'margin_to_date', 'margin_eac', 'pct_complete'].includes(column)) {
|
|
valA = a[column];
|
|
valB = b[column];
|
|
} else {
|
|
valA = (a[column] || '').toLowerCase();
|
|
valB = (b[column] || '').toLowerCase();
|
|
}
|
|
|
|
if (valA < valB) return direction === 'asc' ? -1 : 1;
|
|
if (valA > valB) return direction === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
function sortTable(column) {
|
|
if (currentSort.column === column) {
|
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
currentSort.column = column;
|
|
currentSort.direction = 'asc';
|
|
}
|
|
|
|
sortDataBy(column, currentSort.direction);
|
|
|
|
// Update sort arrows
|
|
document.querySelectorAll('.sort-arrow').forEach(el => el.textContent = '');
|
|
const arrow = document.getElementById('sort-' + column);
|
|
if (arrow) arrow.textContent = currentSort.direction === 'asc' ? ' ▲' : ' ▼';
|
|
|
|
updateTable();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|