diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 69910d9..319c3df 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -85,7 +85,7 @@ jobs: # # -- Lint and parse Output # - pylint_output=$(PYTHONPATH=. pylint . --recursive=y --disable=W0511,R0903 --score=y) + pylint_output=$(PYTHONPATH=. pylint . --recursive=y --disable=W0511,R0903,R0801 --score=y) exitcode=$? score=$(sed -n '$s/[^0-9]*\([0-9.]*\).*/\1/p' <<< "$pylint_output") # diff --git a/Models.md b/Models.md index cd7f368..d789f75 100644 --- a/Models.md +++ b/Models.md @@ -39,7 +39,7 @@ 'date_tx': int, # (UTC) 'text_tx': str, 'betrag': float, - 'gegenkonto': str, + 'peer': str, ----------- optional ----------- diff --git a/README.md b/README.md index 7e65f5b..1549282 100644 --- a/README.md +++ b/README.md @@ -3,40 +3,71 @@ ![pytest](https://img.shields.io/badge/pytest-passed%20(53/53)-darkgreen) ![pylint](https://img.shields.io/badge/pylint-9.75-yellow) +*This repo is german but you are welcome to add your language to the frontend.* + Analyse und Darstellung von Kontoumsätzen bei mehreren Banken. +## Get Started + +### Setup + +``` +python3.12 -m venv .venv +source .venv/bin/activate +.venv/bin/python3.12 -m ensurepip --upgrade # (optional) +pip install -r requirements.txt +.venv/bin/python3.12 app/server.py +``` + +### Start + +- Importiere Kontoumsätze über CSV Listen oder PDF Kontoauszüge deiner Bank ([unterstützte Banken](#unterstützte-banken)) +- Erstelle eine Gruppe mehrerer Konten, um alle diese Umsätze in einer Übersicht zu sehen +- Wende vorgefertigte oder eigene Regeln für das automatische Taggen und Kategorisieren deiner Umsätze an +- Suche und Filtere deine Umsätze nach einer Vielzahl möglicher Kriterien +- Lerne mehr über deinen Cashflow durch die Übersicht der statistischen Auswertungen. Hier kannst du alle oder nur gefilterte Umsätze berücksichtigen. + ## Features -### Parsing +Die Funktionen des PynanceParsers setzen stark auf Reproduzierbarkeit. Das bedeutet, dass du beliebig oft gleiche Daten löschen und reimportieren kannst und halbautomatisch wieder die gleichen Ergebnisse (einmalige Transaktionen, Tagging, Kategorien, Statistiken) erhälts. Ein manuelles Editieren ist zwar möglich, aber die Ausnahme. + +👉 **Modernes und responsives Design** + + *(Übersichtlich auf vielen Geräten)* + +👉 **Keine doppelten Imports** + + *(Datum, Text und Betrag bilden eine einmalige Kombination)* + +👉 **Automatisches Extrahieren von Zusatzinformationen** -Importiere Kontoumsätzen aus Dateien im Format unterstützter Banken (Exports von Umsatzübersichten als CSV, Kontoauszüge als PDF). Für Auswertung der Ausgaben von Zeit zu Zeit. + *(RegEx parst Kerninformationen)* -Modulare Importer können nach und nach für verschiedene Banken oder spezielle Formate entwickelt werden. Füge einen Importer für deine Bank hinzu :wink: +👉 **Automatisches und/oder manuelles Taggen** -### Analyse + *(Regelbasiert: RegEx + Zusatzinformationen)* -- Keine doppelten Imports *(Datum, Text und Betrag bilden eine einmalige Kombination)* -- Automatisches Extrahieren von Zusatzinformationen einer Transaktion durch Muster *(RegEx parst Kerninformationen)* -- Automatisches und/oder manuelles Taggen von Umsätzen *(Regelbasiert: RegEx + Zusatzinformationen)* -- Automatisches und/oder manuelles Kategorisieren von Umsätzen *(Regelbasiert: RegEx + Tags und weitere Indikatoren)* -- Übersicht über alle Transaktionen *(Vielseitige Filtermöglichkeiten)* -- Statistische Auswertung auf dem angereicherten Datensatz vieler Transaktionen *(interaktive Grafiken)* +👉 **Automatisches und/oder manuelles Kategorisieren** -Hinterlegte Regeln können die extrahierten Informationen, weitere Umsatzinformationen und weitere RegExes berücksichtigen und ermöglichen so komplexe Bewertungen einfach zu erstellen. + *(Regelbasiert: RegEx + Tags + Zusatzinformationen)* -Ein Tagging findet anschließend auf angereicherten Informationen regelbasiert statt und kann außerdem auch manuell erfolgen. +👉 **Übersicht über alle Transaktionen** + + *(vielseitige Filtermöglichkeiten in einem Konto oder einer Kontogruppe)* + +👉 **Statistische Auswertung auf dem angereicherten Datensatz** + + *(Kontextabhängige Statistken)* -Auf dieser Grundlage werden Umsätze Kategorisiert wobei auch das händisch editiert werden kann. ### Darstellung +- Kontenverwaltung - Kontohistorie -- Transaktionsansicht +- Transaktionsdetails - Statistiken/Verteilungen/Verläufe -Listen und Diagramme zeigen dir, wo eigentlich das Geld geblieben ist :thinking: - -## Misc +![screenshots](https://github.com/user-attachments/assets/f6201658-eeb0-422c-b1a8-df9cb85cf842) ### Unterstützte Banken @@ -45,7 +76,11 @@ Listen und Diagramme zeigen dir, wo eigentlich das Geld geblieben ist :thinking: | Comdirect | 🟢 Umsatzübersicht | 🟢 Finanzreport | | Commerzbank | 🟢 Umsatzübersicht | 🟢 Kontoauszug | | Sparkasse Hannover | ⚫ *planned* | ⚫ *planned* | -| Volksbank Mittelhessen eG | 🟢 Umsatzübersicht | ⚫ *planned* | +| Volksbank Mittelhessen eG | 🟢 Umsatzübersicht | 🟢 Kontoauszug | + +Ist deine Bank noch nicht dabei? Den modularen Import kannst du mit [überschaubaren Aufwand](#entwickeln-von-neuen-readern) für deine Bank erweitern. + +## Hinweise ### Workflow (CSV / PDF Imports) @@ -58,28 +93,32 @@ Daher sollte man beachten: - Regeln nicht auf zwingend vorhandene Leerzeichen auszulegen - Beim Wechsel eines Formats (PDF / CSV) keine Überschneidungen zu haben (PDF zuerst, dann fehlende Transaktionen selektieren und via CSV exportieren - alternativ bei einem Format bleiben) -### Tagging- und Kategorisierungsregeln +### Default Tagging- und Kategorisierungsregeln In diesem Repository werden nur Basis-Regeln mitgeliefert, da speziellere und genauere Regeln sehr individuell auf einzelne Personen zugeschnitten sind. So schreibt zum Beispiel eine Versicherung die Versichertennummer mit in die Abbuchungen, was einen sehr guten Tagging-Indikator darstellt, jedoch nur für einen speziellen Nutzer dieses Programms. Das schreiben eigener Regeln ist daher unumgänglich, um bessere Ergebnisse zu erzielen. -Für diesen Zweck gibt es aber die Möglichkeit im Frontend Regeln auszuprobieren, ohne dass Umsätze geändert werden. Neue Regeln können ebenfalls über die Oberfläche temporär hochgeladen werden (bis zum Neustart des Servers) oder dauerhaft im Ordner `settings/rule` abgelegt werden. Die Dateien hier werden in alphabetisch sortierter Reihenfolge geladen (angefangen bei `00-*`), wobei spätere Regeln ggf. bestehende Regeln überschreiben können. Im Rwepository werden nur die Default-Regeln angepasst. Auf diese Weise können eigene Regeln gepflegt werden, ohne dass sie bei Updates verloren gehen. +## Anpassungen / Contribution +**You're Welcome !** :tada: -## Contribution +Erstelle einen Reader für verschiedene Formate deiner Bank oder ergänze die `parser` und `rules`. -You're Welcome ! +### Entwickeln neuer `parser` / `rules` -Erstelle einen Reader für verschiedene Formate deiner Bank oder ergänze die `parser` und `rules`. +Für diesen Zweck gibt es die Möglichkeit im Frontend Regeln auszuprobieren, ohne dass Umsätze geändert werden. Neue Regeln können ebenfalls über die Oberfläche temporär hochgeladen werden (bis zum Neustart des Servers) oder dauerhaft im Ordner `settings/rule` abgelegt werden. Die Dateien hier werden in alphabetisch sortierter Reihenfolge geladen (angefangen bei `00-*`), wobei spätere Regeln ggf. bestehende Regeln überschreiben können. Im Repository werden nur die Default-Regeln angepasst. Auf diese Weise können eigene Regeln gepflegt werden, ohne dass sie bei Updates verloren gehen. -## Setup +**Wenn du neue Regeln für dieses Repository beitragen möchtest, gehst du wie folgt vor:** -``` -python3.12 -m venv .venv -source .venv/bin/activate -.venv/bin/python3.12 -m ensurepip --upgrade # (optional) -pip install -r requirements.txt -.venv/bin/python3.12 app/server.py -``` +- Erstelle einen Fork des Repositories +- Erstelle Testdaten, auf die die neuen Regeln treffen können + - (am einfachsten ist eine JSON Datei wie `tests/commerzbank.json`) +- Erstelle einen Test wie in (`test_unit_handler_Tags.py`: `test_parsing_regex()`) + - Tests helfen beim entwickeln, können aber auch durch die Maintainer während des Pull Request erstellt werden +- Stelle einen Pull Request + +### Entwickeln von neuen Readern + +Deine Bank fehlt noch in der Support Tabelle? Stelle einen Pull Request mit einem neuen Reader. [So kannst du ihn erstellen.](Reader.md). ### Testumgebung @@ -89,20 +128,3 @@ pip install -r requirements.txt pip install -r tests/requirements.txt pytest ``` - -## Entwickeln von neuen Readern - -- Erstelle einen neuen Test unter `tests/` - - (kopiere am besten `tests/test_unit_reader_Comdirect.py`) -- Erstelle ein neues Skript unter `reader/` - - (kopiere am besten `reader/Generic.py`) -- Passe die Logik im Test so an, dass dieser ausgeführt wird, wenn eine Testdatei vorhanden ist. -- Entwickle deinen Reader und teste ihn dabei immer wieder mit `pytest -svx tests/test_unit_reader_*.py` -- Pushe **keine** Testdaten (Kontoumsätze) ins Repo! - -## Entwickeln neuer `parser` / `rules` - -- Erstelle Testdaten, auf die die neuen Regeln treffen können - - (am einfachsten ist eine JSON Datei wie `tests/commerzbank.json`) -- Erstelle einen Test wie in (`test_unit_handler_Tags.py`: `test_parsing_regex()`) - - Tests helfen beim entwickeln, können aber auch durch mich beim Pull Request erstellt werden diff --git a/Reader.md b/Reader.md new file mode 100644 index 0000000..e5d4613 --- /dev/null +++ b/Reader.md @@ -0,0 +1,54 @@ +## Entwickeln von neuen Readern + +### Erstelle notwendige Dateien + +Neben dem Modul benätigst du noch einen Test. Ziel des Tests ist es, dass lokal eine Beispiel Datei importiert wird, wenn sie vorhanden ist und im Ergebnis geprüft wird, ob die eingelesenen Einträge sinnvoll sind. Eine Prüfung nach Transaktionsinhalten findet nicht statt. + +**Lade keine privaten Kontoauszüge in das Repository hoch !** + +Kopiere dazu am besten `tests/test_unit_reader_Comdirect.py` und... + +- Passe den Dateinamen der Beispieldatei an +- Entscheide, ggf. eine Testfunktion zu überspringen mit `@pytest.mark.skip(reason="Currently not implemented yet")` + +Kopiere als nächstes `reader/Generic.py` und + +- Passe den Dateinamen der Beispieldatei an +- Entwickle deinen Reader (siehe unten) und teste ihn zwischendurch/am Ende mit `pytest -svx tests/test_unit_reader_*.py` + +Der Test führt die `from_csv` bzw. `from_pdf` Funktion aus und überprüft, ob das Format richtig ist und die Werte der Transaktionen über entsprechende Keys mit sinnvollen Werten verfügen. + +### Logik des Readers erstellen + +Ziel der Funktion `from_csv` bzw. `from_pdf` ist es, eine Liste von Transaktionen in Form eines Dictionaries einzulesen, wobei die Keys der Vorgabe des [allgemeinen Models für Transaktionen](Models.md) folgt. + +Für das Einlesen einer PDF ist dabei mehr Aufwand notwendig. Hierbei kommt das Modul `camelot` zum Einsatz. + +Für die Entwicklung der richtigen Einstellungen wird neben dem Python Modul auch das CLI Programm empfohlen. Installation und Konfigurationen findest du in der [Dokumentation](https://camelot-py.readthedocs.io/en/master/). + +#### Step by Step + +- Lege eine Beispieldatei nach `/tmp/bank.pdf` +- Nutze den `grid` Modus im CLI, um angezeigt zu bekommen, was `camelot` mit den jeweiligen Einstellungen für Tabelleninhalte finden würde. +- Übertrage die erfolgreichen Flags und Einstellungen in den Python Aufruf im Reader-Modul +- Lasse dir dort erst alle eingelesenen Zeileninhalte ausgeben (während eines `pytests`) und entscheide, wie du die Inhalte ggf. umformen, zusammenfassen oder trennen musst, um das [erwartete Muster](Models.md) zu erhalten. + +Ein guter Start ist folgender Aufruf: + +``` +camelot -plot grid /tmp/bank.pdf +``` + +Nützliche Flags sind `-p` für einzelne Seiten (schneller bzw. können so auch Edgecases geprüft werden), `-C` um die Koordinaten von Spalten festzulegen (meistens erforderlich) und `-T` um den Bereich festzulegen, wo nach einer Tabelle geprüft werden soll (meistens erforderlich). Ein fortgeschrittenerer Aufruf könnte daher so aussehen: + +``` +camelot -p 1,6 stream -C "75,112,440,526" -T "60,629,573,51" -plot grid /tmp/bank.pdf +``` + +Am Schluss kann noch der Wert eingestellt werden, ab dem Buchstaben vertikal oder horizontal zu einem Wort oder einer Zeile zusammengefasst oder schon getrennt werden. Mit dem Argument `layout_kwargs` in der Python-Methode können Argumente an den darunterliegenden `PDFMiner` durchgereicht werden, was genau diese Einstellungen ermöglicht. + +Die Dokumentation dieser möglichen Werte findet man in der [Dokumentation des PDFMiner](https://pdfminersix.readthedocs.io/en/latest/reference/composable.html). Der [Commerbank.py Reader](reader/Commerzbank.py) nutzt diese wegen der bestimmten Schriftart und Zeichenabständen im PDF. + +## Stelle einen Pull Request + +Beschreibe, welche Features du hinzugefügt oder verbessert hast. Pytest und Pylint werden hier geprüft. Das Testen des neuen Imports kann nur bei dir erfolgen, da die Maintainer in der Regel über keine Testdateien dafür verfügen. Teste daher sorgfältig. diff --git a/app/routes.py b/app/routes.py index b6344f8..d28bc47 100644 --- a/app/routes.py +++ b/app/routes.py @@ -17,6 +17,20 @@ def __init__(self, parent): def timectime(s): return datetime.fromtimestamp(s).strftime('%d.%m.%Y') + @current_app.template_filter('hash') + def to_hash(string): + hash_value = 0 + + if len(string) == 0: + return hash_value + + for char in string: + char_code = ord(char) + hash_value = ((hash_value << 5) - hash_value) + char_code + hash_value = hash_value & 0xFFFFFFFF # Ensure 32-bit integer + + return hash_value + @current_app.context_processor def version_string(): return { @@ -41,7 +55,9 @@ def iban(iban) -> str: Startseite in einem Konto. Args (uri): - iban, str: IBAN zu der die Einträge angezeigt werden sollen. + iban, str: IBAN zu der die Einträge angezeigt werden sollen. + text, str (query): Volltextsuche im Betreff mit RegEx Support + peer, str (query): Volltextsuche im Gegenkonto mit RegEx Support startDate, str (query): Startdatum (Y-m-d) für die Anzeige der Einträge endDate, str (query): Enddatum (Y-m-d) für die Anzeige der Einträge category, str (query): Kategorie-Filter @@ -237,7 +253,7 @@ def addGroup(groupname): assert ibans is not None, 'No IBANs provided' r = parent.db_handler.add_iban_group(groupname, ibans) if not r.get('inserted'): - return {'error': 'No Group added', 'reason': r.get('error')}, 400 + return {'error': f'Keine Gruppe angelegt: {r.get("error")}'}, 400 return r, 201 @@ -280,7 +296,7 @@ def saveMeta(rule_type): r = parent.db_handler.set_metadata(entry, overwrite=True) if not r.get('inserted'): - return {'error': 'No data inserted', 'reason': r.get('error')}, 400 + return {'error': f'No data inserted: {r.get("error")}'}, 400 return r, 201 @@ -323,7 +339,7 @@ def uploadIban(iban): """ input_file = request.files.get('file-input') if not input_file: - return {'error': 'No file provided'}, 400 + return {'error': 'Es wurde keine Datei übermittelt.'}, 400 # Store Upload file to tmp path = '/tmp/transactions.tmp' @@ -343,15 +359,26 @@ def uploadIban(iban): path = f'{path}.pdf' # Read Input and Parse the contents - parsed_data = parent.read_input( - path, bank=request.form.get('bank', 'Generic'), - data_format=content_format - ) + try: + parsed_data = parent.read_input( + path, bank=request.form.get('bank', 'Generic'), + data_format=content_format + ) + + # Verarbeitete Kontiumsätze in die DB speichern + # und vom Objekt und Dateisystem löschen + insert_result = parent.db_handler.insert(parsed_data, iban) + inserted = insert_result.get('inserted') + + except (KeyError, ValueError, NotImplementedError) as ex: + return { + "error": ( + "Die hochgeladene Datei konnte nicht verarbeitet werden, " + "da das Format unvollständig ist oder nicht erwartet wurde: " + + ex.__class__.__name__ + " " + str(ex) + ) + }, 406 - # Verarbeitete Kontiumsätze in die DB speichern - # und vom Objekt und Dateisystem löschen - insert_result = parent.db_handler.insert(parsed_data, iban) - inserted = insert_result.get('inserted') os.remove(path) return_code = 201 if inserted else 200 @@ -373,9 +400,10 @@ def uploadRules(metadata): Returns: json: Informationen zur Datei und Ergebnis der Untersuchung. """ - input_file = request.files.get('file-input') + print(request.files) + input_file = request.files.get('settings-input') if not input_file: - return {'error': 'No file provided'}, 400 + return {'error': 'Es wurde keine Datei übermittelt.'}, 400 # Store Upload file to tmp path = f'/tmp/{metadata}.tmp' @@ -396,8 +424,7 @@ def deleteDatabase(iban): Returns: json: Informationen zum Ergebnis des Löschauftrags. """ - deleted_entries = parent.db_handler.truncate(iban) - return {'deleted': deleted_entries}, 200 + return parent.db_handler.truncate(iban), 200 @current_app.route('/api/tag/', methods=['PUT']) def tag(iban) -> dict: diff --git a/app/static/css/grid.css b/app/static/css/grid.css index 4176595..4a003bf 100644 --- a/app/static/css/grid.css +++ b/app/static/css/grid.css @@ -13,14 +13,182 @@ /* L */ @media (min-width: 1280px) { - .grid.l-12 {grid-template-columns: 1fr;} - .grid.l-6-6 {grid-template-columns: 1fr 1fr;} - .grid.l-4-4-4 {grid-template-columns: 1fr 1fr 1fr;} + .grid.xl-12 {grid-template-columns: 1fr;} + .grid.xl-6-6 {grid-template-columns: 1fr 1fr;} + .grid.xl-4-4-4 {grid-template-columns: 1fr 1fr 1fr;} } -/* XL */ +/* XXL */ @media (min-width: 1536px) { - .grid.xl-12 {grid-template-columns: 1fr;} - .grid.xl-6-6 {grid-template-columns: 1fr 1fr;} - .grid.xl-8-4 {grid-template-columns: 8fr 4fr;} + .grid.xxl-12 {grid-template-columns: 1fr;} + .grid.xxl-6-6 {grid-template-columns: 1fr 1fr;} + .grid.xxl-8-4 {grid-template-columns: 8fr 4fr;} +} + + +/* ------------------- */ +/* ---- Specials ----- */ +/* ------------------- */ + +/* Iban: Table and Container */ + +/* XS & S and smaller */ +@media (max-width: 767px) { + + /* Details PopUp Table */ + #dynamic-results tr { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.1em; + grid-template-areas: + ". ." + "gegen-head gegen-head" + "gegen-val gegen-val" + "tx-head tx-head" + "tx-val tx-val" + ". ."; + } + + #dynamic-results tr th, + #dynamic-results tr td { + display: block; + text-align: left; + } + + #dynamic-results tr:nth-child(4) th { grid-area: gegen-head;} + #dynamic-results tr:nth-child(4) td { grid-area: gegen-val;} + #dynamic-results tr:nth-child(9) th { grid-area: tx-head;} + #dynamic-results tr:nth-child(9) td { grid-area: tx-val;} + + /* Stack nav */ + nav { display: block; } + nav ul { + display: block; + text-align: center; + margin: 0; + } + main.container.flex { padding-top: 0; } + + /* Stack all table cells */ + .transactions { + display: grid; + grid-template-columns: 1fr; + } + .transactions thead { + display: none; + } + .transactions tr { + display: grid; + grid-template-columns: 1fr 5fr 5fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 0.25em; + grid-template-areas: + "checkbox dates category betrag" + "button betreff betreff betreff"; + padding: 1em 0; + } + .transactions td { + display: block; + border: none; + padding-top: 0em; + padding-bottom: 0em; + } + + /* Naming and Styling Cells */ + .transactions tr td:nth-child(1){ + padding-left: 0; + grid-area: checkbox; + } + .transactions tr td:nth-child(2){grid-area: dates;} + .transactions tr td:nth-child(3){ + padding-right: 0; + grid-area: betreff; + } + .transactions tr td:nth-child(4){grid-area: category;} + .transactions tr td:nth-child(6){ + padding-right: 0; + grid-area: betrag; + } + .transactions tr td:nth-child(7){ + padding-left: 0; + grid-area: button; + } + + /* TODO: #48 ,TX Details PopUp + #dynamic-results tr:last-child th, + #dynamic-results tr:last-child td { + display: block; + } + */ +} + +/* M and smaller */ +/* (No Padding, Category Header hide) */ +@media (max-width: 1023px) { + .container.flex{ + width: 100%; + max-width: inherit; + margin-right: auto; + margin-left: auto; + padding-right: var(--pico-spacing); + padding-left: var(--pico-spacing); + } + + .transactions tr th:nth-child(4) { + color: transparent; + font-size: 0.01em; + padding: 0; + } +} + +/* M */ +/* (Categories are only cirlces) */ +@media (min-width: 768px) and (max-width: 1023px) { + .transactions .tag-chip { + display: inline-block; + width: 1em; + height: 1em; + border-radius: 50%; + text-indent: -9999px; + overflow: hidden; + white-space: nowrap; + } +} + +/* L and smaller */ +@media (max-width: 1279px) { + /* less padding */ + .container.flex { + max-width: 1020px; + } + /* Tag Chips are only circles */ + .transactions tr th:nth-child(5) { + color: transparent; + font-size: 0.01em; + padding: 0; + } + .transactions tr td:nth-child(5) .tag-chip { + display: inline-block; + width: 1em; + height: 1em; + border-radius: 50%; + text-indent: -9999px; + overflow: hidden; + white-space: nowrap; + } +} + +/* Transaktion: Grouped Buttons */ + +/* L an greater */ +/* (Re-Align and don't collapse over full width) */ +@media (min-width: 1280px) { + .collapse[role="group"] { + display: block; + text-align: right; + } + .collapse[role="group"] button { + border-radius: var(--pico-border-radius); + margin-left: var(--pico-spacing); + } } diff --git a/app/static/css/style.css b/app/static/css/style.css index ff9a724..38d2cd6 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -15,6 +15,10 @@ main { flex: 1; } +.container.hero { + margin-top: 10%; +} + /* Pico Tooltip Feature with newlines*/ .long-tooltip:before { white-space: wrap; @@ -28,6 +32,10 @@ main { /* Hide an Element */ .hide {display: none !important;} +/* Hide when S */ +@media (max-width: 767px) {.hide-s {display:none !important;}} +/* Hide when M or smaller */ +@media (max-width: 1023px) {.hide-m {display:none !important;}} /* Color classes */ .delete { @@ -89,11 +97,37 @@ button.info { } /* Tables */ -.transactions td.betrag { +.transactions .betrag { white-space: nowrap; text-align: right; } +#dynamic-results tr th { + font-weight: bold; +} + +/* S : Slim button in transaction table */ +@media (max-width: 767px) { + .transactions th.betrag { + text-align: center; + } + .transactions tr { + border-bottom: var(--pico-border-width) solid var(--pico-table-border-color); + } + .transactions td button { + padding: 1em 0.5em; margin: 0; + background-color: transparent; border: 0; + color: var(--pico-secondary); + } +} + +/* M and smaller: TX Details Page */ +@media (max-width: 1023px) { + .single-transaction tr td:first-child{ + font-weight: bold; + } +} + .tag-chip { display: inline-block; padding: .375rem; @@ -110,6 +144,7 @@ button.info { text-decoration: none; white-space: nowrap; } + .tag-chip a.remove { color: var(--pico-code-kbd-color); } #filter-tag-container .tag-chip:last-child, #add-tag-container .tag-chip:last-child, #tag-container .tag-chip:last-child { @@ -118,15 +153,29 @@ button.info { } .tag-chip:hover, .tag-chip a, .tag-chip a:hover { text-decoration: none; - color: initial; } - .tag-chip.category { - background-color: cornflowerblue; - } - .tag-chip.parsed { - background-color: blueviolet; + .tag-chip a.remove:hover{ + color: var(--pico-color-red-500); } .tag-chip a { margin-left: 0.33em; - color: var(--pico-secondary); - } \ No newline at end of file + } + + .tag-chip.parsed {background-color: blueviolet; color:black;} + .tag-chip:hover { filter: brightness(110%); } + + /* Generated Color Classes */ + .category.gen-color-0 { background-color: #e57373; color: black; } + .category.gen-color-1 { background-color: #f06292; color: white; } + .category.gen-color-2 { background-color: #ba68c8; color: white; } + .category.gen-color-3 { background-color: #9575cd; color: white; } + .category.gen-color-4 { background-color: #7986cb; color: white; } + .category.gen-color-5 { background-color: #64b5f6; color: black; } + .category.gen-color-6 { background-color: #4fc3f7; color: black; } + .category.gen-color-7 { background-color: #4dd0e1; color: black; } + .category.gen-color-8 { background-color: #4db6ac; color: white; } + .category.gen-color-9 { background-color: #81c784; color: black; } + .category.gen-color-10 { background-color: #aed581; color: black; } + .category.gen-color-11 { background-color: #dce775; color: black; } + .category.gen-color-12 { background-color: #fff176; color: black; } + .category.gen-color-13 { background-color: #ffd54f; color: black; } diff --git a/app/static/js/functions.js b/app/static/js/functions.js index 0df5fdd..4414289 100644 --- a/app/static/js/functions.js +++ b/app/static/js/functions.js @@ -30,57 +30,63 @@ function formatUnixToDate(unixSeconds) { * @returns {string} - Returns one GET query args string for all filter inputs */ function getFilteredList() { - let query_args = ''; - let arg_concat = '?'; + let query_args = ''; + let arg_concat = '?'; - const startDate = document.getElementById('filter-range-start').value; - if (startDate) { - query_args = query_args + arg_concat + 'startDate=' + startDate; - arg_concat = '&'; - } + const startDate = document.getElementById('filter-range-start').value; + if (startDate) { + query_args = query_args + arg_concat + 'startDate=' + startDate; + arg_concat = '&'; + } - const endDate = document.getElementById('filter-range-end').value; - if (endDate) { - query_args = query_args + arg_concat + 'endDate=' + endDate; - arg_concat = '&'; - } + const endDate = document.getElementById('filter-range-end').value; + if (endDate) { + query_args = query_args + arg_concat + 'endDate=' + endDate; + arg_concat = '&'; + } - const text_search = document.getElementById('filter-text').value; - if (text_search) { - query_args = query_args + arg_concat + 'text=' + text_search; - arg_concat = '&'; - } + const text_search = document.getElementById('filter-text').value; + if (text_search) { + query_args = query_args + arg_concat + 'text=' + text_search; + arg_concat = '&'; + } - const category = document.getElementById('filter-cat').value; - if (category) { - query_args = query_args + arg_concat + 'category=' + category; - arg_concat = '&'; - } + const peer = document.getElementById('filter-peer').value; + if (peer) { + query_args = query_args + arg_concat + 'peer=' + peer; + arg_concat = '&'; + } - const tags = document.getElementById('filter-tag-result').value; - if (tags) { - query_args = query_args + arg_concat + 'tags=' + tags; - arg_concat = '&'; - const tag_mode = document.getElementById('filter-tag-mode').value; - if (tag_mode) { - query_args = query_args + arg_concat + 'tag_mode=' + tag_mode; - arg_concat = '&'; - } - } + const category = document.getElementById('filter-cat').value; + if (category) { + query_args = query_args + arg_concat + 'category=' + category; + arg_concat = '&'; + } - let betrag_min = document.getElementById('filter-betrag-min').value; - if (betrag_min) { - betrag_min = betrag_min.replace(',', '.'); - query_args = query_args + arg_concat + 'betrag_min=' + betrag_min; - arg_concat = '&'; - } + const tags = document.getElementById('filter-tag-result').value; + if (tags) { + query_args = query_args + arg_concat + 'tags=' + tags; + arg_concat = '&'; + const tag_mode = document.getElementById('filter-tag-mode').value; + if (tag_mode) { + query_args = query_args + arg_concat + 'tag_mode=' + tag_mode; + arg_concat = '&'; + } + } - let betrag_max = document.getElementById('filter-betrag-max').value; - if (betrag_max) { - betrag_max = betrag_max.replace(',', '.'); - query_args = query_args + arg_concat + 'betrag_max=' + betrag_max; - arg_concat = '&'; - } + let betrag_min = document.getElementById('filter-betrag-min').value; + if (betrag_min) { + betrag_min = betrag_min.replace(',', '.'); + query_args = query_args + arg_concat + 'betrag_min=' + betrag_min; + arg_concat = '&'; + } + + let betrag_max = document.getElementById('filter-betrag-max').value; + if (betrag_max) { + betrag_max = betrag_max.replace(',', '.'); + query_args = query_args + arg_concat + 'betrag_max=' + betrag_max; + arg_concat = '&'; + } return query_args; } @@ -110,8 +116,9 @@ function addTagBullet(inputField, tagContainerId, hiddenInputId, tagvalue) { TAGS.push(value); const tagEl = document.createElement("span"); - tagEl.className = "tag-chip"; + tagEl.className = "tag-chip " + generateClass(value); tagEl.textContent = value; + tagEl.title = value; const removeBtn = document.createElement("a"); removeBtn.className = "remove"; @@ -166,22 +173,22 @@ function removeTagBullet(element, hiddenInputId) { * instead of creating an URI string. * @returns {string|FormData} - Returns an URI string or FormData object. */ -function concatURI(value_dict, formData){ +function concatURI(value_dict, formData) { let uri = ""; for (let key in value_dict) { if (value_dict.hasOwnProperty(key)) { if (typeof formData == 'undefined') { // URI - if (key.endsWith("[]")){ + if (key.endsWith("[]")) { for (let i = 0; i < value_dict[key].length; i++) { const val = encodeURIComponent(value_dict[key][i]); uri += key + "=" + val + "&"; } - }else{ + } else { const val = encodeURIComponent(value_dict[key]); uri += key + "=" + val + "&"; } - }else{ + } else { // formData let value = value_dict[key]; if (key == 'file') { @@ -193,7 +200,7 @@ function concatURI(value_dict, formData){ } } } - uri = uri.substring(0, uri.length-1); + uri = uri.substring(0, uri.length - 1); return (typeof formData == 'undefined') ? uri : formData; } @@ -202,7 +209,7 @@ function concatURI(value_dict, formData){ * configured with a callback to handle its response. * * @param {function} callback - A callback function to handle the response. - * Receives the response text and stsatus code as arguments. + * Receives the response text and status code as arguments. * @returns {XMLHttpRequest} - The newly created XMLHttpRequest object. */ function createAjax(callback) { @@ -262,7 +269,7 @@ function apiSubmit(sub, params, callback, isFile = false) { method = "PUT"; request_uri = JSON.stringify(params); } - + ajax.open(method, "/api/" + sub, true); if (method != "POST") { @@ -287,32 +294,34 @@ function manualTag(t_ids, tags, overwrite) { return; } - let tagging = { - 'tags': tags + let tagging = { + 'tags': tags } if (overwrite) { tagging['overwrite'] = true; } - let api_function; - if (t_ids.length == 1) { - api_function = 'setManualTag/'+ IBAN + '/' + t_ids[0]; - } else { - api_function = 'setManualTags/' + IBAN; - tagging['t_ids'] = t_ids; - }; + let api_function; + if (t_ids.length == 1) { + api_function = 'setManualTag/' + IBAN + '/' + t_ids[0]; + } else { + api_function = 'setManualTags/' + IBAN; + tagging['t_ids'] = t_ids; + }; - apiSubmit(api_function, tagging, function (responseText, error) { - if (error) { - alert('Tagging failed: ' + '(' + error + ')' + responseText); + apiSubmit(api_function, tagging, function (responseText, error) { + if (error) { + alert('Tagging fehlgeschlagen: ' + '(' + error + ')'); - } else { - alert('Entries tagged successfully!' + responseText); - window.location.reload(); + } else { + const success_msg = JSON.parse(responseText); + const counts = success_msg.updated != 1 ? success_msg.updated + ' Einträge' : success_msg.updated + ' Eintrag'; + alert(counts + ' getaggt'); + window.location.reload(); - } - }, false); + } + }, false); } /** @@ -330,11 +339,11 @@ function manualCat(t_ids, cat) { return; } - let payload = { - 'category': cat - } + let payload = { + 'category': cat + } - let api_function; + let api_function; if (!cat) { // Delete Category @@ -360,14 +369,61 @@ function manualCat(t_ids, cat) { } - apiSubmit(api_function, payload, function (responseText, error) { - if (error) { - alert('Tagging failed: ' + '(' + error + ')' + responseText); + apiSubmit(api_function, payload, function (responseText, error) { + if (error) { + alert('Tagging failed: ' + '(' + error + ')' + responseText); + + } else { + const success_msg = JSON.parse(responseText); + const counts = success_msg.updated != 1 ? success_msg.updated + ' Einträge' : success_msg.updated + ' Eintrag'; + alert(counts + ' kategorisiert'); + window.location.reload(); + + } + }, false); +} + + +/* Formats an Error Responses from an AJAX Call +* +* @param {number} error_code The HTTP status code from the AJAX call +* @param {string} responseText The response text from the AJAX call +* +*/ +function showAjaxError(error_code, responseText) { + const error_msg = JSON.parse(responseText).error || "unbekannter Fehler"; + alert('Fehler ' + error_code + ': ' + error_msg); +} + +/* Formats a Result from an AJAX Call (JSON as Text) +* +* @param {string} responseText The response text from the AJAX call +* +*/ +function formatResultText(responseText) { + return JSON.stringify(JSON.parse(responseText), null, 4); +} + - } else { - alert('Entries tagged successfully!' + responseText); - window.location.reload(); +/** + * Generates a CSS class name based on the hash of the input string. + * The resulting class name is in the format `gen-color-`, where the number + * is the absolute value of the hash modulo 1000, ensuring a short and unique class name. + * + * @param {string} inputString - The input string to generate the class name from. + * @returns {string} - The generated class name. + */ +function generateClass(string) { - } - }, false); -} \ No newline at end of file + let hash = 0; + + if (string.length == 0) return hash; + + for (let i = 0; i < string.length; i++) { + const char = string.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + + return `gen-color-${Math.abs(hash % 13)}`; +} diff --git a/app/static/js/iban.js b/app/static/js/iban.js index 0df239e..9e0a8e8 100644 --- a/app/static/js/iban.js +++ b/app/static/js/iban.js @@ -104,12 +104,30 @@ function fillTxDetails(result) { // Tag Chips const row = Array.isArray(r[key]) ? r[key] : [r[key]]; for (let index = 0; index < row.length; index++) { - const span = document.createElement('span'); - span.innerHTML = row[index]; - span.className = 'tag-chip ' + key; - td.appendChild(span) + const a_link = document.createElement('a'); + a_link.innerHTML = row[index]; + a_link.href = "?tags=" + row[index]; + a_link.className = 'tag-chip ' + generateClass(row[index]); + if (key == 'category') { + a_link.className += ' category'; + } + a_link.dataset.tooltip = "Add Filter"; + td.appendChild(a_link); } + } else if (key == 'betrag') { + // Round + td.innerHTML = r[key].toFixed(2); + + } else if (key == 'peer') { + // Add Filter link + const a_link = document.createElement('a'); + a_link.innerHTML = r[key]; + a_link.className = "secondary"; + a_link.href = "?peer=" + r[key]; + a_link.dataset.tooltip = "Add Filter"; + td.appendChild(a_link); + } else { td.innerHTML = r[key]; diff --git a/app/static/js/index.js b/app/static/js/index.js index 5059fb2..c3b7f34 100644 --- a/app/static/js/index.js +++ b/app/static/js/index.js @@ -2,7 +2,7 @@ document.addEventListener('DOMContentLoaded', function () { - // Import Input + // Import Input (IBAN) const fileInput = document.getElementById('file-input'); const fileLabel = document.getElementById('file-label'); const fileDropArea = document.getElementById('file-drop-area'); @@ -15,7 +15,7 @@ document.addEventListener('DOMContentLoaded', function () { if (fileInput.files.length > 0) { fileLabel.textContent = fileInput.files[0].name; } else { - fileLabel.textContent = 'Datei hier ablegen oder auswählen (PDF / CSV / HTML)'; + fileLabel.textContent = '.csv,.pdf,.html'; } }); @@ -32,6 +32,36 @@ document.addEventListener('DOMContentLoaded', function () { } }); + // Import Input (Settings) + const settingsInput = document.getElementById('settings-input'); + const settingsLabel = document.getElementById('settings-label'); + const settingsDropArea = document.getElementById('settings-drop-area'); + + settingsDropArea.addEventListener('click', () => { + settingsInput.click(); + }); + + settingsInput.addEventListener('change', () => { + if (settingsInput.files.length > 0) { + settingsLabel.textContent = settingsInput.files[0].name; + } else { + settingsLabel.textContent = '.json'; + } + }); + + settingsDropArea.addEventListener('dragover', (e) => { + e.preventDefault(); + }); + + settingsDropArea.addEventListener('drop', (e) => { + e.preventDefault(); + const files = e.dataTransfer.files; + if (files.length > 0) { + settingsInput.files = files; + settingsLabel.textContent = files[0].name; + } + }); + // Metadata-Select document.getElementById('read-setting').addEventListener('change', function () { document.getElementById('set-setting').value = ""; @@ -120,10 +150,10 @@ function loadSetting() { apiGet('getMeta/' + setting_uuid, {}, function (responseText, error) { if (error) { - alert('Settings not loaded: ' + '(' + error + ')' + responseText); + showAjaxError(error, responseText); } else { - result_text.value = responseText; + result_text.value = formatResultText(responseText); } }); @@ -158,10 +188,10 @@ function saveSetting() { apiSubmit('saveMeta/' + meta_type, payload, function (responseText, error) { if (error) { - alert('Settings not saved: ' + '(' + error + ')' + responseText); + showAjaxError(error, responseText); } else { - alert('Settings saved: ' + responseText) + alert('Einstellungen gespeichert' + responseText) result_text.value = ''; } @@ -186,14 +216,15 @@ function importSettings() { return; } - const params = { file: 'file-input' }; // The value of 'file' corresponds to the input element's ID + const params = { file: 'settings-input' }; // The value of 'file' corresponds to the input element's ID apiSubmit('upload/metadata/' + settings_type, params, function (responseText, error) { if (error) { - alert('File upload failed: ' + '(' + error + ')' + responseText); + showAjaxError(error, responseText); } else { - alert('File uploaded successfully!' + responseText); - window.location.href = '/' + settings_type; + let success_msg = JSON.parse(responseText); + alert('Es wurden ' + success_msg.inserted + ' Einträge aus der Datei importiert.'); + window.location.href = '/'; } }, true); @@ -214,17 +245,21 @@ function uploadFile() { const fileInput = document.getElementById('file-input'); if (fileInput.files.length === 0) { - alert('Please select a file to upload.'); + alert('Es wurde keine Datei ausgewählt.'); return; } const params = { file: 'file-input', 'bank': bank_id}; // The value of 'file' corresponds to the input element's ID apiSubmit('upload/' + iban, params, function (responseText, error) { if (error) { - alert('File upload failed: ' + '(' + error + ')' + responseText); + showAjaxError(error, responseText); } else { - if (confirm('File uploaded successfully!' + responseText + '\nKonto aufrufen?')) { + let success_msg = JSON.parse(responseText); + success_msg = 'Es wurden ' + success_msg.inserted + ' Transaktionen aus der ' + + Math.round(success_msg.size / 1024 * 100) / 100 + + ' KB großen Datei importiert.\n\nMöchtest du das Konto jetzt aufrufen?' + if (confirm(success_msg)) { window.location.href = '/' + iban; } else { prepareAddModal('add-iban', null, iban); @@ -253,10 +288,10 @@ function saveGroup() { apiSubmit('addgroup/' + groupname, params, function (responseText, error) { if (error) { - alert('Gruppe nicht angelegt: ' + '(' + error + ')' + responseText); + showAjaxError(error, responseText); } else { - alert('Gruppe gespeichert!' + responseText); + alert('Gruppe gespeichert!'); window.location.reload(); } @@ -283,10 +318,11 @@ function deleteDB(delete_group) { apiGet('deleteDatabase/'+ collection, {}, function (responseText, error) { if (error) { - alert('Delete failed: ' + '(' + error + ')' + responseText); + showAjaxError(error, responseText); } else { - alert('DB deleted successfully!' + responseText); + let success_msg = JSON.parse(responseText); + alert(success_msg.deleted + ' IBAN(s) / Gruppe(n) gelöscht'); window.location.reload(); } diff --git a/app/templates/iban.html b/app/templates/iban.html index fa8a95a..7599b66 100644 --- a/app/templates/iban.html +++ b/app/templates/iban.html @@ -4,7 +4,7 @@ {% block content %} -
+
-
+
@@ -41,8 +41,8 @@

