AdminAnalytics/src/admin_analytics/dashboard/pages/compensation.py

226 lines
8.2 KiB
Python

"""Page 2: Executive Compensation."""
import dash
import duckdb
from dash import html, dcc, Input, Output, dash_table
import plotly.express as px
import plotly.graph_objects as go
from admin_analytics.dashboard.queries import (
query_top_earners,
query_comp_by_role,
query_comp_vs_cpi,
query_comp_cagr,
query_aggregate_comp,
query_aggregate_comp_cagr,
)
_NO_DATA = html.Div(
"No IRS 990 data loaded. Run: admin-analytics ingest irs990",
style={"textAlign": "center", "padding": "40px", "color": "#888"},
)
# Roles to highlight in trend chart
_KEY_ROLES = ["PRESIDENT", "PROVOST", "VP_FINANCE", "VP_RESEARCH", "VP_ADVANCEMENT", "CFO"]
def _kpi_card(title: str, value: str, subtitle: str = "") -> html.Div:
return html.Div(
[
html.H4(title, style={"margin": "0", "color": "#666", "fontSize": "14px"}),
html.H2(value, style={"margin": "5px 0", "color": "#00539F"}),
html.P(subtitle, style={"margin": "0", "color": "#999", "fontSize": "12px"}),
],
style={
"flex": "1",
"padding": "20px",
"backgroundColor": "#f8f9fa",
"borderRadius": "8px",
"textAlign": "center",
"margin": "0 8px",
},
)
def layout(conn: duckdb.DuckDBPyConnection):
all_earners = query_top_earners(conn)
if all_earners.height == 0:
return _NO_DATA
years = sorted(all_earners["tax_year"].unique().to_list())
year_options = [{"label": "All Years", "value": "all"}] + [
{"label": str(y), "value": y} for y in years
]
# KPI cards
cagr = query_comp_cagr(conn)
agg_cagr = query_aggregate_comp_cagr(conn)
kpi_cards = []
if cagr:
kpi_cards.append(_kpi_card(
"President Compensation",
f"${cagr['end_comp']:,}",
f"Tax year {cagr['end_year']}",
))
kpi_cards.append(_kpi_card(
"President CAGR",
f"{cagr['cagr_pct']}%",
f"Annualized growth, {cagr['start_year']}-{cagr['end_year']}",
))
if agg_cagr:
kpi_cards.append(_kpi_card(
"Top-10 Total Compensation",
f"${agg_cagr['end_comp']:,}",
f"Tax year {agg_cagr['end_year']}",
))
kpi_cards.append(_kpi_card(
"Top-10 CAGR",
f"{agg_cagr['cagr_pct']}%",
f"Annualized growth, {agg_cagr['start_year']}-{agg_cagr['end_year']}",
))
kpi_row = html.Div(kpi_cards, style={"display": "flex", "marginBottom": "24px"}) if kpi_cards else html.Div()
# Compensation by role trend
role_df = query_comp_by_role(conn)
role_fig = go.Figure()
if role_df.height > 0:
role_pd = role_df.to_pandas()
for role in _KEY_ROLES:
subset = role_pd[role_pd["canonical_role"] == role]
if len(subset) > 0:
role_fig.add_trace(go.Scatter(
x=subset["tax_year"],
y=subset["total_compensation"],
mode="lines+markers",
name=role.replace("_", " ").title(),
))
role_fig.update_layout(
title="Compensation Trends by Role",
xaxis_title="Tax Year", yaxis_title="Total Compensation ($)",
template="plotly_white", height=420,
)
# Comp vs CPI indexed
cpi_df = query_comp_vs_cpi(conn)
cpi_fig = go.Figure()
if cpi_df.height > 0:
cpi_pd = cpi_df.to_pandas()
cpi_fig.add_trace(go.Scatter(
x=cpi_pd["year"], y=cpi_pd["comp_index"],
mode="lines+markers", name="Top Compensation",
line={"color": "#00539F"},
))
cpi_fig.add_trace(go.Scatter(
x=cpi_pd["year"], y=cpi_pd["agg_index"],
mode="lines+markers", name="Top-10 Aggregate",
line={"color": "#E07A5F"},
))
cpi_fig.add_trace(go.Scatter(
x=cpi_pd["year"], y=cpi_pd["cpi_index"],
mode="lines+markers", name="CPI-U",
line={"color": "#FFD200", "dash": "dash"},
))
cpi_fig.update_layout(
title="Compensation vs CPI-U (Indexed, Base Year = 100)",
xaxis_title="Year", yaxis_title="Index",
template="plotly_white", height=380,
)
return html.Div([
kpi_row,
html.Div(
[
html.Label("Filter by Tax Year: ", style={"fontWeight": "bold"}),
dcc.Dropdown(
id="comp-year-dropdown",
options=year_options,
value="all",
style={"width": "200px", "display": "inline-block"},
),
],
style={"marginBottom": "16px"},
),
dash_table.DataTable(
id="comp-table",
columns=[
{"name": "Year", "id": "tax_year"},
{"name": "Name", "id": "person_name"},
{"name": "Title", "id": "title"},
{"name": "Role", "id": "canonical_role"},
{"name": "Base", "id": "base_compensation", "type": "numeric",
"format": dash_table.Format.Format().group(True)},
{"name": "Bonus", "id": "bonus_compensation", "type": "numeric",
"format": dash_table.Format.Format().group(True)},
{"name": "Total", "id": "total_compensation", "type": "numeric",
"format": dash_table.Format.Format().group(True)},
],
data=all_earners.to_pandas().to_dict("records"),
page_size=15,
sort_action="native",
filter_action="native",
style_table={"overflowX": "auto"},
style_cell={"textAlign": "left", "padding": "8px", "fontSize": "13px"},
style_header={"fontWeight": "bold", "backgroundColor": "#f0f0f0"},
),
html.Div(
[
html.Div(dcc.Graph(id="comp-breakdown-chart"), style={"flex": "1"}),
html.Div(dcc.Graph(figure=cpi_fig), style={"flex": "1"}),
],
style={"display": "flex", "gap": "16px", "marginTop": "16px"},
),
dcc.Graph(figure=role_fig),
])
def register_callbacks(app: dash.Dash, conn: duckdb.DuckDBPyConnection) -> None:
"""Register interactive callbacks for the compensation page."""
@app.callback(
[Output("comp-table", "data"), Output("comp-breakdown-chart", "figure")],
Input("comp-year-dropdown", "value"),
)
def update_compensation(year_value):
year = None if year_value == "all" else int(year_value)
earners = query_top_earners(conn, year=year)
# Table data
table_data = earners.to_pandas().to_dict("records") if earners.height > 0 else []
# Breakdown chart — stacked bar of comp components
breakdown_fig = go.Figure()
if earners.height > 0:
ep = earners.to_pandas().head(10) # top 10 by total comp
_SUFFIXES = {"JR", "SR", "II", "III", "IV", "JR.", "SR."}
def _short_name(n):
if "," in n:
return n.split(",")[0][:20]
parts = n.split()
while len(parts) > 1 and parts[-1].upper().rstrip(".") in _SUFFIXES:
parts.pop()
return parts[-1][:20] if parts else n[:20]
short_names = [_short_name(n) for n in ep["person_name"]]
for comp_type, label, color in [
("base_compensation", "Base", "#00539F"),
("bonus_compensation", "Bonus", "#FFD200"),
("deferred_compensation", "Deferred", "#7FB069"),
("nontaxable_benefits", "Benefits", "#E07A5F"),
("other_compensation", "Other", "#999"),
]:
if comp_type in ep.columns:
breakdown_fig.add_trace(go.Bar(
x=short_names, y=ep[comp_type],
name=label, marker_color=color,
))
breakdown_fig.update_layout(barmode="stack")
title_suffix = f" ({year})" if year else " (All Years)"
breakdown_fig.update_layout(
title=f"Compensation Breakdown — Top 10{title_suffix}",
xaxis_title="", yaxis_title="$",
template="plotly_white", height=380,
)
return table_data, breakdown_fig