Compare commits

29 Commits

Author SHA1 Message Date
e9a218bb88 Change net income figure; Add table 2024-01-07 18:54:24 +01:00
7c75bd3c37 Round values 2024-01-07 18:15:52 +01:00
1e4efa0c5b Add expense-by-category figure 2024-01-06 22:42:46 +01:00
99a7920118 Restructure data access to pandas model 2024-01-05 14:32:45 +01:00
8d660ea8c2 Reset sorting to normal 2024-01-05 13:15:33 +01:00
be3f3b604e Fix delete category 2024-01-05 13:12:42 +01:00
6a497c8575 Fix sorting for floats 2024-01-04 22:21:00 +01:00
ab1a1c3aad Finish reorganization of ui code 2024-01-04 21:16:15 +01:00
050f5f0ae4 Separate WarningItem and HeaderContextMenu from MainWindow 2024-01-04 19:34:01 +01:00
393f654a57 Change the way PandasModel operates 2024-01-04 18:36:53 +01:00
29aaa9066f Restructure UI code and clean up PandasModel 2024-01-04 17:22:14 +01:00
8c2fa9296d Preserve sort on data reload 2024-01-04 15:37:28 +01:00
0fdaff0fa2 Implement 'assign type' action 2024-01-04 15:31:26 +01:00
ba6b6c2e4a Add clear button; Disable buttons when function unavailable 2024-01-04 14:13:46 +01:00
f4b73064ff Automatically update categories from loaded dataframe 2024-01-04 05:29:21 +01:00
e2bf90d4d8 Implement clearing of category upon deletion 2024-01-04 05:17:32 +01:00
3dad927bf7 Implement category assignment 2024-01-04 05:12:02 +01:00
7c72e59a99 Implement category creation and deletion 2024-01-04 04:50:57 +01:00
d8f571bf31 Fixed bugs in tableview header context menu 2024-01-04 04:36:37 +01:00
ebe5857d37 Code cleanup 2024-01-04 03:30:56 +01:00
97e770ab0d Replace listView by listWidget 2024-01-04 03:28:32 +01:00
0816ebd26b Add category column if it does not exist 2024-01-04 03:19:30 +01:00
8a0d7f748f Implement show/hide warnings depending on columns 2024-01-04 02:29:34 +01:00
14e830ead0 Implement column deletion and renaming 2024-01-04 01:31:36 +01:00
d4211b6744 Changed the way ui files are handled 2024-01-03 23:08:26 +01:00
c72885258b Clean up MainWindow implementation 2024-01-03 22:32:06 +01:00
fb3a54d430 Add warning labels 2024-01-03 18:47:32 +01:00
37bc515712 Change the way categories are added 2024-01-03 16:09:57 +01:00
a31f754cfd Update README.md 2024-01-03 15:53:26 +01:00
16 changed files with 1034 additions and 384 deletions

View File

@@ -1,3 +1,22 @@
# banking-breakdown # banking-breakdown
Visualize bank statements. Visualize bank statements.
## Usage
1. Assign categories to bank statement entries:
```bash
$ python -m banking_breakdown categorize -i [bank_statement.csv] -f [regex_file]
```
2. Generate report from categorized data:
```bash
$ python -m banking_breakdown report -i [categorized.csv]
```
## Other
### Generate GUI from `*.ui` file
```bash
$ pyuic6 res/main_window.ui -o banking_breakdown/ui/main_window.py
```

View File

@@ -1,22 +1,26 @@
from banking_breakdown import document_builder from banking_breakdown import ui, regex_categorizer, statement_parser, \
from banking_breakdown import statement_parser document_builder
from banking_breakdown import ui
import argparse import argparse
def categorize_func(args): def categorize_func(args):
from banking_breakdown.statement_parser import get_stripped_statement import pandas as pd
df = None df = pd.read_csv(args.i, delimiter=args.d)
if args.i is not None:
df = get_stripped_statement(args.i)
ui.show_main_window("res/main_window.ui", df=df) if args.f is not None:
df = regex_categorizer.assign_categories(df, args.f)
import signal
signal.signal(signal.SIGINT, signal.SIG_DFL)
ui.show_main_window(df=df)
def report_func(args): def report_func(args):
print("Report") report_data = statement_parser.parse_statement(args.i)
document_builder.build_document(report_data)
# #
@@ -32,11 +36,14 @@ def main():
categorize_parser = subparsers.add_parser("categorize") categorize_parser = subparsers.add_parser("categorize")
categorize_parser.set_defaults(func=categorize_func) categorize_parser.set_defaults(func=categorize_func)
categorize_parser.add_argument('-i', required=False, categorize_parser.add_argument('-i', required=True,
help="Bank statement CSV") help="Bank statement CSV")
categorize_parser.add_argument('-f', required=False, categorize_parser.add_argument('-f', required=False,
help="JSON file containing regexes to" help="JSON file containing regexes to"
" pre-categorize statement entries") " pre-categorize statement entries")
categorize_parser.add_argument('-d', required=False,
help="Delimiter to use when reading the"
" bank statement", default=',')
report_parser = subparsers.add_parser("report") report_parser = subparsers.add_parser("report")
report_parser.set_defaults(func=report_func) report_parser.set_defaults(func=report_func)

View File

@@ -14,6 +14,8 @@ def _serialize_report_data(report_data: types.ReportData):
report_data.net_income.to_csv('build/net_income.csv', index=False) report_data.net_income.to_csv('build/net_income.csv', index=False)
report_data.category_overview.to_csv('build/category_overview.csv', report_data.category_overview.to_csv('build/category_overview.csv',
index=False) index=False)
report_data.expenses_by_category.to_csv('build/expenses_by_category.csv',
index=False)
report_data.total_value.to_csv('build/total_value.csv', index=False) report_data.total_value.to_csv('build/total_value.csv', index=False)
report_data.detailed_balance.to_csv('build/detailed_balance.csv', report_data.detailed_balance.to_csv('build/detailed_balance.csv',
index=False) index=False)

View File

