Compare commits

...

6 Commits

Author SHA1 Message Date
e1b817252c refactor: replace month dropdown with week dropdown and update related components
feat: add feedback functionality and modal for category feedback
style: add feedback button styles in CSS
docs: add explanation tab for dashboard usage instructions
2025-09-07 20:16:56 +02:00
2d73ae8d63 added some more changes 2025-09-06 07:27:08 +02:00
ab1d541e2c added table 2025-09-06 07:07:08 +02:00
cc8de33780 added table 2025-09-06 07:06:47 +02:00
1c41f666dc added table functionality 2025-09-05 05:46:52 +02:00
d76db631fd added table functionality 2025-09-05 05:46:44 +02:00
16 changed files with 312 additions and 98 deletions

View File

@ -59,3 +59,17 @@ hr {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.feedback-button {
background-color: #007bff;
color: white;
border: none;
padding: 5px 10px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 12px;
margin: 2px 2px;
cursor: pointer;
border-radius: 4px;
}

2
data/feedback.csv Normal file
View File

@ -0,0 +1,2 @@
timestamp,category,comment
2025-09-07 20:02:38,groceries,"ABZT please change this air to P1231313"
1 timestamp category comment
2 2025-09-07 20:02:38 groceries ABZT please change this air to P1231313

View File

@ -1,6 +1,6 @@
# initial commit # initial commit
from dash import Dash from dash import Dash
from dash_bootstrap_components.themes import BOOTSTRAP import dash_bootstrap_components as dbc
from src.components.layout import create_layout from src.components.layout import create_layout
from src.data.loader_gz import load_spc_data from src.data.loader_gz import load_spc_data
@ -17,10 +17,11 @@ with open(config_file) as config_f:
def main() -> None: def main() -> None:
print(os.getenv("MY_ENV_VAR")) print(os.getenv("MY_ENV_VAR"))
print(config["Startup"])
# load the data and create the data manager # load the data and create the data manager
data = load_spc_data(config["DATA_PATH"]) data = load_spc_data(config["DATA_PATH"])
app = Dash(external_stylesheets=[BOOTSTRAP]) app = Dash(external_stylesheets=[dbc.themes.LUX])
app.title = "Reliability Dashboard" app.title = "Reliability Dashboard"
app.layout = create_layout(app, data) app.layout = create_layout(app, data)
app.run() app.run()

View File

@ -7,4 +7,5 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"dash>=3.2.0", "dash>=3.2.0",
"dash-bootstrap-components>=2.0.4", "dash-bootstrap-components>=2.0.4",
"dotenv>=0.9.9",
] ]

View File

@ -12,15 +12,15 @@ def render(app: Dash, data: pd.DataFrame) -> html.Div:
Output(ids.BAR_CHART, "children"), Output(ids.BAR_CHART, "children"),
[ [
Input(ids.YEAR_DROPDOWN, "value"), Input(ids.YEAR_DROPDOWN, "value"),
Input(ids.MONTH_DROPDOWN, "value"), Input(ids.WEEK_DROPDOWN, "value"),
Input(ids.CATEGORY_DROPDOWN, "value"), Input(ids.CATEGORY_DROPDOWN, "value"),
], ],
) )
def update_bar_chart( def update_bar_chart(
years: list[str], months: list[str], categories: list[str] years: list[str], weeks: list[str], categories: list[str]
) -> html.Div: ) -> html.Div:
filtered_data = data.query( filtered_data = data.query(
"year in @years and month in @months and category in @categories" "year in @years and week in @weeks and category in @categories"
) )
if filtered_data.shape[0] == 0: if filtered_data.shape[0] == 0:

View File

