Compare commits

...

70 Commits

Author SHA1 Message Date
Schmitz Schmitz
e0e159166b locale(German): update translation
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-03-02 01:59:16 +00:00
Herculino Trotta
6c7594ad14 Merge pull request #197 from eitchtee/dev
feat(automatic-exchange-rates): add Transitive rate provider
2025-03-01 22:59:00 -03:00
Herculino Trotta
d3ea0e43da feat(automatic-exchange-rates): add Transitive rate provider 2025-03-01 22:58:33 -03:00
Herculino Trotta
dde75416ca Merge pull request #196
feat(automatic-exchange-rates): add Synth Finance Stock
2025-03-01 22:41:12 -03:00
Herculino Trotta
c9b346b791 feat(automatic-exchange-rates): add Synth Finance Stock 2025-03-01 22:40:50 -03:00
Dimitri Decrock
9896044a15 locale(Dutch): update translation
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-03-01 03:01:20 +00:00
Herculino Trotta
eb65eb4590 add translation info on readme 2025-02-28 00:30:00 -03:00
Herculino Trotta
017c70e8b2 locale((Portuguese)): deleted translation using Weblate 2025-02-28 03:04:29 +00:00
Herculino Trotta
64b0830909 locale((Portuguese)): added translation using Weblate 2025-02-28 03:03:27 +00:00
Herculino Trotta
25d99cbece Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-02-28 02:37:39 +00:00
Herculino Trotta
033f0e1b0d Merge pull request #195
feat(insights): add Categories Overview
2025-02-27 23:33:25 -03:00
Herculino Trotta
35027ee0ae feat(insights): add Categories Overview
Closes #94
2025-02-27 23:33:05 -03:00
Herculino Trotta
91904e959b Merge pull request #194
locale(de): update translation - thanks to @CocaCola2701
2025-02-25 10:53:25 -03:00
Herculino Trotta
a6a85ae3a2 locale(de): update translation - thanks to @CocaCola2701 2025-02-25 10:53:08 -03:00
Herculino Trotta
b0f53f45f9 Merge pull request #193
fix(rules): Update or Create Transaction rule unable to match againt dates and other types
2025-02-25 10:49:01 -03:00
Herculino Trotta
0f60f8d486 fix(rules): Update or Create Transaction rule unable to match againt dates and other types 2025-02-25 10:48:43 -03:00
Herculino Trotta
efb207a109 Merge pull request #191 from eitchtee/dev
locale: add en
2025-02-24 23:07:14 -03:00
Herculino Trotta
95b1481dd5 locale: add en 2025-02-24 23:06:15 -03:00
Herculino Trotta
8de340b68b Merge pull request #190
locale(de): enable Deutsch
2025-02-24 16:34:50 -03:00
Herculino Trotta
ef15b85386 fix(locale): transactions quick search placeholder is not translatable 2025-02-24 16:34:05 -03:00
Herculino Trotta
45d939237d locale(de): enable Deutsch 2025-02-24 16:33:14 -03:00
Herculino Trotta
6bf262e514 Merge pull request #189
style(transactions): improve look on wider columns
2025-02-22 23:21:45 -03:00
Herculino Trotta
f9d9137336 style(transactions): improve look on wider columns 2025-02-22 23:21:28 -03:00
Herculino Trotta
b532521f27 Merge pull request #188 from DragonHeart69/main
update dutch to V0.11.3
2025-02-22 23:17:11 -03:00
Dimitri Decrock
1e06e2d34d update dutch to V0.11.3 2025-02-22 15:04:47 +01:00
Herculino Trotta
a33fa5e184 Merge pull request #187 from eitchtee/dev
style(transactions): improve look on wider columns
2025-02-22 01:41:27 -03:00
Herculino Trotta
a2453695d8 style(transactions): improve look on wider columns 2025-02-22 01:41:02 -03:00
Herculino Trotta
3e929d0433 Merge pull request #186
style(transactions): improve look on wider columns
2025-02-22 01:18:35 -03:00
Herculino Trotta
185fc464a5 style(transactions): improve look on wider columns 2025-02-22 01:18:20 -03:00
Herculino Trotta
647c009525 Merge pull request #185
fix(insights:latest-transactions): order transactions from newest to oldest
2025-02-22 01:02:56 -03:00
Herculino Trotta
ba75492dcc fix(insights:latest-transactions): order transactions from newest to oldest 2025-02-22 01:02:35 -03:00
Herculino Trotta
8312baaf45 Merge pull request #184
feat(tools:currency-converter): show 1:1 rates for all available currencies
2025-02-20 23:48:32 -03:00
Herculino Trotta
4d346dc278 feat(tools:currency-converter): show 1:1 rates for all available currencies 2025-02-20 23:48:08 -03:00
Herculino Trotta
70ff7fab38 Merge pull request #183 from eitchtee/dev
feat(insights): add late and recent transactions
2025-02-19 23:07:51 -03:00
Herculino Trotta
6947c6affd feat(insights): add late and recent transactions 2025-02-19 23:07:28 -03:00
Herculino Trotta
dcab83f936 Merge pull request #182
fix(insights:category-explorer): wrong sums
2025-02-19 16:02:14 -03:00
Herculino Trotta
b228e4ec26 fix(insights:category-explorer): wrong sums 2025-02-19 16:01:53 -03:00
Herculino Trotta
4071a1301f Merge pull request #181 from eitchtee/dev
fix(export): unable to import decimals
2025-02-19 15:44:50 -03:00
Herculino Trotta
5c9db10710 fix(export): unable to import decimals 2025-02-19 15:44:18 -03:00
Herculino Trotta
19c92e0014 Merge pull request #180
fix(export): 403 when exporting
2025-02-19 14:02:52 -03:00
Herculino Trotta
6459f2eb46 fix(export): 403 when exporting 2025-02-19 14:02:31 -03:00
Herculino Trotta
7926e081ef locale: update locales 2025-02-19 13:50:45 -03:00
Herculino Trotta
ceefe7075f locale: update locales 2025-02-19 13:48:54 -03:00
Herculino Trotta
ad3230fd83 Merge pull request #179 from eitchtee/export
feat: export and restore
2025-02-19 13:41:53 -03:00
Herculino Trotta
c89b07ed93 Merge branch 'main' into export 2025-02-19 13:41:04 -03:00
Herculino Trotta
201ccea842 feat: export (WIP) 2025-02-19 13:38:00 -03:00
Herculino Trotta
32ada488b4 Merge pull request #178
feat(transactions:actions): select all only selects displayed transactions
2025-02-19 09:08:06 -03:00
Herculino Trotta
794d11a355 feat(transactions:actions): select all only selects displayed transactions 2025-02-19 09:07:49 -03:00
Herculino Trotta
67f8f5fe89 Merge pull request #177
fix(transactions:actions): sum considers everything an expense
2025-02-19 09:00:02 -03:00
Herculino Trotta
9ac69fd92a fix(transactions:actions): sum considers everything an expense 2025-02-19 08:59:30 -03:00
Herculino Trotta
069f1b450c feat: export (WIP) 2025-02-19 08:51:33 -03:00
Herculino Trotta
2f388af928 Merge pull request #176
feat(insights): make sidebar sticky
2025-02-18 21:04:36 -03:00
Herculino Trotta
beeb0579ce feat(insights): make sidebar sticky 2025-02-18 21:04:09 -03:00
Herculino Trotta
a8666da57b Merge pull request #175
feat(insights:category-explorer): separate current and projected totals
2025-02-18 20:46:28 -03:00
Herculino Trotta
835316d0f3 feat(insights:category-explorer): separate current and projected totals 2025-02-18 20:46:06 -03:00
Herculino Trotta
f5feeb9617 Merge pull request #174
feat(insights:category-explorer): allow for uncategorized totals
2025-02-18 20:45:24 -03:00
Herculino Trotta
09e380a480 feat(insights:category-explorer): allow for uncategorized totals 2025-02-18 20:45:07 -03:00
Herculino Trotta
3080df9b66 feat: export (WIP) 2025-02-18 19:55:12 -03:00
Herculino Trotta
ebc41a8049 Merge pull request #173 from eitchtee/insights
fix(insights): error if filter is empty
2025-02-17 21:49:00 -03:00
Herculino Trotta
635628e30e fix(insights): error if filter is empty 2025-02-17 21:48:33 -03:00
Herculino Trotta
819a58ac06 Merge pull request #172
feat(datepicker): disable input and fix toggling dates
2025-02-17 21:37:16 -03:00
Herculino Trotta
d433375522 feat(datepicker): disable input and fix toggling dates 2025-02-17 21:36:11 -03:00
Herculino Trotta
c0150f71a8 Merge pull request #171 from eitchtee/insights
fix(insights:category-explorer): silent categories can't be displayed
2025-02-17 10:43:12 -03:00
Herculino Trotta
6119698d38 fix(insights:category-explorer): silent categories can't be displayed 2025-02-17 10:42:38 -03:00
Herculino Trotta
f5ae231601 Merge pull request #170
feat(insights:category-explorer): add empty message when there's no data or no category selected
2025-02-17 10:28:55 -03:00
Herculino Trotta
972d23abbd feat(insights:category-explorer): add empty message when there's no data or no category selected 2025-02-17 10:28:37 -03:00
Herculino Trotta
9a514a8a69 Merge pull request #169
refactor(insights:flows): improve readability when there's a lot of nodes
2025-02-17 10:21:36 -03:00
Herculino Trotta
7325231548 refactor(insights:flows): improve readability when there's a lot of nodes 2025-02-17 10:21:18 -03:00
Herculino Trotta
570657371a Merge pull request #168
fix(insights:category-explorer): use currency name instead of code
2025-02-16 19:34:15 -03:00
Herculino Trotta
67da60b5b0 fix(insights:category-explorer): use currency name instead of code 2025-02-16 19:33:58 -03:00
66 changed files with 6484 additions and 1545 deletions

View File

@@ -13,6 +13,7 @@
<a href="#key-features">Features</a> •
<a href="#how-to-use">Usage</a> •
<a href="#how-it-works">How</a> •
<a href="#help-us-translate-wygiwyh">Translate</a> •
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
<a href="#built-with">Built with</a>
</p>
@@ -133,6 +134,14 @@ To create the first user, open the container's console using Unraid's UI, by cli
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
# Help us translate WYGIWYH!
<a href="https://translations.herculino.com/engage/wygiwyh/">
<img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
</a>
> [!NOTE]
> Login with your github account
# Caveats and Warnings
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.

View File