@@ -0,0 +1,54 @@
import pandas as pd
import json
def _is_str_column(s: pd.Series):
"""Check if the type of a pandas DataFrame column is str.
Taken from https://stackoverflow.com/a/67001213/3433817.
"""
if isinstance(s.dtype, pd.StringDtype):
# The series was explicitly created as a string series (Pandas>=1.0.0)
return True
elif s.dtype == 'object':
# Object series, check each value
return all((v is None) or isinstance(v, str) for v in s)
else:
return False
def _read_regex_dict(regex_file: str):
with open(regex_file, 'r') as f:
return json.load(f)
def assign_categories(df: pd.DataFrame, regex_file: str) -> pd.DataFrame:
if 'category' not in df.columns:
df['category'] = [' '] * len(df.index)
regex_dict = _read_regex_dict(regex_file)
df = df.fillna('')
for column in df.columns:
if not _is_str_column(df[column]):
continue
for category in regex_dict:
for regex in regex_dict[category]:
matched = df[column].str.contains(regex, regex=True)
df.loc[matched, 'category'] = category
return df
def main():
df = pd.read_csv('../res/bank_statement_2023_categorized.csv')
df = assign_categories(df, regex_file='../res/regexes.json')
print(df['category'])
if __name__ == "__main__":
main()

View File

@@ -6,92 +6,117 @@ import re
import numpy as np import numpy as np
# def _read_regex_dict(regex_file: str = "res/category_regexes.json"): def _escape_string(to_escape: str):
# with open(regex_file, 'r') as f: return to_escape.translate(str.maketrans({"&": r"\&"}))
# return json.load(f)
#
#
# def _tag_with_category(df: pd.DataFrame) -> pd.DataFrame:
# regex_dict = _read_regex_dict()
#
# return df
#
#
# def _compute_total_balance(df: pd.DataFrame) -> pd.DataFrame:
# stripped_df = pd.DataFrame(
# {'t': df["Valutadatum"], 'value': df["Saldo nach Buchung"]})
#
# stripped_df.index = stripped_df['t']
# gb = stripped_df.groupby(pd.Grouper(freq='M'))
#
# result = gb.tail(1)['value'].reset_index()
#
# return result
#
#
# def _compute_net_income(df: pd.DataFrame) -> pd.DataFrame:
# stripped_df = pd.DataFrame({'t': df["Valutadatum"], 'value': df["Betrag"]})
#
# stripped_df.index = stripped_df['t']
# gb = stripped_df.groupby(pd.Grouper(freq='M'))
#
# result = gb["value"].sum().reset_index()
# return result
#
#
# def _compute_category_overview(df: pd.DataFrame) -> pd.DataFrame:
# categories = ["Social life", "Other", "Food", "Hobbies",
# "Rent \\& Utilities", "Education", "Transportation"]
# values = np.array([10, 12, 53, 12, 90, 23, 32])
# values = values / values.sum() * 100
# values = np.round(values, decimals=1)
# values[-1] += 100 - np.sum(values)
#
# category_overview_df = pd.DataFrame(
# {"category": categories, "value": values})
#
# return category_overview_df
#
#
# def _compute_detailed_balance(df: pd.DataFrame) -> pd.DataFrame:
# return pd.DataFrame({'t': df["Valutadatum"],
# 'value': df["Saldo nach Buchung"]})
#
#
# def parse_statement(filename: str) -> types.ReportData:
# df = pd.read_csv(filename, delimiter=';', decimal=",")
# df["Valutadatum"] = pd.to_datetime(df["Valutadatum"], format='%d.%m.%Y')
#
# category_overview_df = _compute_category_overview(df)
# total_balance_df = _compute_total_balance(df)
# net_income_df = _compute_net_income(df)
# detailed_balance_df = _compute_detailed_balance(df)
#
# return types.ReportData(category_overview_df,
# net_income_df,
# total_balance_df,
# detailed_balance_df)
#
#
# def main():
# report_data = parse_statement("../res/banking_statement_2023.csv")
#
#
# if __name__ == "__main__":
# main()
def get_stripped_statement(filename: str) -> pd.DataFrame: def _compute_total_balance(df: pd.DataFrame) -> pd.DataFrame:
df = pd.read_csv(filename, delimiter=';', decimal=",") stripped_df = pd.DataFrame(
df["Valutadatum"] = pd.to_datetime(df["Valutadatum"], format='%d.%m.%Y') {'t': df["t"], 'value': df["balance"]})
result = pd.DataFrame({'t': df["Valutadatum"], stripped_df.index = stripped_df['t']
'other party': df["Name Zahlungsbeteiligter"], gb = stripped_df.groupby(pd.Grouper(freq='M'))
'value': df["Betrag"],
'balance afterwards': df["Saldo nach Buchung"], result = gb.tail(1)['value'].reset_index()
'description': df["Buchungstext"],
'purpose': df["Verwendungszweck"]
})
result['category'] = [''] * len(result.index)
return result return result
def _compute_net_income(df: pd.DataFrame) -> pd.DataFrame:
df.index = df['t']
income_df = df.loc[df['value'] > 0]
expenses_df = df.loc[df['value'] < 0]
income_df = income_df.groupby(pd.Grouper(freq='M'))[
'value'].sum().reset_index().round(decimals=2)
expenses_df = expenses_df.groupby(pd.Grouper(freq='M'))[
'value'].sum().reset_index().round(decimals=2)
t = income_df['t']
income = income_df['value'].round(decimals=2)
expenses = expenses_df['value'].round(decimals=2)
net = (income + expenses).round(decimals=2)
result_df = pd.DataFrame(
{'t': t, 'income': income, 'expenses': expenses, 'net': net})
return result_df
def _compute_category_overview(df: pd.DataFrame) -> pd.DataFrame:
df = df.loc[df['value'] < 0]
df = df.drop('t', axis=1)
df = df.groupby(['category']).sum().reset_index()
values = (df['value'] / df['value'].sum() * 100).to_numpy()
values[-1] += 100 - np.sum(values)
values = np.round(values, decimals=1)
categories = [_escape_string(category) for category in df['category']]
category_overview_df = pd.DataFrame(
{"category": categories, "value": values})
category_overview_df = category_overview_df.sort_values('value',
ascending=False)
return category_overview_df
def _compute_expenses_by_category(complete_df: pd.DataFrame) -> pd.DataFrame:
complete_df = complete_df.loc[complete_df['value'] < 0].copy()
complete_df['value'] = -complete_df['value']
complete_df.index = complete_df['t']
complete_gb = complete_df.groupby(pd.Grouper(freq='M'))
categories = complete_df['category'].unique()
data_dict = {category: [] for category in categories}
for (month_date, month_df) in complete_gb:
month_df = month_df.drop('t', axis=1).reset_index().drop('t', axis=1)
category_df = month_df.groupby(['category']).sum().reset_index()
for _, row in category_df.iterrows():
data_dict[row['category']].append(row['value'])
non_listed = list(set(categories) - set(category_df['category']))
for category in non_listed:
data_dict[category].append(0)
result = pd.DataFrame(data_dict)
result = result.reindex(result.mean().sort_values(ascending=False).index,
axis=1)
result = result.round(decimals=2)
result['t'] = complete_gb.tail(1).drop('t', axis=1).reset_index()['t']
return result
def _compute_detailed_balance(df: pd.DataFrame) -> pd.DataFrame:
return pd.DataFrame({'t': df["t"],
'value': df["balance"]})
def parse_statement(filename: str) -> types.ReportData:
df = pd.read_csv(filename)
df["t"] = pd.to_datetime(df["t"], format='%Y-%m-%d')
category_overview_df = _compute_category_overview(df)
total_balance_df = _compute_total_balance(df)
net_income_df = _compute_net_income(df)
detailed_balance_df = _compute_detailed_balance(df)
expenses_by_category_df = _compute_expenses_by_category(df)
return types.ReportData(category_overview_df,
expenses_by_category_df,
net_income_df,
total_balance_df,
detailed_balance_df, )
def main():
report_data = parse_statement("../res/bank_statement_2023_categorized.csv")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
{
"asdf": [
"Kinemic"
]
}

