mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-01-14 21:23:29 +01:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d14b44fe | ||
|
|
a5b78f7c83 | ||
|
|
9543881aae | ||
|
|
6955294283 | ||
|
|
2b6a73af18 | ||
|
|
526c2cb191 | ||
|
|
4fe62244cd | ||
|
|
011e926e02 | ||
|
|
cd1b872b27 | ||
|
|
3791edce63 | ||
|
|
2cb8100129 | ||
|
|
e7e4ccafb6 | ||
|
|
afbbf7b25d | ||
|
|
1eba2b8731 | ||
|
|
afe366c359 | ||
|
|
3ee2bebc5c | ||
|
|
b951e5f069 | ||
|
|
4005a83a0d | ||
|
|
f81f1d83fd | ||
|
|
7816d6c55d | ||
|
|
6e3fdae4fe | ||
|
|
e2da996217 | ||
|
|
cc2e2293ed | ||
|
|
7060f07ccd | ||
|
|
0adb991879 | ||
|
|
20e03df661 | ||
|
|
71f59bfd68 | ||
|
|
6c76535f91 | ||
|
|
5c8fbc9278 | ||
|
|
89b11421c2 | ||
|
|
056fc4fced | ||
|
|
3f9765ec7b | ||
|
|
0d9d13bf31 | ||
|
|
2f6c396eaf | ||
|
|
d12b920e54 | ||
|
|
9edbf7bd5a | ||
|
|
dbd3eea29a | ||
|
|
881fed1895 | ||
|
|
10a0ac42a2 | ||
|
|
1b47c12a22 | ||
|
|
091f73bf8d | ||
|
|
73fe17de64 | ||
|
|
52af1b2260 | ||
|
|
8efa087aee | ||
|
|
6f69f15474 | ||
|
|
905e80cffe | ||
|
|
baae6bb96a | ||
|
|
f5132e24bd | ||
|
|
41303f39a0 | ||
|
|
0fc8b0ee49 | ||
|
|
037014d024 | ||
|
|
8c5a9efe05 | ||
|
|
f940414b5c | ||
|
|
2d8e97a27e | ||
|
|
5ccb9ff152 | ||
|
|
3c0a2d82ac | ||
|
|
62f049cbb2 | ||
|
|
7a759be357 | ||
|
|
6297e73307 | ||
|
|
eb753bb30e | ||
|
|
1047fb23dd | ||
|
|
c861b9ae07 | ||
|
|
4be849f5de | ||
|
|
3e73332a93 | ||
|
|
ae2217e760 | ||
|
|
e2bf699be0 | ||
|
|
e760d42c2d | ||
|
|
d541b30280 | ||
|
|
366c0b475d | ||
|
|
8576b74aff | ||
|
|
d4b11bd350 | ||
|
|
c8c34c2c56 | ||
|
|
023ceb898f | ||
|
|
1243dddd5d | ||
|
|
8661fb39e8 | ||
|
|
5752606fec | ||
|
|
7250ce0dbb | ||
|
|
b963a3cfb8 | ||
|
|
1f14eb011f | ||
|
|
265af71ac5 | ||
|
|
4c003d4456 | ||
|
|
d66a2e2856 | ||
|
|
74bf6a655d | ||
|
|
114cf2622e |
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
@@ -5,40 +5,23 @@ on:
|
||||
types: [created]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: WYGIWYH
|
||||
IMAGE_NAME: wygiwyh
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
uses: metcalfc/changelog-generator@v4.1.0
|
||||
with:
|
||||
myToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update release with changelog
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: ${{ steps.changelog.outputs.changelog }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -52,7 +35,10 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/prod/django/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
|
||||
${{ 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
|
||||
|
||||
261
README.md
261
README.md
@@ -6,13 +6,15 @@
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
<h4 align="center">An optionated and powerful finance tracker.</h4>
|
||||
<h4 align="center">An opinionated and powerful finance tracker.</h4>
|
||||
|
||||
<p align="center">
|
||||
<a href="#why-wygiwyh">Why</a> •
|
||||
<a href="#key-features">Features</a> •
|
||||
<a href="#how-to-use">Usage</a> •
|
||||
<a href="#how-it-works">How</a>
|
||||
<a href="#how-it-works">How</a> •
|
||||
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
||||
<a href="#built-with">Built with</a>
|
||||
</p>
|
||||
|
||||
**WYGIWYH** (_What You Get Is What You Have_) is a powerful, principles-first finance tracker designed for people who prefer a no-budget, straightforward approach to managing their money. With features like multi-currency support, customizable transactions, and a built-in dollar-cost averaging tracker, WYGIWYH helps you take control of your finances with simplicity and flexibility.
|
||||
@@ -48,29 +50,28 @@ Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**
|
||||
|
||||
# How To Use
|
||||
|
||||
To run this application, you'll need [Git](https://git-scm.com) and [Docker](https://docs.docker.com/engine/install/) with the [docker-compose](https://docs.docker.com/compose/install/).
|
||||
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
From your command line:
|
||||
|
||||
> [!NOTE]
|
||||
> Docker images for this project are currently under development, but manual setup is available now.
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
$ git clone https://github.com/eitchtee/WYGIWYH
|
||||
$ mkdir WYGIWYH
|
||||
|
||||
# Go into the repository
|
||||
$ cd WYGIWYH
|
||||
|
||||
# Fill the .env file with your configurations
|
||||
$ cp .env.example .env
|
||||
$ nano .env # or any other editor you want to use
|
||||
$ touch docker-compose.yml
|
||||
$ nano docker-compose.yml
|
||||
# Paste the contents of https://github.com/eitchtee/WYGIWYH/blob/main/docker-compose.prod.yml and edit according to your needs
|
||||
|
||||
# Create docker-compose file
|
||||
$ cp docker-compose.prod.yml docker-compose.yml
|
||||
# Fill the .env file with your configurations
|
||||
$ touch .env
|
||||
$ nano .env # or any other editor you want to use
|
||||
# Paste the contents of https://github.com/eitchtee/WYGIWYH/blob/main/.env.example and edit accordingly
|
||||
|
||||
# Run the app
|
||||
$ docker compose up -d --build
|
||||
$ docker compose up -d
|
||||
|
||||
# Create the first admin account
|
||||
$ docker compose exec -it web python manage.py createsuperuser
|
||||
@@ -211,35 +212,61 @@ A Recurring Transaction is a helper model that generates recurring transactions
|
||||
|
||||
### Account
|
||||
|
||||
TO-DO
|
||||
Accounts represent different financial entities where transactions occur. They have the following attributes:
|
||||
|
||||
- **Name**: A unique identifier for the account.
|
||||
- **Group**: An optional [account group](#account-groups) the account belongs to for organizational purposes.
|
||||
- **Currency**: The primary [currency](#currency) of the account.
|
||||
- **Exchange Currency**: An optional currency used for exchange rate calculations.
|
||||
- **Is Asset**: A boolean indicating if the account is considered an asset (counts towards net worth).
|
||||
- **Is Archived**: A boolean indicating if the account is archived (doesn't show up in active lists or count towards net worth).
|
||||
|
||||
### Account Groups
|
||||
|
||||
TO-DO
|
||||
Account Groups are used to organize accounts into logical categories. They consist of:
|
||||
|
||||
- **Name**: A unique identifier for the group.
|
||||
|
||||
### Currency
|
||||
|
||||
TO-DO
|
||||
Currencies represent different monetary units. They include:
|
||||
|
||||
* **Code**: A unique identifier for the currency (e.g., USD, EUR).
|
||||
* **Name**: The full name of the currency.
|
||||
* **Decimal Place**: The number of decimal places used for the currency.
|
||||
* **Prefix**: An optional symbol or text that comes before the amount.
|
||||
* **Suffix**: An optional symbol or text that comes after the amount.
|
||||
|
||||
### Exchange Rate
|
||||
|
||||
TO-DO
|
||||
Exchange Rates store conversion rates between currencies:
|
||||
|
||||
* **From Currency**: The source currency.
|
||||
* **To Currency**: The target currency.
|
||||
* **Rate**: The conversion rate.
|
||||
* **Date**: The date the rate was recorded or is valid for.
|
||||
|
||||
### Category
|
||||
|
||||
TO-DO
|
||||
Categories are used to classify transactions:
|
||||
|
||||
* **Name**: A unique identifier for the category.
|
||||
* **Muted**: Muted categories won't count towards your monthly total.
|
||||
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
|
||||
|
||||
### Tag
|
||||
|
||||
TO-DO
|
||||
Tags provide additional labeling for transactions:
|
||||
|
||||
* **Name**: A unique identifier for the tag.
|
||||
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
|
||||
|
||||
### Entity
|
||||
|
||||
TO-DO
|
||||
Entities represent parties involved in transactions:
|
||||
|
||||
### Rule
|
||||
|
||||
TO-DO
|
||||
* **Name**: A unique identifier for the entity.
|
||||
* **Active**: A boolean indicating if the entity is currently in use. This will disable its use on new transactions.
|
||||
|
||||
---
|
||||
|
||||
@@ -265,37 +292,98 @@ This can be useful for savings accounts or other interest accruing investments.!
|
||||
|
||||
### Monthly
|
||||
|
||||
TO-DO
|
||||
The Monthly view provides an overview of your financial activity for a specific month. It includes:
|
||||
|
||||
* Total income and expenses for the month
|
||||
* Daily spending allowance calculation
|
||||
* List of transactions for the month
|
||||
|
||||
> [!NOTE]
|
||||
> Reference dates are taken into account here.
|
||||
|
||||
### Yearly by currency
|
||||
|
||||
TO-DO
|
||||
This view gives you a yearly summary of your finances grouped by currency. It shows:
|
||||
|
||||
* Total income and expenses for each currency
|
||||
* Monthly breakdown of income and expenses
|
||||
|
||||
### Yearly by account
|
||||
|
||||
TO-DO
|
||||
Similar to the [yearly by currency](#yearly-by-currency) view, but groups the data by account instead.
|
||||
|
||||
### Calendar
|
||||
|
||||
TO-DO
|
||||
The Calendar view presents your transactions in a monthly calendar format, allowing you to see your financial activity day by day. It includes:
|
||||
|
||||
* Visual representation of daily transaction totals
|
||||
* Ability to view details of transactions for each day
|
||||
|
||||
> [!NOTE]
|
||||
> Reference dates are **not** taken into account here.
|
||||
|
||||
### Networh
|
||||
|
||||
#### Current
|
||||
|
||||
TO-DO
|
||||
The Current Net Worth view shows your present financial standing, including:
|
||||
|
||||
* Total value of all asset accounts
|
||||
* Breakdown of assets by account and currency
|
||||
* Historical net worth trend
|
||||
|
||||
#### Projected
|
||||
|
||||
TO-DO
|
||||
The Projected Net Worth view estimates your future financial position based on current data and recurring transactions. It includes:
|
||||
|
||||
* Your total net worth with projected and current transactions
|
||||
* Breakdown of assets by account and currency
|
||||
* Historical and future net worth trend
|
||||
|
||||
### All Transactions
|
||||
|
||||
TO-DO
|
||||
This view provides a comprehensive list of all transactions across all accounts. Features include:
|
||||
|
||||
* Advanced filtering and sorting options
|
||||
* Detailed information
|
||||
|
||||
You can use this to see how much you spent on a given category, or a given day, etc..
|
||||
|
||||
### Configuration and Management
|
||||
|
||||
TO-DO
|
||||
#### Management
|
||||
The Management section in the navbar allows you to add and edit most elements of WYGIWYH, including:
|
||||
|
||||
* Accounts and Groups
|
||||
* Currencies and Exchange Rates
|
||||
* Categories, Tags and Entities
|
||||
* Rules
|
||||
|
||||
#### User Settings
|
||||
|
||||
WYGIWYH allows users to personalize their experience through customizable settings. Each user can configure:
|
||||
|
||||
* **Language**: Choose your preferred interface language.
|
||||
* **Timezone**: Set your local timezone for accurate date and time display.
|
||||
* **Start Page**: Select which page you want to see first when you log in.
|
||||
* **Sound Preferences**: Toggle sound effects on or off.
|
||||
* **Amount Display**: Choose to show or hide monetary amounts by default.
|
||||
|
||||
To access and modify these settings:
|
||||
|
||||
1. Click on your username in the top-right corner of the page.
|
||||
2. Select "Settings" from the dropdown menu.
|
||||
3. Adjust your preferences as desired.
|
||||
4. Click "Save" to apply your changes.
|
||||
|
||||
These settings ensure that WYGIWYH adapts to your personal preferences and working style.
|
||||
|
||||
#### Django Admin
|
||||
From here you can also access Django's own admin site.
|
||||
|
||||
> [!WARNING]
|
||||
> Most side effects aren't triggered from the admin.
|
||||
> Only use it if you know what you're doing or were told by a developer to do so.
|
||||
|
||||
---
|
||||
|
||||
@@ -303,7 +391,7 @@ TO-DO
|
||||
|
||||
### Calculator
|
||||
|
||||
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar.
|
||||
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar or by pressing <kbd>Alt</kbd> + <kbd>C</kbd> on any page.
|
||||
|
||||
It allows for any math expression supported by [math.js](https://mathjs.org).
|
||||
|
||||
@@ -337,16 +425,109 @@ You can add additional items by clicking the _Add_ button at the end of the page
|
||||
|
||||
### Currency Converter
|
||||
|
||||
TO-DO
|
||||
The Currency Converter is a tool that allows you to quickly convert amounts between different currencies.
|
||||
|
||||
> [!NOTE]
|
||||
> There's no external Exchange Rate fetching. This uses the Exchange Rates configured in the [Management](#configuration-and-management) page for [Exchange Rates](#exchange-rate)
|
||||
|
||||
## Automation
|
||||
|
||||
### API
|
||||
|
||||
WYGIWYH has a comprehensive API, it's documentation can be accessed on `<your-wygiwyh-url>/api/docs/`
|
||||
|
||||
> [!NOTE]
|
||||
> While the API works, there's still much to be added to it to equipare functionality with the main web app.
|
||||
|
||||
### Transaction Rules
|
||||
|
||||
Transaction Rules are a powerful feature in WYGIWYH that allow for automatic modification of transactions based on specified criteria. This can save time and ensure consistency in your financial tracking.
|
||||
|
||||
Key Aspects of Transaction Rules:
|
||||
|
||||
* **Conditions**: Set specific criteria that a transaction must meet for the rule to apply. This can include attributes like description, amount, account, etc.
|
||||
* **Actions**: Define what changes should be made to a transaction when the conditions are met. This can include setting categories, tags, or modifying other fields.
|
||||
* **Activation Options**: Rules can be set to apply when transactions are created, updated, or both.
|
||||
|
||||
#### Actions and Conditions
|
||||
|
||||
When creating a new rule, you will need to add a Condition and, later, Actions.
|
||||
|
||||
Both use a limited subset of Python, via [SimpleEval](https://github.com/danthedeckie/simpleeval).
|
||||
|
||||
The Condition must evaluate to True or False, and the Action must evaluate to a value that will be set on the selected field.
|
||||
|
||||
You may use any of the available [variables](#available-variables) and [functions](#available-functions).
|
||||
|
||||
#### Available variables
|
||||
|
||||
* `account_name`
|
||||
* `account_id`
|
||||
* `account_group_name`
|
||||
* `account_group_id`
|
||||
* `is_asset_account`
|
||||
* `is_archived_account`
|
||||
* `category_name`
|
||||
* `category_id`
|
||||
* `tag_names`
|
||||
* `tag_ids`
|
||||
* `entities_names`
|
||||
* `entities_ids`
|
||||
* `is_expense`
|
||||
* `is_income`
|
||||
* `is_paid`
|
||||
* `description`
|
||||
* `amount`
|
||||
* `notes`
|
||||
* `date`
|
||||
* `reference_date`
|
||||
|
||||
#### Available functions
|
||||
|
||||
* `relativedelta`
|
||||
|
||||
#### Examples
|
||||
|
||||
Add a tag to an income transaction if it happens in a specific account
|
||||
|
||||
```
|
||||
If...
|
||||
account_name == "My Investing Account" and is_income
|
||||
|
||||
Then...
|
||||
Set Tags to
|
||||
tag_names + ["Yield"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Move credit card transactions to next month when they happen at a cutoff date
|
||||
|
||||
```
|
||||
If...
|
||||
account_name == "My credit card" and date.day >= 26 and reference_date.month == date.month
|
||||
|
||||
Then...
|
||||
Set Reference Date to
|
||||
reference_date + relativedelta(months=1)).replace(day=1)
|
||||
```
|
||||
# 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.
|
||||
- Pretty much all calculations are done at run time, this can lead to some performance degradation. On my personal instance, I have 3000+ transactions over 4+ years and 4000+ exchange rates, and load times average at around 500ms for each page, not bad overall.
|
||||
- This isn't a budgeting or double-entry-accounting application, if you need those features there's a lot of options out there, if you really need them in WYGIWYH, open a discussion.
|
||||
|
||||
# Built with
|
||||
|
||||
WYGIWYH is possible thanks to a lot of amazing open source tools, to name a few:
|
||||
|
||||
- Django
|
||||
- HTMX
|
||||
- _hyperscript
|
||||
- Procrastinate
|
||||
- Bootstrap
|
||||
- Tailwind
|
||||
- Webpack
|
||||
* Django
|
||||
* HTMX
|
||||
* _hyperscript
|
||||
* Procrastinate
|
||||
* Bootstrap
|
||||
* Tailwind
|
||||
* Webpack
|
||||
* PostgreSQL
|
||||
* Django REST framework
|
||||
* Alpine.js
|
||||
|
||||
@@ -53,6 +53,7 @@ class AccountGroupForm(forms.ModelForm):
|
||||
|
||||
class AccountForm(forms.ModelForm):
|
||||
group = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
label=_("Group"),
|
||||
model=AccountGroup,
|
||||
required=False,
|
||||
@@ -112,6 +113,7 @@ class AccountBalanceForm(forms.Form):
|
||||
max_digits=42, decimal_places=30, required=False, label=_("New balance")
|
||||
)
|
||||
category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.transactions.models import (
|
||||
TransactionCategory,
|
||||
@@ -25,13 +26,13 @@ class TransactionCategoryField(serializers.Field):
|
||||
return TransactionCategory.objects.get(pk=data)
|
||||
except TransactionCategory.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
"Category with this ID does not exist."
|
||||
_("Category with this ID does not exist.")
|
||||
)
|
||||
elif isinstance(data, str):
|
||||
category, created = TransactionCategory.objects.get_or_create(name=data)
|
||||
return category
|
||||
raise serializers.ValidationError(
|
||||
"Invalid category data. Provide an ID or name."
|
||||
_("Invalid category data. Provide an ID or name.")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -61,13 +62,13 @@ class TransactionTagField(serializers.Field):
|
||||
tag = TransactionTag.objects.get(pk=item)
|
||||
except TransactionTag.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
f"Tag with ID {item} does not exist."
|
||||
_("Tag with this ID does not exist.")
|
||||
)
|
||||
elif isinstance(item, str):
|
||||
tag, created = TransactionTag.objects.get_or_create(name=item)
|
||||
else:
|
||||
raise serializers.ValidationError(
|
||||
"Invalid tag data. Provide an ID or name."
|
||||
_("Invalid tag data. Provide an ID or name.")
|
||||
)
|
||||
tags.append(tag)
|
||||
return tags
|
||||
@@ -85,13 +86,13 @@ class TransactionEntityField(serializers.Field):
|
||||
entity = TransactionEntity.objects.get(pk=item)
|
||||
except TransactionTag.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
f"Entity with ID {item} does not exist."
|
||||
_("Entity with this ID does not exist.")
|
||||
)
|
||||
elif isinstance(item, str):
|
||||
entity, created = TransactionEntity.objects.get_or_create(name=item)
|
||||
else:
|
||||
raise serializers.ValidationError(
|
||||
"Invalid entity data. Provide an ID or name."
|
||||
_("Invalid entity data. Provide an ID or name.")
|
||||
)
|
||||
entities.append(entity)
|
||||
return entities
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .transactions import *
|
||||
from .accounts import *
|
||||
from .currencies import *
|
||||
from .dca import *
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from apps.api.serializers.currencies import CurrencySerializer
|
||||
from apps.accounts.models import AccountGroup, Account
|
||||
@@ -6,6 +7,8 @@ from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class AccountGroupSerializer(serializers.ModelSerializer):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class Meta:
|
||||
model = AccountGroup
|
||||
fields = "__all__"
|
||||
@@ -31,6 +34,8 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = [
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
|
||||
|
||||
class CurrencySerializer(serializers.ModelSerializer):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class Meta:
|
||||
model = Currency
|
||||
fields = "__all__"
|
||||
@@ -24,6 +28,8 @@ class ExchangeRateSerializer(serializers.ModelSerializer):
|
||||
queryset=Currency.objects.all(), source="to_currency", write_only=True
|
||||
)
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class Meta:
|
||||
model = ExchangeRate
|
||||
fields = "__all__"
|
||||
|
||||
85
app/apps/api/serializers/dca.py
Normal file
85
app/apps/api/serializers/dca.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from apps.dca.models import DCAEntry, DCAStrategy
|
||||
|
||||
|
||||
class DCAEntrySerializer(serializers.ModelSerializer):
|
||||
profit_loss = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
profit_loss_percentage = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
current_value = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
entry_price = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class Meta:
|
||||
model = DCAEntry
|
||||
fields = [
|
||||
"id",
|
||||
"strategy",
|
||||
"date",
|
||||
"amount_paid",
|
||||
"amount_received",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"profit_loss",
|
||||
"profit_loss_percentage",
|
||||
"current_value",
|
||||
"entry_price",
|
||||
]
|
||||
read_only_fields = ["created_at", "updated_at"]
|
||||
|
||||
|
||||
class DCAStrategySerializer(serializers.ModelSerializer):
|
||||
entries = DCAEntrySerializer(many=True, read_only=True)
|
||||
total_invested = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
total_received = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
average_entry_price = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
total_entries = serializers.IntegerField(read_only=True)
|
||||
current_total_value = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
total_profit_loss = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
total_profit_loss_percentage = serializers.DecimalField(
|
||||
max_digits=42, decimal_places=30, read_only=True
|
||||
)
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class Meta:
|
||||
model = DCAStrategy
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"target_currency",
|
||||
"payment_currency",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"entries",
|
||||
"total_invested",
|
||||
"total_received",
|
||||
"average_entry_price",
|
||||
"total_entries",
|
||||
"current_total_value",
|
||||
"total_profit_loss",
|
||||
"total_profit_loss_percentage",
|
||||
]
|
||||
read_only_fields = ["created_at", "updated_at"]
|
||||
@@ -19,6 +19,7 @@ from apps.transactions.models import (
|
||||
TransactionTag,
|
||||
InstallmentPlan,
|
||||
TransactionEntity,
|
||||
RecurringTransaction,
|
||||
)
|
||||
|
||||
|
||||
@@ -47,11 +48,82 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class InstallmentPlanSerializer(serializers.ModelSerializer):
|
||||
category = TransactionCategoryField(required=False)
|
||||
tags = TransactionTagField(required=False)
|
||||
entities = TransactionEntityField(required=False)
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class Meta:
|
||||
model = InstallmentPlan
|
||||
fields = "__all__"
|
||||
fields = [
|
||||
"id",
|
||||
"account",
|
||||
"type",
|
||||
"description",
|
||||
"number_of_installments",
|
||||
"installment_start",
|
||||
"installment_total_number",
|
||||
"start_date",
|
||||
"reference_date",
|
||||
"end_date",
|
||||
"recurrence",
|
||||
"installment_amount",
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
"notes",
|
||||
]
|
||||
read_only_fields = ["installment_total_number", "end_date"]
|
||||
|
||||
def create(self, validated_data):
|
||||
instance = super().create(validated_data)
|
||||
instance.create_transactions()
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
instance = super().update(instance, validated_data)
|
||||
instance.update_transactions()
|
||||
return instance
|
||||
|
||||
|
||||
class RecurringTransactionSerializer(serializers.ModelSerializer):
|
||||
category = TransactionCategoryField(required=False)
|
||||
tags = TransactionTagField(required=False)
|
||||
entities = TransactionEntityField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = RecurringTransaction
|
||||
fields = [
|
||||
"id",
|
||||
"is_paused",
|
||||
"account",
|
||||
"type",
|
||||
"amount",
|
||||
"description",
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
"notes",
|
||||
"reference_date",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"recurrence_type",
|
||||
"recurrence_interval",
|
||||
"last_generated_date",
|
||||
"last_generated_reference_date",
|
||||
]
|
||||
read_only_fields = ["last_generated_date", "last_generated_reference_date"]
|
||||
|
||||
def create(self, validated_data):
|
||||
instance = super().create(validated_data)
|
||||
instance.create_upcoming_transactions()
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
instance = super().update(instance, validated_data)
|
||||
instance.update_unpaid_transactions()
|
||||
return instance
|
||||
|
||||
|
||||
class TransactionSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -9,10 +9,13 @@ router.register(r"categories", views.TransactionCategoryViewSet)
|
||||
router.register(r"tags", views.TransactionTagViewSet)
|
||||
router.register(r"entities", views.TransactionEntityViewSet)
|
||||
router.register(r"installment-plans", views.InstallmentPlanViewSet)
|
||||
router.register(r"recurring-transactions", views.RecurringTransactionViewSet)
|
||||
router.register(r"account-groups", views.AccountGroupViewSet)
|
||||
router.register(r"accounts", views.AccountViewSet)
|
||||
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)
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .transactions import *
|
||||
from .accounts import *
|
||||
from .currencies import *
|
||||
from .dca import *
|
||||
|
||||
41
app/apps/api/views/dca.py
Normal file
41
app/apps/api/views/dca.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.api.serializers import DCAStrategySerializer, DCAEntrySerializer
|
||||
|
||||
|
||||
class DCAStrategyViewSet(viewsets.ModelViewSet):
|
||||
queryset = DCAStrategy.objects.all()
|
||||
serializer_class = DCAStrategySerializer
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def investment_frequency(self, request, pk=None):
|
||||
strategy = self.get_object()
|
||||
return Response(strategy.investment_frequency_data())
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def price_comparison(self, request, pk=None):
|
||||
strategy = self.get_object()
|
||||
return Response(strategy.price_comparison_data())
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def current_price(self, request, pk=None):
|
||||
strategy = self.get_object()
|
||||
price_data = strategy.current_price()
|
||||
if price_data:
|
||||
price, date = price_data
|
||||
return Response({"price": price, "date": date})
|
||||
return Response({"price": None, "date": None})
|
||||
|
||||
|
||||
class DCAEntryViewSet(viewsets.ModelViewSet):
|
||||
queryset = DCAEntry.objects.all()
|
||||
serializer_class = DCAEntrySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = DCAEntry.objects.all()
|
||||
strategy_id = self.request.query_params.get("strategy", None)
|
||||
if strategy_id is not None:
|
||||
queryset = queryset.filter(strategy_id=strategy_id)
|
||||
return queryset
|
||||
@@ -1,4 +1,4 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from rest_framework import viewsets
|
||||
|
||||
from apps.api.serializers import (
|
||||
TransactionSerializer,
|
||||
@@ -6,6 +6,7 @@ from apps.api.serializers import (
|
||||
TransactionTagSerializer,
|
||||
InstallmentPlanSerializer,
|
||||
TransactionEntitySerializer,
|
||||
RecurringTransactionSerializer,
|
||||
)
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
@@ -13,6 +14,7 @@ from apps.transactions.models import (
|
||||
TransactionTag,
|
||||
InstallmentPlan,
|
||||
TransactionEntity,
|
||||
RecurringTransaction,
|
||||
)
|
||||
from apps.rules.signals import transaction_updated, transaction_created
|
||||
|
||||
@@ -53,10 +55,7 @@ class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = InstallmentPlan.objects.all()
|
||||
serializer_class = InstallmentPlanSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance.create_transactions()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
instance.create_transactions()
|
||||
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
||||
queryset = RecurringTransaction.objects.all()
|
||||
serializer_class = RecurringTransactionSerializer
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
|
||||
@@ -8,6 +9,12 @@ from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
def __init__(self, model, *args, **kwargs):
|
||||
self.model = model
|
||||
self.to_field_name = kwargs.pop("to_field_name", "pk")
|
||||
|
||||
self.create_field = kwargs.pop("create_field", None)
|
||||
if not self.create_field:
|
||||
raise ValueError("The 'create_field' parameter is required.")
|
||||
|
||||
self.queryset = kwargs.pop("queryset", model.objects.all())
|
||||
super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||
self._created_instance = None
|
||||
@@ -18,8 +25,7 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
if value in self.empty_values:
|
||||
return None
|
||||
try:
|
||||
key = self.to_field_name or "pk"
|
||||
return self.model.objects.get(**{key: value})
|
||||
return self.model.objects.get(**{self.to_field_name: value})
|
||||
except (ValueError, TypeError, self.model.DoesNotExist):
|
||||
return value # Return the raw value; we'll handle creation in clean()
|
||||
|
||||
@@ -49,7 +55,9 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
except self.model.DoesNotExist:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance = self.model.objects.create(name=value)
|
||||
instance, _ = self.model.objects.update_or_create(
|
||||
**{self.create_field: value}
|
||||
)
|
||||
self._created_instance = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
@@ -111,12 +119,12 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
new_instance = self.queryset.model(**{self.create_field: value})
|
||||
new_instance.full_clean()
|
||||
new_instance.save()
|
||||
return new_instance
|
||||
instance, _ = self.model.objects.update_or_create(
|
||||
**{self.create_field: value}
|
||||
)
|
||||
return instance
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Error creating new instance: {str(e)}")
|
||||
raise ValidationError(_("Error creating new instance"))
|
||||
|
||||
def clean(self, value):
|
||||
"""
|
||||
@@ -152,6 +160,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
try:
|
||||
new_objects.append(self._create_new_instance(new_value))
|
||||
except ValidationError as e:
|
||||
raise ValidationError(f"Error creating '{new_value}': {str(e)}")
|
||||
raise ValidationError(_("Error creating new instance"))
|
||||
|
||||
return existing_objects + new_objects
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import datetime
|
||||
|
||||
from django import forms
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.datepicker import AirMonthYearPickerInput
|
||||
from apps.common.widgets.month_year import MonthYearWidget
|
||||
|
||||
|
||||
@@ -18,7 +20,7 @@ class MonthYearModelField(models.DateField):
|
||||
# Set the day to 1
|
||||
return date.replace(day=1).date()
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid date format. Use YYYY-MM.")
|
||||
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
kwargs["widget"] = MonthYearWidget
|
||||
@@ -27,7 +29,7 @@ class MonthYearModelField(models.DateField):
|
||||
|
||||
|
||||
class MonthYearFormField(forms.DateField):
|
||||
widget = MonthYearWidget
|
||||
widget = AirMonthYearPickerInput
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -42,7 +44,11 @@ class MonthYearFormField(forms.DateField):
|
||||
date = datetime.datetime.strptime(value, "%Y-%m")
|
||||
return date.replace(day=1).date()
|
||||
except ValueError:
|
||||
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
|
||||
try:
|
||||
date = datetime.datetime.strptime(value, "%Y-%m-%d")
|
||||
return date.replace(day=1).date()
|
||||
except ValueError:
|
||||
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
|
||||
|
||||
def prepare_value(self, value):
|
||||
if isinstance(value, datetime.date):
|
||||
|
||||
32
app/apps/common/utils/django.py
Normal file
32
app/apps/common/utils/django.py
Normal file
@@ -0,0 +1,32 @@
|
||||
def django_to_python_datetime(django_format):
|
||||
mapping = {
|
||||
# Day
|
||||
"j": "%d", # Day of the month without leading zeros
|
||||
"d": "%d", # Day of the month with leading zeros
|
||||
"D": "%a", # Day of the week, short version
|
||||
"l": "%A", # Day of the week, full version
|
||||
# Month
|
||||
"n": "%m", # Month without leading zeros
|
||||
"m": "%m", # Month with leading zeros
|
||||
"M": "%b", # Month, short version
|
||||
"F": "%B", # Month, full version
|
||||
# Year
|
||||
"y": "%y", # Year, 2 digits
|
||||
"Y": "%Y", # Year, 4 digits
|
||||
# Time
|
||||
"g": "%I", # Hour (12-hour), without leading zeros
|
||||
"G": "%H", # Hour (24-hour), without leading zeros
|
||||
"h": "%I", # Hour (12-hour), with leading zeros
|
||||
"H": "%H", # Hour (24-hour), with leading zeros
|
||||
"i": "%M", # Minutes
|
||||
"s": "%S", # Seconds
|
||||
"a": "%p", # am/pm
|
||||
"A": "%p", # AM/PM
|
||||
"P": "%I:%M %p",
|
||||
}
|
||||
|
||||
python_format = django_format
|
||||
for django_code, python_code in mapping.items():
|
||||
python_format = python_format.replace(django_code, python_code)
|
||||
|
||||
return python_format
|
||||
185
app/apps/common/widgets/datepicker.py
Normal file
185
app/apps/common/widgets/datepicker.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import datetime
|
||||
|
||||
from django.forms import widgets
|
||||
from django.utils import formats, translation, dates
|
||||
from django.utils.formats import get_format
|
||||
|
||||
from apps.common.utils.django import django_to_python_datetime
|
||||
|
||||
|
||||
class AirDatePickerInput(widgets.DateInput):
|
||||
def __init__(
|
||||
self,
|
||||
attrs=None,
|
||||
format=None,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attrs = attrs or {}
|
||||
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
|
||||
def _get_current_language(self):
|
||||
"""Get current language code in format compatible with AirDatepicker"""
|
||||
lang_code = translation.get_language()
|
||||
# AirDatepicker uses simple language codes
|
||||
return lang_code.split("-")[0]
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
# Add data attributes for AirDatepicker configuration
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
attrs["data-language"] = self._get_current_language()
|
||||
attrs["data-date-format"] = self.format or get_format(
|
||||
"SHORT_DATE_FORMAT", use_l10n=True
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
self.attrs["data-value"] = (
|
||||
value # We use this to dynamically select the initial date on AirDatePicker
|
||||
)
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||
return formats.date_format(
|
||||
value, format=self.format or "SHORT_DATE_FORMAT", use_l10n=True
|
||||
)
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
def __init__(
|
||||
self,
|
||||
attrs=None,
|
||||
format=None,
|
||||
timepicker=True,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attrs = attrs or {}
|
||||
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
|
||||
self.timepicker = timepicker
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
|
||||
def _get_current_language(self):
|
||||
"""Get current language code in format compatible with AirDatepicker"""
|
||||
lang_code = translation.get_language()
|
||||
# AirDatepicker uses simple language codes
|
||||
return lang_code.split("-")[0]
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
# Add data attributes for AirDatepicker configuration
|
||||
attrs["data-timepicker"] = str(self.timepicker).lower()
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
attrs["data-language"] = self._get_current_language()
|
||||
attrs["data-date-format"] = self.format or get_format(
|
||||
"SHORT_DATETIME_FORMAT", use_l10n=True
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
self.attrs["data-value"] = (
|
||||
value # We use this to dynamically select the initial date on AirDatePicker
|
||||
)
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||
return formats.date_format(
|
||||
value, format=self.format or "SHORT_DATETIME_FORMAT", use_l10n=True
|
||||
)
|
||||
|
||||
return str(value)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""Parse the datetime string from the form data."""
|
||||
value = super().value_from_datadict(data, files, name)
|
||||
if value:
|
||||
try:
|
||||
# This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the
|
||||
# value to be read by Django. Probably could be improved
|
||||
return datetime.datetime.strptime(
|
||||
value,
|
||||
self.format
|
||||
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, TypeError) as e:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class AirMonthYearPickerInput(AirDatePickerInput):
|
||||
def __init__(self, attrs=None, format=None, *args, **kwargs):
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
# Store the display format for AirDatepicker
|
||||
self.display_format = "MMMM yyyy"
|
||||
# Store the Python format for internal use
|
||||
self.python_format = "%B %Y"
|
||||
|
||||
def _get_month_names(self):
|
||||
"""Get month names using Django's date translation"""
|
||||
return {dates.MONTHS[i]: i for i in range(1, 13)}
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
self.attrs["data-value"] = (
|
||||
value # We use this to dynamically select the initial date on AirDatePicker
|
||||
)
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return value
|
||||
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||
# Use Django's date translation
|
||||
month_name = dates.MONTHS[value.month]
|
||||
return f"{month_name} {value.year}"
|
||||
return value
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""Convert the value from the widget format back to a format Django can handle."""
|
||||
value = super().value_from_datadict(data, files, name)
|
||||
if value:
|
||||
try:
|
||||
# Split the value into month name and year
|
||||
month_str, year_str = value.rsplit(" ", 1)
|
||||
year = int(year_str)
|
||||
|
||||
# Get month number from translated month name
|
||||
month_names = self._get_month_names()
|
||||
month = month_names.get(month_str)
|
||||
|
||||
if month and year:
|
||||
# Return the first day of the month in Django's expected format
|
||||
return datetime.date(year, month, 1).strftime("%Y-%m-%d")
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
return None
|
||||
@@ -6,9 +6,10 @@ from django.forms import CharField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDateTimePickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
|
||||
|
||||
class CurrencyForm(forms.ModelForm):
|
||||
@@ -64,9 +65,10 @@ class CurrencyForm(forms.ModelForm):
|
||||
|
||||
class ExchangeRateForm(forms.ModelForm):
|
||||
date = forms.DateTimeField(
|
||||
widget=forms.DateTimeInput(
|
||||
attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"
|
||||
)
|
||||
widget=AirDateTimePickerInput(
|
||||
clear_button=False,
|
||||
),
|
||||
label=_("Date"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from django import forms
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Row, Column
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
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.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
|
||||
|
||||
class DCAStrategyForm(forms.ModelForm):
|
||||
@@ -61,7 +62,7 @@ class DCAEntryForm(forms.ModelForm):
|
||||
"notes",
|
||||
]
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"date": AirDatePickerInput(clear_button=False),
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelectMultiple
|
||||
from apps.currencies.models import Currency
|
||||
@@ -87,13 +88,13 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
date_start = django_filters.DateFilter(
|
||||
field_name="date",
|
||||
lookup_expr="gte",
|
||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
widget=AirDatePickerInput(),
|
||||
label=_("Date from"),
|
||||
)
|
||||
date_end = django_filters.DateFilter(
|
||||
field_name="date",
|
||||
lookup_expr="lte",
|
||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
widget=AirDatePickerInput(),
|
||||
label=_("Until"),
|
||||
)
|
||||
reference_date_start = MonthYearFilter(
|
||||
|
||||
@@ -8,6 +8,7 @@ from crispy_forms.layout import (
|
||||
Field,
|
||||
)
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
@@ -15,10 +16,11 @@ from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
from apps.common.fields.month_year import MonthYearFormField
|
||||
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 (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
@@ -27,14 +29,15 @@ from apps.transactions.models import (
|
||||
RecurringTransaction,
|
||||
TransactionEntity,
|
||||
)
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
@@ -42,6 +45,7 @@ class TransactionForm(forms.ModelForm):
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
entities = DynamicModelMultipleChoiceField(
|
||||
model=TransactionEntity,
|
||||
@@ -55,7 +59,14 @@ class TransactionForm(forms.ModelForm):
|
||||
label=_("Account"),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
)
|
||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
||||
|
||||
date = forms.DateField(
|
||||
widget=AirDatePickerInput(clear_button=False), label=_("Date")
|
||||
)
|
||||
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
@@ -73,7 +84,6 @@ class TransactionForm(forms.ModelForm):
|
||||
"entities",
|
||||
]
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
"account": TomSelect(clear_button=False, group_by="group"),
|
||||
}
|
||||
@@ -81,6 +91,24 @@ class TransactionForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing a transaction display non-archived items and it's own item even if it's archived
|
||||
if self.instance.id:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
Q(is_archived=False) | Q(transactions=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
Q(active=True) | Q(transaction=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(
|
||||
Q(active=True) | Q(transaction=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
Q(active=True) | Q(transactions=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
@@ -96,8 +124,8 @@ class TransactionForm(forms.ModelForm):
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column("date", css_class="form-group col-md-6 mb-0"),
|
||||
Column("reference_date", css_class="form-group col-md-6 mb-0"),
|
||||
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",
|
||||
),
|
||||
"description",
|
||||
@@ -181,14 +209,18 @@ class TransferForm(forms.Form):
|
||||
)
|
||||
|
||||
from_category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
to_category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
|
||||
from_tags = DynamicModelMultipleChoiceField(
|
||||
@@ -197,6 +229,7 @@ class TransferForm(forms.Form):
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
to_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
@@ -204,13 +237,17 @@ class TransferForm(forms.Form):
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
|
||||
date = forms.DateField(
|
||||
label=_("Date"),
|
||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
widget=AirDatePickerInput(clear_button=False), label=_("Date")
|
||||
)
|
||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
||||
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||
)
|
||||
|
||||
description = forms.CharField(max_length=500, label=_("Description"))
|
||||
notes = forms.CharField(
|
||||
required=False,
|
||||
@@ -299,7 +336,7 @@ class TransferForm(forms.Form):
|
||||
to_account = cleaned_data.get("to_account")
|
||||
|
||||
if from_account == to_account:
|
||||
raise forms.ValidationError("From and To accounts must be different.")
|
||||
raise forms.ValidationError(_("From and To accounts must be different."))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
@@ -358,11 +395,14 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
entities = DynamicModelMultipleChoiceField(
|
||||
model=TransactionEntity,
|
||||
@@ -370,9 +410,13 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Entities"),
|
||||
queryset=TransactionEntity.objects.filter(active=True),
|
||||
)
|
||||
type = forms.ChoiceField(choices=Transaction.Type.choices)
|
||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
||||
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InstallmentPlan
|
||||
@@ -392,15 +436,33 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
"entities",
|
||||
]
|
||||
widgets = {
|
||||
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"account": TomSelect(),
|
||||
"recurrence": TomSelect(clear_button=False),
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
"start_date": AirDatePickerInput(clear_button=False),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing display non-archived items and it's own item even if it's archived
|
||||
if self.instance.id:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
Q(is_archived=False) | Q(installmentplan=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
Q(active=True) | Q(installmentplan=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(
|
||||
Q(active=True) | Q(installmentplan=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
Q(active=True) | Q(installmentplan=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
@@ -470,7 +532,7 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
class TransactionTagForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionTag
|
||||
fields = ["name"]
|
||||
fields = ["name", "active"]
|
||||
labels = {"name": _("Tag name")}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -479,7 +541,7 @@ class TransactionTagForm(forms.ModelForm):
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(Field("name", css_class="mb-3"))
|
||||
self.helper.layout = Layout(Field("name", css_class="mb-3"), Switch("active"))
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
@@ -502,7 +564,7 @@ class TransactionTagForm(forms.ModelForm):
|
||||
class TransactionEntityForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionEntity
|
||||
fields = ["name"]
|
||||
fields = ["name", "active"]
|
||||
labels = {"name": _("Entity name")}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -511,7 +573,7 @@ class TransactionEntityForm(forms.ModelForm):
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(Field("name", css_class="mb-3"))
|
||||
self.helper.layout = Layout(Field("name"), Switch("active"))
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
@@ -534,7 +596,7 @@ class TransactionEntityForm(forms.ModelForm):
|
||||
class TransactionCategoryForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionCategory
|
||||
fields = ["name", "mute"]
|
||||
fields = ["name", "mute", "active"]
|
||||
labels = {"name": _("Category name")}
|
||||
help_texts = {
|
||||
"mute": _("Muted categories won't count towards your monthly total")
|
||||
@@ -546,7 +608,7 @@ class TransactionCategoryForm(forms.ModelForm):
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(Field("name", css_class="mb-3"), Switch("mute"))
|
||||
self.helper.layout = Layout(Field("name"), Switch("mute"), Switch("active"))
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
@@ -578,11 +640,14 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
entities = DynamicModelMultipleChoiceField(
|
||||
model=TransactionEntity,
|
||||
@@ -590,9 +655,9 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Entities"),
|
||||
queryset=TransactionEntity.objects.filter(active=True),
|
||||
)
|
||||
type = forms.ChoiceField(choices=Transaction.Type.choices)
|
||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
||||
|
||||
class Meta:
|
||||
model = RecurringTransaction
|
||||
@@ -612,8 +677,9 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
"entities",
|
||||
]
|
||||
widgets = {
|
||||
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"end_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"start_date": AirDatePickerInput(clear_button=False),
|
||||
"end_date": AirDatePickerInput(),
|
||||
"reference_date": AirMonthYearPickerInput(),
|
||||
"recurrence_type": TomSelect(clear_button=False),
|
||||
"notes": forms.Textarea(
|
||||
attrs={
|
||||
@@ -624,6 +690,25 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing display non-archived items and it's own item even if it's archived
|
||||
if self.instance.id:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
Q(is_archived=False) | Q(recurringtransaction=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
Q(active=True) | Q(recurringtransaction=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(
|
||||
Q(active=True) | Q(recurringtransaction=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
Q(active=True) | Q(recurringtransaction=self.instance.id)
|
||||
).distinct()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
@@ -694,5 +779,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
instance = super().save(**kwargs)
|
||||
if is_new:
|
||||
instance.create_upcoming_transactions()
|
||||
else:
|
||||
instance.update_unpaid_transactions()
|
||||
|
||||
return instance
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.3 on 2025-01-04 19:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0024_installmentplan_entities_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transactioncategory',
|
||||
name='active',
|
||||
field=models.BooleanField(default=True, help_text="Deactivated categories won't be able to be selected when creating new transactions", verbose_name='Active'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transactiontag',
|
||||
name='active',
|
||||
field=models.BooleanField(default=True, help_text="Deactivated tags won't be able to be selected when creating new transactions", verbose_name='Active'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2025-01-04 19:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0025_transactioncategory_active_transactiontag_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transactionentity',
|
||||
name='active',
|
||||
field=models.BooleanField(default=True, help_text="Deactivated entities won't be able to be selected when creating new transactions", verbose_name='Active'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-14 12:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0026_transactionentity_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Description'),
|
||||
),
|
||||
]
|
||||
@@ -18,6 +18,13 @@ logger = logging.getLogger()
|
||||
class TransactionCategory(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Active"),
|
||||
help_text=_(
|
||||
"Deactivated categories won't be able to be selected when creating new transactions"
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction Category")
|
||||
@@ -30,6 +37,13 @@ class TransactionCategory(models.Model):
|
||||
|
||||
class TransactionTag(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Active"),
|
||||
help_text=_(
|
||||
"Deactivated tags won't be able to be selected when creating new transactions"
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction Tags")
|
||||
@@ -42,8 +56,13 @@ class TransactionTag(models.Model):
|
||||
|
||||
class TransactionEntity(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
|
||||
# Add any other fields you might want for entities
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Active"),
|
||||
help_text=_(
|
||||
"Deactivated entities won't be able to be selected when creating new transactions"
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Entity")
|
||||
@@ -82,7 +101,9 @@ class Transaction(models.Model):
|
||||
validators=[validate_non_negative, validate_decimal_places],
|
||||
)
|
||||
|
||||
description = models.CharField(max_length=500, verbose_name=_("Description"))
|
||||
description = models.CharField(
|
||||
max_length=500, verbose_name=_("Description"), blank=True
|
||||
)
|
||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||
category = models.ForeignKey(
|
||||
TransactionCategory,
|
||||
@@ -315,10 +336,15 @@ class InstallmentPlan(models.Model):
|
||||
existing_transaction.type = self.type
|
||||
existing_transaction.date = transaction_date
|
||||
existing_transaction.reference_date = transaction_reference_date
|
||||
existing_transaction.amount = self.installment_amount
|
||||
existing_transaction.description = self.description
|
||||
existing_transaction.category = self.category
|
||||
existing_transaction.notes = self.notes
|
||||
|
||||
if (
|
||||
not existing_transaction.is_paid
|
||||
): # Don't update value for paid transactions
|
||||
existing_transaction.amount = self.installment_amount
|
||||
|
||||
existing_transaction.save()
|
||||
|
||||
# Update tags
|
||||
@@ -521,3 +547,33 @@ class RecurringTransaction(models.Model):
|
||||
recurring_transaction.save(
|
||||
update_fields=["last_generated_date", "last_generated_reference_date"]
|
||||
)
|
||||
|
||||
def update_unpaid_transactions(self):
|
||||
"""
|
||||
Updates all unpaid transactions associated with this RecurringTransaction.
|
||||
|
||||
Only unpaid transactions (`is_paid=False`) are modified. Updates fields like
|
||||
amount, description, category, notes, and many-to-many relationships (tags, entities).
|
||||
"""
|
||||
unpaid_transactions = self.transactions.filter(is_paid=False)
|
||||
|
||||
for existing_transaction in unpaid_transactions:
|
||||
# Update fields based on RecurringTransaction
|
||||
existing_transaction.amount = self.amount
|
||||
existing_transaction.description = self.description
|
||||
existing_transaction.category = self.category
|
||||
existing_transaction.notes = self.notes
|
||||
|
||||
# Update many-to-many relationships
|
||||
existing_transaction.tags.set(self.tags.all())
|
||||
existing_transaction.entities.set(self.entities.all())
|
||||
|
||||
# Save updated transaction
|
||||
existing_transaction.save()
|
||||
|
||||
def delete_unpaid_transactions(self):
|
||||
"""
|
||||
Deletes all unpaid transactions associated with this RecurringTransaction.
|
||||
"""
|
||||
today = timezone.localdate(timezone.now())
|
||||
self.transactions.filter(is_paid=False, date__gt=today).delete()
|
||||
|
||||
@@ -53,6 +53,8 @@ urlpatterns = [
|
||||
),
|
||||
path("tags/", views.tags_index, name="tags_index"),
|
||||
path("tags/list/", views.tags_list, name="tags_list"),
|
||||
path("tags/table/active/", views.tags_table_active, name="tags_table_active"),
|
||||
path("tags/table/archived/", views.tags_table_archived, name="tags_table_archived"),
|
||||
path("tags/add/", views.tag_add, name="tag_add"),
|
||||
path(
|
||||
"tags/<int:tag_id>/edit/",
|
||||
@@ -66,6 +68,16 @@ urlpatterns = [
|
||||
),
|
||||
path("entities/", views.entities_index, name="entities_index"),
|
||||
path("entities/list/", views.entities_list, name="entities_list"),
|
||||
path(
|
||||
"entities/table/active/",
|
||||
views.entities_table_active,
|
||||
name="entities_table_active",
|
||||
),
|
||||
path(
|
||||
"entities/table/archived/",
|
||||
views.entities_table_archived,
|
||||
name="entities_table_archived",
|
||||
),
|
||||
path("entities/add/", views.entity_add, name="entity_add"),
|
||||
path(
|
||||
"entities/<int:entity_id>/edit/",
|
||||
@@ -79,6 +91,16 @@ urlpatterns = [
|
||||
),
|
||||
path("categories/", views.categories_index, name="categories_index"),
|
||||
path("categories/list/", views.categories_list, name="categories_list"),
|
||||
path(
|
||||
"categories/table/active/",
|
||||
views.categories_table_active,
|
||||
name="categories_table_active",
|
||||
),
|
||||
path(
|
||||
"categories/table/archived/",
|
||||
views.categories_table_archived,
|
||||
name="categories_table_archived",
|
||||
),
|
||||
path("categories/add/", views.category_add, name="category_add"),
|
||||
path(
|
||||
"categories/<int:category_id>/edit/",
|
||||
|
||||
@@ -25,11 +25,33 @@ def categories_index(request):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def categories_list(request):
|
||||
categories = TransactionCategory.objects.all().order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"categories/fragments/list.html",
|
||||
{"categories": categories},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def categories_table_active(request):
|
||||
categories = TransactionCategory.objects.filter(active=True).order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"categories/fragments/table.html",
|
||||
{"categories": categories, "active": True},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def categories_table_archived(request):
|
||||
categories = TransactionCategory.objects.filter(active=False).order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"categories/fragments/table.html",
|
||||
{"categories": categories, "active": False},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,11 +24,33 @@ def entities_index(request):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def entities_list(request):
|
||||
entities = TransactionEntity.objects.all().order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"entities/fragments/list.html",
|
||||
{"entities": entities},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def entities_table_active(request):
|
||||
entities = TransactionEntity.objects.filter(active=True).order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"entities/fragments/table.html",
|
||||
{"entities": entities, "active": True},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def entities_table_archived(request):
|
||||
entities = TransactionEntity.objects.filter(active=False).order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"entities/fragments/table.html",
|
||||
{"entities": entities, "active": False},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -168,12 +168,26 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
|
||||
)
|
||||
current_paused = recurring_transaction.is_paused
|
||||
recurring_transaction.is_paused = not current_paused
|
||||
recurring_transaction.save(update_fields=["is_paused"])
|
||||
|
||||
if current_paused:
|
||||
messages.success(request, _("Recurring transaction unpaused successfully"))
|
||||
today = timezone.localdate(timezone.now())
|
||||
recurring_transaction.last_generated_date = max(
|
||||
recurring_transaction.last_generated_date, today
|
||||
)
|
||||
recurring_transaction.last_generated_reference_date = max(
|
||||
recurring_transaction.last_generated_reference_date, today
|
||||
)
|
||||
recurring_transaction.save(
|
||||
update_fields=[
|
||||
"last_generated_date",
|
||||
"last_generated_reference_date",
|
||||
"is_paused",
|
||||
]
|
||||
)
|
||||
generate_recurring_transactions.defer()
|
||||
messages.success(request, _("Recurring transaction unpaused successfully"))
|
||||
else:
|
||||
recurring_transaction.save(update_fields=["is_paused"])
|
||||
messages.success(request, _("Recurring transaction paused successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
@@ -188,7 +202,7 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def recurring_transaction_finish(request, recurring_transaction_id):
|
||||
recurring_transaction = get_object_or_404(
|
||||
recurring_transaction: RecurringTransaction = get_object_or_404(
|
||||
RecurringTransaction, id=recurring_transaction_id
|
||||
)
|
||||
today = timezone.localdate(timezone.now()) - relativedelta(days=1)
|
||||
@@ -197,6 +211,9 @@ def recurring_transaction_finish(request, recurring_transaction_id):
|
||||
recurring_transaction.is_paused = True
|
||||
recurring_transaction.save(update_fields=["end_date", "is_paused"])
|
||||
|
||||
# Delete all unpaid transactions associated with this RecurringTransaction
|
||||
recurring_transaction.delete_unpaid_transactions()
|
||||
|
||||
messages.success(request, _("Recurring transaction finished successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
|
||||
@@ -24,11 +24,33 @@ def tags_index(request):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def tags_list(request):
|
||||
tags = TransactionTag.objects.all().order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"tags/fragments/list.html",
|
||||
{"tags": tags},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def tags_table_active(request):
|
||||
tags = TransactionTag.objects.filter(active=True).order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"tags/fragments/table.html",
|
||||
{"tags": tags, "active": True},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def tags_table_archived(request):
|
||||
tags = TransactionTag.objects.filter(active=False).order_by("id")
|
||||
return render(
|
||||
request,
|
||||
"tags/fragments/table.html",
|
||||
{"tags": tags, "active": False},
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,53 +15,18 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
{% if categories %}
|
||||
<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"
|
||||
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'
|
||||
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 class="col">{{ category.name }}</td>
|
||||
<td class="col">
|
||||
{% if category.mute %}<i class="fa-solid fa-check text-success"></i>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No categories" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
<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>
|
||||
<div class="card-body">
|
||||
<div id="categories-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
59
app/templates/categories/fragments/table.html
Normal file
59
app/templates/categories/fragments/table.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% load i18n %}
|
||||
{% if active %}
|
||||
<div class="show-loading" hx-get="{% url 'categories_table_active' %}" hx-trigger="updated from:window"
|
||||
hx-swap="outerHTML">
|
||||
{% else %}
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No categories" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -4,5 +4,5 @@
|
||||
{% block title %}{% translate 'Categories' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div hx-get="{% url 'categories_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
|
||||
<div hx-get="{% url 'categories_list' %}" hx-trigger="load" class="show-loading"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% load i18n %}
|
||||
<div class="transaction d-flex my-1">
|
||||
<div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
|
||||
{% if not disable_selection %}
|
||||
<label class="px-3 d-flex align-items-center justify-content-center">
|
||||
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}" id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
||||
|
||||
@@ -11,70 +11,228 @@
|
||||
<div class="card slide-in-left">
|
||||
<div class="card-body p-2">
|
||||
{% spaceless %}
|
||||
<div class="btn-group" role="group">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Select All' %}"
|
||||
_="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"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Unselect All' %}"
|
||||
_="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"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="vr mx-3 tw-align-middle"></div>
|
||||
<div class="btn-group me-3" role="group">
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_pay' %}"
|
||||
hx-include=".transaction"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Mark as paid' %}">
|
||||
<i class="fa-regular fa-circle-check tw-text-green-400"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_unpay' %}"
|
||||
hx-include=".transaction"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Mark as unpaid' %}">
|
||||
<i class="fa-regular fa-circle tw-text-red-400"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Select All' %}"
|
||||
_="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"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Unselect All' %}"
|
||||
_="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"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="vr mx-3 tw-align-middle"></div>
|
||||
<div class="btn-group me-3" role="group">
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_pay' %}"
|
||||
hx-include=".transaction"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Mark as paid' %}">
|
||||
<i class="fa-regular fa-circle-check tw-text-green-400"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_unpay' %}"
|
||||
hx-get="{% url 'transactions_bulk_delete' %}"
|
||||
hx-include=".transaction"
|
||||
hx-trigger="confirmed"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate 'Mark as unpaid' %}">
|
||||
<i class="fa-regular fa-circle tw-text-red-400"></i>
|
||||
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>
|
||||
<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 mx-3 tw-align-middle"></div>
|
||||
<span _="on selected_transactions_updated from #actions-bar
|
||||
set total to 0.0
|
||||
for amt in <.transaction:has(input[name='transactions']:checked) .main-amount .amount/>
|
||||
set amountValue to parseFloat(amt.getAttribute('data-amount'))
|
||||
if not isNaN(amountValue)
|
||||
set total to total + (amountValue * 100)
|
||||
<div class="vr mx-3 tw-align-middle"></div>
|
||||
<div class="btn-group"
|
||||
_="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)/>
|
||||
set flatAmountValues to []
|
||||
set realAmountValues to []
|
||||
|
||||
for transaction in transactions
|
||||
set amt to first <.main-amount .amount/> in transaction
|
||||
set amountValue to parseFloat(amt.getAttribute('data-amount'))
|
||||
append amountValue to flatAmountValues
|
||||
|
||||
if not isNaN(amountValue)
|
||||
set flatTotal to math.chain(flatTotal).add(amountValue)
|
||||
|
||||
if transaction match .income
|
||||
append amountValue to realAmountValues
|
||||
set realTotal to math.chain(realTotal).add(amountValue)
|
||||
else
|
||||
append -amountValue to realAmountValues
|
||||
set realTotal to math.chain(realTotal).subtract(amountValue)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
set total to total / 100
|
||||
put total.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into me
|
||||
end
|
||||
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"
|
||||
class="" role="button"></span>
|
||||
|
||||
set mean to flatTotal.divide(flatAmountValues.length).done().toNumber()
|
||||
set realTotal to realTotal.done().toNumber()
|
||||
set flatTotal to flatTotal.done().toNumber()
|
||||
|
||||
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #real-total-front's innerText
|
||||
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-real-total's innerText
|
||||
put flatTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-flat-total's innerText
|
||||
put Math.max.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-max's innerText
|
||||
put Math.min.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-min's innerText
|
||||
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
|
||||
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-2 text-primary"></i>
|
||||
<span 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>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<div>
|
||||
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
|
||||
{% 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">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<div>
|
||||
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
|
||||
{% 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">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<div>
|
||||
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
|
||||
{% 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">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<div>
|
||||
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
|
||||
{% 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">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<div>
|
||||
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
|
||||
{% 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">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="dropdown-item-text p-0">
|
||||
<div>
|
||||
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
|
||||
{% 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">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,49 +15,18 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
{% if entities %}
|
||||
<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 entity in entities %}
|
||||
<tr class="entity">
|
||||
<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 'entity_edit' entity_id=entity.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 'entity_delete' entity_id=entity.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 class="col">{{ entity.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No entities" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
<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 'entities_table_active' %}" hx-trigger="load, click" hx-target="#entities-table">{% translate 'Active' %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" hx-get="{% url 'entities_table_archived' %}" hx-target="#entities-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="entities-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
55
app/templates/entities/fragments/table.html
Normal file
55
app/templates/entities/fragments/table.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% load i18n %}
|
||||
{% if active %}
|
||||
<div class="show-loading" hx-get="{% url 'entities_table_active' %}" hx-trigger="updated from:window"
|
||||
hx-swap="outerHTML">
|
||||
{% else %}
|
||||
<div class="show-loading" hx-get="{% url 'entities_table_archived' %}" hx-trigger="updated from:window"
|
||||
hx-swap="outerHTML">
|
||||
{% endif %}
|
||||
{% if entities %}
|
||||
<div class="table-responsive">
|
||||
<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 entity in entities %}
|
||||
<tr class="entity">
|
||||
<td class="col-auto">
|
||||
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
role="button"
|
||||
hx-swap="innerHTML"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'entity_edit' entity_id=entity.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"
|
||||
hx-swap="innerHTML"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'entity_delete' entity_id=entity.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 class="col">{{ entity.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No entities" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -4,5 +4,5 @@
|
||||
{% block title %}{% translate 'Entities' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div hx-get="{% url 'entities_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
|
||||
<div hx-get="{% url 'entities_list' %}" hx-trigger="load" class="show-loading"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label={% translate 'Close' %}></button>
|
||||
</div>
|
||||
<div id="generic-offcanvas-body" class="offcanvas-body"
|
||||
_="install init_tom_select">
|
||||
_="install init_tom_select
|
||||
install init_datepicker">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
{% javascript_pack 'bootstrap' attrs="defer" %}
|
||||
{% javascript_pack 'sweetalert2' attrs="defer" %}
|
||||
{% javascript_pack 'select' attrs="defer" %}
|
||||
{% javascript_pack 'datepicker' %}
|
||||
|
||||
{% include 'includes/scripts/hyperscript/init_tom_select.html' %}
|
||||
{% include 'includes/scripts/hyperscript/init_date_picker.html' %}
|
||||
{% include 'includes/scripts/hyperscript/hide_amount.html' %}
|
||||
{% include 'includes/scripts/hyperscript/tooltip.html' %}
|
||||
{% include 'includes/scripts/hyperscript/htmx_error_handler.html' %}
|
||||
{% include 'includes/scripts/hyperscript/sounds.html' %}
|
||||
{% include 'includes/scripts/hyperscript/swal.html' %}
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
{% javascript_pack 'htmx' attrs="defer" %}
|
||||
{% javascript_pack 'charts' %}
|
||||
{#<script src="https://unpkg.com/htmx-ext-alpine-morph@2.0.0/alpine-morph.js"></script>#}
|
||||
|
||||
|
||||
<script>
|
||||
let tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script type="text/hyperscript">
|
||||
behavior init_datepicker
|
||||
init
|
||||
set datepickers to <.airdatepickerinput/> in me
|
||||
for x in datepickers
|
||||
js(it)
|
||||
DatePicker(it)
|
||||
end
|
||||
end
|
||||
set datepickers to <.airdatetimepickerinput/> in me
|
||||
for x in datepickers
|
||||
js(it)
|
||||
DatePicker(it)
|
||||
end
|
||||
end
|
||||
set datepickers to <.airmonthyearpickerinput/> in me
|
||||
for x in datepickers
|
||||
MonthYearPicker(it)
|
||||
end
|
||||
end
|
||||
end
|
||||
</script>
|
||||
@@ -66,11 +66,10 @@
|
||||
})
|
||||
end
|
||||
then set expr to it
|
||||
then call math.evaluate(expr)
|
||||
then call math.evaluate(expr).toNumber()
|
||||
if result exists and result is a Number
|
||||
js(result)
|
||||
return result.toString().replace(new RegExp(',|\\.', 'g'),
|
||||
match => match === '.' ? window.decimalSeparator : window.argSeparator)
|
||||
return result.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40})
|
||||
end
|
||||
then set localizedResult to it
|
||||
set #calculator-result.innerText to localizedResult
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
{% block title %}{% translate 'Currency Converter' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-md-3 py-3 column-gap-5" _="install init_tom_select">
|
||||
<div class="container px-md-3 py-3 column-gap-5"
|
||||
_="install init_tom_select
|
||||
install init_datepicker">
|
||||
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
|
||||
<div>{% translate 'Currency Converter' %}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div>
|
||||
<input class="form-control form-control-lg mb-3"
|
||||
type="text"
|
||||
@@ -25,13 +27,14 @@
|
||||
</div>
|
||||
<div>{{ form.from_currency|as_crispy_field }}</div>
|
||||
</div>
|
||||
<div class="col text-primary tw-flex tw-items-center tw-justify-center">
|
||||
<div class="col text-primary tw-flex tw-items-center tw-justify-center my-3 my-lg-0">
|
||||
<i class="fa-solid fa-equals"></i>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div hx-get="{% url 'currency_converter_convert' %}"
|
||||
hx-trigger="input from:#from_value, input from:#id_from_currency, input from:#id_to_currency"
|
||||
hx-include="#from_value, #id_from_currency, #id_to_currency">
|
||||
hx-trigger="input from:#from_value, input from:#id_from_currency, input from:#id_to_currency, updated"
|
||||
hx-include="#from_value, #id_from_currency, #id_to_currency"
|
||||
id="result">
|
||||
<input class="form-control form-control-lg mb-3"
|
||||
type="text"
|
||||
name="to_value"
|
||||
@@ -41,5 +44,19 @@
|
||||
<div>{{ form.to_currency|as_crispy_field }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="tw-cursor-pointer text-primary text-end"
|
||||
_="on click
|
||||
set from_value to #id_from_currency's value
|
||||
set to_value to #id_to_currency's value
|
||||
set #id_from_currency's value to to_value
|
||||
set #id_to_currency's value to from_value
|
||||
call #id_from_currency.tomselect.sync()
|
||||
call #id_to_currency.tomselect.sync()
|
||||
trigger updated on #result
|
||||
end">
|
||||
<i class="fa-solid fa-rotate me-2"></i><span>{% trans 'Invert' %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -124,7 +124,8 @@
|
||||
<div class="collapse" id="collapse-filter">
|
||||
<div class="card card-body">
|
||||
<form _="on change or submit or search trigger updated on window end
|
||||
install init_tom_select"
|
||||
install init_tom_select
|
||||
install init_datepicker"
|
||||
id="filter">
|
||||
{% crispy filter.form %}
|
||||
</form>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block title %}{% if type == "current" %}{% translate 'Current Net Worth' %}{% else %}{% translate 'Projected Net Worth' %}{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-md-3 py-3">
|
||||
<div class="container px-md-3 py-3" _="init call initializeAccountChart() then initializeCurrencyChart() end">
|
||||
<div class="row gx-xl-4 gy-3 mb-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="row row-cols-1 g-4">
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="chart-container position-relativo tw-min-h-[40vh] tw-h-full">
|
||||
<div class="chart-container position-relative tw-min-h-[40vh] tw-h-full">
|
||||
<canvas id="currencyBalanceChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +136,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="chart-container position-relativo tw-min-h-[40vh] tw-h-full">
|
||||
<div class="chart-container position-relative tw-min-h-[40vh] tw-h-full">
|
||||
<canvas id="accountBalanceChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,13 +144,19 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener('htmx:load', function (evt) {
|
||||
var currencyChart;
|
||||
|
||||
function initializeCurrencyChart() {
|
||||
// Destroy existing chart if it exists
|
||||
if (currencyChart) {
|
||||
currencyChart.destroy();
|
||||
}
|
||||
|
||||
var chartData = JSON.parse('{{ chart_data_currency_json|safe }}');
|
||||
var currencies = {{ currencies|safe }};
|
||||
|
||||
var ctx = document.getElementById('currencyBalanceChart').getContext('2d');
|
||||
|
||||
new Chart(ctx, {
|
||||
currencyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
@@ -197,17 +203,23 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener('htmx:load', function (evt) {
|
||||
<script id="accountBalanceChartScript">
|
||||
var accountChart;
|
||||
|
||||
function initializeAccountChart() {
|
||||
// Destroy existing chart if it exists
|
||||
if (accountChart) {
|
||||
accountChart.destroy();
|
||||
}
|
||||
|
||||
var chartData = JSON.parse('{{ chart_data_accounts_json|safe }}');
|
||||
var accounts = {{ accounts|safe }};
|
||||
|
||||
var ctx = document.getElementById('accountBalanceChart').getContext('2d');
|
||||
|
||||
new Chart(ctx, {
|
||||
accountChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
@@ -256,43 +268,38 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="text/hyperscript">
|
||||
def showOnlyAccountDataset(datasetName)
|
||||
set chart to Chart.getChart('accountBalanceChart')
|
||||
for dataset in chart.data.datasets
|
||||
for dataset in accountChart.data.datasets
|
||||
set isMatch to dataset.label is datasetName
|
||||
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), isMatch)
|
||||
call accountChart.setDatasetVisibility(accountChart.data.datasets.indexOf(dataset), isMatch)
|
||||
end
|
||||
call chart.update()
|
||||
call accountChart.update()
|
||||
end
|
||||
|
||||
def showOnlyCurrencyDataset(datasetName)
|
||||
set chart to Chart.getChart('currencyBalanceChart')
|
||||
for dataset in chart.data.datasets
|
||||
for dataset in currencyChart.data.datasets
|
||||
set isMatch to dataset.label is datasetName
|
||||
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), isMatch)
|
||||
call currencyChart.setDatasetVisibility(currencyChart.data.datasets.indexOf(dataset), isMatch)
|
||||
end
|
||||
call chart.update()
|
||||
call currencyChart.update()
|
||||
end
|
||||
|
||||
def showAllDatasetsAccount()
|
||||
set chart to Chart.getChart('accountBalanceChart')
|
||||
for dataset in chart.data.datasets
|
||||
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), true)
|
||||
for dataset in accountChart.data.datasets
|
||||
call accountChart.setDatasetVisibility(accountChart.data.datasets.indexOf(dataset), true)
|
||||
end
|
||||
call chart.update()
|
||||
call accountChart.update()
|
||||
end
|
||||
|
||||
def showAllDatasetsCurrency()
|
||||
set chart to Chart.getChart('currencyBalanceChart')
|
||||
for dataset in chart.data.datasets
|
||||
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), true)
|
||||
for dataset in currencyChart.data.datasets
|
||||
call currencyChart.setDatasetVisibility(currencyChart.data.datasets.indexOf(dataset), true)
|
||||
end
|
||||
call chart.update()
|
||||
call currencyChart.update()
|
||||
end
|
||||
</script>
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
hx-swap="innerHTML"
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "This will stop the creation of new transactions" %}"
|
||||
data-text="{% translate "This will stop the creation of new transactions and delete any unpaid transactions after today" %}"
|
||||
data-confirm-text="{% translate "Yes, finish it!" %}"
|
||||
_="install prompt_swal">
|
||||
<i class="fa-solid fa-flag-checkered fa-fw"></i></a>
|
||||
|
||||
@@ -15,49 +15,18 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
{% if tags %}
|
||||
<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 tag in tags %}
|
||||
<tr class="tag">
|
||||
<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 'tag_edit' tag_id=tag.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 'tag_delete' tag_id=tag.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 class="col">{{ tag.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No tags" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
<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 'tags_table_active' %}" hx-trigger="load, click" hx-target="#tags-table">{% translate 'Active' %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" hx-get="{% url 'tags_table_archived' %}" hx-target="#tags-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="tags-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
55
app/templates/tags/fragments/table.html
Normal file
55
app/templates/tags/fragments/table.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% load i18n %}
|
||||
{% if active %}
|
||||
<div class="show-loading" hx-get="{% url 'tags_table_active' %}" hx-trigger="updated from:window"
|
||||
hx-swap="outerHTML">
|
||||
{% else %}
|
||||
<div class="show-loading" hx-get="{% url 'tags_table_archived' %}" hx-trigger="updated from:window"
|
||||
hx-swap="outerHTML">
|
||||
{% endif %}
|
||||
{% if tags %}
|
||||
<div class="table-responsive">
|
||||
<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 tag in tags %}
|
||||
<tr class="tag">
|
||||
<td class="col-auto">
|
||||
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
role="button"
|
||||
hx-swap="innerHTML"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'tag_edit' tag_id=tag.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"
|
||||
hx-swap="innerHTML"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'tag_delete' tag_id=tag.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 class="col">{{ tag.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No tags" %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -4,5 +4,5 @@
|
||||
{% block title %}{% translate 'Tags' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div hx-get="{% url 'tags_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
|
||||
<div hx-get="{% url 'tags_list' %}" hx-trigger="load" class="show-loading"></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
<hr>
|
||||
<form hx-get="{% url 'transactions_all_list' %}" hx-trigger="change, submit, search"
|
||||
hx-target="#transactions" id="filter" hx-indicator="#transactions"
|
||||
_="install init_tom_select">
|
||||
_="install init_tom_select
|
||||
install init_datepicker">
|
||||
{% crispy filter.form %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
services:
|
||||
web: &django
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/prod/django/Dockerfile
|
||||
image: ${SERVER_NAME}
|
||||
image: eitchtee/wygiwyh:latest
|
||||
container_name: ${SERVER_NAME}
|
||||
command: /start
|
||||
ports:
|
||||
@@ -27,7 +24,6 @@ services:
|
||||
|
||||
procrastinate:
|
||||
<<: *django
|
||||
image: ${PROCRASTINATE_NAME}
|
||||
container_name: ${PROCRASTINATE_NAME}
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
@@ -6,16 +6,16 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY ./requirements.txt .
|
||||
RUN pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt
|
||||
|
||||
FROM node:lts-alpine AS webpack_build
|
||||
WORKDIR /usr/src/frontend
|
||||
COPY ./frontend .
|
||||
COPY ./app/templates /usr/src/app/templates
|
||||
RUN npm config set registry https://registry.npmmirror.com/ && \
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --verbose && \
|
||||
npm run build && \
|
||||
npm cache clean --force
|
||||
npm run build
|
||||
|
||||
FROM python:3.11-slim-buster AS python-run-stage
|
||||
COPY --from=webpack_build /usr/src/frontend/build /usr/src/frontend/build
|
||||
@@ -29,12 +29,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
|
||||
RUN apt-get update && \
|
||||
RUN --mount=type=cache,target=/root/.cache/apt \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y gettext && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
pip install --upgrade pip && \
|
||||
pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* && \
|
||||
rm -rf /wheels/ ~/.cache/pip/*
|
||||
rm -rf /wheels/
|
||||
|
||||
COPY --chown=app:app ./docker/prod/django/start /start
|
||||
COPY --chown=app:app ./docker/prod/procrastinate/start /start-procrastinate
|
||||
|
||||
45
frontend/package-lock.json
generated
45
frontend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@babel/preset-env": "^7.16.8",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"air-datepicker": "^3.5.3",
|
||||
"alpinejs": "^3.14.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"autosize": "^6.0.1",
|
||||
@@ -2703,6 +2704,12 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/air-datepicker": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.5.3.tgz",
|
||||
"integrity": "sha512-Elf9gLhv/jidN1+TfeRJYMQRUfYx5apXw2dY5DuAMPRnNtQ4Iw9fTTJK772osmXSUB9xQ2Y8Q1Pt6pgBOQLPQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -3441,9 +3448,9 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -4499,16 +4506,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
|
||||
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.6.0",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@@ -4522,7 +4529,7 @@
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.10",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
@@ -4537,6 +4544,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
@@ -5219,9 +5230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
|
||||
"integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==",
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
|
||||
"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.8",
|
||||
"http-proxy": "^1.18.1",
|
||||
@@ -6179,9 +6190,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6535,9 +6546,9 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
|
||||
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w=="
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@babel/preset-env": "^7.16.8",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"air-datepicker": "^3.5.3",
|
||||
"alpinejs": "^3.14.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"autosize": "^6.0.1",
|
||||
|
||||
148
frontend/src/application/datepicker.js
Normal file
148
frontend/src/application/datepicker.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import AirDatepicker from 'air-datepicker';
|
||||
import en from 'air-datepicker/locale/en';
|
||||
import ptBr from 'air-datepicker/locale/pt-BR';
|
||||
import {createPopper} from '@popperjs/core';
|
||||
|
||||
const locales = {
|
||||
'pt': ptBr,
|
||||
'en': en
|
||||
};
|
||||
|
||||
function isMobileDevice() {
|
||||
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
|
||||
return mobileRegex.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function isTouchDevice() {
|
||||
return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
return isMobileDevice() || isTouchDevice();
|
||||
}
|
||||
|
||||
window.DatePicker = function createDynamicDatePicker(element) {
|
||||
let isOnMobile = isMobile();
|
||||
|
||||
let baseOpts = {
|
||||
isMobile: isOnMobile,
|
||||
timepicker: element.dataset.timepicker === 'true',
|
||||
autoClose: element.dataset.autoClose === 'true',
|
||||
buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'],
|
||||
locale: locales[element.dataset.language],
|
||||
onSelect: ({date, formattedDate, datepicker}) => {
|
||||
const _event = new CustomEvent("change", {
|
||||
bubbles: true,
|
||||
});
|
||||
datepicker.$el.dispatchEvent(_event);
|
||||
}
|
||||
};
|
||||
|
||||
const positionConfig = !isOnMobile ? {
|
||||
position({$datepicker, $target, $pointer, done}) {
|
||||
let popper = createPopper($target, $datepicker, {
|
||||
placement: 'bottom',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
padding: {
|
||||
top: 64
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 20]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'arrow',
|
||||
options: {
|
||||
element: $pointer
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return function completeHide() {
|
||||
popper.destroy();
|
||||
done();
|
||||
};
|
||||
}
|
||||
} : {};
|
||||
|
||||
let opts = {...baseOpts, ...positionConfig};
|
||||
|
||||
if (element.dataset.value) {
|
||||
opts["selectedDates"] = [element.dataset.value];
|
||||
opts["startDate"] = [element.dataset.value];
|
||||
}
|
||||
|
||||
return new AirDatepicker(element, opts);
|
||||
};
|
||||
|
||||
|
||||
window.MonthYearPicker = function createDynamicDatePicker(element) {
|
||||
let isOnMobile = isMobile();
|
||||
|
||||
let baseOpts = {
|
||||
isMobile: isOnMobile,
|
||||
view: 'months',
|
||||
minView: 'months',
|
||||
dateFormat: 'MMMM yyyy',
|
||||
autoClose: element.dataset.autoClose === 'true',
|
||||
buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'],
|
||||
locale: locales[element.dataset.language],
|
||||
onSelect: ({date, formattedDate, datepicker}) => {
|
||||
const _event = new CustomEvent("change", {
|
||||
bubbles: true,
|
||||
});
|
||||
datepicker.$el.dispatchEvent(_event);
|
||||
}
|
||||
};
|
||||
|
||||
const positionConfig = !isOnMobile ? {
|
||||
position({$datepicker, $target, $pointer, done}) {
|
||||
let popper = createPopper($target, $datepicker, {
|
||||
placement: 'bottom',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
padding: {
|
||||
top: 64
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 20]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'arrow',
|
||||
options: {
|
||||
element: $pointer
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return function completeHide() {
|
||||
popper.destroy();
|
||||
done();
|
||||
};
|
||||
}
|
||||
} : {};
|
||||
|
||||
let opts = {...baseOpts, ...positionConfig};
|
||||
|
||||
if (element.dataset.value) {
|
||||
opts["selectedDates"] = [element.dataset.value];
|
||||
opts["startDate"] = [element.dataset.value];
|
||||
}
|
||||
return new AirDatepicker(element, opts);
|
||||
};
|
||||
@@ -2,18 +2,23 @@ import _hyperscript from 'hyperscript.org/dist/_hyperscript.min';
|
||||
import './_htmx.js';
|
||||
import Alpine from "alpinejs";
|
||||
import mask from '@alpinejs/mask';
|
||||
import { create, all } from 'mathjs';
|
||||
import {create, all} from 'mathjs';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
window._hyperscript = _hyperscript;
|
||||
window.math = create(all, { });
|
||||
window.math = create(all, {
|
||||
number: 'BigNumber', // Default type of number:
|
||||
// 'number' (default), 'BigNumber', or 'Fraction'
|
||||
precision: 64, // Number of significant digits for BigNumbers
|
||||
relTol: 1e-60,
|
||||
absTol: 1e-63
|
||||
});
|
||||
|
||||
Alpine.plugin(mask);
|
||||
Alpine.start();
|
||||
_hyperscript.browserInit();
|
||||
|
||||
|
||||
|
||||
const successAudio = new Audio("/static/sounds/success.mp3");
|
||||
const popAudio = new Audio("/static/sounds/pop.mp3");
|
||||
window.paidSound = successAudio;
|
||||
|
||||
89
frontend/src/styles/_datepicker.scss
Normal file
89
frontend/src/styles/_datepicker.scss
Normal file
@@ -0,0 +1,89 @@
|
||||
@import 'air-datepicker/air-datepicker.css';
|
||||
|
||||
.air-datepicker-global-container {
|
||||
z-index: 2000; // Allows the datepicker to be shown on top of offcanvas
|
||||
}
|
||||
|
||||
.air-datepicker {
|
||||
--adp-accent-color: #fbb700;
|
||||
--adp-day-name-color: #fbb700;
|
||||
--adp-background-color: #303030; /* $gray-800 */
|
||||
--adp-color: #fff;
|
||||
--adb-color-other-month: #888; /* $gray-600 */
|
||||
--adp-cell-background-color-selected: #fbb700;
|
||||
|
||||
--adp-border-color-inline: #444;
|
||||
|
||||
--adp-background-color-selected-other-month-focused: #e6a600; /* Slightly darker than $yellow */
|
||||
--adp-background-color-selected-other-month: #fbb700;
|
||||
|
||||
--adp-color-secondary: #adb5bd; /* $gray-500 */
|
||||
--adp-background-color-hover: #444;
|
||||
--adp-background-color-active: #3c3c3c;
|
||||
--adp-cell-background-color-selected-hover: #e6a600;
|
||||
--adp-color-other-month: #888; /* $gray-600 */
|
||||
--adp-color-disabled: #444; /* $gray-700 */
|
||||
--adp-color-disabled-in-range: #666; /* Between $gray-600 and $gray-700 */
|
||||
--adp-color-other-month-hover: #ced4da; /* $gray-400 */
|
||||
--adp-time-track-color: #444; /* $gray-700 */
|
||||
--adp-time-track-color-hover: #888; /* $gray-600 */
|
||||
}
|
||||
|
||||
.air-datepicker-cell.-selected-,
|
||||
.air-datepicker-cell.-selected-.-current-,
|
||||
.-selected-.air-datepicker-cell.-year-.-other-decade-,
|
||||
.-selected-.air-datepicker-cell.-day-.-other-month-{
|
||||
color: #222; /* $gray-900 */
|
||||
}
|
||||
|
||||
/* Additional styles for better dark theme integration */
|
||||
.air-datepicker {
|
||||
border-color: #444; /* $gray-700 */
|
||||
}
|
||||
|
||||
.air-datepicker-body--day-names {
|
||||
color: #fbb700; /* $yellow */
|
||||
}
|
||||
|
||||
.air-datepicker-cell:hover {
|
||||
background-color: #444; /* $gray-700 */
|
||||
}
|
||||
|
||||
.air-datepicker-cell.-current- {
|
||||
color: #fbb700; /* $yellow */
|
||||
}
|
||||
|
||||
.air-datepicker-cell.-range-from-,
|
||||
.air-datepicker-cell.-range-to- {
|
||||
border: 1px solid #fbb700; /* $yellow */
|
||||
}
|
||||
|
||||
.air-datepicker-cell.-range-from-::before,
|
||||
.air-datepicker-cell.-range-to-::before {
|
||||
background-color: rgba(251, 183, 0, 0.1); /* $yellow with opacity */
|
||||
}
|
||||
|
||||
.air-datepicker-cell.-in-range- {
|
||||
background-color: rgba(251, 183, 0, 0.1); /* $yellow with opacity */
|
||||
}
|
||||
|
||||
.air-datepicker-time--row input[type='range']::-webkit-slider-thumb {
|
||||
background-color: #fbb700; /* $yellow */
|
||||
}
|
||||
|
||||
.air-datepicker-time--row input[type='range']::-moz-range-thumb {
|
||||
background-color: #fbb700; /* $yellow */
|
||||
}
|
||||
|
||||
.air-datepicker-button,
|
||||
.air-datepicker-button:hover {
|
||||
color: #fbb700; /* $yellow */
|
||||
}
|
||||
|
||||
.air-datepicker-button:hover {
|
||||
background-color: #444; /* $gray-700 */
|
||||
}
|
||||
|
||||
.air-datepicker--pointer:after {
|
||||
background: #303030
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "font-awesome.scss";
|
||||
@import "tailwind.scss";
|
||||
@import "bootstrap.scss";
|
||||
@import "datepicker.scss";
|
||||
@import "tom-select.scss";
|
||||
@import "animations.scss";
|
||||
@import "scrollbar.scss";
|
||||
|
||||
Reference in New Issue
Block a user