Compare commits

...

120 Commits

Author SHA1 Message Date
Dimitri Decrock
b7c53a3c2d locale(Dutch): update translation
Currently translated at 99.7% (673 of 675 strings)

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

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-08 04:17:41 +00:00
Herculino Trotta
ccc4deb1d8 Merge branch 'main' of https://github.com/eitchtee/WYGIWYH 2025-08-07 23:48:45 -03:00
Herculino Trotta
d3ecf55375 Merge remote-tracking branch 'weblate/main' 2025-08-07 23:43:25 -03:00
eitchtee
580f3e7345 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:41:32 +00:00
Herculino Trotta
0e5843094b Merge pull request #319
dev
2025-08-07 23:39:41 -03:00
Herculino Trotta
ed65945d19 feat(automatic-exchange-rates): rename automatic field 2025-08-07 23:39:21 -03:00
Herculino Trotta
18d8837c64 locale(Portuguese (Brazil)): update translation
Currently translated at 99.2% (671 of 676 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-08 02:37:06 +00:00
eitchtee
067d819077 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:37:02 +00:00
Herculino Trotta
bbaae4746a Merge pull request #318
feat(transactions:recurring): try to create transactions on update
2025-08-07 23:36:12 -03:00
Herculino Trotta
d2e5c1d6b3 feat(transactions:recurring): try to create transactions on update 2025-08-07 23:35:57 -03:00
eitchtee
ffef61d514 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:27:52 +00:00
Herculino Trotta
9020f6f972 Merge pull request #317
feat(automatic-exchange-rates): add "Single exchange rate" where only one exchange rate is added and updated to avoid db clutter
2025-08-07 23:26:10 -03:00
Herculino Trotta
540235c1b0 feat(automatic-exchange-rates): add "Single exchange rate" where only one exchange rate is added and updated to avoid db clutter 2025-08-07 23:25:51 -03:00
eitchtee
9070bc5705 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-07 20:04:56 +00:00
Herculino Trotta
ba5a6c9772 Merge pull request #316 from eitchtee/dev
feat(transactions): add menu itens for quickly changing transaction date
2025-08-07 17:04:11 -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
5f24d05540 Merge pull request #315
style(login): make login button take full width as open id login
2025-08-07 12:03:02 -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
Dimitri Decrock
15d990007e locale(Dutch): update translation
Currently translated at 100.0% (670 of 670 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-07 06:17:41 +00:00
Herculino Trotta
3d5bc9cd3f Merge pull request #314
feat(tasks:check_for_updates): add env variable to disable checking
2025-08-06 16:59:15 -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
Dimitri Decrock
635f87a8ad locale(Dutch): update translation
Currently translated at 100.0% (666 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-07-20 09:17:41 +00:00
Herculino Trotta
1a073ba53d Merge pull request #287
feat: improve ordering when searching existing transactions on DCA creation
2025-07-20 00:24:51 -03:00
Herculino Trotta
5412e5b12c feat: improve ordering when searching existing transactions on DCA creation 2025-07-20 00:24:31 -03:00
Herculino Trotta
2103ba1b38 Merge pull request #286
fix: DCA and other indicators too close together when there's more than one
2025-07-20 00:09:25 -03:00
Herculino Trotta
04fb15224c fix: DCA and other indicators too close together when there's more than one 2025-07-20 00:09:09 -03:00
Herculino Trotta
2fc526beac Merge pull request #285
docs: update README
2025-07-20 00:02:46 -03:00
Herculino Trotta
cc3ca4f4a3 docs: improve README 2025-07-20 00:02:11 -03:00
Herculino Trotta
8d3844c431 Merge pull request #284
feat: add FAB to networth and all transactions pages
2025-07-20 00:00:14 -03:00
Herculino Trotta
5e7e918085 feat: add FAB to networth and all transactions pages 2025-07-19 23:59:58 -03:00
Herculino Trotta
c3f02320b5 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (666 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-07-19 21:17:41 +00:00
eitchtee
da8bbbfb0b chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-19 19:24:50 +00:00
Herculino Trotta
e3f74538d2 Merge pull request #283
feat: quickly add an existing transaction as a model for quick transactions
2025-07-19 16:23:16 -03:00
Herculino Trotta
d8234950c6 feat: quickly add an existing transaction as a model for quick transactions
Use the dropdown menu on a transaction
2025-07-19 16:22:50 -03:00
Herculino Trotta
58f19ce1ca Merge pull request #282
feat: allow single transactions to be hidden from summaries

fix #274
2025-07-19 16:19:50 -03:00
Herculino Trotta
ef5f3580a0 feat: allow single transactions to be hidden from summaries
Control is done via the dropdown menu on a transaction item
Transfers are hidden by default

fix #274
2025-07-19 16:19:11 -03:00
eitchtee
efe0f99cb4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-19 18:47:16 +00:00
Herculino Trotta
dccb5079ad Merge pull request #281
feat: add selected count to floating transactions bar
2025-07-19 15:45:55 -03:00
Herculino Trotta
6c90150661 feat: add selected count to floating transactions bar 2025-07-19 15:39:57 -03:00
eitchtee
c33d6fab69 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-19 17:48:14 +00:00
Herculino Trotta
c0c57a6d77 Merge pull request #280
feat: add dropdown to transaction menu and move duplication to there
2025-07-19 14:46:41 -03:00
Herculino Trotta
f19d58a2bd feat: add dropdown to transaction menu and move duplication to there 2025-07-19 14:46:23 -03:00
eitchtee
dfe99093e9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-19 17:43:49 +00:00
Herculino Trotta
d737e573cc Merge pull request #279
refactor: improve transaction menu displaying
2025-07-19 14:43:10 -03:00
Herculino Trotta
805d3f419e refactor: improve transaction menu displaying
Use hover instead of hyperscript and events
2025-07-19 14:42:50 -03:00
Herculino Trotta
9777aac746 Merge pull request #278
feat: slightly opaque transactions if they're silenced
2025-07-18 23:35:20 -03:00
Herculino Trotta
61b782104d feat: slightly opaque transactions if they're silenced 2025-07-18 23:34:55 -03:00
eitchtee
79dec2b515 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-12 05:27:34 +00:00
Herculino Trotta
db23e162c4 Merge pull request #276
feat: auto-refresh overview and networth pages (10m for overview and 1h for networth)
2025-07-12 02:26:50 -03:00
Herculino Trotta
d81d89d9f6 feat: auto-refresh overview and networth pages (10m for overview and 1h for networth)
Useful if you want to leave WYGIWYH open in a fixed tab
2025-07-12 02:26:15 -03:00
Herculino Trotta
6826cfe79a Merge pull request #275
fix: backspacing on currency fields doesn't update mask
2025-07-09 20:27:40 -03:00
Dimitri Decrock
3090f632de locale(Dutch): update translation
Currently translated at 100.0% (661 of 661 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-06-30 06:17:41 +00:00
75 changed files with 5041 additions and 3051 deletions

View File

@@ -29,15 +29,15 @@ Managing money can feel unnecessarily complex, but it doesnt have to be. WYGI
By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes.
While this philosophy is simple, finding tools to make it work wasnt. I initially used a spreadsheet, which served me well for yearsuntil it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
While this philosophy is simple, finding tools to make it work wasnt. I initially used a spreadsheet, which served me well for years, until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
1. **Multi-currency support** to track income and expenses in different currencies.
2. **Not a budgeting app** as I dislike budgeting constraints.
2. **Not a budgeting app** as I dislike budgeting constraints.
3. **Web app usability** (ideally with mobile support, though optional).
4. **Automation-ready API** to integrate with other tools and services.
5. **Custom transaction rules** for credit card billing cycles or similar quirks.
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH** an opinionated yet powerful tool that I believe will resonate with like-minded users.
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**, an opinionated yet powerful tool that I believe will resonate with like-minded users.
# Key Features
@@ -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

@@ -138,6 +138,7 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_unpaid_transactions()
instance.generate_upcoming_transactions()
return instance

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

@@ -203,21 +203,63 @@ class ExchangeRateFetcher:
if provider.rates_inverted:
# If rates are inverted, we need to swap currencies
ExchangeRate.objects.create(
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((to_currency.id, from_currency.id))
else:
# If rates are not inverted, we can use them as is
ExchangeRate.objects.create(
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now()

View File

@@ -114,6 +114,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
"fetch_interval",
"target_currencies",
"target_accounts",
"singleton",
]
def __init__(self, *args, **kwargs):
@@ -126,6 +127,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
"name",
"service_type",
Switch("is_active"),
Switch("singleton"),
"api_key",
Row(
Column("interval_type", css_class="form-group col-md-6"),

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-08 02:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0014_alter_currency_options'),
]
operations = [
migrations.AddField(
model_name='exchangerate',
name='automatic',
field=models.BooleanField(default=False, verbose_name='Automatic'),
),
migrations.AddField(
model_name='exchangerateservice',
name='singleton',
field=models.BooleanField(default=False, help_text='Create one exchange rate and keep updating it. Avoids database clutter.', verbose_name='Single exchange rate'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-08 02:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0015_exchangerate_automatic_exchangerateservice_singleton'),
]
operations = [
migrations.AlterField(
model_name='exchangerate',
name='automatic',
field=models.BooleanField(default=False, verbose_name='Auto'),
),
]

View File

@@ -70,6 +70,8 @@ class ExchangeRate(models.Model):
)
date = models.DateTimeField(verbose_name=_("Date and Time"))
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
class Meta:
verbose_name = _("Exchange Rate")
verbose_name_plural = _("Exchange Rates")
@@ -148,6 +150,14 @@ class ExchangeRateService(models.Model):
blank=True,
)
singleton = models.BooleanField(
verbose_name=_("Single exchange rate"),
default=False,
help_text=_(
"Create one exchange rate and keep updating it. Avoids database clutter."
),
)
class Meta:
verbose_name = _("Exchange Rate Service")
verbose_name_plural = _("Exchange Rate Services")

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

@@ -91,6 +91,8 @@ def get_transactions(request, include_unpaid=True, include_silent=False):
transactions = transactions.filter(is_paid=True)
if not include_silent:
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
transactions = transactions.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
return transactions

View File

@@ -260,6 +260,7 @@ def emergency_fund(request):
reference_date__gte=start_date,
reference_date__lte=end_date,
category__mute=False,
mute=False,
)
.values("reference_date", "account__currency")
.annotate(monthly_total=Sum("amount"))

View File

@@ -109,7 +109,7 @@ def monthly_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = Transaction.objects.filter(
reference_date__year=year, reference_date__month=month, account__is_asset=False
).exclude(Q(category__mute=True) & ~Q(category=None))
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
data = calculate_currency_totals(base_queryset, ignore_empty=True)
percentages = calculate_percentage_distribution(data)
@@ -143,7 +143,7 @@ def monthly_account_summary(request, month: int, year: int):
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
).exclude(Q(category__mute=True) & ~Q(category=None))
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
account_percentages = calculate_percentage_distribution(account_data)
@@ -168,7 +168,7 @@ def monthly_currency_summary(request, month: int, year: int):
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
).exclude(Q(category__mute=True) & ~Q(category=None))
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)

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,
@@ -290,11 +290,15 @@ class QuickTransactionForm(forms.ModelForm):
"category",
"tags",
"entities",
"mute",
]
widgets = {
"notes": forms.Textarea(attrs={"rows": 3}),
"account": TomSelect(clear_button=False, group_by="group"),
}
help_texts = {
"mute": _("Muted transactions won't be displayed on monthly summaries")
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -356,6 +360,7 @@ class QuickTransactionForm(forms.ModelForm):
css_class="form-row",
),
"notes",
Switch("mute"),
)
if self.instance and self.instance.pk:
@@ -494,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)
@@ -512,6 +524,7 @@ class TransferForm(forms.Form):
),
Field("description"),
Field("notes"),
Switch("mute"),
Row(
Column(
Row(
@@ -594,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"]
@@ -616,6 +631,7 @@ class TransferForm(forms.Form):
description=description,
category=from_category,
notes=notes,
mute=mute,
)
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
@@ -630,6 +646,7 @@ class TransferForm(forms.Form):
description=description,
category=to_category,
notes=notes,
mute=mute,
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
@@ -868,7 +885,7 @@ class TransactionCategoryForm(forms.ModelForm):
fields = ["name", "mute", "active"]
labels = {"name": _("Category name")}
help_texts = {
"mute": _("Muted categories won't count towards your monthly total")
"mute": _("Muted categories won't be displayed on monthly summaries")
}
def __init__(self, *args, **kwargs):
@@ -946,6 +963,7 @@ class RecurringTransactionForm(forms.ModelForm):
"notes",
"add_notes_to_transaction",
"entities",
"keep_at_most",
]
widgets = {
"reference_date": AirMonthYearPickerInput(),
@@ -1025,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()
@@ -1066,5 +1085,6 @@ class RecurringTransactionForm(forms.ModelForm):
instance.create_upcoming_transactions()
else:
instance.update_unpaid_transactions()
instance.generate_upcoming_transactions()
return instance

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.11 on 2025-07-19 18:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0044_alter_quicktransaction_unique_together'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='mute',
field=models.BooleanField(default=False, verbose_name='Mute'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.11 on 2025-07-19 18:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0045_transaction_mute'),
]
operations = [
migrations.AddField(
model_name='quicktransaction',
name='mute',
field=models.BooleanField(default=False, verbose_name='Mute'),
),
]

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

@@ -299,6 +299,7 @@ class Transaction(OwnedObject):
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
date = models.DateField(verbose_name=_("Date"))
reference_date = MonthYearModelField(verbose_name=_("Reference Date"))
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
amount = models.DecimalField(
max_digits=42,
@@ -721,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
@@ -758,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:
@@ -836,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}")
@@ -918,6 +932,7 @@ class QuickTransaction(OwnedObject):
verbose_name=_("Type"),
)
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
amount = models.DecimalField(
max_digits=42,
@@ -974,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

@@ -66,6 +66,21 @@ urlpatterns = [
views.transaction_pay,
name="transaction_pay",
),
path(
"transaction/<int:transaction_id>/mute/",
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,
@@ -342,4 +357,9 @@ urlpatterns = [
views.quick_transaction_add_as_transaction,
name="quick_transaction_add_as_transaction",
),
path(
"transactions/<int:transaction_id>/add-as-quick-transaction/",
views.quick_transaction_add_as_quick_transaction,
name="quick_transaction_add_as_quick_transaction",
),
]

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

@@ -129,7 +129,15 @@ def quick_transaction_add_as_transaction(request, quick_transaction_id):
quick_transaction_data = model_to_dict(
quick_transaction,
exclude=["id", "name", "owner", "account", "category", "tags", "entities"],
exclude=[
"id",
"name",
"owner",
"account",
"category",
"tags",
"entities",
],
)
new_transaction = Transaction(**quick_transaction_data)
@@ -152,3 +160,70 @@ def quick_transaction_add_as_transaction(request, quick_transaction_id):
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def quick_transaction_add_as_quick_transaction(request, transaction_id):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
if (
transaction.description
and QuickTransaction.objects.filter(
name__startswith=transaction.description
).exists()
) or QuickTransaction.objects.filter(
name__startswith=_("Quick Transaction")
).exists():
if transaction.description:
count = QuickTransaction.objects.filter(
name__startswith=transaction.description
).count()
qt_name = transaction.description + f" ({count + 1})"
else:
count = QuickTransaction.objects.filter(
name__startswith=_("Quick Transaction")
).count()
qt_name = _("Quick Transaction") + f" ({count + 1})"
else:
qt_name = transaction.description or _("Quick Transaction")
transaction_data = model_to_dict(
transaction,
exclude=[
"id",
"name",
"owner",
"account",
"category",
"tags",
"entities",
"date",
"reference_date",
"installment_plan",
"installment_id",
"recurring_transaction",
"deleted",
"deleted_at",
],
)
new_quick_transaction = QuickTransaction(**transaction_data)
new_quick_transaction.account = transaction.account
new_quick_transaction.category = transaction.category
new_quick_transaction.name = qt_name
new_quick_transaction.save()
new_quick_transaction.tags.set(transaction.tags.all())
new_quick_transaction.entities.set(transaction.entities.all())
messages.success(request, _("Item added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "toasts",
},
)

View File

@@ -1,10 +1,11 @@
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
from django.db.models import Q
from django.db.models import Q, When, Case, Value, IntegerField
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
@@ -388,6 +389,67 @@ def transaction_pay(request, transaction_id):
return response
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_mute(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=transaction_id)
new_mute = False if transaction.mute else True
transaction.mute = new_mute
transaction.save()
transaction_updated.send(sender=transaction)
response = render(
request,
"transactions/fragments/item.html",
context={"transaction": transaction, **request.GET},
)
response.headers["HX-Trigger"] = "selective_update"
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):
@@ -586,11 +648,26 @@ def get_recent_transactions(request, filter_type=None):
# Get search term from query params
search_term = request.GET.get("q", "").strip()
today = timezone.localdate(timezone.now())
yesterday = today - timezone.timedelta(days=1)
tomorrow = today + timezone.timedelta(days=1)
# Base queryset with selected fields
queryset = (
Transaction.objects.filter(deleted=False)
.annotate(
date_order=Case(
When(date=today, then=Value(0)),
When(date=tomorrow, then=Value(1)),
When(date=yesterday, then=Value(2)),
When(date__gt=tomorrow, then=Value(3)),
When(date__lt=yesterday, then=Value(4)),
default=Value(5),
output_field=IntegerField(),
)
)
.select_related("account", "category")
.order_by("-created_at")
.order_by("date_order", "date", "id")
)
if filter_type:

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

@@ -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",
},
)
@@ -75,7 +94,7 @@ def yearly_overview_by_currency(request, year: int):
transactions = (
Transaction.objects.filter(**filter_params)
.exclude(Q(category__mute=True) & ~Q(category=None))
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.order_by("account__currency__name")
)
@@ -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",
},
)
@@ -141,7 +162,7 @@ def yearly_overview_by_account(request, year: int):
transactions = (
Transaction.objects.filter(**filter_params)
.exclude(Q(category__mute=True) & ~Q(category=None))
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.order_by(
"account__group__name",
"account__name",

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

@@ -52,7 +52,7 @@
</div>
<div class="row">
<div class="show-loading" hx-get="{% url 'calendar_list' month=month year=year %}"
hx-trigger="load, updated from:window, selective_update from:window"></div>
hx-trigger="load, updated from:window, selective_update from:window, every 10m"></div>
</div>
</div>
<c-ui.transactions_fab></c-ui.transactions_fab>

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

@@ -1,6 +1,6 @@
{% load markdown %}
{% load i18n %}
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10">
<div class="d-flex my-1">
{% if not disable_selection %}
<label class="px-3 d-flex align-items-center justify-content-center">
@@ -9,11 +9,9 @@
</label>
{% endif %}
<div class="tw:border-s-4 tw:border-e-0 tw:border-t-0 tw:border-b-0 border-bottom
tw:hover:bg-zinc-900 p-2 {% if transaction.account.is_asset %}tw:border-dashed{% else %}tw:border-solid{% endif %}
{% if transaction.type == "EX" %}tw:border-red-500{% else %}tw:border-green-500{% endif %} tw:relative
w-100 transaction-item"
_="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">
tw:hover:bg-zinc-900 p-2 {% if transaction.account.is_asset %}tw:border-dashed{% else %}tw:border-solid{% endif %}
{% if transaction.type == "EX" %}tw:border-red-500{% else %}tw:border-green-500{% endif %} tw:relative
w-100 transaction-item">
<div class="row font-monospace tw:text-sm align-items-center">
<div
class="col-lg-auto col-12 d-flex align-items-center tw:text-2xl tw:lg:text-xl text-lg-center text-center p-0 ps-1">
@@ -35,7 +33,7 @@
</div>
{% endif %}
</div>
<div class="col-lg col-12">
<div class="col-lg col-12 {% if transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
{# 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>
@@ -48,13 +46,13 @@
<span class="{% if transaction.description %}me-2{% endif %}">{{ transaction.description }}</span>
{% if transaction.installment_plan and transaction.installment_id %}
<span
class="badge text-bg-secondary">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
class="badge text-bg-secondary mx-1">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
{% endif %}
{% if transaction.recurring_transaction %}
<span class="text-primary tw:text-xs"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
<span class="text-primary tw:text-xs mx-1"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
{% endif %}
{% if transaction.dca_expense_entries.all or transaction.dca_income_entries.all %}
<span class="badge text-bg-secondary">{% trans 'DCA' %}</span>
<span class="badge text-bg-secondary mx-1">{% trans 'DCA' %}</span>
{% endif %}
{% endspaceless %}
</div>
@@ -93,7 +91,7 @@
{% endwith %}
</div>
</div>
<div class="col-lg-auto col-12 text-lg-end align-self-end">
<div class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
<div class="main-amount mb-2 mb-lg-0">
<c-amount.display
:amount="transaction.amount"
@@ -122,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 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"
@@ -132,14 +130,6 @@
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Duplicate" %}"
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
hx-trigger="ready">
<i class="fa-solid fa-clone fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
@@ -152,6 +142,33 @@
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
<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 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">
<i class="fa-solid fa-eye fa-fw me-2"></i>
<div>
{% translate 'Show on summaries' %}
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
</div>
</a>
</li>
{% elif transaction.mute %}
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
{% else %}
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
{% 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 %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"

View File

@@ -10,18 +10,21 @@
end
else
if #actions-bar
remove .tw:hidden from #actions-bar
set #selected-count's innerHTML to length of <input[type='checkbox']:checked/> in #transactions-list
then remove .tw:hidden from #actions-bar
then trigger selected_transactions_updated
end
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">
@@ -50,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>
@@ -141,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,31 @@
</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 id="content">
{% block content %}{% endblock %}
</div>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
</main>
</div>
{% include 'includes/tools/calculator.html' %}

View File

@@ -104,7 +104,7 @@
<div id="summary"
hx-get="{% url 'monthly_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window">
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
</div>
</div>
<div class="tab-pane fade {% if summary_tab == 'currency' %}show active{% endif %}"
@@ -115,7 +115,7 @@
<div id="currency-summary"
hx-get="{% url 'monthly_currency_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window">
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
</div>
</div>
<div class="tab-pane fade {% if summary_tab == 'account' %}show active{% endif %}"
@@ -126,7 +126,7 @@
<div id="account-summary"
hx-get="{% url 'monthly_account_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window">
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
</div>
</div>
</div>
@@ -191,7 +191,7 @@
<div id="transactions"
class="show-loading"
hx-get="{% url 'monthly_transactions_list' month=month year=year %}"
hx-trigger="load, updated from:window" hx-include="#filter, #order">
hx-trigger="load, updated from:window, every 10m" hx-include="#filter, #order">
</div>
</div>
</div>

View File

@@ -9,6 +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, 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">
@@ -318,5 +341,6 @@
call currencyChart.update()
end
</script>
</div>
<c-ui.transactions_fab></c-ui.transactions_fab>
{% endblock %}

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,8 +107,9 @@
hx-include="#filter">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<c-ui.transactions_fab></c-ui.transactions_fab>
{% endblock %}

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"
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"
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