Compare commits

26 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
13 changed files with 929 additions and 377 deletions

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) 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) 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,47 +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(categories: typing.Sequence[str] = None, def show_main_window(df: pd.DataFrame = None):
df: pd.DataFrame = None):
app = QApplication(sys.argv) app = QApplication(sys.argv)
window = GeneratedWindowWrapper() window = MainWindow()
if categories is not None:
window.add_categories(categories)
if df is not None: if df is not None:
window.set_statement_data(df) window.set_statement_data(df)
window.add_warning_text("The column 't' does not exist. Please rename the"
" column containing the dates of the transactions"
" to 't'.")
window.add_warning_text("The column 'balance' does not exist. Please"
" rename the column containing the balance after"
" each transaction to 'balance'")
window.add_warning_text("The column 'value' does not exist. Please rename"
" the column containing the values of the"
" transactions to 'value'.")
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(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,121 +0,0 @@
import typing
from functools import partial
import pandas as pd
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QMainWindow, QTableView, QHBoxLayout, QLabel
from PyQt6 import uic, QtGui, QtCore
from PyQt6.QtCore import Qt
from banking_breakdown.ui.main_window 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):
super(GeneratedWindowWrapper, self).__init__()
self._window = Ui_MainWindow()
self._window.setupUi(self)
# Set window behavior
self._window.statementTableView.setSelectionBehavior(
QTableView.SelectionBehavior.SelectRows)
# Populate categories
self._category_model = QtGui.QStandardItemModel()
self._window.categoryListView.setModel(self._category_model)
# Onclick handlers
self._window.createCategoryButton.clicked.connect(
self._handle_create_click)
def add_categories(self, categories: typing.Sequence[str]):
for category in categories:
item = QtGui.QStandardItem(category)
self._category_model.appendRow(item)
def set_statement_data(self, df: pd.DataFrame):
model = PandasModel(df)
self._window.statementTableView.setModel(model)
def _handle_create_click(self):
self.add_categories(['asdf'])
def _handle_delete_click(self):
pass
def _handle_apply_click(self):
pass
def add_warning_text(self, text: str):
layout = QHBoxLayout()
warningIcon = QLabel()
pixmap = QPixmap('res/warning.png')
warningIcon.setPixmap(pixmap)
label = QLabel(text)
label.setWordWrap(True)
layout.addWidget(warningIcon)
layout.addWidget(label)
layout.setStretch(0, 0)
layout.setStretch(1, 1)
self._window.warningLayout.addLayout(layout)

View File