@ -14,12 +14,12 @@ def render(app: Dash, data: pd.DataFrame) -> html.Div:
Output(ids.CATEGORY_DROPDOWN, "value"), Output(ids.CATEGORY_DROPDOWN, "value"),
[ [
Input(ids.YEAR_DROPDOWN, "value"), Input(ids.YEAR_DROPDOWN, "value"),
Input(ids.MONTH_DROPDOWN, "value"), Input(ids.WEEK_DROPDOWN, "value"),
Input(ids.SELECT_ALL_CATEGORIES_BUTTON, "n_clicks"), Input(ids.SELECT_ALL_CATEGORIES_BUTTON, "n_clicks"),
], ],
) )
def select_all_categories(years: list[str], months: list[str], _: int) -> list[str]: def select_all_categories(years: list[str], weeks: list[str], _: int) -> list[str]:
filtered_data = data.query("year in @years and month in @months") filtered_data = data.query("year in @years and week in @weeks")
return sorted(set(filtered_data[DataSchema.CATEGORY].tolist())) return sorted(set(filtered_data[DataSchema.CATEGORY].tolist()))
return html.Div( return html.Div(

View File

@ -1,48 +1,123 @@
import pandas as pd import pandas as pd
import plotly.express as px from dash import Dash, dcc, html, dash_table, Input, Output, State, callback_context
from dash import Dash, dcc, html from datetime import datetime
from dash.dependencies import Input, Output import os
import dash_bootstrap_components as dbc
from ..data.loader import DataSchema from ..data.loader import DataSchema
from . import ids from . import ids
def render(app: Dash, data: pd.DataFrame) -> html.Div: def render(app: Dash, data: pd.DataFrame) -> html.Div:
@app.callback( @app.callback(
Output(ids.BAR_CHART, "children"), Output(ids.DATA_TABLE, "children"),
[ [
Input(ids.YEAR_DROPDOWN, "value"), Input(ids.YEAR_DROPDOWN, "value"),
Input(ids.MONTH_DROPDOWN, "value"), Input(ids.WEEK_DROPDOWN, "value"),
Input(ids.CATEGORY_DROPDOWN, "value"), Input(ids.CATEGORY_DROPDOWN, "value"),
], ],
) )
def update_bar_chart( def update_data_table(
years: list[str], months: list[str], categories: list[str] years: list[str], weeks: list[str], categories: list[str]
) -> html.Div: ) -> html.Div:
filtered_data = data.query( filtered_data = data.query(
"year in @years and month in @months and category in @categories" "year in @years and week in @weeks and category in @categories"
) )
if filtered_data.shape[0] == 0: if filtered_data.shape[0] == 0:
return html.Div("No data selected.", id=ids.BAR_CHART) return html.Div("No data selected.")
def create_pivot_table() -> pd.DataFrame:
pt = filtered_data.pivot_table( pt = filtered_data.pivot_table(
values=DataSchema.AMOUNT, values=DataSchema.AMOUNT,
index=[DataSchema.CATEGORY], index=[DataSchema.CATEGORY],
aggfunc="sum", aggfunc="sum",
fill_value=0, fill_value=0,
dropna=False, dropna=False,
) ).reset_index().sort_values(DataSchema.AMOUNT, ascending=False)
return pt.reset_index().sort_values(DataSchema.AMOUNT, ascending=False)
fig = px.bar( columns = [{"name": i.capitalize(), "id": i} for i in pt.columns]
create_pivot_table(),
x=DataSchema.CATEGORY, return dash_table.DataTable(
y=DataSchema.AMOUNT, id=ids.CATEGORY_TABLE,
color=DataSchema.CATEGORY, data=pt.to_dict("records"),
columns=columns,
row_selectable='single',
selected_rows=[]
) )
return html.Div(dcc.Graph(figure=fig), id=ids.BAR_CHART) @app.callback(
Output(ids.FEEDBACK_MODAL, "is_open"),
Output(ids.FEEDBACK_MESSAGE, "children"),
Input(ids.CATEGORY_TABLE, "selected_rows"),
Input(ids.SAVE_FEEDBACK_BUTTON_POPUP, "n_clicks"),
Input(ids.CLOSE_FEEDBACK_BUTTON_POPUP, "n_clicks"),
State(ids.FEEDBACK_MODAL, "is_open"),
State(ids.CATEGORY_TABLE, "data"),
State(ids.FEEDBACK_INPUT, "value"),
)
def handle_feedback_modal(selected_rows, save_clicks, close_clicks, is_open, data, comment):
ctx = callback_context
if not ctx.triggered:
return False, ""
return html.Div(id=ids.BAR_CHART) triggered_id = ctx.triggered[0]['prop_id'].split('.')[0]
if triggered_id == ids.CATEGORY_TABLE and selected_rows:
return True, ""
if triggered_id == ids.SAVE_FEEDBACK_BUTTON_POPUP and selected_rows:
selected_row_data = data[selected_rows[0]]
category = selected_row_data[DataSchema.CATEGORY]
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if not comment:
comment = ""
feedback_data = f'{timestamp},{category},"{comment}"\n'
file_path = "data/feedback.csv"
try:
is_new_file = not os.path.exists(file_path) or os.path.getsize(file_path) == 0
with open(file_path, "a") as f:
if is_new_file:
f.write("timestamp,category,comment\n")
f.write(feedback_data)
message = f"Feedback for category '{category}' has been saved successfully at {timestamp}."
return False, message
except Exception as e:
return True, "An error occurred while saving feedback."
if triggered_id == ids.CLOSE_FEEDBACK_BUTTON_POPUP:
return False, ""
return is_open, ""
@app.callback(
Output("feedback-category-label", "children"),
Input(ids.CATEGORY_TABLE, "selected_rows"),
State(ids.CATEGORY_TABLE, "data"),
prevent_initial_call=True
)
def update_feedback_category_label(selected_rows, data):
if selected_rows:
category = data[selected_rows[0]][DataSchema.CATEGORY]
return f"Category: {category}"
return "Category: "
modal = dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Submit Feedback")),
dbc.ModalBody([
html.H6("Category: ", id="feedback-category-label"),
dcc.Input(id=ids.FEEDBACK_INPUT, type='text', placeholder='Enter feedback...', style={'width': '100%'}),
]),
dbc.ModalFooter([
dbc.Button("Save", id=ids.SAVE_FEEDBACK_BUTTON_POPUP, className="ms-auto", n_clicks=0),
dbc.Button("Close", id=ids.CLOSE_FEEDBACK_BUTTON_POPUP, className="ms-auto", n_clicks=0)
]),
],
id=ids.FEEDBACK_MODAL,
is_open=False,
)
return html.Div([
html.Div(id=ids.DATA_TABLE),
html.Div(id=ids.FEEDBACK_MESSAGE),
modal
])