View File

@@ -5,6 +5,7 @@ import pandas as pd
@dataclass @dataclass
class ReportData: class ReportData:
category_overview: pd.DataFrame category_overview: pd.DataFrame
expenses_by_category: pd.DataFrame
net_income: pd.DataFrame net_income: pd.DataFrame
total_value: pd.DataFrame total_value: pd.DataFrame
detailed_balance: pd.DataFrame detailed_balance: pd.DataFrame

View File

@@ -1,37 +1,17 @@
import sys import sys
import typing
import pandas as pd import pandas as pd
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
from banking_breakdown.ui.generated_wrapper import GeneratedWindowWrapper from banking_breakdown.ui.main_window import MainWindow
def show_main_window(ui_file, categories: typing.Sequence[str] = None, def show_main_window(df: pd.DataFrame = None):
df: pd.DataFrame = None):
if categories is None:
categories = []
app = QApplication(sys.argv) app = QApplication(sys.argv)
window = GeneratedWindowWrapper(ui_file, categories) window = MainWindow()
if df is not None: if df is not None:
window.set_statement_data(df) window.set_statement_data(df)
window.show() window.show()
app.exec() app.exec()
def main():
from banking_breakdown.statement_parser import get_stripped_statement
categories = ["Food", "Rent & Utilities", "Hobbies", "Education",
"Transportation", "Social Life", "Other"]
df = get_stripped_statement("../../res/banking_statement_2023.csv")
show_main_window("../../res/main_window.ui", categories, df)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,161 @@
import pandas as pd
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QAction
from PyQt6.QtWidgets import QHBoxLayout, QLabel, QMenu, QInputDialog, \
QMessageBox
from banking_breakdown.ui.pandas_model import PandasModel
class WarningItem(QHBoxLayout):
"""Item appearing at top of Window with warning icon."""
def __init__(self, text: str, parent=None):
super(WarningItem, self).__init__()
self._warningIcon = QLabel()
pixmap = QPixmap("res/warning.png")
self._warningIcon.setPixmap(pixmap)
self._label = QLabel(text)
self._label.setWordWrap(True)
self.addWidget(self._warningIcon)
self.addWidget(self._label)
self.setStretch(0, 0)
self.setStretch(1, 1)
def hide(self):
self._label.hide()
self._warningIcon.hide()
class HeaderContextMenu(QMenu):
"""Context menu appearing when right-clicking the header of the QTableView.
"""
def __init__(self, column_index, pandas_model: PandasModel, callback=None,
parent=None):
super(HeaderContextMenu, self).__init__()
self._pandas_model = pandas_model
self._callback = callback
self._column_index = column_index
self._column_text \
= self._pandas_model.headerData(self._column_index,
Qt.Orientation.Horizontal)
# Define assign action
assign_menu = QMenu("Assign type", self)
assign_date_action = QAction("date", self)
assign_float_action = QAction("float", self)
assign_menu.addAction(assign_date_action)
assign_menu.addAction(assign_float_action)
assign_date_action.triggered.connect(self._assign_date_handler)
assign_float_action.triggered.connect(self._assign_float_handler)
# Define other actions
rename_action = QAction("Rename", self)
delete_action = QAction("Delete", self)
switch_action = QAction("Switch position", self)
rename_action.triggered.connect(self._rename_handler)
delete_action.triggered.connect(self._delete_handler)
switch_action.triggered.connect(self._switch_handler)
# Add actions to menu
self.addAction(rename_action)
self.addAction(delete_action)
self.addAction(switch_action)
self.addAction(assign_menu.menuAction())
def _rename_handler(self):
new_name, flag = QInputDialog.getText(self, "Rename column",
"New name:",
text=self._column_text)
if not flag:
return
if (new_name != self._column_text) and (new_name != ''):
try:
self._pandas_model.rename_column(self._column_text, new_name)
except:
QMessageBox.warning(self, "No action performed",
"An error occurred.")
if self._callback:
self._callback()
def _delete_handler(self):
button = QMessageBox.question(self, "Delete column",
f"Are you sure you want to delete"
f" column '{self._column_text}'?")
if button == QMessageBox.StandardButton.Yes:
self._pandas_model.delete_column_by_index(self._column_index)
if self._callback:
self._callback()
def _switch_handler(self):
df = self._pandas_model.get_dataframe()
columns = [column for column in df.columns
if column != self._column_text]
other_name, flag = QInputDialog.getItem(self, "Switch column position",
f"Switch position of colum "
f"'{self._column_text}' with:",
columns, editable=False)
if not flag:
return
self._pandas_model.switch_columns(self._column_text, other_name)
if self._callback:
self._callback()
def _assign_date_handler(self):
date_format, flag = QInputDialog.getText(self, "Format",
"Format:",
text="%d.%m.%Y")
if not flag:
return
try:
self._pandas_model.assign_date_column(self._column_text,
date_format)
except:
QMessageBox.warning(self, "No action performed",
"An error occurred.")
if self._callback:
self._callback()
def _assign_float_handler(self):
chars = ['.', ',']
decimal_sep, flag = QInputDialog.getItem(self, "Decimal separator",
"Decimal separator:",
chars, editable=False)
if not flag:
return
try:
self._pandas_model.assign_float_column(self._column_text,
decimal_sep)
except:
QMessageBox.warning(self, "No action performed",
"An error occurred.")
if self._callback:
self._callback()

