Compare commits

...

71 Commits

Author SHA1 Message Date
Herculino Trotta
2f99021d0b feat: initial commit 2025-08-07 22:55:25 -03:00
Herculino Trotta
2f33b5989f feat(transactions): add menu itens for quickly changing transaction date 2025-08-07 15:30:42 -03:00
Herculino Trotta
31cf62e277 style(login): make login button take full width as open id login 2025-08-07 12:02:41 -03:00
Herculino Trotta
a544dc4943 feat(tasks:check_for_updates): add env variable to disable checking 2025-08-06 16:58:58 -03:00
Herculino Trotta
b1178198e9 Merge remote-tracking branch 'origin/main' 2025-08-06 16:37:48 -03:00
Herculino Trotta
02a488bfff fix(sidebar): management menu gets hidden on my mobile by floating UI 2025-08-06 16:37:12 -03:00
Herculino Trotta
b05285947b locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (670 of 670 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-06 18:17:41 +00:00
eitchtee
d7b7dd28c7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-06 16:21:44 +00:00
Herculino Trotta
9353d498ef Merge pull request #313
feat(transactions:recurring): allow to set how many future instances of a recurring transaction to create in advance
2025-08-06 13:20:58 -03:00
Herculino Trotta
4f6903e8e4 feat(transactions:recurring): Allow to set how many future instances of a recurring transaction to create in advance 2025-08-06 13:13:59 -03:00
eitchtee
7d3d6ea2fc chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-06 14:48:14 +00:00
Herculino Trotta
cce9c7a7a5 Merge pull request #312
refactor(transactions:all): reduce screen state by moving filter to top
2025-08-06 11:46:06 -03:00
Herculino Trotta
f80f74a01a refactor(transactions:all): reduce screen state by moving filter to top 2025-08-06 11:45:43 -03:00
Herculino Trotta
df47ffc49c Merge pull request #311
refactor(yearly): convert into a single page instead of two
2025-08-06 11:45:07 -03:00
eitchtee
4f35647a22 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-06 14:45:03 +00:00
Herculino Trotta
368342853f refactor(yearly): convert into a single page instead of two 2025-08-06 11:44:36 -03:00
Herculino Trotta
5a675f674d Merge pull request #310
refactor(networth): convert into a single page instead of two
2025-08-06 11:44:15 -03:00
Herculino Trotta
9ef8fdec49 refactor(networth): convert into a single page instead of two 2025-08-06 11:43:49 -03:00
Herculino Trotta
f29a8d8bc0 refactor: remove debug print from category overview 2025-08-06 11:42:53 -03:00
Herculino Trotta
8c43365ec0 Merge pull request #309
fix: bulk delete not working
2025-08-06 11:42:24 -03:00
Herculino Trotta
2cdcc4ee26 fix: bulk delete not working 2025-08-06 11:41:57 -03:00
eitchtee
84852012f9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-06 14:36:50 +00:00
Herculino Trotta
edf0e2c66f Merge pull request #308
feat: replace navbar with sidebar
2025-08-06 11:35:58 -03:00
Herculino Trotta
f90a31f2b9 feat: add sidebar 2025-08-06 11:35:17 -03:00
Herculino Trotta
dd1f6a6ef2 feat: add sidebar 2025-08-04 22:10:44 -03:00
Herculino Trotta
57f98ba171 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (669 of 669 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-03 16:17:41 +00:00
eitchtee
f2e93f7df9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-02 18:39:27 +00:00
Herculino Trotta
26cfa493b3 Merge pull request #305 from eitchtee/dev
fix(ui:transactions): transaction menu overflows screen on smaller screens
2025-08-02 15:37:30 -03:00
Herculino Trotta
c6e003ed86 fix(ui:transactions): transaction menu overflows screen on smaller screens 2025-08-02 15:36:57 -03:00
Herculino Trotta
c09ad0e49d Merge pull request #304
fix(ui:transactions): transaction menu overflows screen on smaller screens + alternative menu placement
2025-08-02 15:35:42 -03:00
Herculino Trotta
9250131396 fix(ui:transactions): transaction menu overflows screen on smaller screens + alternative menu placement 2025-08-02 15:35:20 -03:00
Herculino Trotta
5f503149ce Merge pull request #303
fix(mobile): tooltips show up on top of offcanvas
2025-08-02 15:34:18 -03:00
Herculino Trotta
d45b4f2942 fix(mobile): tooltips show up on top of offcanvas 2025-08-02 15:34:01 -03:00
Erwan Colin
4a8493c7d9 locale(French): update translation
Currently translated at 51.1% (342 of 669 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-07-28 22:17:41 +00:00
Dimitri Decrock
c39c3ccacb locale(Dutch): update translation
Currently translated at 100.0% (669 of 669 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-07-28 17:17:41 +00:00
Herculino Trotta
4bb8ef6582 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (669 of 669 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-07-28 04:17:41 +00:00
eitchtee
d711ccca69 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-28 02:21:41 +00:00
Herculino Trotta
76d59f1038 Merge pull request #302
refactor: properly name shareable objects generic fields
2025-07-27 23:20:01 -03:00
Herculino Trotta
5b6c123fa1 refactor: properly name shareable objects generic fields 2025-07-27 23:19:39 -03:00
Herculino Trotta
782ab11ae4 Merge pull request #301
feat: add quick transactions to django admin and properly name it
2025-07-27 23:19:01 -03:00
Herculino Trotta
8db885f47d feat: add quick transactions to django admin and properly name it
+ fix some wrongly assigned admin instances
2025-07-27 23:18:42 -03:00
Herculino Trotta
01bd8710d8 Merge pull request #300
feat(sharing): add action for turning shareable objects into public or private on Django Admin
2025-07-27 23:17:31 -03:00
Herculino Trotta
569d08711c feat(sharing): add action for turning shareable objects into public or private on Django Admin 2025-07-27 23:17:14 -03:00
eitchtee
a285f055e4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-28 01:58:29 +00:00
Herculino Trotta
6aae9b1207 Merge pull request #299
feat(transactions): display more information about recurring, quick and installments
2025-07-27 22:56:26 -03:00
Herculino Trotta
9d2206f8a4 feat(transactions): display more information about recurring, quick and installments 2025-07-27 22:55:59 -03:00
Herculino Trotta
d7e3c50c2c Merge pull request #298
chore: bump python requirements
2025-07-27 22:55:27 -03:00
Herculino Trotta
789fd4eb80 chore: bump python requirements 2025-07-27 22:54:50 -03:00
Herculino Trotta
586b3a5d44 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (667 of 667 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-07-27 06:17:41 +00:00
eitchtee
9248e8bd77 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-27 04:41:52 +00:00
Herculino Trotta
c44247f6a5 Merge pull request #297
feat: automatically dismiss htmx error popups after 1 minute
2025-07-27 01:39:33 -03:00
Herculino Trotta
8ba89434f8 feat: automatically dismiss htmx error popups after 1 minute
this is mostly to avoid being greeted by a popup when you leave the app open and it is unable to auto-update every few minutes
2025-07-27 01:39:02 -03:00
eitchtee
f2f41981a3 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-27 04:29:40 +00:00
Herculino Trotta
1153fd6b0a Merge pull request #296
feat: check and notify users of new versions
2025-07-27 01:28:54 -03:00
Herculino Trotta
76822224a0 feat: check and notify users of new versions
checks are done against github's API with one request every 12 hours
2025-07-27 01:28:27 -03:00
Herculino Trotta
31b2b98eb9 Merge pull request #295 from eitchtee/dev
chore: bump python requirements
2025-07-27 01:26:51 -03:00
Herculino Trotta
d7a4e79321 chore: bump python requirements 2025-07-27 01:25:20 -03:00
Herculino Trotta
985f07e792 Merge pull request #294
fix(insights): filters not changing on click
2025-07-26 23:32:18 -03:00
Herculino Trotta
5465bb1eeb fix(insights): filters not changing on click 2025-07-26 23:31:54 -03:00
eitchtee
451a85a998 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-27 02:07:51 +00:00
Herculino Trotta
54c74e7c07 Merge pull request #293
fix(insights): filters not changing on click
2025-07-26 23:07:12 -03:00
Herculino Trotta
d6e9e123b7 fix(insights): filters not changing on click 2025-07-26 23:06:45 -03:00
seraphblade2010
80c9c43a02 locale(German): update translation
Currently translated at 98.0% (653 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-07-22 06:17:41 +00:00
afermar
3e34f088fc locale(Spanish): update translation
Currently translated at 19.6% (131 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-07-21 18:17:41 +00:00
afermar
5b9e5c6003 locale(Spanish): update translation
Currently translated at 19.5% (130 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-07-21 16:41:11 +00:00
eitchtee
c266b8809f chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-21 13:16:57 +00:00
Herculino Trotta
8cda4116bc Merge pull request #290
fix: FAB gets bellow hovered transaction
2025-07-21 10:16:14 -03:00
Herculino Trotta
c2510b2261 feat: allow to select if transfer transactions are muted or not on creation 2025-07-21 10:15:02 -03:00
Herculino Trotta
dcdaf756f9 fix: FAB gets bellow hovered transaction 2025-07-21 09:51:11 -03:00
Herculino Trotta
50ca08165a Merge pull request #289
fix: hover detection doesn't work with Firefox mobile
2025-07-21 09:39:13 -03:00
Herculino Trotta
f85618fa01 fix: hover detection doesn't work with Firefox mobile 2025-07-21 09:38:48 -03:00
64 changed files with 3390 additions and 2086 deletions

View File

@@ -140,9 +140,10 @@ To create the first user, open the container's console using Unraid's UI, by cli
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
| DEMO | true\|false | false | If demo mode is enabled. |
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
| DEMO | true\|false | false | If demo mode is enabled. |
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
| CHECK_FOR_UPDATES | bool | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
## OIDC Configuration

View File

@@ -487,6 +487,8 @@ else:
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
# Procrastinate
PROCRASTINATE_ON_APP_READY = "apps.common.procrastinate.on_app_ready"
# PWA
PWA_APP_NAME = SITE_TITLE
@@ -535,6 +537,7 @@ PWA_APP_SCREENSHOTS = [
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
CHECK_FOR_UPDATES = os.getenv("CHECK_FOR_UPDATES", "true").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
APP_VERSION = os.getenv("APP_VERSION", "unknown")
DEMO = os.getenv("DEMO", "false").lower() == "true"

View File

@@ -0,0 +1,46 @@
# Generated by Django 5.2.4 on 2025-07-28 02:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='account',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='account',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
migrations.AlterField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='accountgroup',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='accountgroup',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
]

View File

@@ -1,7 +1,26 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
@admin.action(description=_("Make public"))
def make_public(modeladmin, request, queryset):
queryset.update(visibility="public")
@admin.action(description=_("Make private"))
def make_private(modeladmin, request, queryset):
queryset.update(visibility="private")
class SharedObjectModelAdmin(admin.ModelAdmin):
actions = [make_public, make_private]
list_display = ("__str__", "visibility", "owner", "get_shared_with")
@admin.display(description=_("Shared with users"))
def get_shared_with(self, obj):
return ", ".join([p.email for p in obj.shared_with.all()])
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()

View File

@@ -1,4 +1,5 @@
from django.apps import AppConfig
from django.core.cache import cache
class CommonConfig(AppConfig):
@@ -18,3 +19,7 @@ class CommonConfig(AppConfig):
admin.site.unregister(SocialAccount)
admin.site.unregister(SocialApp)
admin.site.unregister(SocialToken)
# Delete the cache for update checks to prevent false-positives when the app is restarted
# this will be recreated by the check_for_updates task
cache.delete("update_check")

View File

@@ -36,12 +36,19 @@ class SharedObject(models.Model):
related_name="%(class)s_owned",
null=True,
blank=True,
verbose_name=_("Owner"),
)
visibility = models.CharField(
max_length=10, choices=Visibility.choices, default=Visibility.private
max_length=10,
choices=Visibility.choices,
default=Visibility.private,
verbose_name=_("Visibility"),
)
shared_with = models.ManyToManyField(
settings.AUTH_USER_MODEL, related_name="%(class)s_shared", blank=True
settings.AUTH_USER_MODEL,
related_name="%(class)s_shared",
blank=True,
verbose_name=_("Shared with users"),
)
# Use as abstract base class

View File

@@ -0,0 +1,6 @@
import procrastinate
def on_app_ready(app: procrastinate.App):
"""This function is ran upon procrastinate initialization."""
...

View File

@@ -1,13 +1,17 @@
import logging
from packaging.version import parse as parse_version, InvalidVersion
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core import management
from django.db import DEFAULT_DB_ALIAS
from django.core.cache import cache
from procrastinate import builtin_tasks
from procrastinate.contrib.django import app
import requests
logger = logging.getLogger(__name__)
@@ -79,3 +83,49 @@ def reset_demo_data(timestamp=None):
except Exception as e:
logger.exception(f"Error during daily demo data reset: {e}")
raise
@app.periodic(cron="0 */12 * * *") # Every 12 hours
@app.task(
name="check_for_updates",
)
def check_for_updates(timestamp=None):
if not settings.CHECK_FOR_UPDATES:
return "CHECK_FOR_UPDATES is disabled"
url = "https://api.github.com/repos/eitchtee/WYGIWYH/releases/latest"
try:
response = requests.get(url, timeout=60)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
data = response.json()
latest_version = data.get("tag_name")
if latest_version:
try:
current_v = parse_version(settings.APP_VERSION)
except InvalidVersion:
current_v = parse_version("0.0.0")
try:
latest_v = parse_version(latest_version)
except InvalidVersion:
latest_v = parse_version("0.0.0")
update_info = {
"update_available": False,
"current_version": str(current_v),
"latest_version": str(latest_v),
}
if latest_v > current_v:
update_info["update_available"] = True
# Cache the entire dictionary
cache.set("update_check", update_info, 60 * 60 * 25)
logger.info(f"Update check complete. Result: {update_info}")
else:
logger.warning("Could not find 'tag_name' in GitHub API response.")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to fetch updates from GitHub: {e}")

View File

@@ -0,0 +1,17 @@
# core/templatetags/update_tags.py
from django import template
from django.core.cache import cache
register = template.Library()
@register.simple_tag
def get_update_check():
"""
Retrieves the update status dictionary from the cache.
Returns a default dictionary if nothing is found.
"""
return cache.get("update_check") or {
"update_available": False,
"latest_version": "N/A",
}

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.4 on 2025-07-28 02:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dca', '0003_dcastrategy_owner_dcastrategy_shared_with_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='dcastrategy',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='dcastrategy',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='dcastrategy',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
]

View File

@@ -5,4 +5,5 @@ from . import views
urlpatterns = [
path("net-worth/current/", views.net_worth_current, name="net_worth_current"),
path("net-worth/projected/", views.net_worth_projected, name="net_worth_projected"),
path("net-worth/", views.net_worth, name="net_worth"),
]

View File

@@ -2,7 +2,7 @@ import json
from django.contrib.auth.decorators import login_required
from django.core.serializers.json import DjangoJSONEncoder
from django.shortcuts import render
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods
from apps.net_worth.utils.calculate_net_worth import (
@@ -18,18 +18,38 @@ from apps.transactions.utils.calculations import (
@login_required
@require_http_methods(["GET"])
def net_worth_current(request):
transactions_currency_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False
).order_by(
"account__currency__name",
)
transactions_account_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False
).order_by(
"account__group__name",
"account__name",
)
def net_worth(request):
if "view_type" in request.GET:
print(request.GET["view_type"])
view_type = request.GET["view_type"]
request.session["networth_view_type"] = view_type
else:
view_type = request.session.get("networth_view_type", "current")
if view_type == "current":
transactions_currency_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False
).order_by(
"account__currency__name",
)
transactions_account_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False
).order_by(
"account__group__name",
"account__name",
)
else:
transactions_currency_queryset = Transaction.objects.filter(
account__is_archived=False
).order_by(
"account__currency__name",
)
transactions_account_queryset = Transaction.objects.filter(
account__is_archived=False
).order_by(
"account__group__name",
"account__name",
)
currency_net_worth = calculate_currency_totals(
transactions_queryset=transactions_currency_queryset, deep_search=True
@@ -116,111 +136,22 @@ def net_worth_current(request):
"currencies": currencies,
"chart_data_accounts_json": chart_data_accounts_json,
"accounts": accounts,
"type": "current",
"type": view_type,
},
)
@login_required
@require_http_methods(["GET"])
def net_worth_current(request):
request.session["networth_view_type"] = "current"
return redirect("net_worth")
@login_required
@require_http_methods(["GET"])
def net_worth_projected(request):
transactions_currency_queryset = Transaction.objects.filter(
account__is_archived=False
).order_by(
"account__currency__name",
)
transactions_account_queryset = Transaction.objects.filter(
account__is_archived=False
).order_by(
"account__group__name",
"account__name",
)
request.session["networth_view_type"] = "projected"
currency_net_worth = calculate_currency_totals(
transactions_queryset=transactions_currency_queryset, deep_search=True
)
account_net_worth = calculate_account_totals(
transactions_queryset=transactions_account_queryset
)
historical_currency_net_worth = calculate_historical_currency_net_worth(
queryset=transactions_currency_queryset
)
labels = (
list(historical_currency_net_worth.keys())
if historical_currency_net_worth
else []
)
currencies = (
list(historical_currency_net_worth[labels[0]].keys())
if historical_currency_net_worth
else []
)
datasets = []
for i, currency in enumerate(currencies):
data = [
float(month_data[currency])
for month_data in historical_currency_net_worth.values()
]
datasets.append(
{
"label": currency,
"data": data,
"yAxisID": f"y{i}",
"fill": False,
"tension": 0.1,
}
)
chart_data_currency = {"labels": labels, "datasets": datasets}
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
historical_account_balance = calculate_historical_account_balance(
queryset=transactions_account_queryset
)
labels = (
list(historical_account_balance.keys()) if historical_account_balance else []
)
accounts = (
list(historical_account_balance[labels[0]].keys())
if historical_account_balance
else []
)
datasets = []
for i, account in enumerate(accounts):
data = [
float(month_data[account])
for month_data in historical_account_balance.values()
]
datasets.append(
{
"label": account,
"data": data,
"fill": False,
"tension": 0.1,
"yAxisID": f"y-axis-{i}", # Assign each dataset to its own Y-axis
}
)
chart_data_accounts = {"labels": labels, "datasets": datasets}
chart_data_accounts_json = json.dumps(chart_data_accounts, cls=DjangoJSONEncoder)
return render(
request,
"net_worth/net_worth.html",
{
"currency_net_worth": currency_net_worth,
"account_net_worth": account_net_worth,
"chart_data_currency_json": chart_data_currency_json,
"currencies": currencies,
"chart_data_accounts_json": chart_data_accounts_json,
"accounts": accounts,
"type": "projected",
},
)
return redirect("net_worth")

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.4 on 2025-07-28 02:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0013_transactionrule_on_delete'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='transactionrule',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='transactionrule',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='transactionrule',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
]

View File

@@ -7,6 +7,7 @@ from apps.transactions.models import (
InstallmentPlan,
RecurringTransaction,
TransactionEntity,
QuickTransaction,
)
from apps.common.admin import SharedObjectModelAdmin
@@ -49,19 +50,22 @@ class TransactionInline(admin.TabularInline):
@admin.register(InstallmentPlan)
class InstallmentPlanAdmin(SharedObjectModelAdmin):
class InstallmentPlanAdmin(admin.ModelAdmin):
inlines = [
TransactionInline,
]
@admin.register(RecurringTransaction)
class RecurringTransactionAdmin(SharedObjectModelAdmin):
class RecurringTransactionAdmin(admin.ModelAdmin):
inlines = [
TransactionInline,
]
admin.site.register(QuickTransaction)
@admin.register(TransactionCategory)
class TransactionCategoryModelAdmin(SharedObjectModelAdmin):
pass

View File

@@ -1,5 +1,5 @@
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout,
@@ -499,6 +499,13 @@ class TransferForm(forms.Form):
label=_("Notes"),
)
mute = forms.BooleanField(
label=_("Mute"),
initial=True,
required=False,
help_text=_("Muted transactions won't be displayed on monthly summaries"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -517,6 +524,7 @@ class TransferForm(forms.Form):
),
Field("description"),
Field("notes"),
Switch("mute"),
Row(
Column(
Row(
@@ -599,6 +607,8 @@ class TransferForm(forms.Form):
return cleaned_data
def save(self):
mute = self.cleaned_data["mute"]
from_account = self.cleaned_data["from_account"]
to_account = self.cleaned_data["to_account"]
from_amount = self.cleaned_data["from_amount"]
@@ -621,7 +631,7 @@ class TransferForm(forms.Form):
description=description,
category=from_category,
notes=notes,
mute=True,
mute=mute,
)
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
@@ -636,7 +646,7 @@ class TransferForm(forms.Form):
description=description,
category=to_category,
notes=notes,
mute=True,
mute=mute,
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
@@ -953,6 +963,7 @@ class RecurringTransactionForm(forms.ModelForm):
"notes",
"add_notes_to_transaction",
"entities",
"keep_at_most",
]
widgets = {
"reference_date": AirMonthYearPickerInput(),
@@ -1032,6 +1043,7 @@ class RecurringTransactionForm(forms.ModelForm):
Column("end_date", css_class="form-group col-md-4 mb-0"),
css_class="form-row",
),
AppendedText("keep_at_most", _("future transactions")),
)
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()

View File

@@ -0,0 +1,61 @@
# Generated by Django 5.2.4 on 2025-07-28 02:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0046_quicktransaction_mute'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='transactioncategory',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='transactioncategory',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='transactioncategory',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
migrations.AlterField(
model_name='transactionentity',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='transactionentity',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='transactionentity',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
migrations.AlterField(
model_name='transactiontag',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='transactiontag',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='transactiontag',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-08-06 14:51
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0047_alter_transactioncategory_owner_and_more'),
]
operations = [
migrations.AddField(
model_name='recurringtransaction',
name='keep_at_most',
field=models.PositiveIntegerField(default=6, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Keep at most'),
),
]

View File

@@ -722,6 +722,9 @@ class RecurringTransaction(models.Model):
recurrence_interval = models.PositiveIntegerField(
verbose_name=_("Recurrence Interval"),
)
keep_at_most = models.PositiveIntegerField(
verbose_name=_("Keep at most"), default=6, validators=[MinValueValidator(1)]
)
last_generated_date = models.DateField(
verbose_name=_("Last Generated Date"), null=True, blank=True
@@ -759,8 +762,10 @@ class RecurringTransaction(models.Model):
current_date = self.start_date
reference_date = self.reference_date
end_date = min(
self.end_date or timezone.now().date() + (self.get_recurrence_delta() * 5),
timezone.now().date() + (self.get_recurrence_delta() * 5),
self.end_date
or timezone.now().date()
+ (self.get_recurrence_delta() * self.keep_at_most),
timezone.now().date() + (self.get_recurrence_delta() * self.keep_at_most),
)
while current_date <= end_date:
@@ -837,8 +842,16 @@ class RecurringTransaction(models.Model):
current_date = start_date
end_date = min(
recurring_transaction.end_date
or today + (recurring_transaction.get_recurrence_delta() * 6),
today + (recurring_transaction.get_recurrence_delta() * 6),
or today
+ (
recurring_transaction.get_recurrence_delta()
* recurring_transaction.keep_at_most
),
today
+ (
recurring_transaction.get_recurrence_delta()
* recurring_transaction.keep_at_most
),
)
logger.info(f"End date: {end_date}")
@@ -976,3 +989,6 @@ class QuickTransaction(OwnedObject):
self.full_clean()
super().save(*args, **kwargs)
def __str__(self):
return self.name

View File

@@ -28,23 +28,18 @@ def generate_recurring_transactions(timestamp=None):
@app.periodic(cron="10 1 * * *")
@app.task(name="cleanup_deleted_transactions")
def cleanup_deleted_transactions(timestamp=None):
with cachalot_disabled():
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
if not settings.ENABLE_SOFT_DELETE:
# Hard delete all soft-deleted transactions
deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete()
return (
f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
)
if not settings.ENABLE_SOFT_DELETE:
# Hard delete all soft-deleted transactions
deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete()
return f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
# Calculate the cutoff date
cutoff_date = timezone.now() - timedelta(
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
)
invalidate()
# Calculate the cutoff date
cutoff_date = timezone.now() - timedelta(
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
)
# Hard delete soft-deleted transactions older than the cutoff date
old_transactions = Transaction.userless_deleted_objects.filter(

View File

@@ -71,6 +71,16 @@ urlpatterns = [
views.transaction_mute,
name="transaction_mute",
),
path(
"transaction/<int:transaction_id>/change-month/<str:change_type>/",
views.transaction_change_month,
name="transaction_change_month",
),
path(
"transaction/<int:transaction_id>/move-to-today/",
views.transaction_move_to_today,
name="transaction_move_to_today",
),
path(
"transaction/<int:transaction_id>/delete/",
views.transaction_delete,

View File

@@ -62,7 +62,7 @@ def bulk_unpay_transactions(request):
@login_required
def bulk_delete_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
transactions = Transaction.all_objects.filter(id__in=selected_transactions)
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.delete()

View File

@@ -1,6 +1,7 @@
import datetime
from copy import deepcopy
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@@ -408,6 +409,47 @@ def transaction_mute(request, transaction_id):
return response
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_change_month(request, transaction_id, change_type):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
if change_type == "next":
transaction.reference_date = transaction.reference_date + relativedelta(
months=1
)
transaction.save()
transaction_updated.send(sender=transaction)
elif change_type == "previous":
transaction.reference_date = transaction.reference_date - relativedelta(
months=1
)
transaction.save()
transaction_updated.send(sender=transaction)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_move_to_today(request, transaction_id):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
transaction.date = timezone.localdate(timezone.now())
transaction.save()
transaction_updated.send(sender=transaction)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@login_required
@require_http_methods(["GET"])
def transaction_all_index(request):

View File

@@ -45,7 +45,7 @@ class LoginForm(AuthenticationForm):
self.helper.layout = Layout(
"username",
"password",
Submit("Submit", "Login"),
Submit("Submit", "Login", css_class="btn btn-primary w-100"),
)

View File

@@ -4,6 +4,7 @@ from . import views
urlpatterns = [
path("", views.index, name="index"),
path("setup/", views.setup, name="setup"),
path("login/", views.UserLoginView.as_view(), name="login"),
# path("login/fallback/", views.UserLoginView.as_view(), name="fallback_login"),
path("logout/", views.logout_view, name="logout"),

View File

@@ -21,6 +21,8 @@ from apps.users.forms import (
)
from apps.users.models import UserSettings
from apps.common.decorators.demo import disabled_on_demo
from apps.currencies.models import Currency
from apps.accounts.models import Account
def logout_view(request):
@@ -48,6 +50,28 @@ def index(request):
return redirect(reverse("monthly_index"))
@login_required
def setup(request):
has_currency = Currency.objects.exists()
has_account = Account.objects.exists()
# return render(
# request,
# "users/setup/setup.html",
# {"has_currency": has_currency, "has_account": has_account},
# )
if not has_currency or not has_account:
return render(
request,
"users/setup/setup.html",
{"has_currency": has_currency, "has_account": has_account},
)
else:
return HttpResponse(
status=200,
headers={"HX-Reswap": "delete"},
)
class UserLoginView(LoginView):
form_class = LoginForm
template_name = "users/login.html"

View File

@@ -3,6 +3,7 @@ from django.urls import path
from . import views
urlpatterns = [
path("yearly/", views.index, name="yearly_index"),
path("yearly/currency/", views.index_by_currency, name="yearly_index_currency"),
path("yearly/account/", views.index_by_account, name="yearly_index_account"),
path(

View File

@@ -16,6 +16,22 @@ from apps.transactions.utils.calculations import (
)
@login_required
def index(request):
if "view_type" in request.GET:
view_type = request.GET["view_type"]
request.session["yearly_view_type"] = view_type
else:
view_type = request.session.get("yearly_view_type", "currency")
now = timezone.localdate(timezone.now())
if view_type == "currency":
return redirect(to="yearly_overview_currency", year=now.year)
else:
return redirect(to="yearly_overview_account", year=now.year)
@login_required
def index_by_currency(request):
now = timezone.localdate(timezone.now())
@@ -32,6 +48,8 @@ def index_by_account(request):
@login_required
def index_yearly_overview_by_currency(request, year: int):
request.session["yearly_view_type"] = "currency"
next_year = year + 1
previous_year = year - 1
@@ -49,6 +67,7 @@ def index_yearly_overview_by_currency(request, year: int):
"previous_year": previous_year,
"months": month_options,
"currencies": currency_options,
"type": "currency",
},
)
@@ -95,6 +114,7 @@ def yearly_overview_by_currency(request, year: int):
@login_required
def index_yearly_overview_by_account(request, year: int):
request.session["yearly_view_type"] = "account"
next_year = year + 1
previous_year = year - 1
@@ -115,6 +135,7 @@ def index_yearly_overview_by_account(request, year: int):
"previous_year": previous_year,
"months": month_options,
"accounts": account_options,
"type": "account",
},
)

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

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

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

@@ -1,7 +1,7 @@
<div class="tw:min-h-16">
<div
id="fab-wrapper"
class="tw:fixed tw:bottom-5 tw:right-5 tw:ml-auto tw:w-max tw:flex tw:flex-col tw:items-end mt-5">
class="tw:fixed tw:bottom-5 tw:right-5 tw:ml-auto tw:w-max tw:flex tw:flex-col tw:items-end mt-5 tw:z-20">
<div
id="menu"
class="tw:flex tw:flex-col tw:items-end tw:space-y-6 tw:transition-all tw:duration-300 tw:ease-in-out tw:opacity-0 tw:invisible tw:hidden tw:mb-2">

View File

@@ -0,0 +1,12 @@
<li class="tw:lg:hidden tw:lg:group-hover:block">
<div class="d-flex align-items-center" data-bs-toggle="collapse" href="#{{ title|slugify }}" role="button"
aria-expanded="false" aria-controls="{{ title|slugify }}">
<span
class="text-muted small fw-bold text-uppercase tw:lg:hidden tw:lg:group-hover:inline me-2">{{ title }}</span>
<hr class="flex-grow-1"/>
<i class="fas fa-chevron-down text-muted tw:lg:before:hidden tw:lg:group-hover:before:inline tw:ml-2 tw:lg:ml-0 tw:lg:group-hover:ml-2"></i>
</div>
</li>
<div class="collapse tw:lg:hidden tw:lg:group-hover:block" id="{{ title|slugify }}">
{{ slot }}
</div>

View File

@@ -0,0 +1,6 @@
<li>
<div class="d-flex align-items-center">
<span class="text-muted small fw-bold text-uppercase tw:lg:hidden tw:lg:group-hover:inline me-2">{{ title }}</span>
<hr class="flex-grow-1"/>
</div>
</li>

View File

@@ -0,0 +1,14 @@
{% load active_link %}
<li>
<a href="{% url url %}"
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
{% if tooltip %}
data-bs-placement="right"
data-bs-toggle="tooltip"
data-bs-title="{{ tooltip }}"
{% endif %}>
<i class="{{ icon }} fa-fw"></i>
<span
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
</a>
</li>

View File

@@ -0,0 +1,16 @@
{% load active_link %}
<li>
<a href="{{ url }}"
hx-boost="false"
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
{% if tooltip %}
data-bs-placement="right"
data-bs-toggle="tooltip"
data-bs-title="{{ tooltip }}"
{% endif %}>
<i class="{{ icon }} fa-fw"></i>
<span
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
</a>
</li>

View File

@@ -120,8 +120,8 @@
<div>
{# Item actions#}
<div
class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
<div class="card-body p-1 shadow-lg">
class="transaction-actions tw:absolute! tw:right-[15px] tw:top-[50%] tw:md:right-auto tw:md:left-1/2 tw:md:top-0 tw:md:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
<div class="card-body p-1 shadow-lg d-flex flex-column flex-md-row gap-1">
{% if not transaction.deleted %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
@@ -145,7 +145,7 @@
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-ellipsis fa-fw"></i>
</button>
<ul class="dropdown-menu">
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
{% if transaction.category.mute %}
<li>
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
@@ -163,6 +163,10 @@
{% endif %}
<li><a class="dropdown-item" href="#" hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a></li>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
</ul>
{% else %}

View File

@@ -17,14 +17,14 @@
end
end
end">
<div class="card slide-in-bottom">
<div class="card-body p-2 d-flex justify-content-between align-items-center gap-3">
<div class="card slide-in-bottom tw:max-w-[90vw]">
<div class="card-body p-2 d-flex justify-content-between align-items-center gap-3 tw:overflow-x-auto">
{% spaceless %}
<div class="tw:font-bold tw:text-md ms-2" id="selected-count">0</div>
<div class="vr tw:align-middle"></div>
<div class="dropdown">
<button class="btn btn-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
aria-expanded="false" data-bs-popper-config='{"strategy":"fixed"}'>
<i class="fa-regular fa-square-check fa-fw"></i>
</button>
<ul class="dropdown-menu">
@@ -53,7 +53,8 @@
<i class="fa-solid fa-pencil"></i>
</button>
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
data-bs-toggle="dropdown" data-bs-popper-config='{"strategy":"fixed"}' aria-expanded="false"
data-bs-auto-close="outside">
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
</button>
@@ -144,7 +145,8 @@
<span class="d-none d-md-inline-block" id="real-total-front">0</span>
</button>
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside"
data-bs-popper-config='{"strategy":"fixed"}'>
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
</button>

View File

@@ -0,0 +1,16 @@
{% load cache_access %}
{% load settings %}
{% load static %}
{% load i18n %}
{% load active_link %}
<nav class="navbar border-bottom bg-body-tertiary d-flex d-lg-none" hx-boost="true">
<div class="container-fluid">
<a class="navbar-brand fw-bold text-primary font-base" href="{% url 'index' %}">
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="40" width="40" title="WYGIWYH"/>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebar"
aria-controls="sidebar" aria-label={% translate "Toggle navigation" %}>
<span class="navbar-toggler-icon"></span>
</button>
</div>
</nav>

View File

@@ -1,3 +1,4 @@
{% load cache_access %}
{% load settings %}
{% load static %}
{% load i18n %}
@@ -162,6 +163,12 @@
</li>
</ul>
<ul class="navbar-nav mb-2 mb-lg-0 gap-3">
{% get_update_check as update_check %}
{% if update_check.update_available %}
<li class="nav-item my-auto">
<a class="badge text-bg-secondary text-decoration-none tw:cursor-pointer" href="https://github.com/eitchtee/WYGIWYH/releases/latest" target="_blank"><i class="fa-solid fa-circle-info fa-fw me-2"></i>v.{{ update_check.latest_version }} {% translate 'is available' %}!</a>
</li>
{% endif %}
<li class="nav-item">
<div class="nav-link tw:lg:text-2xl! tw:cursor-pointer"
data-bs-toggle="tooltip" data-bs-placement="left" data-bs-title="{% trans "Calculator" %}"

View File

@@ -1,13 +1,10 @@
{% load settings %}
{% load i18n %}
<div class="dropdown">
<div class="nav-link tw:lg:text-2xl! tw:cursor-pointer" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-user"></i>
<span class="d-lg-none d-inline">{% trans "Profile" %}</span>
<div class="btn btn-secondary btn-sm" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-cog"></i>
</div>
<ul class="dropdown-menu dropdown-menu-start dropdown-menu-lg-end">
<li class="dropdown-item-text">{{ user.email }}</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item"
hx-get="{% url 'user_settings' %}"
hx-target="#generic-offcanvas"

View File

@@ -1,4 +1,4 @@
<div id="persistent-generic-offcanvas" class="offcanvas offcanvas-end offcanvas-size-xl"
<div id="persistent-generic-offcanvas" class="offcanvas offcanvas-end offcanvas-size-xl tw:z-1100!"
data-bs-backdrop="static"
tabindex="-1"
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end
@@ -6,7 +6,7 @@
on force_hide_offcanvas call bootstrap.Offcanvas.getOrCreateInstance(me).hide() end
on hidden.bs.offcanvas set my innerHTML to '' end">
</div>
<div id="persistent-generic-offcanvas-left" class="offcanvas offcanvas-start offcanvas-size-xl"
<div id="persistent-generic-offcanvas-left" class="offcanvas offcanvas-start offcanvas-size-xl tw:z-1100!"
data-bs-backdrop="static"
tabindex="-1"
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end
@@ -16,7 +16,7 @@
</div>
<div id="generic-offcanvas" class="offcanvas offcanvas-end offcanvas-size-xl"
<div id="generic-offcanvas" class="offcanvas offcanvas-end offcanvas-size-xl tw:z-1100!"
data-bs-backdrop="static"
tabindex="-1"
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end
@@ -24,7 +24,7 @@
on htmx:beforeOnLoad[detail.boosted] call bootstrap.Offcanvas.getOrCreateInstance(me).hide()
on hidden.bs.offcanvas set my innerHTML to '' end">
</div>
<div id="generic-offcanvas-left" class="offcanvas offcanvas-start offcanvas-size-xl"
<div id="generic-offcanvas-left" class="offcanvas offcanvas-start offcanvas-size-xl tw:z-1100!"
data-bs-backdrop="static"
tabindex="-1"
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end

View File

@@ -8,6 +8,7 @@ behavior htmx_error_handler
title: '{% trans "Access Denied" %}',
text: '{% trans "You do not have permission to perform this action or access this resource." %}',
icon: 'warning',
timer: 60000,
customClass: {
confirmButton: 'btn btn-warning' -- Optional: different button style
},
@@ -18,6 +19,7 @@ behavior htmx_error_handler
title: '{% trans "Something went wrong loading your data" %}',
text: '{% trans "Try reloading the page or check the console for more information." %}',
icon: 'error',
timer: 60000,
customClass: {
confirmButton: 'btn btn-primary'
},

View File

@@ -0,0 +1,289 @@
{% load active_link %}
{% load cache_access %}
{% load settings %}
{% load i18n %}
{% load static %}
<div
class="tw:group tw:lg:w-16 tw:lg:hover:w-112 tw:transition-all tw:duration-100 tw:fixed tw:top-0 tw:start-0 tw:h-full tw:z-1020">
<nav
id="sidebar"
hx-boost="true"
data-bs-scroll="true"
class="offcanvas-lg offcanvas-start d-lg-flex flex-column position-fixed top-0 start-0 h-100 bg-body-tertiary shadow-sm tw:z-1020 tw:lg:w-16 tw:lg:group-hover:w-104 tw:transition-all tw:duration-100 tw:overflow-hidden">
<div
class="tw:hidden tw:lg:group-hover:block tw:absolute tw:top-0 tw:left-104 tw:w-8 tw:h-full tw:bg-transparent tw:pointer-events-auto tw:z-10"></div>
<a href="{% url 'index' %}" class="d-none d-lg-flex tw:justify-start p-3 text-decoration-none">
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span>
</a>
<div class="offcanvas-header">
<a href="{% url 'index' %}" class="offcanvas-title d-flex tw:justify-start text-decoration-none">
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span>
</a>
<button type="button" class="btn-close" data-bs-target="#sidebar" data-bs-dismiss="offcanvas"
aria-label={% translate 'Close' %}></button>
</div>
<hr class="m-0">
<ul class="list-unstyled p-3 d-flex flex-column gap-0 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]"
style="animation-duration: 100ms">
<c-components.sidebar-menu-item
title="{% translate 'Monthly' %}"
url='monthly_index'
active="monthly_overview"
icon="fa-solid fa-list">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Yearly' %}"
url='yearly_index'
active="yearly_overview_currency||yearly_overview_account"
icon="fa-solid fa-calendar-plus">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Calendar' %}"
url='calendar_index'
active="calendar"
icon="fa-solid fa-calendar-days">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Insights' %}"
url='insights_index'
active="insights_index"
icon="fa-solid fa-chart-simple">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Net Worth' %}"
url='net_worth'
active="net_worth"
icon="fa-solid fa-money-bill">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'All' %}"
url='transactions_all_index'
active="transactions_all_index"
icon="fa-solid fa-right-left">
</c-components.sidebar-menu-item>
{% settings "ENABLE_SOFT_DELETE" as enable_soft_delete %}
{% if enable_soft_delete %}
<c-components.sidebar-menu-item
title="{% translate 'Trash Can' %}"
url='transactions_trash_index'
active="transactions_trash_index"
icon="fa-solid fa-trash">
</c-components.sidebar-menu-item>
{% endif %}
<c-components.sidebar-menu-item
title="{% translate 'Quick Transactions' %}"
url='quick_transactions_index'
active="quick_transactions_index"
icon="fa-solid fa-person-running">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Installment Plans' %}"
url='installment_plans_index'
active="installment_plans_index"
icon="fa-solid fa-divide">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Recurring Transactions' %}"
url='recurring_trasanctions_index'
active="recurring_trasanctions_index"
icon="fa-solid fa-repeat">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Tools' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Dollar Cost Average Tracker' %}"
url='dca_strategy_index'
active="dca_strategy_index||dca_strategy_detail_index"
icon="fa-solid fa-magnifying-glass-chart">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Unit Price Calculator' %}"
url='unit_price_calculator'
active="unit_price_calculator"
icon="fa-solid fa-tags">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Currency Converter' %}"
url='currency_converter'
active="currency_converter"
icon="fa-solid fa-money-bill-transfer">
</c-components.sidebar-menu-item>
<div>
<hr>
</div>
<div type="button"
data-bs-toggle="collapse"
data-bs-target="#collapsible-panel"
aria-expanded="false"
aria-controls="collapsible-panel"
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% 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||users_index' css_class="sidebar-active" %}">
<i class="fa-solid fa-toolbox fa-fw"></i>
<span
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">
{% translate 'Management' %}
</span>
</div>
</ul>
<div class="mt-auto p-2 w-100">
<div id="collapsible-panel"
class="p-0 collapse tw:absolute tw:bottom-0 tw:left-0 tw:w-full tw:z-30 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:max-h-dvh">
<div class="tw:h-dvh tw:backdrop-blur-3xl">
<!-- Header -->
<div
class="tw:flex tw:justify-between tw:items-center tw:p-4 tw:border-b tw:border-gray-600 tw:lg:hidden tw:lg:group-hover:flex">
<h5 class="tw:text-lg tw:font-semibold tw:text-gray-800 tw:lg:invisible tw:lg:group-hover:visible">
{% trans 'Management' %}
</h5>
<button type="button" class="btn-close tw:lg:hidden tw:lg:group-hover:inline" aria-label="Close"
data-bs-toggle="collapse"
data-bs-target="#collapsible-panel"
aria-expanded="true"
aria-controls="collapsible-panel">
</button>
</div>
<ul class="list-unstyled p-3 d-flex flex-column gap-1 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]"
style="animation-duration: 100ms">
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Categories' %}"
url='categories_index'
active="categories_index"
icon="fa-solid fa-icons">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Tags' %}"
url='tags_index'
active="tags_index"
icon="fa-solid fa-hashtag">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Entities' %}"
url='entities_index'
active="entities_index"
icon="fa-solid fa-user-group">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Accounts' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Accounts' %}"
url='accounts_index'
active="accounts_index"
icon="fa-solid fa-wallet">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Account Groups' %}"
url='account_groups_index'
active="account_groups_index"
icon="fa-solid fa-wallet">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Currencies' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Currencies' %}"
url='currencies_index'
active="currencies_index"
icon="fa-solid fa-coins">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Exchange Rates' %}"
url='exchange_rates_index'
active="exchange_rates_index"
icon="fa-solid fa-right-left">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Automation' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Rules' %}"
url='rules_index'
active="rules_index"
icon="fa-solid fa-pen-ruler">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Import' %}"
url='import_profiles_index'
active="import_profiles_index"
icon="fa-solid fa-file-import">
</c-components.sidebar-menu-item>
{% if user.is_superuser %}
<c-components.sidebar-menu-item
title="{% translate 'Export and Restore' %}"
url='export_index'
active="export_index"
icon="fa-solid fa-file-export">
</c-components.sidebar-menu-item>
{% endif %}
<c-components.sidebar-menu-item
title="{% translate 'Automatic Exchange Rates' %}"
url='automatic_exchange_rates_index'
active="automatic_exchange_rates_index"
icon="fa-solid fa-right-left">
</c-components.sidebar-menu-item>
{% if user.is_superuser %}
<c-components.sidebar-menu-header title="{% translate 'Admin' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Users' %}"
url='users_index'
active="users_index"
icon="fa-solid fa-users">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-url-item
title="{% translate 'Django Admin' %}"
tooltip="{% translate "Only use this if you know what you're doing" %}"
url='/admin/'
icon="fa-solid fa-screwdriver-wrench">
</c-components.sidebar-menu-url-item>
{% endif %}
</ul>
</div>
</div>
{% get_update_check as update_check %}
{% if update_check.update_available %}
<div class="my-3">
<a class="px-3 badge text-bg-primary text-decoration-none tw:cursor-pointer w-100 tw:text-xs!"
href="https://github.com/eitchtee/WYGIWYH/releases/latest" target="_blank"><i
class="fa-solid fa-circle-exclamation fa-fw me-2"></i><span
class="tw:lg:invisible tw:lg:group-hover:visible">v.{{ update_check.latest_version }} {% translate 'is available' %}!</span></a>
</div>
{% endif %}
<div class="btn-group w-100" role="group">
<button type="button" class="btn btn-secondary btn-sm" data-bs-toggle="tooltip"
data-bs-title="{% trans "Calculator" %}"
_="on click trigger show on #calculator">
<i class="fa-solid fa-calculator fa-fw"></i>
</button>
</div>
</div>
<div>
<hr class="my-1">
<div
class="ps-4 pe-2 py-2 d-flex align-items-center text-body-secondary text-decoration-none justify-content-between tw:text-wrap tw:lg:text-nowrap">
<div>
<i class="fa-solid fa-circle-user"></i>
<strong class="ms-2 tw:lg:invisible tw:lg:group-hover:visible">{{ user.email }}</strong>
</div>
<div class="tw:lg:invisible tw:lg:group-hover:visible">
{% include 'includes/navbar/user_menu.html' %}
</div>
</div>
</div>
</nav>
<div
class="tw:hidden tw:lg:group-hover:block tw:absolute tw:top-0 tw:left-104 tw:w-16 tw:h-full tw:bg-transparent"></div>
</div>

View File

@@ -273,7 +273,6 @@
function setupChart() {
var rawData = JSON.parse(document.getElementById('categoryOverviewData').textContent);
var showing_string = JSON.parse(document.getElementById('showingString').textContent);
console.log(showing_string)
// --- Dynamic Data Processing ---
var categories = [];

View File

@@ -13,7 +13,7 @@
<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)/>
add .tw:hidden to <#picker-form > div:not(.tw\\:hidden)/>
if type == 'month'
remove .tw:hidden from #month-form

View File

@@ -14,6 +14,8 @@
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Account' %}</th>
<th scope="col" class="col">{% translate 'Amount' %}</th>
</tr>
</thead>
<tbody>
@@ -69,6 +71,17 @@
</div>
<div class="tw:text-sm tw:text-gray-400">{{ installment_plan.notes|linebreaksbr }}</div>
</td>
<td class="col-auto">
{% if installment_plan.account.group %}{{ installment_plan.account.group }} • {% endif %}{{ installment_plan.account }}
</td>
<td class="col-auto">
<c-amount.display
:amount="installment_plan.installment_amount"
:prefix="installment_plan.account.currency.prefix"
:suffix="installment_plan.account.currency.suffix"
:decimal_places="installment_plan.account.currency.decimal_places"
color="{% if installment_plan.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -28,27 +28,33 @@
</head>
<body class="font-monospace">
<div _="install hide_amounts
install htmx_error_handler
{% block body_hyperscript %}{% endblock %}"
install htmx_error_handler
{% block body_hyperscript %}{% endblock %}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/navbar.html' %}
{% settings "DEMO" as demo_mode %}
{% if demo_mode %}
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
<div class="alert alert-warning alert-dismissible fade show my-3" role="alert">
<strong>{% trans 'This is a demo!' %}</strong> {% trans 'Any data you add here will be wiped in 24hrs or less' %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
{% include 'includes/mobile_navbar.html' %}
{% include 'includes/sidebar.html' %}
<main class="tw:p-4 tw:lg:ml-16">
{% settings "DEMO" as demo_mode %}
{% if demo_mode %}
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
<div class="alert alert-warning alert-dismissible fade show my-3" role="alert">
<strong>{% trans 'This is a demo!' %}</strong> {% trans 'Any data you add here will be wiped in 24hrs or less' %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
</div>
{% endif %}
{% endif %}
<div id="content">
{% block content %}{% endblock %}
</div>
<div class="container" hx-get="{% url 'setup' %}" hx-trigger="load, updated from:window"></div>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
<div id="content">
{% block content %}{% endblock %}
</div>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
</main>
</div>
{% include 'includes/tools/calculator.html' %}

View File

@@ -9,7 +9,29 @@
{% block title %}{% if type == "current" %}{% translate 'Current Net Worth' %}{% else %}{% translate 'Projected Net Worth' %}{% endif %}{% endblock %}
{% block content %}
<div hx-trigger="every 60m" class="show-loading" hx-get="" hx-target="body">
<div hx-trigger="every 60m, updated from:window" hx-include="#view-type" class="show-loading" hx-get="" hx-target="body">
<div class="h-100 text-center mb-4 pt-2">
<div class="btn-group gap-3" role="group" id="view-type" _="on change trigger updated">
<input type="radio" class="btn-check"
name="view_type"
id="current-view"
autocomplete="off"
value="current"
{% if type == "current" %}checked{% endif %}>
<label class="btn btn-outline-primary rounded-5" for="current-view"><i
class="fa-solid fa-sack-dollar fa-fw me-2"></i>{% trans 'Current' %}</label>
<input type="radio"
class="btn-check"
name="view_type"
id="projected-view"
autocomplete="off"
value="projected"
{% if type == "projected" %}checked{% endif %}>
<label class="btn btn-outline-primary rounded-5" for="projected-view"><i
class="fa-solid fa-rocket fa-fw me-2"></i>{% trans 'Projected' %}</label>
</div>
</div>
<div class="container px-md-3 py-3" _="init call initializeAccountChart() then initializeCurrencyChart() end">
<div class="row gx-xl-4 gy-3 mb-4">
<div class="col-12 col-xl-5">

View File

@@ -11,6 +11,8 @@
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Account' %}</th>
<th scope="col" class="col">{% translate 'Amount' %}</th>
</tr>
</thead>
<tbody>
@@ -46,6 +48,17 @@
{{ qt.name }}
</div>
</td>
<td class="col-auto">
{% if qt.account.group %}{{ qt.account.group }} • {% endif %}{{ qt.account }}
</td>
<td class="col-auto">
<c-amount.display
:amount="qt.amount"
:prefix="qt.account.currency.prefix"
:suffix="qt.account.currency.suffix"
:decimal_places="qt.account.currency.decimal_places"
color="{% if qt.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -16,6 +16,8 @@
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Account' %}</th>
<th scope="col" class="col">{% translate 'Amount' %}</th>
</tr>
</thead>
<tbody>
@@ -105,6 +107,17 @@
</div>
<div class="tw:text-sm tw:text-gray-400">{{ recurring_transaction.notes|linebreaksbr }}</div>
</td>
<td class="col-auto">
{% if recurring_transaction.account.group %}{{ recurring_transaction.account.group }} • {% endif %}{{ recurring_transaction.account }}
</td>
<td class="col-auto">
<c-amount.display
:amount="recurring_transaction.amount"
:prefix="recurring_transaction.account.currency.prefix"
:suffix="recurring_transaction.account.currency.suffix"
:decimal_places="recurring_transaction.account.currency.decimal_places"
color="{% if recurring_transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -5,47 +5,56 @@
{% block title %}{% translate 'Transactions' %}{% endblock %}
{% block content %}
<div class="container-fluid px-md-3 py-3 column-gap-5">
<div class="row gx-xl-4 gy-3">
<div class="col-12 col-xl-3 order-0 order-xl-0">
{# Filter transactions#}
<div class="row mb-1">
<div class="col-12">
<div class="d-flex mb-3 align-self-center">
<div class="me-auto"><h4><i class="fa-solid fa-filter me-2"></i>{% translate 'Filter' %}</h4></div>
<div class="align-self-center">
<button class="btn btn-outline-danger btn-sm" _="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
<div class="container px-md-3 py-3 column-gap-5">
<div class="row gx-xl-4 gy-3">
<div class="col-12 col-xl-8 order-2 order-xl-1">
<div class="row mb-1">
<div class="col-sm-6 col-12">
{# Filter transactions button #}
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false"
aria-controls="collapse-filter">
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %}
</button>
</div>
{# Ordering button#}
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0">
<div class="text-sm-end" _="on change trigger updated on window">
<label for="order">{% translate "Order by" %}</label>
<select
class="tw:border-0 tw:focus-visible:outline-0 w-full pe-2 tw:leading-normal text-bg-tertiary tw:font-medium rounded bg-body text-body"
name="order" id="order">
<option value="default"
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
<option value="older"
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
<option value="newer"
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
</select>
</div>
<hr>
<form hx-get="{% url 'transactions_all_list' %}" hx-trigger="change, submit, search"
hx-target="#transactions" id="filter" hx-indicator="#transactions"
_="install init_tom_select
install init_datepicker">
{% crispy filter.form %}
</form>
</div>
</div>
</div>
<div class="col-12 col-xl-6 order-2 order-xl-1">
<div class="text-end tw:justify-end tw:flex tw:text-sm mb-3">
<div class="tw:content-center" _="on change trigger updated on window">
<label for="order">{% translate "Order by" %}</label>
<select class="tw:border-0 tw:focus-visible:outline-0 w-full pe-2 tw:leading-normal text-bg-tertiary tw:font-medium rounded bg-body text-body" name="order" id="order">
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
<option value="older" {% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
<option value="newer" {% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
</select>
{# Filter transactions form#}
<div class="collapse" id="collapse-filter">
<div class="card card-body">
<form _="on change or submit or search trigger updated on window end
install init_tom_select
install init_datepicker"
id="filter">
{% crispy filter.form %}
</form>
<button class="btn btn-outline-danger btn-sm"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
</div>
<div id="transactions"
class="show-loading"
hx-get="{% url 'transactions_all_list' %}"
hx-trigger="load, updated from:window" hx-include="#filter, #page, #order">
</div>
</div>
<div id="transactions"
class="show-loading"
hx-get="{% url 'transactions_all_list' %}"
hx-trigger="load, updated from:window" hx-include="#filter, #page, #order">
</div>
</div>
<div class="col-12 col-xl-3 order-1 order-xl-2">
<ul class="nav nav-tabs" id="all-transactions-summary" role="tablist">
<div class="col-12 col-xl-4 order-1 order-xl-2">
<ul class="nav nav-tabs" id="all-transactions-summary" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link {% if summary_tab == 'currency' %}active{% endif %}"
id="currency-tab"
@@ -73,7 +82,7 @@
</button>
</li>
</ul>
<div class="tab-content" id="all-transactions-content">
<div class="tab-content" id="all-transactions-content">
<div class="tab-pane fade {% if summary_tab == 'currency' %}show active{% endif %}"
id="currency-tab-pane"
role="tabpanel"
@@ -98,9 +107,9 @@
hx-include="#filter">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<c-ui.transactions_fab></c-ui.transactions_fab>
<c-ui.transactions_fab></c-ui.transactions_fab>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% load i18n %}
<div class="card shadow-sm rounded-3">
<div class="card-header border-bottom-0 pt-4 px-4">
<h5 class="card-title fw-bold"><i class="fas fa-rocket me-2"></i>Let's Get You Set Up</h5>
</div>
<div class="card-body p-4">
<!-- Explanation Text -->
<div class="alert alert-info border-0" role="alert">
<div class="d-flex align-items-center">
<i class="fas fa-info-circle fa-lg me-3"></i>
<div>
Welcome to <strong>WYGIWYH</strong>! To get started, you need to configure a currency and create your first account. This will establish the foundation for managing your finances.
</div>
</div>
</div>
<!-- Task Lists -->
<div class="mt-4 border rounded-3 overflow-hidden">
<!-- Required Section -->
<div class="p-3 text-bg-secondary text-muted text-uppercase fw-bold small">Required Steps</div>
<ul class="list-group list-group-flush">
<!-- Task 1: Create Currency -->
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center">
<i class="fa-regular fa-circle{% if has_currency %}-check{% endif %} fa-fw text-primary me-3"></i>
<span class="fw-medium {% if has_currency %}tw:line-through{% endif %}">{% trans 'Add' %} {% trans 'Currency' %}</span>
</div>
<a href="#" class="btn btn-primary btn-sm"
role="button"
hx-get="{% url 'currency_add' %}"
hx-target="#generic-offcanvas">{% trans 'Add' %} <i class="fas fa-arrow-right ms-1"></i></a>
</li>
<!-- Task 2: Create Account -->
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center">
<i class="fa-regular fa-circle{% if has_account %}-check{% endif %} fa-fw text-primary me-3"></i>
<span class="fw-medium {% if has_account %}tw:line-through{% endif %}">{% trans 'Add' %} {% trans 'Account' %}</span>
</div>
<a class="btn btn-primary btn-sm"
role="button"
hx-get="{% url 'account_add' %}"
hx-target="#generic-offcanvas">{% trans 'Add' %} <i class="fas fa-arrow-right ms-1"></i></a>
</li>
</ul>
<!-- Optional Section -->
<div class="p-3 text-bg-secondary text-muted text-uppercase fw-bold small border-top">Optional Steps</div>
<ul class="list-group list-group-flush">
<!-- Task 3: Import Data -->
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center">
<i class="fas fa-upload fa-fw text-secondary me-3"></i>
<span class="fw-medium">Import data from another app</span>
</div>
<a href="#" class="btn btn-outline-secondary btn-sm">Import</a>
</li>
<!-- Task 4: Invite Team -->
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center">
<i class="fas fa-user-plus fa-fw text-secondary me-3"></i>
<span class="fw-medium">Invite team members</span>
</div>
<a href="#" class="btn btn-outline-secondary btn-sm">Invite</a>
</li>
</ul>
</div>
</div>
</div>

View File

@@ -12,7 +12,18 @@
{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="h-100 text-center mb-4 pt-2">
<div class="btn-group gap-3" role="group">
<a href="{% url 'yearly_overview_currency' year=year %}" class="btn {% if type != 'currency' %}btn-outline-primary{% else %}btn-primary{% endif %} rounded-5" hx-boost>
<i class="fa-solid fa-solid fa-coins fa-fw me-2"></i>{% trans 'Currency' %}
</a>
<a href="{% url 'yearly_overview_account' year=year %}" class="btn {% if type != 'account' %}btn-outline-primary{% else %}btn-primary{% endif %} rounded-5" hx-boost>
<i class="fa-solid fa-wallet fa-fw me-2"></i>{% trans 'Account' %}
</a>
</div>
</div>
<div class="container px-md-3 py-3 column-gap-5" id="yearly-content">
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
{# Date picker#}
<div class="col-12 col-xl-2 flex-row align-items-center d-flex">
@@ -113,7 +124,7 @@
<div id="data-content"
class="show-loading"
hx-get="{% url 'yearly_overview_account_data' year=year %}"
hx-trigger="load, every 10m"
hx-trigger="load, every 10m, updated from:window"
hx-include="[name='account'], [name='month']"
hx-swap="innerHTML">
</div>

View File

@@ -14,7 +14,18 @@
{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="h-100 text-center mb-4 pt-2">
<div class="btn-group gap-3" role="group">
<a href="{% url 'yearly_overview_currency' year=year %}" class="btn {% if type != 'currency' %}btn-outline-primary{% else %}btn-primary{% endif %} rounded-5" hx-boost>
<i class="fa-solid fa-solid fa-coins fa-fw me-2"></i>{% trans 'Currency' %}
</a>
<a href="{% url 'yearly_overview_account' year=year %}" class="btn {% if type != 'account' %}btn-outline-primary{% else %}btn-primary{% endif %} rounded-5" hx-boost>
<i class="fa-solid fa-wallet fa-fw me-2"></i>{% trans 'Account' %}
</a>
</div>
</div>
<div class="container px-md-3 py-3 column-gap-5" id="yearly-content">
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
{# Date picker#}
<div class="col-12 col-xl-2 flex-row align-items-center d-flex">
@@ -115,7 +126,7 @@
<div id="data-content"
class="show-loading"
hx-get="{% url 'yearly_overview_currency_data' year=year %}"
hx-trigger="load, every 10m"
hx-trigger="load, every 10m, updated from:window"
hx-include="[name='currency'], [name='month']"
hx-swap="innerHTML">
</div>

View File

@@ -209,11 +209,11 @@
.slide-in-bottom {
animation: slide-in-bottom 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
animation: slide-in-bottom 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
.slide-in-bottom-reverse {
animation: slide-in-bottom 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) reverse both;
animation: slide-in-bottom 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) reverse both;
}
/* ----------------------------------------------
@@ -238,3 +238,12 @@
opacity: 1;
}
}
@keyframes disable-pointer-events {
0%, 99% {
pointer-events: none;
}
100% {
pointer-events: auto;
}
}

View File

@@ -77,3 +77,11 @@ select[multiple] {
margin-top: 20px;
font-size: 14px;
}
[data-bs-toggle="collapse"] .fa-chevron-down {
transition: transform 0.25s ease-in-out;
}
[data-bs-toggle="collapse"][aria-expanded="true"] .fa-chevron-down {
transform: rotate(-180deg);
}

View File

@@ -1,2 +1,11 @@
@import "tailwindcss" prefix(tw) source("../../../app/templates/");
@custom-variant dark (&:where([data-bs-theme=dark], [data-bs-theme=dark] *));
@custom-variant hover (&:hover);
.sidebar-active {
@apply tw:bg-gray-700 tw:text-white;
}
.sidebar-item:not(.sidebar-active) {
@apply tw:text-gray-300 tw:hover:text-white;
}

View File

@@ -1,27 +1,27 @@
Django~=5.1
psycopg[binary]==3.2.6
Django~=5.2
psycopg[binary]==3.2.9
python-webpack-boilerplate==1.0.4
django-crispy-forms==2.3
crispy-bootstrap5==2025.4
django-crispy-forms==2.4
crispy-bootstrap5==2025.6
django-browser-reload==1.18.0
django-hijack==3.7.1
django-hijack==3.7.3
django-filter==25.1
django-debug-toolbar==4.4.6
django-cachalot~=2.7.0
django-cotton~=1.5.2
django-cachalot~=2.8.0
django-cotton~=2.1.3
django-pwa~=2.0.1
djangorestframework~=3.16.0
drf-spectacular~=0.28.0
django-import-export~=4.3.7
django-import-export~=4.3.9
gunicorn==23.0.0
whitenoise[brotli]==6.9.0
watchfiles==0.24.0 # https://github.com/samuelcolvin/watchfiles
procrastinate[django]~=2.15.1
watchfiles==1.1.0 # https://github.com/samuelcolvin/watchfiles
procrastinate[django]~=3.4.0
requests~=2.32.3
django-allauth[socialaccount]~=65.9.0
django-allauth[socialaccount]~=65.10.0
pytz
python-dateutil~=2.9.0.post0