@@ -1,95 +1,245 @@
# Form implementation generated from reading ui file 'res/main_window.ui' import typing
# import pandas as pd
# Created by: PyQt6 UI code generator 6.6.1 from PyQt6 import uic
# from PyQt6.QtCore import Qt, QSortFilterProxyModel
# WARNING: Any manual changes made to this file will be lost when pyuic6 is from PyQt6.QtGui import QAction
# run again. Do not edit this file unless you know what you are doing. 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
from PyQt6 import QtCore, QtGui, QtWidgets class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
uic.loadUi("res/main_window.ui", self)
self.setContentsMargins(9, 9, 9, 9)
class Ui_MainWindow(object): self._warnings = []
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(800, 600)
self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.centralwidget)
self.verticalLayout_5.setSpacing(0)
self.verticalLayout_5.setObjectName("verticalLayout_5")
self.horizontalLayout_5 = QtWidgets.QHBoxLayout()
self.horizontalLayout_5.setObjectName("horizontalLayout_5")
self.warningWidget = QtWidgets.QWidget(parent=self.centralwidget)
self.warningWidget.setMaximumSize(QtCore.QSize(600, 16777215))
self.warningWidget.setObjectName("warningWidget")
self.warningLayout = QtWidgets.QVBoxLayout(self.warningWidget)
self.warningLayout.setContentsMargins(-1, 0, -1, 9)
self.warningLayout.setObjectName("warningLayout")
self.horizontalLayout_5.addWidget(self.warningWidget)
self.verticalLayout_5.addLayout(self.horizontalLayout_5)
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
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.setStatusTip("")
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)
self.verticalLayout_5.addLayout(self.horizontalLayout_3)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 23))
self.menubar.setObjectName("menubar")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(parent=MainWindow)
self.statusbar.setObjectName("statusbar")
MainWindow.setStatusBar(self.statusbar)
self.retranslateUi(MainWindow) # Extract elements
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow): self._warning_layout \
_translate = QtCore.QCoreApplication.translate = self.findChild(QVBoxLayout, "warningLayout")
MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) self._create_button \
self.groupBox.setTitle(_translate("MainWindow", "Categories")) = self.findChild(QPushButton, "createCategoryButton")
self.createCategoryButton.setToolTip(_translate("MainWindow", "Create new category")) self._delete_button \
self.createCategoryButton.setText(_translate("MainWindow", "Create")) = self.findChild(QPushButton, "deleteCategoryButton")
self.deleteCategoryButton.setToolTip(_translate("MainWindow", "Delete selected category")) self._apply_button \
self.deleteCategoryButton.setText(_translate("MainWindow", "Delete")) = self.findChild(QPushButton, "applyCategoryButton")
self.applyCategoryButton.setToolTip(_translate("MainWindow", "Apply selected category to selected transactions")) self._clear_button \
self.applyCategoryButton.setText(_translate("MainWindow", "Apply")) = self.findChild(QPushButton, "clearCategoryButton")
self.groupBox_2.setTitle(_translate("MainWindow", "Bank statement")) 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,22 +6,22 @@
<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="QVBoxLayout" name="verticalLayout_5"> <layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing"> <property name="spacing">
<number>0</number> <number>0</number>
</property> </property>
<item> <item>
<layout class="QHBoxLayout" name="horizontalLayout_5"> <layout class="QHBoxLayout" name="horizontalLayout_6">
<item> <item>
<widget class="QWidget" name="warningWidget" native="true"> <widget class="QWidget" name="widget" native="true">
<property name="maximumSize"> <property name="maximumSize">
<size> <size>
<width>600</width> <width>600</width>
@@ -33,7 +33,7 @@
<number>0</number> <number>0</number>
</property> </property>
<property name="bottomMargin"> <property name="bottomMargin">
<number>9</number> <number>0</number>
</property> </property>
</layout> </layout>
</widget> </widget>
@@ -55,9 +55,6 @@
<property name="toolTip"> <property name="toolTip">
<string>Create new category</string> <string>Create new category</string>
</property> </property>
<property name="statusTip">
<string/>
</property>
<property name="text"> <property name="text">
<string>Create</string> <string>Create</string>
</property> </property>
@@ -65,6 +62,9 @@
</item> </item>
<item> <item>
<widget class="QPushButton" name="deleteCategoryButton"> <widget class="QPushButton" name="deleteCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip"> <property name="toolTip">
<string>Delete selected category</string> <string>Delete selected category</string>
</property> </property>
@@ -76,7 +76,7 @@
</layout> </layout>
</item> </item>
<item> <item>
<widget class="QListView" name="categoryListView"> <widget class="QListWidget" name="categoryListWidget">
<property name="sizeAdjustPolicy"> <property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum> <enum>QAbstractScrollArea::AdjustToContents</enum>
</property> </property>
@@ -85,20 +85,23 @@
<item> <item>
<layout class="QHBoxLayout" name="horizontalLayout_2"> <layout class="QHBoxLayout" name="horizontalLayout_2">
<item> <item>
<spacer name="horizontalSpacer"> <widget class="QPushButton" name="clearCategoryButton">
<property name="orientation"> <property name="enabled">
<enum>Qt::Horizontal</enum> <bool>false</bool>
</property> </property>
<property name="sizeHint" stdset="0"> <property name="toolTip">
<size> <string>Clear category field of selected transactions</string>
<width>40</width>
<height>20</height>
</size>
</property> </property>
</spacer> <property name="text">
<string>Clear</string>
</property>
</widget>
</item> </item>
<item> <item>
<widget class="QPushButton" name="applyCategoryButton"> <widget class="QPushButton" name="applyCategoryButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip"> <property name="toolTip">
<string>Apply selected category to selected transactions</string> <string>Apply selected category to selected transactions</string>
</property> </property>
@@ -115,19 +118,22 @@
<item> <item>
<widget class="QGroupBox" name="groupBox_2"> <widget class="QGroupBox" name="groupBox_2">
<property name="title"> <property name="title">
<string>Bank statement</string> <string>Transactions</string>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item> <item>
<widget class="QTableView" name="statementTableView"> <widget class="QTableView" name="transactionTableView">
<property name="sortingEnabled"> <property name="selectionBehavior">
<bool>false</bool> <enum>QAbstractItemView::SelectRows</enum>
</property> </property>
<property name="wordWrap"> <property name="sortingEnabled">
<bool>true</bool> <bool>true</bool>
</property> </property>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0"> <attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>false</bool> <bool>true</bool>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute> </attribute>
</widget> </widget>
</item> </item>
@@ -143,12 +149,27 @@
<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>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>File</string>
</property>
<addaction name="actionSave"/>
</widget>
<addaction name="menuFile"/>
</widget> </widget>
<widget class="QStatusBar" name="statusbar"/> <widget class="QStatusBar" name="statusbar"/>
<action name="actionSave">
<property name="text">
<string>Save</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
</widget> </widget>
<resources/> <resources/>
<connections/> <connections/>

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}