View File

@@ -1,86 +0,0 @@
# Form implementation generated from reading ui file 'res/main_window.ui'
#
# Created by: PyQt6 UI code generator 6.6.1
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt6 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(800, 600)
self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout(self.centralwidget)
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.groupBox = QtWidgets.QGroupBox(parent=self.centralwidget)
self.groupBox.setObjectName("groupBox")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.createCategoryButton = QtWidgets.QPushButton(parent=self.groupBox)
self.createCategoryButton.setObjectName("createCategoryButton")
self.horizontalLayout.addWidget(self.createCategoryButton)
self.deleteCategoryButton = QtWidgets.QPushButton(parent=self.groupBox)
self.deleteCategoryButton.setObjectName("deleteCategoryButton")
self.horizontalLayout.addWidget(self.deleteCategoryButton)
self.verticalLayout_2.addLayout(self.horizontalLayout)
self.categoryListView = QtWidgets.QListView(parent=self.groupBox)
self.categoryListView.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)
self.categoryListView.setObjectName("categoryListView")
self.verticalLayout_2.addWidget(self.categoryListView)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.horizontalLayout_2.addItem(spacerItem)
self.applyCategoryButton = QtWidgets.QPushButton(parent=self.groupBox)
self.applyCategoryButton.setObjectName("applyCategoryButton")
self.horizontalLayout_2.addWidget(self.applyCategoryButton)
self.verticalLayout_2.addLayout(self.horizontalLayout_2)
self.horizontalLayout_3.addWidget(self.groupBox)
self.groupBox_2 = QtWidgets.QGroupBox(parent=self.centralwidget)
self.groupBox_2.setObjectName("groupBox_2")
self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox_2)
self.verticalLayout.setObjectName("verticalLayout")
self.statementTableView = QtWidgets.QTableView(parent=self.groupBox_2)
self.statementTableView.setSortingEnabled(False)
self.statementTableView.setWordWrap(True)
self.statementTableView.setObjectName("statementTableView")
self.statementTableView.horizontalHeader().setSortIndicatorShown(False)
self.verticalLayout.addWidget(self.statementTableView)
self.horizontalLayout_3.addWidget(self.groupBox_2)
self.horizontalLayout_3.setStretch(1, 1)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 23))
self.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(parent=self.menubar)
self.menuFile.setObjectName("menuFile")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
self.statusbar.setObjectName("statusbar")
MainWindow.setStatusBar(self.statusbar)
self.actionSave = QtGui.QAction(parent=MainWindow)
self.actionSave.setObjectName("actionSave")
self.menuFile.addAction(self.actionSave)
self.menubar.addAction(self.menuFile.menuAction())
self.retranslateUi(MainWindow)
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
self.groupBox.setTitle(_translate("MainWindow", "Categories"))
self.createCategoryButton.setText(_translate("MainWindow", "Create"))
self.deleteCategoryButton.setText(_translate("MainWindow", "Delete"))
self.applyCategoryButton.setText(_translate("MainWindow", "Apply"))
self.groupBox_2.setTitle(_translate("MainWindow", "Bank statement"))
self.menuFile.setTitle(_translate("MainWindow", "File"))
self.actionSave.setText(_translate("MainWindow", "Save"))
self.actionSave.setShortcut(_translate("MainWindow", "Ctrl+S"))

View File

@@ -1,87 +0,0 @@
import typing
import pandas as pd
from PyQt6.QtWidgets import QMainWindow, QTableView
from PyQt6 import uic, QtGui, QtCore
from PyQt6.QtCore import Qt
from banking_breakdown.ui.generated import Ui_MainWindow
class PandasModel(QtCore.QAbstractTableModel):
def __init__(self, df: pd.DataFrame, parent=None):
QtCore.QAbstractTableModel.__init__(self, parent)
self._data = df
self._horizontalHeaders = [''] * len(df.columns)
for i, column in enumerate(df.columns):
self.setHeaderData(i, Qt.Orientation.Horizontal, column)
def setHeaderData(self, section, orientation, data,
role=Qt.ItemDataRole.EditRole):
if ((orientation == Qt.Orientation.Horizontal)
and ((role == Qt.ItemDataRole.DisplayRole)
or (role == Qt.ItemDataRole.EditRole))):
self._horizontalHeaders[section] = data
return True
else:
return super().setHeaderData(section, orientation, data, role)
def headerData(self, section, orientation,
role=Qt.ItemDataRole.DisplayRole):
if (orientation == Qt.Orientation.Horizontal
and role == Qt.ItemDataRole.DisplayRole):
return self._horizontalHeaders[section]
else:
return super().headerData(section, orientation, role)
def rowCount(self, parent=None):
return len(self._data.values)
def columnCount(self, parent=None):
return self._data.columns.size
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return QtCore.QVariant()
if role != Qt.ItemDataRole.DisplayRole:
return QtCore.QVariant()
item = self._data.iloc[index.row()].iloc[index.column()]
if type(item) is pd.Timestamp:
return QtCore.QVariant(item.strftime('%Y-%m-%d'))
else:
return QtCore.QVariant(str(item))
class GeneratedWindowWrapper(QMainWindow):
def __init__(self, ui_file: str, categories: typing.Sequence):
super(GeneratedWindowWrapper, self).__init__()
self._window = Ui_MainWindow()
self._window.setupUi(self)
# Set up window
self._window.statementTableView.setSelectionBehavior(
QTableView.SelectionBehavior.SelectRows)
self._set_categories(categories)
def _set_categories(self, categories: typing.Sequence[str]):
model = QtGui.QStandardItemModel()
self._window.categoryListView.setModel(model)
for category in categories:
item = QtGui.QStandardItem(category)
model.appendRow(item)
def set_statement_data(self, df: pd.DataFrame):
model = PandasModel(df)
self._window.statementTableView.setModel(model)

