Make dashboard usable on mobile
Move inline styles into assets/style.css with wrapping KPI and chart rows, a horizontally scrolling tab bar, and phone breakpoints. Shared components put chart titles in HTML headings, legends above plots, and disable drag-to-zoom so swiping scrolls the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X9jG4CUFcH6n4vFvgtwndY
This commit is contained in:
parent
8916d10b63
commit
eb489a782d
9 changed files with 319 additions and 196 deletions
|
|
@ -18,7 +18,7 @@ def create_app() -> dash.Dash:
|
||||||
[
|
[
|
||||||
html.H1(
|
html.H1(
|
||||||
"University of Delaware — Administrative Analytics",
|
"University of Delaware — Administrative Analytics",
|
||||||
style={"textAlign": "center", "padding": "20px", "color": "#00539F"},
|
className="app-title",
|
||||||
),
|
),
|
||||||
dcc.Tabs(
|
dcc.Tabs(
|
||||||
id="tabs",
|
id="tabs",
|
||||||
|
|
@ -32,11 +32,12 @@ def create_app() -> dash.Dash:
|
||||||
dcc.Tab(label="Current Headcount", value="headcount"),
|
dcc.Tab(label="Current Headcount", value="headcount"),
|
||||||
dcc.Tab(label="About", value="about"),
|
dcc.Tab(label="About", value="about"),
|
||||||
],
|
],
|
||||||
style={"marginBottom": "20px"},
|
className="tabs-bar",
|
||||||
|
mobile_breakpoint=0,
|
||||||
),
|
),
|
||||||
html.Div(id="tab-content", style={"padding": "0 20px 20px 20px"}),
|
html.Div(id="tab-content", className="tab-content"),
|
||||||
],
|
],
|
||||||
style={"fontFamily": "system-ui, -apple-system, sans-serif", "maxWidth": "1400px", "margin": "0 auto"},
|
className="app",
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.callback(Output("tab-content", "children"), Input("tabs", "value"))
|
@app.callback(Output("tab-content", "children"), Input("tabs", "value"))
|
||||||
|
|
|
||||||
159
src/admin_analytics/dashboard/assets/style.css
Normal file
159
src/admin_analytics/dashboard/assets/style.css
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/* Served automatically by Dash from the assets/ folder. */
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
text-align: center;
|
||||||
|
color: #00539F;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs: one horizontally scrollable row at every width, instead of the
|
||||||
|
vertical stack dcc.Tabs switches to on small screens. */
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar .tab {
|
||||||
|
flex: 1 0 auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding: 0 20px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI cards wrap onto multiple lines rather than squeezing. */
|
||||||
|
.kpi-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card h2 {
|
||||||
|
margin: 5px 0;
|
||||||
|
color: #00539F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Side-by-side charts stack once each would be narrower than ~450px. */
|
||||||
|
.chart-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-row > * {
|
||||||
|
flex: 1 1 450px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
margin: 16px 0 0 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2a3f5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .dash-dropdown {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.app-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding: 0 12px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-row {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
flex-basis: 140px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts are view-only on phones; the toolbar overlaps titles. */
|
||||||
|
.modebar-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown tables (About page) scroll within themselves instead of
|
||||||
|
widening the page. */
|
||||||
|
.tab-content table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
69
src/admin_analytics/dashboard/components.py
Normal file
69
src/admin_analytics/dashboard/components.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""Shared layout components for dashboard pages.
|
||||||
|
|
||||||
|
Layout classes (kpi-row, chart-row, chart-title, ...) are styled in
|
||||||
|
assets/style.css, which handles the responsive wrapping for narrow screens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
from dash import html, dcc
|
||||||
|
|
||||||
|
GRAPH_CONFIG = {"displaylogo": False, "responsive": True}
|
||||||
|
|
||||||
|
|
||||||
|
def no_data(message: str) -> html.Div:
|
||||||
|
return html.Div(message, className="no-data")
|
||||||
|
|
||||||
|
|
||||||
|
def kpi_card(title: str, value: str, subtitle: str = "") -> html.Div:
|
||||||
|
children = [html.H4(title), html.H2(value)]
|
||||||
|
if subtitle:
|
||||||
|
children.append(html.P(subtitle))
|
||||||
|
return html.Div(children, className="kpi-card")
|
||||||
|
|
||||||
|
|
||||||
|
def kpi_row(cards: list) -> html.Div:
|
||||||
|
return html.Div(cards, className="kpi-row")
|
||||||
|
|
||||||
|
|
||||||
|
def chart_row(*items) -> html.Div:
|
||||||
|
"""Place charts side by side on wide screens; they stack on narrow ones."""
|
||||||
|
return html.Div(list(items), className="chart-row")
|
||||||
|
|
||||||
|
|
||||||
|
def style_figure(fig: go.Figure) -> go.Figure:
|
||||||
|
"""Apply layout defaults that read well at both phone and desktop widths.
|
||||||
|
|
||||||
|
The legend sits horizontally above the plot area so it never steals width
|
||||||
|
from the data, and drag-to-zoom is off so swiping on a phone scrolls the
|
||||||
|
page instead of zooming the chart (zoom remains available in the modebar).
|
||||||
|
"""
|
||||||
|
fig.update_layout(
|
||||||
|
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
|
||||||
|
margin={"l": 50, "r": 16, "t": 24, "b": 40},
|
||||||
|
dragmode=False,
|
||||||
|
)
|
||||||
|
fig.update_yaxes(automargin=True, title_standoff=8)
|
||||||
|
fig.update_xaxes(automargin=True)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def graph_style(height: int | None) -> dict:
|
||||||
|
"""Pin the graph container to the figure height.
|
||||||
|
|
||||||
|
With responsive=True the plot fills its container, so the container needs
|
||||||
|
an explicit height or flex layouts collapse it to nothing.
|
||||||
|
"""
|
||||||
|
return {"height": f"{height or 450}px"}
|
||||||
|
|
||||||
|
|
||||||
|
def graph(fig: go.Figure, **kwargs) -> html.Div:
|
||||||
|
"""Render a figure with its title as an HTML heading.
|
||||||
|
|
||||||
|
HTML headings wrap on narrow screens; Plotly titles get clipped instead.
|
||||||
|
"""
|
||||||
|
title = fig.layout.title.text
|
||||||
|
fig.update_layout(title=None)
|
||||||
|
style_figure(fig)
|
||||||
|
children = [html.H3(title, className="chart-title")] if title else []
|
||||||
|
children.append(dcc.Graph(figure=fig, config=GRAPH_CONFIG, style=graph_style(fig.layout.height), **kwargs))
|
||||||
|
return html.Div(children, className="chart")
|
||||||
|
|
@ -6,6 +6,16 @@ from dash import html, dcc, Input, Output, dash_table
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
from admin_analytics.dashboard.components import (
|
||||||
|
GRAPH_CONFIG,
|
||||||
|
graph,
|
||||||
|
graph_style,
|
||||||
|
kpi_card,
|
||||||
|
kpi_row,
|
||||||
|
chart_row,
|
||||||
|
no_data,
|
||||||
|
style_figure,
|
||||||
|
)
|
||||||
from admin_analytics.dashboard.queries import (
|
from admin_analytics.dashboard.queries import (
|
||||||
query_top_earners,
|
query_top_earners,
|
||||||
query_comp_by_role,
|
query_comp_by_role,
|
||||||
|
|
@ -15,33 +25,12 @@ from admin_analytics.dashboard.queries import (
|
||||||
query_aggregate_comp_cagr,
|
query_aggregate_comp_cagr,
|
||||||
)
|
)
|
||||||
|
|
||||||
_NO_DATA = html.Div(
|
_NO_DATA = no_data("No IRS 990 data loaded. Run: admin-analytics ingest irs990")
|
||||||
"No IRS 990 data loaded. Run: admin-analytics ingest irs990",
|
|
||||||
style={"textAlign": "center", "padding": "40px", "color": "#888"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Roles to highlight in trend chart
|
# Roles to highlight in trend chart
|
||||||
_KEY_ROLES = ["PRESIDENT", "PROVOST", "VP_FINANCE", "VP_RESEARCH", "VP_ADVANCEMENT", "CFO"]
|
_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):
|
def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
all_earners = query_top_earners(conn)
|
all_earners = query_top_earners(conn)
|
||||||
if all_earners.height == 0:
|
if all_earners.height == 0:
|
||||||
|
|
@ -57,28 +46,28 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
agg_cagr = query_aggregate_comp_cagr(conn)
|
agg_cagr = query_aggregate_comp_cagr(conn)
|
||||||
kpi_cards = []
|
kpi_cards = []
|
||||||
if cagr:
|
if cagr:
|
||||||
kpi_cards.append(_kpi_card(
|
kpi_cards.append(kpi_card(
|
||||||
"President Compensation",
|
"President Compensation",
|
||||||
f"${cagr['end_comp']:,}",
|
f"${cagr['end_comp']:,}",
|
||||||
f"Tax year {cagr['end_year']}",
|
f"Tax year {cagr['end_year']}",
|
||||||
))
|
))
|
||||||
kpi_cards.append(_kpi_card(
|
kpi_cards.append(kpi_card(
|
||||||
"President CAGR",
|
"President CAGR",
|
||||||
f"{cagr['cagr_pct']}%",
|
f"{cagr['cagr_pct']}%",
|
||||||
f"Annualized growth, {cagr['start_year']}-{cagr['end_year']}",
|
f"Annualized growth, {cagr['start_year']}-{cagr['end_year']}",
|
||||||
))
|
))
|
||||||
if agg_cagr:
|
if agg_cagr:
|
||||||
kpi_cards.append(_kpi_card(
|
kpi_cards.append(kpi_card(
|
||||||
"Top-10 Total Compensation",
|
"Top-10 Total Compensation",
|
||||||
f"${agg_cagr['end_comp']:,}",
|
f"${agg_cagr['end_comp']:,}",
|
||||||
f"Tax year {agg_cagr['end_year']}",
|
f"Tax year {agg_cagr['end_year']}",
|
||||||
))
|
))
|
||||||
kpi_cards.append(_kpi_card(
|
kpi_cards.append(kpi_card(
|
||||||
"Top-10 CAGR",
|
"Top-10 CAGR",
|
||||||
f"{agg_cagr['cagr_pct']}%",
|
f"{agg_cagr['cagr_pct']}%",
|
||||||
f"Annualized growth, {agg_cagr['start_year']}-{agg_cagr['end_year']}",
|
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()
|
kpis = kpi_row(kpi_cards) if kpi_cards else html.Div()
|
||||||
|
|
||||||
# Compensation by role trend
|
# Compensation by role trend
|
||||||
role_df = query_comp_by_role(conn)
|
role_df = query_comp_by_role(conn)
|
||||||
|
|
@ -127,7 +116,7 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
)
|
)
|
||||||
|
|
||||||
return html.Div([
|
return html.Div([
|
||||||
kpi_row,
|
kpis,
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
html.Label("Filter by Tax Year: ", style={"fontWeight": "bold"}),
|
html.Label("Filter by Tax Year: ", style={"fontWeight": "bold"}),
|
||||||
|
|
@ -135,10 +124,10 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
id="comp-year-dropdown",
|
id="comp-year-dropdown",
|
||||||
options=year_options,
|
options=year_options,
|
||||||
value="all",
|
value="all",
|
||||||
style={"width": "200px", "display": "inline-block"},
|
clearable=False,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
style={"marginBottom": "16px"},
|
className="filter-bar",
|
||||||
),
|
),
|
||||||
dash_table.DataTable(
|
dash_table.DataTable(
|
||||||
id="comp-table",
|
id="comp-table",
|
||||||
|
|
@ -162,14 +151,17 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
style_cell={"textAlign": "left", "padding": "8px", "fontSize": "13px"},
|
style_cell={"textAlign": "left", "padding": "8px", "fontSize": "13px"},
|
||||||
style_header={"fontWeight": "bold", "backgroundColor": "#f0f0f0"},
|
style_header={"fontWeight": "bold", "backgroundColor": "#f0f0f0"},
|
||||||
),
|
),
|
||||||
html.Div(
|
chart_row(
|
||||||
[
|
html.Div(
|
||||||
html.Div(dcc.Graph(id="comp-breakdown-chart", config={"displaylogo": False}), style={"flex": "1"}),
|
[
|
||||||
html.Div(dcc.Graph(figure=cpi_fig, config={"displaylogo": False}), style={"flex": "1"}),
|
html.H3(id="comp-breakdown-title", className="chart-title"),
|
||||||
],
|
dcc.Graph(id="comp-breakdown-chart", config=GRAPH_CONFIG, style=graph_style(380)),
|
||||||
style={"display": "flex", "gap": "16px", "marginTop": "16px"},
|
],
|
||||||
|
className="chart",
|
||||||
|
),
|
||||||
|
graph(cpi_fig),
|
||||||
),
|
),
|
||||||
dcc.Graph(figure=role_fig, config={"displaylogo": False}),
|
graph(role_fig),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -177,7 +169,11 @@ def register_callbacks(app: dash.Dash, conn: duckdb.DuckDBPyConnection) -> None:
|
||||||
"""Register interactive callbacks for the compensation page."""
|
"""Register interactive callbacks for the compensation page."""
|
||||||
|
|
||||||
@app.callback(
|
@app.callback(
|
||||||
[Output("comp-table", "data"), Output("comp-breakdown-chart", "figure")],
|
[
|
||||||
|
Output("comp-table", "data"),
|
||||||
|
Output("comp-breakdown-chart", "figure"),
|
||||||
|
Output("comp-breakdown-title", "children"),
|
||||||
|
],
|
||||||
Input("comp-year-dropdown", "value"),
|
Input("comp-year-dropdown", "value"),
|
||||||
)
|
)
|
||||||
def update_compensation(year_value):
|
def update_compensation(year_value):
|
||||||
|
|
@ -218,9 +214,9 @@ def register_callbacks(app: dash.Dash, conn: duckdb.DuckDBPyConnection) -> None:
|
||||||
|
|
||||||
title_suffix = f" ({year})" if year else " (All Years)"
|
title_suffix = f" ({year})" if year else " (All Years)"
|
||||||
breakdown_fig.update_layout(
|
breakdown_fig.update_layout(
|
||||||
title=f"Compensation Breakdown — Top 10{title_suffix}",
|
|
||||||
xaxis_title="", yaxis_title="$",
|
xaxis_title="", yaxis_title="$",
|
||||||
template="plotly_white", height=380,
|
template="plotly_white", height=380,
|
||||||
)
|
)
|
||||||
|
style_figure(breakdown_fig)
|
||||||
|
|
||||||
return table_data, breakdown_fig
|
return table_data, breakdown_fig, f"Compensation Breakdown — Top 10{title_suffix}"
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,15 @@
|
||||||
"""Page: Endowment Performance."""
|
"""Page: Endowment Performance."""
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
from dash import html, dcc
|
from dash import html
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
from admin_analytics.dashboard.components import graph, kpi_card, kpi_row, no_data
|
||||||
from admin_analytics.dashboard.queries import (
|
from admin_analytics.dashboard.queries import (
|
||||||
query_endowment, query_endowment_per_student, query_cio_vs_endowment,
|
query_endowment, query_endowment_per_student, query_cio_vs_endowment,
|
||||||
)
|
)
|
||||||
|
|
||||||
_NO_DATA = html.Div(
|
_NO_DATA = no_data("No endowment data loaded. Run: admin-analytics ingest ipeds --component finance")
|
||||||
"No endowment data loaded. Run: admin-analytics ingest ipeds --component finance",
|
|
||||||
style={"textAlign": "center", "padding": "40px", "color": "#888"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
|
|
@ -62,29 +42,29 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
|
|
||||||
# KPI cards — single row
|
# KPI cards — single row
|
||||||
kpi_cards = [
|
kpi_cards = [
|
||||||
_kpi_card(
|
kpi_card(
|
||||||
"Endowment Value",
|
"Endowment Value",
|
||||||
f"${latest['endowment_eoy'] / 1e9:.2f}B" if latest["endowment_eoy"] else "N/A",
|
f"${latest['endowment_eoy'] / 1e9:.2f}B" if latest["endowment_eoy"] else "N/A",
|
||||||
f"End of FY {int(latest['year'])}",
|
f"End of FY {int(latest['year'])}",
|
||||||
),
|
),
|
||||||
_kpi_card(
|
kpi_card(
|
||||||
"Endowment CAGR",
|
"Endowment CAGR",
|
||||||
f"{endow_cagr}%" if endow_cagr is not None else "N/A",
|
f"{endow_cagr}%" if endow_cagr is not None else "N/A",
|
||||||
f"FY {start_year}-{end_year}" if start_year else "",
|
f"FY {start_year}-{end_year}" if start_year else "",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
if latest_eps is not None and latest_eps["endowment_per_student"]:
|
if latest_eps is not None and latest_eps["endowment_per_student"]:
|
||||||
kpi_cards.append(_kpi_card(
|
kpi_cards.append(kpi_card(
|
||||||
"Endowment per Student",
|
"Endowment per Student",
|
||||||
f"${int(latest_eps['endowment_per_student']):,}",
|
f"${int(latest_eps['endowment_per_student']):,}",
|
||||||
f"FY {int(latest_eps['year'])}",
|
f"FY {int(latest_eps['year'])}",
|
||||||
))
|
))
|
||||||
kpi_cards.append(_kpi_card(
|
kpi_cards.append(kpi_card(
|
||||||
"New Gifts to Endowment",
|
"New Gifts to Endowment",
|
||||||
f"${latest['new_gifts'] / 1e6:.1f}M" if latest["new_gifts"] else "N/A",
|
f"${latest['new_gifts'] / 1e6:.1f}M" if latest["new_gifts"] else "N/A",
|
||||||
f"FY {int(latest['year'])}",
|
f"FY {int(latest['year'])}",
|
||||||
))
|
))
|
||||||
kpi_row = html.Div(kpi_cards, style={"display": "flex", "marginBottom": "24px"})
|
kpis = kpi_row(kpi_cards)
|
||||||
|
|
||||||
# Endowment value trend
|
# Endowment value trend
|
||||||
value_fig = go.Figure()
|
value_fig = go.Figure()
|
||||||
|
|
@ -189,13 +169,13 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
)
|
)
|
||||||
|
|
||||||
charts = [
|
charts = [
|
||||||
kpi_row,
|
kpis,
|
||||||
dcc.Graph(figure=value_fig, config={"displaylogo": False}),
|
graph(value_fig),
|
||||||
dcc.Graph(figure=eps_fig, config={"displaylogo": False}),
|
graph(eps_fig),
|
||||||
dcc.Graph(figure=components_fig, config={"displaylogo": False}),
|
graph(components_fig),
|
||||||
dcc.Graph(figure=rate_fig, config={"displaylogo": False}),
|
graph(rate_fig),
|
||||||
]
|
]
|
||||||
if cio_df.height > 1:
|
if cio_df.height > 1:
|
||||||
charts.append(dcc.Graph(figure=cio_fig, config={"displaylogo": False}))
|
charts.append(graph(cio_fig))
|
||||||
|
|
||||||
return html.Div(charts)
|
return html.Div(charts)
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,17 @@
|
||||||
"""Page 4: Current Admin Headcount (from scraper)."""
|
"""Page 4: Current Admin Headcount (from scraper)."""
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
from dash import html, dcc, dash_table
|
from dash import html, dash_table
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
from admin_analytics.dashboard.components import graph, kpi_card, kpi_row, chart_row, no_data
|
||||||
from admin_analytics.dashboard.queries import (
|
from admin_analytics.dashboard.queries import (
|
||||||
query_admin_headcount,
|
query_admin_headcount,
|
||||||
query_headcount_summary,
|
query_headcount_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
_NO_DATA = html.Div(
|
_NO_DATA = no_data("No headcount data loaded. Run: admin-analytics ingest scrape")
|
||||||
"No headcount data loaded. Run: admin-analytics ingest scrape",
|
|
||||||
style={"textAlign": "center", "padding": "40px", "color": "#888"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _kpi_card(title: str, value: 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"}),
|
|
||||||
],
|
|
||||||
style={
|
|
||||||
"flex": "1",
|
|
||||||
"padding": "20px",
|
|
||||||
"backgroundColor": "#f8f9fa",
|
|
||||||
"borderRadius": "8px",
|
|
||||||
"textAlign": "center",
|
|
||||||
"margin": "0 8px",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def layout(conn: duckdb.DuckDBPyConnection):
|
def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
|
|
@ -47,13 +28,12 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
overhead_pct = round(overhead_count * 100 / total, 1) if total > 0 else 0
|
overhead_pct = round(overhead_count * 100 / total, 1) if total > 0 else 0
|
||||||
|
|
||||||
# KPI cards
|
# KPI cards
|
||||||
kpi_row = html.Div(
|
kpis = kpi_row(
|
||||||
[
|
[
|
||||||
_kpi_card("Total Staff Scraped", str(total)),
|
kpi_card("Total Staff Scraped", str(total)),
|
||||||
_kpi_card("Overhead Staff", str(overhead_count)),
|
kpi_card("Overhead Staff", str(overhead_count)),
|
||||||
_kpi_card("Overhead %", f"{overhead_pct}%"),
|
kpi_card("Overhead %", f"{overhead_pct}%"),
|
||||||
],
|
],
|
||||||
style={"display": "flex", "marginBottom": "24px"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Staff by unit bar chart
|
# Staff by unit bar chart
|
||||||
|
|
@ -104,13 +84,7 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
)
|
)
|
||||||
|
|
||||||
return html.Div([
|
return html.Div([
|
||||||
kpi_row,
|
kpis,
|
||||||
html.Div(
|
chart_row(graph(unit_fig), graph(pie_fig)),
|
||||||
[
|
graph(cat_fig),
|
||||||
html.Div(dcc.Graph(figure=unit_fig, config={"displaylogo": False}), style={"flex": "1"}),
|
|
||||||
html.Div(dcc.Graph(figure=pie_fig, config={"displaylogo": False}), style={"flex": "1"}),
|
|
||||||
],
|
|
||||||
style={"display": "flex", "gap": "16px"},
|
|
||||||
),
|
|
||||||
dcc.Graph(figure=cat_fig, config={"displaylogo": False}),
|
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""Page 1: Administrative Cost Overview."""
|
"""Page 1: Administrative Cost Overview."""
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
from dash import html, dcc
|
from dash import html
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
from admin_analytics.dashboard.components import graph, kpi_card, kpi_row, chart_row, no_data
|
||||||
from admin_analytics.dashboard.queries import (
|
from admin_analytics.dashboard.queries import (
|
||||||
query_admin_cost_ratio,
|
query_admin_cost_ratio,
|
||||||
query_expense_breakdown,
|
query_expense_breakdown,
|
||||||
|
|
@ -12,28 +13,7 @@ from admin_analytics.dashboard.queries import (
|
||||||
query_admin_faculty_ratio,
|
query_admin_faculty_ratio,
|
||||||
)
|
)
|
||||||
|
|
||||||
_NO_DATA = html.Div(
|
_NO_DATA = no_data("No data loaded. Run: admin-analytics ingest all")
|
||||||
"No data loaded. Run: admin-analytics ingest all",
|
|
||||||
style={"textAlign": "center", "padding": "40px", "color": "#888"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
|
|
@ -56,25 +36,24 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
latest_afr = afr_pd.iloc[-1] if afr_pd is not None else None
|
latest_afr = afr_pd.iloc[-1] if afr_pd is not None else None
|
||||||
|
|
||||||
# KPI cards
|
# KPI cards
|
||||||
kpi_row = html.Div(
|
kpis = kpi_row(
|
||||||
[
|
[
|
||||||
_kpi_card(
|
kpi_card(
|
||||||
"Admin Cost Ratio",
|
"Admin Cost Ratio",
|
||||||
f"{latest['admin_cost_pct']:.1f}%",
|
f"{latest['admin_cost_pct']:.1f}%",
|
||||||
f"Institutional Support / Total Expenses ({int(latest['year'])})",
|
f"Institutional Support / Total Expenses ({int(latest['year'])})",
|
||||||
),
|
),
|
||||||
_kpi_card(
|
kpi_card(
|
||||||
"Admin Cost per Student",
|
"Admin Cost per Student",
|
||||||
f"${int(latest_aps['admin_per_student']):,}" if latest_aps is not None else "N/A",
|
f"${int(latest_aps['admin_per_student']):,}" if latest_aps is not None else "N/A",
|
||||||
f"CPI-adjusted: ${int(latest_aps['admin_per_student_cpi']):,}" if latest_aps is not None else "",
|
f"CPI-adjusted: ${int(latest_aps['admin_per_student_cpi']):,}" if latest_aps is not None else "",
|
||||||
),
|
),
|
||||||
_kpi_card(
|
kpi_card(
|
||||||
"Admin-to-Faculty Ratio",
|
"Admin-to-Faculty Ratio",
|
||||||
f"{latest_afr['admin_faculty_ratio']:.2f}" if latest_afr is not None else "N/A",
|
f"{latest_afr['admin_faculty_ratio']:.2f}" if latest_afr is not None else "N/A",
|
||||||
f"Management / Faculty ({int(latest_afr['year'])})" if latest_afr is not None else "",
|
f"Management / Faculty ({int(latest_afr['year'])})" if latest_afr is not None else "",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
style={"display": "flex", "marginBottom": "24px"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Admin cost ratio trend
|
# Admin cost ratio trend
|
||||||
|
|
@ -155,14 +134,8 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
)
|
)
|
||||||
|
|
||||||
return html.Div([
|
return html.Div([
|
||||||
kpi_row,
|
kpis,
|
||||||
dcc.Graph(figure=ratio_fig, config={"displaylogo": False}),
|
graph(ratio_fig),
|
||||||
dcc.Graph(figure=breakdown_fig, config={"displaylogo": False}),
|
graph(breakdown_fig),
|
||||||
html.Div(
|
chart_row(graph(aps_fig), graph(afr_fig)),
|
||||||
[
|
|
||||||
html.Div(dcc.Graph(figure=aps_fig, config={"displaylogo": False}), style={"flex": "1"}),
|
|
||||||
html.Div(dcc.Graph(figure=afr_fig, config={"displaylogo": False}), style={"flex": "1"}),
|
|
||||||
],
|
|
||||||
style={"display": "flex", "gap": "16px"},
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,13 @@
|
||||||
"""Page: Philanthropic Giving."""
|
"""Page: Philanthropic Giving."""
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
from dash import html, dcc
|
from dash import html
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
from admin_analytics.dashboard.components import graph, kpi_card, kpi_row, no_data
|
||||||
from admin_analytics.dashboard.queries import query_philanthropy, query_comp_vs_philanthropy
|
from admin_analytics.dashboard.queries import query_philanthropy, query_comp_vs_philanthropy
|
||||||
|
|
||||||
_NO_DATA = html.Div(
|
_NO_DATA = no_data("No philanthropy data loaded. Run: admin-analytics ingest ipeds --component finance")
|
||||||
"No philanthropy data loaded. Run: admin-analytics ingest ipeds --component finance",
|
|
||||||
style={"textAlign": "center", "padding": "40px", "color": "#888"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
|
|
@ -39,20 +19,19 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
latest = pd.iloc[-1]
|
latest = pd.iloc[-1]
|
||||||
|
|
||||||
# KPI cards
|
# KPI cards
|
||||||
kpi_row = html.Div(
|
kpis = kpi_row(
|
||||||
[
|
[
|
||||||
_kpi_card(
|
kpi_card(
|
||||||
"Total Private Gifts & Grants",
|
"Total Private Gifts & Grants",
|
||||||
f"${latest['total_private_gifts'] / 1e6:.1f}M" if latest["total_private_gifts"] else "N/A",
|
f"${latest['total_private_gifts'] / 1e6:.1f}M" if latest["total_private_gifts"] else "N/A",
|
||||||
f"FY {int(latest['year'])}",
|
f"FY {int(latest['year'])}",
|
||||||
),
|
),
|
||||||
_kpi_card(
|
kpi_card(
|
||||||
"Gifts to Endowment",
|
"Gifts to Endowment",
|
||||||
f"${latest['endowment_gifts'] / 1e6:.1f}M" if latest["endowment_gifts"] else "N/A",
|
f"${latest['endowment_gifts'] / 1e6:.1f}M" if latest["endowment_gifts"] else "N/A",
|
||||||
f"FY {int(latest['year'])}",
|
f"FY {int(latest['year'])}",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
style={"display": "flex", "marginBottom": "24px"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Private gifts trend (nominal and CPI-adjusted)
|
# Private gifts trend (nominal and CPI-adjusted)
|
||||||
|
|
@ -122,11 +101,11 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
)
|
)
|
||||||
|
|
||||||
charts = [
|
charts = [
|
||||||
kpi_row,
|
kpis,
|
||||||
dcc.Graph(figure=gifts_fig, config={"displaylogo": False}),
|
graph(gifts_fig),
|
||||||
dcc.Graph(figure=split_fig, config={"displaylogo": False}),
|
graph(split_fig),
|
||||||
]
|
]
|
||||||
if cvp_df.height > 1:
|
if cvp_df.height > 1:
|
||||||
charts.append(dcc.Graph(figure=cvp_fig, config={"displaylogo": False}))
|
charts.append(graph(cvp_fig))
|
||||||
|
|
||||||
return html.Div(charts)
|
return html.Div(charts)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
"""Page 3: Staffing & Enrollment."""
|
"""Page 3: Staffing & Enrollment."""
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
from dash import html, dcc
|
from dash import html
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
from admin_analytics.dashboard.components import graph, chart_row, no_data
|
||||||
from admin_analytics.dashboard.queries import (
|
from admin_analytics.dashboard.queries import (
|
||||||
query_staff_composition,
|
query_staff_composition,
|
||||||
query_student_staff_ratios,
|
query_student_staff_ratios,
|
||||||
query_growth_index,
|
query_growth_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
_NO_DATA = html.Div(
|
_NO_DATA = no_data("No IPEDS staff data loaded. Run: admin-analytics ingest ipeds")
|
||||||
"No IPEDS staff data loaded. Run: admin-analytics ingest ipeds",
|
|
||||||
style={"textAlign": "center", "padding": "40px", "color": "#888"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def layout(conn: duckdb.DuckDBPyConnection):
|
def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
|
|
@ -91,12 +89,6 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
||||||
)
|
)
|
||||||
|
|
||||||
return html.Div([
|
return html.Div([
|
||||||
dcc.Graph(figure=comp_fig, config={"displaylogo": False}),
|
graph(comp_fig),
|
||||||
html.Div(
|
chart_row(graph(ratio_fig), graph(growth_fig)),
|
||||||
[
|
|
||||||
html.Div(dcc.Graph(figure=ratio_fig, config={"displaylogo": False}), style={"flex": "1"}),
|
|
||||||
html.Div(dcc.Graph(figure=growth_fig, config={"displaylogo": False}), style={"flex": "1"}),
|
|
||||||
],
|
|
||||||
style={"display": "flex", "gap": "16px"},
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue