stamford-pending-signature-report.html•24.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stamford - Pending Customer Approval Report</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 1600px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px;
text-align: center;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
.header p {
font-size: 1.2rem;
opacity: 0.95;
}
.summary-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
padding: 40px;
background: #f8f9fa;
}
.summary-card {
background: white;
padding: 25px;
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.summary-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
.summary-card h3 {
font-size: 0.9rem;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.summary-card .value {
font-size: 2rem;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
}
.summary-card .subtitle {
font-size: 0.85rem;
color: #999;
}
.content {
padding: 40px;
}
.filters {
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin-bottom: 30px;
}
.filter-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
.filter-label {
font-weight: 600;
color: #667eea;
margin-right: 10px;
}
input[type="text"], select {
padding: 8px 15px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 0.9rem;
transition: border-color 0.3s ease;
}
input[type="text"]:focus, select:focus {
outline: none;
border-color: #667eea;
}
.table-container {
overflow-x: auto;
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1200px;
}
thead {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
th {
padding: 15px;
text-align: left;
font-weight: 600;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.5px;
}
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
background: rgba(255, 255, 255, 0.1);
}
tbody tr {
border-bottom: 1px solid #eee;
transition: background-color 0.2s ease;
}
tbody tr:hover {
background-color: #f8f9fa;
}
td {
padding: 15px;
font-size: 0.9rem;
}
.job-number {
font-weight: 600;
color: #667eea;
}
.estimate-value {
font-weight: 700;
color: #28a745;
font-size: 1.1rem;
}
.estimate-value.high {
color: #dc3545;
}
.estimate-value.medium {
color: #ffc107;
}
.sales-rep {
display: inline-block;
padding: 5px 12px;
background: #e3f2fd;
color: #1976d2;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.address {
color: #666;
font-size: 0.85rem;
}
.date-badge {
display: inline-block;
padding: 4px 10px;
background: #f0f0f0;
border-radius: 15px;
font-size: 0.8rem;
color: #666;
}
.priority-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 15px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.priority-high {
background: #fee;
color: #c00;
}
.priority-medium {
background: #fff3cd;
color: #856404;
}
.priority-low {
background: #d4edda;
color: #155724;
}
.no-estimate {
color: #999;
font-style: italic;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 30px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.stat-card h4 {
color: #667eea;
margin-bottom: 15px;
font-size: 1.1rem;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
color: #666;
font-size: 0.9rem;
}
.stat-value {
font-weight: 600;
color: #333;
}
@media (max-width: 768px) {
.header h1 {
font-size: 1.8rem;
}
.summary-section {
grid-template-columns: 1fr;
padding: 20px;
}
.content {
padding: 20px;
}
table {
font-size: 0.85rem;
}
}
.action-buttons {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Stamford - Pending Customer Approval Report</h1>
<p>Jobs Awaiting Customer Signature - January-October 2025</p>
</div>
<div class="summary-section">
<div class="summary-card">
<h3>Total Pending Jobs</h3>
<div class="value" id="totalJobs">0</div>
<div class="subtitle">Awaiting customer signature</div>
</div>
<div class="summary-card">
<h3>Total Estimate Value</h3>
<div class="value" id="totalValue">$0</div>
<div class="subtitle">Combined estimate amount</div>
</div>
<div class="summary-card">
<h3>Average Job Value</h3>
<div class="value" id="avgValue">$0</div>
<div class="subtitle">Mean estimate per job</div>
</div>
<div class="summary-card">
<h3>Highest Value Job</h3>
<div class="value" id="maxValue">$0</div>
<div class="subtitle">Largest pending estimate</div>
</div>
</div>
<div class="content">
<div class="filters">
<div class="filter-group">
<span class="filter-label">Search:</span>
<input type="text" id="searchInput" placeholder="Search by customer, job name, or address..." style="width: 300px;">
<span class="filter-label">Sales Rep:</span>
<select id="salesRepFilter">
<option value="">All Sales Reps</option>
</select>
<span class="filter-label">Sort By:</span>
<select id="sortSelect">
<option value="value-desc">Highest Value First</option>
<option value="value-asc">Lowest Value First</option>
<option value="date-desc">Newest First</option>
<option value="date-asc">Oldest First</option>
</select>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-primary" onclick="exportToCSV()">Export to CSV</button>
<button class="btn btn-secondary" onclick="window.print()">Print Report</button>
</div>
<div class="table-container">
<table id="jobsTable">
<thead>
<tr>
<th class="sortable" onclick="sortTable('number')">Job #</th>
<th class="sortable" onclick="sortTable('name')">Job Name</th>
<th class="sortable" onclick="sortTable('customer')">Customer</th>
<th class="sortable" onclick="sortTable('estimate')">Estimate</th>
<th>Sales Rep</th>
<th>Address</th>
<th class="sortable" onclick="sortTable('date')">Date Created</th>
<th>Days Pending</th>
<th>Priority</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- Data will be populated by JavaScript -->
</tbody>
</table>
</div>
<div class="stats-grid">
<div class="stat-card">
<h4>By Sales Representative</h4>
<div id="salesRepStats"></div>
</div>
<div class="stat-card">
<h4>Value Distribution</h4>
<div id="valueDistribution"></div>
</div>
<div class="stat-card">
<h4>Age Distribution</h4>
<div id="ageDistribution"></div>
</div>
</div>
</div>
</div>
<script>
// Pending Jobs Data - Filtered from JobNimbus
const pendingJobs = [
{"number": "1874", "name": "99 Huckleberry Hill Rd - Slate Repairs", "customer": "Jack Montgomery Deign", "estimate": 2790, "sales_rep": "Deivis Castro", "address": "99 Huckleberry Hill Rd, New Canaan, CT", "date": "2025-10-08"},
{"number": "1864", "name": "406 Courtland Avenue", "customer": "Courtland Manor Condominiums", "estimate": 60869, "sales_rep": "Deivis Castro", "address": "406 Courtland Avenue, Stamford, CT", "date": "2025-10-02"},
{"number": "1863", "name": "165 N Pasture Lane", "customer": "Raymond Ayoub", "estimate": 21270, "sales_rep": "Bill Tyson", "address": "165 N Pasture Lane, Stratford, CT", "date": "2025-10-02"},
{"number": "1861", "name": "220 Banks Road", "customer": "Mathew Degennaro", "estimate": 0, "sales_rep": "Bill Tyson", "address": "220 Banks Road, Easton, CT", "date": "2025-10-01"},
{"number": "1858", "name": "11Sport Hill Road", "customer": "Danny Rodrigues", "estimate": 9497, "sales_rep": "Deivis Castro", "address": "11 Sport Hill Road, Easton, CT", "date": "2025-09-29"},
{"number": "1853", "name": "57 Belltown Rd Repairs", "customer": "Shaun Azari", "estimate": 6200, "sales_rep": "Deivis Castro", "address": "57 Belltown Rd, Stamford, CT", "date": "2025-09-22"},
{"number": "1850", "name": "68 Leonard St - Wood Shingle", "customer": "Al Candito", "estimate": 28800, "sales_rep": "Deivis Castro", "address": "68 Leonard St, Stamford, CT", "date": "2025-09-22"},
{"number": "1835", "name": "28 Raymond Ter - Roof & Skylight", "customer": "Susan Gonzales", "estimate": 13132, "sales_rep": "Deivis Castro", "address": "28 Raymond Terrace, Norwalk, CT", "date": "2025-09-08"},
{"number": "1831", "name": "22 Timber Ln Wood Shakes Siding Repairs", "customer": "Sara Wallace", "estimate": 1250, "sales_rep": "Deivis Castro", "address": "22 Timber Ln, Wesport, CT", "date": "2025-09-08"},
{"number": "1826", "name": "146 Ridgecrest Rd Roof Replacement", "customer": "Kimberly Quinones", "estimate": 41341, "sales_rep": "Deivis Castro", "address": "146 Ridgecrest Rd, Stamford, CT", "date": "2025-09-03"},
{"number": "1824", "name": "52 Shinnacock Trail House Paint and Repairs", "customer": "Gypsy Rios", "estimate": 16982, "sales_rep": "Deivis Castro", "address": "52 Shinnacock Trail, Shelton, CT", "date": "2025-09-02"},
{"number": "1816", "name": "47 Shadow Ln", "customer": "Mike Thomas (4349573)", "estimate": 29574, "sales_rep": "Deivis Castro", "address": "47 Shadow Ln, Ridgefield, CT", "date": "2025-08-27"},
{"number": "1803", "name": "111 Mulberry Street, Roof Replacement", "customer": "Jeff Chirico", "estimate": 22236, "sales_rep": "Deivis Castro", "address": "111 Mulberry Street,, Stamford, CT", "date": "2025-08-19"},
{"number": "1800", "name": "46 Dandy Dr Roof Replacement", "customer": "Patrick Dean", "estimate": 77016, "sales_rep": "Deivis Castro", "address": "46 Dandy Dr, CosCob, CT", "date": "2025-08-15"},
{"number": "1794", "name": "700 Steamboat Rd Slate Repairs/Replacement", "customer": "Elizabeth Sahlin", "estimate": 4711, "sales_rep": "Deivis Castro", "address": "700 Steamboat Rd, Greenwich,, CT", "date": "2025-08-07"},
{"number": "1791", "name": "14 Nursery Ct Roof Replacement & Skylights Installation", "customer": "Sean M", "estimate": 29193, "sales_rep": "Deivis Castro", "address": "14 Nursery Ct, Norwalk, CT", "date": "2025-08-06"}
];
let filteredJobs = [...pendingJobs];
function calculateDaysPending(dateStr) {
const created = new Date(dateStr);
const now = new Date();
const diffTime = Math.abs(now - created);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
function getPriority(estimate, daysPending) {
if (estimate > 50000 || daysPending > 60) return 'high';
if (estimate > 20000 || daysPending > 30) return 'medium';
return 'low';
}
function renderTable() {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
filteredJobs.forEach(job => {
const daysPending = calculateDaysPending(job.date);
const priority = getPriority(job.estimate, daysPending);
const estimateClass = job.estimate > 50000 ? 'high' : job.estimate > 20000 ? 'medium' : '';
const row = `
<tr>
<td class="job-number">#${job.number}</td>
<td>${job.name}</td>
<td>${job.customer}</td>
<td class="estimate-value ${estimateClass}">
${job.estimate > 0 ? '$' + job.estimate.toLocaleString('en-US') : '<span class="no-estimate">No estimate</span>'}
</td>
<td><span class="sales-rep">${job.sales_rep || 'Unassigned'}</span></td>
<td class="address">${job.address}</td>
<td><span class="date-badge">${job.date}</span></td>
<td>${daysPending} days</td>
<td><span class="priority-badge priority-${priority}">${priority}</span></td>
</tr>
`;
tbody.innerHTML += row;
});
}
function updateSummary() {
const jobsWithEstimate = filteredJobs.filter(j => j.estimate > 0);
const totalValue = jobsWithEstimate.reduce((sum, j) => sum + j.estimate, 0);
const avgValue = jobsWithEstimate.length > 0 ? totalValue / jobsWithEstimate.length : 0;
const maxValue = jobsWithEstimate.length > 0 ? Math.max(...jobsWithEstimate.map(j => j.estimate)) : 0;
document.getElementById('totalJobs').textContent = filteredJobs.length;
document.getElementById('totalValue').textContent = '$' + totalValue.toLocaleString('en-US');
document.getElementById('avgValue').textContent = '$' + Math.round(avgValue).toLocaleString('en-US');
document.getElementById('maxValue').textContent = '$' + maxValue.toLocaleString('en-US');
}
function updateStats() {
// Sales Rep Stats
const salesRepStats = {};
filteredJobs.forEach(job => {
const rep = job.sales_rep || 'Unassigned';
if (!salesRepStats[rep]) {
salesRepStats[rep] = { count: 0, value: 0 };
}
salesRepStats[rep].count++;
salesRepStats[rep].value += job.estimate;
});
const salesRepHTML = Object.entries(salesRepStats)
.sort((a, b) => b[1].value - a[1].value)
.map(([rep, stats]) => `
<div class="stat-item">
<span class="stat-label">${rep}</span>
<span class="stat-value">${stats.count} jobs ($${stats.value.toLocaleString('en-US')})</span>
</div>
`).join('');
document.getElementById('salesRepStats').innerHTML = salesRepHTML;
// Value Distribution
const ranges = [
{ label: '$0 - $10K', min: 0, max: 10000 },
{ label: '$10K - $30K', min: 10000, max: 30000 },
{ label: '$30K - $50K', min: 30000, max: 50000 },
{ label: '$50K+', min: 50000, max: Infinity }
];
const valueDistHTML = ranges.map(range => {
const count = filteredJobs.filter(j => j.estimate >= range.min && j.estimate < range.max).length;
return `
<div class="stat-item">
<span class="stat-label">${range.label}</span>
<span class="stat-value">${count} jobs</span>
</div>
`;
}).join('');
document.getElementById('valueDistribution').innerHTML = valueDistHTML;
// Age Distribution
const ageRanges = [
{ label: '0-15 days', min: 0, max: 15 },
{ label: '16-30 days', min: 16, max: 30 },
{ label: '31-60 days', min: 31, max: 60 },
{ label: '60+ days', min: 61, max: Infinity }
];
const ageDistHTML = ageRanges.map(range => {
const count = filteredJobs.filter(j => {
const days = calculateDaysPending(j.date);
return days >= range.min && days <= range.max;
}).length;
return `
<div class="stat-item">
<span class="stat-label">${range.label}</span>
<span class="stat-value">${count} jobs</span>
</div>
`;
}).join('');
document.getElementById('ageDistribution').innerHTML = ageDistHTML;
}
function populateFilters() {
const salesReps = [...new Set(pendingJobs.map(j => j.sales_rep || 'Unassigned'))].sort();
const salesRepFilter = document.getElementById('salesRepFilter');
salesReps.forEach(rep => {
const option = document.createElement('option');
option.value = rep;
option.textContent = rep;
salesRepFilter.appendChild(option);
});
}
function applyFilters() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const salesRep = document.getElementById('salesRepFilter').value;
filteredJobs = pendingJobs.filter(job => {
const matchesSearch = !searchTerm ||
job.name.toLowerCase().includes(searchTerm) ||
job.customer.toLowerCase().includes(searchTerm) ||
job.address.toLowerCase().includes(searchTerm);
const matchesSalesRep = !salesRep || (job.sales_rep || 'Unassigned') === salesRep;
return matchesSearch && matchesSalesRep;
});
applySorting();
}
function applySorting() {
const sortBy = document.getElementById('sortSelect').value;
switch(sortBy) {
case 'value-desc':
filteredJobs.sort((a, b) => b.estimate - a.estimate);
break;
case 'value-asc':
filteredJobs.sort((a, b) => a.estimate - b.estimate);
break;
case 'date-desc':
filteredJobs.sort((a, b) => new Date(b.date) - new Date(a.date));
break;
case 'date-asc':
filteredJobs.sort((a, b) => new Date(a.date) - new Date(b.date));
break;
}
renderTable();
updateSummary();
updateStats();
}
function exportToCSV() {
const headers = ['Job #', 'Job Name', 'Customer', 'Estimate', 'Sales Rep', 'Address', 'Date Created', 'Days Pending'];
const rows = filteredJobs.map(job => [
job.number,
job.name,
job.customer,
job.estimate,
job.sales_rep || 'Unassigned',
job.address,
job.date,
calculateDaysPending(job.date)
]);
let csv = headers.join(',') + '\n';
rows.forEach(row => {
csv += row.map(cell => `"${cell}"`).join(',') + '\n';
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'stamford-pending-approval-' + new Date().toISOString().split('T')[0] + '.csv';
a.click();
}
// Event Listeners
document.getElementById('searchInput').addEventListener('input', applyFilters);
document.getElementById('salesRepFilter').addEventListener('change', applyFilters);
document.getElementById('sortSelect').addEventListener('change', applySorting);
// Initialize
populateFilters();
applyFilters();
</script>
</body>
</html>