View File

@@ -0,0 +1,245 @@
import typing
import pandas as pd
from PyQt6 import uic
from PyQt6.QtCore import Qt, QSortFilterProxyModel
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QMainWindow, QPushButton, QVBoxLayout, \
QTableView, QInputDialog, QMessageBox, QFileDialog, QListWidget
from banking_breakdown.ui.pandas_model import PandasModel
from banking_breakdown.ui.custom_ui_items import WarningItem, HeaderContextMenu
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
uic.loadUi("res/main_window.ui", self)
self.setContentsMargins(9, 9, 9, 9)
self._warnings = []
# Extract elements
self._warning_layout \
= self.findChild(QVBoxLayout, "warningLayout")
self._create_button \
= self.findChild(QPushButton, "createCategoryButton")
self._delete_button \
= self.findChild(QPushButton, "deleteCategoryButton")
self._apply_button \
= self.findChild(QPushButton, "applyCategoryButton")
self._clear_button \
= self.findChild(QPushButton, "clearCategoryButton")
self._list_widget \
= self.findChild(QListWidget, "categoryListWidget")
self._table_view \
= self.findChild(QTableView, "transactionTableView")
self._action_save \
= self.findChild(QAction, "actionSave")
# Set up QTableView model
self._pandas_model = PandasModel(self)
self._proxy_model = QSortFilterProxyModel(self)
self._proxy_model.setSourceModel(self._pandas_model)
self._table_view.setModel(self._proxy_model)
self._proxy_model.setSortRole(Qt.ItemDataRole.EditRole)
self._proxy_model.setDynamicSortFilter(False)
# Set event handlers
self._create_button.clicked.connect(self._handle_create_click)
self._delete_button.clicked.connect(self._handle_delete_click)
self._clear_button.clicked.connect(self._handle_clear_click)
self._apply_button.clicked.connect(self._handle_apply_click)
self._list_widget.itemActivated.connect(self._handle_item_double_click)
self._action_save.triggered.connect(self._handle_save)
header = self._table_view.horizontalHeader()
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
header.customContextMenuRequested.connect(
self._handle_header_right_click)
self._list_widget.itemSelectionChanged.connect(
self._handle_list_selection_changed)
self._table_view.selectionModel().selectionChanged.connect(
self._handle_table_selection_changed)
#
# Table data updates
#
def set_statement_data(self, df: pd.DataFrame):
self._pandas_model.set_dataframe(df)
self._dataframe_update_callback()
self._resize_table_columns_to_content()
def get_statement_data(self) -> pd.DataFrame:
return self._pandas_model.get_dataframe()
def _dataframe_update_callback(self):
self._show_warnings()
self._update_categories_from_dataframe()
def _resize_table_columns_to_content(self):
df = self._pandas_model.get_dataframe()
# Resize columns
if len(df.columns) < 10: # Experimentally determined threshold
# Properly resize columns (takes longer)
self._table_view.resizeColumnsToContents()
else:
# Quickly approximate sizes
for i, col in enumerate(df.columns):
max_char = max(max([len(str(x)) for x in df[col].values]),
len(col))
self._table_view.setColumnWidth(i, max_char * 10)
def _assign_category_to_selected_transactions(self, category: str):
indexes = self._table_view.selectionModel().selectedRows()
row_indices = [self._table_view.model().mapToSource(index).row()
for index in indexes]
self._pandas_model.assign_category(category, row_indices)
#
# List data updates
#
def _add_categories(self, categories: typing.Sequence[str]):
for category in categories:
self._list_widget.addItem(category)
def _update_categories_from_dataframe(self):
df_categories = self._pandas_model.get_categories()
current_categories = [self._list_widget.item(x).text() for x
in range(self._list_widget.count())]
missing = list(set(df_categories) - set(current_categories))
self._add_categories([category for category
in missing if category != ' '])
#
# Warnings
#
def _add_warning_item(self, text: str):
warning_item = WarningItem(text=text, parent=self)
self._warning_layout.addLayout(warning_item)
self._warnings.append(warning_item)
def _show_warnings(self):
for warning_item in self._warnings:
warning_item.hide()
self._warning_layout.removeItem(warning_item)
columns = self._pandas_model.get_columns()
if 't' not in columns:
self._add_warning_item(
"The column 't' does not exist. Please rename the column"
" containing the dates of the transactions to 't'.")
if 'value' not in columns:
self._add_warning_item(
"The column 'value' does not exist. Please rename the column"
" containing the values of the transactions to 'value'.")
if 'balance' not in columns:
self._add_warning_item(
"The column 'balance' does not exist. Please rename the column"
" containing the balance after each transaction to 'balance'")
#
# Event handlers
#
def _handle_header_right_click(self, pos):
column = self._table_view.horizontalHeader().logicalIndexAt(pos)
context = HeaderContextMenu(parent=self, column_index=column,
pandas_model=self._pandas_model,
callback=self._dataframe_update_callback)
context.exec(self.sender().mapToGlobal(pos))
def _handle_create_click(self):
new_name, flag = QInputDialog.getText(self, "Create category",
"New category:",
text="Category")
if not flag:
return
current_items = [self._list_widget.item(x).text() for x
in range(self._list_widget.count())]
if new_name not in current_items:
self._add_categories([new_name])
else:
QMessageBox.warning(self, "No action performed",
f"Category '{new_name}' already exists.")
def _handle_delete_click(self):
selected_item = self._list_widget.selectedItems()[0]
button = QMessageBox.question(self, "Delete category",
f"Are you sure you want to delete"
f" category '{selected_item.text()}'?")
if button == QMessageBox.StandardButton.Yes:
self._pandas_model.delete_category(selected_item.text())
self._list_widget.takeItem(self._list_widget.row(selected_item))
def _handle_clear_click(self):
self._assign_category_to_selected_transactions(' ')
def _handle_apply_click(self):
category = self._list_widget.selectedItems()[0].text()
self._assign_category_to_selected_transactions(category)
def _handle_item_double_click(self, item):
self._assign_category_to_selected_transactions(item.text())
def _handle_save(self):
filename, _ = QFileDialog.getSaveFileName(self, 'Save File')
if filename == '':
return
df = self.get_statement_data()
df.to_csv(filename, index=False)
#
# Enable / Disable buttons
#
def _check_enable_delete_button(self):
if len(self._list_widget.selectedItems()) > 0:
self._delete_button.setEnabled(True)
else:
self._delete_button.setEnabled(False)
def _check_enable_clear_button(self):
if len(self._table_view.selectionModel().selectedRows()) > 0:
self._clear_button.setEnabled(True)
else:
self._clear_button.setEnabled(False)
def _check_enable_apply_button(self):
if ((len(self._table_view.selectionModel().selectedRows()) > 0)
and (len(self._list_widget.selectedItems()) > 0)):
self._apply_button.setEnabled(True)
else:
self._apply_button.setEnabled(False)
def _handle_list_selection_changed(self):
self._check_enable_delete_button()
self._check_enable_apply_button()
def _handle_table_selection_changed(self):
self._check_enable_clear_button()
self._check_enable_apply_button()

