From e1b817252c61528abce1a649001fbc79c8d258de Mon Sep 17 00:00:00 2001 From: jack leene Date: Sun, 7 Sep 2025 20:16:56 +0200 Subject: [PATCH] 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 --- assets/style.css | 16 +++- data/feedback.csv | 2 + main.py | 4 +- src/components/bar_chart.py | 8 +- src/components/category_dropdown.py | 8 +- src/components/data_table.py | 114 +++++++++++++++++++++++----- src/components/explanation_tab.py | 22 ++++++ src/components/feedback_tab.py | 27 +++++++ src/components/ids.py | 14 +++- src/components/layout.py | 49 ++++++++---- src/components/month_dropdown.py | 40 ---------- src/components/week_dropdown.py | 53 +++++++++++++ src/data/loader.py | 7 +- src/data/loader_gz.py | 7 +- 14 files changed, 281 insertions(+), 90 deletions(-) create mode 100644 data/feedback.csv create mode 100644 src/components/explanation_tab.py create mode 100644 src/components/feedback_tab.py delete mode 100644 src/components/month_dropdown.py create mode 100644 src/components/week_dropdown.py diff --git a/assets/style.css b/assets/style.css index 37122ed..3f4e0fa 100644 --- a/assets/style.css +++ b/assets/style.css @@ -58,4 +58,18 @@ hr { .dropdown-container { grid-template-columns: 1fr; } -} \ No newline at end of file +} + +.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; +} diff --git a/data/feedback.csv b/data/feedback.csv new file mode 100644 index 0000000..f08e885 --- /dev/null +++ b/data/feedback.csv @@ -0,0 +1,2 @@ +timestamp,category,comment +2025-09-07 20:02:38,groceries,"ABZT please change this air to P1231313" diff --git a/main.py b/main.py index b60f7a2..3ebe295 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ # initial commit 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.data.loader_gz import load_spc_data @@ -21,7 +21,7 @@ def main() -> None: # load the data and create the data manager data = load_spc_data(config["DATA_PATH"]) - app = Dash(external_stylesheets=[BOOTSTRAP]) + app = Dash(external_stylesheets=[dbc.themes.LUX]) app.title = "Reliability Dashboard" app.layout = create_layout(app, data) app.run() diff --git a/src/components/bar_chart.py b/src/components/bar_chart.py index dfa2e49..1a9bf58 100644 --- a/src/components/bar_chart.py +++ b/src/components/bar_chart.py @@ -12,15 +12,15 @@ def render(app: Dash, data: pd.DataFrame) -> html.Div: Output(ids.BAR_CHART, "children"), [ Input(ids.YEAR_DROPDOWN, "value"), - Input(ids.MONTH_DROPDOWN, "value"), + Input(ids.WEEK_DROPDOWN, "value"), Input(ids.CATEGORY_DROPDOWN, "value"), ], ) def update_bar_chart( - years: list[str], months: list[str], categories: list[str] + years: list[str], weeks: list[str], categories: list[str] ) -> html.Div: 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: @@ -45,4 +45,4 @@ def render(app: Dash, data: pd.DataFrame) -> html.Div: return html.Div(dcc.Graph(figure=fig), id=ids.BAR_CHART) - return html.Div(id=ids.BAR_CHART) + return html.Div(id=ids.BAR_CHART) \ No newline at end of file diff --git a/src/components/category_dropdown.py b/src/components/category_dropdown.py index 2d2e17b..125a0e5 100644 --- a/src/components/category_dropdown.py +++ b/src/components/category_dropdown.py @@ -14,12 +14,12 @@ def render(app: Dash, data: pd.DataFrame) -> html.Div: Output(ids.CATEGORY_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"), ], ) - def select_all_categories(years: list[str], months: list[str], _: int) -> list[str]: - filtered_data = data.query("year in @years and month in @months") + def select_all_categories(years: list[str], weeks: list[str], _: int) -> list[str]: + filtered_data = data.query("year in @years and week in @weeks") return sorted(set(filtered_data[DataSchema.CATEGORY].tolist())) return html.Div( @@ -42,4 +42,4 @@ def render(app: Dash, data: pd.DataFrame) -> html.Div: n_clicks=0, ), ], - ) + ) \ No newline at end of file diff --git a/src/components/data_table.py b/src/components/data_table.py index eeea71f..c1b92a5 100644 --- a/src/components/data_table.py +++ b/src/components/data_table.py @@ -1,45 +1,123 @@ import pandas as pd -from dash import Dash, dcc, html, dash_table -from dash.dependencies import Input, Output +from dash import Dash, dcc, html, dash_table, Input, Output, State, callback_context +from datetime import datetime +import os +import dash_bootstrap_components as dbc from ..data.loader import DataSchema from . import ids - def render(app: Dash, data: pd.DataFrame) -> html.Div: @app.callback( Output(ids.DATA_TABLE, "children"), [ Input(ids.YEAR_DROPDOWN, "value"), - Input(ids.MONTH_DROPDOWN, "value"), + Input(ids.WEEK_DROPDOWN, "value"), Input(ids.CATEGORY_DROPDOWN, "value"), ], ) def update_data_table( - years: list[str], months: list[str], categories: list[str] + years: list[str], weeks: list[str], categories: list[str] ) -> html.Div: 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: return html.Div("No data selected.") - def create_pivot_table() -> pd.DataFrame: - pt = filtered_data.pivot_table( - values=DataSchema.AMOUNT, - index=[DataSchema.CATEGORY], - aggfunc="sum", - fill_value=0, - dropna=False, - ) - return pt.reset_index().sort_values(DataSchema.AMOUNT, ascending=False) + pt = filtered_data.pivot_table( + values=DataSchema.AMOUNT, + index=[DataSchema.CATEGORY], + aggfunc="sum", + fill_value=0, + dropna=False, + ).reset_index().sort_values(DataSchema.AMOUNT, ascending=False) - pt = create_pivot_table() + columns = [{"name": i.capitalize(), "id": i} for i in pt.columns] return dash_table.DataTable( + id=ids.CATEGORY_TABLE, data=pt.to_dict("records"), - columns=[{"name": i, "id": i} for i in pt.columns], + columns=columns, + row_selectable='single', + selected_rows=[] ) - return html.Div(id=ids.DATA_TABLE) \ No newline at end of file + @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, "" + + 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 + ]) \ No newline at end of file diff --git a/src/components/explanation_tab.py b/src/components/explanation_tab.py new file mode 100644 index 0000000..60ce872 --- /dev/null +++ b/src/components/explanation_tab.py @@ -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. + """) + ]) diff --git a/src/components/feedback_tab.py b/src/components/feedback_tab.py new file mode 100644 index 0000000..03e559b --- /dev/null +++ b/src/components/feedback_tab.py @@ -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 + ]) diff --git a/src/components/ids.py b/src/components/ids.py index 22a44ea..a820e36 100644 --- a/src/components/ids.py +++ b/src/components/ids.py @@ -5,8 +5,16 @@ DATA_TABLE = "data-table" SELECT_ALL_CATEGORIES_BUTTON = "select-all-categories-button" CATEGORY_DROPDOWN = "category-dropdown" -SELECT_ALL_MONTHS_BUTTON = "select-all-months-button" -MONTH_DROPDOWN = "month-dropdown" - YEAR_DROPDOWN = "year-dropdown" 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" \ No newline at end of file diff --git a/src/components/layout.py b/src/components/layout.py index 1a39fa9..1e642b2 100644 --- a/src/components/layout.py +++ b/src/components/layout.py @@ -1,31 +1,48 @@ import pandas as pd - -from dash import Dash, html +from dash import Dash, dcc, html from src.components import ( bar_chart, data_table, - pie_chart, year_dropdown, - month_dropdown, + week_dropdown, category_dropdown, + feedback_tab, + explanation_tab, ) def create_layout(app: Dash, data: pd.DataFrame) -> html.Div: + tab_content_style = {'height': 'calc(100vh - 220px)', 'overflowY': 'auto', 'padding': '15px'} + return html.Div( className="app-div", children=[ html.H1(app.title), html.Hr(), - html.Div( - className="dropdown-container", - children=[ - year_dropdown.render(app, data), - month_dropdown.render(app, data), - category_dropdown.render(app, data), - ], - ), - bar_chart.render(app, data), - pie_chart.render(app, data), - data_table.render(app, data), + 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( + className="dropdown-container", + children=[ + year_dropdown.render(app, data), + week_dropdown.render(app, data), + category_dropdown.render(app, data), + ], + ), + bar_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) + ]) + ]), + ]), ], - ) + ) \ No newline at end of file diff --git a/src/components/month_dropdown.py b/src/components/month_dropdown.py deleted file mode 100644 index af36177..0000000 --- a/src/components/month_dropdown.py +++ /dev/null @@ -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, - ), - ] - ) diff --git a/src/components/week_dropdown.py b/src/components/week_dropdown.py new file mode 100644 index 0000000..f25431f --- /dev/null +++ b/src/components/week_dropdown.py @@ -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, + ), + ] + ) diff --git a/src/data/loader.py b/src/data/loader.py index 2f3e2a7..0e45e7b 100644 --- a/src/data/loader.py +++ b/src/data/loader.py @@ -7,6 +7,7 @@ class DataSchema: DATE = "date" MONTH = "month" YEAR = "year" + WEEK = "week" 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.MONTH] = data[DataSchema.DATE].dt.month.astype(str) - return data + data[DataSchema.WEEK] = ( + data[DataSchema.DATE].dt.isocalendar().year.astype(str) + + data[DataSchema.DATE].dt.isocalendar().week.astype(str).str.zfill(2) + ) + return data \ No newline at end of file diff --git a/src/data/loader_gz.py b/src/data/loader_gz.py index 27a5aea..a7c3f7a 100644 --- a/src/data/loader_gz.py +++ b/src/data/loader_gz.py @@ -6,6 +6,7 @@ class DataSchema: DATE = "date" MONTH = "month" YEAR = "year" + WEEK = "week" class SPC_Schema: 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.MONTH] = data[DataSchema.DATE].dt.month.astype(str) - return data + data[DataSchema.WEEK] = ( + data[DataSchema.DATE].dt.isocalendar().year.astype(str) + + data[DataSchema.DATE].dt.isocalendar().week.astype(str).str.zfill(2) + ) + return data \ No newline at end of file