mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 08:54:52 +01:00
Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cb0a2a0f | ||
|
|
05ede58c36 | ||
|
|
20b6366a18 | ||
|
|
b0101dae1a | ||
|
|
a3d38ff9e0 | ||
|
|
776e2117a0 | ||
|
|
edcad37926 | ||
|
|
2d51d21035 | ||
|
|
94f5c25829 | ||
|
|
88a5c103e5 | ||
|
|
3dce9e1c55 | ||
|
|
41d8564e8b | ||
|
|
5ee2fd244f | ||
|
|
0545fb7651 | ||
|
|
7bd1d2d751 | ||
|
|
9a4ec449df | ||
|
|
f918351303 | ||
|
|
ef66b3a1e5 | ||
|
|
7486660223 | ||
|
|
00d5ccda34 | ||
|
|
1656eec601 | ||
|
|
64b96ed2f3 | ||
|
|
1f5e4f132d | ||
|
|
edf056b68c | ||
|
|
35865ce21c | ||
|
|
8f06c06d32 | ||
|
|
15eaa2239a | ||
|
|
fd7214df95 | ||
|
|
e531c63de3 | ||
|
|
5a79dd5424 | ||
|
|
315dd1479a | ||
|
|
67f79effab | ||
|
|
c168886968 | ||
|
|
272c34d3b3 | ||
|
|
43ce79ae65 | ||
|
|
4aa29545ec | ||
|
|
fd1fcb832c | ||
|
|
b5fd928a5d | ||
|
|
2dc398f82b | ||
|
|
cf7d4b1404 | ||
|
|
e9c3af1a85 | ||
|
|
b121e8e982 | ||
|
|
606e6b3843 | ||
|
|
6e46b5abb8 | ||
|
|
5b4dab93a1 | ||
|
|
29b6ee3af3 | ||
|
|
484686b709 | ||
|
|
938c128d07 | ||
|
|
8123f7f3cb | ||
|
|
547dc90d9e | ||
|
|
dc33fda5d3 | ||
|
|
92960d1b9a | ||
|
|
1978a467cb | ||
|
|
5bdafbba91 | ||
|
|
16de87376a | ||
|
|
e8e1144fdd | ||
|
|
157f357a7a | ||
|
|
d77eddbd26 | ||
|
|
fb1b383962 | ||
|
|
11998475c5 | ||
|
|
21363e23a1 | ||
|
|
d3a816d91b | ||
|
|
9c92bbd3cf | ||
|
|
c55d688956 | ||
|
|
231b9065c9 | ||
|
|
01ea0de4b3 | ||
|
|
c57fa1630b | ||
|
|
92f7bcfd9e | ||
|
|
58b855f55e | ||
|
|
d4d51301b3 | ||
|
|
aed3fb11fe | ||
|
|
70d427bec4 | ||
|
|
b6f52458db | ||
|
|
8d76c40b7e | ||
|
|
a43e3d158f | ||
|
|
588ae2de6e | ||
|
|
4b97ba681a | ||
|
|
1a903507ad | ||
|
|
bf920df771 | ||
|
|
23ae6f3d54 | ||
|
|
49f28834e9 | ||
|
|
4351027b87 | ||
|
|
c37aa6e059 | ||
|
|
8a5a54dcbd | ||
|
|
24ee8ecd68 | ||
|
|
a14332bb80 | ||
|
|
32747071fe | ||
|
|
24fa9cde51 | ||
|
|
372ec2f30f | ||
|
|
fffba037a6 | ||
|
|
43488147d8 | ||
|
|
31a31e9922 | ||
|
|
7af6280b29 | ||
|
|
40389396e3 | ||
|
|
21845d501e | ||
|
|
5f098e11a3 | ||
|
|
d2de0684fb | ||
|
|
eb4723e890 | ||
|
|
890cc90420 | ||
|
|
307af9e40a | ||
|
|
1eeb0b0f5e | ||
|
|
605ece705e | ||
|
|
2ae57e83cb | ||
|
|
af72e3f44e | ||
|
|
e2e1c5cff5 | ||
|
|
ed3d58f1fd | ||
|
|
b58f894dc6 | ||
|
|
2ed7fa44c0 | ||
|
|
7c3120cd43 | ||
|
|
2bc5e24e51 | ||
|
|
d3f8a637bc | ||
|
|
b02b6451d2 | ||
|
|
0b0d760bab | ||
|
|
b38ed37bc5 | ||
|
|
5d7dd622f5 | ||
|
|
7e37948616 | ||
|
|
2afb6b1f5f | ||
|
|
cd54df6f2d | ||
|
|
3e4ace8993 | ||
|
|
a878af28f1 | ||
|
|
0a4d4c12b9 | ||
|
|
9ade58a003 | ||
|
|
89b2d0118d | ||
|
|
232d5003b8 | ||
|
|
133d70d3d1 | ||
|
|
e70608eaaf | ||
|
|
a63367a772 | ||
|
|
baef86b6cb | ||
|
|
3011b32fa6 | ||
|
|
910decfe00 | ||
|
|
e600d87968 | ||
|
|
dd82289488 | ||
|
|
1e816ec80a | ||
|
|
3b5626cbd1 | ||
|
|
a819ceaa43 | ||
|
|
de28dbb0f0 | ||
|
|
cfb34a4dc3 | ||
|
|
efdcfc192a | ||
|
|
a7856a6671 | ||
|
|
7b8e3b528a | ||
|
|
cc3244a034 | ||
|
|
2121a68c82 | ||
|
|
f35002f862 | ||
|
|
73a992256d | ||
|
|
9f1098d6b9 | ||
|
|
2c0936b7e5 | ||
|
|
5fb717c3fe | ||
|
|
c5f94fb34d | ||
|
|
29cdec4577 | ||
|
|
82efd48e53 | ||
|
|
5a3a0b7e5c | ||
|
|
41a5900f12 | ||
|
|
2dbdd02350 | ||
|
|
fa0cde1a4e | ||
|
|
623d91d26f | ||
|
|
57200437dc | ||
|
|
6f4a2b687c | ||
|
|
8bb40be41c | ||
|
|
66c1cf2371 | ||
|
|
4b23836544 | ||
|
|
585af1270f | ||
|
|
a0cc51b2ec | ||
|
|
6a5de7d94d | ||
|
|
6d9687de0b | ||
|
|
e9acf1dd8f | ||
|
|
698e05bd06 | ||
|
|
90b3778e36 | ||
|
|
85a773bc01 | ||
|
|
355016a7a5 | ||
|
|
f04fcf99b7 | ||
|
|
0fb389e7e8 | ||
|
|
63898aeef0 | ||
|
|
4fdf00d098 | ||
|
|
025cc585d5 | ||
|
|
17018d87cd | ||
|
|
1e5f4f6583 | ||
|
|
a99851cf9b | ||
|
|
9fb1ad4861 | ||
|
|
66c3abfe37 | ||
|
|
8ca64f5820 | ||
|
|
e743821570 | ||
|
|
5c698d8735 | ||
|
|
3e5aa90df0 | ||
|
|
b2add14238 | ||
|
|
a052c00aa8 | ||
|
|
7f343708e0 | ||
|
|
22e95c7f4a | ||
|
|
7645153f77 | ||
|
|
1abfed9abf | ||
|
|
eea0ab009d | ||
|
|
29446def22 | ||
|
|
9dce5e9efe | ||
|
|
695e2cb322 | ||
|
|
b135ec3b15 | ||
|
|
bb3cc5da6c | ||
|
|
ca7fe24a8a | ||
|
|
483ba74010 |
74
.github/workflows/release.yml
vendored
74
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA)'
|
||||
description: 'Git ref to checkout'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
@@ -29,73 +29,57 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # Needed if you switch to GHCR, good practice
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
- name: Checkout code (non-manual)
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# This action handles all the logic for tags (nightly vs release vs custom)
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Logic for Push to Main -> nightly
|
||||
type=raw,value=nightly,enable=${{ github.event_name == 'push' }}
|
||||
# Logic for Release -> semver and latest
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
# Logic for Manual Dispatch -> custom input
|
||||
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push nightly image
|
||||
if: github.event_name == 'push'
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/prod/django/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
# Pass the calculated tags from the meta step
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=nightly
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:nightly
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push release image
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/prod/django/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
build-args: |
|
||||
VERSION=${{ github.event.release.tag_name }}
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push custom image
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/prod/django/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
build-args: |
|
||||
VERSION=${{ github.event.inputs.tag }}
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.tag }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# --- CACHE CONFIGURATION ---
|
||||
# We set a specific 'scope' key.
|
||||
# This allows the Release tag to see the cache created by the Main branch.
|
||||
cache-from: type=gha,scope=build-cache
|
||||
cache-to: type=gha,mode=max,scope=build-cache
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -123,6 +123,7 @@ celerybeat.pid
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.prod.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
@@ -160,3 +161,7 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
node_modules/
|
||||
postgres_data/
|
||||
.prod.env
|
||||
|
||||
8
.vscode/settings.json
vendored
Normal file
8
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"djlint.showInstallError": false,
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"tailwindCSS.experimental.configFile": "frontend/src/styles/tailwind.css",
|
||||
"djlint.profile": "django",
|
||||
}
|
||||
11
README.md
11
README.md
@@ -13,6 +13,7 @@
|
||||
<a href="#key-features">Features</a> •
|
||||
<a href="#how-to-use">Usage</a> •
|
||||
<a href="#how-it-works">How</a> •
|
||||
<a href="#mcp-server">MCP Server</a> •
|
||||
<a href="#help-us-translate-wygiwyh">Translate</a> •
|
||||
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
||||
<a href="#built-with">Built with</a>
|
||||
@@ -126,6 +127,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
|
||||
| variable | type | default | explanation |
|
||||
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
|
||||
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
|
||||
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
|
||||
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
|
||||
@@ -143,7 +145,10 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
| 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. |
|
||||
| CHECK_FOR_UPDATES | true\|false | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
|
||||
| DJANGO_VITE_DEV_MODE | true\|false | false | Enables Vite dev server mode for frontend development. When true, assets are served from Vite's dev server instead of the build manifest. For development only! |
|
||||
| DJANGO_VITE_DEV_SERVER_PORT | int | 5173 | The port where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! |
|
||||
| DJANGO_VITE_DEV_SERVER_HOST | string | localhost | The host where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! |
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
@@ -182,6 +187,10 @@ Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more informat
|
||||
> [!NOTE]
|
||||
> Login with your github account
|
||||
|
||||
# MCP Server
|
||||
|
||||
[IZIme07](https://github.com/IZIme07) has kindly created an MCP Server for WYGIWYH that you can self-host. [Check it out at MCP-WYGIWYH](https://github.com/ReNewator/MCP-WYGIWYH)!
|
||||
|
||||
# Caveats and Warnings
|
||||
|
||||
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
|
||||
|
||||
@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.1/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -46,7 +47,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sites",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
"webpack_boilerplate",
|
||||
"django_vite",
|
||||
"django.contrib.humanize",
|
||||
"django.contrib.postgres",
|
||||
"django_browser_reload",
|
||||
@@ -128,12 +129,23 @@ STORAGES = {
|
||||
|
||||
WHITENOISE_MANIFEST_STRICT = False
|
||||
|
||||
|
||||
def immutable_file_test(path, url):
|
||||
# Match vite (rollup)-generated hashes, à la, `some_file-CSliV9zW.js`
|
||||
return re.match(r"^.+[.-][0-9a-zA-Z_-]{8,12}\..+$", url)
|
||||
|
||||
|
||||
WHITENOISE_IMMUTABLE_FILE_TEST = immutable_file_test
|
||||
|
||||
WSGI_APPLICATION = "WYGIWYH.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
THREADS = int(os.getenv("GUNICORN_THREADS", 1))
|
||||
MAX_POOL_SIZE = THREADS + 1
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
@@ -142,6 +154,17 @@ DATABASES = {
|
||||
"PASSWORD": os.getenv("SQL_PASSWORD", "password"),
|
||||
"HOST": os.getenv("SQL_HOST", "localhost"),
|
||||
"PORT": os.getenv("SQL_PORT", "5432"),
|
||||
"CONN_MAX_AGE": 0,
|
||||
"CONN_HEALTH_CHECKS": True,
|
||||
"OPTIONS": {
|
||||
"pool": {
|
||||
"min_size": 1,
|
||||
"max_size": MAX_POOL_SIZE,
|
||||
"timeout": 10,
|
||||
"max_lifetime": 600,
|
||||
"max_idle": 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +312,7 @@ STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "static_files"
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
ROOT_DIR / "frontend/build",
|
||||
ROOT_DIR / "frontend" / "build",
|
||||
BASE_DIR / "static",
|
||||
]
|
||||
|
||||
@@ -305,9 +328,11 @@ CACHES = {
|
||||
}
|
||||
}
|
||||
|
||||
WEBPACK_LOADER = {
|
||||
"MANIFEST_FILE": ROOT_DIR / "frontend/build/manifest.json",
|
||||
}
|
||||
DJANGO_VITE_ASSETS_PATH = STATIC_ROOT
|
||||
DJANGO_VITE_MANIFEST_PATH = DJANGO_VITE_ASSETS_PATH / "manifest.json"
|
||||
DJANGO_VITE_DEV_MODE = os.getenv("DJANGO_VITE_DEV_MODE", "false").lower() == "true"
|
||||
DJANGO_VITE_DEV_SERVER_PORT = int(os.getenv("DJANGO_VITE_DEV_SERVER_PORT", "5173"))
|
||||
DJANGO_VITE_DEV_SERVER_HOST = os.getenv("DJANGO_VITE_DEV_SERVER_HOST", "localhost")
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
@@ -354,8 +379,11 @@ ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
|
||||
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
|
||||
|
||||
# CRISPY FORMS
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = [
|
||||
"crispy_forms/pure_text",
|
||||
"crispy-daisyui",
|
||||
]
|
||||
CRISPY_TEMPLATE_PACK = "crispy-daisyui"
|
||||
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
|
||||
SESSION_COOKIE_AGE = int(os.getenv("SESSION_EXPIRY_TIME", 2678400)) # 31 days
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
from apps.common.widgets.crispy.daisyui import Switch
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.currencies.models import Currency
|
||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Column, Row
|
||||
from crispy_forms.layout import Column, Field, Layout, Row
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.accounts.models import AccountGroup
|
||||
from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelMultipleChoiceField,
|
||||
DynamicModelChoiceField,
|
||||
)
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class AccountGroupForm(forms.ModelForm):
|
||||
class Meta:
|
||||
@@ -38,17 +36,13 @@ class AccountGroupForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -108,17 +102,13 @@ class AccountForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -156,9 +146,8 @@ class AccountBalanceForm(forms.Form):
|
||||
self.helper.layout = Layout(
|
||||
"new_balance",
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("category"),
|
||||
Column("tags"),
|
||||
),
|
||||
Field("account_id"),
|
||||
)
|
||||
|
||||
33
app/apps/accounts/services.py
Normal file
33
app/apps/accounts/services.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
def get_account_balance(account: Account, paid_only: bool = True) -> Decimal:
|
||||
"""
|
||||
Calculate account balance (income - expense).
|
||||
|
||||
Args:
|
||||
account: Account instance to calculate balance for.
|
||||
paid_only: If True, only count paid transactions (current balance).
|
||||
If False, count all transactions (projected balance).
|
||||
|
||||
Returns:
|
||||
Decimal: The calculated balance (income - expense).
|
||||
"""
|
||||
filters = {"account": account}
|
||||
if paid_only:
|
||||
filters["is_paid"] = True
|
||||
|
||||
income = Transaction.objects.filter(
|
||||
type=Transaction.Type.INCOME, **filters
|
||||
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
expense = Transaction.objects.filter(
|
||||
type=Transaction.Type.EXPENSE, **filters
|
||||
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
return income - expense
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import date
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
@@ -39,3 +41,135 @@ class AccountTests(TestCase):
|
||||
exchange_currency=self.exchange_currency,
|
||||
)
|
||||
self.assertEqual(account.exchange_currency, self.exchange_currency)
|
||||
|
||||
|
||||
class GetAccountBalanceServiceTests(TestCase):
|
||||
"""Tests for the get_account_balance service function"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
from apps.transactions.models import Transaction
|
||||
self.Transaction = Transaction
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="BRL", name="Brazilian Real", decimal_places=2, prefix="R$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Service Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Service Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
|
||||
def test_balance_with_no_transactions(self):
|
||||
"""Test balance is 0 when no transactions exist"""
|
||||
from apps.accounts.services import get_account_balance
|
||||
from decimal import Decimal
|
||||
|
||||
balance = get_account_balance(self.account, paid_only=True)
|
||||
self.assertEqual(balance, Decimal("0"))
|
||||
|
||||
def test_current_balance_only_counts_paid(self):
|
||||
"""Test current balance only counts paid transactions"""
|
||||
from apps.accounts.services import get_account_balance
|
||||
from decimal import Decimal
|
||||
|
||||
# Paid income
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid income",
|
||||
)
|
||||
# Unpaid income (should not count)
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("50.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 1),
|
||||
description="Unpaid income",
|
||||
)
|
||||
# Paid expense
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.EXPENSE,
|
||||
amount=Decimal("30.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid expense",
|
||||
)
|
||||
|
||||
balance = get_account_balance(self.account, paid_only=True)
|
||||
self.assertEqual(balance, Decimal("70.00")) # 100 - 30
|
||||
|
||||
def test_projected_balance_counts_all(self):
|
||||
"""Test projected balance counts all transactions"""
|
||||
from apps.accounts.services import get_account_balance
|
||||
from decimal import Decimal
|
||||
|
||||
# Paid income
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid income",
|
||||
)
|
||||
# Unpaid income
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("50.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 1),
|
||||
description="Unpaid income",
|
||||
)
|
||||
# Paid expense
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.EXPENSE,
|
||||
amount=Decimal("30.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid expense",
|
||||
)
|
||||
# Unpaid expense
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.EXPENSE,
|
||||
amount=Decimal("20.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 1),
|
||||
description="Unpaid expense",
|
||||
)
|
||||
|
||||
balance = get_account_balance(self.account, paid_only=False)
|
||||
self.assertEqual(balance, Decimal("100.00")) # (100 + 50) - (30 + 20)
|
||||
|
||||
def test_balance_defaults_to_paid_only(self):
|
||||
"""Test that paid_only defaults to True"""
|
||||
from apps.accounts.services import get_account_balance
|
||||
from decimal import Decimal
|
||||
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid",
|
||||
)
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("50.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 1),
|
||||
description="Unpaid",
|
||||
)
|
||||
|
||||
balance = get_account_balance(self.account) # defaults to paid_only=True
|
||||
self.assertEqual(balance, Decimal("100.00"))
|
||||
|
||||
|
||||
@@ -11,23 +11,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.forms import AccountBalanceFormSet
|
||||
from apps.accounts.models import Account, Transaction
|
||||
from apps.accounts.services import get_account_balance
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
def account_reconciliation(request):
|
||||
def get_account_balance(account):
|
||||
income = Transaction.objects.filter(
|
||||
account=account, type=Transaction.Type.INCOME, is_paid=True
|
||||
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
expense = Transaction.objects.filter(
|
||||
account=account, type=Transaction.Type.EXPENSE, is_paid=True
|
||||
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
return income - expense
|
||||
|
||||
initial_data = [
|
||||
{
|
||||
"account_id": account.id,
|
||||
|
||||
@@ -10,15 +10,19 @@ from apps.transactions.models import (
|
||||
|
||||
@extend_schema_field(
|
||||
{
|
||||
"oneOf": [{"type": "string"}, {"type": "integer"}],
|
||||
"description": "TransactionCategory ID or name. If the name doesn't exist, a new one will be created",
|
||||
"oneOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}],
|
||||
"description": "TransactionCategory ID or name. If the name doesn't exist, a new one will be created. Can be null if no category is assigned.",
|
||||
}
|
||||
)
|
||||
class TransactionCategoryField(serializers.Field):
|
||||
def to_representation(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
return {"id": value.id, "name": value.name}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data is None:
|
||||
return None
|
||||
if isinstance(data, int):
|
||||
try:
|
||||
return TransactionCategory.objects.get(pk=data)
|
||||
|
||||
@@ -2,3 +2,5 @@ from .transactions import *
|
||||
from .accounts import *
|
||||
from .currencies import *
|
||||
from .dca import *
|
||||
from .imports import *
|
||||
|
||||
|
||||
@@ -67,3 +67,12 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class AccountBalanceSerializer(serializers.Serializer):
|
||||
"""Serializer for account balance response."""
|
||||
|
||||
current_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
|
||||
projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
|
||||
currency = CurrencySerializer()
|
||||
|
||||
|
||||
41
app/apps/api/serializers/imports.py
Normal file
41
app/apps/api/serializers/imports.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
|
||||
|
||||
class ImportProfileSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for listing import profiles."""
|
||||
|
||||
class Meta:
|
||||
model = ImportProfile
|
||||
fields = ["id", "name", "version", "yaml_config"]
|
||||
|
||||
|
||||
class ImportRunSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for listing import runs."""
|
||||
|
||||
class Meta:
|
||||
model = ImportRun
|
||||
fields = [
|
||||
"id",
|
||||
"status",
|
||||
"profile",
|
||||
"file_name",
|
||||
"logs",
|
||||
"processed_rows",
|
||||
"total_rows",
|
||||
"successful_rows",
|
||||
"skipped_rows",
|
||||
"failed_rows",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
]
|
||||
|
||||
|
||||
class ImportFileSerializer(serializers.Serializer):
|
||||
"""Serializer for uploading a file to import using an existing profile."""
|
||||
|
||||
profile_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ImportProfile.objects.all(), source="profile"
|
||||
)
|
||||
file = serializers.FileField()
|
||||
4
app/apps/api/tests/__init__.py
Normal file
4
app/apps/api/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Import all test classes for Django test discovery
|
||||
from .test_imports import *
|
||||
from .test_accounts import *
|
||||
|
||||
99
app/apps/api/tests/test_accounts.py
Normal file
99
app/apps/api/tests/test_accounts.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class AccountBalanceAPITests(TestCase):
|
||||
"""Tests for the Account Balance API endpoint"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
|
||||
# Create some transactions
|
||||
Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal("500.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid income",
|
||||
)
|
||||
Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal("200.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 15),
|
||||
description="Unpaid income",
|
||||
)
|
||||
Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 10),
|
||||
description="Paid expense",
|
||||
)
|
||||
|
||||
def test_get_balance_success(self):
|
||||
"""Test successful balance retrieval"""
|
||||
response = self.client.get(f"/api/accounts/{self.account.id}/balance/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("current_balance", response.data)
|
||||
self.assertIn("projected_balance", response.data)
|
||||
self.assertIn("currency", response.data)
|
||||
|
||||
# Current: 500 - 100 = 400
|
||||
self.assertEqual(Decimal(response.data["current_balance"]), Decimal("400.00"))
|
||||
# Projected: (500 + 200) - 100 = 600
|
||||
self.assertEqual(Decimal(response.data["projected_balance"]), Decimal("600.00"))
|
||||
|
||||
# Check currency data
|
||||
self.assertEqual(response.data["currency"]["code"], "USD")
|
||||
|
||||
def test_get_balance_nonexistent_account(self):
|
||||
"""Test balance for non-existent account returns 404"""
|
||||
response = self.client.get("/api/accounts/99999/balance/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_get_balance_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.get(
|
||||
f"/api/accounts/{self.account.id}/balance/"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
404
app/apps/api/tests/test_imports.py
Normal file
404
app/apps/api/tests/test_imports.py
Normal file
@@ -0,0 +1,404 @@
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class ImportAPITests(TestCase):
|
||||
"""Tests for the Import API endpoint"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Create a basic import profile with minimal valid YAML config
|
||||
self.profile = ImportProfile.objects.create(
|
||||
name="Test Profile",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_expense
|
||||
is_paid:
|
||||
detection_method: always_paid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
|
||||
@patch("apps.import_app.tasks.process_import.defer")
|
||||
@patch("django.core.files.storage.FileSystemStorage.save")
|
||||
@patch("django.core.files.storage.FileSystemStorage.path")
|
||||
def test_create_import_success(self, mock_path, mock_save, mock_defer):
|
||||
"""Test successful file upload creates ImportRun and queues task"""
|
||||
mock_save.return_value = "test_file.csv"
|
||||
mock_path.return_value = "/usr/src/app/temp/test_file.csv"
|
||||
|
||||
csv_content = b"date,description,amount,account\n2025-01-01,Test,100,Main"
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.csv", csv_content, content_type="text/csv"
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": self.profile.id, "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertIn("import_run_id", response.data)
|
||||
self.assertEqual(response.data["status"], "queued")
|
||||
|
||||
# Verify ImportRun was created
|
||||
import_run = ImportRun.objects.get(id=response.data["import_run_id"])
|
||||
self.assertEqual(import_run.profile, self.profile)
|
||||
self.assertEqual(import_run.file_name, "test_file.csv")
|
||||
|
||||
# Verify task was deferred
|
||||
mock_defer.assert_called_once_with(
|
||||
import_run_id=import_run.id,
|
||||
file_path="/usr/src/app/temp/test_file.csv",
|
||||
user_id=self.user.id,
|
||||
)
|
||||
|
||||
def test_create_import_missing_profile(self):
|
||||
"""Test request without profile_id returns 400"""
|
||||
csv_content = b"date,description,amount\n2025-01-01,Test,100"
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.csv", csv_content, content_type="text/csv"
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("profile_id", response.data)
|
||||
|
||||
def test_create_import_missing_file(self):
|
||||
"""Test request without file returns 400"""
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": self.profile.id},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("file", response.data)
|
||||
|
||||
def test_create_import_invalid_profile(self):
|
||||
"""Test request with non-existent profile returns 400"""
|
||||
csv_content = b"date,description,amount\n2025-01-01,Test,100"
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.csv", csv_content, content_type="text/csv"
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": 99999, "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("profile_id", response.data)
|
||||
|
||||
@patch("apps.import_app.tasks.process_import.defer")
|
||||
@patch("django.core.files.storage.FileSystemStorage.save")
|
||||
@patch("django.core.files.storage.FileSystemStorage.path")
|
||||
def test_create_import_xlsx(self, mock_path, mock_save, mock_defer):
|
||||
"""Test successful XLSX file upload"""
|
||||
mock_save.return_value = "test_file.xlsx"
|
||||
mock_path.return_value = "/usr/src/app/temp/test_file.xlsx"
|
||||
|
||||
# Create a simple XLSX-like content (just for the upload test)
|
||||
xlsx_content = BytesIO(b"PK\x03\x04") # XLSX files start with PK header
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.xlsx",
|
||||
xlsx_content.getvalue(),
|
||||
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": self.profile.id, "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertIn("import_run_id", response.data)
|
||||
|
||||
def test_unauthenticated_request(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
unauthenticated_client = APIClient()
|
||||
|
||||
csv_content = b"date,description,amount\n2025-01-01,Test,100"
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.csv", csv_content, content_type="text/csv"
|
||||
)
|
||||
|
||||
response = unauthenticated_client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": self.profile.id, "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class ImportProfileAPITests(TestCase):
|
||||
"""Tests for the Import Profile API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.profile1 = ImportProfile.objects.create(
|
||||
name="Profile 1",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_expense
|
||||
is_paid:
|
||||
detection_method: always_paid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
self.profile2 = ImportProfile.objects.create(
|
||||
name="Profile 2",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_income
|
||||
is_paid:
|
||||
detection_method: always_unpaid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
|
||||
def test_list_profiles(self):
|
||||
"""Test listing all profiles"""
|
||||
response = self.client.get("/api/import/profiles/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 2)
|
||||
self.assertEqual(len(response.data["results"]), 2)
|
||||
|
||||
def test_retrieve_profile(self):
|
||||
"""Test retrieving a specific profile"""
|
||||
response = self.client.get(f"/api/import/profiles/{self.profile1.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["id"], self.profile1.id)
|
||||
self.assertEqual(response.data["name"], "Profile 1")
|
||||
self.assertIn("yaml_config", response.data)
|
||||
|
||||
def test_retrieve_nonexistent_profile(self):
|
||||
"""Test retrieving a non-existent profile returns 404"""
|
||||
response = self.client.get("/api/import/profiles/99999/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_profiles_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.get("/api/import/profiles/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class ImportRunAPITests(TestCase):
|
||||
"""Tests for the Import Run API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.profile1 = ImportProfile.objects.create(
|
||||
name="Profile 1",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_expense
|
||||
is_paid:
|
||||
detection_method: always_paid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
self.profile2 = ImportProfile.objects.create(
|
||||
name="Profile 2",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_income
|
||||
is_paid:
|
||||
detection_method: always_unpaid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
|
||||
# Create import runs
|
||||
self.run1 = ImportRun.objects.create(
|
||||
profile=self.profile1,
|
||||
file_name="file1.csv",
|
||||
status=ImportRun.Status.FINISHED,
|
||||
)
|
||||
self.run2 = ImportRun.objects.create(
|
||||
profile=self.profile1,
|
||||
file_name="file2.csv",
|
||||
status=ImportRun.Status.QUEUED,
|
||||
)
|
||||
self.run3 = ImportRun.objects.create(
|
||||
profile=self.profile2,
|
||||
file_name="file3.csv",
|
||||
status=ImportRun.Status.FINISHED,
|
||||
)
|
||||
|
||||
def test_list_all_runs(self):
|
||||
"""Test listing all runs"""
|
||||
response = self.client.get("/api/import/runs/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 3)
|
||||
self.assertEqual(len(response.data["results"]), 3)
|
||||
|
||||
def test_list_runs_by_profile(self):
|
||||
"""Test filtering runs by profile_id"""
|
||||
response = self.client.get(f"/api/import/runs/?profile_id={self.profile1.id}")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 2)
|
||||
for run in response.data["results"]:
|
||||
self.assertEqual(run["profile"], self.profile1.id)
|
||||
|
||||
def test_list_runs_by_other_profile(self):
|
||||
"""Test filtering runs by another profile_id"""
|
||||
response = self.client.get(f"/api/import/runs/?profile_id={self.profile2.id}")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertEqual(response.data["results"][0]["profile"], self.profile2.id)
|
||||
|
||||
def test_retrieve_run(self):
|
||||
"""Test retrieving a specific run"""
|
||||
response = self.client.get(f"/api/import/runs/{self.run1.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["id"], self.run1.id)
|
||||
self.assertEqual(response.data["file_name"], "file1.csv")
|
||||
self.assertEqual(response.data["status"], "FINISHED")
|
||||
|
||||
def test_retrieve_nonexistent_run(self):
|
||||
"""Test retrieving a non-existent run returns 404"""
|
||||
response = self.client.get("/api/import/runs/99999/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_runs_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.get("/api/import/runs/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
@@ -16,7 +16,11 @@ router.register(r"currencies", views.CurrencyViewSet)
|
||||
router.register(r"exchange-rates", views.ExchangeRateViewSet)
|
||||
router.register(r"dca/strategies", views.DCAStrategyViewSet)
|
||||
router.register(r"dca/entries", views.DCAEntryViewSet)
|
||||
router.register(r"import/profiles", views.ImportProfileViewSet, basename="import-profiles")
|
||||
router.register(r"import/runs", views.ImportRunViewSet, basename="import-runs")
|
||||
router.register(r"import/import", views.ImportViewSet, basename="import-import")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -2,3 +2,5 @@ from .transactions import *
|
||||
from .accounts import *
|
||||
from .currencies import *
|
||||
from .dca import *
|
||||
from .imports import *
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||
from apps.accounts.models import AccountGroup, Account
|
||||
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
||||
from apps.accounts.services import get_account_balance
|
||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||
from apps.api.serializers import AccountGroupSerializer, AccountSerializer, AccountBalanceSerializer
|
||||
|
||||
|
||||
class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing account groups."""
|
||||
|
||||
queryset = AccountGroup.objects.all()
|
||||
serializer_class = AccountGroupSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
@@ -14,7 +21,16 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
return AccountGroup.objects.all().order_by("id")
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
balance=extend_schema(
|
||||
summary="Get account balance",
|
||||
description="Returns the current and projected balance for the account, along with currency data.",
|
||||
responses={200: AccountBalanceSerializer},
|
||||
),
|
||||
)
|
||||
class AccountViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing accounts."""
|
||||
|
||||
queryset = Account.objects.all()
|
||||
serializer_class = AccountSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
@@ -25,3 +41,20 @@ class AccountViewSet(viewsets.ModelViewSet):
|
||||
.order_by("id")
|
||||
.select_related("group", "currency", "exchange_currency")
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
|
||||
def balance(self, request, pk=None):
|
||||
"""Get current and projected balance for an account."""
|
||||
account = self.get_object()
|
||||
|
||||
current_balance = get_account_balance(account, paid_only=True)
|
||||
projected_balance = get_account_balance(account, paid_only=False)
|
||||
|
||||
serializer = AccountBalanceSerializer({
|
||||
"current_balance": current_balance,
|
||||
"projected_balance": projected_balance,
|
||||
"currency": account.currency,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
123
app/apps/api/views/imports.py
Normal file
123
app/apps/api/views/imports.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view, inline_serializer
|
||||
from rest_framework import serializers as drf_serializers
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.api.serializers import ImportFileSerializer, ImportProfileSerializer, ImportRunSerializer
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.tasks import process_import
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List import profiles",
|
||||
description="Returns a paginated list of all available import profiles.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get import profile",
|
||||
description="Returns the details of a specific import profile by ID.",
|
||||
),
|
||||
)
|
||||
class ImportProfileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for listing and retrieving import profiles."""
|
||||
|
||||
queryset = ImportProfile.objects.all()
|
||||
serializer_class = ImportProfileSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List import runs",
|
||||
description="Returns a paginated list of import runs. Optionally filter by profile_id.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="profile_id",
|
||||
type=int,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter runs by profile ID",
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get import run",
|
||||
description="Returns the details of a specific import run by ID, including status and logs.",
|
||||
),
|
||||
)
|
||||
class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for listing and retrieving import runs."""
|
||||
|
||||
queryset = ImportRun.objects.all().order_by("-id")
|
||||
serializer_class = ImportRunSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
profile_id = self.request.query_params.get("profile_id")
|
||||
if profile_id:
|
||||
queryset = queryset.filter(profile_id=profile_id)
|
||||
return queryset
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
create=extend_schema(
|
||||
summary="Import file",
|
||||
description="Upload a CSV or XLSX file to import using an existing import profile. The import is queued and processed asynchronously.",
|
||||
request={
|
||||
"multipart/form-data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {"type": "integer", "description": "ID of the ImportProfile to use"},
|
||||
"file": {"type": "string", "format": "binary", "description": "CSV or XLSX file to import"},
|
||||
},
|
||||
"required": ["profile_id", "file"],
|
||||
},
|
||||
},
|
||||
responses={
|
||||
202: inline_serializer(
|
||||
name="ImportResponse",
|
||||
fields={
|
||||
"import_run_id": drf_serializers.IntegerField(),
|
||||
"status": drf_serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class ImportViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for importing data via file upload."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser]
|
||||
|
||||
def create(self, request):
|
||||
serializer = ImportFileSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
profile = serializer.validated_data["profile"]
|
||||
uploaded_file = serializer.validated_data["file"]
|
||||
|
||||
# Save file to temp location
|
||||
fs = FileSystemStorage(location="/usr/src/app/temp")
|
||||
filename = fs.save(uploaded_file.name, uploaded_file)
|
||||
file_path = fs.path(filename)
|
||||
|
||||
# Create ImportRun record
|
||||
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
|
||||
# Queue import task
|
||||
process_import.defer(
|
||||
import_run_id=import_run.id,
|
||||
file_path=file_path,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"import_run_id": import_run.id, "status": "queued"},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
@@ -32,7 +32,7 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
||||
transaction_created.send(sender=instance)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
|
||||
old_data = deepcopy(self.get_object())
|
||||
instance = serializer.save()
|
||||
transaction_updated.send(sender=instance, old_data=old_data)
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.exceptions import ValidationError
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
|
||||
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import HTML, Div, Field, Layout, Submit
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -39,6 +38,7 @@ class SharedObjectForm(forms.Form):
|
||||
choices=SharedObject.Visibility.choices,
|
||||
required=True,
|
||||
label=_("Visibility"),
|
||||
widget=TomSelect(clear_button=False),
|
||||
help_text=_(
|
||||
"Private: Only shown for the owner and shared users. Only editable by the owner."
|
||||
"<br/>"
|
||||
@@ -48,9 +48,6 @@ class SharedObjectForm(forms.Form):
|
||||
|
||||
class Meta:
|
||||
fields = ["visibility", "shared_with_users"]
|
||||
widgets = {
|
||||
"visibility": TomSelect(clear_button=False),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Get the current user to filter available sharing options
|
||||
@@ -73,12 +70,10 @@ class SharedObjectForm(forms.Form):
|
||||
self.helper.layout = Layout(
|
||||
Field("owner"),
|
||||
Field("visibility"),
|
||||
HTML("<hr>"),
|
||||
HTML('<hr class="hr my-3">'),
|
||||
Field("shared_with_users"),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 4 * * *")
|
||||
@app.task(queueing_lock="remove_old_jobs", pass_context=True, name="remove_old_jobs")
|
||||
@app.task(
|
||||
lock="remove_old_jobs",
|
||||
queueing_lock="remove_old_jobs",
|
||||
pass_context=True,
|
||||
name="remove_old_jobs",
|
||||
)
|
||||
async def remove_old_jobs(context, timestamp):
|
||||
try:
|
||||
return await builtin_tasks.remove_old_jobs(
|
||||
@@ -36,7 +41,11 @@ async def remove_old_jobs(context, timestamp):
|
||||
|
||||
|
||||
@app.periodic(cron="0 6 1 * *")
|
||||
@app.task(queueing_lock="remove_expired_sessions", name="remove_expired_sessions")
|
||||
@app.task(
|
||||
lock="remove_expired_sessions",
|
||||
queueing_lock="remove_expired_sessions",
|
||||
name="remove_expired_sessions",
|
||||
)
|
||||
async def remove_expired_sessions(timestamp=None):
|
||||
"""Cleanup expired sessions by using Django management command."""
|
||||
try:
|
||||
@@ -49,7 +58,7 @@ async def remove_expired_sessions(timestamp=None):
|
||||
|
||||
|
||||
@app.periodic(cron="0 8 * * *")
|
||||
@app.task(name="reset_demo_data")
|
||||
@app.task(lock="reset_demo_data", name="reset_demo_data")
|
||||
def reset_demo_data(timestamp=None):
|
||||
"""
|
||||
Wipes the database and loads fresh demo data if DEMO mode is active.
|
||||
@@ -86,9 +95,7 @@ def reset_demo_data(timestamp=None):
|
||||
|
||||
|
||||
@app.periodic(cron="0 */12 * * *") # Every 12 hours
|
||||
@app.task(
|
||||
name="check_for_updates",
|
||||
)
|
||||
@app.task(lock="check_for_updates", name="check_for_updates")
|
||||
def check_for_updates(timestamp=None):
|
||||
if not settings.CHECK_FOR_UPDATES:
|
||||
return "CHECK_FOR_UPDATES is disabled"
|
||||
|
||||
13
app/apps/common/templatetags/crispy_extra.py
Normal file
13
app/apps/common/templatetags/crispy_extra.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django import forms, template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def is_input(field):
|
||||
return isinstance(field.field.widget, forms.TextInput)
|
||||
|
||||
|
||||
@register.filter
|
||||
def is_textarea(field):
|
||||
return isinstance(field.field.widget, forms.Textarea)
|
||||
@@ -11,7 +11,7 @@ def toast_bg(tags):
|
||||
elif "warning" in tags:
|
||||
return "warning"
|
||||
elif "error" in tags:
|
||||
return "danger"
|
||||
return "error"
|
||||
elif "info" in tags:
|
||||
return "info"
|
||||
|
||||
|
||||
5
app/apps/common/widgets/crispy/daisyui.py
Normal file
5
app/apps/common/widgets/crispy/daisyui.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from crispy_forms.layout import Field
|
||||
|
||||
|
||||
class Switch(Field):
|
||||
template = "crispy-daisyui/layout/switch.html"
|
||||
@@ -1,15 +1,14 @@
|
||||
import datetime
|
||||
|
||||
from django.forms import widgets
|
||||
from django.utils import formats, translation, dates
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.functions.format import get_format
|
||||
from apps.common.utils.django import (
|
||||
django_to_python_datetime,
|
||||
django_to_airdatepicker_datetime,
|
||||
django_to_airdatepicker_datetime_separated,
|
||||
django_to_python_datetime,
|
||||
)
|
||||
from apps.common.functions.format import get_format
|
||||
from django.forms import widgets
|
||||
from django.utils import dates, formats, translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class AirDatePickerInput(widgets.DateInput):
|
||||
@@ -52,6 +51,8 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
attrs["class"] = attrs.get("class", "") + " input"
|
||||
|
||||
attrs["data-now-button-txt"] = _("Today")
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.forms import widgets, SelectMultiple
|
||||
from django.forms import SelectMultiple, widgets
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -17,7 +17,7 @@ class TomSelect(widgets.Select):
|
||||
checkboxes=False,
|
||||
group_by=None,
|
||||
*args,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(attrs, *args, **kwargs)
|
||||
self.remove_button = remove_button
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Row, Column
|
||||
from django import forms
|
||||
from django.forms import CharField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.daisyui import Switch
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDateTimePickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Column, Layout, Row
|
||||
from django import forms
|
||||
from django.forms import CharField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class CurrencyForm(forms.ModelForm):
|
||||
@@ -51,17 +50,13 @@ class CurrencyForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -89,17 +84,13 @@ class ExchangeRateForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -132,8 +123,8 @@ class ExchangeRateServiceForm(forms.ModelForm):
|
||||
Switch("singleton"),
|
||||
"api_key",
|
||||
Row(
|
||||
Column("interval_type", css_class="form-group col-md-6"),
|
||||
Column("fetch_interval", css_class="form-group col-md-6"),
|
||||
Column("interval_type"),
|
||||
Column("fetch_interval"),
|
||||
),
|
||||
"target_currencies",
|
||||
"target_accounts",
|
||||
@@ -142,16 +133,12 @@ class ExchangeRateServiceForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 * * * *") # Run every hour
|
||||
@app.task(name="automatic_fetch_exchange_rates")
|
||||
@app.task(lock="automatic_fetch_exchange_rates", name="automatic_fetch_exchange_rates")
|
||||
def automatic_fetch_exchange_rates(timestamp=None):
|
||||
"""Fetch exchange rates for all due services"""
|
||||
fetcher = ExchangeRateFetcher()
|
||||
@@ -19,7 +19,7 @@ def automatic_fetch_exchange_rates(timestamp=None):
|
||||
logger.error(e, exc_info=True)
|
||||
|
||||
|
||||
@app.task(name="manual_fetch_exchange_rates")
|
||||
@app.task(lock="manual_fetch_exchange_rates", name="manual_fetch_exchange_rates")
|
||||
def manual_fetch_exchange_rates(timestamp=None):
|
||||
"""Fetch exchange rates for all due services"""
|
||||
fetcher = ExchangeRateFetcher()
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Row, Column, HTML
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.common.widgets.tom_select import TransactionSelect
|
||||
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
|
||||
from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
from apps.common.widgets.crispy.daisyui import Switch
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||
from apps.dca.models import DCAEntry, DCAStrategy
|
||||
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
|
||||
from crispy_forms.bootstrap import AccordionGroup, FormActions, Accordion
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import HTML, Column, Layout, Row
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class DCAStrategyForm(forms.ModelForm):
|
||||
@@ -36,8 +34,8 @@ class DCAStrategyForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
"name",
|
||||
Row(
|
||||
Column("payment_currency", css_class="form-group col-md-6"),
|
||||
Column("target_currency", css_class="form-group col-md-6"),
|
||||
Column("payment_currency"),
|
||||
Column("target_currency"),
|
||||
),
|
||||
"notes",
|
||||
)
|
||||
@@ -45,17 +43,13 @@ class DCAStrategyForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -155,11 +149,11 @@ class DCAEntryForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
"date",
|
||||
Row(
|
||||
Column("amount_paid", css_class="form-group col-md-6"),
|
||||
Column("amount_received", css_class="form-group col-md-6"),
|
||||
Column("amount_paid"),
|
||||
Column("amount_received"),
|
||||
),
|
||||
"notes",
|
||||
BS5Accordion(
|
||||
Accordion(
|
||||
AccordionGroup(
|
||||
_("Create transaction"),
|
||||
Switch("create_transaction"),
|
||||
@@ -168,19 +162,11 @@ class DCAEntryForm(forms.ModelForm):
|
||||
Row(
|
||||
Column(
|
||||
"from_account",
|
||||
css_class="form-group",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
"from_category",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
"from_tags", css_class="form-group col-md-6 mb-0"
|
||||
),
|
||||
css_class="form-row",
|
||||
Column("from_category"),
|
||||
Column("from_tags"),
|
||||
),
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
@@ -192,14 +178,10 @@ class DCAEntryForm(forms.ModelForm):
|
||||
"to_account",
|
||||
css_class="form-group",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
"to_category", css_class="form-group col-md-6 mb-0"
|
||||
),
|
||||
Column("to_tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("to_category"),
|
||||
Column("to_tags"),
|
||||
),
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
@@ -220,17 +202,13 @@ class DCAEntryForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, HTML
|
||||
from crispy_forms.layout import HTML, Layout
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
|
||||
|
||||
class ExportForm(forms.Form):
|
||||
users = forms.BooleanField(
|
||||
@@ -115,9 +114,7 @@ class ExportForm(forms.Form):
|
||||
"dca",
|
||||
"import_profiles",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Export"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -162,7 +159,7 @@ class RestoreForm(forms.Form):
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"zip_file",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3"/>'),
|
||||
"users",
|
||||
"accounts",
|
||||
"currencies",
|
||||
@@ -181,9 +178,7 @@ class RestoreForm(forms.Form):
|
||||
"dca_entries",
|
||||
"import_profiles",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Restore"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.import_app.models import ImportProfile
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import (
|
||||
@@ -6,9 +8,6 @@ from crispy_forms.layout import (
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.import_app.models import ImportProfile
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
|
||||
|
||||
class ImportProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
@@ -30,17 +29,13 @@ class ImportProfileForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,8 +52,6 @@ class ImportRunFileUploadForm(forms.Form):
|
||||
self.helper.layout = Layout(
|
||||
"file",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Import"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Import"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -459,12 +459,13 @@ class ImportService:
|
||||
# Build query conditions for each field in the rule
|
||||
for field in rule.fields:
|
||||
if field in transaction_data:
|
||||
if rule.match_type == "strict":
|
||||
query = query.filter(**{field: transaction_data[field]})
|
||||
else: # lax matching
|
||||
query = query.filter(
|
||||
**{f"{field}__iexact": transaction_data[field]}
|
||||
)
|
||||
value = transaction_data[field]
|
||||
# Use __iexact only for string fields; non-string types
|
||||
# (date, Decimal, bool, int, etc.) don't support UPPER()
|
||||
if rule.match_type == "strict" or not isinstance(value, str):
|
||||
query = query.filter(**{field: value})
|
||||
else: # lax matching for strings only
|
||||
query = query.filter(**{f"{field}__iexact": value})
|
||||
|
||||
# If we found any matching transaction, it's a duplicate
|
||||
if query.exists():
|
||||
@@ -475,11 +476,27 @@ class ImportService:
|
||||
def _coerce_type(
|
||||
self, value: str, mapping: version_1.ColumnMapping
|
||||
) -> Union[str, int, bool, Decimal, datetime, list, None]:
|
||||
coerce_to = mapping.coerce_to
|
||||
|
||||
# Handle detection methods that don't require a source value
|
||||
if coerce_to == "transaction_type" and isinstance(
|
||||
mapping, version_1.TransactionTypeMapping
|
||||
):
|
||||
if mapping.detection_method == "always_income":
|
||||
return Transaction.Type.INCOME
|
||||
elif mapping.detection_method == "always_expense":
|
||||
return Transaction.Type.EXPENSE
|
||||
elif coerce_to == "is_paid" and isinstance(
|
||||
mapping, version_1.TransactionIsPaidMapping
|
||||
):
|
||||
if mapping.detection_method == "always_paid":
|
||||
return True
|
||||
elif mapping.detection_method == "always_unpaid":
|
||||
return False
|
||||
|
||||
if not value:
|
||||
return None
|
||||
|
||||
coerce_to = mapping.coerce_to
|
||||
|
||||
return self._coerce_single_type(value, coerce_to, mapping)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
276
app/apps/import_app/tests/test_import_service_v1.py
Normal file
276
app/apps/import_app/tests/test_import_service_v1.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Tests for ImportService v1, specifically for deduplication logic.
|
||||
|
||||
These tests verify that the _check_duplicate_transaction method handles
|
||||
different field types correctly, particularly ensuring that __iexact
|
||||
is only used for string fields (not dates, decimals, etc.).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.services.v1 import ImportService
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
class DeduplicationTests(TestCase):
|
||||
"""Tests for transaction deduplication during import."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
|
||||
# Create an existing transaction for deduplication tests
|
||||
self.existing_transaction = Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
date=date(2024, 1, 15),
|
||||
amount=Decimal("100.00"),
|
||||
description="Existing Transaction",
|
||||
internal_id="ABC123",
|
||||
)
|
||||
|
||||
def _create_import_service_with_deduplication(
|
||||
self, fields: list[str], match_type: str = "lax"
|
||||
) -> ImportService:
|
||||
"""Helper to create an ImportService with specific deduplication rules."""
|
||||
yaml_config = f"""
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: transactions
|
||||
trigger_transaction_rules: false
|
||||
mapping:
|
||||
date_field:
|
||||
source: date
|
||||
target: date
|
||||
format: "%Y-%m-%d"
|
||||
amount_field:
|
||||
source: amount
|
||||
target: amount
|
||||
description_field:
|
||||
source: description
|
||||
target: description
|
||||
account_field:
|
||||
source: account
|
||||
target: account
|
||||
type: id
|
||||
deduplication:
|
||||
- type: compare
|
||||
fields: {fields}
|
||||
match_type: {match_type}
|
||||
"""
|
||||
profile = ImportProfile.objects.create(
|
||||
name=f"Test Profile {match_type} {'_'.join(fields)}",
|
||||
yaml_config=yaml_config,
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
)
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile,
|
||||
file_name="test.csv",
|
||||
)
|
||||
return ImportService(import_run)
|
||||
|
||||
def test_deduplication_with_date_field_strict_match(self):
|
||||
"""Test that date fields work with strict matching."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date"], match_type="strict"
|
||||
)
|
||||
|
||||
# Should find duplicate when date matches
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_date_field_lax_match(self):
|
||||
"""
|
||||
Test that date fields use strict matching even when match_type is 'lax'.
|
||||
|
||||
This is the fix for the UPPER(date) PostgreSQL error. Date fields
|
||||
cannot use __iexact, so they should fall back to strict matching.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when date matches (using strict comparison)
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_amount_field_lax_match(self):
|
||||
"""
|
||||
Test that Decimal fields use strict matching even when match_type is 'lax'.
|
||||
|
||||
Decimal fields cannot use __iexact, so they should fall back to strict matching.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["amount"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when amount matches
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"amount": Decimal("100.00")}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when amount differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"amount": Decimal("200.00")}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_string_field_lax_match(self):
|
||||
"""
|
||||
Test that string fields use case-insensitive matching with match_type 'lax'.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["description"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate with case-insensitive match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "EXISTING TRANSACTION"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact case match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Existing Transaction"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when description differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Different Transaction"}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_string_field_strict_match(self):
|
||||
"""
|
||||
Test that string fields use case-sensitive matching with match_type 'strict'.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["description"], match_type="strict"
|
||||
)
|
||||
|
||||
# Should NOT find duplicate with different case (strict matching)
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "EXISTING TRANSACTION"}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact case match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Existing Transaction"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
def test_deduplication_with_multiple_fields_mixed_types(self):
|
||||
"""
|
||||
Test deduplication with multiple fields of different types.
|
||||
|
||||
Verifies that string fields use __iexact while non-string fields
|
||||
use strict matching, all in the same deduplication rule.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "amount", "description"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when all fields match (with case-insensitive description)
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("100.00"),
|
||||
"description": "existing transaction", # lowercase should match
|
||||
}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should NOT find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 2, 20),
|
||||
"amount": Decimal("100.00"),
|
||||
"description": "existing transaction",
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
# Should NOT find duplicate when amount differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("999.99"),
|
||||
"description": "existing transaction",
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_internal_id_lax_match(self):
|
||||
"""Test deduplication with internal_id field using lax matching."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["internal_id"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate with case-insensitive match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"internal_id": "abc123"} # lowercase should match ABC123
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact match
|
||||
is_duplicate = service._check_duplicate_transaction({"internal_id": "ABC123"})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when internal_id differs
|
||||
is_duplicate = service._check_duplicate_transaction({"internal_id": "XYZ789"})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_no_duplicate_when_no_transactions_exist(self):
|
||||
"""Test that no duplicate is found when there are no matching transactions."""
|
||||
# Hard delete to bypass signals that require user context
|
||||
self.existing_transaction.hard_delete()
|
||||
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "amount"], match_type="lax"
|
||||
)
|
||||
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("100.00"),
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_missing_field_in_data(self):
|
||||
"""Test that missing fields in transaction_data are handled gracefully."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "nonexistent_field"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should still work, only checking the fields that exist
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
@@ -1,15 +1,14 @@
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Row, Column
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.datepicker import (
|
||||
AirDatePickerInput,
|
||||
AirMonthYearPickerInput,
|
||||
AirYearPickerInput,
|
||||
AirDatePickerInput,
|
||||
)
|
||||
from apps.transactions.models import TransactionCategory
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.transactions.models import TransactionCategory
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Column, Field, Layout, Row
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class SingleMonthForm(forms.Form):
|
||||
@@ -59,8 +58,8 @@ class MonthRangeForm(forms.Form):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("month_from", css_class="form-group col-md-6"),
|
||||
Column("month_to", css_class="form-group col-md-6"),
|
||||
Column("month_from"),
|
||||
Column("month_to"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -82,8 +81,8 @@ class YearRangeForm(forms.Form):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("year_from", css_class="form-group col-md-6"),
|
||||
Column("year_to", css_class="form-group col-md-6"),
|
||||
Column("year_from"),
|
||||
Column("year_to"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -105,8 +104,8 @@ class DateRangeForm(forms.Form):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("date_from", css_class="form-group col-md-6"),
|
||||
Column("date_to", css_class="form-group col-md-6"),
|
||||
Column("date_from"),
|
||||
Column("date_to"),
|
||||
css_class="mb-0",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -74,7 +74,9 @@ def index(request):
|
||||
def sankey_by_account(request):
|
||||
# Get filtered transactions
|
||||
|
||||
transactions = get_transactions(request, include_untracked_accounts=True)
|
||||
transactions = get_transactions(
|
||||
request, include_untracked_accounts=True, include_silent=True
|
||||
)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_account(transactions)
|
||||
@@ -91,7 +93,9 @@ def sankey_by_account(request):
|
||||
@require_http_methods(["GET"])
|
||||
def sankey_by_currency(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request)
|
||||
transactions = get_transactions(
|
||||
request, include_silent=True, include_untracked_accounts=True
|
||||
)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_currency(transactions)
|
||||
|
||||
@@ -182,3 +182,29 @@ def calculate_historical_account_balance(queryset):
|
||||
historical_account_balance[date_filter(end_date, "b Y")] = month_data
|
||||
|
||||
return historical_account_balance
|
||||
|
||||
|
||||
def calculate_monthly_net_worth_difference(historical_net_worth):
|
||||
diff_dict = OrderedDict()
|
||||
if not historical_net_worth:
|
||||
return diff_dict
|
||||
|
||||
# Get all currencies
|
||||
currencies = set()
|
||||
for data in historical_net_worth.values():
|
||||
currencies.update(data.keys())
|
||||
|
||||
# Initialize prev_values for all currencies
|
||||
prev_values = {currency: Decimal("0.00") for currency in currencies}
|
||||
|
||||
for month, values in historical_net_worth.items():
|
||||
diff_values = {}
|
||||
for currency in sorted(list(currencies)):
|
||||
current_val = values.get(currency, Decimal("0.00"))
|
||||
prev_val = prev_values.get(currency, Decimal("0.00"))
|
||||
diff_values[currency] = current_val - prev_val
|
||||
|
||||
diff_dict[month] = diff_values
|
||||
prev_values = values.copy()
|
||||
|
||||
return diff_dict
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.views.decorators.http import require_http_methods
|
||||
from apps.net_worth.utils.calculate_net_worth import (
|
||||
calculate_historical_currency_net_worth,
|
||||
calculate_historical_account_balance,
|
||||
calculate_monthly_net_worth_difference,
|
||||
)
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.utils.calculations import (
|
||||
@@ -96,6 +97,38 @@ def net_worth(request):
|
||||
|
||||
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
|
||||
|
||||
monthly_difference_data = calculate_monthly_net_worth_difference(
|
||||
historical_net_worth=historical_currency_net_worth
|
||||
)
|
||||
|
||||
diff_labels = (
|
||||
list(monthly_difference_data.keys()) if monthly_difference_data else []
|
||||
)
|
||||
diff_currencies = (
|
||||
list(monthly_difference_data[diff_labels[0]].keys())
|
||||
if monthly_difference_data and diff_labels
|
||||
else []
|
||||
)
|
||||
|
||||
diff_datasets = []
|
||||
for i, currency in enumerate(diff_currencies):
|
||||
data = [
|
||||
float(month_data.get(currency, 0))
|
||||
for month_data in monthly_difference_data.values()
|
||||
]
|
||||
diff_datasets.append(
|
||||
{
|
||||
"label": currency,
|
||||
"data": data,
|
||||
"borderWidth": 3,
|
||||
}
|
||||
)
|
||||
|
||||
chart_data_monthly_difference = {"labels": diff_labels, "datasets": diff_datasets}
|
||||
chart_data_monthly_difference_json = json.dumps(
|
||||
chart_data_monthly_difference, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
historical_account_balance = calculate_historical_account_balance(
|
||||
queryset=transactions_account_queryset
|
||||
)
|
||||
@@ -140,6 +173,7 @@ def net_worth(request):
|
||||
"chart_data_accounts_json": chart_data_accounts_json,
|
||||
"accounts": accounts,
|
||||
"type": view_type,
|
||||
"chart_data_monthly_difference_json": chart_data_monthly_difference_json,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||
from crispy_forms.bootstrap import Alert
|
||||
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
|
||||
from apps.common.widgets.crispy.daisyui import Switch
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
TransactionRuleAction,
|
||||
UpdateOrCreateTransactionRuleAction,
|
||||
)
|
||||
from apps.transactions.forms import BulkEditTransactionForm
|
||||
from apps.transactions.models import Transaction
|
||||
from crispy_forms.bootstrap import AccordionGroup, FormActions, Accordion
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Row, Column, HTML
|
||||
from crispy_forms.layout import HTML, Column, Field, Layout, Row
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
||||
from apps.rules.models import TransactionRuleAction
|
||||
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
|
||||
from apps.transactions.forms import BulkEditTransactionForm
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
class TransactionRuleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
@@ -35,7 +37,6 @@ class TransactionRuleForm(forms.ModelForm):
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
# TO-DO: Add helper with available commands
|
||||
self.helper.layout = Layout(
|
||||
Switch("active"),
|
||||
"name",
|
||||
@@ -48,22 +49,21 @@ class TransactionRuleForm(forms.ModelForm):
|
||||
Switch("sequenced"),
|
||||
"description",
|
||||
"trigger",
|
||||
Alert(
|
||||
_("You can add actions to this rule in the next screen."), dismiss=False
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -97,17 +97,13 @@ class TransactionRuleActionForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -214,148 +210,148 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
"order",
|
||||
BS5Accordion(
|
||||
Accordion(
|
||||
AccordionGroup(
|
||||
_("Search Criteria"),
|
||||
Field("filter", rows=1),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_type_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_type", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_is_paid_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_is_paid", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_mute_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_mute", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_account_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_account", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_entities_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_entities", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_date_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_date", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_reference_date_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_reference_date", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_description_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_description", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_amount_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_amount", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_category_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_category", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_tags_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_tags", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_notes_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_notes", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_internal_note_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_internal_note", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_internal_id_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
css_class="col-span-12 md:col-span-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_internal_id", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
css_class="col-span-12 md:col-span-8",
|
||||
),
|
||||
),
|
||||
active=True,
|
||||
@@ -386,17 +382,13 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -427,9 +419,7 @@ class DryRunCreatedTransacion(forms.Form):
|
||||
self.helper.layout = Layout(
|
||||
"transaction",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Test"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -464,9 +454,7 @@ class DryRunDeletedTransacion(forms.Form):
|
||||
self.helper.layout = Layout(
|
||||
"transaction",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Test"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -496,13 +484,11 @@ class DryRunUpdatedTransactionForm(BulkEditTransactionForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper.layout.insert(0, "transaction")
|
||||
self.helper.layout.insert(1, HTML("<hr/>"))
|
||||
self.helper.layout.insert(1, HTML('<hr class="hr my-3" />'))
|
||||
|
||||
# Change submit button
|
||||
self.helper.layout[-1] = FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
)
|
||||
NoClassSubmit("submit", _("Test"), css_class="btn btn-primary")
|
||||
)
|
||||
|
||||
if self.data.get("transaction"):
|
||||
|
||||
@@ -564,7 +564,7 @@ def dry_run_rule_updated(request, pk):
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
"rules/fragments/transaction_rule/dry_run/updated.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import django_filters
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Row, Column
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import Filter
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.fields.month_year import MonthYearFormField
|
||||
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||
@@ -15,9 +8,15 @@ from apps.currencies.models import Currency
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
TransactionEntity,
|
||||
TransactionTag,
|
||||
)
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Column, Field, Layout, Row
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import Filter
|
||||
|
||||
SITUACAO_CHOICES = (
|
||||
("1", _("Paid")),
|
||||
@@ -159,14 +158,12 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
Field("description"),
|
||||
Row(Column("date_start"), Column("date_end")),
|
||||
Row(
|
||||
Column("reference_date_start", css_class="form-group col-md-6 mb-0"),
|
||||
Column("reference_date_end", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("reference_date_start"),
|
||||
Column("reference_date_end"),
|
||||
),
|
||||
Row(
|
||||
Column("from_amount", css_class="form-group col-md-6 mb-0"),
|
||||
Column("to_amount", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("from_amount"),
|
||||
Column("to_amount"),
|
||||
),
|
||||
Field("account", size=1),
|
||||
Field("currency", size=1),
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import (
|
||||
Layout,
|
||||
Row,
|
||||
Column,
|
||||
Field,
|
||||
Div,
|
||||
HTML,
|
||||
)
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
from apps.common.widgets.crispy.daisyui import Switch
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
from apps.transactions.models import (
|
||||
InstallmentPlan,
|
||||
QuickTransaction,
|
||||
RecurringTransaction,
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
InstallmentPlan,
|
||||
RecurringTransaction,
|
||||
TransactionEntity,
|
||||
QuickTransaction,
|
||||
TransactionTag,
|
||||
)
|
||||
from crispy_forms.bootstrap import AccordionGroup, AppendedText, FormActions, Accordion
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import (
|
||||
HTML,
|
||||
Column,
|
||||
Div,
|
||||
Field,
|
||||
Layout,
|
||||
Row,
|
||||
)
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
@@ -134,21 +133,18 @@ class TransactionForm(forms.ModelForm):
|
||||
),
|
||||
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("account"),
|
||||
Column("entities"),
|
||||
),
|
||||
Row(
|
||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column(Field("date")),
|
||||
Column(Field("reference_date")),
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("category"),
|
||||
Column("tags"),
|
||||
),
|
||||
"notes",
|
||||
)
|
||||
@@ -164,20 +160,18 @@ class TransactionForm(forms.ModelForm):
|
||||
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||
"account",
|
||||
Row(
|
||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column(Field("date")),
|
||||
Column(Field("reference_date")),
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
BS5Accordion(
|
||||
Accordion(
|
||||
AccordionGroup(
|
||||
_("More"),
|
||||
"entities",
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("category"),
|
||||
Column("tags"),
|
||||
),
|
||||
"notes",
|
||||
active=False,
|
||||
@@ -187,9 +181,7 @@ class TransactionForm(forms.ModelForm):
|
||||
css_class="mb-3",
|
||||
),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -202,29 +194,25 @@ class TransactionForm(forms.ModelForm):
|
||||
)
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
Div(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
NoClassSubmit(
|
||||
"submit_and_similar",
|
||||
_("Save and add similar"),
|
||||
css_class="btn btn-outline-primary",
|
||||
css_class="btn btn-primary btn-soft",
|
||||
),
|
||||
NoClassSubmit(
|
||||
"submit_and_another",
|
||||
_("Save and add another"),
|
||||
css_class="btn btn-outline-primary",
|
||||
css_class="btn btn-primary btn-soft",
|
||||
),
|
||||
css_class="d-grid gap-2",
|
||||
css_class="flex flex-col gap-2 mt-3",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -348,18 +336,16 @@ class QuickTransactionForm(forms.ModelForm):
|
||||
),
|
||||
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||
"name",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("account"),
|
||||
Column("entities"),
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("category"),
|
||||
Column("tags"),
|
||||
),
|
||||
"notes",
|
||||
Switch("mute"),
|
||||
@@ -372,19 +358,14 @@ class QuickTransactionForm(forms.ModelForm):
|
||||
)
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
Div(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary"
|
||||
),
|
||||
css_class="d-grid gap-2",
|
||||
FormActions(
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -481,27 +462,22 @@ class BulkEditTransactionForm(forms.Form):
|
||||
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
||||
),
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("account"),
|
||||
Column("entities"),
|
||||
),
|
||||
Row(
|
||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column(Field("date")),
|
||||
Column(Field("reference_date")),
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("category"),
|
||||
Column("tags"),
|
||||
),
|
||||
"notes",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -600,62 +576,34 @@ class TransferForm(forms.Form):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||
Column(Field("date")),
|
||||
Column(
|
||||
Field("reference_date"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Field("description"),
|
||||
Field("notes"),
|
||||
Switch("mute"),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column(
|
||||
"from_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
Field("from_amount"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column("from_category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("from_tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
Column("from_account"),
|
||||
Column(Field("from_amount")),
|
||||
Column("from_category"),
|
||||
Column("from_tags"),
|
||||
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column(
|
||||
"to_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
Field("to_amount"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column("to_category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("to_tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"to_account",
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
Column(
|
||||
Field("to_amount"),
|
||||
),
|
||||
Column("to_category"),
|
||||
Column("to_tags"),
|
||||
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border",
|
||||
),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Transfer"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Transfer"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -841,30 +789,26 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("account"),
|
||||
Column("entities"),
|
||||
),
|
||||
"description",
|
||||
Switch("add_description_to_transaction"),
|
||||
"notes",
|
||||
Switch("add_notes_to_transaction"),
|
||||
Row(
|
||||
Column("number_of_installments", css_class="form-group col-md-6 mb-0"),
|
||||
Column("installment_start", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("number_of_installments"),
|
||||
Column("installment_start"),
|
||||
),
|
||||
Row(
|
||||
Column("start_date", css_class="form-group col-md-4 mb-0"),
|
||||
Column("reference_date", css_class="form-group col-md-4 mb-0"),
|
||||
Column("recurrence", css_class="form-group col-md-4 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("start_date", css_class="col-span-12 md:col-span-4"),
|
||||
Column("reference_date", css_class="col-span-12 md:col-span-4"),
|
||||
Column("recurrence", css_class="col-span-12 md:col-span-4"),
|
||||
),
|
||||
"installment_amount",
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("category"),
|
||||
Column("tags"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -874,17 +818,13 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -917,17 +857,13 @@ class TransactionTagForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -949,17 +885,13 @@ class TransactionEntityForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -984,17 +916,13 @@ class TransactionCategoryForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1103,30 +1031,26 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("account"),
|
||||
Column("entities"),
|
||||
),
|
||||
"description",
|
||||
Switch("add_description_to_transaction"),
|
||||
"amount",
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("category"),
|
||||
Column("tags"),
|
||||
),
|
||||
"notes",
|
||||
Switch("add_notes_to_transaction"),
|
||||
Row(
|
||||
Column("start_date", css_class="form-group col-md-6 mb-0"),
|
||||
Column("reference_date", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("start_date"),
|
||||
Column("reference_date"),
|
||||
),
|
||||
Row(
|
||||
Column("recurrence_interval", css_class="form-group col-md-4 mb-0"),
|
||||
Column("recurrence_type", css_class="form-group col-md-4 mb-0"),
|
||||
Column("end_date", css_class="form-group col-md-4 mb-0"),
|
||||
css_class="form-row",
|
||||
Column("recurrence_interval", css_class="col-span-12 md:col-span-4"),
|
||||
Column("recurrence_type", css_class="col-span-12 md:col-span-4"),
|
||||
Column("end_date", css_class="col-span-12 md:col-span-4"),
|
||||
),
|
||||
AppendedText("keep_at_most", _("future transactions")),
|
||||
)
|
||||
@@ -1138,17 +1062,13 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@ import decimal
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
from apps.common.fields.month_year import MonthYearModelField
|
||||
from apps.common.functions.decimals import truncate_decimal
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.common.models import (
|
||||
OwnedObject,
|
||||
OwnedObjectManager,
|
||||
SharedObject,
|
||||
SharedObjectManager,
|
||||
)
|
||||
from apps.common.templatetags.decimal import drop_trailing_zeros, localize_number
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
@@ -12,19 +24,6 @@ from django.template.defaultfilters import date
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.fields.month_year import MonthYearModelField
|
||||
from apps.common.functions.decimals import truncate_decimal
|
||||
from apps.common.templatetags.decimal import localize_number, drop_trailing_zeros
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.common.models import (
|
||||
SharedObject,
|
||||
SharedObjectManager,
|
||||
OwnedObject,
|
||||
OwnedObjectManager,
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
@@ -381,22 +380,37 @@ class Transaction(OwnedObject):
|
||||
db_table = "transactions"
|
||||
default_manager_name = "objects"
|
||||
|
||||
def clean_fields(self, *args, **kwargs):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Only process amount and reference_date if account exists
|
||||
# If account is missing, Django's required field validation will handle it
|
||||
try:
|
||||
account = self.account
|
||||
except Transaction.account.RelatedObjectDoesNotExist:
|
||||
# Account doesn't exist, skip processing that depends on it
|
||||
# Django will add the required field error
|
||||
return
|
||||
|
||||
# Validate and normalize amount
|
||||
if isinstance(self.amount, (str, int, float)):
|
||||
self.amount = decimal.Decimal(str(self.amount))
|
||||
|
||||
self.amount = truncate_decimal(
|
||||
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||
value=self.amount, decimal_places=account.currency.decimal_places
|
||||
)
|
||||
|
||||
# Normalize reference_date
|
||||
if self.reference_date:
|
||||
self.reference_date = self.reference_date.replace(day=1)
|
||||
elif not self.reference_date and self.date:
|
||||
self.reference_date = self.date.replace(day=1)
|
||||
|
||||
super().clean_fields(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# This is here so Django validation doesn't trigger an error before clean() is ran
|
||||
if not self.reference_date and self.date:
|
||||
self.reference_date = self.date.replace(day=1)
|
||||
|
||||
# This is not recommended as it will run twice on some cases like form and API saves.
|
||||
# We only do this here because we forgot to independently call it on multiple places.
|
||||
self.full_clean()
|
||||
@@ -856,10 +870,8 @@ class RecurringTransaction(models.Model):
|
||||
notes=self.notes if self.add_notes_to_transaction else "",
|
||||
owner=self.account.owner,
|
||||
)
|
||||
if self.tags.exists():
|
||||
created_transaction.tags.set(self.tags.all())
|
||||
if self.entities.exists():
|
||||
created_transaction.entities.set(self.entities.all())
|
||||
created_transaction.tags.set(self.tags.all())
|
||||
created_transaction.entities.set(self.entities.all())
|
||||
|
||||
def get_recurrence_delta(self):
|
||||
if self.recurrence_type == self.RecurrenceType.DAY:
|
||||
|
||||
@@ -13,7 +13,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 0 * * *")
|
||||
@app.task(name="generate_recurring_transactions")
|
||||
@app.task(
|
||||
lock="generate_recurring_transactions", name="generate_recurring_transactions"
|
||||
)
|
||||
def generate_recurring_transactions(timestamp=None):
|
||||
try:
|
||||
RecurringTransaction.generate_upcoming_transactions()
|
||||
@@ -26,7 +28,7 @@ def generate_recurring_transactions(timestamp=None):
|
||||
|
||||
|
||||
@app.periodic(cron="10 1 * * *")
|
||||
@app.task(name="cleanup_deleted_transactions")
|
||||
@app.task(lock="cleanup_deleted_transactions", name="cleanup_deleted_transactions")
|
||||
def cleanup_deleted_transactions(timestamp=None):
|
||||
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
|
||||
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
|
||||
|
||||
@@ -3,7 +3,6 @@ from decimal import Decimal
|
||||
from django import template
|
||||
from django.utils.formats import number_format
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@@ -13,13 +12,27 @@ def _format_string(prefix, amount, decimal_places, suffix):
|
||||
value=abs(amount), decimal_pos=decimal_places, force_grouping=True
|
||||
)
|
||||
if amount < 0:
|
||||
return "-", prefix, formatted_amount, suffix
|
||||
return f"-{prefix}{formatted_amount}{suffix}"
|
||||
else:
|
||||
return "", prefix, formatted_amount, suffix
|
||||
return f"{prefix}{formatted_amount}{suffix}"
|
||||
else:
|
||||
return "ERR"
|
||||
return "", "", "ERR", ""
|
||||
|
||||
|
||||
@register.simple_tag(name="currency_display")
|
||||
def currency_display(amount, prefix, suffix, decimal_places):
|
||||
return _format_string(prefix, amount, decimal_places, suffix)
|
||||
def currency_display(amount, prefix, suffix, decimal_places, string=False):
|
||||
sign, prefix, amount, suffix = _format_string(
|
||||
prefix, amount, decimal_places, suffix
|
||||
)
|
||||
|
||||
if string:
|
||||
return f"{sign}{prefix}{amount}{suffix}"
|
||||
|
||||
return {
|
||||
"sign": sign,
|
||||
"prefix": prefix,
|
||||
"amount": amount,
|
||||
"suffix": suffix,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.transactions.models import (
|
||||
174
app/apps/transactions/tests/test_views.py
Normal file
174
app/apps/transactions/tests/test_views.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class TransactionSimpleAddViewTests(TestCase):
|
||||
"""Tests for the transaction_simple_add view with query parameters"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client.login(username="testuser@test.com", password="testpass123")
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
self.category = TransactionCategory.objects.create(name="Test Category")
|
||||
self.tag = TransactionTag.objects.create(name="TestTag")
|
||||
|
||||
def test_get_returns_form_with_default_values(self):
|
||||
"""Test GET request returns 200 and form with defaults"""
|
||||
response = self.client.get("/add/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("form", response.context)
|
||||
|
||||
def test_get_with_type_param(self):
|
||||
"""Test type param sets form initial value"""
|
||||
response = self.client.get("/add/?type=EX")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("type"), Transaction.Type.EXPENSE)
|
||||
|
||||
def test_get_with_account_param(self):
|
||||
"""Test account param sets form initial value"""
|
||||
response = self.client.get(f"/add/?account={self.account.id}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("account"), self.account.id)
|
||||
|
||||
def test_get_with_is_paid_param_true(self):
|
||||
"""Test is_paid param with true value"""
|
||||
response = self.client.get("/add/?is_paid=true")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertTrue(form.initial.get("is_paid"))
|
||||
|
||||
def test_get_with_is_paid_param_false(self):
|
||||
"""Test is_paid param with false value"""
|
||||
response = self.client.get("/add/?is_paid=false")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertFalse(form.initial.get("is_paid"))
|
||||
|
||||
def test_get_with_amount_param(self):
|
||||
"""Test amount param sets form initial value"""
|
||||
response = self.client.get("/add/?amount=150.50")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("amount"), "150.50")
|
||||
|
||||
def test_get_with_description_param(self):
|
||||
"""Test description param sets form initial value"""
|
||||
response = self.client.get("/add/?description=Test%20Transaction")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("description"), "Test Transaction")
|
||||
|
||||
def test_get_with_notes_param(self):
|
||||
"""Test notes param sets form initial value"""
|
||||
response = self.client.get("/add/?notes=Some%20notes")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("notes"), "Some notes")
|
||||
|
||||
def test_get_with_category_param(self):
|
||||
"""Test category param sets form initial value"""
|
||||
response = self.client.get(f"/add/?category={self.category.id}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("category"), self.category.id)
|
||||
|
||||
def test_get_with_tags_param(self):
|
||||
"""Test tags param as comma-separated names"""
|
||||
response = self.client.get("/add/?tags=TestTag,AnotherTag")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("tags"), ["TestTag", "AnotherTag"])
|
||||
|
||||
def test_get_with_all_params(self):
|
||||
"""Test all params together work correctly"""
|
||||
url = (
|
||||
f"/add/?type=EX&account={self.account.id}&is_paid=true"
|
||||
f"&amount=200.00&description=Full%20Test¬es=Test%20notes"
|
||||
f"&category={self.category.id}&tags=TestTag"
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("type"), Transaction.Type.EXPENSE)
|
||||
self.assertEqual(form.initial.get("account"), self.account.id)
|
||||
self.assertTrue(form.initial.get("is_paid"))
|
||||
self.assertEqual(form.initial.get("amount"), "200.00")
|
||||
self.assertEqual(form.initial.get("description"), "Full Test")
|
||||
self.assertEqual(form.initial.get("notes"), "Test notes")
|
||||
self.assertEqual(form.initial.get("category"), self.category.id)
|
||||
self.assertEqual(form.initial.get("tags"), ["TestTag"])
|
||||
|
||||
def test_post_creates_transaction(self):
|
||||
"""Test form submission creates transaction"""
|
||||
data = {
|
||||
"account": self.account.id,
|
||||
"type": "EX",
|
||||
"is_paid": True,
|
||||
"date": timezone.now().date().isoformat(),
|
||||
"amount": "100.00",
|
||||
"description": "Test Transaction",
|
||||
}
|
||||
response = self.client.post("/add/", data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(
|
||||
Transaction.objects.filter(description="Test Transaction").exists()
|
||||
)
|
||||
|
||||
def test_get_with_date_param(self):
|
||||
"""Test date param overrides expected date"""
|
||||
response = self.client.get("/add/?date=2025-06-15")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("date"), date(2025, 6, 15))
|
||||
|
||||
def test_get_with_reference_date_param(self):
|
||||
"""Test reference_date param sets form initial value"""
|
||||
response = self.client.get("/add/?reference_date=2025-07-01")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("reference_date"), date(2025, 7, 1))
|
||||
|
||||
def test_get_with_account_name_param(self):
|
||||
"""Test account param by name (case-insensitive)"""
|
||||
response = self.client.get("/add/?account=Test%20Account")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("account"), self.account.id)
|
||||
|
||||
def test_get_with_category_name_param(self):
|
||||
"""Test category param by name (case-insensitive)"""
|
||||
response = self.client.get("/add/?category=Test%20Category")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("category"), self.category.id)
|
||||
@@ -137,6 +137,7 @@ def quick_transaction_add_as_transaction(request, quick_transaction_id):
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
"internal_id",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -206,6 +207,7 @@ def quick_transaction_add_as_quick_transaction(request, transaction_id):
|
||||
"recurring_transaction",
|
||||
"deleted",
|
||||
"deleted_at",
|
||||
"internal_id",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -142,26 +142,95 @@ def transaction_simple_add(request):
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
# Build initial data from query parameters
|
||||
initial_data = {
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
}
|
||||
|
||||
# Handle date param (ISO format: YYYY-MM-DD) - overrides expected_date
|
||||
date_param = request.GET.get("date")
|
||||
if date_param:
|
||||
try:
|
||||
initial_data["date"] = datetime.datetime.strptime(date_param, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle reference_date param (ISO format: YYYY-MM-DD)
|
||||
reference_date_param = request.GET.get("reference_date")
|
||||
if reference_date_param:
|
||||
try:
|
||||
initial_data["reference_date"] = datetime.datetime.strptime(reference_date_param, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle account param (by ID or name)
|
||||
account_param = request.GET.get("account")
|
||||
if account_param:
|
||||
try:
|
||||
initial_data["account"] = int(account_param)
|
||||
except (ValueError, TypeError):
|
||||
# Try to find by name
|
||||
from apps.accounts.models import Account
|
||||
account = Account.objects.filter(name__iexact=account_param, is_archived=False).first()
|
||||
if account:
|
||||
initial_data["account"] = account.pk
|
||||
|
||||
# Handle is_paid param (boolean)
|
||||
is_paid = request.GET.get("is_paid")
|
||||
if is_paid is not None:
|
||||
initial_data["is_paid"] = is_paid.lower() in ("true", "1", "yes")
|
||||
|
||||
# Handle amount param (decimal)
|
||||
amount = request.GET.get("amount")
|
||||
if amount:
|
||||
try:
|
||||
initial_data["amount"] = amount
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Handle description param (string)
|
||||
description = request.GET.get("description")
|
||||
if description:
|
||||
initial_data["description"] = description
|
||||
|
||||
# Handle notes param (string)
|
||||
notes = request.GET.get("notes")
|
||||
if notes:
|
||||
initial_data["notes"] = notes
|
||||
|
||||
# Handle category param (by ID or name)
|
||||
category_param = request.GET.get("category")
|
||||
if category_param:
|
||||
try:
|
||||
initial_data["category"] = int(category_param)
|
||||
except (ValueError, TypeError):
|
||||
# Try to find by name
|
||||
from apps.transactions.models import TransactionCategory
|
||||
category = TransactionCategory.objects.filter(name__iexact=category_param, active=True).first()
|
||||
if category:
|
||||
initial_data["category"] = category.pk
|
||||
|
||||
# Handle tags param (comma-separated names)
|
||||
tags = request.GET.get("tags")
|
||||
if tags:
|
||||
initial_data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
# Handle entities param (comma-separated names)
|
||||
entities = request.GET.get("entities")
|
||||
if entities:
|
||||
initial_data["entities"] = [e.strip() for e in entities.split(",") if e.strip()]
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
},
|
||||
)
|
||||
# Only reset form after successful save
|
||||
form = TransactionForm(initial=initial_data)
|
||||
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
},
|
||||
)
|
||||
form = TransactionForm(initial=initial_data)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.users.models import UserSettings
|
||||
from crispy_forms.bootstrap import (
|
||||
FormActions,
|
||||
)
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div, HTML
|
||||
from crispy_forms.layout import HTML, Column, Div, Field, Layout, Row, Submit
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.forms import (
|
||||
UsernameField,
|
||||
AuthenticationForm,
|
||||
UserCreationForm,
|
||||
UsernameField,
|
||||
)
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.users.models import UserSettings
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
username = UsernameField(
|
||||
label=_("E-mail"),
|
||||
widget=forms.EmailInput(
|
||||
attrs={"class": "form-control", "placeholder": "E-mail", "name": "email"}
|
||||
attrs={
|
||||
"class": "input",
|
||||
"placeholder": _("E-mail"),
|
||||
"name": "email",
|
||||
"autocomplete": "email",
|
||||
}
|
||||
),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_("Password"),
|
||||
strip=False,
|
||||
widget=forms.PasswordInput(
|
||||
attrs={"class": "form-control", "placeholder": "Senha"}
|
||||
attrs={
|
||||
"class": "input",
|
||||
"placeholder": _("Password"),
|
||||
"autocomplete": "current-password",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -45,7 +53,7 @@ class LoginForm(AuthenticationForm):
|
||||
self.helper.layout = Layout(
|
||||
"username",
|
||||
"password",
|
||||
Submit("Submit", "Login", css_class="btn btn-primary w-100"),
|
||||
Submit("Submit", "Login", css_class="w-full mt-3"),
|
||||
)
|
||||
|
||||
|
||||
@@ -129,18 +137,16 @@ class UserSettingsForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
"language",
|
||||
"timezone",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"start_page",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"volume",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -191,8 +197,8 @@ class UserUpdateForm(forms.ModelForm):
|
||||
# Define the layout using Crispy Forms, including the new fields
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("first_name", css_class="form-group col-md-6"),
|
||||
Column("last_name", css_class="form-group col-md-6"),
|
||||
Column("first_name"),
|
||||
Column("last_name"),
|
||||
css_class="row",
|
||||
),
|
||||
Field("email"),
|
||||
@@ -213,17 +219,13 @@ class UserUpdateForm(forms.ModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -354,8 +356,8 @@ class UserAddForm(UserCreationForm):
|
||||
self.helper.layout = Layout(
|
||||
Field("email"),
|
||||
Row(
|
||||
Column("first_name", css_class="form-group col-md-6"),
|
||||
Column("last_name", css_class="form-group col-md-6"),
|
||||
Column("first_name"),
|
||||
Column("last_name"),
|
||||
css_class="row",
|
||||
),
|
||||
# UserCreationForm provides 'password1' and 'password2' fields
|
||||
@@ -375,17 +377,13 @@ class UserAddForm(UserCreationForm):
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,10 +18,15 @@ urlpatterns = [
|
||||
name="toggle_sound_playing",
|
||||
),
|
||||
path(
|
||||
"user/toggle-sidebar/",
|
||||
"user/session/toggle-sidebar/",
|
||||
views.toggle_sidebar_status,
|
||||
name="toggle_sidebar_status",
|
||||
),
|
||||
path(
|
||||
"user/session/toggle-theme/",
|
||||
views.toggle_theme,
|
||||
name="toggle_theme",
|
||||
),
|
||||
path(
|
||||
"user/settings/",
|
||||
views.update_settings,
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
from apps.common.decorators.demo import disabled_on_demo
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.decorators.user import htmx_login_required, is_superuser
|
||||
from apps.users.forms import (
|
||||
LoginForm,
|
||||
UserAddForm,
|
||||
UserSettingsForm,
|
||||
UserUpdateForm,
|
||||
)
|
||||
from apps.users.models import UserSettings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout, get_user_model
|
||||
from django.contrib.auth import get_user_model, logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.views import (
|
||||
LoginView,
|
||||
)
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render, get_object_or_404
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.decorators.user import is_superuser, htmx_login_required
|
||||
from apps.users.forms import (
|
||||
LoginForm,
|
||||
UserSettingsForm,
|
||||
UserUpdateForm,
|
||||
UserAddForm,
|
||||
)
|
||||
from apps.users.models import UserSettings
|
||||
from apps.common.decorators.demo import disabled_on_demo
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
logout(request)
|
||||
@@ -118,6 +117,7 @@ def update_settings(request):
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
@require_http_methods(["GET"])
|
||||
def toggle_sidebar_status(request):
|
||||
if not request.session.get("sidebar_status"):
|
||||
request.session["sidebar_status"] = "floating"
|
||||
@@ -134,6 +134,24 @@ def toggle_sidebar_status(request):
|
||||
)
|
||||
|
||||
|
||||
@htmx_login_required
|
||||
@require_http_methods(["GET"])
|
||||
def toggle_theme(request):
|
||||
if not request.session.get("theme"):
|
||||
request.session["theme"] = "wygiwyh_dark"
|
||||
|
||||
if request.session["theme"] == "wygiwyh_dark":
|
||||
request.session["theme"] = "wygiwyh_light"
|
||||
elif request.session["theme"] == "wygiwyh_light":
|
||||
request.session["theme"] = "wygiwyh_dark"
|
||||
else:
|
||||
request.session["theme"] = "wygiwyh_light"
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
)
|
||||
|
||||
|
||||
@htmx_login_required
|
||||
@is_superuser
|
||||
@require_http_methods(["GET"])
|
||||
|
||||
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
3431
app/locale/hu/LC_MESSAGES/django.po
Normal file
3431
app/locale/hu/LC_MESSAGES/django.po
Normal file
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
3431
app/locale/pl/LC_MESSAGES/django.po
Normal file
3431
app/locale/pl/LC_MESSAGES/django.po
Normal file
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
3458
app/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
3458
app/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a2373d79ec"><path d="M 1.980469 1.980469 L 373 1.980469 L 373 373 L 1.980469 373 Z M 1.980469 1.980469 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a2373d79ec)"><path fill="#fbb700" d="M 239.671875 301.757812 L 79.152344 141.238281 L 118.234375 102.152344 L 239.671875 223.589844 L 355.179688 108.078125 C 325.429688 45.34375 261.519531 1.957031 187.472656 1.957031 C 113.375 1.957031 49.433594 45.410156 19.707031 108.210938 L 174.503906 263.003906 L 135.757812 301.757812 L 2.882812 168.878906 C 2.273438 174.996094 1.957031 181.199219 1.957031 187.472656 C 1.957031 289.929688 85.015625 372.988281 187.472656 372.988281 C 289.929688 372.988281 372.988281 289.929688 372.988281 187.472656 C 372.988281 181.347656 372.679688 175.296875 372.101562 169.320312 L 239.671875 301.757812 " fill-opacity="1" fill-rule="nonzero"/></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 374.999991" version="1.0">
|
||||
<path fill="#000000" d="M 239.671875 301.757812 L 79.152344 141.238281 L 118.234375 102.152344 L 239.671875 223.589844 L 355.179688 108.078125 C 325.429688 45.34375 261.519531 1.957031 187.472656 1.957031 C 113.375 1.957031 49.433594 45.410156 19.707031 108.210938 L 174.503906 263.003906 L 135.757812 301.757812 L 2.882812 168.878906 C 2.273438 174.996094 1.957031 181.199219 1.957031 187.472656 C 1.957031 289.929688 85.015625 372.988281 187.472656 372.988281 C 289.929688 372.988281 372.988281 289.929688 372.988281 187.472656 C 372.988281 181.347656 372.679688 175.296875 372.101562 169.320312 L 239.671875 301.757812 "/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 718 B |
@@ -1,78 +1,72 @@
|
||||
{% load i18n %}
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="tw:text-3xl fw-bold font-monospace tw:w-full mb-3">
|
||||
<c-ui.fab-single-action
|
||||
url="{% url 'account_group_add' %}"
|
||||
hx_target="#generic-offcanvas">
|
||||
</c-ui.fab-single-action>
|
||||
<div class="container">
|
||||
<div class="text-3xl font-bold font-mono w-full mb-3">
|
||||
{% spaceless %}
|
||||
<div>{% translate 'Account Groups' %}<span>
|
||||
<a class="text-decoration-none tw:text-2xl p-1 category-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Add" %}"
|
||||
hx-get="{% url 'account_group_add' %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
|
||||
</span></div>
|
||||
<div>{% translate 'Account Groups' %}</div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
{% if account_groups %}
|
||||
<c-config.search></c-config.search>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="col-auto"></th>
|
||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account_group in account_groups %}
|
||||
<tr class="account_group">
|
||||
<td class="col-auto">
|
||||
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'account_group_edit' pk=account_group.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm text-danger"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'account_group_delete' pk=account_group.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
{% if not account_group.owner %}
|
||||
<a class="btn btn-secondary btn-sm text-warning"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Take ownership" %}"
|
||||
hx-get="{% url 'account_group_take_ownership' pk=account_group.id %}">
|
||||
<i class="fa-solid fa-crown fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if user == account_group.owner %}
|
||||
<a class="btn btn-secondary btn-sm text-primary"
|
||||
role="button"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-swap="innerHTML"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Share" %}"
|
||||
hx-get="{% url 'account_group_share_settings' pk=account_group.id %}">
|
||||
<i class="fa-solid fa-share fa-fw"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="col">{{ account_group.name }}</td>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="table-col-auto"></th>
|
||||
<th scope="col">{% translate 'Name' %}</th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account_group in account_groups %}
|
||||
<tr class="account_group">
|
||||
<td class="table-col-auto">
|
||||
<div class="join" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Edit" %}"
|
||||
hx-get="{% url 'account_group_edit' pk=account_group.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
{% if not account_group.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Take ownership" %}"
|
||||
hx-get="{% url 'account_group_take_ownership' pk=account_group.id %}">
|
||||
<i class="fa-solid fa-crown fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if user == account_group.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-swap="innerHTML"
|
||||
data-tippy-content="{% translate "Share" %}"
|
||||
hx-get="{% url 'account_group_share_settings' pk=account_group.id %}">
|
||||
<i class="fa-solid fa-share fa-fw"></i></a>
|
||||
{% endif %}
|
||||
<a class="btn btn-error btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'account_group_delete' pk=account_group.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ account_group.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No account groups" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
|
||||
@@ -9,65 +9,59 @@
|
||||
<form hx-post="{% url 'account_reconciliation' %}">
|
||||
{% csrf_token %}
|
||||
{{ form.management_form }}
|
||||
<div class="accordion accordion-flush" id="balanceAccordionFlush">
|
||||
<div class="join join-vertical w-full" id="balanceAccordionFlush">
|
||||
{% for form in form.forms %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#flush-collapse-{{ forloop.counter0 }}" aria-expanded="false"
|
||||
aria-controls="flush-collapseOne">
|
||||
{% if form.account_group %}<span class="badge text-bg-primary me-2">{{ form.account_group.name }}</span>{% endif %}{{ form.account_name }}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="flush-collapse-{{ forloop.counter0 }}" class="accordion-collapse collapse">
|
||||
<div class="accordion-body">
|
||||
<div class="mb-3">
|
||||
<div class="form-label">
|
||||
{% translate 'Current balance' %}
|
||||
</div>
|
||||
<div data-amount="{{ form.current_balance|floatformat:"-40u" }}"
|
||||
data-decimal-places="{{ form.currency_decimal_places }}"
|
||||
id="amount-{{ forloop.counter0 }}">
|
||||
{% currency_display amount=form.current_balance prefix=form.currency_prefix suffix=form.currency_suffix decimal_places=form.currency_decimal_places %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{% crispy form %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-label">
|
||||
{% translate 'Difference' %}
|
||||
</div>
|
||||
<div _="on input from #id_form-{{ forloop.counter0 }}-new_balance
|
||||
set original_amount to parseFloat('{{ form.current_balance|floatformat:"-40u" }}')
|
||||
then set prefix to '{{ form.currency_prefix }}'
|
||||
then set suffix to '{{ form.currency_suffix }}'
|
||||
then set decimal_places to {{ form.currency_decimal_places }}
|
||||
then call parseLocaleNumber(#id_form-{{ forloop.counter0 }}-new_balance.value)
|
||||
then set new_amount to result
|
||||
then set diff to (Math.round((new_amount - original_amount) * Math.pow(10, decimal_places))) / Math.pow(10, decimal_places)
|
||||
then log diff
|
||||
then set format_new_amount to
|
||||
Intl.NumberFormat(
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: decimal_places,
|
||||
maximumFractionDigits: decimal_places,
|
||||
roundingMode: 'trunc'
|
||||
}
|
||||
).format(diff)
|
||||
then set formatted_string to `${prefix}${format_new_amount}${suffix}`
|
||||
then put formatted_string into me if diff else
|
||||
put '-' into me">-</div>
|
||||
<c-ui.components.collapse>
|
||||
<c-slot name="title">
|
||||
{% if form.account_group %}<span class="badge badge-primary badge-outline me-2">{{ form.account_group.name }}</span>{% endif %}{{ form.account_name }}
|
||||
</c-slot>
|
||||
<c-slot name="content">
|
||||
<div class="fieldset">
|
||||
<span class="fieldset-legend">{% translate 'Current balance' %}</span>
|
||||
<div data-amount="{{ form.current_balance|floatformat:"-40u" }}"
|
||||
data-decimal-places="{{ form.currency_decimal_places }}"
|
||||
id="amount-{{ forloop.counter0 }}" class="text-base">
|
||||
<c-amount.display
|
||||
:amount="form.current_balance"
|
||||
:prefix="form.currency_prefix"
|
||||
:suffix="form.currency_suffix"
|
||||
:decimal_places="form.currency_decimal_places"
|
||||
color="auto"></c-amount.display>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{% crispy form %}
|
||||
</div>
|
||||
<div class="fieldset">
|
||||
<span class="fieldset-legend">{% translate 'Difference' %}</span>
|
||||
<div class="text-base"
|
||||
_="on input from #id_form-{{ forloop.counter0 }}-new_balance
|
||||
set original_amount to parseFloat('{{ form.current_balance|floatformat:"-40u" }}')
|
||||
then set prefix to '{{ form.currency_prefix }}'
|
||||
then set suffix to '{{ form.currency_suffix }}'
|
||||
then set decimal_places to {{ form.currency_decimal_places }}
|
||||
then call parseLocaleNumber(#id_form-{{ forloop.counter0 }}-new_balance.value)
|
||||
then set new_amount to result
|
||||
then set diff to (Math.round((new_amount - original_amount) * Math.pow(10, decimal_places))) / Math.pow(10, decimal_places)
|
||||
then set format_new_amount to
|
||||
Intl.NumberFormat(
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: decimal_places,
|
||||
maximumFractionDigits: decimal_places,
|
||||
roundingMode: 'trunc'
|
||||
}
|
||||
).format(diff)
|
||||
then set formatted_string to `${prefix}${format_new_amount}${suffix}`
|
||||
then put formatted_string into me if diff else put '-' into me">-</div>
|
||||
</div>
|
||||
</c-slot>
|
||||
</c-ui.components.collapse>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div>
|
||||
<input type="submit" name="submit" value="{% translate 'Reconcile balances' %}" class="btn btn-outline-primary w-100" id="submit-id-submit">
|
||||
<input type="submit" name="submit" value="{% translate 'Reconcile balances' %}" class="btn btn-primary w-full" id="submit-id-submit">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,101 +1,96 @@
|
||||
{% load i18n %}
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="tw:text-3xl fw-bold font-monospace tw:w-full mb-3">
|
||||
<c-ui.fab-single-action
|
||||
url="{% url 'account_add' %}"
|
||||
hx_target="#generic-offcanvas">
|
||||
</c-ui.fab-single-action>
|
||||
<div class="container">
|
||||
<div class="text-3xl font-bold font-mono w-full mb-3">
|
||||
{% spaceless %}
|
||||
<div>{% translate 'Accounts' %}<span>
|
||||
<a class="text-decoration-none tw:text-2xl p-1 category-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Add" %}"
|
||||
hx-get="{% url 'account_add' %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
|
||||
</span></div>
|
||||
<div>{% translate 'Accounts' %}</div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
{% if accounts %}
|
||||
<c-config.search></c-config.search>
|
||||
<table class="table table-hover text-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="col-auto"></th>
|
||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Group' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Currency' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Exchange Currency' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Is Asset' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Archived' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account in accounts %}
|
||||
<tr class="account">
|
||||
<td class="col-auto">
|
||||
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'account_edit' pk=account.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm text-danger"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'account_delete' pk=account.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
{% if not account.owner %}
|
||||
<a class="btn btn-secondary btn-sm text-primary"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Take ownership" %}"
|
||||
hx-get="{% url 'account_take_ownership' pk=account.id %}">
|
||||
<i class="fa-solid fa-crown fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if user == account.owner %}
|
||||
<a class="btn btn-secondary btn-sm text-primary"
|
||||
role="button"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-swap="innerHTML"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Share" %}"
|
||||
hx-get="{% url 'account_share_settings' pk=account.id %}">
|
||||
<i class="fa-solid fa-share fa-fw"></i></a>
|
||||
{% endif %}
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
role="button"
|
||||
hx-get="{% url 'account_toggle_untracked' pk=account.id %}"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% if account.is_untracked_by %}{% translate "Track" %}{% else %}{% translate "Untrack" %}{% endif %}">
|
||||
{% if account.is_untracked_by %}
|
||||
<i class="fa-solid fa-eye fa-fw"></i>
|
||||
{% else %}
|
||||
<i class="fa-solid fa-eye-slash fa-fw"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col">{{ account.name }}</td>
|
||||
<td class="col">{{ account.group.name }}</td>
|
||||
<td class="col">{{ account.currency }}</td>
|
||||
<td class="col">{% if account.exchange_currency %}{{ account.exchange_currency }}{% else %}-{% endif %}</td>
|
||||
<td class="col">{% if account.is_asset %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||
<td class="col">{% if account.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<c-config.search></c-config.search>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="table-col-auto"></th>
|
||||
<th scope="col">{% translate 'Name' %}</th>
|
||||
<th scope="col">{% translate 'Group' %}</th>
|
||||
<th scope="col">{% translate 'Currency' %}</th>
|
||||
<th scope="col">{% translate 'Exchange Currency' %}</th>
|
||||
<th scope="col">{% translate 'Is Asset' %}</th>
|
||||
<th scope="col">{% translate 'Archived' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account in accounts %}
|
||||
<tr class="account">
|
||||
<td class="table-col-auto">
|
||||
<div class="join" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Edit" %}"
|
||||
hx-get="{% url 'account_edit' pk=account.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
{% if not account.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Take ownership" %}"
|
||||
hx-get="{% url 'account_take_ownership' pk=account.id %}">
|
||||
<i class="fa-solid fa-crown fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if user == account.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-swap="innerHTML"
|
||||
data-tippy-content="{% translate "Share" %}"
|
||||
hx-get="{% url 'account_share_settings' pk=account.id %}">
|
||||
<i class="fa-solid fa-share fa-fw"></i></a>
|
||||
{% endif %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
hx-get="{% url 'account_toggle_untracked' pk=account.id %}"
|
||||
data-tippy-content="
|
||||
|
||||
{% if account.is_untracked_by %}{% translate "Track" %}{% else %}{% translate "Untrack" %}{% endif %}">
|
||||
{% if account.is_untracked_by %}
|
||||
<i class="fa-solid fa-eye fa-fw"></i>
|
||||
{% else %}
|
||||
<i class="fa-solid fa-eye-slash fa-fw"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a class="btn btn-error btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'account_delete' pk=account.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ account.name }}</td>
|
||||
<td>{{ account.group.name }}</td>
|
||||
<td>{{ account.currency }}</td>
|
||||
<td>{% if account.exchange_currency %}{{ account.exchange_currency }}{% else %}-{% endif %}</td>
|
||||
<td>{% if account.is_asset %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||
<td>{% if account.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No accounts" %}" remove-padding></c-msg.empty>
|
||||
<c-msg.empty title="{% translate "No accounts" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,67 +2,67 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div>
|
||||
<div class="tw:hidden tw:lg:grid tw:lg:grid-cols-7 tw:gap-4 tw:lg:gap-0">
|
||||
<div class="border-start border-top border-bottom p-2 text-center">
|
||||
<div class="hidden lg:grid lg:grid-cols-7 gap-4 lg:gap-0 bg-base-200">
|
||||
<div class="border-l border-t border-b border-base-300 p-2 text-center">
|
||||
{% translate 'MON' %}
|
||||
</div>
|
||||
<div class="border-top border-bottom p-2 text-center">
|
||||
<div class="border-t border-b border-base-300 p-2 text-center">
|
||||
{% translate 'TUE' %}
|
||||
</div>
|
||||
<div class="border-top border-bottom p-2 text-center">
|
||||
<div class="border-t border-b border-base-300 p-2 text-center">
|
||||
{% translate 'WED' %}
|
||||
</div>
|
||||
<div class="border-top border-bottom p-2 text-center">
|
||||
<div class="border-t border-b border-base-300 p-2 text-center">
|
||||
{% translate 'THU' %}
|
||||
</div>
|
||||
<div class="border-top border-bottom p-2 text-center">
|
||||
<div class="border-t border-b border-base-300 p-2 text-center">
|
||||
{% translate 'FRI' %}
|
||||
</div>
|
||||
<div class="border-top border-bottom p-2 text-center">
|
||||
<div class="border-t border-b border-base-300 p-2 text-center">
|
||||
{% translate 'SAT' %}
|
||||
</div>
|
||||
<div class="border-end border-top border-bottom p-2 text-center">
|
||||
<div class="border-r border-t border-b border-base-300 p-2 text-center">
|
||||
{% translate 'SUN' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw:grid tw:grid-cols-1 tw:grid-rows-1 tw:lg:grid-cols-7 tw:lg:grid-rows-6 tw:gap-4 tw:lg:gap-0">
|
||||
<div class="grid grid-cols-1 grid-rows-1 lg:grid-cols-7 lg:grid-rows-6 gap-4 lg:gap-0">
|
||||
{% for date in dates %}
|
||||
{% if date %}
|
||||
<div class="card h-100 tw:hover:bg-zinc-900! rounded-0{% if not date.transactions %} tw:hidden! tw:lg:flex!{% endif %}{% if today == date.date %} tw:border-yellow-300 border-primary{% endif %} " role="button"
|
||||
<div class="card bg-base-100 h-full hover:bg-base-200! border border-base-content/30 rounded-none {% if not date.transactions %}hidden! lg:flex!{% endif %}{% if today == date.date %} border-2 border-primary{% endif %} cursor-pointer" role="button"
|
||||
hx-get="{% url 'calendar_transactions_list' day=date.date.day month=date.date.month year=date.date.year %}"
|
||||
hx-target="#persistent-generic-offcanvas-left">
|
||||
<div class="card-header border-0 bg-transparent text-end tw:flex justify-content-between p-2 w-100">
|
||||
<div class="tw:lg:hidden text-start w-100">{{ date.date|date:"l"|lower }}</div>
|
||||
<div class="text-end w-100">{{ date.day }}</div>
|
||||
<div class="card-header border-0 bg-transparent text-end flex justify-between p-2 w-full">
|
||||
<div class="lg:hidden text-start w-full">{{ date.date|date:"l"|lower }}</div>
|
||||
<div class="text-end w-full">{{ date.day }}</div>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="card-body p-2 flex flex-row flex-wrap gap-1">
|
||||
{% for transaction in date.transactions %}
|
||||
{% if transaction.is_paid %}
|
||||
{% if transaction.type == "IN" and not transaction.account.is_asset %}
|
||||
<i class="fa-solid fa-circle-check tw:text-green-400" data-bs-toggle="tooltip" data-bs-title="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Income' %}{% endif %}"></i>
|
||||
<i class="fa-solid fa-circle-check text-success" data-tippy-content="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Income' %}{% endif %}"></i>
|
||||
{% elif transaction.type == "IN" and transaction.account.is_asset %}
|
||||
<i class="fa-solid fa-circle-check tw:text-green-300" data-bs-toggle="tooltip" data-bs-title="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Income' %}{% endif %}"></i>
|
||||
<i class="fa-solid fa-circle-check text-success/80" data-tippy-content="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Income' %}{% endif %}"></i>
|
||||
{% elif transaction.type == "EX" and not transaction.account.is_asset %}
|
||||
<i class="fa-solid fa-circle-check tw:text-red-400" data-bs-toggle="tooltip" data-bs-title="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Expense' %}{% endif %}"></i>
|
||||
<i class="fa-solid fa-circle-check text-error" data-tippy-content="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Expense' %}{% endif %}"></i>
|
||||
{% elif transaction.type == "EX" and transaction.account.is_asset %}
|
||||
<i class="fa-solid fa-circle-check tw:text-red-300" data-bs-toggle="tooltip" data-bs-title="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Expense' %}{% endif %}"></i>
|
||||
<i class="fa-solid fa-circle-check text-error/80" data-tippy-content="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Expense' %}{% endif %}"></i>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if transaction.type == "IN" and not transaction.account.is_asset %}
|
||||
<i class="fa-regular fa-circle tw:text-green-400" data-bs-toggle="tooltip" data-bs-title="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Income' %}{% endif %}"></i>
|
||||
<i class="fa-regular fa-circle text-success" data-tippy-content="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Income' %}{% endif %}"></i>
|
||||
{% elif transaction.type == "IN" and transaction.account.is_asset %}
|
||||
<i class="fa-regular fa-circle tw:text-green-300" data-bs-toggle="tooltip" data-bs-title="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Income' %}{% endif %}"></i>
|
||||
<i class="fa-regular fa-circle text-success/80" data-tippy-content="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Income' %}{% endif %}"></i>
|
||||
{% elif transaction.type == "EX" and not transaction.account.is_asset %}
|
||||
<i class="fa-regular fa-circle tw:text-red-400" data-bs-toggle="tooltip" data-bs-title="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Expense' %}{% endif %}"></i>
|
||||
<i class="fa-regular fa-circle text-error" data-tippy-content="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Expense' %}{% endif %}"></i>
|
||||
{% elif transaction.type == "EX" and transaction.account.is_asset %}
|
||||
<i class="fa-regular fa-circle tw:text-red-300" data-bs-toggle="tooltip" data-bs-title="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Expense' %}{% endif %}"></i>
|
||||
<i class="fa-regular fa-circle text-error/80" data-tippy-content="{% if transaction.description %}{{ transaction.description }}{% else %}{% trans 'Expense' %}{% endif %}"></i>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tw:hidden! tw:lg:block! card h-100 rounded-0"></div>
|
||||
<div class="hidden! lg:block! card bg-base-300 h-full rounded-none"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
{% load i18n %}
|
||||
{% load month_name %}
|
||||
{% load static %}
|
||||
{% load webpack_loader %}
|
||||
|
||||
{% block title %}{% translate 'Monthly Overview' %} :: {{ month|month_name }}/{{ year }}{% endblock %}
|
||||
|
||||
@@ -13,45 +12,35 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
|
||||
<div class="container">
|
||||
<div class="flex flex-wrap mb-4 gap-x-xl-4 gap-y-3">
|
||||
{# Date picker#}
|
||||
<div class="col-12 col-xl-4 flex-row align-items-center d-flex">
|
||||
<div class="tw:text-base h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="pe-4 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, previous_month from:window"
|
||||
href="{% url 'calendar' month=previous_month year=previous_year %}"><i
|
||||
class="fa-solid fa-chevron-left"></i></a>
|
||||
</div>
|
||||
<div class="tw:text-3xl fw-bold font-monospace tw:w-full text-center"
|
||||
<div class="w-full xl:w-4/12 flex-row items-center flex">
|
||||
<a role="button"
|
||||
hx-boost="true"
|
||||
class="btn btn-ghost"
|
||||
hx-trigger="click, previous_month from:window"
|
||||
href="{% url 'calendar' month=previous_month year=previous_year %}">
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
</a>
|
||||
<div class="text-2xl font-bold btn btn-ghost flex-1 text-center whitespace-normal flex-wrap h-auto min-w-0 1flex flex-"
|
||||
hx-get="{% url 'month_year_picker' %}"
|
||||
hx-target="#generic-offcanvas-left"
|
||||
hx-trigger="click, date_picker from:window"
|
||||
hx-vals='{"month": {{ month }}, "year": {{ year }}, "for": "calendar", "field": "date"}' role="button">
|
||||
{{ month|month_name }} {{ year }}
|
||||
</div>
|
||||
<div class="tw:text-base mx-2 h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="ps-3 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, next_month from:window"
|
||||
href="{% url 'calendar' month=next_month year=next_year %}">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{# Action buttons#}
|
||||
<div class="col-12 col-xl-8">
|
||||
{# <c-ui.quick-transactions-buttons#}
|
||||
{# :year="year"#}
|
||||
{# :month="month"#}
|
||||
{# ></c-ui.quick-transactions-buttons>#}
|
||||
<a role="button"
|
||||
hx-boost="true"
|
||||
class="btn btn-ghost"
|
||||
hx-trigger="click, next_month from:window"
|
||||
href="{% url 'calendar' month=next_month year=next_year %}">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="show-loading" hx-get="{% url 'calendar_list' month=month year=year %}"
|
||||
<div class="flex flex-wrap">
|
||||
<div class="show-loading w-full" hx-get="{% url 'calendar_list' month=month year=year %}"
|
||||
hx-trigger="load, updated from:window, selective_update from:window, every 10m"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
{% load i18n %}
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="tw:text-3xl fw-bold font-monospace tw:w-full mb-3">
|
||||
<c-ui.fab-single-action
|
||||
url="{% url 'category_add' %}"
|
||||
hx_target="#generic-offcanvas">
|
||||
</c-ui.fab-single-action>
|
||||
<div class="container">
|
||||
<div class="text-3xl font-bold font-mono w-full mb-3">
|
||||
{% spaceless %}
|
||||
<div>{% translate 'Categories' %}<span>
|
||||
<a class="text-decoration-none tw:text-2xl p-1 category-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Add" %}"
|
||||
hx-get="{% url 'category_add' %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
|
||||
</span></div>
|
||||
<div>{% translate 'Categories' %}</div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-pills card-header-pills" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" type="button" role="tab" aria-selected="true" hx-get="{% url 'categories_table_active' %}" hx-trigger="load, click" hx-target="#categories-table">{% translate 'Active' %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" hx-get="{% url 'categories_table_archived' %}" hx-target="#categories-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-header bg-base-200 p-4 rounded-box">
|
||||
<div role="tablist" class="tabs tabs-border">
|
||||
<input type="radio" name="installment_plan_tabs" class="tab" aria-label="{% translate 'Active' %}"
|
||||
checked="checked"
|
||||
hx-get="{% url 'categories_table_active' %}" hx-trigger="load, click" hx-target="#categories-table"
|
||||
hx-indicator="#categories-table"/>
|
||||
<input type="radio" name="installment_plan_tabs" class="tab" aria-label="{% translate 'Archived' %}"
|
||||
hx-get="{% url 'categories_table_archived' %}" hx-trigger="click" hx-target="#categories-table"
|
||||
hx-indicator="#categories-table"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="categories-table"></div>
|
||||
<div id="categories-table" class="show-loading"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,72 +6,70 @@
|
||||
<div class="show-loading" hx-get="{% url 'categories_table_archived' %}" hx-trigger="updated from:window"
|
||||
hx-swap="outerHTML">
|
||||
{% endif %}
|
||||
{% if categories %}
|
||||
<div class="table-responsive">
|
||||
{% if categories %}
|
||||
<div>
|
||||
<c-config.search></c-config.search>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="col-auto"></th>
|
||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||
<th scope="col" class="col">{% translate 'Muted' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr class="category">
|
||||
<td class="col-auto text-center">
|
||||
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
hx-swap="innerHTML"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'category_edit' category_id=category.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm text-danger"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'category_delete' category_id=category.id %}"
|
||||
hx-trigger='confirmed'
|
||||
hx-swap="innerHTML"
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
{% if not category.owner %}
|
||||
<a class="btn btn-secondary btn-sm text-primary"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Take ownership" %}"
|
||||
hx-get="{% url 'category_take_ownership' category_id=category.id %}">
|
||||
<i class="fa-solid fa-crown fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if user == category.owner %}
|
||||
<a class="btn btn-secondary btn-sm text-primary"
|
||||
role="button"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-swap="innerHTML"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Share" %}"
|
||||
hx-get="{% url 'category_share_settings' pk=category.id %}">
|
||||
<i class="fa-solid fa-share fa-fw"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="col">{{ category.name }}</td>
|
||||
<td class="col">
|
||||
{% if category.mute %}<i class="fa-solid fa-check text-success"></i>{% endif %}
|
||||
</td>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="table-col-auto"></th>
|
||||
<th scope="col">{% translate 'Name' %}</th>
|
||||
<th scope="col">{% translate 'Muted' %}</th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr class="category">
|
||||
<td class="table-col-auto text-center">
|
||||
<div class="join" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
hx-swap="innerHTML"
|
||||
data-tippy-content="{% translate "Edit" %}"
|
||||
hx-get="{% url 'category_edit' category_id=category.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
{% if not category.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Take ownership" %}"
|
||||
hx-get="{% url 'category_take_ownership' category_id=category.id %}">
|
||||
<i class="fa-solid fa-crown fa-fw"></i></a>
|
||||
{% endif %}
|
||||
{% if user == category.owner %}
|
||||
<a class="btn btn-secondary btn-sm join-item"
|
||||
role="button"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-swap="innerHTML"
|
||||
data-tippy-content="{% translate "Share" %}"
|
||||
hx-get="{% url 'category_share_settings' pk=category.id %}">
|
||||
<i class="fa-solid fa-share fa-fw"></i></a>
|
||||
{% endif %}
|
||||
<a class="btn btn-error btn-sm join-item"
|
||||
role="button"
|
||||
data-tippy-content="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'category_delete' category_id=category.id %}"
|
||||
hx-trigger='confirmed'
|
||||
hx-swap="innerHTML"
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ category.name }}</td>
|
||||
<td>
|
||||
{% if category.mute %}<i class="fa-solid fa-check text-success"></i>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No categories" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No categories" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -7,49 +7,33 @@
|
||||
{% block body %}
|
||||
{% regroup month_year_data by year as years_list %}
|
||||
|
||||
<ul class="nav nav-pills nav-fill" id="yearTabs" role="tablist">
|
||||
<div role="tablist" class="tabs tabs-border w-full" id="yearTabs">
|
||||
{% for x in years_list %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link{% if x.grouper == current_year %} active{% endif %}"
|
||||
id="{{ x.grouper }}"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#{{ x.grouper }}-pane"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="{{ x.grouper }}-pane"
|
||||
aria-selected="{% if x.grouper == current_year %}true{% else %}false{% endif %}">
|
||||
{{ x.grouper }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="tab-content" id="yearTabsContent" hx-boost="true">
|
||||
{% for x in years_list %}
|
||||
<div class="tab-pane fade{% if x.grouper == current_year %} show active{% endif %} mt-2"
|
||||
id="{{ x.grouper }}-pane"
|
||||
role="tabpanel"
|
||||
aria-labelledby="{{ x.grouper }}"
|
||||
tabindex="0">
|
||||
<ul class="list-group list-group-flush" id="month-year-list">
|
||||
<input type="radio"
|
||||
name="year_tabs"
|
||||
role="tab"
|
||||
class="tab"
|
||||
aria-label="{{ x.grouper }}"
|
||||
id="tab-{{ x.grouper }}"
|
||||
{% if x.grouper == current_year %}checked="checked"{% endif %} />
|
||||
<div role="tabpanel" class="tab-content" id="{{ x.grouper }}-pane">
|
||||
<ul class="menu bg-base-100 w-full" id="month-year-list" hx-boost="true">
|
||||
{% for month_data in x.list %}
|
||||
<li class="list-group-item tw:hover:bg-zinc-900
|
||||
{% if month_data.month == current_month and month_data.year == current_year %} disabled bg-primary{% endif %}"
|
||||
{% if month_data.month == current_month and month_data.year == current_year %}aria-disabled="true"{% endif %}>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a class="text-decoration-none stretched-link {% if month_data.month == current_month and month_data.year == current_year %} text-black{% endif %}"
|
||||
href={{ month_data.url }}>
|
||||
{{ month_data.month|month_name }}</a>
|
||||
<span class="badge text-bg-secondary">{{ month_data.transaction_count }}</span>
|
||||
</div>
|
||||
|
||||
<li {% if month_data.month == current_month and month_data.year == current_year %}class="disabled"{% endif %}>
|
||||
<a class="{% if month_data.month == current_month and month_data.year == current_year %}menu-active{% endif %}"
|
||||
href={{ month_data.url }}
|
||||
{% if month_data.month == current_month and month_data.year == current_year %}aria-disabled="true"{% endif %}>
|
||||
<span class="flex-1">{{ month_data.month|month_name }}</span>
|
||||
<span class="badge badge-primary">{{ month_data.transaction_count }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr>
|
||||
<hr class="hr my-4">
|
||||
<div class="w-full text-end">
|
||||
<a class="btn btn-outline-primary btn-sm" href="{{ today_url }}" role="button" hx-boost="true">{% trans 'Today' %}</a>
|
||||
<a class="btn btn-outline btn-primary btn-sm" href="{{ today_url }}" role="button" hx-boost="true">{% trans 'Today' %}</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,19 +2,25 @@
|
||||
{% load toast_bg %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="toast align-items-center text-bg-{{ message.tags | toast_bg }} border-0"
|
||||
<div class="toasty alert alert-{{ message.tags | toast_bg }}"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<i class="{{ message.tags | toast_icon }} fa-fw me-1"></i>
|
||||
<strong class="me-auto">{{ message.tags | toast_title }}</strong>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="{{ message.tags | toast_icon }} fa-fw"></i>
|
||||
<div>
|
||||
<strong>{{ message.tags | toast_title }}</strong>
|
||||
<div>{{ message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="toast"
|
||||
aria-label={% translate 'Close' %}></button>
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
_="on click remove closest .toasty"
|
||||
aria-label={% translate 'Close' %}>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toast-body">{{ message }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
{#This is here so we can add dynamic Tailwind classes that will be required via JS/hyperscript but Tailwind has no knowledge of#}
|
||||
<div class="tw:lg:w-[15vw]"></div>
|
||||
<div class="tw:lg:ml-[16vw]"></div>
|
||||
<div class="lg:w-[15vw]"></div>
|
||||
<div class="lg:ml-[16vw]"></div>
|
||||
<div class="grid-cols-12"></div>
|
||||
<div class="md:col-span-1"></div>
|
||||
<div class="md:col-span-2"></div>
|
||||
<div class="md:col-span-3"></div>
|
||||
<div class="md:col-span-4"></div>
|
||||
<div class="md:col-span-5"></div>
|
||||
<div class="md:col-span-6"></div>
|
||||
<div class="md:col-span-7"></div>
|
||||
<div class="md:col-span-8"></div>
|
||||
<div class="md:col-span-9"></div>
|
||||
<div class="md:col-span-10"></div>
|
||||
<div class="md:col-span-11"></div>
|
||||
<div class="md:col-span-12"></div>
|
||||
<div class="col-span-12"></div>
|
||||
<div class="alert-error"></div>
|
||||
<div class="alert-info"></div>
|
||||
<div class="alert-success"></div>
|
||||
<div class="alert-warning"></div>
|
||||
<div class="textarea"></div>
|
||||
<div class="border-base-content/60"></div>
|
||||
<div class="bg-error/20"></div>
|
||||
<div class="bg-success/20"></div>
|
||||
<div class="checkbox checkbox-xs"></div>
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
{% load currency_display %}
|
||||
|
||||
{% currency_display amount=amount prefix=prefix suffix=suffix decimal_places=decimal_places as formatted_amount %}
|
||||
|
||||
{% if not divless %}
|
||||
<div class="{% if text_end %}text-end{% elif text_start %}text-start{% endif %}">
|
||||
{% endif %}
|
||||
<span class="amount{% if color == 'grey' or color == "gray" %} tw:text-gray-500{% elif color == 'green' %} tw:text-green-400{% elif color == 'red' %} tw:text-red-400{% endif %} {{ custom_class }}"
|
||||
data-original-value="{% currency_display amount=amount prefix=prefix suffix=suffix decimal_places=decimal_places %}"
|
||||
<span class="amount
|
||||
{% if color == 'grey' or color == "gray" %} text-exchange-rate
|
||||
{% elif color == 'green' %} text-income {% elif color == 'red' %} text-expense
|
||||
{% elif color == 'auto' %}
|
||||
{% if amount > 0 %} text-income
|
||||
{% elif amount < 0 %} text-expense
|
||||
{% else %} text-base-content {% endif %}
|
||||
{% endif %}
|
||||
font-medium {{ custom_class }}"
|
||||
data-original-sign="{{ formatted_amount.sign }}"
|
||||
data-original-prefix="{{ formatted_amount.prefix }}"
|
||||
data-original-amount="{{ formatted_amount.amount }}"
|
||||
data-original-suffix="{{ formatted_amount.suffix }}"
|
||||
data-amount="{{ amount|floatformat:"-40u" }}">
|
||||
</span><span>{{ slot }}</span>
|
||||
{% if not divless %}
|
||||
|
||||
@@ -1,33 +1,14 @@
|
||||
<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 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">
|
||||
{% load i18n%}
|
||||
|
||||
{{ slot }}
|
||||
<div class="fab">
|
||||
<div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">
|
||||
<i class="fa-solid fa-plus text-2xl fa-fw"></i>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary rounded-circle p-0 tw:w-12 tw:h-12 tw:flex tw:items-center tw:justify-center tw:shadow-lg tw:hover:shadow-xl tw:focus:shadow-xl tw:transition-all tw:duration-300 tw:ease-in-out"
|
||||
_="
|
||||
on click or focusout
|
||||
if #menu.classList.contains('tw:invisible') and event.type === 'click'
|
||||
add .{'tw:rotate-45'} to #fab-icon
|
||||
remove .{'tw:invisible'} from #menu
|
||||
remove .{'tw:hidden'} from #menu
|
||||
remove .{'tw:opacity-0'} from #menu
|
||||
else
|
||||
wait 0.2s
|
||||
remove .{'tw:rotate-45'} from #fab-icon
|
||||
add .{'tw:invisible'} to #menu
|
||||
add .{'tw:hidden'} to #menu
|
||||
add .{'tw:opacity-0'} to #menu
|
||||
end
|
||||
"
|
||||
>
|
||||
<i id="fab-icon" class="fa-solid fa-plus tw:text-3xl tw:transition-transform tw:duration-300 tw:ease-in-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fab-close">
|
||||
{% trans 'Close' %} <span class="btn btn-circle btn-lg btn-error"><i class="fa-solid fa-xmark text-2xl fa-fw"></i></span>
|
||||
</div>
|
||||
|
||||
{{ slot }}
|
||||
|
||||
</div>
|
||||
@@ -1,11 +1,6 @@
|
||||
{% load i18n %}
|
||||
<div class="tw:relative fab-item">
|
||||
<button class="btn btn-sm btn-{{ color }}"
|
||||
hx-get="{{ url }}"
|
||||
hx-trigger="{{ hx_trigger }}"
|
||||
hx-target="{{ hx_target }}"
|
||||
hx-vals='{{ hx_vals }}'>
|
||||
<i class="{{ icon }} me-2"></i>
|
||||
{{ title }}
|
||||
</button>
|
||||
</div>
|
||||
<div hx-get="{{ url }}"
|
||||
hx-trigger="{{ hx_trigger }}"
|
||||
hx-target="{{ hx_target }}"
|
||||
hx-vals='{{ hx_vals }}'>
|
||||
<span class="bg-neutral/60 text-neutral-content rounded-box p-2">{{ title }}</span>
|
||||
<button class="btn btn-lg btn-circle btn-{{color}}"><i class="{{ icon }} fa-fw"></i></button></div>
|
||||
@@ -0,0 +1,39 @@
|
||||
{% load active_link %}
|
||||
{% load i18n %}
|
||||
<c-vars id="collapsible-panel" />
|
||||
|
||||
<li>
|
||||
<div role="button"
|
||||
_="on click toggle .hidden on #{{ id }} then toggle .slide-in-left on #{{ id }}"
|
||||
class="text-xs flex items-center no-underline ps-3 p-2 rounded-box sidebar-item cursor-pointer {% active_link views=active css_class='sidebar-active' %}">
|
||||
<i class="{{ icon }} fa-fw"></i>
|
||||
<span class="ml-3 font-medium lg:group-hover:truncate lg:group-focus:truncate lg:group-hover:text-ellipsis lg:group-focus:text-ellipsis">
|
||||
{{ title }}
|
||||
</span>
|
||||
<i class="fa-solid fa-chevron-right fa-fw ml-auto pe-2"></i>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<div id="{{ id }}"
|
||||
class="p-0 absolute bottom-0 left-0 w-full z-30 max-h-dvh {% active_link views=active css_class='slide-in-left' inactive_class='hidden' %}">
|
||||
<div class="h-dvh bg-base-300 flex flex-col">
|
||||
<div class="items-center p-4 border-b border-base-content/10 sidebar-submenu-header text-base-content">
|
||||
<div class="flex items-center sidebar-submenu-title">
|
||||
<i class="{{ icon }} fa-fw lg:group-hover:me-2 me-2 lg:me-0"></i>
|
||||
<h5 class="text-lg font-semibold text-base-content m-0">
|
||||
{{ title }}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{% trans 'Close' %}"
|
||||
_="on click remove .slide-in-left from #{{ id }} then add .slide-out-left to #{{ id }} then wait 150ms then add .hidden to #{{ id }} then remove .slide-out-left from #{{ id }}">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-item-list list-none p-3 flex flex-col gap-1 whitespace-nowrap lg:group-hover:animate-[disable-pointer-events] overflow-y-auto lg:overflow-y-hidden lg:hover:overflow-y-auto overflow-x-hidden"
|
||||
style="animation-duration: 100ms">
|
||||
{{ slot }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,14 @@
|
||||
<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 }}">
|
||||
<li class="lg:hidden lg:group-hover:block" x-data="{ open: false }">
|
||||
<div class="flex items-center" @click="open = !open" role="button"
|
||||
:aria-expanded="open">
|
||||
<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>
|
||||
class="text-base-content/60 text-sm font-bold uppercase lg:hidden lg:group-hover:inline me-2">{{ title }}</span>
|
||||
<hr class="flex-grow"/>
|
||||
<i class="fas fa-chevron-down text-base-content/60 lg:before:hidden lg:group-hover:before:inline ml-2 lg:ml-0 lg:group-hover:ml-2"
|
||||
:class="{ 'rotate-180': open }"
|
||||
style="transition: transform 0.2s ease"></i>
|
||||
</div>
|
||||
<div x-show="open" x-collapse>
|
||||
{{ slot }}
|
||||
</div>
|
||||
</li>
|
||||
<div class="collapse tw:lg:hidden tw:lg:group-hover:block" id="{{ title|slugify }}">
|
||||
{{ slot }}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<li>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="sidebar-menu-header text-muted small fw-bold text-uppercase me-2">{{ title }}</span>
|
||||
<hr class="flex-grow-1"/>
|
||||
<div class="flex items-center min-h-6">
|
||||
{% if title %}
|
||||
<span class="sidebar-menu-header text-base-content/60 text-xs font-bold uppercase mr-3">{{ title }}</span>
|
||||
{% endif %}
|
||||
<hr class="hr grow"/>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
{% load active_link %}
|
||||
<li>
|
||||
<a href="{% url url %}"
|
||||
class="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" %}"
|
||||
class="text-xs flex items-center no-underline ps-3 p-2 rounded-box sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
||||
{% if tooltip %}
|
||||
data-bs-placement="right"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ tooltip }}"
|
||||
data-tippy-placement="right"
|
||||
data-tippy-content="{{ tooltip }}"
|
||||
{% endif %}>
|
||||
<i class="{{ icon }} fa-fw"></i>
|
||||
<span
|
||||
class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
||||
class="ms-3 font-medium">{{ title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
<li>
|
||||
<a href="{{ url }}"
|
||||
hx-boost="false"
|
||||
class="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" %}"
|
||||
class="text-xs flex items-center no-underline ps-3 p-2 rounded-3xl sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
||||
{% if tooltip %}
|
||||
data-bs-placement="right"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ tooltip }}"
|
||||
data-tippy-placement="right"
|
||||
data-tippy-content="{{ tooltip }}"
|
||||
{% endif %}>
|
||||
|
||||
<i class="{{ icon }} fa-fw"></i>
|
||||
<span
|
||||
class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
||||
class="ms-3 font-medium">{{ title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% load i18n %}
|
||||
<div id="search" class="mb-3">
|
||||
<label class="w-100">
|
||||
<label class="w-full">
|
||||
<input type="search"
|
||||
class="form-control"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="{% translate 'Search' %}"
|
||||
_="on input or search
|
||||
show < tbody>tr /> in <table/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="row {% if not remove_padding %}p-5{% endif %}">
|
||||
<div class="col {% if not remove_padding %}p-5{% endif %}">
|
||||
<div class="grid grid-cols-1 {% if not remove_padding %}p-5{% endif %}">
|
||||
<div class="{% if not remove_padding %}p-5{% endif %}">
|
||||
<div class="text-center">
|
||||
<i class="{% if icon %}{{ icon }}{% else %}fa-solid fa-circle-xmark{% endif %} tw:text-6xl"></i>
|
||||
<p class="lead mt-4 mb-0">{{ title }}</p>
|
||||
<p class="tw:text-gray-500">{{ subtitle }}</p>
|
||||
<i class="{% if icon %}{{ icon }}{% else %}fa-solid fa-circle-xmark{% endif %} text-6xl"></i>
|
||||
<p class="text-lg mt-4 mb-0">{{ title }}</p>
|
||||
<p class="text-subtle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
{% load markdown %}
|
||||
{% load i18n %}
|
||||
<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">
|
||||
class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} group/transaction">
|
||||
<div class="flex my-1">
|
||||
{% if not disable_selection or not dummy %}
|
||||
<label class="px-3 d-flex align-items-center justify-content-center">
|
||||
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
|
||||
<label class="px-3 flex! items-center justify-center">
|
||||
<input class="checkbox" type="checkbox" name="transactions" value="{{ transaction.id }}"
|
||||
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
||||
</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">
|
||||
<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">
|
||||
<div class="border-s-4 border-e-0 border-t-0
|
||||
hover:bg-base-200 p-2 {% if transaction.account.is_asset %}border-dashed{% else %}border-solid{% endif %}
|
||||
{% if transaction.type == "EX" %}border-s-error{% else %}border-s-success{% endif %} relative
|
||||
w-full transaction-item bg-base-100 rounded">
|
||||
<div class="flex flex-wrap font-mono text-sm items-center">
|
||||
<div class="lg:w-auto w-full flex items-center text-2xl lg:text-xl lg:text-center text-center p-0 cursor-pointer">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="text-decoration-none p-3 tw:text-gray-500!"
|
||||
<a class="no-underline p-3 text-base-content/50"
|
||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
||||
role="button"
|
||||
{% if not dummy %}
|
||||
@@ -28,7 +27,7 @@
|
||||
class="fa-regular fa-circle"></i>{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-decoration-none p-3 tw:text-gray-500!"
|
||||
<div class="no-underline p-3 text-base-content/50"
|
||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}">
|
||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
||||
class="fa-regular fa-circle"></i>{% endif %}
|
||||
@@ -36,79 +35,79 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div
|
||||
class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||
class="lg:flex-1 w-full {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}opacity-70{% 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>
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-calendar fa-fw mr-1 fa-xs"></i></div>
|
||||
<div
|
||||
class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
|
||||
class="flex-1 ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
|
||||
</div>
|
||||
{# Description#}
|
||||
<div class="mb-2 mb-lg-1 text-body tw:text-base">
|
||||
<div class="mb-2 lg:mb-1 text-base-content text-base">
|
||||
{% spaceless %}
|
||||
<span class="{% if transaction.description %}me-2{% endif %}">{{ transaction.description }}</span>
|
||||
<span class="{% if transaction.description %}mr-2{% endif %}">{{ transaction.description }}</span>
|
||||
{% if transaction.installment_plan and transaction.installment_id %}
|
||||
<span
|
||||
class="badge text-bg-secondary mx-1">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
|
||||
class="badge badge-neutral mx-1">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
|
||||
{% endif %}
|
||||
{% if transaction.recurring_transaction %}
|
||||
<span class="text-primary tw:text-xs mx-1"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
|
||||
<span class="text-primary 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 mx-1">{% trans 'DCA' %}</span>
|
||||
<span class="badge badge-neutral mx-1">{% trans 'DCA' %}</span>
|
||||
{% endif %}
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
<div class="tw:text-gray-400 tw:text-sm">
|
||||
<div class="text-base-content/70 text-sm">
|
||||
{# Entities #}
|
||||
{% comment %} First, check for the highest priority: a valid 'overriden_entities' list. {% endcomment %}
|
||||
{% if overriden_entities %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ overriden_entities|join:", " }}</div>
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-user-group fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ overriden_entities|join:", " }}</div>
|
||||
</div>
|
||||
|
||||
{% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
|
||||
{% elif transaction.id and transaction.entities.all %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.entities.all|join:", " }}</div>
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-user-group fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.entities.all|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Notes#}
|
||||
{% if transaction.notes %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-align-left fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-align-left fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Category#}
|
||||
{% if transaction.category %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-icons fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.category.name }}</div>
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-icons fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.category.name }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Tags#}
|
||||
{% comment %} First, check for the highest priority: a valid 'overriden_tags' list. {% endcomment %}
|
||||
{% if overriden_tags %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ overriden_tags|join:", " }}</div>
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-hashtag fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ overriden_tags|join:", " }}</div>
|
||||
</div>
|
||||
|
||||
{% comment %} If no override, fall back to transaction tags, but ONLY if the transaction has an ID. {% endcomment %}
|
||||
{% elif transaction.id and transaction.tags.all %}
|
||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.tags.all|join:", " }}</div>
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-hashtag fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.tags.all|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||
<div class="main-amount mb-2 mb-lg-0">
|
||||
class="lg:w-auto w-full lg:text-right self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}opacity-70{% endif %}">
|
||||
<div class="main-amount mb-2 lg:mb-0">
|
||||
<c-amount.display
|
||||
:amount="transaction.amount"
|
||||
:prefix="transaction.account.currency.prefix"
|
||||
@@ -120,7 +119,7 @@
|
||||
{% if not dummy %}
|
||||
{% with exchanged=transaction.exchanged_amount %}
|
||||
{% if exchanged %}
|
||||
<div class="exchanged-amount mb-2 mb-lg-0">
|
||||
<div class="exchanged-amount mb-2 lg:mb-0">
|
||||
<c-amount.display
|
||||
:amount="exchanged.amount"
|
||||
:prefix="exchanged.prefix"
|
||||
@@ -136,112 +135,106 @@
|
||||
</div>
|
||||
</div>
|
||||
{% if not dummy %}
|
||||
<div>
|
||||
<div class="z-1000">
|
||||
{# Item actions#}
|
||||
<div
|
||||
class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
|
||||
<div class="card-body p-1 shadow-lg d-flex flex-row gap-1">
|
||||
class="card card-border-base-100 transaction-actions absolute left-1/2 -translate-x-1/2 -translate-y-1/2 top-0 invisible group-hover/transaction:visible flex flex-row bg-base-200">
|
||||
<div class="card-body p-1 shadow-lg flex flex-row gap-1">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
||||
<a class="btn btn-soft btn-sm transaction-action"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas" hx-swap="innerHTML"
|
||||
data-tippy-content="{% translate "Edit" %}">
|
||||
<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 "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
<a class="btn btn-error btn-soft btn-sm transaction-action"
|
||||
role="button"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-tippy-content="{% translate "Delete" %}"
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></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.account.is_untracked_by %}
|
||||
<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 account' %}</div>
|
||||
</div>
|
||||
</a>
|
||||
<button class="btn btn-soft btn-sm transaction-action" 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 menu">
|
||||
<div class="absolute inset-[0rem_-3rem_-3rem_-3rem] pointer-events-auto -z-10"></div>
|
||||
{% if transaction.account.is_untracked_by %}
|
||||
<li class="menu-disabled" aria-disabled="true">
|
||||
<a class="flex items-center">
|
||||
<i class="fa-solid fa-eye fa-fw mr-2"></i>
|
||||
<div>
|
||||
{% translate 'Show on summaries' %}
|
||||
<div
|
||||
class="block text-base-content/60 text-xs font-medium">{% translate 'Controlled by account' %}</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% elif transaction.category.mute %}
|
||||
<li class="menu-disabled" aria-disabled="true">
|
||||
<a class="flex items-center">
|
||||
<i class="fa-solid fa-eye fa-fw mr-2"></i>
|
||||
<div>
|
||||
{% translate 'Show on summaries' %}
|
||||
<div
|
||||
class="block text-base-content/60 text-xs font-medium">{% translate 'Controlled by category' %}</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% elif transaction.mute %}
|
||||
<li><a 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 mr-2"></i>{% translate 'Show on summaries' %}</a></li>
|
||||
{% else %}
|
||||
<li><a 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 mr-2"></i>{% translate 'Hide from summaries' %}</a></li>
|
||||
{% endif %}
|
||||
<li><a href="#"
|
||||
hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-person-running fa-fw mr-2"></i>{% translate 'Add as quick transaction' %}</a>
|
||||
</li>
|
||||
{% elif 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>
|
||||
<hr class="my-1 hr">
|
||||
<li><a href="#"
|
||||
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i
|
||||
class="fa-solid fa-calendar-minus fa-fw mr-2"></i>{% translate 'Move to previous month' %}</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>
|
||||
<li><a href="#"
|
||||
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i
|
||||
class="fa-solid fa-calendar-plus fa-fw mr-2"></i>{% translate 'Move to next month' %}</a></li>
|
||||
<li><a href="#"
|
||||
hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-calendar-day fa-fw mr-2"></i>{% translate 'Move to today' %}</a></li>
|
||||
<hr class="my-1 hr">
|
||||
<li><a href="#"
|
||||
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-clone fa-fw mr-2"></i>{% translate 'Duplicate' %}</a></li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Restore" %}"
|
||||
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-trash-arrow-up"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
<div class="tooltip" data-tippy-content="{% translate "Restore" %}">
|
||||
<a class="btn btn-success btn-soft btn-sm transaction-action"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-trash-arrow-up fa-fw"></i></a>
|
||||
</div>
|
||||
<div class="tooltip" data-tippy-content="{% translate "Delete" %}">
|
||||
<a class="btn btn-error btn-soft btn-sm transaction-action"
|
||||
role="button"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<div class="card mb-2 transaction-item">
|
||||
<div class="card-body p-2 tw:flex tw:items-center tw:gap-3" data-bs-toggle="collapse" data-bs-target="#{{ transaction.id }}" role="button" aria-expanded="false" aria-controls="{{ transaction.id }}">
|
||||
<!-- Main visible content -->
|
||||
<div class="tw:flex flex-lg-row flex-column tw:lg:items-center tw:w-full tw:gap-3">
|
||||
<!-- Type indicator -->
|
||||
<div class="tw:w-8">
|
||||
{% if transaction.type == 'IN' %}
|
||||
<span class="badge bg-success">↑</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">↓</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Payment status -->
|
||||
<div class="tw:w-8">
|
||||
{% if transaction.is_paid %}
|
||||
<span class="badge bg-success">✓</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">○</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="tw:flex-grow">
|
||||
<span class="tw:font-medium">{{ transaction.description }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div class="tw:text-right tw:whitespace-nowrap">
|
||||
<span class="{% if transaction.type == 'IN' %}tw:text-green-400{% else %}tw:text-red-400{% endif %}">
|
||||
{{ transaction.amount }}
|
||||
</span>
|
||||
{% if transaction.exchanged_amount %}
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
{{ transaction.exchanged_amount.prefix }}{{ transaction.exchanged_amount.amount }}{{ transaction.exchanged_amount.suffix }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expandable details -->
|
||||
<div class="collapse" id="{{ transaction.id }}">
|
||||
<div class="card-body p-3 transaction-details">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Date</dt>
|
||||
<dd class="col-sm-8">{{ transaction.date|date:"Y-m-d" }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Reference Date</dt>
|
||||
<dd class="col-sm-8">{{ transaction.reference_date|date:"Y-m" }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Account</dt>
|
||||
<dd class="col-sm-8">{{ transaction.account.name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Category</dt>
|
||||
<dd class="col-sm-8">{{ transaction.category|default:"-" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<dl class="row">
|
||||
{% if transaction.tags.exists %}
|
||||
<dt class="col-sm-4">Tags</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for tag in transaction.tags.all %}
|
||||
<span class="badge bg-secondary">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if transaction.installment_plan %}
|
||||
<dt class="col-sm-4">Installment</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ transaction.installment_id }} of {{ transaction.installment_plan.total_installments }}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if transaction.recurring_transaction %}
|
||||
<dt class="col-sm-4">Recurring</dt>
|
||||
<dd class="col-sm-8">Yes</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if transaction.notes %}
|
||||
<dt class="col-sm-4">Notes</dt>
|
||||
<dd class="col-sm-8">{{ transaction.notes }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,194 +1,166 @@
|
||||
{% load tools %}
|
||||
{% load i18n %}
|
||||
<div class="col card shadow">
|
||||
<div class="card bg-base-100 shadow-md card-border border-base-300">
|
||||
<div class="card-body">
|
||||
{% if account.account.group %}
|
||||
<div class="tw:text-sm mb-2">
|
||||
<span class="badge text-bg-primary ">{{ account.account.group }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h5 class="card-title">
|
||||
{{ account.account.name }}
|
||||
<h5 class="card-title mb-4">
|
||||
{% if account.account.group %}<span class="badge badge-primary badge-outline">{{ account.account.group }}</span>{% endif %} {{ account.account.name }}
|
||||
</h5>
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'projected income' %}</div>
|
||||
<div class="card-data-section">
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'projected income' %}</span>
|
||||
<div class="card-data-values">
|
||||
{% if account.income_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="account.income_projected"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% if account.exchanged and account.exchanged.income_projected %}
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.income_projected"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
{% if account.income_projected != 0 %}
|
||||
<div class="text-end font-monospace tw:text-green-400">
|
||||
<c-amount.display
|
||||
:amount="account.income_projected"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"></c-amount.display>
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'projected expenses' %}</span>
|
||||
<div class="card-data-values">
|
||||
{% if account.expense_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="account.expense_projected"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% if account.exchanged and account.exchanged.expense_projected %}
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.expense_projected"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if account.exchanged and account.exchanged.income_projected %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.income_projected"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'projected expenses' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
<div>
|
||||
{% if account.expense_projected != 0 %}
|
||||
<div class="text-end font-monospace tw:text-red-400">
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'projected total' %}</span>
|
||||
<div class="card-data-values">
|
||||
<c-amount.display
|
||||
:amount="account.expense_projected"
|
||||
:amount="account.total_projected"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"></c-amount.display>
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="{% if account.total_projected > 0 %}green{% elif account.total_projected < 0 %}red{% endif %}"></c-amount.display>
|
||||
{% if account.exchanged.total_projected and account.exchanged.total_projected %}
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.total_projected"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if account.exchanged and account.exchanged.expense_projected %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.expense_projected"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'projected total' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
<div
|
||||
class="text-end font-monospace">
|
||||
<c-amount.display
|
||||
:amount="account.total_projected"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="{% if account.total_projected > 0 %}green{% elif account.total_projected < 0 %}red{% endif %}"></c-amount.display>
|
||||
</div>
|
||||
</div>
|
||||
{% if account.exchanged.total_projected and account.exchanged.total_projected %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.total_projected"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<hr class="my-3">
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'current income' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
{% if account.income_current != 0 %}
|
||||
<div class="text-end font-monospace tw:text-green-400">
|
||||
<c-amount.display
|
||||
:amount="account.income_current"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if account.exchanged and account.exchanged.income_current %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.income_current"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'current expenses' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
{% if account.expense_current != 0 %}
|
||||
<div class="text-end font-monospace tw:text-red-400">
|
||||
<c-amount.display
|
||||
:amount="account.expense_current"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if account.exchanged and account.exchanged.expense_current %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.expense_current"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'current total' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
<div class="text-end font-monospace">
|
||||
<c-amount.display
|
||||
:amount="account.total_current"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="{% if account.total_current > 0 %}green{% elif account.total_current < 0 %}red{% endif %}"></c-amount.display>
|
||||
</div>
|
||||
</div>
|
||||
{% if account.exchanged and account.exchanged.total_current %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.total_current"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<hr class="my-3">
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'final total' %}</div>
|
||||
<hr class="card-data-divider" />
|
||||
<div class="card-data-section">
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'current income' %}</span>
|
||||
<div class="card-data-values">
|
||||
{% if account.income_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="account.income_current"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% if account.exchanged and account.exchanged.income_current %}
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.income_current"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
<div class="text-end font-monospace">
|
||||
</div>
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'current expenses' %}</span>
|
||||
<div class="card-data-values">
|
||||
{% if account.expense_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="account.expense_current"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% if account.exchanged and account.exchanged.expense_current %}
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.expense_current"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'current total' %}</span>
|
||||
<div class="card-data-values">
|
||||
<c-amount.display
|
||||
:amount="account.total_current"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="{% if account.total_current > 0 %}green{% elif account.total_current < 0 %}red{% endif %}"></c-amount.display>
|
||||
{% if account.exchanged and account.exchanged.total_current %}
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.total_current"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="card-data-divider" />
|
||||
<div class="card-data-section">
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'final total' %}</span>
|
||||
<div class="card-data-values">
|
||||
<c-amount.display
|
||||
:amount="account.total_final"
|
||||
:prefix="account.currency.prefix"
|
||||
:suffix="account.currency.suffix"
|
||||
:decimal_places="account.currency.decimal_places"
|
||||
color="{% if account.total_final > 0 %}green{% elif account.total_final < 0 %}red{% endif %}"></c-amount.display>
|
||||
{% if account.exchanged and account.exchanged.total_final %}
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.total_final"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if account.exchanged and account.exchanged.total_final %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="account.exchanged.total_final"
|
||||
:prefix="account.exchanged.currency.prefix"
|
||||
:suffix="account.exchanged.currency.suffix"
|
||||
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% with p=percentages|get_dict_item:account_id %}
|
||||
<div class="my-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="card tw:relative h-100 shadow">
|
||||
<div class="card bg-base-100 shadow-xl relative h-full">
|
||||
<div class="card-body">
|
||||
{{ slot }}
|
||||
</div>
|
||||
|
||||
21
app/templates/cotton/ui/components/collapse.html
Normal file
21
app/templates/cotton/ui/components/collapse.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="bg-base-100 border-base-300 border-2 join-item {{ base_classes }}"
|
||||
x-data="{ expanded: {% if active %}true{% else %}false{% endif %} }">
|
||||
|
||||
<div class="font-medium text-sm cursor-pointer p-4 flex w-full items-center justify-between gap-4 text-left {{ title_classes }}"
|
||||
@click="expanded = ! expanded">
|
||||
|
||||
<div>
|
||||
{{ title }}
|
||||
</div>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke="currentColor" class="size-5 shrink-0 transition" aria-hidden="true" x-bind:class="expanded ? 'rotate-180' : ''">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 p-4 {{ content_classes }}"
|
||||
x-show="expanded"
|
||||
x-collapse>
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,188 +1,166 @@
|
||||
{% load tools %}
|
||||
{% load i18n %}
|
||||
<div class="col card shadow">
|
||||
<div class="col card bg-base-100 shadow card-border">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<h5 class="card-title mb-4">
|
||||
{{ currency.currency.name }}
|
||||
</h5>
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'projected income' %}</div>
|
||||
<div class="card-data-section">
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'projected income' %}</span>
|
||||
<div class="card-data-values">
|
||||
{% if currency.income_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% if currency.exchanged and currency.exchanged.income_projected %}
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.income_projected"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
{% if currency.income_projected != 0 %}
|
||||
<div class="text-end font-monospace tw:text-green-400">
|
||||
<c-amount.display
|
||||
:amount="currency.income_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"></c-amount.display>
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'projected expenses' %}</span>
|
||||
<div class="card-data-values">
|
||||
{% if currency.expense_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% if currency.exchanged and currency.exchanged.expense_projected %}
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.expense_projected"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if currency.exchanged and currency.exchanged.income_projected %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.income_projected"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'projected expenses' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
<div>
|
||||
{% if currency.expense_projected != 0 %}
|
||||
<div class="text-end font-monospace tw:text-red-400">
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'projected total' %}</span>
|
||||
<div class="card-data-values">
|
||||
<c-amount.display
|
||||
:amount="currency.expense_projected"
|
||||
:amount="currency.total_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"></c-amount.display>
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_projected > 0 %}green{% elif currency.total_projected < 0 %}red{% endif %}"></c-amount.display>
|
||||
{% if currency.exchanged.total_projected and currency.exchanged.total_projected %}
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.total_projected"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if currency.exchanged and currency.exchanged.expense_projected %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.expense_projected"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'projected total' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
<div class="text-end font-monospace">
|
||||
<c-amount.display
|
||||
:amount="currency.total_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_projected > 0 %}green{% elif currency.total_projected < 0 %}red{% endif %}"></c-amount.display>
|
||||
</div>
|
||||
</div>
|
||||
{% if currency.exchanged.total_projected and currency.exchanged.total_projected %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.total_projected"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<hr class="my-3">
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'current income' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
{% if currency.income_current != 0 %}
|
||||
<div class="text-end font-monospace tw:text-green-400">
|
||||
<c-amount.display
|
||||
:amount="currency.income_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if currency.exchanged and currency.exchanged.income_current %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.income_current"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'current expenses' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
{% if currency.expense_current != 0 %}
|
||||
<div class="text-end font-monospace tw:text-red-400">
|
||||
<c-amount.display
|
||||
:amount="currency.expense_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-end font-monospace">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if currency.exchanged and currency.exchanged.expense_current %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.expense_current"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'current total' %}</div>
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
<div class="text-end font-monospace">
|
||||
<c-amount.display
|
||||
:amount="currency.total_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_current > 0 %}green{% elif currency.total_current < 0 %}red{% endif %}"></c-amount.display>
|
||||
</div>
|
||||
</div>
|
||||
{% if currency.exchanged and currency.exchanged.total_current %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.total_current"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<hr class="my-3">
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="text-end font-monospace">
|
||||
<div class="tw:text-gray-400">{% translate 'final total' %}</div>
|
||||
<hr class="card-data-divider" />
|
||||
<div class="card-data-section">
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'current income' %}</span>
|
||||
<div class="card-data-values">
|
||||
{% if currency.income_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% if currency.exchanged and currency.exchanged.income_current %}
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.income_current"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dotted-line flex-grow-1"></div>
|
||||
<div class="text-end font-monospace">
|
||||
</div>
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'current expenses' %}</span>
|
||||
<div class="card-data-values">
|
||||
{% if currency.expense_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% if currency.exchanged and currency.exchanged.expense_current %}
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.expense_current"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-semibold">-</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'current total' %}</span>
|
||||
<div class="card-data-values">
|
||||
<c-amount.display
|
||||
:amount="currency.total_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_current > 0 %}green{% elif currency.total_current < 0 %}red{% endif %}"></c-amount.display>
|
||||
{% if currency.exchanged and currency.exchanged.total_current %}
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.total_current"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="card-data-divider" />
|
||||
<div class="card-data-section">
|
||||
<div class="card-data-row">
|
||||
<span class="card-data-label">{% translate 'final total' %}</span>
|
||||
<div class="card-data-values">
|
||||
<c-amount.display
|
||||
:amount="currency.total_final"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final > 0 %}green{% elif currency.total_final < 0 %}red{% endif %}"></c-amount.display>
|
||||
{% if currency.exchanged and currency.exchanged.total_final %}
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.total_final"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"
|
||||
color="gray"></c-amount.display>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if currency.exchanged and currency.exchanged.total_final %}
|
||||
<div class="text-end font-monospace tw:text-gray-500">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.total_final"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% with p=percentages|get_dict_item:currency_id %}
|
||||
<div class="my-3">
|
||||
|
||||
@@ -1,68 +1,66 @@
|
||||
{% load i18n %}
|
||||
<div class="tw:sticky tw:bottom-4 tw:left-0 tw:right-0 tw:z-50 tw:hidden mx-auto tw:w-fit" id="actions-bar"
|
||||
_="on change from #transactions-list or htmx:afterSettle from window
|
||||
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto w-fit" id="actions-bar" _="on change from #transactions-list or htmx:afterSettle from window
|
||||
if #actions-bar then
|
||||
if no <input[type='checkbox']:checked/> in #transactions-list
|
||||
if #actions-bar
|
||||
add .slide-in-bottom-reverse then settle
|
||||
then add .tw:hidden to #actions-bar
|
||||
then remove .slide-in-bottom-reverse
|
||||
add .slide-in-bottom-short-reverse then settle
|
||||
then add .hidden to #actions-bar
|
||||
then remove .slide-in-bottom-short-reverse
|
||||
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 .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 bg-base-300 shadow slide-in-bottom-short max-w-[90vw] card-border mt-5">
|
||||
<div class="card-body flex-row p-2 flex justify-between items-center gap-3 overflow-x-auto">
|
||||
{% spaceless %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-regular fa-square-check fa-fw"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<div class="dropdown-item px-3 tw:cursor-pointer"
|
||||
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square-check tw:text-green-400 me-3"></i>{% translate 'Select All' %}
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item px-3 tw:cursor-pointer"
|
||||
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square tw:text-red-400 me-3"></i>{% translate 'Unselect All' %}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="vr tw:align-middle"></div>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_undelete' %}"
|
||||
hx-include=".transaction"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Restore' %}">
|
||||
<i class="fa-solid fa-trash-arrow-up fa-fw"></i>
|
||||
<div class="font-bold text-md ms-2" id="selected-count">0</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div>
|
||||
<button role="button" class="btn btn-secondary btn-sm" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-regular fa-square-check fa-fw"></i>
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_delete' %}"
|
||||
hx-include=".transaction"
|
||||
hx-trigger="confirmed"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Delete' %}"
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete them!" %}"
|
||||
_="install prompt_swal">
|
||||
<i class="fa-solid fa-trash text-danger"></i>
|
||||
</button>
|
||||
<div class="vr tw:align-middle"></div>
|
||||
<div class="btn-group"
|
||||
_="on selected_transactions_updated from #actions-bar
|
||||
<ul class="dropdown-menu menu">
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square text-error me-3"></i>{% translate 'Unselect All' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click for checkbox in <#transactions-list input[type='checkbox']/> set checkbox.checked to (not checkbox.checked) end then call me.blur() then trigger change">
|
||||
<i class="fa-solid fa-arrow-right-arrow-left text-info me-3"></i>{% translate 'Invert election' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<button class="btn btn-secondary btn-sm" hx-get="{% url 'transactions_bulk_undelete' %}" hx-include=".transaction"
|
||||
data-tippy-content="{% translate 'Restore' %}">
|
||||
<i class="fa-solid fa-trash-arrow-up fa-fw"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" hx-get="{% url 'transactions_bulk_delete' %}" hx-include=".transaction"
|
||||
hx-trigger="confirmed" data-tippy-content="{% translate 'Delete' %}" data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}" data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete them!" %}" _="install prompt_swal">
|
||||
<i class="fa-solid fa-trash text-error"></i>
|
||||
</button>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div class="join" _="on selected_transactions_updated from #actions-bar
|
||||
set realTotal to math.bignumber(0)
|
||||
set flatTotal to math.bignumber(0)
|
||||
set transactions to <.transaction:has(input[name='transactions']:checked)/>
|
||||
@@ -99,137 +97,112 @@
|
||||
put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
|
||||
put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
|
||||
end">
|
||||
<button class="btn btn-secondary btn-sm" _="on click
|
||||
<button class="btn btn-secondary btn-sm join-item" _="on click
|
||||
set original_value to #real-total-front's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #real-total-front's innerText
|
||||
wait 1s
|
||||
put original_value into #real-total-front's innerText
|
||||
end">
|
||||
<i class="fa-solid fa-plus fa-fw me-md-2 text-primary"></i>
|
||||
<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">
|
||||
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
|
||||
put '{% translate "copied!" %}' into #real-total-front's innerText wait 1s put original_value
|
||||
into #real-total-front's innerText end">
|
||||
<i class="fa-solid fa-plus fa-fw me-md-2"></i>
|
||||
<span class="hidden md:inline-block" id="real-total-front">0</span>
|
||||
</button>
|
||||
<div>
|
||||
<button class="join-item btn btn-sm btn-secondary" type="button" data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside" aria-expanded="false">
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<ul class="dropdown-menu dropdown-menu-end menu">
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-flat-total's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-flat-total wait 1s put original_value into
|
||||
#calc-menu-flat-total end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-body-secondary tw:text-xs tw:font-medium px-3">
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Flat Total" %}
|
||||
</div>
|
||||
<div class="dropdown-item px-3 tw:cursor-pointer"
|
||||
id="calc-menu-flat-total"
|
||||
_="on click
|
||||
set original_value to my innerText
|
||||
writeText(my innerText) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into me
|
||||
wait 1s
|
||||
put original_value into me
|
||||
end">
|
||||
<div id="calc-menu-flat-total">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-real-total's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-real-total wait 1s put original_value into
|
||||
#calc-menu-real-total end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-body-secondary tw:text-xs tw:font-medium px-3">
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Real Total" %}
|
||||
</div>
|
||||
<div class="dropdown-item px-3 tw:cursor-pointer"
|
||||
id="calc-menu-real-total"
|
||||
_="on click
|
||||
set original_value to my innerText
|
||||
writeText(my innerText) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into me
|
||||
wait 1s
|
||||
put original_value into me
|
||||
end">
|
||||
<div id="calc-menu-real-total">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-mean's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-mean wait 1s put original_value into
|
||||
#calc-menu-mean end">
|
||||
<div class="p-1 px-3">
|
||||
<div>
|
||||
<div class="text-body-secondary tw:text-xs tw:font-medium px-3">
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Mean" %}
|
||||
</div>
|
||||
<div class="dropdown-item px-3 tw:cursor-pointer"
|
||||
id="calc-menu-mean"
|
||||
_="on click
|
||||
set original_value to my innerText
|
||||
writeText(my innerText) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into me
|
||||
wait 1s
|
||||
put original_value into me
|
||||
end">
|
||||
<div id="calc-menu-mean">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-max's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-max wait 1s put original_value into
|
||||
#calc-menu-max end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-body-secondary tw:text-xs tw:font-medium px-3">
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Max" %}
|
||||
</div>
|
||||
<div class="dropdown-item px-3 tw:cursor-pointer"
|
||||
id="calc-menu-max"
|
||||
_="on click
|
||||
set original_value to my innerText
|
||||
writeText(my innerText) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into me
|
||||
wait 1s
|
||||
put original_value into me
|
||||
end">
|
||||
<div id="calc-menu-max">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-min's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-min wait 1s put original_value into
|
||||
#calc-menu-min end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-body-secondary tw:text-xs tw:font-medium px-3">
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Min" %}
|
||||
</div>
|
||||
<div class="dropdown-item px-3 tw:cursor-pointer"
|
||||
id="calc-menu-min"
|
||||
_="on click
|
||||
set original_value to my innerText
|
||||
writeText(my innerText) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into me
|
||||
wait 1s
|
||||
put original_value into me
|
||||
end">
|
||||
<div id="calc-menu-min">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-count's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-count wait 1s put original_value into
|
||||
#calc-menu-count end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-body-secondary tw:text-xs tw:font-medium px-3">
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Count" %}
|
||||
</div>
|
||||
<div class="dropdown-item px-3 tw:cursor-pointer"
|
||||
id="calc-menu-count"
|
||||
_="on click
|
||||
set original_value to my innerText
|
||||
writeText(my innerText) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into me
|
||||
wait 1s
|
||||
put original_value into me
|
||||
end">
|
||||
<div id="calc-menu-count">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +210,8 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
7
app/templates/cotton/ui/fab_single_action.html
Normal file
7
app/templates/cotton/ui/fab_single_action.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="fab">
|
||||
<button class="btn btn-lg btn-circle btn-primary"
|
||||
hx-get="{{ url }}"
|
||||
hx-target="{{ hx_target }}">
|
||||
<i class="fa-solid fa-plus text-2xl fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,8 +1,6 @@
|
||||
{% spaceless %}
|
||||
{% load i18n %}
|
||||
<span class="tw:text-xs text-white-50 mx-1"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ content }}">
|
||||
<span class="text-xs text-base-content/50 mx-1" data-tippy-content="{{ content }}">
|
||||
<i class="{% if not icon %}fa-solid fa-circle-question{% else %}{{ icon }}{% endif %} fa-fw"></i>
|
||||
</span>
|
||||
{% endspaceless %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user