View File

@ -0,0 +1,22 @@
from dash import Dash, dcc, html
def render(_: Dash) -> html.Div:
return html.Div([
dcc.Markdown("""
### How to use this Dashboard
**Dashboard Tab:**
- Use the dropdowns at the top to filter the data by year, week, and category.
- The bar chart shows the total amount per category for the selected period.
- The data table shows the detailed data.
**Feedback:**
- In the data table on the Dashboard tab, you can select a row to provide feedback.
- A popup will appear where you can enter your comments for the selected category.
- Click "Save" to store your feedback.
**Feedback Tab:**
- This tab displays all the feedback that has been submitted.
- The table on this tab updates automatically every 5 seconds.
""")
])

View File

@ -0,0 +1,27 @@
from dash import Dash, dcc, html, dash_table
from dash.dependencies import Input, Output
import pandas as pd
def render(app: Dash) -> html.Div:
@app.callback(
Output("feedback-table-content", "children"),
Input("feedback-interval", "n_intervals")
)
def update_feedback_table(_):
try:
feedback_df = pd.read_csv("data/feedback.csv")
return dash_table.DataTable(
data=feedback_df.to_dict("records"),
columns=[{"name": i, "id": i} for i in feedback_df.columns],
page_size=10,
)
except FileNotFoundError:
return html.P("No feedback submitted yet.")
except Exception as e:
return html.P(f"An error occurred: {e}")
return html.Div([
html.H4("Submitted Feedback"),
html.Div(id="feedback-table-content"),
dcc.Interval(id="feedback-interval", interval=5 * 1000, n_intervals=0) # 5 seconds
])

View File

@ -5,8 +5,16 @@ DATA_TABLE = "data-table"
SELECT_ALL_CATEGORIES_BUTTON = "select-all-categories-button" SELECT_ALL_CATEGORIES_BUTTON = "select-all-categories-button"
CATEGORY_DROPDOWN = "category-dropdown" CATEGORY_DROPDOWN = "category-dropdown"
SELECT_ALL_MONTHS_BUTTON = "select-all-months-button"
MONTH_DROPDOWN = "month-dropdown"
YEAR_DROPDOWN = "year-dropdown" YEAR_DROPDOWN = "year-dropdown"
SELECT_ALL_YEARS_BUTTON = "select-all-years-button" SELECT_ALL_YEARS_BUTTON = "select-all-years-button"
WEEK_DROPDOWN = "week-dropdown"
SELECT_ALL_WEEKS_BUTTON = "select-all-weeks-button"
CATEGORY_TABLE = "category-table"
FEEDBACK_INPUT = "feedback-input"
FEEDBACK_MESSAGE = "feedback-message"
FEEDBACK_MODAL = "feedback-modal"
SAVE_FEEDBACK_BUTTON_POPUP = "save-feedback-button-popup"
CLOSE_FEEDBACK_BUTTON_POPUP = "close-feedback-button-popup"

View File