@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"hijack",
"hijack.contrib.admin",
"django_filters",
"import_export",
"apps.users.apps.UsersConfig",
"procrastinate.contrib.django",
"apps.transactions.apps.TransactionsConfig",
@@ -63,6 +64,7 @@ INSTALLED_APPS = [
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
"apps.export_app.apps.ExportConfig",
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
@@ -161,6 +163,7 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en"
LANGUAGES = (
("de", "Deutsch"),
("en", "English"),
("nl", "Nederlands"),
("pt-br", "Português (Brasil)"),

View File

@@ -49,5 +49,6 @@ urlpatterns = [
path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
path("", include("apps.export_app.urls")),
path("", include("apps.insights.urls")),
]

View File

@@ -19,6 +19,8 @@ class AirDatePickerInput(widgets.DateInput):
format=None,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
@@ -26,6 +28,10 @@ class AirDatePickerInput(widgets.DateInput):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
@@ -47,9 +53,13 @@ class AirDatePickerInput(widgets.DateInput):
attrs["data-now-button-txt"] = _("Today")
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
@@ -89,6 +99,8 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
timepicker=True,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
@@ -97,6 +109,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
self.timepicker = timepicker
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
@@ -123,11 +139,15 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
attrs["data-now-button-txt"] = _("Now")
attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = date_format
attrs["data-time-format"] = time_format
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):

View File

@@ -6,8 +6,10 @@ from django.utils import timezone
from apps.currencies.exchange_rates.providers import (
SynthFinanceProvider,
SynthFinanceStockProvider,
CoinGeckoFreeProvider,
CoinGeckoProProvider,
TransitiveRateProvider,
)
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
@@ -17,8 +19,10 @@ logger = logging.getLogger(__name__)
# Map service types to provider classes
PROVIDER_MAPPING = {
"synth_finance": SynthFinanceProvider,
"synth_finance_stock": SynthFinanceStockProvider,
"coingecko_free": CoinGeckoFreeProvider,
"coingecko_pro": CoinGeckoProProvider,
"transitive": TransitiveRateProvider,
}

View File

@@ -3,11 +3,11 @@ import time
import requests
from decimal import Decimal
from typing import Tuple, List
from typing import Tuple, List, Optional, Dict
from django.db.models import QuerySet
from apps.currencies.models import Currency
from apps.currencies.models import Currency, ExchangeRate
from apps.currencies.exchange_rates.base import ExchangeRateProvider
logger = logging.getLogger(__name__)
@@ -150,3 +150,159 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"x-cg-pro-api-key": api_key})
class SynthFinanceStockProvider(ExchangeRateProvider):
"""Implementation for Synth Finance API Real-Time Prices endpoint (synthfinance.com)"""
BASE_URL = "https://api.synthfinance.com/tickers"
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update(
{"Authorization": f"Bearer {self.api_key}", "accept": "application/json"}
)
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
for currency in target_currencies:
if currency.exchange_currency not in exchange_currencies:
continue
try:
# Same currency has rate of 1
if currency.code == currency.exchange_currency.code:
rate = Decimal("1")
results.append((currency.exchange_currency, currency, rate))
continue
# Fetch real-time price for this ticker
response = self.session.get(
f"{self.BASE_URL}/{currency.code}/real-time"
)
response.raise_for_status()
data = response.json()
# Use fair market value as the rate
rate = Decimal(data["data"]["fair_market_value"])
results.append((currency.exchange_currency, currency, rate))
# Log API usage
credits_used = data["meta"]["credits_used"]
credits_remaining = data["meta"]["credits_remaining"]
logger.info(
f"Synth Finance API call for {currency.code}: {credits_used} credits used, {credits_remaining} remaining"
)
except requests.RequestException as e:
logger.error(
f"Error fetching rate from Synth Finance API for ticker {currency.code}: {e}",
exc_info=True,
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Synth Finance API for ticker {currency.code}: {e}",
exc_info=True,
)
except Exception as e:
logger.error(
f"Unexpected error processing Synth Finance data for ticker {currency.code}: {e}",
exc_info=True,
)
return results
class TransitiveRateProvider(ExchangeRateProvider):
"""Calculates exchange rates through paths of existing rates"""
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key) # API key not needed but maintaining interface
@classmethod
def requires_api_key(cls) -> bool:
return False
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
# Get recent rates for building the graph
recent_rates = ExchangeRate.objects.all()
# Build currency graph
currency_graph = self._build_currency_graph(recent_rates)
for target in target_currencies:
if (
not target.exchange_currency
or target.exchange_currency not in exchange_currencies
):
continue
# Find path and calculate rate
from_id = target.exchange_currency.id
to_id = target.id
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
if path and rate:
path_codes = [Currency.objects.get(id=cid).code for cid in path]
logger.info(
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
)
results.append((target.exchange_currency, target, rate))
else:
logger.debug(
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
)
return results
@staticmethod
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
"""Build a graph representation of currency relationships"""
graph = {}
for rate in rates:
# Add both directions to make the graph bidirectional
if rate.from_currency_id not in graph:
graph[rate.from_currency_id] = {}
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
if rate.to_currency_id not in graph:
graph[rate.to_currency_id] = {}
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
return graph
@staticmethod
def _find_conversion_path(
graph, from_id, to_id
) -> Tuple[Optional[list], Optional[Decimal]]:
"""Find the shortest path between currencies using breadth-first search"""
if from_id not in graph or to_id not in graph:
return None, None
queue = [(from_id, [from_id], Decimal("1"))]
visited = {from_id}
while queue:
current, path, current_rate = queue.pop(0)
if current == to_id:
return path, current_rate
for neighbor, rate in graph.get(current, {}).items():
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor], current_rate * rate))
return None, None

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-02 01:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0011_remove_exchangerateservice_fetch_interval_hours_and_more'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)')], max_length=255, verbose_name='Service Type'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-02 01:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0012_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)')], max_length=255, verbose_name='Service Type'),
),
]

View File

@@ -92,8 +92,10 @@ class ExchangeRateService(models.Model):
class ServiceType(models.TextChoices):
SYNTH_FINANCE = "synth_finance", "Synth Finance"
SYNTH_FINANCE_STOCK = "synth_finance_stock", "Synth Finance Stock"
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
class IntervalType(models.TextChoices):
ON = "on", _("On")

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ExportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.export_app"

View File