- - + + @@ -204,7 +204,7 @@

Transaktions Details

- + diff --git a/app/templates/index.html b/app/templates/index.html index e3151db..413b17e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,24 +2,28 @@ {% block content %} -
-

Pynance Parser

-

Manage your Bankaccounts like a Boss !

-
+
+
+

Pynance Parser

+

Manage Bankaccounts like a Boss !

+
+
-

- Importiere neue Kontoumsätze oder wähle die Übersicht aus, an der du weiterarbeiten möchtest. Das Konto wird bei einem Import automatisch erstellt. -

+
+

+ Importiere neue Kontoumsätze oder wähle die Übersicht aus, an der du weiterarbeiten möchtest. +

+
-
+
Konten
-
+
{% if ibans %} {% for iban in ibans %} @@ -33,7 +37,7 @@

Pynance Parser

Gruppen
-
+
{% if groups %} {% for group in groups %} @@ -48,7 +52,11 @@

Pynance Parser

- + +
+

Das Konto wird bei einem Import automatisch erstellt.

+
+
@@ -159,11 +167,11 @@

Settings

- Datei hier ablegen oder auswählen (JSON) + .json +
- Diese Datei kann auch im Server-Backend abgelegt werden, um für alle verfügbar zu sein. diff --git a/app/templates/macros.html b/app/templates/macros.html index 5fb28c8..8cf1b77 100644 --- a/app/templates/macros.html +++ b/app/templates/macros.html @@ -1,3 +1,14 @@ +{% macro generate_class(input_string) %} + {# Berechne einen Hashwert und wandle ihn in eine CSS-kompatible Klasse um #} + {% set hash_value = input_string|hash %} + {% if hash_value == 0 %} + {{ '' }} + {% else %} + {% set class_name = 'gen-color-' ~ (hash_value % 13) %} + {{ class_name }} + {% endif %} +{% endmacro %} + {% macro createTxRow(transaction) -%} {# Erstellt eine Zeile der Transaktionsübersicht #} @@ -8,19 +19,19 @@ {{ transaction.date_tx | ctime }} ({{ transaction.valuta | ctime }}) -
+ -
Datum
(Valuta)
Buchungstext KategorieTagsBetragTagsBetrag  
Gegenkonto:
Betrag {{ transaction.text_tx[:60] }}{% if transaction.text_tx|length > 60 %} ...{%endif%}{{ transaction.text_tx[:90] }}{% if transaction.text_tx|length > 60 %} ...{%endif%} {% if transaction.category %} - + {{ transaction.category }} {% else %}   {% endif %} + {% for tag in transaction.tags %} - + {{ tag }} {% endfor %} @@ -76,22 +87,34 @@

Transaktions Details

{% endif %} /> - +
+ + +