@ -1,29 +1,48 @@
import pandas as pd import pandas as pd
from dash import Dash, html from dash import Dash, dcc, html
from src.components import ( from src.components import (
bar_chart, bar_chart,
category_dropdown, data_table,
month_dropdown,
pie_chart,
year_dropdown, year_dropdown,
week_dropdown,
category_dropdown,
feedback_tab,
explanation_tab,
) )
def create_layout(app: Dash, data: pd.DataFrame) -> html.Div: def create_layout(app: Dash, data: pd.DataFrame) -> html.Div:
tab_content_style = {'height': 'calc(100vh - 220px)', 'overflowY': 'auto', 'padding': '15px'}
return html.Div( return html.Div(
className="app-div", className="app-div",
children=[ children=[
html.H1(app.title), html.H1(app.title),
html.Hr(), html.Hr(),
dcc.Tabs(id="tabs", value='tab-dashboard', children=[
dcc.Tab(label='Dashboard', value='tab-dashboard', children=[
html.Div(style=tab_content_style, children=[
html.Div( html.Div(
className="dropdown-container", className="dropdown-container",
children=[ children=[
year_dropdown.render(app, data), year_dropdown.render(app, data),
month_dropdown.render(app, data), week_dropdown.render(app, data),
category_dropdown.render(app, data), category_dropdown.render(app, data),
], ],
), ),
bar_chart.render(app, data), bar_chart.render(app, data),
pie_chart.render(app, data), data_table.render(app, data),
])
]),
dcc.Tab(label='Feedback', value='tab-feedback', children=[
html.Div(style=tab_content_style, children=[
feedback_tab.render(app)
])
]),
dcc.Tab(label='Explanation', value='tab-explanation', children=[
html.Div(style=tab_content_style, children=[
explanation_tab.render(app)
])
]),
]),
], ],
) )

View File

@ -1,40 +0,0 @@
import pandas as pd
from dash import Dash, dcc, html
from dash.dependencies import Input, Output
from ..data.loader import DataSchema
from . import ids
def render(app: Dash, data: pd.DataFrame) -> html.Div:
all_months: list[str] = data[DataSchema.MONTH].tolist()
unique_months = sorted(set(all_months))
@app.callback(
Output(ids.MONTH_DROPDOWN, "value"),
[
Input(ids.YEAR_DROPDOWN, "value"),
Input(ids.SELECT_ALL_MONTHS_BUTTON, "n_clicks"),
],
)
def select_all_months(years: list[str], _: int) -> list[str]:
filtered_data = data.query("year in @years")
return sorted(set(filtered_data[DataSchema.MONTH].tolist()))
return html.Div(
children=[
html.H6("Month"),
dcc.Dropdown(
id=ids.MONTH_DROPDOWN,
options=[{"label": month, "value": month} for month in unique_months],
value=unique_months,
multi=True,
),
html.Button(
className="dropdown-button",
children=["Select All"],
id=ids.SELECT_ALL_MONTHS_BUTTON,
n_clicks=0,
),
]
)

View File

@ -0,0 +1,53 @@
import pandas as pd
from dash import Dash, dcc, html
from dash.dependencies import Input, Output
from datetime import datetime, timedelta
from ..data.loader import DataSchema
from . import ids
def render(app: Dash, data: pd.DataFrame) -> html.Div:
all_weeks: list[str] = data[DataSchema.WEEK].tolist()
unique_weeks = sorted(set(all_weeks), reverse=True)
# determine default weeks (last 13)
today = datetime.now()
last_13_weeks = []
for i in range(13):
date = today - timedelta(weeks=i)
year, week, _ = date.isocalendar()
last_13_weeks.append(f"{year}{week:02d}")
default_weeks = [week for week in last_13_weeks if week in unique_weeks]
if not default_weeks and unique_weeks:
default_weeks = unique_weeks[:13]
@app.callback(
Output(ids.WEEK_DROPDOWN, "value"),
[
Input(ids.YEAR_DROPDOWN, "value"),
Input(ids.SELECT_ALL_WEEKS_BUTTON, "n_clicks"),
],
)
def select_all_weeks(years: list[str], _: int) -> list[str]:
filtered_data = data.query("year in @years")
return sorted(set(filtered_data[DataSchema.WEEK].tolist()))
return html.Div(
children=[
html.H6("Week"),
dcc.Dropdown(
id=ids.WEEK_DROPDOWN,
options=[{"label": f"{week[:4]}-{week[4:]}", "value": week} for week in unique_weeks],
value=default_weeks,
multi=True,
),
html.Button(
className="dropdown-button",
children=["Select All"],
id=ids.SELECT_ALL_WEEKS_BUTTON,
n_clicks=0,
),
]
)

