mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 08:54:52 +01:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18273a562 | ||
|
|
60fe4c9681 | ||
|
|
f68e954bc0 | ||
|
|
404036bafa | ||
|
|
5e8074ea01 | ||
|
|
c9cc942a10 | ||
|
|
315f4e1269 | ||
|
|
b025ab7d24 | ||
|
|
e2134e98a5 | ||
|
|
3f250338a3 | ||
|
|
97c6b13d57 | ||
|
|
3dcee4dbf2 | ||
|
|
09d14b44fe | ||
|
|
a5b78f7c83 | ||
|
|
9543881aae | ||
|
|
6955294283 | ||
|
|
2b6a73af18 | ||
|
|
526c2cb191 | ||
|
|
4fe62244cd | ||
|
|
011e926e02 | ||
|
|
cd1b872b27 | ||
|
|
3791edce63 | ||
|
|
2cb8100129 | ||
|
|
e7e4ccafb6 | ||
|
|
afbbf7b25d | ||
|
|
1eba2b8731 | ||
|
|
afe366c359 | ||
|
|
3ee2bebc5c | ||
|
|
b951e5f069 | ||
|
|
4005a83a0d | ||
|
|
f81f1d83fd | ||
|
|
7816d6c55d | ||
|
|
6e3fdae4fe |
BIN
.github/img/all_transactions.png
vendored
Normal file
BIN
.github/img/all_transactions.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
.github/img/calendar.png
vendored
Normal file
BIN
.github/img/calendar.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
.github/img/monthly_view.png
vendored
Normal file
BIN
.github/img/monthly_view.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
.github/img/networth.png
vendored
Normal file
BIN
.github/img/networth.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
.github/img/yearly.png
vendored
Normal file
BIN
.github/img/yearly.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
288
README.md
288
README.md
@@ -6,17 +6,21 @@
|
||||
<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.
|
||||
|
||||
<img src=".github/img/monthly_view.png" width="18%"></img> <img src=".github/img/yearly.png" width="18%"></img> <img src=".github/img/networth.png" width="18%"></img> <img src=".github/img/calendar.png" width="18%"></img> <img src=".github/img/all_transactions.png" width="18%"></img>
|
||||
|
||||
# Why WYGIWYH?
|
||||
Managing money can feel unnecessarily complex, but it doesn’t have to be. WYGIWYH (pronounced "wiggy-wih") is based on a simple principle:
|
||||
|
||||
@@ -53,10 +57,10 @@ To run this application, you'll need [Docker](https://docs.docker.com/engine/ins
|
||||
From your command line:
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
# Create a folder for WYGIWYH (optional)
|
||||
$ mkdir WYGIWYH
|
||||
|
||||
# Go into the repository
|
||||
# Go into the folder
|
||||
$ cd WYGIWYH
|
||||
|
||||
$ touch docker-compose.yml
|
||||
@@ -75,6 +79,48 @@ $ docker compose up -d
|
||||
$ docker compose exec -it web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
If you want to run WYGIWYH locally, on your env file:
|
||||
|
||||
1. Remove `URL`
|
||||
2. Set `HTTPS_ENABLED` to `false`
|
||||
3. Leave the default `DJANGO_ALLOWED_HOSTS` (localhost 127.0.0.1 [::1])
|
||||
|
||||
You can now access localhost:OUTBOUND_PORT
|
||||
|
||||
> [!NOTE]
|
||||
> If you're planning on running this behind Tailscale or other similar service also add your machine given IP to `DJANGO_ALLOWED_HOSTS`
|
||||
|
||||
> [!NOTE]
|
||||
> If you're going to use another IP that isn't localhost, add it to `DJANGO_ALLOWED_HOSTS`, without `http://`
|
||||
|
||||
|
||||
## Building from source
|
||||
|
||||
Features are only added to `main` when ready, if you want to run the latest version, you must build from source.
|
||||
|
||||
```bash
|
||||
# Create a folder for WYGIWYH (optional)
|
||||
$ mkdir WYGIWYH
|
||||
|
||||
# Go into the folder
|
||||
$ cd WYGIWYH
|
||||
|
||||
# Clone this repository
|
||||
$ git clone https://github.com/eitchtee/WYGIWYH.git .
|
||||
|
||||
$ cp docker-compose.prod.yml docker-compose.yml
|
||||
$ cp .env.example .env
|
||||
# Now edit both files as you see fit
|
||||
|
||||
# Run the app
|
||||
$ docker compose up -d --build
|
||||
|
||||
# Create the first admin account
|
||||
$ docker compose exec -it web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
# How it works
|
||||
|
||||
## Models
|
||||
@@ -210,35 +256,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -264,37 +336,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -302,7 +435,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).
|
||||
|
||||
@@ -336,16 +469,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
|
||||
|
||||
@@ -26,7 +26,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-##6^&g49xwn7s67xc&33vf&=*4ibqfzn#xa*p-1sy8ag+zjjb9"
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
@@ -120,6 +120,11 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
category = TransactionCategoryField(required=False)
|
||||
|
||||
@@ -61,7 +61,6 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
self._created_instance = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise ValidationError(
|
||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/templatetags/date.py
Normal file
32
app/apps/common/templatetags/date.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django import template
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.utils import formats, timezone
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def custom_date(value, user=None):
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
# Determine if the value is a datetime or just a date
|
||||
is_datetime = hasattr(value, "hour")
|
||||
|
||||
# Convert to current timezone if it's a datetime
|
||||
if is_datetime and timezone.is_aware(value):
|
||||
value = timezone.localtime(value)
|
||||
|
||||
if user and user.is_authenticated:
|
||||
user_settings = user.settings
|
||||
|
||||
if is_datetime:
|
||||
format_setting = user_settings.datetime_format
|
||||
else:
|
||||
format_setting = user_settings.date_format
|
||||
|
||||
return formats.date_format(value, format_setting, use_l10n=True)
|
||||
|
||||
return date_filter(
|
||||
value, "SHORT_DATE_FORMAT" if not is_datetime else "SHORT_DATETIME_FORMAT"
|
||||
)
|
||||
161
app/apps/common/utils/django.py
Normal file
161
app/apps/common/utils/django.py
Normal file
@@ -0,0 +1,161 @@
|
||||
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
|
||||
|
||||
|
||||
def django_to_airdatepicker_datetime(django_format):
|
||||
format_map = {
|
||||
# Time
|
||||
"h": "h", # Hour (12-hour)
|
||||
"H": "H", # Hour (24-hour)
|
||||
"i": "m", # Minutes
|
||||
"A": "AA", # AM/PM uppercase
|
||||
"a": "aa", # am/pm lowercase
|
||||
"P": "h:mm AA", # Localized time format (e.g., "2:30 PM")
|
||||
# Date
|
||||
"D": "E", # Short weekday name
|
||||
"l": "EEEE", # Full weekday name
|
||||
"j": "d", # Day of month without leading zero
|
||||
"d": "dd", # Day of month with leading zero
|
||||
"n": "M", # Month without leading zero
|
||||
"m": "MM", # Month with leading zero
|
||||
"M": "MMM", # Short month name
|
||||
"F": "MMMM", # Full month name
|
||||
"y": "yy", # Year, 2 digits
|
||||
"Y": "yyyy", # Year, 4 digits
|
||||
}
|
||||
|
||||
result = ""
|
||||
i = 0
|
||||
while i < len(django_format):
|
||||
char = django_format[i]
|
||||
if char == "\\": # Handle escaped characters
|
||||
if i + 1 < len(django_format):
|
||||
result += django_format[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
|
||||
if char in format_map:
|
||||
result += format_map[char]
|
||||
else:
|
||||
result += char
|
||||
i += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def django_to_airdatepicker_datetime_separated(django_format):
|
||||
format_map = {
|
||||
# Time formats
|
||||
"h": "h", # Hour (12-hour)
|
||||
"H": "H", # Hour (24-hour)
|
||||
"i": "m", # Minutes
|
||||
"A": "AA", # AM/PM uppercase
|
||||
"a": "aa", # am/pm lowercase
|
||||
"P": "h:mm AA", # Localized time format
|
||||
# Date formats
|
||||
"D": "E", # Short weekday name
|
||||
"l": "EEEE", # Full weekday name
|
||||
"j": "d", # Day of month without leading zero
|
||||
"d": "dd", # Day of month with leading zero
|
||||
"n": "M", # Month without leading zero
|
||||
"m": "MM", # Month with leading zero
|
||||
"M": "MMM", # Short month name
|
||||
"F": "MMMM", # Full month name
|
||||
"y": "yy", # Year, 2 digits
|
||||
"Y": "yyyy", # Year, 4 digits
|
||||
}
|
||||
|
||||
# Define which characters belong to time format
|
||||
time_chars = {"h", "H", "i", "A", "a", "P"}
|
||||
date_chars = {"D", "l", "j", "d", "n", "m", "M", "F", "y", "Y"}
|
||||
|
||||
date_parts = []
|
||||
time_parts = []
|
||||
current_part = []
|
||||
is_time = False
|
||||
|
||||
i = 0
|
||||
while i < len(django_format):
|
||||
char = django_format[i]
|
||||
|
||||
if char == "\\": # Handle escaped characters
|
||||
if i + 1 < len(django_format):
|
||||
current_part.append(django_format[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
|
||||
if char in format_map:
|
||||
if char in time_chars:
|
||||
# If we were building a date part, save it and start a time part
|
||||
if current_part and not is_time:
|
||||
date_parts.append("".join(current_part))
|
||||
current_part = []
|
||||
is_time = True
|
||||
current_part.append(format_map[char])
|
||||
elif char in date_chars:
|
||||
# If we were building a time part, save it and start a date part
|
||||
if current_part and is_time:
|
||||
time_parts.append("".join(current_part))
|
||||
current_part = []
|
||||
is_time = False
|
||||
current_part.append(format_map[char])
|
||||
else:
|
||||
# Handle separators
|
||||
if char in "/:.-":
|
||||
current_part.append(char)
|
||||
elif char == " ":
|
||||
if current_part:
|
||||
if is_time:
|
||||
time_parts.append("".join(current_part))
|
||||
else:
|
||||
date_parts.append("".join(current_part))
|
||||
current_part = []
|
||||
current_part.append(char)
|
||||
|
||||
i += 1
|
||||
|
||||
# Don't forget the last part
|
||||
if current_part:
|
||||
if is_time:
|
||||
time_parts.append("".join(current_part))
|
||||
else:
|
||||
date_parts.append("".join(current_part))
|
||||
|
||||
date_format = "".join(date_parts)
|
||||
time_format = "".join(time_parts)
|
||||
|
||||
# Clean up multiple spaces while preserving necessary ones
|
||||
date_format = " ".join(filter(None, date_format.split()))
|
||||
time_format = " ".join(filter(None, time_format.split()))
|
||||
|
||||
return date_format, time_format
|
||||
229
app/apps/common/widgets/datepicker.py
Normal file
229
app/apps/common/widgets/datepicker.py
Normal file
@@ -0,0 +1,229 @@
|
||||
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,
|
||||
django_to_airdatepicker_datetime,
|
||||
django_to_airdatepicker_datetime_separated,
|
||||
)
|
||||
|
||||
|
||||
class AirDatePickerInput(widgets.DateInput):
|
||||
def __init__(
|
||||
self,
|
||||
attrs=None,
|
||||
format=None,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
user=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attrs = attrs or {}
|
||||
self.user = user
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
|
||||
@staticmethod
|
||||
def _get_current_language():
|
||||
"""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 _get_format(self):
|
||||
"""Get the format string based on user settings or default"""
|
||||
if self.format:
|
||||
return self.format
|
||||
|
||||
if self.user and hasattr(self.user, "settings"):
|
||||
user_format = self.user.settings.date_format
|
||||
print(user_format)
|
||||
if user_format == "SHORT_DATE_FORMAT":
|
||||
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
|
||||
return user_format
|
||||
|
||||
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
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"] = django_to_airdatepicker_datetime(self._get_format())
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
self.attrs["data-value"] = value
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||
return formats.date_format(value, format=self._get_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.strip(),
|
||||
django_to_python_datetime(self._get_format())
|
||||
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
|
||||
).strftime("%Y-%m-%d")
|
||||
except (ValueError, TypeError) as e:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
def __init__(
|
||||
self,
|
||||
attrs=None,
|
||||
format=None,
|
||||
timepicker=True,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
user=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attrs = attrs or {}
|
||||
self.user = user
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
self.timepicker = timepicker
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
|
||||
@staticmethod
|
||||
def _get_current_language():
|
||||
"""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 _get_format(self):
|
||||
"""Get the format string based on user settings or default"""
|
||||
if self.format:
|
||||
return self.format
|
||||
|
||||
if self.user and hasattr(self.user, "settings"):
|
||||
user_format = self.user.settings.datetime_format
|
||||
if user_format == "SHORT_DATETIME_FORMAT":
|
||||
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
|
||||
return user_format
|
||||
|
||||
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
date_format, time_format = django_to_airdatepicker_datetime_separated(
|
||||
self._get_format()
|
||||
)
|
||||
|
||||
# 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"] = date_format
|
||||
attrs["data-time-format"] = time_format
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
self.attrs["data-value"] = datetime.datetime.strftime(
|
||||
value, "%Y-%m-%d %H:%M:00"
|
||||
)
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||
return formats.date_format(value, format=self._get_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.strip(),
|
||||
django_to_python_datetime(self._get_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"
|
||||
|
||||
@staticmethod
|
||||
def _get_month_names():
|
||||
"""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,16 +65,14 @@ class CurrencyForm(forms.ModelForm):
|
||||
|
||||
class ExchangeRateForm(forms.ModelForm):
|
||||
date = forms.DateTimeField(
|
||||
widget=forms.DateTimeInput(
|
||||
attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"
|
||||
)
|
||||
label=_("Date"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExchangeRate
|
||||
fields = ["from_currency", "to_currency", "rate", "date"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
@@ -82,6 +81,9 @@ class ExchangeRateForm(forms.ModelForm):
|
||||
self.helper.layout = Layout("date", "from_currency", "to_currency", "rate")
|
||||
|
||||
self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDateTimePickerInput(
|
||||
clear_button=False, user=user
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
|
||||
@@ -84,7 +84,7 @@ def exchange_rates_list_pair(request):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def exchange_rate_add(request):
|
||||
if request.method == "POST":
|
||||
form = ExchangeRateForm(request.POST)
|
||||
form = ExchangeRateForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Exchange rate added successfully"))
|
||||
@@ -96,7 +96,7 @@ def exchange_rate_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ExchangeRateForm()
|
||||
form = ExchangeRateForm(user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -112,7 +112,7 @@ def exchange_rate_edit(request, pk):
|
||||
exchange_rate = get_object_or_404(ExchangeRate, id=pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExchangeRateForm(request.POST, instance=exchange_rate)
|
||||
form = ExchangeRateForm(request.POST, instance=exchange_rate, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Exchange rate updated successfully"))
|
||||
@@ -124,7 +124,7 @@ def exchange_rate_edit(request, pk):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ExchangeRateForm(instance=exchange_rate)
|
||||
form = ExchangeRateForm(instance=exchange_rate, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -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,11 +62,10 @@ class DCAEntryForm(forms.ModelForm):
|
||||
"notes",
|
||||
]
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
@@ -106,3 +106,4 @@ class DCAEntryForm(forms.ModelForm):
|
||||
|
||||
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
|
||||
@@ -157,7 +157,7 @@ def strategy_detail(request, strategy_id):
|
||||
def strategy_entry_add(request, strategy_id):
|
||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
if request.method == "POST":
|
||||
form = DCAEntryForm(request.POST)
|
||||
form = DCAEntryForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
entry = form.save(commit=False)
|
||||
entry.strategy = strategy
|
||||
@@ -171,7 +171,7 @@ def strategy_entry_add(request, strategy_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = DCAEntryForm()
|
||||
form = DCAEntryForm(user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -186,7 +186,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
|
||||
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = DCAEntryForm(request.POST, instance=dca_entry)
|
||||
form = DCAEntryForm(request.POST, instance=dca_entry, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Entry updated successfully"))
|
||||
@@ -198,7 +198,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = DCAEntryForm(instance=dca_entry)
|
||||
form = DCAEntryForm(instance=dca_entry, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -41,7 +41,7 @@ def monthly_overview(request, month: int, year: int):
|
||||
previous_month = 12 if month == 1 else month - 1
|
||||
previous_year = year - 1 if previous_month == 12 and month == 1 else year
|
||||
|
||||
f = TransactionsFilter(request.GET)
|
||||
f = TransactionsFilter(request.GET, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -64,7 +64,7 @@ def monthly_overview(request, month: int, year: int):
|
||||
def transactions_list(request, month: int, year: int):
|
||||
order = request.GET.get("order")
|
||||
|
||||
f = TransactionsFilter(request.GET)
|
||||
f = TransactionsFilter(request.GET, user=request.user)
|
||||
transactions_filtered = (
|
||||
f.qs.filter()
|
||||
.filter(
|
||||
|
||||
@@ -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,11 @@ 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"),
|
||||
label=_("Date from"),
|
||||
)
|
||||
date_end = django_filters.DateFilter(
|
||||
field_name="date",
|
||||
lookup_expr="lte",
|
||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
label=_("Until"),
|
||||
)
|
||||
reference_date_start = MonthYearFilter(
|
||||
@@ -134,7 +133,7 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
"to_amount",
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
def __init__(self, data=None, user=None, *args, **kwargs):
|
||||
# if filterset is bound, use initial values as defaults
|
||||
if data is not None:
|
||||
# get a mutable copy of the QueryDict
|
||||
@@ -183,3 +182,5 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
|
||||
self.form.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.form.fields["date_start"].widget = AirDatePickerInput(user=user)
|
||||
self.form.fields["date_end"].widget = AirDatePickerInput(user=user)
|
||||
|
||||
@@ -16,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,
|
||||
@@ -28,7 +29,6 @@ from apps.transactions.models import (
|
||||
RecurringTransaction,
|
||||
TransactionEntity,
|
||||
)
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
@@ -59,7 +59,12 @@ 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(label=_("Date"))
|
||||
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
@@ -77,12 +82,11 @@ 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"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing a transaction display non-archived items and it's own item even if it's archived
|
||||
@@ -118,8 +122,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",
|
||||
@@ -133,6 +137,7 @@ class TransactionForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
self.fields["reference_date"].required = False
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
decimal_places = self.instance.account.currency.decimal_places
|
||||
@@ -234,11 +239,12 @@ class TransferForm(forms.Form):
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
|
||||
date = forms.DateField(
|
||||
label=_("Date"),
|
||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
date = forms.DateField(label=_("Date"))
|
||||
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||
)
|
||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
||||
|
||||
description = forms.CharField(max_length=500, label=_("Description"))
|
||||
notes = forms.CharField(
|
||||
required=False,
|
||||
@@ -250,7 +256,7 @@ class TransferForm(forms.Form):
|
||||
label=_("Notes"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
@@ -318,8 +324,8 @@ class TransferForm(forms.Form):
|
||||
)
|
||||
|
||||
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
|
||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -404,7 +410,10 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
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
|
||||
@@ -424,13 +433,12 @@ 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}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing display non-archived items and it's own item even if it's archived
|
||||
@@ -487,6 +495,9 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["start_date"].widget = AirDatePickerInput(
|
||||
clear_button=False, user=user
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
@@ -646,7 +657,6 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
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
|
||||
@@ -666,8 +676,7 @@ 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"),
|
||||
"reference_date": AirMonthYearPickerInput(),
|
||||
"recurrence_type": TomSelect(clear_button=False),
|
||||
"notes": forms.Textarea(
|
||||
attrs={
|
||||
@@ -676,7 +685,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing display non-archived items and it's own item even if it's archived
|
||||
@@ -733,6 +742,10 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["start_date"].widget = AirDatePickerInput(
|
||||
clear_button=False, user=user
|
||||
)
|
||||
self.fields["end_date"].widget = AirDatePickerInput(user=user)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
@@ -767,5 +780,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,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'),
|
||||
),
|
||||
]
|
||||
@@ -101,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,
|
||||
@@ -334,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
|
||||
@@ -540,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()
|
||||
|
||||
@@ -41,6 +41,11 @@ urlpatterns = [
|
||||
views.transaction_edit,
|
||||
name="transaction_edit",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/clone",
|
||||
views.transaction_clone,
|
||||
name="transaction_clone",
|
||||
),
|
||||
path(
|
||||
"transaction/add",
|
||||
views.transaction_add,
|
||||
|
||||
@@ -82,7 +82,7 @@ def installment_plan_transactions(request, installment_plan_id):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def installment_plan_add(request):
|
||||
if request.method == "POST":
|
||||
form = InstallmentPlanForm(request.POST)
|
||||
form = InstallmentPlanForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Installment Plan added successfully"))
|
||||
@@ -94,7 +94,7 @@ def installment_plan_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = InstallmentPlanForm()
|
||||
form = InstallmentPlanForm(user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -110,7 +110,9 @@ def installment_plan_edit(request, installment_plan_id):
|
||||
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = InstallmentPlanForm(request.POST, instance=installment_plan)
|
||||
form = InstallmentPlanForm(
|
||||
request.POST, instance=installment_plan, user=request.user
|
||||
)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Installment Plan updated successfully"))
|
||||
@@ -122,7 +124,7 @@ def installment_plan_edit(request, installment_plan_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = InstallmentPlanForm(instance=installment_plan)
|
||||
form = InstallmentPlanForm(instance=installment_plan, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -108,7 +108,7 @@ def recurring_transaction_transactions(request, recurring_transaction_id):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def recurring_transaction_add(request):
|
||||
if request.method == "POST":
|
||||
form = RecurringTransactionForm(request.POST)
|
||||
form = RecurringTransactionForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Recurring Transaction added successfully"))
|
||||
@@ -120,7 +120,7 @@ def recurring_transaction_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = RecurringTransactionForm()
|
||||
form = RecurringTransactionForm(user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -138,7 +138,9 @@ def recurring_transaction_edit(request, recurring_transaction_id):
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = RecurringTransactionForm(request.POST, instance=recurring_transaction)
|
||||
form = RecurringTransactionForm(
|
||||
request.POST, instance=recurring_transaction, user=request.user
|
||||
)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Recurring Transaction updated successfully"))
|
||||
@@ -150,7 +152,9 @@ def recurring_transaction_edit(request, recurring_transaction_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = RecurringTransactionForm(instance=recurring_transaction)
|
||||
form = RecurringTransactionForm(
|
||||
instance=recurring_transaction, user=request.user
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -168,12 +172,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 +206,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 +215,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(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -12,6 +13,7 @@ from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.utils.dicts import remove_falsey_entries
|
||||
from apps.rules.signals import transaction_created
|
||||
from apps.transactions.filters import TransactionsFilter
|
||||
from apps.transactions.forms import TransactionForm, TransferForm
|
||||
from apps.transactions.models import Transaction
|
||||
@@ -39,7 +41,7 @@ def transaction_add(request):
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
form = TransactionForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
@@ -50,10 +52,11 @@ def transaction_add(request):
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(
|
||||
user=request.user,
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return render(
|
||||
@@ -70,7 +73,7 @@ def transaction_edit(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, instance=transaction)
|
||||
form = TransactionForm(request.POST, user=request.user, instance=transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction updated successfully"))
|
||||
@@ -80,7 +83,7 @@ def transaction_edit(request, transaction_id, **kwargs):
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(instance=transaction)
|
||||
form = TransactionForm(instance=transaction, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -89,6 +92,55 @@ def transaction_edit(request, transaction_id, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transaction_clone(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
new_transaction = deepcopy(transaction)
|
||||
new_transaction.pk = None
|
||||
new_transaction.installment_plan = None
|
||||
new_transaction.installment_id = None
|
||||
new_transaction.recurring_transaction = None
|
||||
new_transaction.save()
|
||||
|
||||
new_transaction.tags.add(*transaction.tags.all())
|
||||
new_transaction.entities.add(*transaction.entities.all())
|
||||
|
||||
messages.success(request, _("Transaction duplicated successfully"))
|
||||
|
||||
transaction_created.send(sender=transaction)
|
||||
|
||||
# THIS HAS BEEN DISABLE DUE TO HTMX INCOMPATIBILITY
|
||||
# SEE https://github.com/bigskysoftware/htmx/issues/3115 and https://github.com/bigskysoftware/htmx/issues/2706
|
||||
|
||||
# if request.GET.get("edit") == "true":
|
||||
# return HttpResponse(
|
||||
# status=200,
|
||||
# headers={
|
||||
# "HX-Trigger": "updated",
|
||||
# "HX-Push-Url": "false",
|
||||
# "HX-Location": json.dumps(
|
||||
# {
|
||||
# "path": reverse(
|
||||
# "transaction_edit",
|
||||
# kwargs={"transaction_id": new_transaction.id},
|
||||
# ),
|
||||
# "target": "#generic-offcanvas",
|
||||
# "swap": "innerHTML",
|
||||
# }
|
||||
# ),
|
||||
# },
|
||||
# )
|
||||
# else:
|
||||
# transaction_created.send(sender=transaction)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@@ -121,7 +173,7 @@ def transactions_transfer(request):
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransferForm(request.POST)
|
||||
form = TransferForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transfer added successfully"))
|
||||
@@ -134,7 +186,8 @@ def transactions_transfer(request):
|
||||
initial={
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
}
|
||||
},
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
return render(request, "transactions/fragments/transfer.html", {"form": form})
|
||||
@@ -163,7 +216,7 @@ def transaction_pay(request, transaction_id):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_all_index(request):
|
||||
f = TransactionsFilter(request.GET)
|
||||
f = TransactionsFilter(request.GET, user=request.user)
|
||||
return render(request, "transactions/pages/transactions.html", {"filter": f})
|
||||
|
||||
|
||||
@@ -185,7 +238,7 @@ def transaction_all_list(request):
|
||||
|
||||
transactions = default_order(transactions, order=order)
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
f = TransactionsFilter(request.GET, user=request.user, queryset=transactions)
|
||||
|
||||
page_number = request.GET.get("page", 1)
|
||||
paginator = Paginator(f.qs, 100)
|
||||
@@ -215,7 +268,7 @@ def transaction_all_summary(request):
|
||||
"installment_plan",
|
||||
).all()
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
f = TransactionsFilter(request.GET, user=request.user, queryset=transactions)
|
||||
|
||||
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
|
||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||
|
||||
@@ -46,9 +46,57 @@ class LoginForm(AuthenticationForm):
|
||||
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
DATE_FORMAT_CHOICES = [
|
||||
("SHORT_DATE_FORMAT", _("Default")),
|
||||
("d-m-Y", "20-01-2025"),
|
||||
("m-d-Y", "01-20-2025"),
|
||||
("Y-m-d", "2025-01-20"),
|
||||
("d/m/Y", "20/01/2025"),
|
||||
("m/d/Y", "01/20/2025"),
|
||||
("Y/m/d", "2025/01/20"),
|
||||
("d.m.Y", "20.01.2025"),
|
||||
("m.d.Y", "01.20.2025"),
|
||||
("Y.m.d", "2025.01.20"),
|
||||
]
|
||||
|
||||
DATETIME_FORMAT_CHOICES = [
|
||||
("SHORT_DATETIME_FORMAT", _("Default")),
|
||||
("d-m-Y H:i", "20-01-2025 15:30"),
|
||||
("m-d-Y H:i", "01-20-2025 15:30"),
|
||||
("Y-m-d H:i", "2025-01-20 15:30"),
|
||||
("d-m-Y h:i A", "20-01-2025 03:30 PM"),
|
||||
("m-d-Y h:i A", "01-20-2025 03:30 PM"),
|
||||
("Y-m-d h:i A", "2025-01-20 03:30 PM"),
|
||||
("d/m/Y H:i", "20/01/2025 15:30"),
|
||||
("m/d/Y H:i", "01/20/2025 15:30"),
|
||||
("Y/m/d H:i", "2025/01/20 15:30"),
|
||||
("d/m/Y h:i A", "20/01/2025 03:30 PM"),
|
||||
("m/d/Y h:i A", "01/20/2025 03:30 PM"),
|
||||
("Y/m/d h:i A", "2025/01/20 03:30 PM"),
|
||||
("d.m.Y H:i", "20.01.2025 15:30"),
|
||||
("m.d.Y H:i", "01.20.2025 15:30"),
|
||||
("Y.m.d H:i", "2025.01.20 15:30"),
|
||||
("d.m.Y h:i A", "20.01.2025 03:30 PM"),
|
||||
("m.d.Y h:i A", "01.20.2025 03:30 PM"),
|
||||
("Y.m.d h:i A", "2025.01.20 03:30 PM"),
|
||||
]
|
||||
|
||||
date_format = forms.ChoiceField(
|
||||
choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT"
|
||||
)
|
||||
datetime_format = forms.ChoiceField(
|
||||
choices=DATETIME_FORMAT_CHOICES, initial="SHORT_DATETIME_FORMAT"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserSettings
|
||||
fields = ["language", "timezone", "start_page"]
|
||||
fields = [
|
||||
"language",
|
||||
"timezone",
|
||||
"start_page",
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -59,6 +107,8 @@ class UserSettingsForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
"language",
|
||||
"timezone",
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"start_page",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-20 17:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0012_alter_usersettings_start_page'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='usersettings',
|
||||
name='date_format',
|
||||
field=models.CharField(default='SHORT_DATE_FORMAT', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='usersettings',
|
||||
name='datetime_format',
|
||||
field=models.CharField(default='SHORT_DATETIME_FORMAT', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -36,6 +36,9 @@ class UserSettings(models.Model):
|
||||
hide_amounts = models.BooleanField(default=False)
|
||||
mute_sounds = models.BooleanField(default=False)
|
||||
|
||||
date_format = models.CharField(max_length=100, default="SHORT_DATE_FORMAT")
|
||||
datetime_format = models.CharField(max_length=100, default="SHORT_DATETIME_FORMAT")
|
||||
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=(("auto", _("Auto")),) + settings.LANGUAGES,
|
||||
|
||||
@@ -8,8 +8,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-05 17:33+0000\n"
|
||||
"PO-Revision-Date: 2025-01-05 14:35-0300\n"
|
||||
"POT-Creation-Date: 2025-01-11 16:40+0000\n"
|
||||
"PO-Revision-Date: 2025-01-11 13:41-0300\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: pt_BR\n"
|
||||
@@ -66,7 +66,7 @@ msgstr "Novo saldo"
|
||||
#: apps/transactions/forms.py:39 apps/transactions/forms.py:209
|
||||
#: apps/transactions/forms.py:216 apps/transactions/forms.py:395
|
||||
#: apps/transactions/forms.py:637 apps/transactions/models.py:109
|
||||
#: apps/transactions/models.py:228 apps/transactions/models.py:403
|
||||
#: apps/transactions/models.py:228 apps/transactions/models.py:408
|
||||
msgid "Category"
|
||||
msgstr "Categoria"
|
||||
|
||||
@@ -75,7 +75,7 @@ msgstr "Categoria"
|
||||
#: apps/transactions/forms.py:225 apps/transactions/forms.py:233
|
||||
#: apps/transactions/forms.py:388 apps/transactions/forms.py:630
|
||||
#: apps/transactions/models.py:115 apps/transactions/models.py:230
|
||||
#: apps/transactions/models.py:407 templates/includes/navbar.html:98
|
||||
#: apps/transactions/models.py:412 templates/includes/navbar.html:98
|
||||
#: templates/tags/fragments/list.html:5 templates/tags/pages/index.html:4
|
||||
msgid "Tags"
|
||||
msgstr "Tags"
|
||||
@@ -145,7 +145,7 @@ msgstr ""
|
||||
#: apps/accounts/models.py:59 apps/rules/models.py:19
|
||||
#: apps/transactions/forms.py:59 apps/transactions/forms.py:380
|
||||
#: apps/transactions/forms.py:622 apps/transactions/models.py:84
|
||||
#: apps/transactions/models.py:188 apps/transactions/models.py:385
|
||||
#: apps/transactions/models.py:188 apps/transactions/models.py:390
|
||||
msgid "Account"
|
||||
msgstr "Conta"
|
||||
|
||||
@@ -217,7 +217,7 @@ msgstr "Entidade com esse ID não existe."
|
||||
msgid "Invalid entity data. Provide an ID or name."
|
||||
msgstr "Dados da entidade inválidos. Forneça um ID ou nome."
|
||||
|
||||
#: apps/api/serializers/transactions.py:163
|
||||
#: apps/api/serializers/transactions.py:168
|
||||
msgid "Either 'date' or 'reference_date' must be provided."
|
||||
msgstr "É necessário fornecer “date” ou “reference_date”."
|
||||
|
||||
@@ -420,7 +420,7 @@ msgstr "Moeda de pagamento"
|
||||
|
||||
#: apps/dca/models.py:27 apps/dca/models.py:179 apps/rules/models.py:26
|
||||
#: apps/transactions/forms.py:250 apps/transactions/models.py:105
|
||||
#: apps/transactions/models.py:237 apps/transactions/models.py:413
|
||||
#: apps/transactions/models.py:237 apps/transactions/models.py:418
|
||||
msgid "Notes"
|
||||
msgstr "Notas"
|
||||
|
||||
@@ -517,7 +517,7 @@ msgstr "Já existe um valor para esse campo na regra."
|
||||
|
||||
#: apps/rules/models.py:10 apps/rules/models.py:25
|
||||
#: apps/transactions/forms.py:242 apps/transactions/models.py:104
|
||||
#: apps/transactions/models.py:195 apps/transactions/models.py:399
|
||||
#: apps/transactions/models.py:195 apps/transactions/models.py:404
|
||||
msgid "Description"
|
||||
msgstr "Descrição"
|
||||
|
||||
@@ -526,7 +526,7 @@ msgid "Trigger"
|
||||
msgstr "Gatilho"
|
||||
|
||||
#: apps/rules/models.py:20 apps/transactions/models.py:91
|
||||
#: apps/transactions/models.py:193 apps/transactions/models.py:391
|
||||
#: apps/transactions/models.py:193 apps/transactions/models.py:396
|
||||
msgid "Type"
|
||||
msgstr "Tipo"
|
||||
|
||||
@@ -538,12 +538,12 @@ msgstr "Pago"
|
||||
#: apps/rules/models.py:23 apps/transactions/forms.py:62
|
||||
#: apps/transactions/forms.py:241 apps/transactions/forms.py:407
|
||||
#: apps/transactions/forms.py:649 apps/transactions/models.py:95
|
||||
#: apps/transactions/models.py:211 apps/transactions/models.py:415
|
||||
#: apps/transactions/models.py:211 apps/transactions/models.py:420
|
||||
msgid "Reference Date"
|
||||
msgstr "Data de Referência"
|
||||
|
||||
#: apps/rules/models.py:24 apps/transactions/models.py:100
|
||||
#: apps/transactions/models.py:396
|
||||
#: apps/transactions/models.py:401
|
||||
msgid "Amount"
|
||||
msgstr "Quantia"
|
||||
|
||||
@@ -551,7 +551,7 @@ msgstr "Quantia"
|
||||
#: apps/transactions/forms.py:55 apps/transactions/forms.py:403
|
||||
#: apps/transactions/forms.py:645 apps/transactions/models.py:69
|
||||
#: apps/transactions/models.py:120 apps/transactions/models.py:233
|
||||
#: apps/transactions/models.py:410 templates/entities/fragments/list.html:5
|
||||
#: apps/transactions/models.py:415 templates/entities/fragments/list.html:5
|
||||
#: templates/entities/pages/index.html:4 templates/includes/navbar.html:100
|
||||
msgid "Entities"
|
||||
msgstr "Entidades"
|
||||
@@ -753,7 +753,7 @@ msgstr "Despesa"
|
||||
msgid "Installment Plan"
|
||||
msgstr "Parcelamento"
|
||||
|
||||
#: apps/transactions/models.py:140 apps/transactions/models.py:436
|
||||
#: apps/transactions/models.py:140 apps/transactions/models.py:441
|
||||
msgid "Recurring Transaction"
|
||||
msgstr "Transação Recorrente"
|
||||
|
||||
@@ -798,11 +798,11 @@ msgstr "Parcela inicial"
|
||||
msgid "The installment number to start counting from"
|
||||
msgstr "O número da parcela a partir do qual se inicia a contagem"
|
||||
|
||||
#: apps/transactions/models.py:209 apps/transactions/models.py:419
|
||||
#: apps/transactions/models.py:209 apps/transactions/models.py:424
|
||||
msgid "Start Date"
|
||||
msgstr "Data de Início"
|
||||
|
||||
#: apps/transactions/models.py:213 apps/transactions/models.py:420
|
||||
#: apps/transactions/models.py:213 apps/transactions/models.py:425
|
||||
msgid "End Date"
|
||||
msgstr "Data Final"
|
||||
|
||||
@@ -820,44 +820,44 @@ msgstr "Valor da Parcela"
|
||||
msgid "Installment Plans"
|
||||
msgstr "Parcelamentos"
|
||||
|
||||
#: apps/transactions/models.py:378
|
||||
#: apps/transactions/models.py:383
|
||||
msgid "day(s)"
|
||||
msgstr "dia(s)"
|
||||
|
||||
#: apps/transactions/models.py:379
|
||||
#: apps/transactions/models.py:384
|
||||
msgid "week(s)"
|
||||
msgstr "semana(s)"
|
||||
|
||||
#: apps/transactions/models.py:380
|
||||
#: apps/transactions/models.py:385
|
||||
msgid "month(s)"
|
||||
msgstr "mês(es)"
|
||||
|
||||
#: apps/transactions/models.py:381
|
||||
#: apps/transactions/models.py:386
|
||||
msgid "year(s)"
|
||||
msgstr "ano(s)"
|
||||
|
||||
#: apps/transactions/models.py:383
|
||||
#: apps/transactions/models.py:388
|
||||
#: templates/recurring_transactions/fragments/list.html:24
|
||||
msgid "Paused"
|
||||
msgstr "Pausado"
|
||||
|
||||
#: apps/transactions/models.py:422
|
||||
#: apps/transactions/models.py:427
|
||||
msgid "Recurrence Type"
|
||||
msgstr "Tipo de recorrência"
|
||||
|
||||
#: apps/transactions/models.py:425
|
||||
#: apps/transactions/models.py:430
|
||||
msgid "Recurrence Interval"
|
||||
msgstr "Intervalo de recorrência"
|
||||
|
||||
#: apps/transactions/models.py:429
|
||||
#: apps/transactions/models.py:434
|
||||
msgid "Last Generated Date"
|
||||
msgstr "Última data gerada"
|
||||
|
||||
#: apps/transactions/models.py:432
|
||||
#: apps/transactions/models.py:437
|
||||
msgid "Last Generated Reference Date"
|
||||
msgstr "Última data de referência gerada"
|
||||
|
||||
#: apps/transactions/models.py:437 templates/includes/navbar.html:64
|
||||
#: apps/transactions/models.py:442 templates/includes/navbar.html:64
|
||||
#: templates/recurring_transactions/fragments/list.html:5
|
||||
#: templates/recurring_transactions/pages/index.html:4
|
||||
msgid "Recurring Transactions"
|
||||
@@ -921,19 +921,19 @@ msgstr "Transação Recorrente adicionada com sucesso"
|
||||
msgid "Recurring Transaction updated successfully"
|
||||
msgstr "Transação Recorrente atualizada com sucesso"
|
||||
|
||||
#: apps/transactions/views/recurring_transactions.py:174
|
||||
#: apps/transactions/views/recurring_transactions.py:188
|
||||
msgid "Recurring transaction unpaused successfully"
|
||||
msgstr "Transação Recorrente despausada com sucesso"
|
||||
|
||||
#: apps/transactions/views/recurring_transactions.py:177
|
||||
#: apps/transactions/views/recurring_transactions.py:191
|
||||
msgid "Recurring transaction paused successfully"
|
||||
msgstr "Transação Recorrente pausada com sucesso"
|
||||
|
||||
#: apps/transactions/views/recurring_transactions.py:200
|
||||
#: apps/transactions/views/recurring_transactions.py:217
|
||||
msgid "Recurring transaction finished successfully"
|
||||
msgstr "Transação Recorrente finalizada com sucesso"
|
||||
|
||||
#: apps/transactions/views/recurring_transactions.py:221
|
||||
#: apps/transactions/views/recurring_transactions.py:238
|
||||
msgid "Recurring Transaction deleted successfully"
|
||||
msgstr "Transação Recorrente apagada com sucesso"
|
||||
|
||||
@@ -1344,41 +1344,41 @@ msgstr "Marcar como não pago"
|
||||
msgid "Yes, delete them!"
|
||||
msgstr "Sim, apague!"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:127
|
||||
#: templates/cotton/ui/transactions_action_bar.html:149
|
||||
#: templates/cotton/ui/transactions_action_bar.html:169
|
||||
#: templates/cotton/ui/transactions_action_bar.html:189
|
||||
#: templates/cotton/ui/transactions_action_bar.html:209
|
||||
#: templates/cotton/ui/transactions_action_bar.html:229
|
||||
#: templates/cotton/ui/transactions_action_bar.html:249
|
||||
#: templates/cotton/ui/transactions_action_bar.html:101
|
||||
#: templates/cotton/ui/transactions_action_bar.html:125
|
||||
#: templates/cotton/ui/transactions_action_bar.html:145
|
||||
#: templates/cotton/ui/transactions_action_bar.html:165
|
||||
#: templates/cotton/ui/transactions_action_bar.html:185
|
||||
#: templates/cotton/ui/transactions_action_bar.html:205
|
||||
#: templates/cotton/ui/transactions_action_bar.html:225
|
||||
msgid "copied!"
|
||||
msgstr "copiado!"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:134
|
||||
#: templates/cotton/ui/transactions_action_bar.html:110
|
||||
msgid "Toggle Dropdown"
|
||||
msgstr "Alternar menu suspenso"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:142
|
||||
#: templates/cotton/ui/transactions_action_bar.html:118
|
||||
msgid "Flat Total"
|
||||
msgstr "Total Fixo"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:162
|
||||
#: templates/cotton/ui/transactions_action_bar.html:138
|
||||
msgid "Real Total"
|
||||
msgstr "Total Real"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:182
|
||||
#: templates/cotton/ui/transactions_action_bar.html:158
|
||||
msgid "Mean"
|
||||
msgstr "Média"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:202
|
||||
#: templates/cotton/ui/transactions_action_bar.html:178
|
||||
msgid "Max"
|
||||
msgstr "Máximo"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:222
|
||||
#: templates/cotton/ui/transactions_action_bar.html:198
|
||||
msgid "Min"
|
||||
msgstr "Minímo"
|
||||
|
||||
#: templates/cotton/ui/transactions_action_bar.html:242
|
||||
#: templates/cotton/ui/transactions_action_bar.html:218
|
||||
msgid "Count"
|
||||
msgstr "Contagem"
|
||||
|
||||
@@ -1841,8 +1841,12 @@ msgid "Finish"
|
||||
msgstr "Finalizar"
|
||||
|
||||
#: templates/recurring_transactions/fragments/table.html:83
|
||||
msgid "This will stop the creation of new transactions"
|
||||
msgstr "Isso interromperá a criação de novas transações"
|
||||
msgid ""
|
||||
"This will stop the creation of new transactions and delete any unpaid "
|
||||
"transactions after today"
|
||||
msgstr ""
|
||||
"Isso interromperá a criação de novas transações e apagará transações não "
|
||||
"pagas depois de hoje"
|
||||
|
||||
#: templates/recurring_transactions/fragments/table.html:84
|
||||
msgid "Yes, finish it!"
|
||||
@@ -2028,6 +2032,9 @@ msgstr "Visão Anual"
|
||||
msgid "Year"
|
||||
msgstr "Ano"
|
||||
|
||||
#~ msgid "This will stop the creation of new transactions"
|
||||
#~ msgstr "Isso interromperá a criação de novas transações"
|
||||
|
||||
#~ msgid "Is an asset account?"
|
||||
#~ msgstr "É uma conta de ativos?"
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load date %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Transactions on' %} {{ date|date:"SHORT_DATE_FORMAT" }}{% endblock %}
|
||||
{% block title %}{% translate 'Transactions on' %} {{ date|custom_date:request.user }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div hx-get="{% url 'calendar_transactions_list' day=date.day month=date.month year=date.year %}" hx-trigger="updated from:window" hx-vals='{"disable_selection": true}' hx-target="closest .offcanvas" class="show-loading">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% load date %}
|
||||
{% load i18n %}
|
||||
<div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
|
||||
{% if not disable_selection %}
|
||||
@@ -26,7 +27,7 @@
|
||||
{# Date#}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
|
||||
<div class="col ps-0">{{ transaction.date|custom_date:request.user }} • {{ transaction.reference_date|date:"b/Y" }}</div>
|
||||
</div>
|
||||
{# Description#}
|
||||
<div class="mb-2 mb-lg-1 text-white tw-text-base">
|
||||
@@ -110,6 +111,14 @@
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Duplicate" %}"
|
||||
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
|
||||
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
|
||||
hx-trigger="ready" >
|
||||
<i class="fa-solid fa-clone fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
<div class="vr mx-3 tw-align-middle"></div>
|
||||
<div class="btn-group"
|
||||
_="on selected_transactions_updated from #actions-bar
|
||||
set realTotal to 0.0
|
||||
set flatTotal to 0.0
|
||||
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 []
|
||||
@@ -82,14 +82,16 @@
|
||||
end
|
||||
end
|
||||
|
||||
set mean to flatTotal.divide(flatAmountValues.length)
|
||||
set mean to flatTotal.divide(flatAmountValues.length).done().toNumber()
|
||||
set realTotal to realTotal.done().toNumber()
|
||||
set flatTotal to flatTotal.done().toNumber()
|
||||
|
||||
put realTotal.value.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #real-total-front's innerText
|
||||
put realTotal.value.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-real-total's innerText
|
||||
put flatTotal.value.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-flat-total's innerText
|
||||
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.value.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean'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"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% load date %}
|
||||
{% load currency_display %}
|
||||
{% load i18n %}
|
||||
<div class="container-fluid px-md-3 py-3 column-gap-5">
|
||||
@@ -16,7 +17,7 @@
|
||||
:prefix="strategy.payment_currency.prefix"
|
||||
:suffix="strategy.payment_currency.suffix"
|
||||
:decimal_places="strategy.payment_currency.decimal_places">
|
||||
• {{ strategy.current_price.1|date:"SHORT_DATETIME_FORMAT" }}
|
||||
• {{ strategy.current_price.1|custom_date:request.user }}
|
||||
</c-amount.display>
|
||||
{% else %}
|
||||
<div class="tw-text-red-400">{% trans "No exchange rate available" %}</div>
|
||||
@@ -83,7 +84,7 @@
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ entry.date|date:"SHORT_DATE_FORMAT" }}</td>
|
||||
<td>{{ entry.date|custom_date:request.user }}</td>
|
||||
<td>
|
||||
<c-amount.display
|
||||
:amount="entry.amount_received"
|
||||
@@ -221,7 +222,7 @@
|
||||
new Chart(perfomancectx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [{% for entry in entries_data %}'{{ entry.entry.date|date:"SHORT_DATE_FORMAT" }}'{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
labels: [{% for entry in entries_data %}'{{ entry.entry.date|custom_date:request.user }}'{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
datasets: [{
|
||||
label: '{% trans "P/L %" %}',
|
||||
data: [{% for entry in entries_data %}{{ entry.profit_loss_percentage|floatformat:"-40u" }}{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% load date %}
|
||||
{% load currency_display %}
|
||||
{% load i18n %}
|
||||
<div class="card-body show-loading" hx-get="{% url 'exchange_rates_list_pair' %}" hx-trigger="updated from:window" hx-swap="outerHTML" hx-vals='{"page": "{{ page_obj.number }}", "from": "{{ from_currency|default_if_none:"" }}", "to": "{{ to_currency|default_if_none:"" }}"}'>
|
||||
@@ -39,7 +40,7 @@
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-3">{{ exchange_rate.date|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td class="col-3">{{ exchange_rate.date|custom_date:request.user }}</td>
|
||||
<td class="col-3"><span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.from_currency.code }}</span> x <span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.to_currency.code }}</span></td>
|
||||
<td class="col-3">1 {{ exchange_rate.from_currency.code }} ≅ {% currency_display amount=exchange_rate.rate prefix=exchange_rate.to_currency.prefix suffix=exchange_rate.to_currency.suffix decimal_places=exchange_rate.to_currency.decimal_places%}</td>
|
||||
</tr>
|
||||
|
||||
@@ -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,7 +66,7 @@
|
||||
})
|
||||
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.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40})
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
{% 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
services:
|
||||
web: &django
|
||||
web:
|
||||
image: eitchtee/wygiwyh:latest
|
||||
container_name: ${SERVER_NAME}
|
||||
command: /start
|
||||
@@ -23,10 +23,11 @@ services:
|
||||
- POSTGRES_DB=${SQL_DATABASE}
|
||||
|
||||
procrastinate:
|
||||
<<: *django
|
||||
image: eitchtee/wygiwyh:latest
|
||||
container_name: ${PROCRASTINATE_NAME}
|
||||
depends_on:
|
||||
- db
|
||||
ports: [ ]
|
||||
env_file:
|
||||
- .env
|
||||
command: /start-procrastinate
|
||||
restart: unless-stopped
|
||||
|
||||
7
frontend/package-lock.json
generated
7
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
150
frontend/src/application/datepicker.js
Normal file
150
frontend/src/application/datepicker.js
Normal file
@@ -0,0 +1,150 @@
|
||||
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,
|
||||
dateFormat: element.dataset.dateFormat,
|
||||
timeFormat: element.dataset.timeFormat,
|
||||
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