@@ -0,0 +1,189 @@
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, HTML
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
class ExportForm(forms.Form):
accounts = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Accounts"),
initial=True,
)
currencies = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Currencies"),
initial=True,
)
transactions = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Transactions"),
initial=True,
)
categories = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Categories"),
initial=True,
)
tags = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Tags"),
initial=False,
)
entities = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Entities"),
initial=False,
)
recurring_transactions = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Recurring Transactions"),
initial=True,
)
installment_plans = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Installment Plans"),
initial=True,
)
exchange_rates = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Exchange Rates"),
initial=False,
)
exchange_rates_services = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Automatic Exchange Rates"),
initial=False,
)
rules = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Rules"),
initial=True,
)
dca = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("DCA"),
initial=False,
)
import_profiles = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Import Profiles"),
initial=True,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"accounts",
"currencies",
"transactions",
"categories",
"entities",
"tags",
"installment_plans",
"recurring_transactions",
"exchange_rates_services",
"exchange_rates",
"rules",
"dca",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
),
),
)
class RestoreForm(forms.Form):
zip_file = forms.FileField(
required=False,
help_text=_("Import a ZIP file exported from WYGIWYH"),
label=_("ZIP File"),
)
accounts = forms.FileField(required=False, label=_("Accounts"))
currencies = forms.FileField(required=False, label=_("Currencies"))
transactions_categories = forms.FileField(required=False, label=_("Categories"))
transactions_tags = forms.FileField(required=False, label=_("Tags"))
transactions_entities = forms.FileField(required=False, label=_("Entities"))
transactions = forms.FileField(required=False, label=_("Transactions"))
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
recurring_transactions = forms.FileField(
required=False, label=_("Recurring Transactions")
)
automatic_exchange_rates = forms.FileField(
required=False, label=_("Automatic Exchange Rates")
)
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
transaction_rules_actions = forms.FileField(
required=False, label=_("Edit transaction action")
)
transaction_rules_update_or_create = forms.FileField(
required=False, label=_("Update or create transaction actions")
)
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"zip_file",
HTML("<hr />"),
"accounts",
"currencies",
"transactions",
"transactions_categories",
"transactions_entities",
"transactions_tags",
"installment_plans",
"recurring_transactions",
"automatic_exchange_rates",
"exchange_rates",
"transaction_rules",
"transaction_rules_actions",
"transaction_rules_update_or_create",
"dca_strategies",
"dca_entries",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
),
),
)
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get("zip_file") and not any(
cleaned_data.get(field) for field in self.fields if field != "zip_file"
):
raise forms.ValidationError(
_("Please upload either a ZIP file or at least one CSV file")
)
return cleaned_data

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,26 @@
from import_export import fields, resources, widgets
from apps.accounts.models import Account, AccountGroup
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.currencies.models import Currency
class AccountResource(resources.ModelResource):
group = fields.Field(
attribute="group",
column_name="group",
widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
)
currency = fields.Field(
attribute="currency",
column_name="currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
exchange_currency = fields.Field(
attribute="exchange_currency",
column_name="exchange_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
class Meta:
model = Account

View File

@@ -0,0 +1,52 @@
from import_export import fields, resources, widgets
from apps.accounts.models import Account
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
from apps.export_app.widgets.foreign_key import SkipMissingForeignKeyWidget
from apps.export_app.widgets.numbers import UniversalDecimalWidget
class CurrencyResource(resources.ModelResource):
exchange_currency = fields.Field(
attribute="exchange_currency",
column_name="exchange_currency",
widget=SkipMissingForeignKeyWidget(Currency, "name"),
)
class Meta:
model = Currency
class ExchangeRateResource(resources.ModelResource):
from_currency = fields.Field(
attribute="from_currency",
column_name="from_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
to_currency = fields.Field(
attribute="to_currency",
column_name="to_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
rate = fields.Field(
attribute="rate", column_name="rate", widget=UniversalDecimalWidget()
)
class Meta:
model = ExchangeRate
class ExchangeRateServiceResource(resources.ModelResource):
target_currencies = fields.Field(
attribute="target_currencies",
column_name="target_currencies",
widget=widgets.ManyToManyWidget(Currency, field="name"),
)
target_accounts = fields.Field(
attribute="target_accounts",
column_name="target_accounts",
widget=widgets.ManyToManyWidget(Account, field="name"),
)
class Meta:
model = ExchangeRateService

View File

@@ -0,0 +1,38 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.dca.models import DCAStrategy, DCAEntry
from apps.currencies.models import Currency
from apps.export_app.widgets.numbers import UniversalDecimalWidget
class DCAStrategyResource(resources.ModelResource):
target_currency = fields.Field(
attribute="target_currency",
column_name="target_currency",
widget=ForeignKeyWidget(Currency, "name"),
)
payment_currency = fields.Field(
attribute="payment_currency",
column_name="payment_currency",
widget=ForeignKeyWidget(Currency, "name"),
)
class Meta:
model = DCAStrategy
class DCAEntryResource(resources.ModelResource):
amount_paid = fields.Field(
attribute="amount_paid",
column_name="amount_paid",
widget=UniversalDecimalWidget(),
)
amount_received = fields.Field(
attribute="amount_received",
column_name="amount_received",
widget=UniversalDecimalWidget(),
)
class Meta:
model = DCAEntry

View File

@@ -0,0 +1,8 @@
from import_export import resources
from apps.import_app.models import ImportProfile
class ImportProfileResource(resources.ModelResource):
class Meta:
model = ImportProfile

View File

@@ -0,0 +1,25 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
class TransactionRuleResource(resources.ModelResource):
class Meta:
model = TransactionRule
class TransactionRuleActionResource(resources.ModelResource):
class Meta:
model = TransactionRuleAction
class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
class Meta:
model = UpdateOrCreateTransactionRuleAction

View File

@@ -0,0 +1,143 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.accounts.models import Account
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
from apps.export_app.widgets.string import EmptyStringToNoneField
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
RecurringTransaction,
InstallmentPlan,
)
from apps.export_app.widgets.numbers import UniversalDecimalWidget
class TransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
internal_id = EmptyStringToNoneField(
column_name="internal_id", attribute="internal_id"
)
amount = fields.Field(
attribute="amount",
column_name="amount",
widget=UniversalDecimalWidget(),
)
class Meta:
model = Transaction
def get_queryset(self):
return Transaction.all_objects.all()
class TransactionTagResource(resources.ModelResource):
class Meta:
model = TransactionTag
class TransactionEntityResource(resources.ModelResource):
class Meta:
model = TransactionEntity
class TransactionCategoyResource(resources.ModelResource):
class Meta:
model = TransactionCategory
class RecurringTransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
amount = fields.Field(
attribute="amount",
column_name="amount",
widget=UniversalDecimalWidget(),
)
class Meta:
model = RecurringTransaction
class InstallmentPlanResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
installment_amount = fields.Field(
attribute="installment_amount",
column_name="installment_amount",
widget=UniversalDecimalWidget(),
)
class Meta:
model = InstallmentPlan

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,8 @@
from django.urls import path
import apps.export_app.views as views
urlpatterns = [
path("export/", views.export_index, name="export_index"),
path("export/form/", views.export_form, name="export_form"),
path("export/restore/", views.import_form, name="restore_form"),
]

View File

@@ -0,0 +1,284 @@
import logging
import zipfile
from io import BytesIO, TextIOWrapper
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from tablib import Dataset
from apps.export_app.forms import ExportForm, RestoreForm
from apps.export_app.resources.accounts import AccountResource
from apps.export_app.resources.transactions import (
TransactionResource,
TransactionTagResource,
TransactionEntityResource,
TransactionCategoyResource,
InstallmentPlanResource,
RecurringTransactionResource,
)
from apps.export_app.resources.currencies import (
CurrencyResource,
ExchangeRateResource,
ExchangeRateServiceResource,
)
from apps.export_app.resources.rules import (
TransactionRuleResource,
TransactionRuleActionResource,
UpdateOrCreateTransactionRuleResource,
)
from apps.export_app.resources.dca import (
DCAStrategyResource,
DCAEntryResource,
)
from apps.export_app.resources.import_app import (
ImportProfileResource,
)
from apps.common.decorators.htmx import only_htmx
logger = logging.getLogger()
@login_required
@require_http_methods(["GET"])
def export_index(request):
return render(request, "export_app/pages/index.html")
@login_required
@require_http_methods(["GET", "POST"])
def export_form(request):
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
if request.method == "POST":
form = ExportForm(request.POST)
if form.is_valid():
zip_buffer = BytesIO()
export_accounts = form.cleaned_data.get("accounts", False)
export_currencies = form.cleaned_data.get("currencies", False)
export_transactions = form.cleaned_data.get("transactions", False)
export_categories = form.cleaned_data.get("categories", False)
export_tags = form.cleaned_data.get("tags", False)
export_entities = form.cleaned_data.get("entities", False)
export_installment_plans = form.cleaned_data.get("installment_plans", False)
export_recurring_transactions = form.cleaned_data.get(
"recurring_transactions", False
)
export_exchange_rates_services = form.cleaned_data.get(
"exchange_rates_services", False
)
export_exchange_rates = form.cleaned_data.get("exchange_rates", False)
export_rules = form.cleaned_data.get("rules", False)
export_dca = form.cleaned_data.get("dca", False)
export_import_profiles = form.cleaned_data.get("import_profiles", False)
exports = []
if export_accounts:
exports.append((AccountResource().export(), "accounts"))
if export_currencies:
exports.append((CurrencyResource().export(), "currencies"))
if export_transactions:
exports.append((TransactionResource().export(), "transactions"))
if export_categories:
exports.append(
(TransactionCategoyResource().export(), "transactions_categories")
)
if export_tags:
exports.append((TransactionTagResource().export(), "transactions_tags"))
if export_entities:
exports.append(
(TransactionEntityResource().export(), "transactions_entities")
)
if export_installment_plans:
exports.append(
(InstallmentPlanResource().export(), "installment_plans")
)
if export_recurring_transactions:
exports.append(
(RecurringTransactionResource().export(), "recurring_transactions")
)
if export_exchange_rates_services:
exports.append(
(ExchangeRateServiceResource().export(), "automatic_exchange_rates")
)
if export_exchange_rates:
exports.append((ExchangeRateResource().export(), "exchange_rates"))
if export_rules:
exports.append(
(TransactionRuleResource().export(), "transaction_rules")
)
exports.append(
(
TransactionRuleActionResource().export(),
"transaction_rules_actions",
)
)
exports.append(
(
UpdateOrCreateTransactionRuleResource().export(),
"transaction_rules_update_or_create",
)
)
if export_dca:
exports.append((DCAStrategyResource().export(), "dca_strategies"))
exports.append(
(
DCAEntryResource().export(),
"dca_entries",
)
)
if export_import_profiles:
exports.append((ImportProfileResource().export(), "import_profiles"))
if len(exports) >= 2:
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for dataset, name in exports:
zip_file.writestr(f"{name}.csv", dataset.csv)
response = HttpResponse(
zip_buffer.getvalue(),
content_type="application/zip",
headers={
"HX-Trigger": "hide_offcanvas, updated",
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export.zip"',
},
)
return response
elif len(exports) == 1:
dataset, name = exports[0]
response = HttpResponse(
dataset.csv,
content_type="text/csv",
headers={
"HX-Trigger": "hide_offcanvas, updated",
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export_{name}.csv"',
},
)
return response
else:
return HttpResponse(
_("You have to select at least one export"),
)
else:
form = ExportForm()
return render(request, "export_app/fragments/export.html", context={"form": form})
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_form(request):
if request.method == "POST":
form = RestoreForm(request.POST, request.FILES)
if form.is_valid():
try:
process_imports(request, form.cleaned_data)
messages.success(request, _("Data restored successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "hide_offcanvas, updated",
},
)
except Exception as e:
logger.error("Error importing", exc_info=e)
messages.error(
request,
_(
"There was an error restoring your data. Check the logs for more details."
),
)
else:
form = RestoreForm()
response = render(request, "export_app/fragments/restore.html", {"form": form})
response["HX-Trigger"] = "updated"
return response
def process_imports(request, cleaned_data):
# Define import order to handle dependencies
import_order = [
("currencies", CurrencyResource),
(
"currencies",
CurrencyResource,
), # We do a double pass because exchange_currency may not exist when currency is initially created
("accounts", AccountResource),
("transactions_categories", TransactionCategoyResource),
("transactions_tags", TransactionTagResource),
("transactions_entities", TransactionEntityResource),
("automatic_exchange_rates", ExchangeRateServiceResource),
("exchange_rates", ExchangeRateResource),
("installment_plans", InstallmentPlanResource),
("recurring_transactions", RecurringTransactionResource),
("transactions", TransactionResource),
("dca_strategies", DCAStrategyResource),
("dca_entries", DCAEntryResource),
("import_profiles", ImportProfileResource),
("transaction_rules", TransactionRuleResource),
("transaction_rules_actions", TransactionRuleActionResource),
("transaction_rules_update_or_create", UpdateOrCreateTransactionRuleResource),
]
def import_dataset(content, resource_class, field_name):
try:
# Create a new resource instance
resource = resource_class()
# Create dataset from CSV content
dataset = Dataset()
dataset.load(content, format="csv")
# Perform the import
result = resource.import_data(
dataset,
dry_run=False,
raise_errors=True,
collect_failed_rows=True,
use_transactions=False,
skip_unchanged=True,
)
if result.has_errors():
raise ImportError(f"Failed rows: {result.failed_dataset}")
return result
except Exception as e:
logger.error(f"Error importing {field_name}: {str(e)}")
raise ImportError(f"Error importing {field_name}: {str(e)}")
with transaction.atomic():
files = {}
if zip_file := cleaned_data.get("zip_file"):
# Process ZIP file
with zipfile.ZipFile(zip_file) as z:
for filename in z.namelist():
name = filename.replace(".csv", "")
with z.open(filename) as f:
content = f.read().decode("utf-8")
files[name] = content
for field_name, resource_class in import_order:
if field_name in files.keys():
content = files[field_name]
import_dataset(content, resource_class, field_name)
else:
# Process individual files
for field_name, resource_class in import_order:
if csv_file := cleaned_data.get(field_name):
content = csv_file.read().decode("utf-8")
import_dataset(content, resource_class, field_name)

View File

View File

@@ -0,0 +1,22 @@
from import_export.widgets import ForeignKeyWidget
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
def clean(self, value, row=None, *args, **kwargs):
if value:
try:
return super().clean(value, row, **kwargs)
except self.model.DoesNotExist:
return self.model.objects.create(name=value)
return None
class SkipMissingForeignKeyWidget(ForeignKeyWidget):
def clean(self, value, row=None, *args, **kwargs):
if not value:
return None
try:
return super().clean(value, row, *args, **kwargs)
except self.model.DoesNotExist:
return None

View File

@@ -0,0 +1,21 @@
from import_export.widgets import ManyToManyWidget
class AutoCreateManyToManyWidget(ManyToManyWidget):
def clean(self, value, row=None, *args, **kwargs):
if not value:
return []
values = value.split(self.separator)
cleaned_values = []
for val in values:
val = val.strip()
if val:
try:
obj = self.model.objects.get(**{self.field: val})
except self.model.DoesNotExist:
obj = self.model.objects.create(name=val)
cleaned_values.append(obj)
return cleaned_values

View File

@@ -0,0 +1,18 @@
from decimal import Decimal
from import_export.widgets import NumberWidget
class UniversalDecimalWidget(NumberWidget):
def clean(self, value, row=None, *args, **kwargs):
if self.is_empty(value):
return None
# Replace comma with dot if present
if isinstance(value, str):
value = value.replace(",", ".")
return Decimal(str(value))
def render(self, value, obj=None, **kwargs):
if value is None:
return ""
return str(value).replace(",", ".")

View File

@@ -0,0 +1,7 @@
from import_export import fields
class EmptyStringToNoneField(fields.Field):
def clean(self, data, **kwargs):
value = super().clean(data)
return None if value == "" else value

View File

@@ -9,11 +9,12 @@ from apps.common.widgets.datepicker import (
AirDatePickerInput,
)
from apps.transactions.models import TransactionCategory
from apps.common.widgets.tom_select import TomSelect
class SingleMonthForm(forms.Form):
month = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=False
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
@@ -28,7 +29,7 @@ class SingleMonthForm(forms.Form):
class SingleYearForm(forms.Form):
year = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=False
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
@@ -43,10 +44,10 @@ class SingleYearForm(forms.Form):
class MonthRangeForm(forms.Form):
month_from = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=False
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
month_to = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=False
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
@@ -66,10 +67,10 @@ class MonthRangeForm(forms.Form):
class YearRangeForm(forms.Form):
year_from = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=False
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
year_to = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=False
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
@@ -89,10 +90,10 @@ class YearRangeForm(forms.Form):
class DateRangeForm(forms.Form):
date_from = forms.DateField(
widget=AirDatePickerInput(clear_button=False), label="", required=False
widget=AirDatePickerInput(clear_button=False), label="", required=True
)
date_to = forms.DateField(
widget=AirDatePickerInput(clear_button=False), label="", required=False
widget=AirDatePickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
@@ -115,7 +116,9 @@ class CategoryForm(forms.Form):
category = forms.ModelChoiceField(
required=False,
label=_("Category"),
empty_label=_("Uncategorized"),
queryset=TransactionCategory.objects.filter(active=True),
widget=TomSelect(clear_button=True),
)
def __init__(self, *args, **kwargs):

View File

@@ -29,4 +29,19 @@ urlpatterns = [
views.category_sum_by_currency,
name="category_sum_by_currency",
),
path(
"insights/category-overview/",
views.category_overview,
name="category_overview",
),
path(
"insights/late-transactions/",
views.late_transactions,
name="insights_late_transactions",
),
path(
"insights/latest-transactions/",
views.latest_transactions,
name="insights_latest_transactions",
),
]

View File

@@ -3,7 +3,7 @@ from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
def get_category_sums_by_account(queryset, category):
def get_category_sums_by_account(queryset, category=None):
"""
Returns income/expense sums per account for a specific category.
"""
@@ -11,10 +11,10 @@ def get_category_sums_by_account(queryset, category):
queryset.filter(category=category)
.values("account__name")
.annotate(
income=Coalesce(
current_income=Coalesce(
Sum(
Case(
When(type="IN", then="amount"),
When(type="IN", is_paid=True, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
@@ -22,10 +22,32 @@ def get_category_sums_by_account(queryset, category):
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
expense=Coalesce(
current_expense=Coalesce(
Sum(
Case(
When(type="EX", then=-F("amount")),
When(type="EX", is_paid=True, then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_income=Coalesce(
Sum(
Case(
When(type="IN", is_paid=False, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_expense=Coalesce(
Sum(
Case(
When(type="EX", is_paid=False, then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
@@ -41,29 +63,37 @@ def get_category_sums_by_account(queryset, category):
"labels": [item["account__name"] for item in sums],
"datasets": [
{
"label": _("Income"),
"data": [float(item["income"]) for item in sums],
"label": _("Current Income"),
"data": [float(item["current_income"]) for item in sums],
},
{
"label": _("Expenses"),
"data": [float(item["expense"]) for item in sums],
"label": _("Current Expenses"),
"data": [float(item["current_expense"]) for item in sums],
},
{
"label": _("Projected Income"),
"data": [float(item["projected_income"]) for item in sums],
},
{
"label": _("Projected Expenses"),
"data": [float(item["projected_expense"]) for item in sums],
},
],
}
def get_category_sums_by_currency(queryset, category):
def get_category_sums_by_currency(queryset, category=None):
"""
Returns income/expense sums per currency for a specific category.
"""
sums = (
queryset.filter(category=category)
.values("account__currency__code")
.values("account__currency__name")
.annotate(
income=Coalesce(
current_income=Coalesce(
Sum(
Case(
When(type="IN", then="amount"),
When(type="IN", is_paid=True, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
@@ -71,10 +101,32 @@ def get_category_sums_by_currency(queryset, category):
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
expense=Coalesce(
current_expense=Coalesce(
Sum(
Case(
When(type="EX", then=-F("amount")),
When(type="EX", is_paid=True, then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_income=Coalesce(
Sum(
Case(
When(type="IN", is_paid=False, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_expense=Coalesce(
Sum(
Case(
When(type="EX", is_paid=False, then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
@@ -83,19 +135,27 @@ def get_category_sums_by_currency(queryset, category):
output_field=DecimalField(max_digits=42, decimal_places=30),
),
)
.order_by("account__currency__code")
.order_by("account__currency__name")
)
return {
"labels": [item["account__currency__code"] for item in sums],
"labels": [item["account__currency__name"] for item in sums],
"datasets": [
{
"label": _("Income"),
"data": [float(item["income"]) for item in sums],
"label": _("Current Income"),
"data": [float(item["current_income"]) for item in sums],
},
{
"label": _("Expenses"),
"data": [float(item["expense"]) for item in sums],
"label": _("Current Expenses"),
"data": [float(item["current_expense"]) for item in sums],
},
{
"label": _("Projected Income"),
"data": [float(item["projected_income"]) for item in sums],
},
{
"label": _("Projected Expenses"),
"data": [float(item["projected_expense"]) for item in sums],
},
],
}

View File

@@ -0,0 +1,165 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value, DecimalField
from django.db.models.functions import Coalesce
from apps.transactions.models import Transaction
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
def get_categories_totals(transactions_queryset, ignore_empty=False):
# Get metrics for each category and currency in a single query
category_currency_metrics = (
transactions_queryset.values(
"category",
"category__name",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_current=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
expense_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_current=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.INCOME, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by("category__name")
)
# Process the results to structure by category
result = {}
for metric in category_currency_metrics:
# Skip empty categories if ignore_empty is True
if ignore_empty and all(
metric[field] == Decimal("0")
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
]
):
continue
# Calculate derived totals
total_current = metric["income_current"] - metric["expense_current"]
total_projected = metric["income_projected"] - metric["expense_projected"]
total_income = metric["income_current"] + metric["income_projected"]
total_expense = metric["expense_current"] + metric["expense_projected"]
total_final = total_current + total_projected
category_id = metric["category"]
currency_id = metric["account__currency"]
if category_id not in result:
result[category_id] = {"name": metric["category__name"], "currencies": {}}
# Add currency data
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"expense_current": metric["expense_current"],
"expense_projected": metric["expense_projected"],
"total_expense": total_expense,
"income_current": metric["income_current"],
"income_projected": metric["income_projected"],
"total_income": total_income,
"total_current": total_current,
"total_projected": total_projected,
"total_final": total_final,
}
# Add exchanged values if exchange_currency exists
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
exchanged = {}
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
"total_income",
"total_expense",
"total_current",
"total_projected",
"total_final",
]:
amount, prefix, suffix, decimal_places = convert(
amount=currency_data[field],
from_currency=from_currency,
to_currency=exchange_currency,
)
if amount is not None:
exchanged[field] = amount
if "currency" not in exchanged:
exchanged["currency"] = {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
}
if exchanged:
currency_data["exchanged"] = exchanged
result[category_id]["currencies"][currency_id] = currency_data
return result

View File

@@ -1,3 +1,6 @@
import decimal
import json
from dateutil.relativedelta import relativedelta
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
@@ -22,7 +25,8 @@ from apps.insights.utils.sankey import (
generate_sankey_data_by_currency,
)
from apps.insights.utils.transactions import get_transactions
from apps.transactions.models import TransactionCategory
from apps.transactions.models import TransactionCategory, Transaction
from apps.insights.utils.category_overview import get_categories_totals
@login_required
@@ -116,7 +120,7 @@ def category_explorer_index(request):
@require_http_methods(["GET"])
def category_sum_by_account(request):
# Get filtered transactions
transactions = get_transactions(request)
transactions = get_transactions(request, include_silent=True)
category = request.GET.get("category")
@@ -126,7 +130,7 @@ def category_sum_by_account(request):
# Generate data
account_data = get_category_sums_by_account(transactions, category)
else:
account_data = None
account_data = get_category_sums_by_account(transactions, category=None)
return render(
request,
@@ -140,7 +144,7 @@ def category_sum_by_account(request):
@require_http_methods(["GET"])
def category_sum_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request)
transactions = get_transactions(request, include_silent=True)
category = request.GET.get("category")
@@ -150,12 +154,58 @@ def category_sum_by_currency(request):
# Generate data
currency_data = get_category_sums_by_currency(transactions, category)
else:
currency_data = None
print(currency_data)
currency_data = get_category_sums_by_currency(transactions, category=None)
return render(
request,
"insights/fragments/category_explorer/charts/currency.html",
{"currency_data": currency_data},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_overview(request):
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
total_table = get_categories_totals(
transactions_queryset=transactions, ignore_empty=False
)
return render(
request,
"insights/fragments/category_overview/index.html",
{"total_table": total_table},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def latest_transactions(request):
limit = timezone.now() - relativedelta(days=3)
transactions = Transaction.objects.filter(created_at__gte=limit).order_by("-id")[
:30
]
return render(
request,
"insights/fragments/latest_transactions.html",
{"transactions": transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def late_transactions(request):
now = timezone.localdate(timezone.now())
transactions = Transaction.objects.filter(is_paid=False, date__lt=now)
return render(
request,
"insights/fragments/late_transactions.html",
{"transactions": transactions},
)

View File

View File

@@ -0,0 +1,85 @@
from typing import Dict
from django.db.models import Func, F, Value
from django.db.models.functions import Extract
from django.utils import timezone
from apps.currencies.models import ExchangeRate
def get_currency_exchange_map(date=None) -> Dict[str, dict]:
"""
Creates a nested dictionary of exchange rates and currency information.
Returns:
{
'BTC': {
'decimal_places': 8,
'prefix': '',
'suffix': '',
'rates': {'USD': Decimal('34000.00'), 'EUR': Decimal('31000.00')}
},
'USD': {
'decimal_places': 2,
'prefix': '$',
'suffix': '',
'rates': {'BTC': Decimal('0.0000294'), 'EUR': Decimal('0.91')}
},
...
}
"""
if date is None:
date = timezone.localtime(timezone.now())
# Get all exchange rates for the closest date
exchange_rates = (
ExchangeRate.objects.select_related(
"from_currency", "to_currency"
) # Optimize currency queries
.annotate(
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
effective_rate=F("rate"),
)
.order_by("from_currency", "to_currency", "date_diff")
.distinct("from_currency", "to_currency")
)
# Initialize the result dictionary
rate_map = {}
# Build the exchange rate mapping with currency info
for rate in exchange_rates:
# Add from_currency info if not exists
if rate.from_currency.name not in rate_map:
rate_map[rate.from_currency.name] = {
"decimal_places": rate.from_currency.decimal_places,
"prefix": rate.from_currency.prefix,
"suffix": rate.from_currency.suffix,
"rates": {},
}
# Add to_currency info if not exists
if rate.to_currency.name not in rate_map:
rate_map[rate.to_currency.name] = {
"decimal_places": rate.to_currency.decimal_places,
"prefix": rate.to_currency.prefix,
"suffix": rate.to_currency.suffix,
"rates": {},
}
# Add direct rate
rate_map[rate.from_currency.name]["rates"][rate.to_currency.name] = {
"rate": rate.rate,
"decimal_places": rate.to_currency.decimal_places,
"prefix": rate.to_currency.prefix,
"suffix": rate.to_currency.suffix,
}
# Add inverse rate
rate_map[rate.to_currency.name]["rates"][rate.from_currency.name] = {
"rate": 1 / rate.rate,
"decimal_places": rate.from_currency.decimal_places,
"prefix": rate.from_currency.prefix,
"suffix": rate.from_currency.suffix,
}
return rate_map

View File

@@ -5,6 +5,7 @@ from apps.common.widgets.decimal import convert_to_decimal
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.mini_tools.forms import CurrencyConverterForm
from apps.mini_tools.utils.exchange_rate_map import get_currency_exchange_map
@login_required
@@ -14,11 +15,13 @@ def unit_price_calculator(request):
@login_required
def currency_converter(request):
rate_map = get_currency_exchange_map()
form = CurrencyConverterForm()
return render(
request,
"mini_tools/currency_converter/currency_converter.html",
context={"form": form},
context={"form": form, "rate_map": rate_map},
)

View File

@@ -297,10 +297,8 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
search_query = Q()
def add_to_query(field_name, value, operator):
if isinstance(value, (int, str)):
lookup = f"{field_name}__{operator}"
return Q(**{lookup: value})
return Q()
lookup = f"{field_name}__{operator}"
return Q(**{lookup: value})
if self.search_account:
value = simple.eval(self.search_account)

View File

@@ -131,14 +131,16 @@ def _process_update_or_create_transaction_action(action, simple_eval):
# Build search query using the helper method
search_query = action.build_search_query(simple_eval)
logger.info("Searching transactions using: %s", search_query)
# Find latest matching transaction or create new
if search_query:
transaction = (
Transaction.objects.filter(search_query).order_by("-date", "-id").first()
)
transactions = Transaction.objects.filter(search_query).order_by("-date", "-id")
transaction = transactions.first()
logger.info("Found at least one matching transaction, using latest")
else:
transaction = None
logger.info("No matching transaction found, creating a new transaction")
if not transaction:
transaction = Transaction()

View File

@@ -63,7 +63,9 @@ class TransactionForm(forms.ModelForm):
date = forms.DateField(label=_("Date"))
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
widget=AirMonthYearPickerInput(),
label=_("Reference Date"),
required=False,
)
class Meta:
@@ -176,7 +178,6 @@ class TransactionForm(forms.ModelForm):
),
)
self.fields["reference_date"].required = False
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
if self.instance and self.instance.pk:

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-02-24 19:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0018_alter_usersettings_start_page'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('de', 'Deutsch'), ('en', 'English'), ('nl', 'Nederlands'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
),
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,21 +3,21 @@
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-16 13:04-0300\n"
"PO-Revision-Date: 2025-02-16 13:05-0300\n"
"Last-Translator: Herculino Trotta\n"
"Language-Team: \n"
"POT-Creation-Date: 2025-02-27 23:32-0300\n"
"PO-Revision-Date: 2025-02-28 02:37+0000\n"
"Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n"
"Language-Team: Portuguese (Brazil) <https://translations.herculino.com/"
"projects/wygiwyh/app/pt_BR/>\n"
"Language: pt_BR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: Poedit 3.5\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.10.1\n"
#: apps/accounts/forms.py:24
msgid "Group name"
@@ -27,10 +27,10 @@ msgstr "Nome do grupo"
#: apps/currencies/forms.py:53 apps/currencies/forms.py:91
#: apps/currencies/forms.py:142 apps/dca/forms.py:49 apps/dca/forms.py:224
#: apps/import_app/forms.py:34 apps/rules/forms.py:45 apps/rules/forms.py:87
#: apps/rules/forms.py:359 apps/transactions/forms.py:190
#: apps/transactions/forms.py:257 apps/transactions/forms.py:581
#: apps/transactions/forms.py:624 apps/transactions/forms.py:656
#: apps/transactions/forms.py:691 apps/transactions/forms.py:827
#: apps/rules/forms.py:359 apps/transactions/forms.py:191
#: apps/transactions/forms.py:258 apps/transactions/forms.py:582
#: apps/transactions/forms.py:625 apps/transactions/forms.py:657
#: apps/transactions/forms.py:692 apps/transactions/forms.py:828
msgid "Update"
msgstr "Atualizar"
@@ -39,10 +39,10 @@ msgstr "Atualizar"
#: apps/currencies/forms.py:99 apps/currencies/forms.py:150
#: apps/dca/forms.py:57 apps/dca/forms.py:232 apps/import_app/forms.py:42
#: apps/rules/forms.py:53 apps/rules/forms.py:95 apps/rules/forms.py:367
#: apps/transactions/forms.py:174 apps/transactions/forms.py:199
#: apps/transactions/forms.py:589 apps/transactions/forms.py:632
#: apps/transactions/forms.py:664 apps/transactions/forms.py:699
#: apps/transactions/forms.py:835
#: apps/transactions/forms.py:176 apps/transactions/forms.py:200
#: apps/transactions/forms.py:590 apps/transactions/forms.py:633
#: apps/transactions/forms.py:665 apps/transactions/forms.py:700
#: apps/transactions/forms.py:836
#: templates/account_groups/fragments/list.html:9
#: templates/accounts/fragments/list.html:9
#: templates/categories/fragments/list.html:9
@@ -70,21 +70,23 @@ msgid "New balance"
msgstr "Novo saldo"
#: apps/accounts/forms.py:119 apps/dca/forms.py:85 apps/dca/forms.py:92
#: apps/insights/forms.py:117 apps/rules/forms.py:168 apps/rules/forms.py:183
#: apps/insights/forms.py:118 apps/rules/forms.py:168 apps/rules/forms.py:183
#: apps/rules/models.py:32 apps/rules/models.py:280
#: apps/transactions/forms.py:39 apps/transactions/forms.py:291
#: apps/transactions/forms.py:298 apps/transactions/forms.py:478
#: apps/transactions/forms.py:723 apps/transactions/models.py:203
#: apps/transactions/forms.py:39 apps/transactions/forms.py:292
#: apps/transactions/forms.py:299 apps/transactions/forms.py:479
#: apps/transactions/forms.py:724 apps/transactions/models.py:203
#: apps/transactions/models.py:378 apps/transactions/models.py:558
#: templates/insights/fragments/category_overview/index.html:9
msgid "Category"
msgstr "Categoria"
#: apps/accounts/forms.py:126 apps/dca/forms.py:101 apps/dca/forms.py:109
#: apps/export_app/forms.py:38 apps/export_app/forms.py:127
#: apps/rules/forms.py:171 apps/rules/forms.py:180 apps/rules/models.py:33
#: apps/rules/models.py:284 apps/transactions/filters.py:74
#: apps/transactions/forms.py:47 apps/transactions/forms.py:307
#: apps/transactions/forms.py:315 apps/transactions/forms.py:471
#: apps/transactions/forms.py:716 apps/transactions/models.py:209
#: apps/transactions/forms.py:47 apps/transactions/forms.py:308
#: apps/transactions/forms.py:316 apps/transactions/forms.py:472
#: apps/transactions/forms.py:717 apps/transactions/models.py:209
#: apps/transactions/models.py:380 apps/transactions/models.py:562
#: templates/includes/navbar.html:108 templates/tags/fragments/list.html:5
#: templates/tags/pages/index.html:4
@@ -158,13 +160,14 @@ msgstr ""
#: apps/accounts/models.py:59 apps/rules/forms.py:160 apps/rules/forms.py:173
#: apps/rules/models.py:24 apps/rules/models.py:236
#: apps/transactions/forms.py:59 apps/transactions/forms.py:463
#: apps/transactions/forms.py:708 apps/transactions/models.py:176
#: apps/transactions/forms.py:59 apps/transactions/forms.py:464
#: apps/transactions/forms.py:709 apps/transactions/models.py:176
#: apps/transactions/models.py:338 apps/transactions/models.py:540
msgid "Account"
msgstr "Conta"
#: apps/accounts/models.py:60 apps/transactions/filters.py:53
#: apps/accounts/models.py:60 apps/export_app/forms.py:14
#: apps/export_app/forms.py:124 apps/transactions/filters.py:53
#: templates/accounts/fragments/list.html:5
#: templates/accounts/pages/index.html:4 templates/includes/navbar.html:114
#: templates/includes/navbar.html:116
@@ -335,12 +338,12 @@ msgstr "Informação"
msgid "Cache cleared successfully"
msgstr "Cache limpo com sucesso"
#: apps/common/widgets/datepicker.py:47 apps/common/widgets/datepicker.py:186
#: apps/common/widgets/datepicker.py:244
#: apps/common/widgets/datepicker.py:53 apps/common/widgets/datepicker.py:206
#: apps/common/widgets/datepicker.py:264
msgid "Today"
msgstr "Hoje"
#: apps/common/widgets/datepicker.py:123
#: apps/common/widgets/datepicker.py:139
msgid "Now"
msgstr "Agora"
@@ -369,7 +372,7 @@ msgstr "Sufixo"
#: apps/currencies/forms.py:69 apps/dca/models.py:156 apps/rules/forms.py:163
#: apps/rules/forms.py:176 apps/rules/models.py:27 apps/rules/models.py:248
#: apps/transactions/forms.py:63 apps/transactions/forms.py:319
#: apps/transactions/forms.py:63 apps/transactions/forms.py:320
#: apps/transactions/models.py:186
#: templates/dca/fragments/strategy/details.html:52
#: templates/exchange_rates/fragments/table.html:10
@@ -389,7 +392,8 @@ msgstr "Nome da Moeda"
msgid "Decimal Places"
msgstr "Casas Decimais"
#: apps/currencies/models.py:40 apps/transactions/filters.py:60
#: apps/currencies/models.py:40 apps/export_app/forms.py:20
#: apps/export_app/forms.py:125 apps/transactions/filters.py:60
#: templates/currencies/fragments/list.html:5
#: templates/currencies/pages/index.html:4 templates/includes/navbar.html:122
#: templates/includes/navbar.html:124
@@ -419,7 +423,8 @@ msgstr "Taxa de Câmbio"
msgid "Date and Time"
msgstr "Data e Tempo"
#: apps/currencies/models.py:74 templates/exchange_rates/fragments/list.html:6
#: apps/currencies/models.py:74 apps/export_app/forms.py:62
#: apps/export_app/forms.py:137 templates/exchange_rates/fragments/list.html:6
#: templates/exchange_rates/pages/index.html:4
#: templates/includes/navbar.html:126
msgid "Exchange Rates"
@@ -525,8 +530,8 @@ msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')."
msgstr ""
"Formato inválido de hora. Use uma lista de horas separada por vírgulas "
"(0-23) e/ou uma faixa (ex.: '1-5,8,10-12')"
"Formato inválido de hora. Use uma lista de horas separada por vírgulas (0-23)"
" e/ou uma faixa (ex.: '1-5,8,10-12')."
#: apps/currencies/models.py:236
msgid ""
@@ -580,11 +585,11 @@ msgstr "Serviços marcados para execução com sucesso"
msgid "Create transaction"
msgstr "Criar transação"
#: apps/dca/forms.py:70 apps/transactions/forms.py:266
#: apps/dca/forms.py:70 apps/transactions/forms.py:267
msgid "From Account"
msgstr "Conta de origem"
#: apps/dca/forms.py:76 apps/transactions/forms.py:271
#: apps/dca/forms.py:76 apps/transactions/forms.py:272
msgid "To Account"
msgstr "Conta de destino"
@@ -609,7 +614,7 @@ msgstr "Conectar transação"
msgid "You must provide an account."
msgstr "Você deve informar uma conta."
#: apps/dca/forms.py:294 apps/transactions/forms.py:413
#: apps/dca/forms.py:294 apps/transactions/forms.py:414
msgid "From and To accounts must be different."
msgstr "As contas De e Para devem ser diferentes."
@@ -628,7 +633,7 @@ msgstr "Moeda de pagamento"
#: apps/dca/models.py:27 apps/dca/models.py:179 apps/rules/forms.py:167
#: apps/rules/forms.py:182 apps/rules/models.py:31 apps/rules/models.py:264
#: apps/transactions/forms.py:333 apps/transactions/models.py:199
#: apps/transactions/forms.py:334 apps/transactions/models.py:199
#: apps/transactions/models.py:387 apps/transactions/models.py:568
msgid "Notes"
msgstr "Notas"
@@ -637,7 +642,7 @@ msgstr "Notas"
msgid "DCA Strategy"
msgstr "Estratégia CMP"
#: apps/dca/models.py:33
#: apps/dca/models.py:33 apps/export_app/forms.py:145
msgid "DCA Strategies"
msgstr "Estratégias CMP"
@@ -657,7 +662,7 @@ msgstr "Quantia recebida"
msgid "DCA Entry"
msgstr "Entrada CMP"
#: apps/dca/models.py:185
#: apps/dca/models.py:185 apps/export_app/forms.py:146
msgid "DCA Entries"
msgstr "Entradas CMP"
@@ -685,6 +690,119 @@ msgstr "Entrada atualizada com sucesso"
msgid "Entry deleted successfully"
msgstr "Entrada apagada com sucesso"
#: apps/export_app/forms.py:26 apps/export_app/forms.py:129
#: apps/transactions/models.py:256 templates/includes/navbar.html:57
#: templates/includes/navbar.html:104
#: templates/recurring_transactions/fragments/list_transactions.html:5
#: templates/recurring_transactions/fragments/table.html:37
#: templates/transactions/pages/transactions.html:5
msgid "Transactions"
msgstr "Transações"
#: apps/export_app/forms.py:32 apps/export_app/forms.py:126
#: apps/transactions/filters.py:67 templates/categories/fragments/list.html:5
#: templates/categories/pages/index.html:4 templates/includes/navbar.html:106
msgid "Categories"
msgstr "Categorias"
#: apps/export_app/forms.py:44 apps/export_app/forms.py:128
#: apps/rules/forms.py:172 apps/rules/forms.py:181 apps/rules/models.py:34
#: apps/rules/models.py:276 apps/transactions/filters.py:81
#: apps/transactions/forms.py:55 apps/transactions/forms.py:487
#: apps/transactions/forms.py:732 apps/transactions/models.py:161
#: apps/transactions/models.py:214 apps/transactions/models.py:383
#: apps/transactions/models.py:565 templates/entities/fragments/list.html:5
#: templates/entities/pages/index.html:4 templates/includes/navbar.html:110
msgid "Entities"
msgstr "Entidades"
#: apps/export_app/forms.py:50 apps/export_app/forms.py:132
#: apps/transactions/models.py:592 templates/includes/navbar.html:74
#: templates/recurring_transactions/fragments/list.html:5
#: templates/recurring_transactions/pages/index.html:4
msgid "Recurring Transactions"
msgstr "Transações Recorrentes"
#: apps/export_app/forms.py:56 apps/export_app/forms.py:130
#: apps/transactions/models.py:391 templates/includes/navbar.html:72
#: templates/installment_plans/fragments/list.html:5
#: templates/installment_plans/pages/index.html:4
msgid "Installment Plans"
msgstr "Parcelamentos"
#: apps/export_app/forms.py:68 apps/export_app/forms.py:135
#: templates/exchange_rates_services/fragments/list.html:6
#: templates/exchange_rates_services/pages/index.html:4
#: templates/includes/navbar.html:138
msgid "Automatic Exchange Rates"
msgstr "Taxas de Câmbio Automáticas"
#: apps/export_app/forms.py:74 templates/includes/navbar.html:132
#: templates/rules/fragments/list.html:5 templates/rules/pages/index.html:4
msgid "Rules"
msgstr "Regras"
#: apps/export_app/forms.py:80 templates/cotton/transaction/item.html:56
msgid "DCA"
msgstr "CMP"
#: apps/export_app/forms.py:86 apps/export_app/forms.py:147
#: templates/import_app/fragments/profiles/list.html:5
#: templates/import_app/pages/profiles_index.html:4
msgid "Import Profiles"
msgstr "Perfis de Importação"
#: apps/export_app/forms.py:112 templates/export_app/fragments/export.html:5
#: templates/export_app/pages/index.html:15
msgid "Export"
msgstr "Exportar"
#: apps/export_app/forms.py:121
msgid "Import a ZIP file exported from WYGIWYH"
msgstr "Importe um arquivo ZIP exportado do WYGIWYH"
#: apps/export_app/forms.py:122
msgid "ZIP File"
msgstr "Arquivo ZIP"
#: apps/export_app/forms.py:138 apps/rules/models.py:16
msgid "Transaction rules"
msgstr "Regra da transação"
#: apps/export_app/forms.py:140 apps/rules/models.py:53
msgid "Edit transaction action"
msgstr "Ação de editar de transação"
#: apps/export_app/forms.py:143 apps/rules/models.py:290
msgid "Update or create transaction actions"
msgstr "Ações de atualizar ou criar transação"
#: apps/export_app/forms.py:176 templates/cotton/transaction/item.html:158
#: templates/cotton/ui/deleted_transactions_action_bar.html:47
#: templates/export_app/fragments/restore.html:5
#: templates/export_app/pages/index.html:24
msgid "Restore"
msgstr "Restaurar"
#: apps/export_app/forms.py:187
msgid "Please upload either a ZIP file or at least one CSV file"
msgstr "Carregue um arquivo ZIP ou pelo menos um arquivo CSV"
#: apps/export_app/views.py:168
msgid "You have to select at least one export"
msgstr "É necessário selecionar pelo menos uma exportação"
#: apps/export_app/views.py:186
msgid "Data restored successfully"
msgstr "Dados restaurados com sucesso"
#: apps/export_app/views.py:198
msgid ""
"There was an error restoring your data. Check the logs for more details."
msgstr ""
"Ocorreu um erro ao restaurar seus dados. Verifique o log para obter mais "
"detalhes."
#: apps/import_app/forms.py:49
msgid "Select a file"
msgstr "Selecione um arquivo"
@@ -759,31 +877,48 @@ msgstr "Importação adicionada à fila com sucesso"
msgid "Run deleted successfully"
msgstr "Importação apagada com sucesso"
#: apps/insights/utils/category_explorer.py:44
#: apps/insights/utils/category_explorer.py:93 apps/transactions/models.py:170
#: templates/calendar_view/fragments/list.html:42
#: templates/calendar_view/fragments/list.html:44
#: templates/calendar_view/fragments/list.html:52
#: templates/calendar_view/fragments/list.html:54
#: templates/cotton/ui/quick_transactions_buttons.html:10
#: templates/insights/fragments/category_explorer/charts/account.html:54
#: templates/insights/fragments/category_explorer/charts/currency.html:55
#: templates/monthly_overview/fragments/monthly_summary.html:39
msgid "Income"
msgstr "Renda"
#: apps/insights/utils/category_explorer.py:48
#: apps/insights/utils/category_explorer.py:97
#: templates/insights/fragments/category_explorer/charts/account.html:60
#: templates/insights/fragments/category_explorer/charts/currency.html:61
#: templates/monthly_overview/fragments/monthly_summary.html:103
msgid "Expenses"
msgstr "Despesas"
#: apps/insights/utils/sankey.py:36 apps/insights/utils/sankey.py:167
#: apps/insights/forms.py:119 apps/insights/utils/sankey.py:36
#: apps/insights/utils/sankey.py:167
#: templates/insights/fragments/category_overview/index.html:18
msgid "Uncategorized"
msgstr "Sem categoria"
#: apps/insights/utils/category_explorer.py:66
#: apps/insights/utils/category_explorer.py:145
#: templates/cotton/ui/percentage_distribution.html:10
#: templates/cotton/ui/percentage_distribution.html:14
#: templates/insights/fragments/category_explorer/charts/account.html:72
#: templates/insights/fragments/category_explorer/charts/currency.html:72
msgid "Current Income"
msgstr "Renda Atual"
#: apps/insights/utils/category_explorer.py:70
#: apps/insights/utils/category_explorer.py:149
#: templates/cotton/ui/percentage_distribution.html:24
#: templates/cotton/ui/percentage_distribution.html:28
#: templates/insights/fragments/category_explorer/charts/account.html:66
#: templates/insights/fragments/category_explorer/charts/currency.html:66
msgid "Current Expenses"
msgstr "Despesas Atuais"
#: apps/insights/utils/category_explorer.py:74
#: apps/insights/utils/category_explorer.py:153
#: templates/cotton/ui/percentage_distribution.html:3
#: templates/cotton/ui/percentage_distribution.html:7
#: templates/insights/fragments/category_explorer/charts/account.html:78
#: templates/insights/fragments/category_explorer/charts/currency.html:78
msgid "Projected Income"
msgstr "Renda Prevista"
#: apps/insights/utils/category_explorer.py:78
#: apps/insights/utils/category_explorer.py:157
#: templates/cotton/ui/percentage_distribution.html:17
#: templates/cotton/ui/percentage_distribution.html:21
#: templates/insights/fragments/category_explorer/charts/account.html:60
#: templates/insights/fragments/category_explorer/charts/currency.html:60
msgid "Projected Expenses"
msgstr "Despesas Previstas"
#: apps/insights/utils/sankey.py:133 apps/insights/utils/sankey.py:134
#: apps/insights/utils/sankey.py:263 apps/insights/utils/sankey.py:264
msgid "Saved"
@@ -805,7 +940,7 @@ msgstr "Se..."
msgid "Set field"
msgstr "Definir campo"
#: apps/rules/forms.py:65 templates/insights/fragments/sankey.html:90
#: apps/rules/forms.py:65 templates/insights/fragments/sankey.html:94
msgid "To"
msgstr "Para"
@@ -837,8 +972,8 @@ msgid "Paid"
msgstr "Pago"
#: apps/rules/forms.py:164 apps/rules/forms.py:177 apps/rules/models.py:28
#: apps/rules/models.py:252 apps/transactions/forms.py:66
#: apps/transactions/forms.py:322 apps/transactions/forms.py:492
#: apps/rules/models.py:252 apps/transactions/forms.py:67
#: apps/transactions/forms.py:323 apps/transactions/forms.py:493
#: apps/transactions/models.py:187 apps/transactions/models.py:361
#: apps/transactions/models.py:570
msgid "Reference Date"
@@ -846,13 +981,13 @@ msgstr "Data de Referência"
#: apps/rules/forms.py:165 apps/rules/forms.py:178 apps/rules/models.py:29
#: apps/rules/models.py:256 apps/transactions/models.py:192
#: apps/transactions/models.py:551 templates/insights/fragments/sankey.html:91
#: apps/transactions/models.py:551 templates/insights/fragments/sankey.html:95
msgid "Amount"
msgstr "Quantia"
#: apps/rules/forms.py:166 apps/rules/forms.py:179 apps/rules/models.py:11
#: apps/rules/models.py:30 apps/rules/models.py:260
#: apps/transactions/forms.py:325 apps/transactions/models.py:197
#: apps/transactions/forms.py:326 apps/transactions/models.py:197
#: apps/transactions/models.py:345 apps/transactions/models.py:554
msgid "Description"
msgstr "Descrição"
@@ -867,16 +1002,6 @@ msgstr "Nota Interna"
msgid "Internal ID"
msgstr "ID Interna"
#: apps/rules/forms.py:172 apps/rules/forms.py:181 apps/rules/models.py:34
#: apps/rules/models.py:276 apps/transactions/filters.py:81
#: apps/transactions/forms.py:55 apps/transactions/forms.py:486
#: apps/transactions/forms.py:731 apps/transactions/models.py:161
#: apps/transactions/models.py:214 apps/transactions/models.py:383
#: apps/transactions/models.py:565 templates/entities/fragments/list.html:5
#: templates/entities/pages/index.html:4 templates/includes/navbar.html:110
msgid "Entities"
msgstr "Entidades"
#: apps/rules/forms.py:199
msgid "Search Criteria"
msgstr "Critério de Busca"
@@ -893,10 +1018,6 @@ msgstr "Gatilho"
msgid "Transaction rule"
msgstr "Regra da transação"
#: apps/rules/models.py:16
msgid "Transaction rules"
msgstr "Regra da transação"
#: apps/rules/models.py:40 apps/rules/models.py:78
msgid "Rule"
msgstr "Regra"
@@ -909,13 +1030,9 @@ msgstr "Campo"
msgid "Value"
msgstr "Valor"
#: apps/rules/models.py:53
msgid "Edit transaction action"
msgstr "Editar ação de transação"
#: apps/rules/models.py:54
msgid "Edit transaction actions"
msgstr "Editar ações de transação"
msgstr "Ações de editar de transação"
#: apps/rules/models.py:64
msgid "is exactly"
@@ -967,11 +1084,7 @@ msgstr ""
#: apps/rules/models.py:289
msgid "Update or create transaction action"
msgstr "Atualizar ou criar transação ação"
#: apps/rules/models.py:290
msgid "Update or create transaction actions"
msgstr "Atualizar ou criar transação ações"
msgstr "Ação de atualizar ou criar transação"
#: apps/rules/views.py:52
msgid "Rule deactivated successfully"
@@ -1028,11 +1141,6 @@ msgstr "Conteúdo"
msgid "Transaction Type"
msgstr "Tipo de Transação"
#: apps/transactions/filters.py:67 templates/categories/fragments/list.html:5
#: templates/categories/pages/index.html:4 templates/includes/navbar.html:106
msgid "Categories"
msgstr "Categorias"
#: apps/transactions/filters.py:91
msgid "Date from"
msgstr "Data de"
@@ -1053,40 +1161,40 @@ msgstr "Quantia miníma"
msgid "Amount max"
msgstr "Quantia máxima"
#: apps/transactions/forms.py:158
#: apps/transactions/forms.py:160
msgid "More"
msgstr "Mais"
#: apps/transactions/forms.py:278
#: apps/transactions/forms.py:279
msgid "From Amount"
msgstr "Quantia de origem"
#: apps/transactions/forms.py:283
#: apps/transactions/forms.py:284
msgid "To Amount"
msgstr "Quantia de destino"
#: apps/transactions/forms.py:398
#: apps/transactions/forms.py:399
#: templates/cotton/ui/quick_transactions_buttons.html:40
msgid "Transfer"
msgstr "Transferir"
#: apps/transactions/forms.py:610
#: apps/transactions/forms.py:611
msgid "Tag name"
msgstr "Nome da Tag"
#: apps/transactions/forms.py:642
#: apps/transactions/forms.py:643
msgid "Entity name"
msgstr "Nome da entidade"
#: apps/transactions/forms.py:674
#: apps/transactions/forms.py:675
msgid "Category name"
msgstr "Nome da Categoria"
#: apps/transactions/forms.py:676
#: apps/transactions/forms.py:677
msgid "Muted categories won't count towards your monthly total"
msgstr "As categorias silenciadas não serão contabilizadas em seu total mensal"
#: apps/transactions/forms.py:846
#: apps/transactions/forms.py:847
msgid "End date should be after the start date"
msgstr "Data final deve ser após data inicial"
@@ -1132,12 +1240,24 @@ msgstr ""
msgid "Entity"
msgstr "Entidade"
#: apps/transactions/models.py:170
#: templates/calendar_view/fragments/list.html:42
#: templates/calendar_view/fragments/list.html:44
#: templates/calendar_view/fragments/list.html:52
#: templates/calendar_view/fragments/list.html:54
#: templates/cotton/ui/quick_transactions_buttons.html:10
#: templates/insights/fragments/category_overview/index.html:10
#: templates/monthly_overview/fragments/monthly_summary.html:39
msgid "Income"
msgstr "Renda"
#: apps/transactions/models.py:171
#: templates/calendar_view/fragments/list.html:46
#: templates/calendar_view/fragments/list.html:48
#: templates/calendar_view/fragments/list.html:56
#: templates/calendar_view/fragments/list.html:58
#: templates/cotton/ui/quick_transactions_buttons.html:18
#: templates/insights/fragments/category_overview/index.html:11
msgid "Expense"
msgstr "Despesa"
@@ -1161,14 +1281,6 @@ msgstr "Apagado Em"
msgid "Transaction"
msgstr "Transação"
#: apps/transactions/models.py:256 templates/includes/navbar.html:57
#: templates/includes/navbar.html:104
#: templates/recurring_transactions/fragments/list_transactions.html:5
#: templates/recurring_transactions/fragments/table.html:37
#: templates/transactions/pages/transactions.html:5
msgid "Transactions"
msgstr "Transações"
#: apps/transactions/models.py:323 templates/tags/fragments/table.html:53
msgid "No tags"
msgstr "Nenhuma tag"
@@ -1226,12 +1338,6 @@ msgstr "Recorrência"
msgid "Installment Amount"
msgstr "Valor da Parcela"
#: apps/transactions/models.py:391 templates/includes/navbar.html:72
#: templates/installment_plans/fragments/list.html:5
#: templates/installment_plans/pages/index.html:4
msgid "Installment Plans"
msgstr "Parcelamentos"
#: apps/transactions/models.py:533
msgid "day(s)"
msgstr "dia(s)"
@@ -1269,12 +1375,6 @@ msgstr "Última data gerada"
msgid "Last Generated Reference Date"
msgstr "Última data de referência gerada"
#: apps/transactions/models.py:592 templates/includes/navbar.html:74
#: templates/recurring_transactions/fragments/list.html:5
#: templates/recurring_transactions/pages/index.html:4
msgid "Recurring Transactions"
msgstr "Transações Recorrentes"
#: apps/transactions/validators.py:8
#, python-format
msgid "%(value)s has too many decimal places. Maximum is 30."
@@ -1783,6 +1883,7 @@ msgid "Muted"
msgstr "Silenciada"
#: templates/categories/fragments/table.html:57
#: templates/insights/fragments/category_overview/index.html:67
msgid "No categories"
msgstr "Nenhum categoria"
@@ -1796,6 +1897,7 @@ msgstr "Fechar"
#: templates/cotton/config/search.html:6
#: templates/import_app/fragments/profiles/list_presets.html:13
#: templates/monthly_overview/pages/overview.html:177
msgid "Search"
msgstr "Buscar"
@@ -1803,20 +1905,11 @@ msgstr "Buscar"
msgid "Select"
msgstr "Selecionar"
#: templates/cotton/transaction/item.html:56
msgid "DCA"
msgstr "CMP"
#: templates/cotton/transaction/item.html:137
#: templates/cotton/ui/transactions_action_bar.html:78
msgid "Duplicate"
msgstr "Duplicar"
#: templates/cotton/transaction/item.html:158
#: templates/cotton/ui/deleted_transactions_action_bar.html:47
msgid "Restore"
msgstr "Restaurar"
#: templates/cotton/ui/account_card.html:15
#: templates/cotton/ui/currency_card.html:10
msgid "projected income"
@@ -1920,26 +2013,6 @@ msgstr "Minímo"
msgid "Count"
msgstr "Contagem"
#: templates/cotton/ui/percentage_distribution.html:3
#: templates/cotton/ui/percentage_distribution.html:7
msgid "Projected Income"
msgstr "Renda Prevista"
#: templates/cotton/ui/percentage_distribution.html:10
#: templates/cotton/ui/percentage_distribution.html:14
msgid "Current Income"
msgstr "Renda Atual"
#: templates/cotton/ui/percentage_distribution.html:17
#: templates/cotton/ui/percentage_distribution.html:21
msgid "Projected Expenses"
msgstr "Despesas Previstas"
#: templates/cotton/ui/percentage_distribution.html:24
#: templates/cotton/ui/percentage_distribution.html:28
msgid "Current Expenses"
msgstr "Despesas Atuais"
#: templates/cotton/ui/quick_transactions_buttons.html:25
msgid "Installment"
msgstr "Parcelamento"
@@ -2141,12 +2214,6 @@ msgstr "Nenhuma taxa de câmbio"
msgid "Page navigation"
msgstr "Navegação por página"
#: templates/exchange_rates_services/fragments/list.html:6
#: templates/exchange_rates_services/pages/index.html:4
#: templates/includes/navbar.html:136
msgid "Automatic Exchange Rates"
msgstr "Taxas de Câmbio Automáticas"
#: templates/exchange_rates_services/fragments/list.html:21
msgid "Fetch all"
msgstr "Executar todos"
@@ -2175,6 +2242,10 @@ msgstr "contas"
msgid "No services configured"
msgstr "Nenhum serviço configurado"
#: templates/export_app/pages/index.html:4 templates/includes/navbar.html:136
msgid "Export and Restore"
msgstr "Exportar e Restaurar"
#: templates/import_app/fragments/profiles/add.html:6
msgid "Add new import profile"
msgstr "Adicionar novo perfil de importação"
@@ -2187,11 +2258,6 @@ msgstr "Uma mensagem do autor"
msgid "Edit import profile"
msgstr "Editar perfil de importação"
#: templates/import_app/fragments/profiles/list.html:5
#: templates/import_app/pages/profiles_index.html:4
msgid "Import Profiles"
msgstr "Perfis de Importação"
#: templates/import_app/fragments/profiles/list.html:17
msgid "New"
msgstr "Novo"
@@ -2318,20 +2384,15 @@ msgstr "Gerenciar"
msgid "Automation"
msgstr "Automação"
#: templates/includes/navbar.html:132 templates/rules/fragments/list.html:5
#: templates/rules/pages/index.html:4
msgid "Rules"
msgstr "Regras"
#: templates/includes/navbar.html:146
#: templates/includes/navbar.html:148
msgid "Only use this if you know what you're doing"
msgstr "Só use isso se você souber o que está fazendo"
#: templates/includes/navbar.html:147
#: templates/includes/navbar.html:149
msgid "Django Admin"
msgstr "Django Admin"
#: templates/includes/navbar.html:156
#: templates/includes/navbar.html:158
msgid "Calculator"
msgstr "Calculadora"
@@ -2364,56 +2425,98 @@ msgstr "Cancelar"
msgid "Confirm"
msgstr "Confirmar"
#: templates/insights/fragments/category_explorer/index.html:13
#: templates/insights/fragments/category_explorer/charts/account.html:100
#: templates/insights/fragments/category_explorer/charts/currency.html:92
#: templates/monthly_overview/fragments/monthly_account_summary.html:14
#: templates/monthly_overview/fragments/monthly_currency_summary.html:13
#: templates/transactions/fragments/all_account_summary.html:14
#: templates/transactions/fragments/all_currency_summary.html:13
#: templates/transactions/fragments/summary.html:27
#: templates/transactions/fragments/summary.html:42
#: templates/yearly_overview/fragments/account_data.html:12
#: templates/yearly_overview/fragments/currency_data.html:12
msgid "No information to display"
msgstr "Não há informação para mostrar"
#: templates/insights/fragments/category_explorer/index.html:14
msgid "Income/Expense by Account"
msgstr "Gasto/Despesa por Conta"
#: templates/insights/fragments/category_explorer/index.html:25
#: templates/insights/fragments/category_explorer/index.html:26
msgid "Income/Expense by Currency"
msgstr "Gasto/Despesa por Moeda"
#: templates/insights/fragments/sankey.html:89
#: templates/insights/fragments/category_overview/index.html:12
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr "Total"
#: templates/insights/fragments/late_transactions.html:15
msgid "All good!"
msgstr "Tudo certo!"
#: templates/insights/fragments/late_transactions.html:16
msgid "No late transactions"
msgstr "Nenhuma transação atrasada"
#: templates/insights/fragments/latest_transactions.html:14
msgid "No recent transactions"
msgstr "Nenhuma transação recente"
#: templates/insights/fragments/sankey.html:93
msgid "From"
msgstr "De"
#: templates/insights/fragments/sankey.html:92
#: templates/insights/fragments/sankey.html:96
msgid "Percentage"
msgstr "Porcentagem"
#: templates/insights/pages/index.html:33
#: templates/insights/pages/index.html:35
msgid "Month"
msgstr "Mês"
#: templates/insights/pages/index.html:36
#: templates/insights/pages/index.html:38
#: templates/yearly_overview/pages/overview_by_account.html:61
#: templates/yearly_overview/pages/overview_by_currency.html:63
msgid "Year"
msgstr "Ano"
#: templates/insights/pages/index.html:39
#: templates/insights/pages/index.html:43
msgid "Month Range"
msgstr "Intervalo de Mês"
#: templates/insights/pages/index.html:42
#: templates/insights/pages/index.html:48
msgid "Year Range"
msgstr "Intervalo de Ano"
#: templates/insights/pages/index.html:45
#: templates/insights/pages/index.html:53
msgid "Date Range"
msgstr "Intervalo de Data"
#: templates/insights/pages/index.html:74
#: templates/insights/pages/index.html:81
msgid "Account Flow"
msgstr "Fluxo de Conta"
#: templates/insights/pages/index.html:81
#: templates/insights/pages/index.html:88
msgid "Currency Flow"
msgstr "Fluxo de Moeda"
#: templates/insights/pages/index.html:88
#: templates/insights/pages/index.html:95
msgid "Category Explorer"
msgstr "Explorador de Categoria"
#: templates/insights/pages/index.html:102
msgid "Categories Overview"
msgstr "Visão geral das categorias"
#: templates/insights/pages/index.html:109
msgid "Late Transactions"
msgstr "Transações Atrasadas"
#: templates/insights/pages/index.html:115
msgid "Latest Transactions"
msgstr "Últimas Transações"
#: templates/installment_plans/fragments/add.html:5
msgid "Add installment plan"
msgstr "Adicionar parcelamento"
@@ -2483,17 +2586,6 @@ msgstr "Item"
msgid "No transactions this month"
msgstr "Nenhuma transação neste mês"
#: templates/monthly_overview/fragments/monthly_account_summary.html:14
#: templates/monthly_overview/fragments/monthly_currency_summary.html:13
#: templates/transactions/fragments/all_account_summary.html:14
#: templates/transactions/fragments/all_currency_summary.html:13
#: templates/transactions/fragments/summary.html:27
#: templates/transactions/fragments/summary.html:42
#: templates/yearly_overview/fragments/account_data.html:12
#: templates/yearly_overview/fragments/currency_data.html:12
msgid "No information to display"
msgstr "Não há informação para mostrar"
#: templates/monthly_overview/fragments/monthly_summary.html:6
msgid "Daily Spending Allowance"
msgstr "Gasto Diário"
@@ -2514,9 +2606,9 @@ msgstr "atual"
msgid "projected"
msgstr "previsto"
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr "Total"
#: templates/monthly_overview/fragments/monthly_summary.html:103
msgid "Expenses"
msgstr "Despesas"
#: templates/monthly_overview/fragments/monthly_summary.html:257
msgid "Distribution"
@@ -2780,6 +2872,31 @@ msgstr "Mostrar valores"
msgid "Yearly Overview"
msgstr "Visão Anual"
#, fuzzy
#~| msgid "From Amount"
#~ msgid "Principal Amount"
#~ msgstr "Quantia de origem"
#, fuzzy
#~| msgid "Interval"
#~ msgid "Interest"
#~ msgstr "Intervalo"
#, fuzzy
#~| msgid "Management"
#~ msgid "Loan Payment"
#~ msgstr "Gerenciar"
#, fuzzy
#~| msgid "Management"
#~ msgid "Loan Payments"
#~ msgstr "Gerenciar"
#, fuzzy
#~| msgid "Installment Plans"
#~ msgid "Installment Planss"
#~ msgstr "Parcelamentos"
#, fuzzy
#~| msgid "Tags"
#~ msgid "No Tags"
@@ -2840,11 +2957,6 @@ msgstr "Visão Anual"
#~ msgid "Reference Date Operator"
#~ msgstr "Data de Referência de"
#, fuzzy
#~| msgid "From Amount"
#~ msgid "Search Amount"
#~ msgstr "Quantia de origem"
#, fuzzy
#~| msgid "Amount max"
#~ msgid "Amount Operator"

View File

@@ -1,7 +1,7 @@
<div class="row {% if not remove_padding %}p-5{% endif %}">
<div class="col {% if not remove_padding %}p-5{% endif %}">
<div class="text-center">
<i class="fa-solid fa-circle-xmark tw-text-6xl"></i>
<i class="{% if icon %}{{ icon }}{% else %}fa-solid fa-circle-xmark{% endif %} tw-text-6xl"></i>
<p class="lead mt-4 mb-0">{{ title }}</p>
<p class="tw-text-gray-500">{{ subtitle }}</p>
</div>

View File

@@ -1,7 +1,7 @@
{% load markdown %}
{% load i18n %}
<div class="transaction">
<div class="d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
<div class="d-flex my-1">
{% if not disable_selection %}
<label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
@@ -15,9 +15,9 @@
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
on mouseout add .tw-invisible to the first .transaction-actions in me end">
<div class="row font-monospace tw-text-sm align-items-center">
<div class="col-lg-1 col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center">
<div class="col-lg-auto col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center p-0 ps-1">
{% if not transaction.deleted %}
<a class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
<a class="text-decoration-none p-3 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
role="button"
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
@@ -27,14 +27,14 @@
class="fa-regular fa-circle"></i>{% endif %}
</a>
{% else %}
<div class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
<div class="text-decoration-none p-3 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}">
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
class="fa-regular fa-circle"></i>{% endif %}
</div>
{% endif %}
</div>
<div class="col-lg-8 col-12">
<div class="col-lg col-12">
{# Date#}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
@@ -92,7 +92,7 @@
{% endwith %}
</div>
</div>
<div class="col-lg-3 col-12 text-lg-end align-self-end">
<div class="col-lg-auto col-12 text-lg-end align-self-end">
<div class="main-amount mb-2 mb-lg-0">
<c-amount.display
:amount="transaction.amount"

View File

@@ -1,9 +1,9 @@
<div class="card tw-relative h-100 shadow">
<div class="tw-absolute tw-h-8 tw-w-8 tw-right-2 tw-top-2 tw-bg-{{ color }}-300 tw-text-{{ color }}-800 text-center align-items-center d-flex justify-content-center rounded-2">
<i class="{{ icon }}"></i>
{% if icon %}<i class="{{ icon }}"></i>{% else %}<span class="fw-bold">{{ title.0 }}</span>{% endif %}
</div>
<div class="card-body">
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}{% include 'includes/help_icon.html' with content=help_text %}{% endif %}</h5>
{{ slot }}
</div>
</div>
</div>

View File

@@ -27,7 +27,7 @@
<ul class="dropdown-menu">
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400 me-3"></i>{% translate 'Select All' %}
</div>
</li>

View File

@@ -0,0 +1,13 @@
{% extends "extends/offcanvas.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block title %}{% translate 'Export' %}{% endblock %}
{% block body %}
<div class="container p-3">
<form method="post" action="{% url 'export_form' %}" id="export-form" class="show-loading px-1" _="on submit trigger hide_offcanvas" target="_blank">
{% crispy form %}
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "extends/offcanvas.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block title %}{% translate 'Restore' %}{% endblock %}
{% block body %}
<div class="container p-3">
<form hx-post="{% url 'restore_form' %}"
hx-target="#generic-offcanvas"
id="restore-form"
enctype="multipart/form-data"
class="show-loading px-1">
{% crispy form %}
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Export and Restore' %}{% endblock %}
{% block content %}
<div class="container">
<div class="row d-flex flex-row align-items-center justify-content-center my-5">
<div class="text-center w-auto mb-3">
<button class="btn btn-outline-success d-flex flex-column align-items-center justify-content-center p-3"
style="width: 100px; height: 100px;"
hx-get="{% url 'export_form' %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-download mb-1"></i>
<span>{% trans 'Export' %}</span>
</button>
</div>
<div class="text-center w-auto mb-3">
<button class="btn btn-outline-primary d-flex flex-column align-items-center justify-content-center p-3"
style="width: 100px; height: 100px;"
hx-get="{% url 'restore_form' %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-upload mb-1"></i>
<span>{% trans 'Restore' %}</span>
</button>
</div>
</div>
</div>
{% endblock %}

View File

@@ -94,7 +94,7 @@
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index' %}"
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@@ -132,6 +132,8 @@
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
<li><a class="dropdown-item {% active_link views='import_profiles_index' %}"
href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li>
<li><a class="dropdown-item {% active_link views='export_index' %}"
href="{% url 'export_index' %}">{% translate 'Export and Restore' %}</a></li>
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
<li>

View File

@@ -1,79 +1,101 @@
{% load i18n %}
<div class="chart-container" style="position: relative; height:400px; width:100%" _="init call setupAccountChart() end">
<canvas id="accountChart"></canvas>
</div>
{% if account_data.labels %}
<div class="chart-container" style="position: relative; height:400px; width:100%"
_="init call setupAccountChart() end">
<canvas id="accountChart"></canvas>
</div>
<script>
// Get the data from your Django view (passed as JSON)
var accountData = {{ account_data|safe }};
<script>
// Get the data from your Django view (passed as JSON)
var accountData = {{ account_data|safe }};
function setupAccountChart() {
var chartOptions = {
indexAxis: 'y', // This makes the chart horizontal
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true, // Enable stacking on the x-axis
title: {
display: false,
},
},
y: {
stacked: true, // Enable stacking on the y-axis
title: {
display: false,
},
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context) {
if (context.parsed.x !== null) {
return new Intl.NumberFormat(undefined).format(Math.abs(context.parsed.x)); // Using abs for display
}
return "";
},
}
}
}
};
function setupAccountChart() {
var chartOptions = {
indexAxis: 'y', // This makes the chart horizontal
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true, // Enable stacking on the x-axis
title: {
display: false,
},
},
y: {
stacked: true, // Enable stacking on the y-axis
title: {
display: false,
},
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context) {
if (context.parsed.x !== null) {
return `${context.dataset.label}: ${new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 30,
roundingMode: 'trunc'
}).format(Math.abs(context.parsed.x))}`;
}
return "";
},
}
}
}
};
new Chart(
document.getElementById('accountChart'),
{
type: 'bar',
data: {
labels: accountData.labels,
datasets: [
{
label: "{% trans 'Income' %}",
data: accountData.datasets[0].data,
backgroundColor: '#4dde80',
stack: 'stack0'
},
{
label: "{% trans 'Expenses' %}",
data: accountData.datasets[1].data,
backgroundColor: '#f87171',
stack: 'stack0'
}
]
},
options: {
...chartOptions,
plugins: {
...chartOptions.plugins,
title: {
display: false,
}
}
}
}
);
}
</script>
new Chart(
document.getElementById('accountChart'),
{
type: 'bar',
data: {
labels: accountData.labels,
datasets: [
{
label: "{% trans 'Projected Expenses' %}",
data: accountData.datasets[3].data,
backgroundColor: '#f8717180', // Added transparency
stack: 'stack0'
},
{
label: "{% trans 'Current Expenses' %}",
data: accountData.datasets[1].data,
backgroundColor: '#f87171',
stack: 'stack0'
},
{
label: "{% trans 'Current Income' %}",
data: accountData.datasets[0].data,
backgroundColor: '#4dde80',
stack: 'stack0'
},
{
label: "{% trans 'Projected Income' %}",
data: accountData.datasets[2].data,
backgroundColor: '#4dde8080', // Added transparency
stack: 'stack0'
},
]
},
options: {
...chartOptions,
plugins: {
...chartOptions.plugins,
title: {
display: false,
}
}
}
}
);
}
</script>
{% else %}
<c-msg.empty title="{% translate "No information to display" %}"></c-msg.empty>
{% endif %}

View File

@@ -1,80 +1,93 @@
{% load i18n %}
<div class="chart-container" style="position: relative; height:400px; width:100%"
_="init call setupCurrencyChart() end">
<canvas id="currencyChart"></canvas>
</div>
{% if currency_data.labels %}
<div class="chart-container" style="position: relative; height:400px; width:100%"
_="init call setupCurrencyChart() end">
<canvas id="currencyChart"></canvas>
</div>
<script>
// Get the data from your Django view (passed as JSON)
var currencyData = {{ currency_data|safe }};
<script>
// Get the data from your Django view (passed as JSON)
var currencyData = {{ currency_data|safe }};
function setupCurrencyChart() {
var chartOptions = {
indexAxis: 'y', // This makes the chart horizontal
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true, // Enable stacking on the x-axis
title: {
display: false,
},
},
y: {
stacked: true, // Enable stacking on the y-axis
title: {
display: false,
},
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context) {
if (context.parsed.x !== null) {
return new Intl.NumberFormat(undefined).format(Math.abs(context.parsed.x)); // Using abs for display
}
return "";
},
}
}
}
};
function setupCurrencyChart() {
var chartOptions = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
title: {
display: false,
},
},
y: {
stacked: true,
title: {
display: false,
},
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context) {
if (context.parsed.x !== null) {
return `${context.dataset.label}: ${new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 30,
roundingMode: 'trunc'
}).format(Math.abs(context.parsed.x))}`;
}
return "";
},
}
}
}
};
new Chart(
document.getElementById('currencyChart'),
{
type: 'bar',
data: {
labels: currencyData.labels,
datasets: [
{
label: "{% trans 'Income' %}",
data: currencyData.datasets[0].data,
backgroundColor: '#4dde80',
stack: 'stack0'
},
{
label: "{% trans 'Expenses' %}",
data: currencyData.datasets[1].data,
backgroundColor: '#f87171',
stack: 'stack0'
}
]
},
options: {
...chartOptions,
plugins: {
...chartOptions.plugins,
title: {
display: false,
}
}
}
}
);
}
</script>
new Chart(
document.getElementById('currencyChart'),
{
type: 'bar',
data: {
labels: currencyData.labels,
datasets: [
{
label: "{% trans 'Projected Expenses' %}",
data: currencyData.datasets[3].data,
backgroundColor: '#f8717180', // Added transparency
stack: 'stack0'
},
{
label: "{% trans 'Current Expenses' %}",
data: currencyData.datasets[1].data,
backgroundColor: '#f87171',
stack: 'stack0'
},
{
label: "{% trans 'Current Income' %}",
data: currencyData.datasets[0].data,
backgroundColor: '#4dde80',
stack: 'stack0'
},
{
label: "{% trans 'Projected Income' %}",
data: currencyData.datasets[2].data,
backgroundColor: '#4dde8080', // Added transparency
stack: 'stack0'
},
]
},
options: chartOptions
}
);
}
</script>
{% else %}
<c-msg.empty title="{% translate "No information to display" %}"></c-msg.empty>
{% endif %}

View File

@@ -2,13 +2,14 @@
{% load crispy_forms_tags %}
<form _="install init_tom_select
on change trigger updated" id="category-form">
on change trigger updated
init trigger updated" id="category-form">
{% crispy category_form %}
</form>
<div class="row row-cols-1 row-cols-lg-2 gx-3 gy-3">
<div class="col">
<div class="card">
<div class="card h-100">
<div class="card-header">
{% trans "Income/Expense by Account" %}
</div>
@@ -20,14 +21,13 @@
</div>
</div>
<div class="col">
<div class="card">
<div class="card h-100">
<div class="card-header">
{% trans "Income/Expense by Currency" %}
</div>
<div class="card-body">
<div id="currency-card" class="show-loading" hx-get="{% url 'category_sum_by_currency' %}"
hx-trigger="updated from:window" hx-include="#category-form, #picker-form, #picker-type">
</div>
</div>
</div>

View File

@@ -0,0 +1,69 @@
{% load i18n %}
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
{% if total_table %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{% trans 'Category' %}</th>
<th scope="col">{% trans 'Income' %}</th>
<th scope="col">{% trans 'Expense' %}</th>
<th scope="col">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody>
{% for category in total_table.values %}
<tr>
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_income != 0 %}
<c-amount.display
:amount="currency.total_income"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_expense != 0 %}
<c-amount.display
:amount="currency.total_expense"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_final != 0 %}
<c-amount.display
:amount="currency.total_final"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No categories" %}"></c-msg.empty>
{% endif %}
</div>

View File

@@ -0,0 +1,18 @@
{% load i18n %}
<div hx-get="{% url 'insights_late_transactions' %}" hx-trigger="updated from:window" class="show-loading"
id="transactions-list" hx-swap="outerHTML">
{% if transactions %}
{% for transaction in transactions %}
<c-transaction.item :transaction="transaction"></c-transaction.item>
{% endfor %}
{# Floating bar #}
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
{% else %}
<c-msg.empty
icon="fa-regular fa-hourglass"
title="{% translate 'All good!' %}"
subtitle="{% translate "No late transactions" %}"></c-msg.empty>
{% endif %}
</div>

View File

@@ -0,0 +1,16 @@
{% load i18n %}
<div hx-get="{% url 'insights_late_transactions' %}" hx-trigger="updated from:window" class="show-loading"
id="transactions-list" hx-swap="outerHTML">
{% if transactions %}
{% for transaction in transactions %}
<c-transaction.item :transaction="transaction"></c-transaction.item>
{% endfor %}
{# Floating bar #}
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
{% else %}
<c-msg.empty
title="{% translate 'No recent transactions' %}"></c-msg.empty>
{% endif %}
</div>

View File

@@ -7,7 +7,7 @@
<div class="show-loading" hx-get="{% url 'insights_sankey_by_currency' %}" hx-trigger="updated from:window"
hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
{% endif %}
<div class="chart-container position-relative tw-min-h-[60vh] tw-max-h-[60vh] tw-h-full tw-w-full"
<div class="chart-container position-relative tw-min-h-[85vh] tw-max-h-[85vh] tw-h-full tw-w-full"
id="sankeyContainer"
_="init call setupSankeyChart() end">
<canvas id="sankeyChart"></canvas>
@@ -64,6 +64,7 @@
alpha: 0.5,
size: 'max',
color: "white",
nodePadding: 30,
priority: data.nodes.reduce((acc, node) => {
acc[node.id] = node.priority;
return acc;
@@ -77,6 +78,9 @@
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: 20
},
plugins: {
tooltip: {
callbacks: {

View File

@@ -4,11 +4,12 @@
{% block content %}
<div class="container-fluid">
<div class="row my-3">
<div class="row my-3 h-100">
<div class="col-lg-2 col-md-3 mb-3 mb-md-0">
<div class="">
<div class="mb-2 w-100 d-lg-inline-flex d-grid gap-2 flex-wrap justify-content-lg-center" role="group"
_="on change
<div class="position-sticky tw-top-3">
<div class="">
<div class="mb-2 w-100 d-lg-inline-flex d-grid gap-2 flex-wrap justify-content-lg-center" role="group"
_="on change
set type to event.target.value
add .tw-hidden to <#picker-form > div:not(.tw-hidden)/>
@@ -28,65 +29,92 @@
remove .tw-hidden from #date-range-form
end
then trigger updated"
id="picker-type">
<input type="radio" class="btn-check" name="type" value="month" id="monthradio" autocomplete="off" checked>
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="monthradio">{% translate 'Month' %}</label>
id="picker-type">
<input type="radio" class="btn-check" name="type" value="month" id="monthradio" autocomplete="off"
checked>
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="monthradio">{% translate 'Month' %}</label>
<input type="radio" class="btn-check" name="type" value="year" id="yearradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="yearradio">{% translate 'Year' %}</label>
<input type="radio" class="btn-check" name="type" value="year" id="yearradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="yearradio">{% translate 'Year' %}</label>
<input type="radio" class="btn-check" name="type" value="month-range" id="monthrangeradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="monthrangeradio">{% translate 'Month Range' %}</label>
<input type="radio" class="btn-check" name="type" value="month-range" id="monthrangeradio"
autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1"
for="monthrangeradio">{% translate 'Month Range' %}</label>
<input type="radio" class="btn-check" name="type" value="year-range" id="yearrangeradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="yearrangeradio">{% translate 'Year Range' %}</label>
<input type="radio" class="btn-check" name="type" value="year-range" id="yearrangeradio"
autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1"
for="yearrangeradio">{% translate 'Year Range' %}</label>
<input type="radio" class="btn-check" name="type" value="date-range" id="daterangeradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="daterangeradio">{% translate 'Date Range' %}</label>
</div>
<form id="picker-form"
_="install init_datepicker
<input type="radio" class="btn-check" name="type" value="date-range" id="daterangeradio"
autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1"
for="daterangeradio">{% translate 'Date Range' %}</label>
</div>
<form id="picker-form"
_="install init_datepicker
on change trigger updated">
<div id="month-form" class="">
{% crispy month_form %}
</div>
<div id="year-form" class="tw-hidden">
{% crispy year_form %}
</div>
<div id="month-range-form" class="tw-hidden">
{% crispy month_range_form %}
</div>
<div id="year-range-form" class="tw-hidden">
{% crispy year_range_form %}
</div>
<div id="date-range-form" class="tw-hidden">
{% crispy date_range_form %}
</div>
</form>
</div>
<hr class="mt-0">
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist"
aria-orientation="vertical">
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_sankey_by_account' %}" hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Account Flow' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_sankey_by_currency' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Currency Flow' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'category_explorer_index' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Category Explorer' %}
</button>
<div id="month-form" class="">
{% crispy month_form %}
</div>
<div id="year-form" class="tw-hidden">
{% crispy year_form %}
</div>
<div id="month-range-form" class="tw-hidden">
{% crispy month_range_form %}
</div>
<div id="year-range-form" class="tw-hidden">
{% crispy year_range_form %}
</div>
<div id="date-range-form" class="tw-hidden">
{% crispy date_range_form %}
</div>
</form>
</div>
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist"
aria-orientation="vertical">
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_sankey_by_account' %}" hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Account Flow' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_sankey_by_currency' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Currency Flow' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'category_explorer_index' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Category Explorer' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'category_overview' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Categories Overview' %}
</button>
<hr>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_late_transactions' %}"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Late Transactions' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_latest_transactions' %}"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Latest Transactions' %}
</button>
</div>
</div>
</div>
<div class="col-md-9 col-lg-10">

View File

@@ -45,7 +45,7 @@
</div>
</div>
<div class="row">
<div class="tw-cursor-pointer text-primary text-end"
<div class="tw-cursor-pointer text-primary text-end"
_="on click
set from_value to #id_from_currency's value
set to_value to #id_to_currency's value
@@ -58,5 +58,39 @@
<i class="fa-solid fa-rotate me-2"></i><span>{% trans 'Invert' %}</span>
</div>
</div>
<hr>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for currency, data in rate_map.items %}
<div class="col">
<c-ui.info-card color="yellow" title="{{ currency }}">
{% for rate in data.rates.values %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">
{# <c-amount.display#}
{# :amount="1"#}
{# :prefix="data.prefix"#}
{# :suffix="data.suffix"#}
{# :decimal_places="data.decimal_places"></c-amount.display>#}
</div>
</div>
<div class="dotted-line flex-grow-1"></div>
{% if currency.income_projected != 0 %}
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="rate.rate"
:prefix="rate.prefix"
:suffix="rate.suffix"
:decimal_places="rate.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
{% endfor %}
</c-ui.info-card>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -174,7 +174,7 @@
</div>
<div id="search" class="my-3">
<label class="w-100">
<input type="search" class="form-control" placeholder="Buscar" hx-preserve id="quick-search"
<input type="search" class="form-control" placeholder="{% translate 'Search' %}" hx-preserve id="quick-search"
_="on input or search or htmx:afterSwap from window
if my value is empty
trigger toggle on <.transactions-divider-collapse/>

View File

@@ -2,12 +2,14 @@ import AirDatepicker from 'air-datepicker';
import en from 'air-datepicker/locale/en';
import ptBr from 'air-datepicker/locale/pt-BR';
import nl from 'air-datepicker/locale/nl';
import de from 'air-datepicker/locale/de';
import {createPopper} from '@popperjs/core';
const locales = {
'pt': ptBr,
'en': en,
'nl': nl
'nl': nl,
'de': de
};
function isMobileDevice() {
@@ -40,9 +42,10 @@ window.DatePicker = function createDynamicDatePicker(element) {
dateFormat: element.dataset.dateFormat,
timeFormat: element.dataset.timeFormat,
timepicker: element.dataset.timepicker === 'true',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: locales[element.dataset.language],
locale: locales[element.dataset.language] || locales['en'],
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
@@ -96,7 +99,6 @@ window.DatePicker = function createDynamicDatePicker(element) {
return new AirDatepicker(element, opts);
};
window.MonthYearPicker = function createDynamicDatePicker(element) {
let todayButton = {
content: element.dataset.nowButtonTxt,
@@ -114,9 +116,10 @@ window.MonthYearPicker = function createDynamicDatePicker(element) {
view: 'months',
minView: 'months',
dateFormat: 'MMMM yyyy',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: locales[element.dataset.language],
locale: locales[element.dataset.language] || locales['en'],
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
@@ -163,8 +166,8 @@ window.MonthYearPicker = function createDynamicDatePicker(element) {
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
}
return new AirDatepicker(element, opts);
};
@@ -186,9 +189,10 @@ window.YearPicker = function createDynamicDatePicker(element) {
view: 'years',
minView: 'years',
dateFormat: 'yyyy',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: locales[element.dataset.language],
locale: locales[element.dataset.language] || locales['en'],
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
@@ -235,8 +239,8 @@ window.YearPicker = function createDynamicDatePicker(element) {
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
}
return new AirDatepicker(element, opts);
};

View File

@@ -12,6 +12,7 @@ django-cotton~=1.2.1
django-pwa~=2.0.1
djangorestframework~=3.15.2
drf-spectacular~=0.27.2
django-import-export~=4.3.5
gunicorn==22.0.0
whitenoise[brotli]==6.6.0