View File

@ -7,6 +7,7 @@ class DataSchema:
DATE = "date" DATE = "date"
MONTH = "month" MONTH = "month"
YEAR = "year" YEAR = "year"
WEEK = "week"
def load_transaction_data(path: str) -> pd.DataFrame: def load_transaction_data(path: str) -> pd.DataFrame:
@ -22,4 +23,8 @@ def load_transaction_data(path: str) -> pd.DataFrame:
) )
data[DataSchema.YEAR] = data[DataSchema.DATE].dt.year.astype(str) data[DataSchema.YEAR] = data[DataSchema.DATE].dt.year.astype(str)
data[DataSchema.MONTH] = data[DataSchema.DATE].dt.month.astype(str) data[DataSchema.MONTH] = data[DataSchema.DATE].dt.month.astype(str)
data[DataSchema.WEEK] = (
data[DataSchema.DATE].dt.isocalendar().year.astype(str)
+ data[DataSchema.DATE].dt.isocalendar().week.astype(str).str.zfill(2)
)
return data return data

View File

@ -6,6 +6,7 @@ class DataSchema:
DATE = "date" DATE = "date"
MONTH = "month" MONTH = "month"
YEAR = "year" YEAR = "year"
WEEK = "week"
class SPC_Schema: class SPC_Schema:
FC = "FC" FC = "FC"
@ -30,4 +31,8 @@ def load_spc_data(path: str) -> pd.DataFrame:
) )
data[DataSchema.YEAR] = data[DataSchema.DATE].dt.year.astype(str) data[DataSchema.YEAR] = data[DataSchema.DATE].dt.year.astype(str)
data[DataSchema.MONTH] = data[DataSchema.DATE].dt.month.astype(str) data[DataSchema.MONTH] = data[DataSchema.DATE].dt.month.astype(str)
data[DataSchema.WEEK] = (
data[DataSchema.DATE].dt.isocalendar().year.astype(str)
+ data[DataSchema.DATE].dt.isocalendar().week.astype(str).str.zfill(2)
)
return data return data

22
uv.lock generated
View File

@ -9,12 +9,14 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "dash" }, { name = "dash" },
{ name = "dash-bootstrap-components" }, { name = "dash-bootstrap-components" },
{ name = "dotenv" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "dash", specifier = ">=3.2.0" }, { name = "dash", specifier = ">=3.2.0" },
{ name = "dash-bootstrap-components", specifier = ">=2.0.4" }, { name = "dash-bootstrap-components", specifier = ">=2.0.4" },
{ name = "dotenv", specifier = ">=0.9.9" },
] ]
[[package]] [[package]]
@ -141,6 +143,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/38/1efeec8b4d741c09ccd169baf8a00c07a0176b58e418d4cd0c30dffedd22/dash_bootstrap_components-2.0.4-py3-none-any.whl", hash = "sha256:767cf0084586c1b2b614ccf50f79fe4525fdbbf8e3a161ed60016e584a14f5d1", size = 204044 }, { url = "https://files.pythonhosted.org/packages/d6/38/1efeec8b4d741c09ccd169baf8a00c07a0176b58e418d4cd0c30dffedd22/dash_bootstrap_components-2.0.4-py3-none-any.whl", hash = "sha256:767cf0084586c1b2b614ccf50f79fe4525fdbbf8e3a161ed60016e584a14f5d1", size = 204044 },
] ]
[[package]]
name = "dotenv"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dotenv" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 },
]
[[package]] [[package]]
name = "flask" name = "flask"
version = "3.1.2" version = "3.1.2"
@ -288,6 +301,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/a9/12e2dc726ba1ba775a2c6922d5d5b4488ad60bdab0888c337c194c8e6de8/plotly-6.3.0-py3-none-any.whl", hash = "sha256:7ad806edce9d3cdd882eaebaf97c0c9e252043ed1ed3d382c3e3520ec07806d4", size = 9791257 }, { url = "https://files.pythonhosted.org/packages/95/a9/12e2dc726ba1ba775a2c6922d5d5b4488ad60bdab0888c337c194c8e6de8/plotly-6.3.0-py3-none-any.whl", hash = "sha256:7ad806edce9d3cdd882eaebaf97c0c9e252043ed1ed3d382c3e3520ec07806d4", size = 9791257 },
] ]
[[package]]
name = "python-dotenv"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"