View File

@@ -0,0 +1,172 @@
import typing
import numpy
import pandas as pd
from PyQt6 import QtCore
from PyQt6.QtCore import Qt, QModelIndex, QSortFilterProxyModel
def _get_str_dataframe(df: pd.DataFrame) -> pd.DataFrame:
"""Return a given dataframe with all values turned into strings.
When the data given to the PandasModel class contains non-strings,
an attached QTableView seems to respond rather slowly. This function
turns all data in the DataFrame into strings, yielding a better experience.
"""
return df.astype(str)
class PandasModel(QtCore.QAbstractTableModel):
def __init__(self, parent=None):
QtCore.QAbstractTableModel.__init__(self, parent)
self._data = pd.DataFrame()
self._data_str = pd.DataFrame()
#
# Overloaded functions
#
def rowCount(self, parent=None):
return len(self._data_str.values)
def columnCount(self, parent=None):
return self._data_str.columns.size
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return QtCore.QVariant()
if (role != Qt.ItemDataRole.DisplayRole) and (
role != Qt.ItemDataRole.EditRole):
return QtCore.QVariant()
if role == Qt.ItemDataRole.DisplayRole:
item = self._data_str.iloc[index.row(), index.column()]
return QtCore.QVariant(item)
elif role == Qt.ItemDataRole.EditRole:
item = self._data.iloc[index.row(), index.column()]
if type(item) is numpy.float64:
return QtCore.QVariant(float(item))
else:
return QtCore.QVariant(item)
def headerData(self, section, orientation,
role=Qt.ItemDataRole.DisplayRole):
if not ((orientation == Qt.Orientation.Horizontal)
and (role == Qt.ItemDataRole.DisplayRole)):
return super().headerData(section, orientation, role)
return self._data_str.columns[section]
#
# Manipulate categories
#
def assign_category(self, category, row_indices):
if 'category' not in self._data.columns:
self.create_column('category')
self._data.loc[row_indices, 'category'] = category
self._data_str = _get_str_dataframe(self._data)
for row_index in row_indices:
start_index = self.index(row_index, 0)
stop_index = self.index(row_index, len(self._data.columns) - 1)
self.dataChanged.emit(start_index, stop_index)
def delete_category(self, category):
if 'category' not in self._data.columns:
self.create_column('category')
row_indices = self._data.loc[self._data['category'] == category].index
self.assign_category(' ', row_indices)
def get_categories(self) -> typing.List[str]:
if 'category' not in self._data.columns:
self.create_column('category')
return self._data['category'].unique()
#
# Manipulate columns
#
def create_column(self, column, initial_value=' '):
self._data[column] = [initial_value] * len(self._data.index)
self._data_str = _get_str_dataframe(self._data)
self.layoutAboutToBeChanged.emit()
self.layoutChanged.emit()
def delete_column_by_index(self, column_index):
self._data \
= self._data.iloc[:, [j for j, c in enumerate(self._data.columns)
if j != column_index]]
self._data_str = _get_str_dataframe(self._data)
self.layoutAboutToBeChanged.emit()
self.layoutChanged.emit()
def rename_column(self, old_name, new_name):
if new_name in self._data.columns:
raise Exception(
f"A column with the name '{new_name}' already exists.")
self._data = self._data.rename(columns={old_name: new_name})
self._data_str = _get_str_dataframe(self._data)
column_index = self._data.columns.get_loc(new_name)
self.headerDataChanged.emit(Qt.Orientation.Horizontal,
column_index, column_index)
def switch_columns(self, column1, column2):
column_titles = list(self._data.columns)
index1, index2 \
= column_titles.index(column1), column_titles.index(column2)
column_titles[index1], column_titles[index2] \
= column_titles[index2], column_titles[index1]
self._data = self._data.reindex(columns=column_titles)
self._data_str = _get_str_dataframe(self._data)
self.layoutAboutToBeChanged.emit()
self.layoutChanged.emit()
def get_columns(self) -> typing.List[str]:
return list(self._data.columns)
def assign_float_column(self, column, decimal_sep):
if decimal_sep == ',':
self._data[column] \
= self._data[column].str.replace(',', '.').astype(float)
else:
self._data[column] = self._data[column].astype(float)
self._data_str = _get_str_dataframe(self._data)
column_index = self._data.columns.get_loc(column)
start_index = self.index(0, column_index)
stop_index = self.index(len(self._data.index), column_index)
self.dataChanged.emit(start_index, stop_index)
def assign_date_column(self, column, date_format):
self._data[column] \
= pd.to_datetime(self._data[column], format=date_format)
self._data_str = _get_str_dataframe(self._data)
#
# Directly access dataframe
#
def set_dataframe(self, df):
self._data = df
self._data_str = _get_str_dataframe(df)
self.layoutAboutToBeChanged.emit()
self.layoutChanged.emit()
def get_dataframe(self) -> pd.DataFrame:
return self._data

