vendor_report/html_generator.py
2025-11-07 02:42:28 +04:00

1525 lines
56 KiB
Python

#!/usr/bin/env python3
"""
HTML Report Generator
Generates a clean, professional HTML report from JSON report data with search and filter capabilities.
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional
def format_date(date_str: Optional[str]) -> str:
"""Format date string for display."""
if not date_str:
return "N/A"
try:
# Try parsing various formats
if "/" in date_str:
# MM/DD/YY or MM/DD/YYYY
parts = date_str.split("/")
if len(parts) == 3:
if len(parts[2]) == 2:
# Convert YY to YYYY
parts[2] = "20" + parts[2]
return "/".join(parts)
# Already formatted or ISO format
return date_str.split()[0] if " " in date_str else date_str
except:
return date_str
def get_status_badge_class(status: str) -> str:
"""Get CSS class for status badge."""
status_lower = status.lower()
if "complete" in status_lower:
return "badge-success"
elif "monitor" in status_lower:
return "badge-warning"
elif "incomplete" in status_lower:
return "badge-danger"
else:
return "badge-secondary"
def get_priority_badge_class(priority: Optional[str]) -> str:
"""Get CSS class for priority badge."""
if not priority:
return "badge-secondary"
priority_lower = priority.lower()
if "very high" in priority_lower or "(1)" in priority_lower:
return "badge-critical"
elif "high" in priority_lower or "(2)" in priority_lower:
return "badge-high"
elif "medium" in priority_lower or "(3)" in priority_lower:
return "badge-medium"
elif "low" in priority_lower or "(4)" in priority_lower:
return "badge-low"
else:
return "badge-secondary"
def escape_js_string(s: str) -> str:
"""Escape a string for use in JavaScript double-quoted strings."""
return s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r')
def generate_html_report(json_path: str, output_path: Optional[str] = None) -> str:
"""
Generate HTML report from JSON report file.
Args:
json_path: Path to JSON report file
output_path: Optional output HTML file path (defaults to same location as JSON with .html extension)
Returns:
Path to generated HTML file
"""
# Load JSON report
with open(json_path, 'r', encoding='utf-8') as f:
report_data = json.load(f)
# Determine output path
if output_path is None:
json_file = Path(json_path)
output_path = json_file.parent / f"{json_file.stem}.html"
else:
output_path = Path(output_path)
# Generate HTML
html_content = generate_html_content(report_data)
# Save HTML file
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
return str(output_path)
def generate_html_content(report_data: Dict) -> str:
"""Generate HTML content from report data."""
vendors = report_data.get('vendors', [])
summary = report_data.get('summary', {})
report_generated_at = report_data.get('report_generated_at', datetime.now().isoformat())
# Format generation time
try:
gen_time = datetime.fromisoformat(report_generated_at.replace('Z', '+00:00'))
gen_time_str = gen_time.strftime('%Y-%m-%d %H:%M:%S')
except:
gen_time_str = report_generated_at
# Extract vendor names for filter
vendor_names = sorted([v.get('vendor_name', '') for v in vendors if v.get('vendor_name')])
html = rf"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vendor Punchlist Report</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1f2937;
background-color: #f9fafb;
padding: 20px;
}}
.container {{
max-width: 1600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
padding: 40px;
}}
header {{
border-bottom: 3px solid #2563eb;
padding-bottom: 25px;
margin-bottom: 30px;
}}
h1 {{
color: #1e40af;
font-size: 2.5em;
margin-bottom: 10px;
font-weight: 600;
}}
.meta {{
color: #6b7280;
font-size: 0.95em;
}}
.filters-bar {{
background: #f3f4f6;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border: 1px solid #e5e7eb;
}}
.filters-title {{
font-weight: 600;
color: #374151;
margin-bottom: 15px;
font-size: 1.1em;
}}
.filters-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 15px;
}}
.filter-group {{
display: flex;
flex-direction: column;
}}
.filter-label {{
font-size: 0.85em;
color: #6b7280;
margin-bottom: 5px;
font-weight: 500;
}}
.filter-input {{
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95em;
background: white;
color: #1f2937;
}}
.filter-input:focus {{
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}}
.filter-select {{
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95em;
background: white;
color: #1f2937;
cursor: pointer;
}}
.filter-select:focus {{
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}}
.filter-actions {{
display: flex;
gap: 10px;
margin-top: 10px;
}}
.btn {{
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.9em;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}}
.btn-primary {{
background: #2563eb;
color: white;
}}
.btn-primary:hover {{
background: #1d4ed8;
}}
.btn-secondary {{
background: #6b7280;
color: white;
}}
.btn-secondary:hover {{
background: #4b5563;
}}
.results-count {{
color: #6b7280;
font-size: 0.9em;
margin-top: 10px;
}}
.quick-filters-bar {{
background: #f9fafb;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border: 1px solid #e5e7eb;
}}
.quick-filters-title {{
font-weight: 600;
color: #374151;
margin-bottom: 15px;
font-size: 1.05em;
}}
.quick-filters {{
display: flex;
gap: 12px;
flex-wrap: wrap;
}}
.quick-filter-btn {{
padding: 10px 20px;
border: 2px solid #2563eb;
border-radius: 6px;
font-size: 0.95em;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: white;
color: #2563eb;
}}
.quick-filter-btn:hover {{
background: #eff6ff;
border-color: #1d4ed8;
}}
.quick-filter-btn.active {{
background: #2563eb;
color: white;
border-color: #2563eb;
}}
.summary-cards {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 40px;
}}
.summary-card {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.summary-card h3 {{
font-size: 2.2em;
margin-bottom: 8px;
font-weight: 600;
}}
.summary-card p {{
opacity: 0.95;
font-size: 0.95em;
font-weight: 500;
}}
.summary-card.success {{
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}}
.summary-card.warning {{
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}}
.summary-card.danger {{
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}}
.vendor-section {{
margin-bottom: 40px;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: opacity 0.3s;
}}
.vendor-section.hidden {{
display: none;
}}
.vendor-header {{
background: #f9fafb;
padding: 24px;
border-bottom: 2px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}}
.vendor-name {{
font-size: 1.6em;
font-weight: 600;
color: #1e40af;
}}
.vendor-stats {{
display: flex;
gap: 20px;
flex-wrap: wrap;
}}
.stat-item {{
text-align: center;
padding: 12px 18px;
background: white;
border-radius: 6px;
border: 1px solid #e5e7eb;
min-width: 80px;
}}
.stat-value {{
font-size: 1.8em;
font-weight: 600;
color: #1e40af;
}}
.stat-label {{
font-size: 0.85em;
color: #6b7280;
margin-top: 5px;
font-weight: 500;
}}
.vendor-content {{
padding: 24px;
}}
.section {{
margin-bottom: 30px;
}}
.section-title {{
font-size: 1.25em;
font-weight: 600;
color: #374151;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #e5e7eb;
}}
.updates-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-bottom: 20px;
}}
.update-box {{
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 20px;
background: #f9fafb;
}}
.update-box h4 {{
color: #1e40af;
margin-bottom: 12px;
font-size: 1.05em;
font-weight: 600;
}}
.item-list {{
list-style: none;
}}
.item-list li {{
padding: 14px;
margin-bottom: 12px;
background: white;
border-left: 4px solid #2563eb;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
transition: box-shadow 0.2s;
}}
.item-list li:hover {{
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
}}
.item-list li.empty {{
color: #9ca3af;
font-style: italic;
border-left-color: #d1d5db;
background: #f9fafb;
}}
.item-header {{
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
flex-wrap: wrap;
gap: 12px;
}}
.item-name {{
font-weight: 600;
color: #1e40af;
flex: 1;
font-size: 1.05em;
}}
.badges {{
display: flex;
gap: 8px;
flex-wrap: wrap;
}}
.badge {{
padding: 5px 12px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
.badge-success {{
background: #d1fae5;
color: #065f46;
}}
.badge-warning {{
background: #fef3c7;
color: #92400e;
}}
.badge-danger {{
background: #dc2626;
color: white;
}}
.badge-secondary {{
background: #e5e7eb;
color: #374151;
}}
.badge-critical {{
background: #fee2e2;
color: #991b1b;
font-weight: 700;
}}
.badge-high {{
background: #fed7aa;
color: #9a3412;
}}
.badge-medium {{
background: #fef3c7;
color: #92400e;
}}
.badge-low {{
background: #dbeafe;
color: #1e40af;
}}
.item-details {{
margin-top: 10px;
font-size: 0.9em;
color: #6b7280;
}}
.item-details p {{
margin-bottom: 6px;
line-height: 1.5;
}}
.item-details strong {{
color: #374151;
font-weight: 600;
}}
.item-description {{
margin-top: 8px;
color: #4b5563;
line-height: 1.6;
}}
.age-days {{
color: #dc2626;
font-weight: 600;
font-size: 0.9em;
}}
.tabs-container {{
margin-bottom: 30px;
border-bottom: 2px solid #e5e7eb;
}}
.tabs {{
display: flex;
gap: 0;
flex-wrap: wrap;
margin-bottom: 0;
}}
.tab {{
padding: 12px 24px;
background: #f3f4f6;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 0.95em;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
white-space: nowrap;
}}
.tab:hover {{
background: #e5e7eb;
color: #374151;
}}
.tab.active {{
background: white;
color: #1e40af;
border-bottom-color: #2563eb;
font-weight: 600;
}}
.tab-content {{
display: none;
}}
.tab-content.active {{
display: block;
}}
.status-tabs {{
display: flex;
gap: 0;
margin-bottom: 20px;
border-bottom: 2px solid #e5e7eb;
}}
.status-tab {{
padding: 10px 20px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}}
.status-tab:hover {{
color: #374151;
background: #f9fafb;
}}
.status-tab.active {{
color: #1e40af;
border-bottom-color: #2563eb;
font-weight: 600;
}}
.status-tab-content {{
display: none;
}}
.status-tab-content.active {{
display: block;
}}
.updates-sub-tabs {{
display: flex;
gap: 0;
margin-bottom: 20px;
border-bottom: 2px solid #e5e7eb;
}}
.update-tab-content {{
display: none;
}}
.update-tab-content.active {{
display: block;
}}
.footer {{
margin-top: 50px;
padding-top: 25px;
border-top: 2px solid #e5e7eb;
text-align: center;
color: #6b7280;
font-size: 0.9em;
}}
@media print {{
body {{
background: white;
padding: 0;
}}
.container {{
box-shadow: none;
}}
.filters-bar {{
display: none;
}}
.vendor-section {{
page-break-inside: avoid;
}}
}}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Vendor Punchlist Report</h1>
<div class="meta">
Generated: {gen_time_str} |
Total Vendors: {summary.get('total_vendors', 0)} |
Total Items: {summary.get('total_items', 0)}
</div>
</header>
<div class="filters-bar">
<div class="filters-title">Filters & Search</div>
<div class="filters-grid">
<div class="filter-group">
<label class="filter-label">Search Items</label>
<input type="text" id="search-input" class="filter-input" placeholder="Search by name or description...">
</div>
<div class="filter-group">
<label class="filter-label">Vendor</label>
<select id="vendor-filter" class="filter-select">
<option value="">All Vendors</option>
{''.join([f'<option value="{vn}">{vn}</option>' for vn in vendor_names])}
</select>
</div>
<div class="filter-group">
<label class="filter-label">Status</label>
<select id="status-filter" class="filter-select">
<option value="">All Statuses</option>
<option value="Complete">Complete</option>
<option value="Monitor">Monitor</option>
<option value="Incomplete">Incomplete</option>
</select>
</div>
<div class="filter-group">
<label class="filter-label">Priority</label>
<select id="priority-filter" class="filter-select">
<option value="">All Priorities</option>
<option value="very_high">Very High</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
<div class="filter-actions">
<button class="btn btn-primary" onclick="applyFilters()">Apply Filters</button>
<button class="btn btn-secondary" onclick="clearFilters()">Clear All</button>
</div>
<div class="results-count" id="results-count"></div>
</div>
<div class="quick-filters-bar">
<div class="quick-filters-title">Quick Filters</div>
<div class="quick-filters">
<button class="quick-filter-btn" onclick="showOnly24hUpdates()" id="filter-24h-updates">Show Only Vendors with Yesterday's Updates</button>
<button class="quick-filter-btn" onclick="showOnlyOldestUnaddressed()" id="filter-oldest-unaddressed">Show Only Vendors with Oldest Unaddressed</button>
<button class="quick-filter-btn" onclick="showAllVendors()" id="filter-all-vendors">Show All Vendors</button>
</div>
</div>
<div class="summary-cards">
<div class="summary-card">
<h3>{summary.get('total_vendors', 0)}</h3>
<p>Vendors</p>
</div>
<div class="summary-card">
<h3>{summary.get('total_items', 0)}</h3>
<p>Total Items</p>
</div>
<div class="summary-card success">
<h3>{summary.get('total_closed', 0)}</h3>
<p>Closed</p>
</div>
<div class="summary-card warning">
<h3>{summary.get('total_monitor', 0)}</h3>
<p>Monitor</p>
</div>
<div class="summary-card danger">
<h3>{summary.get('total_open', 0)}</h3>
<p>Open</p>
</div>
<div class="summary-card danger">
<h3>{summary.get('total_incomplete', 0)}</h3>
<p>Incomplete</p>
</div>
</div>
<div class="tabs-container">
<div class="tabs" id="vendor-tabs">
<button class="tab active" onclick="switchVendorTab('all')" data-vendor="all">All Vendors</button>
{''.join(['<button class="tab" onclick="switchVendorTab(' + "'" + escape_js_string(vn) + "'" + ')" data-vendor="' + vn + '">' + vn + '</button>' for vn in vendor_names])}
</div>
</div>
<div id="vendor-sections">
{generate_vendor_sections(vendors)}
</div>
<div class="footer">
<p>Report generated automatically from Excel punchlist data</p>
</div>
</div>
<script>
let currentVendorTab = 'all';
function switchVendorTab(vendorName) {{
currentVendorTab = vendorName;
// Update tab buttons
document.querySelectorAll('#vendor-tabs .tab').forEach(tab => {{
if (tab.dataset.vendor === vendorName) {{
tab.classList.add('active');
}} else {{
tab.classList.remove('active');
}}
}});
// Show/hide vendor sections
document.querySelectorAll('.vendor-section').forEach(section => {{
const sectionVendor = section.dataset.vendor;
if (vendorName === 'all' || sectionVendor === vendorName) {{
section.style.display = '';
}} else {{
section.style.display = 'none';
}}
}});
// Reapply filters after switching tabs
applyFilters();
}}
function applyFilters() {{
const searchText = document.getElementById('search-input').value.toLowerCase();
const vendorFilter = document.getElementById('vendor-filter').value;
const statusFilter = document.getElementById('status-filter').value;
const priorityFilter = document.getElementById('priority-filter').value;
const vendorSections = document.querySelectorAll('.vendor-section');
let visibleCount = 0;
vendorSections.forEach(section => {{
const vendorName = section.querySelector('.vendor-name').textContent.trim();
const items = section.querySelectorAll('.item-list li');
let hasVisibleItems = false;
// Check vendor tab (currentVendorTab)
if (currentVendorTab !== 'all' && vendorName !== currentVendorTab) {{
section.style.display = 'none';
return;
}}
// Check vendor filter
if (vendorFilter && vendorName !== vendorFilter) {{
section.style.display = 'none';
return;
}}
// Filter items within section
items.forEach(item => {{
const itemName = item.querySelector('.item-name')?.textContent.toLowerCase() || '';
const itemDescription = item.querySelector('.item-description')?.textContent.toLowerCase() || '';
const itemDetails = item.querySelector('.item-details')?.textContent.toLowerCase() || '';
const itemText = (itemName + ' ' + itemDescription + ' ' + itemDetails).toLowerCase();
const statusBadge = item.querySelector('.badge-success, .badge-warning, .badge-danger');
const status = statusBadge ? statusBadge.textContent.trim() : '';
const priorityBadge = item.querySelector('.badge-critical, .badge-high, .badge-medium, .badge-low');
const priority = priorityBadge ? priorityBadge.textContent.toLowerCase() : '';
let priorityMatch = priorityFilter === '';
if (priorityFilter === 'very_high' && (priority.includes('very high') || priority.includes('critical'))) {{
priorityMatch = true;
}} else if (priorityFilter === 'high' && priority.includes('high') && !priority.includes('very')) {{
priorityMatch = true;
}} else if (priorityFilter === 'medium' && priority.includes('medium')) {{
priorityMatch = true;
}} else if (priorityFilter === 'low' && priority.includes('low')) {{
priorityMatch = true;
}}
const matchesSearch = !searchText || itemText.includes(searchText);
const matchesStatus = !statusFilter || status === statusFilter;
const matchesPriority = priorityMatch;
if (matchesSearch && matchesStatus && matchesPriority) {{
item.style.display = '';
hasVisibleItems = true;
}} else {{
item.style.display = 'none';
}}
}});
// Show/hide empty sections
const visibleItems = section.querySelectorAll('.item-list li[style=""], .item-list li:not([style*="display: none"])');
if (visibleItems.length > 0 || (!searchText && !statusFilter && !priorityFilter)) {{
section.style.display = '';
visibleCount++;
}} else {{
section.style.display = 'none';
}}
}});
// Update results count
document.getElementById('results-count').textContent =
`Showing ${{visibleCount}} vendor section(s) matching filters`;
}}
function clearFilters() {{
document.getElementById('search-input').value = '';
document.getElementById('vendor-filter').value = '';
document.getElementById('status-filter').value = '';
document.getElementById('priority-filter').value = '';
// Reset to "All Vendors" tab
switchVendorTab('all');
// Show all sections and items
document.querySelectorAll('.vendor-section').forEach(section => {{
section.style.display = '';
}});
document.querySelectorAll('.item-list li').forEach(item => {{
item.style.display = '';
}});
// Reset status tabs in each vendor section
document.querySelectorAll('.status-tab').forEach(tab => {{
tab.classList.remove('active');
}});
document.querySelectorAll('.status-tab-content').forEach(content => {{
content.classList.remove('active');
}});
// Activate "All" status tabs
document.querySelectorAll('.status-tab[data-status="all"]').forEach(tab => {{
tab.classList.add('active');
}});
document.querySelectorAll('.status-tab-content[data-status="all"]').forEach(content => {{
content.classList.add('active');
}});
document.getElementById('results-count').textContent = '';
}}
// Apply filters on Enter key in search
document.addEventListener('DOMContentLoaded', function() {{
document.getElementById('search-input').addEventListener('keypress', function(e) {{
if (e.key === 'Enter') {{
applyFilters();
}}
}});
// Auto-apply on filter change
document.getElementById('vendor-filter').addEventListener('change', applyFilters);
document.getElementById('status-filter').addEventListener('change', applyFilters);
document.getElementById('priority-filter').addEventListener('change', applyFilters);
}});
function switchStatusTab(tabElement, vendorName) {{
const status = tabElement.dataset.status;
// Update tabs for this vendor
const vendorSection = document.querySelector(`.vendor-section[data-vendor="${{vendorName}}"]`);
if (!vendorSection) return;
vendorSection.querySelectorAll('.status-tab').forEach(t => {{
if (t.dataset.status === status) {{
t.classList.add('active');
}} else {{
t.classList.remove('active');
}}
}});
// Update content for this vendor
vendorSection.querySelectorAll('.status-tab-content').forEach(content => {{
if (content.dataset.status === status && content.dataset.vendor === vendorName) {{
content.classList.add('active');
}} else {{
content.classList.remove('active');
}}
}});
}}
function switchUpdateTab(tabElement, vendorNameSafe) {{
const updateType = tabElement.dataset.updateType;
// Find the parent updates section
const updatesSection = tabElement.closest('.section');
if (!updatesSection) return;
// Update sub-tabs
updatesSection.querySelectorAll('.updates-sub-tabs .status-tab').forEach(t => {{
if (t.dataset.updateType === updateType) {{
t.classList.add('active');
}} else {{
t.classList.remove('active');
}}
}});
// Update content
updatesSection.querySelectorAll('.update-tab-content').forEach(content => {{
if (content.dataset.updateType === updateType && content.dataset.vendorUpdate === vendorNameSafe) {{
content.classList.add('active');
}} else {{
content.classList.remove('active');
}}
}});
}}
function showOnly24hUpdates() {{
// Remove active class from all quick filter buttons
document.querySelectorAll('.quick-filter-btn').forEach(btn => {{
btn.classList.remove('active');
}});
document.getElementById('filter-24h-updates').classList.add('active');
// Show only vendors with 24h updates, and switch to updates tab
let visibleCount = 0;
document.querySelectorAll('.vendor-section').forEach(section => {{
const vendorName = section.dataset.vendor;
const updates24hTab = section.querySelector('.status-tab[data-status="updates_24h"]');
if (updates24hTab) {{
// Check the tab text for count - format is "Yesterday's Updates (X)"
const tabText = updates24hTab.textContent.trim();
const match = tabText.match(/\((\d+)\)/);
const count = match ? parseInt(match[1]) : 0;
if (count > 0) {{
section.style.display = '';
visibleCount++;
// Switch to updates_24h tab for this vendor
switchStatusTab(updates24hTab, vendorName);
// Hide all other tab content except updates_24h
section.querySelectorAll('.status-tab-content').forEach(content => {{
if (content.dataset.status === 'updates_24h' && content.dataset.vendor === vendorName) {{
content.classList.add('active');
}} else {{
content.classList.remove('active');
}}
}});
}} else {{
section.style.display = 'none';
}}
}} else {{
section.style.display = 'none';
}}
}});
// Update results count
document.getElementById('results-count').textContent =
`Showing yesterday's update items from ${{visibleCount}} vendor(s)`;
}}
function showOnlyOldestUnaddressed() {{
// Remove active class from all quick filter buttons
document.querySelectorAll('.quick-filter-btn').forEach(btn => {{
btn.classList.remove('active');
}});
document.getElementById('filter-oldest-unaddressed').classList.add('active');
// Switch to "All Vendors" tab first
switchVendorTab('all');
// Show only vendors with oldest unaddressed items, and switch to oldest tab
let visibleCount = 0;
document.querySelectorAll('.vendor-section').forEach(section => {{
const vendorName = section.dataset.vendor;
const oldestTab = section.querySelector('.status-tab[data-status="oldest_unaddressed"]');
const oldestContent = section.querySelector('.status-tab-content[data-status="oldest_unaddressed"]');
if (oldestTab && oldestContent) {{
// Check the tab text for count - format is "Oldest Unaddressed (X)"
const tabText = oldestTab.textContent.trim();
// Use a simpler regex pattern that works reliably
const match = tabText.match(/\((\d+)\)/);
const count = match ? parseInt(match[1]) : 0;
// Also check if there are actual items in the content
const items = oldestContent.querySelectorAll('.item-list li:not(.empty)');
const hasItems = items.length > 0;
if (count > 0 && hasItems) {{
section.style.display = '';
visibleCount++;
// Switch to oldest_unaddressed tab for this vendor
switchStatusTab(oldestTab, vendorName);
// Hide all other tab content except oldest_unaddressed
section.querySelectorAll('.status-tab-content').forEach(content => {{
if (content.dataset.status === 'oldest_unaddressed' && content.dataset.vendor === vendorName) {{
content.classList.add('active');
}} else {{
content.classList.remove('active');
}}
}});
}} else {{
section.style.display = 'none';
}}
}} else {{
section.style.display = 'none';
}}
}});
// Update results count
document.getElementById('results-count').textContent =
`Showing oldest unaddressed items from ${{visibleCount}} vendor(s)`;
}}
function showAllVendors() {{
// Remove active class from all quick filter buttons
document.querySelectorAll('.quick-filter-btn').forEach(btn => {{
btn.classList.remove('active');
}});
document.getElementById('filter-all-vendors').classList.add('active');
// Show all vendor sections
document.querySelectorAll('.vendor-section').forEach(section => {{
section.style.display = '';
// Reset all tabs to "All" tab for each vendor
const vendorName = section.dataset.vendor;
const allTab = section.querySelector('.status-tab[data-status="all"]');
if (allTab) {{
switchStatusTab(allTab, vendorName);
}}
}});
// Clear results count or reapply filters if any are set
const searchText = document.getElementById('search-input').value;
const vendorFilter = document.getElementById('vendor-filter').value;
const statusFilter = document.getElementById('status-filter').value;
const priorityFilter = document.getElementById('priority-filter').value;
if (searchText || vendorFilter || statusFilter || priorityFilter) {{
applyFilters();
}} else {{
document.getElementById('results-count').textContent = '';
}}
}}
</script>
</body>
</html>"""
return html
def generate_vendor_sections(vendors: List[Dict]) -> str:
"""Generate HTML for all vendor sections."""
sections = []
for vendor in vendors:
sections.append(generate_vendor_section(vendor))
return "\n".join(sections)
def generate_vendor_section(vendor: Dict) -> str:
"""Generate HTML for a single vendor section."""
vendor_name = vendor.get('vendor_name', 'Unknown')
total_items = vendor.get('total_items', 0)
closed_count = vendor.get('closed_count', 0)
open_count = vendor.get('open_count', 0)
monitor_count = vendor.get('monitor_count', 0)
incomplete_count = vendor.get('incomplete_count', 0)
updates_24h = vendor.get('updates_24h', {})
oldest_unaddressed = vendor.get('oldest_unaddressed', [])
very_high_items = vendor.get('very_high_priority_items', [])
high_items = vendor.get('high_priority_items', [])
# Use the pre-populated status-grouped items from report_generator
# These contain ALL items, not just priority/oldest subsets
closed_items = vendor.get('closed_items', [])
monitor_items = vendor.get('monitor_items', [])
open_items = vendor.get('open_items', [])
incomplete_items = vendor.get('incomplete_items', [])
# Convert to lists if needed (they should already be lists)
if not isinstance(closed_items, list):
closed_items = []
if not isinstance(monitor_items, list):
monitor_items = []
if not isinstance(open_items, list):
open_items = []
if not isinstance(incomplete_items, list):
incomplete_items = []
# Group all items by priority for the "All" tab
# Combine all items first
all_items_combined = []
seen_names = set()
# Add all closed items
for item in closed_items:
name = item.get('punchlist_name', '')
if name and name not in seen_names:
seen_names.add(name)
all_items_combined.append(item)
# Add all monitor items
for item in monitor_items:
name = item.get('punchlist_name', '')
if name and name not in seen_names:
seen_names.add(name)
all_items_combined.append(item)
# Add all open items
for item in open_items:
name = item.get('punchlist_name', '')
if name and name not in seen_names:
seen_names.add(name)
all_items_combined.append(item)
# Add all incomplete items
for item in incomplete_items:
name = item.get('punchlist_name', '')
if name and name not in seen_names:
seen_names.add(name)
all_items_combined.append(item)
# Group items by priority level
very_high_all = []
high_all = []
medium_all = []
low_all = []
other_priority_all = []
no_priority_all = []
for item in all_items_combined:
priority = item.get('priority', '') or ''
priority_lower = priority.lower()
if 'very high' in priority_lower or '(1)' in priority_lower or 'very hgh' in priority_lower:
very_high_all.append(item)
elif ('high' in priority_lower and 'very' not in priority_lower) or '(2)' in priority_lower:
high_all.append(item)
elif 'medium' in priority_lower or '(3)' in priority_lower:
medium_all.append(item)
elif 'low' in priority_lower or '(4)' in priority_lower:
low_all.append(item)
elif priority.strip():
other_priority_all.append(item)
else:
no_priority_all.append(item)
html = f"""
<div class="vendor-section" data-vendor="{vendor_name}">
<div class="vendor-header">
<div class="vendor-name">{vendor_name}</div>
<div class="vendor-stats">
<div class="stat-item">
<div class="stat-value">{total_items}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #10b981;">{closed_count}</div>
<div class="stat-label">Closed</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #f59e0b;">{monitor_count}</div>
<div class="stat-label">Monitor</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #ef4444;">{open_count}</div>
<div class="stat-label">Open</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #dc2626;">{incomplete_count}</div>
<div class="stat-label">Incomplete</div>
</div>
</div>
</div>
<div class="vendor-content">
<div class="status-tabs">
<button class="status-tab active" onclick="switchStatusTab(this, '" + escape_js_string(vendor_name) + "')" data-status="all">All ({total_items})</button>
<button class="status-tab" onclick="switchStatusTab(this, '" + escape_js_string(vendor_name) + "')" data-status="updates_24h">Yesterday's Updates ({len(updates_24h.get('added', [])) + len(updates_24h.get('closed', [])) + len(updates_24h.get('changed_to_monitor', []))})</button>
<button class="status-tab" onclick="switchStatusTab(this, '" + escape_js_string(vendor_name) + "')" data-status="oldest_unaddressed">Oldest Unaddressed ({len(oldest_unaddressed)})</button>
<button class="status-tab" onclick="switchStatusTab(this, '" + escape_js_string(vendor_name) + "')" data-status="closed">Closed ({closed_count})</button>
<button class="status-tab" onclick="switchStatusTab(this, '" + escape_js_string(vendor_name) + "')" data-status="monitor">Monitor ({monitor_count})</button>
<button class="status-tab" onclick="switchStatusTab(this, '" + escape_js_string(vendor_name) + "')" data-status="open">Open ({open_count})</button>
<button class="status-tab" onclick="switchStatusTab(this, '" + escape_js_string(vendor_name) + "')" data-status="incomplete">Incomplete ({incomplete_count})</button>
</div>
<div class="status-tab-content active" data-status="all" data-vendor="{vendor_name}">
{generate_priority_items_section('Very High Priority', very_high_all)}
{generate_priority_items_section('High Priority', high_all)}
{generate_priority_items_section('Medium Priority', medium_all)}
{generate_priority_items_section('Low Priority', low_all)}
{generate_priority_items_section('Other Priority', other_priority_all) if other_priority_all else ''}
{generate_priority_items_section('No Priority', no_priority_all) if no_priority_all else ''}
{'' if (very_high_all or high_all or medium_all or low_all or other_priority_all or no_priority_all) else '<div class="section"><div class="section-title">All Items</div><ul class="item-list"><li class="empty">No items</li></ul></div>'}
</div>
<div class="status-tab-content" data-status="updates_24h" data-vendor="{vendor_name}">
{generate_24h_updates_section(updates_24h, vendor_name)}
</div>
<div class="status-tab-content" data-status="oldest_unaddressed" data-vendor="{vendor_name}">
{generate_oldest_unaddressed_section(oldest_unaddressed)}
</div>
<div class="status-tab-content" data-status="closed" data-vendor="{vendor_name}">
<div class="section">
<div class="section-title">Closed Items ({len(closed_items)})</div>
<ul class="item-list">
{''.join([generate_item_html(item) for item in closed_items]) if closed_items else '<li class="empty">No closed items</li>'}
</ul>
</div>
</div>
<div class="status-tab-content" data-status="monitor" data-vendor="{vendor_name}">
<div class="section">
<div class="section-title">Monitor Items ({len(monitor_items)})</div>
<ul class="item-list">
{''.join([generate_item_html(item) for item in monitor_items]) if monitor_items else '<li class="empty">No monitor items</li>'}
</ul>
</div>
</div>
<div class="status-tab-content" data-status="open" data-vendor="{vendor_name}">
<div class="section">
<div class="section-title">Open Items ({len(open_items)})</div>
<ul class="item-list">
{''.join([generate_item_html(item) for item in open_items]) if open_items else '<li class="empty">No open items</li>'}
</ul>
</div>
</div>
<div class="status-tab-content" data-status="incomplete" data-vendor="{vendor_name}">
<div class="section">
<div class="section-title">Incomplete Items ({len(incomplete_items)})</div>
<ul class="item-list">
{''.join([generate_item_html(item) for item in incomplete_items]) if incomplete_items else '<li class="empty">No incomplete items</li>'}
</ul>
</div>
</div>
</div>
</div>
"""
return html
def generate_24h_updates_section(updates_24h: Dict, vendor_name: str = '') -> str:
"""Generate HTML for yesterday's updates section with sub-tabs."""
added = updates_24h.get('added', [])
closed = updates_24h.get('closed', [])
changed_to_monitor = updates_24h.get('changed_to_monitor', [])
total_updates = len(added) + len(closed) + len(changed_to_monitor)
if total_updates == 0:
return """
<div class="section">
<div class="section-title">Yesterday's Updates</div>
<ul class="item-list">
<li class="empty">No updates from yesterday</li>
</ul>
</div>
"""
# Generate unique ID for this vendor's update tabs
vendor_name_safe = vendor_name.replace('/', '_').replace(' ', '_').replace('(', '').replace(')', '')
html = f"""
<div class="section">
<div class="section-title">Yesterday's Updates ({total_updates})</div>
<div class="updates-sub-tabs" style="margin-bottom: 20px;">
<button class="status-tab {'active' if added or (not added and not closed and not changed_to_monitor) else ''}" onclick="switchUpdateTab(this, '{vendor_name_safe}')" data-update-type="added">Added ({len(added)})</button>
<button class="status-tab {'active' if not added and closed else ''}" onclick="switchUpdateTab(this, '{vendor_name_safe}')" data-update-type="closed">Closed ({len(closed)})</button>
<button class="status-tab {'active' if not added and not closed and changed_to_monitor else ''}" onclick="switchUpdateTab(this, '{vendor_name_safe}')" data-update-type="monitor">Changed to Monitor ({len(changed_to_monitor)})</button>
</div>
<div class="update-tab-content {'active' if added or (not added and not closed and not changed_to_monitor) else ''}" data-update-type="added" data-vendor-update="{vendor_name_safe}">
<div class="section">
<div class="section-title">Items Added Yesterday ({len(added)})</div>
<ul class="item-list">
{''.join([generate_item_html(item) for item in added]) if added else '<li class="empty">No items added yesterday</li>'}
</ul>
</div>
</div>
<div class="update-tab-content" data-update-type="closed" data-vendor-update="{vendor_name_safe}">
<div class="section">
<div class="section-title">Items Closed Yesterday ({len(closed)})</div>
<ul class="item-list">
{''.join([generate_item_html(item) for item in closed]) if closed else '<li class="empty">No items closed yesterday</li>'}
</ul>
</div>
</div>
<div class="update-tab-content" data-update-type="monitor" data-vendor-update="{vendor_name_safe}">
<div class="section">
<div class="section-title">Items Changed to Monitor Yesterday ({len(changed_to_monitor)})</div>
<ul class="item-list">
{''.join([generate_item_html(item) for item in changed_to_monitor]) if changed_to_monitor else '<li class="empty">No items changed to monitor yesterday</li>'}
</ul>
</div>
</div>
</div>
"""
return html
def generate_oldest_unaddressed_section(items: List[Dict]) -> str:
"""Generate HTML for oldest unaddressed items section."""
if not items:
return """
<div class="section">
<div class="section-title">Oldest 3 Unaddressed Items</div>
<ul class="item-list">
<li class="empty">No unaddressed items</li>
</ul>
</div>
"""
html = f"""
<div class="section">
<div class="section-title">Oldest 3 Unaddressed Items ({len(items)})</div>
<ul class="item-list">
"""
for item in items:
html += generate_item_html(item, show_age=True)
html += """
</ul>
</div>
"""
return html
def generate_priority_items_section(title: str, items: List[Dict]) -> str:
"""Generate HTML for priority items section."""
if not items:
return ""
html = f"""
<div class="section">
<div class="section-title">{title} ({len(items)} items)</div>
<ul class="item-list">
"""
for item in items:
html += generate_item_html(item)
html += """
</ul>
</div>
"""
return html
def generate_item_html(item: Dict, show_age: bool = False) -> str:
"""Generate HTML for a single item."""
name = item.get('punchlist_name', 'Unknown')
description = item.get('description', '')
priority = item.get('priority', '')
status = item.get('status', 'Unknown')
date_identified = item.get('date_identified', '')
date_completed = item.get('date_completed', '')
status_updates = item.get('status_updates', '')
issue_image = item.get('issue_image', '')
age_days = item.get('age_days')
status_class = get_status_badge_class(status)
priority_class = get_priority_badge_class(priority)
html = f"""
<li>
<div class="item-header">
<div class="item-name">{name}</div>
<div class="badges">
<span class="badge {status_class}">{status}</span>
{f'<span class="badge {priority_class}">{priority}</span>' if priority else ''}
{f'<span class="age-days">{age_days} days</span>' if show_age and age_days is not None else ''}
</div>
</div>
<div class="item-details">
{f'<p class="item-description"><strong>Description:</strong> {description}</p>' if description else ''}
{f'<p><strong>Date Identified:</strong> {format_date(date_identified)}</p>' if date_identified else ''}
{f'<p><strong>Date Completed:</strong> {format_date(date_completed)}</p>' if date_completed else ''}
{f'<p><strong>Status Updates:</strong> {status_updates}</p>' if status_updates else ''}
{f'<p><strong>Image:</strong> {issue_image}</p>' if issue_image else ''}
</div>
</li>
"""
return html
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Generate HTML report from JSON")
parser.add_argument(
"--json",
type=str,
required=True,
help="Path to JSON report file"
)
parser.add_argument(
"--output",
type=str,
default=None,
help="Output HTML file path (defaults to same location as JSON with .html extension)"
)
args = parser.parse_args()
output_path = generate_html_report(args.json, args.output)
print(f"✓ HTML report generated: {output_path}")