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(
|
||||
"University of Delaware — Administrative Analytics",
|
||||
style={"textAlign": "center", "padding": "20px", "color": "#00539F"},
|
||||
className="app-title",
|
||||
),
|
||||
dcc.Tabs(
|
||||
id="tabs",
|
||||
|
|
@ -32,11 +32,12 @@ def create_app() -> dash.Dash:
|
|||
dcc.Tab(label="Current Headcount", value="headcount"),
|
||||
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"))
|
||||
|
|
|
|||
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.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 (
|
||||
query_top_earners,
|
||||
query_comp_by_role,
|
||||
|
|
@ -15,33 +25,12 @@ from admin_analytics.dashboard.queries import (
|
|||
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"},
|
||||
)
|
||||
_NO_DATA = no_data("No IRS 990 data loaded. Run: admin-analytics ingest irs990")
|
||||
|
||||
# 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:
|
||||
|
|
@ -57,28 +46,28 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
agg_cagr = query_aggregate_comp_cagr(conn)
|
||||
kpi_cards = []
|
||||
if cagr:
|
||||
kpi_cards.append(_kpi_card(
|
||||
kpi_cards.append(kpi_card(
|
||||
"President Compensation",
|
||||
f"${cagr['end_comp']:,}",
|
||||
f"Tax year {cagr['end_year']}",
|
||||
))
|
||||
kpi_cards.append(_kpi_card(
|
||||
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(
|
||||
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(
|
||||
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()
|
||||
kpis = kpi_row(kpi_cards) if kpi_cards else html.Div()
|
||||
|
||||
# Compensation by role trend
|
||||
role_df = query_comp_by_role(conn)
|
||||
|
|
@ -127,7 +116,7 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
)
|
||||
|
||||
return html.Div([
|
||||
kpi_row,
|
||||
kpis,
|
||||
html.Div(
|
||||
[
|
||||
html.Label("Filter by Tax Year: ", style={"fontWeight": "bold"}),
|
||||
|
|
@ -135,10 +124,10 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
id="comp-year-dropdown",
|
||||
options=year_options,
|
||||
value="all",
|
||||
style={"width": "200px", "display": "inline-block"},
|
||||
clearable=False,
|
||||
),
|
||||
],
|
||||
style={"marginBottom": "16px"},
|
||||
className="filter-bar",
|
||||
),
|
||||
dash_table.DataTable(
|
||||
id="comp-table",
|
||||
|
|
@ -162,14 +151,17 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
style_cell={"textAlign": "left", "padding": "8px", "fontSize": "13px"},
|
||||
style_header={"fontWeight": "bold", "backgroundColor": "#f0f0f0"},
|
||||
),
|
||||
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"}),
|
||||
],
|
||||
style={"display": "flex", "gap": "16px", "marginTop": "16px"},
|
||||
chart_row(
|
||||
html.Div(
|
||||
[
|
||||
html.H3(id="comp-breakdown-title", className="chart-title"),
|
||||
dcc.Graph(id="comp-breakdown-chart", config=GRAPH_CONFIG, style=graph_style(380)),
|
||||
],
|
||||
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."""
|
||||
|
||||
@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"),
|
||||
)
|
||||
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)"
|
||||
breakdown_fig.update_layout(
|
||||
title=f"Compensation Breakdown — Top 10{title_suffix}",
|
||||
xaxis_title="", yaxis_title="$",
|
||||
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."""
|
||||
|
||||
import duckdb
|
||||
from dash import html, dcc
|
||||
from dash import html
|
||||
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_endowment, query_endowment_per_student, query_cio_vs_endowment,
|
||||
)
|
||||
|
||||
_NO_DATA = html.Div(
|
||||
"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",
|
||||
},
|
||||
)
|
||||
_NO_DATA = no_data("No endowment data loaded. Run: admin-analytics ingest ipeds --component finance")
|
||||
|
||||
|
||||
def layout(conn: duckdb.DuckDBPyConnection):
|
||||
|
|
@ -62,29 +42,29 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
|
||||
# KPI cards — single row
|
||||
kpi_cards = [
|
||||
_kpi_card(
|
||||
kpi_card(
|
||||
"Endowment Value",
|
||||
f"${latest['endowment_eoy'] / 1e9:.2f}B" if latest["endowment_eoy"] else "N/A",
|
||||
f"End of FY {int(latest['year'])}",
|
||||
),
|
||||
_kpi_card(
|
||||
kpi_card(
|
||||
"Endowment CAGR",
|
||||
f"{endow_cagr}%" if endow_cagr is not None else "N/A",
|
||||
f"FY {start_year}-{end_year}" if start_year else "",
|
||||
),
|
||||
]
|
||||
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",
|
||||
f"${int(latest_eps['endowment_per_student']):,}",
|
||||
f"FY {int(latest_eps['year'])}",
|
||||
))
|
||||
kpi_cards.append(_kpi_card(
|
||||
kpi_cards.append(kpi_card(
|
||||
"New Gifts to Endowment",
|
||||
f"${latest['new_gifts'] / 1e6:.1f}M" if latest["new_gifts"] else "N/A",
|
||||
f"FY {int(latest['year'])}",
|
||||
))
|
||||
kpi_row = html.Div(kpi_cards, style={"display": "flex", "marginBottom": "24px"})
|
||||
kpis = kpi_row(kpi_cards)
|
||||
|
||||
# Endowment value trend
|
||||
value_fig = go.Figure()
|
||||
|
|
@ -189,13 +169,13 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
)
|
||||
|
||||
charts = [
|
||||
kpi_row,
|
||||
dcc.Graph(figure=value_fig, config={"displaylogo": False}),
|
||||
dcc.Graph(figure=eps_fig, config={"displaylogo": False}),
|
||||
dcc.Graph(figure=components_fig, config={"displaylogo": False}),
|
||||
dcc.Graph(figure=rate_fig, config={"displaylogo": False}),
|
||||
kpis,
|
||||
graph(value_fig),
|
||||
graph(eps_fig),
|
||||
graph(components_fig),
|
||||
graph(rate_fig),
|
||||
]
|
||||
if cio_df.height > 1:
|
||||
charts.append(dcc.Graph(figure=cio_fig, config={"displaylogo": False}))
|
||||
charts.append(graph(cio_fig))
|
||||
|
||||
return html.Div(charts)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,17 @@
|
|||
"""Page 4: Current Admin Headcount (from scraper)."""
|
||||
|
||||
import duckdb
|
||||
from dash import html, dcc, dash_table
|
||||
from dash import html, dash_table
|
||||
import plotly.express as px
|
||||
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 (
|
||||
query_admin_headcount,
|
||||
query_headcount_summary,
|
||||
)
|
||||
|
||||
_NO_DATA = html.Div(
|
||||
"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",
|
||||
},
|
||||
)
|
||||
_NO_DATA = no_data("No headcount data loaded. Run: admin-analytics ingest scrape")
|
||||
|
||||
|
||||
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
|
||||
|
||||
# KPI cards
|
||||
kpi_row = html.Div(
|
||||
kpis = kpi_row(
|
||||
[
|
||||
_kpi_card("Total Staff Scraped", str(total)),
|
||||
_kpi_card("Overhead Staff", str(overhead_count)),
|
||||
_kpi_card("Overhead %", f"{overhead_pct}%"),
|
||||
kpi_card("Total Staff Scraped", str(total)),
|
||||
kpi_card("Overhead Staff", str(overhead_count)),
|
||||
kpi_card("Overhead %", f"{overhead_pct}%"),
|
||||
],
|
||||
style={"display": "flex", "marginBottom": "24px"},
|
||||
)
|
||||
|
||||
# Staff by unit bar chart
|
||||
|
|
@ -104,13 +84,7 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
)
|
||||
|
||||
return html.Div([
|
||||
kpi_row,
|
||||
html.Div(
|
||||
[
|
||||
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}),
|
||||
kpis,
|
||||
chart_row(graph(unit_fig), graph(pie_fig)),
|
||||
graph(cat_fig),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""Page 1: Administrative Cost Overview."""
|
||||
|
||||
import duckdb
|
||||
from dash import html, dcc
|
||||
from dash import html
|
||||
import plotly.express as px
|
||||
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 (
|
||||
query_admin_cost_ratio,
|
||||
query_expense_breakdown,
|
||||
|
|
@ -12,28 +13,7 @@ from admin_analytics.dashboard.queries import (
|
|||
query_admin_faculty_ratio,
|
||||
)
|
||||
|
||||
_NO_DATA = html.Div(
|
||||
"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",
|
||||
},
|
||||
)
|
||||
_NO_DATA = no_data("No data loaded. Run: admin-analytics ingest all")
|
||||
|
||||
|
||||
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
|
||||
|
||||
# KPI cards
|
||||
kpi_row = html.Div(
|
||||
kpis = kpi_row(
|
||||
[
|
||||
_kpi_card(
|
||||
kpi_card(
|
||||
"Admin Cost Ratio",
|
||||
f"{latest['admin_cost_pct']:.1f}%",
|
||||
f"Institutional Support / Total Expenses ({int(latest['year'])})",
|
||||
),
|
||||
_kpi_card(
|
||||
kpi_card(
|
||||
"Admin Cost per Student",
|
||||
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 "",
|
||||
),
|
||||
_kpi_card(
|
||||
kpi_card(
|
||||
"Admin-to-Faculty Ratio",
|
||||
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 "",
|
||||
),
|
||||
],
|
||||
style={"display": "flex", "marginBottom": "24px"},
|
||||
)
|
||||
|
||||
# Admin cost ratio trend
|
||||
|
|
@ -155,14 +134,8 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
)
|
||||
|
||||
return html.Div([
|
||||
kpi_row,
|
||||
dcc.Graph(figure=ratio_fig, config={"displaylogo": False}),
|
||||
dcc.Graph(figure=breakdown_fig, config={"displaylogo": False}),
|
||||
html.Div(
|
||||
[
|
||||
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"},
|
||||
),
|
||||
kpis,
|
||||
graph(ratio_fig),
|
||||
graph(breakdown_fig),
|
||||
chart_row(graph(aps_fig), graph(afr_fig)),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,33 +1,13 @@
|
|||
"""Page: Philanthropic Giving."""
|
||||
|
||||
import duckdb
|
||||
from dash import html, dcc
|
||||
from dash import html
|
||||
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
|
||||
|
||||
_NO_DATA = html.Div(
|
||||
"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",
|
||||
},
|
||||
)
|
||||
_NO_DATA = no_data("No philanthropy data loaded. Run: admin-analytics ingest ipeds --component finance")
|
||||
|
||||
|
||||
def layout(conn: duckdb.DuckDBPyConnection):
|
||||
|
|
@ -39,20 +19,19 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
latest = pd.iloc[-1]
|
||||
|
||||
# KPI cards
|
||||
kpi_row = html.Div(
|
||||
kpis = kpi_row(
|
||||
[
|
||||
_kpi_card(
|
||||
kpi_card(
|
||||
"Total Private Gifts & Grants",
|
||||
f"${latest['total_private_gifts'] / 1e6:.1f}M" if latest["total_private_gifts"] else "N/A",
|
||||
f"FY {int(latest['year'])}",
|
||||
),
|
||||
_kpi_card(
|
||||
kpi_card(
|
||||
"Gifts to Endowment",
|
||||
f"${latest['endowment_gifts'] / 1e6:.1f}M" if latest["endowment_gifts"] else "N/A",
|
||||
f"FY {int(latest['year'])}",
|
||||
),
|
||||
],
|
||||
style={"display": "flex", "marginBottom": "24px"},
|
||||
)
|
||||
|
||||
# Private gifts trend (nominal and CPI-adjusted)
|
||||
|
|
@ -122,11 +101,11 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
)
|
||||
|
||||
charts = [
|
||||
kpi_row,
|
||||
dcc.Graph(figure=gifts_fig, config={"displaylogo": False}),
|
||||
dcc.Graph(figure=split_fig, config={"displaylogo": False}),
|
||||
kpis,
|
||||
graph(gifts_fig),
|
||||
graph(split_fig),
|
||||
]
|
||||
if cvp_df.height > 1:
|
||||
charts.append(dcc.Graph(figure=cvp_fig, config={"displaylogo": False}))
|
||||
charts.append(graph(cvp_fig))
|
||||
|
||||
return html.Div(charts)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
"""Page 3: Staffing & Enrollment."""
|
||||
|
||||
import duckdb
|
||||
from dash import html, dcc
|
||||
from dash import html
|
||||
import plotly.graph_objects as go
|
||||
|
||||
from admin_analytics.dashboard.components import graph, chart_row, no_data
|
||||
from admin_analytics.dashboard.queries import (
|
||||
query_staff_composition,
|
||||
query_student_staff_ratios,
|
||||
query_growth_index,
|
||||
)
|
||||
|
||||
_NO_DATA = html.Div(
|
||||
"No IPEDS staff data loaded. Run: admin-analytics ingest ipeds",
|
||||
style={"textAlign": "center", "padding": "40px", "color": "#888"},
|
||||
)
|
||||
_NO_DATA = no_data("No IPEDS staff data loaded. Run: admin-analytics ingest ipeds")
|
||||
|
||||
|
||||
def layout(conn: duckdb.DuckDBPyConnection):
|
||||
|
|
@ -91,12 +89,6 @@ def layout(conn: duckdb.DuckDBPyConnection):
|
|||
)
|
||||
|
||||
return html.Div([
|
||||
dcc.Graph(figure=comp_fig, config={"displaylogo": False}),
|
||||
html.Div(
|
||||
[
|
||||
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"},
|
||||
),
|
||||
graph(comp_fig),
|
||||
chart_row(graph(ratio_fig), graph(growth_fig)),
|
||||
])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue