1525 lines
56 KiB
Python
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}")
|