View File

@@ -6,94 +6,141 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>800</width> <width>1300</width>
<height>600</height> <height>731</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
<string>MainWindow</string> <string>MainWindow</string>
</property> </property>
<widget class="QWidget" name="centralwidget"> <widget class="QWidget" name="centralwidget">
<layout class="QHBoxLayout" name="horizontalLayout_3" stretch="0,1"> <layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<item> <item>
<widget class="QGroupBox" name="groupBox"> <layout class="QHBoxLayout" name="horizontalLayout_6">
<property name="title"> <item>
<string>Categories</string> <widget class="QWidget" name="widget" native="true">
</property> <property name="maximumSize">
<layout class="QVBoxLayout" name="verticalLayout_2"> <size>
<item> <width>600</width>
<layout class="QHBoxLayout" name="horizontalLayout"> <height>16777215</height>
<item> </size>
<widget class="QPushButton" name="createCategoryButton"> </property>
<property name="text"> <layout class="QVBoxLayout" name="warningLayout">
<string>Create</string> <property name="topMargin">
</property> <number>0</number>
</widget> </property>
</item> <property name="bottomMargin">
<item> <number>0</number>
<widget class="QPushButton" name="deleteCategoryButton">
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QListView" name="categoryListView">
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum>
</property> </property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="applyCategoryButton">
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
</layout> </layout>
</item> </widget>
</layout> </item>
</widget> </layout>
</item> </item>
<item> <item>
<widget class="QGroupBox" name="groupBox_2"> <layout class="QHBoxLayout" name="horizontalLayout_3" stretch="0,1">
<property name="title"> <item>
<string>Bank statement</string> <widget class="QGroupBox" name="groupBox">
</property> <property name="title">
<layout class="QVBoxLayout" name="verticalLayout"> <string>Categories</string>
<item> </property>
<widget class="QTableView" name="statementTableView"> <layout class="QVBoxLayout" name="verticalLayout_2">
<property name="sortingEnabled"> <item>
<bool>false</bool> <layout class="QHBoxLayout" name="horizontalLayout">
</property> <item>
<property name="wordWrap"> <widget class="QPushButton" name="createCategoryButton">
<bool>true</bool> <property name="toolTip">
</property> <string>Create new category</string>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0"> </property>
<bool>false</bool> <property name="text">
</attribute> <string>Create</string>
</widget> </property>
</item> </widget>
</layout> </item>
</widget> <item>
<widget class="QPushButton" name="deleteCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Delete selected category</string>
</property>
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QListWidget" name="categoryListWidget">
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="clearCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Clear category field of selected transactions</string>
</property>
<property name="text">
<string>Clear</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="applyCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Apply selected category to selected transactions</string>
</property>
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Transactions</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTableView" name="transactionTableView">
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item> </item>
</layout> </layout>
</widget> </widget>
@@ -102,7 +149,7 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>800</width> <width>1300</width>
<height>23</height> <height>23</height>
</rect> </rect>
</property> </property>

View File

