EastSync-AI / UI /render_plan_html.py
Daniel Tatar
Cv reader + matching project (#13)
07273d8
from __future__ import annotations
import html
import json
from typing import Any
# ==========================================
# HELPER: ENTERPRISE REPORT RENDERER
# ==========================================
def render_plan_html(result: Any) -> str:
"""
Renders the analysis payload into a High-Contrast Enterprise Grid.
"""
# --- INTERNAL CSS FOR THE REPORT ---
# (CSS remains unchanged to preserve the requested theme)
css = """
<style>
/* FORCE ROBUST CONTAINER HEIGHT */
.sec-report-wrapper {
width: 100%;
min-height: 70vh; /* Force container to take up significant screen space */
display: flex;
flex-direction: column;
}
.sec-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); /* Wider cards for better readability */
gap: 20px;
padding: 10px 0;
flex-grow: 1; /* Allow grid to expand */
}
/* CARD STYLING */
.sec-card {
background-color: var(--bg-card);
border: 1px solid var(--border-dim);
border-left: 4px solid var(--arc-orange); /* Default tactical color */
display: flex;
flex-direction: column;
position: relative;
transition: border-color 0.2s;
}
.sec-card:hover {
border-color: var(--arc-yellow);
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
}
/* HEADER */
.sec-card-header {
background: rgba(255,255,255,0.03);
padding: 16px 20px;
border-bottom: 1px solid var(--border-dim);
display: flex;
justify-content: space-between;
align-items: center;
}
.sec-role-badge {
font-size: 12px;
font-weight: 700;
color: var(--text-main);
letter-spacing: 1px;
text-transform: uppercase;
opacity: 0.9;
}
/* BODY */
.sec-card-body {
padding: 20px;
flex-grow: 1;
}
.sec-name {
font-family: var(--font-header);
font-size: 20px; /* Larger Name */
font-weight: 700;
color: var(--text-main);
margin-bottom: 4px;
letter-spacing: 0.5px;
}
.sec-id-tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--arc-orange);
margin-bottom: 20px;
opacity: 1.0;
}
/* SKILL GAPS (Binary State) */
.sec-skill-container {
background: rgba(255, 42, 42, 0.08); /* Red tint */
border: 1px dashed var(--arc-red);
padding: 12px 16px;
margin-bottom: 20px;
border-radius: 4px;
}
.sec-skill-label {
font-size: 12px;
color: var(--arc-red);
font-weight: 700;
text-transform: uppercase;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.sec-skill-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sec-skill-tag {
font-family: var(--font-mono);
font-size: 12px;
background: var(--bg-void);
border: 1px solid var(--arc-red);
color: var(--text-main);
padding: 6px 10px;
display: flex;
align-items: center;
gap: 6px;
}
.sec-skill-icon {
width: 8px; height: 8px; background: var(--arc-red); border-radius: 50%;
}
/* TRAINING PLAN */
.sec-plan-section {
border-top: 1px solid var(--border-dim);
padding-top: 16px;
}
.sec-section-title {
font-size: 12px;
color: var(--arc-yellow);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
font-weight: 700;
}
.sec-item {
display: flex;
background: var(--bg-panel);
border-left: 3px solid var(--text-dim);
margin-bottom: 10px;
padding: 12px;
align-items: flex-start;
}
/* Cost Indicators */
.inv-low { border-left-color: var(--arc-green); }
.inv-med { border-left-color: var(--arc-yellow); }
.inv-high { border-left-color: var(--arc-red); }
.sec-item-details { flex-grow: 1; }
.sec-item-title { font-size: 14px; color: #fff; font-weight: 600; margin-bottom: 4px; line-height: 1.4;}
.sec-item-meta { font-size: 12px; color: var(--text-main); font-family: var(--font-mono); opacity: 0.9; }
/* STATS BAR */
.sec-stats-bar {
display: flex;
gap: 20px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border-dim);
padding: 12px 20px;
margin-bottom: 20px;
align-items: center;
}
.sec-stat-group {
display: flex;
flex-direction: column;
}
.sec-stat-label {
font-size: 11px;
color: var(--text-main);
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
opacity: 0.9;
}
.sec-stat-value {
font-size: 18px;
color: var(--text-main);
font-weight: 700;
font-family: var(--font-mono);
}
.sec-divider {
width: 1px;
height: 24px;
background: var(--border-dim);
}
/* LOADING / ERROR STATES */
.sec-status-offline {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 14px;
padding: 20px;
min-height: 70vh; /* Use VH to ensure it fills screen vertical space */
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.01);
border: 1px dashed var(--border-dim);
flex-grow: 1;
}
.sec-terminal-text {
color: var(--arc-red);
font-family: var(--font-mono);
font-size: 14px;
padding: 20px;
min-height: 70vh; /* Consistent height for error states */
}
</style>
"""
if result is None:
return f"{css}<div class='sec-report-wrapper'><div class='sec-status-offline'>// WAITING FOR ANALYSIS DATA</div></div>"
# Ensure data is dict; handle string inputs gracefully
data = result
if not isinstance(data, dict):
try:
data = json.loads(str(result))
except (json.JSONDecodeError, TypeError):
# Fallback for non-JSON strings
return f"{css}<div class='sec-report-wrapper'><div class='sec-terminal-text'>{html.escape(str(result))}</div></div>"
project_name = html.escape(str(data.get("project_name", "UNDEFINED PROJECT")).upper())
team = data.get("team", [])
# --- CALCULATE AGGREGATE STATS ---
total_cost = 0.0
max_duration = 0.0
for member in team:
member_cost = 0.0
member_duration = 0.0
for plan in member.get("training_plan", []):
try:
member_cost += float(plan.get("cost", 0))
member_duration += float(plan.get("duration_hours", 0))
except (ValueError, TypeError):
continue
total_cost += member_cost
# Parallel training assumption: Project duration is limited by the person with the longest plan
if member_duration > max_duration:
max_duration = member_duration
# --- BUILD HTML ---
html_parts = [css, "<div class='sec-report-wrapper'>"]
# --- MAIN HEADER ---
html_parts.append(f"""
<div style="margin-bottom: 16px; padding: 0 4px; display:flex; flex-wrap: wrap; justify-content:space-between; align-items:flex-end; border-bottom: 1px solid var(--border-dim); padding-bottom: 12px; gap: 10px;">
<div>
<h2 style="margin:0; color:white; font-size:24px; letter-spacing: 0.5px;">CAPABILITY ANALYSIS REPORT</h2>
<div style="color:var(--arc-yellow); font-family:var(--font-mono); font-size: 14px; margin-top:6px;">PROJECT: {project_name}</div>
</div>
<div style="font-family: var(--font-mono); font-size: 12px; color: var(--text-main); text-align: right;">
<div>STATUS: <span style="color:var(--arc-green)">ACTIVE</span></div>
<div>HEADCOUNT: {len(team)}</div>
</div>
</div>
""")
# --- OVERALL STATS BAR ---
html_parts.append(f"""
<div class="sec-stats-bar">
<div class="sec-stat-group">
<div class="sec-stat-label">Total Budget</div>
<div class="sec-stat-value" style="color: var(--arc-cyan);">${total_cost:,.2f}</div>
</div>
<div class="sec-divider"></div>
<div class="sec-stat-group">
<div class="sec-stat-label">Est. Timeline</div>
<div class="sec-stat-value" style="color: var(--arc-orange);">{max_duration:.0f} HRS <span style="font-size:11px; color:var(--text-main); opacity:0.8;">(CONCURRENT)</span></div>
</div>
<div class="sec-divider"></div>
<div class="sec-stat-group">
<div class="sec-stat-label">Team Size</div>
<div class="sec-stat-value">{len(team)}</div>
</div>
</div>
""")
html_parts.append("<div class='sec-grid'>")
for member in team:
name = html.escape(str(member.get("employee_name", "UNKNOWN")).upper())
role = html.escape(str(member.get("role", "UNASSIGNED")).upper())
# --- SKILL GAPS ---
skills_html = ""
gaps_list = member.get("skills_gaps", [])
if gaps_list:
skill_tags = ""
for gap_data in gaps_list:
skill_name = html.escape(gap_data.get("skill", "GENERIC"))
skill_tags += f"""
<div class="sec-skill-tag">
<div class="sec-skill-icon"></div>
{skill_name}
</div>
"""
skills_html = f"""
<div class="sec-skill-container">
<div class="sec-skill-label">⚠ SKILL GAP IDENTIFIED</div>
<div class="sec-skill-tags">{skill_tags}</div>
</div>
"""
else:
skills_html = """
<div style="padding: 12px; border: 1px solid var(--arc-green); color: var(--arc-green); font-size: 12px; display:flex; align-items:center; gap:8px; margin-bottom:20px; border-radius:4px;">
<span>✔</span> SKILLS VERIFIED. NO GAPS.
</div>
"""
# --- TRAINING PLAN ---
training_html = ""
for plan in member.get("training_plan", []):
title = html.escape(plan.get("title", "Training Module"))
cost = float(plan.get("cost", 0))
hours = float(plan.get("duration_hours", 0))
# Investment Level Logic
inv_class = "inv-low"
if cost > 50: inv_class = "inv-med"
if cost > 200: inv_class = "inv-high"
training_html += f"""
<div class="sec-item {inv_class}">
<div class="sec-item-details">
<div class="sec-item-title">{title}</div>
<div class="sec-item-meta">COST: ${cost} | DURATION: {hours} HRS</div>
</div>
</div>
"""
if not training_html and gaps_list:
training_html = "<div style='font-size:12px; color:var(--arc-red); padding: 4px 0;'>PENDING: CURRICULUM GENERATION REQUIRED</div>"
elif not training_html:
training_html = "<div style='font-size:12px; color:var(--text-main); opacity:0.9; padding: 4px 0;'>NO ACTION REQUIRED</div>"
# Assemble Card
# Create a faux Employee ID
emp_hash = f"EMP-{hash(name) % 99999:05d}"
card_html = f"""
<div class="sec-card">
<div class="sec-card-header">
<div style="display:flex; align-items:center; gap:8px;">
<div style="width:8px; height:8px; background:var(--arc-orange);"></div>
<div class="sec-role-badge">{role}</div>
</div>
</div>
<div class="sec-card-body">
<div class="sec-name">{name}</div>
<div class="sec-id-tag">{emp_hash}</div>
{skills_html}
<div class="sec-plan-section">
<div class="sec-section-title">RECOMMENDED TRAINING</div>
{training_html}
</div>
</div>
</div>
"""
html_parts.append(card_html)
html_parts.append("</div>")
html_parts.append("</div>") # Close sec-report-wrapper
return "".join(html_parts)