tom-l-pm-dashboard/dashboard-csv.html
DJP a6db845054 Initial commit - PM dashboard with CSV and email versions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:01:21 -05:00

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 (&lt;-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>