@@ -38,13 +38,13 @@
\newcommand{\slice}[6]{ \newcommand{\slice}[6]{
\pgfmathparse{0.5*#1+0.5*#2} \pgfmathparse{0.5*#1+0.5*#2}
\let\midangle\pgfmathresult \let\midangle\pgfmathresult
% slice % slice
\fill[thick,color=#5] (0,0) -- (#1:1) arc (#1:#2+1:1) -- (0,0); \fill[thick,color=#5] (0,0) -- (#1:1) arc (#1:#2+1:1) -- (0,0);
% outer label % outer label
\node[label=\midangle:#4] at (\midangle:1) {}; \node[label=\midangle:#4] at (\midangle:1) {};
% inner label % inner label
\pgfmathparse{min((#2-#1-10)/110*(-0.3),0)} \pgfmathparse{min((#2-#1-10)/110*(-0.3),0)}
\let\temp\pgfmathresult \let\temp\pgfmathresult
@@ -58,13 +58,13 @@
\newcounter{pieSliceB} \newcounter{pieSliceB}
\newcommand{\pie}[1]{ \newcommand{\pie}[1]{
% Count elements % Count elements
\setcounter{pieElem}{0}% \setcounter{pieElem}{0}%
\foreach\pieElem in {#1}{\stepcounter{pieElem}}% \foreach\pieElem in {#1}{\stepcounter{pieElem}}%
\edef\numElements{\arabic{pieElem}} \edef\numElements{\arabic{pieElem}}
% Draw pie chart % Draw pie chart
\setcounter{pieSliceA}{0}% \setcounter{pieSliceA}{0}%
\setcounter{pieSliceB}{0}% \setcounter{pieSliceB}{0}%
\foreach \xi/\t [count=\xk from 0] in {#1} { \foreach \xi/\t [count=\xk from 0] in {#1} {
% Get colors % Get colors
\pgfmathparse{1000 / (\numElements - 1) * \xk} \pgfmathparse{1000 / (\numElements - 1) * \xk}
@@ -81,16 +81,16 @@
\newcommand{\csvPie}[1]{ \newcommand{\csvPie}[1]{
% Count elements % Count elements
\setcounter{pieElem}{0}% \setcounter{pieElem}{0}%
\csvreader[head to column names]{#1}{}{% \csvreader[head to column names]{#1}{}{%
\stepcounter{pieElem} \stepcounter{pieElem}
} }
\edef\numElements{\arabic{pieElem}} \edef\numElements{\arabic{pieElem}}
% Draw pie chart % Draw pie chart
\setcounter{pieElem}{0}% \setcounter{pieElem}{0}%
\setcounter{pieSliceA}{0}% \setcounter{pieSliceA}{0}%
\setcounter{pieSliceB}{0}% \setcounter{pieSliceB}{0}%
\csvreader[head to column names]{#1}{}{% \csvreader[head to column names]{#1}{}{%
% Get colors % Get colors
\pgfmathparse{1000 / (\numElements - 1) * \thepieElem} \pgfmathparse{1000 / (\numElements - 1) * \thepieElem}
@@ -157,7 +157,7 @@
] ]
% Dummy plot to set x axis ticks % Dummy plot to set x axis ticks
\addplot[draw=none] \addplot[draw=none]
table[col sep=comma, x=t, y=value] table[col sep=comma, x=t, y=net]
{net_income.csv}; {net_income.csv};
% Dummy plot to set x axis scale % Dummy plot to set x axis scale
@@ -165,11 +165,17 @@
table[col sep=comma, x=t, y expr=0] table[col sep=comma, x=t, y expr=0]
{detailed_balance.csv}; {detailed_balance.csv};
\addplot[ybar, color=scol2, fill=scol2, line width=1pt] \addplot[ybar, bar width=0.4cm, draw=none, fill=scol2!30, line width=1pt]
table[col sep=comma, x=t, y=value, discard if lt={value}{0}] table[col sep=comma, x=t, y=income]
{net_income.csv}; {net_income.csv};
\addplot[ybar, color=scol0, fill=scol0, line width=1pt] \addplot[ybar, bar width=0.4cm, draw=none, fill=scol0!30, line width=1pt]
table[col sep=comma, x=t, y=value, discard if gt={value}{0}] table[col sep=comma, x=t, y=expenses]
{net_income.csv};
\addplot[ybar, bar width=0.3cm, draw=none, fill=scol0, line width=1pt]
table[col sep=comma, x=t, y=net, discard if gt={net}{0}]
{net_income.csv};
\addplot[ybar, bar width=0.3cm, draw=none, fill=scol2, line width=1pt]
table[col sep=comma, x=t, y=net, discard if lt={net}{0}]
{net_income.csv}; {net_income.csv};
\end{axis} \end{axis}
\end{tikzpicture} \end{tikzpicture}
@@ -211,6 +217,105 @@
\caption{Development of account balance over time} \caption{Development of account balance over time}
\end{figure} \end{figure}
\begin{figure}
\centering
\csvautotabular{net_income.csv}
\end{figure}
\begin{figure}[H]
\centering
% Read table
\pgfplotstableread[col sep=comma]{expenses_by_category.csv}\expbycattable
\pgfplotstablegetcolsof{\expbycattable}
\pgfmathtruncatemacro\NumCols{\pgfplotsretval-1}
\begin{subfigure}[c]{\textwidth}
\centering
\begin{tikzpicture}
\begin{axis}[
stack plots=y,
area style,
date coordinates in=x,
width=\textwidth,
height=0.375\textwidth,
xticklabel=\month.\shortyear{\year},
xtick=data,
enlargelimits=false,
xticklabel style={
rotate=60,
anchor=near xticklabel,
},
legend columns=5,
legend style={at={(0.5,-0.6)},anchor=south},
ylabel={Expenses in €},
ymin=0,
]
% For each
\pgfplotsinvokeforeach{0,...,\NumCols/2 -1}{
% Define color
\pgfmathparse{1000 / (\NumCols/2 -1) * #1}
\extractcolormapcolor{tempcol#1}{\pgfmathresult}
% Add plot
\addplot+[tempcol#1]
table[col sep=comma, x=t, y index=#1]
{\expbycattable} \closedcycle;
% Add legend entry (https://tex.stackexchange.com/a/405018)
\pgfplotstablegetcolumnnamebyindex{#1}\of{\expbycattable}\to\pgfplotsretval
\expandafter\addlegendentry\expandafter{\pgfplotsretval}
}
\end{axis}
\end{tikzpicture}
\end{subfigure}\\[1em]
\begin{subfigure}[c]{\textwidth}
\centering
\begin{tikzpicture}
\begin{axis}[
stack plots=y,
area style,
date coordinates in=x,
width=\textwidth,
height=0.375\textwidth,
xticklabel=\month.\shortyear{\year},
xtick=data,
enlargelimits=false,
xticklabel style={
rotate=60,
anchor=near xticklabel,
},
legend columns=5,
legend style={at={(0.5,-0.6)},anchor=south},
ylabel={Expenses in €},
ymin=0,
]
% For each
\pgfplotsinvokeforeach{\NumCols/2,...,\NumCols-1}{
% Define color
\pgfmathparse{1000 * (#1 - \NumCols/2) / (\NumCols-1 - \NumCols/2)}
\extractcolormapcolor{tempcol#1}{\pgfmathresult}
% Add plot
\addplot+[tempcol#1]
table[col sep=comma, x=t, y index=#1]
{\expbycattable} \closedcycle;
% Add legend entry (https://tex.stackexchange.com/a/405018)
\pgfplotstablegetcolumnnamebyindex{#1}\of{\expbycattable}\to\pgfplotsretval
\expandafter\addlegendentry\expandafter{\pgfplotsretval}
}
\end{axis}
\end{tikzpicture}
\end{subfigure}
\caption{Expenses by category}
\end{figure}
\end{document} \end{document}

BIN
res/warning.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B