diff --git a/src/admin_analytics/dashboard/app.py b/src/admin_analytics/dashboard/app.py index 55e6909..a068783 100644 --- a/src/admin_analytics/dashboard/app.py +++ b/src/admin_analytics/dashboard/app.py @@ -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")) diff --git a/src/admin_analytics/dashboard/assets/style.css b/src/admin_analytics/dashboard/assets/style.css new file mode 100644 index 0000000..0be1abf --- /dev/null +++ b/src/admin_analytics/dashboard/assets/style.css @@ -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%; +} diff --git a/src/admin_analytics/dashboard/components.py b/src/admin_analytics/dashboard/components.py new file mode 100644 index 0000000..c761d6b --- /dev/null +++ b/src/admin_analytics/dashboard/components.py @@ -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") diff --git a/src/admin_analytics/dashboard/pages/compensation.py b/src/admin_analytics/dashboard/pages/compensation.py index 08a370e..df18a63 100644 --- a/src/admin_analytics/dashboard/pages/compensation.py +++ b/src/admin_analytics/dashboard/pages/compensation.py @@ -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}" diff --git a/src/admin_analytics/dashboard/pages/endowment.py b/src/admin_analytics/dashboard/pages/endowment.py index d2418e5..209b4e4 100644 --- a/src/admin_analytics/dashboard/pages/endowment.py +++ b/src/admin_analytics/dashboard/pages/endowment.py @@ -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) diff --git a/src/admin_analytics/dashboard/pages/headcount.py b/src/admin_analytics/dashboard/pages/headcount.py index 3adf03e..73aec2f 100644 --- a/src/admin_analytics/dashboard/pages/headcount.py +++ b/src/admin_analytics/dashboard/pages/headcount.py @@ -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), ]) diff --git a/src/admin_analytics/dashboard/pages/overview.py b/src/admin_analytics/dashboard/pages/overview.py index cf46de2..fdce580 100644 --- a/src/admin_analytics/dashboard/pages/overview.py +++ b/src/admin_analytics/dashboard/pages/overview.py @@ -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)), ]) diff --git a/src/admin_analytics/dashboard/pages/philanthropy.py b/src/admin_analytics/dashboard/pages/philanthropy.py index 0b77d9e..afb16ce 100644 --- a/src/admin_analytics/dashboard/pages/philanthropy.py +++ b/src/admin_analytics/dashboard/pages/philanthropy.py @@ -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) diff --git a/src/admin_analytics/dashboard/pages/staffing.py b/src/admin_analytics/dashboard/pages/staffing.py index 5050960..18f2930 100644 --- a/src/admin_analytics/dashboard/pages/staffing.py +++ b/src/admin_analytics/dashboard/pages/staffing.py @@ -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)), ])