Compare commits

...

84 Commits
0.1.0 ... 0.5.0

Author SHA1 Message Date
Herculino Trotta
09d14b44fe Merge pull request #39 from eitchtee/dev
feat(transactions): make description optional
2025-01-14 23:49:45 -03:00
Herculino Trotta
a5b78f7c83 Merge pull request #40 from eitchtee/new_datepicker
feat(datepicker): drop native datepickers in favor of AirDatePicker for better compatibility
2025-01-14 23:49:27 -03:00
Herculino Trotta
9543881aae Merge pull request #38 from eltociear/patch-1
docs: update README.md
2025-01-14 23:49:06 -03:00
Herculino Trotta
6955294283 feat(datepicker): drop native datepickers in favor of AirDatePicker for better compatibility
As Firefox (still) doesn't support month input type
2025-01-14 23:47:03 -03:00
Herculino Trotta
2b6a73af18 feat(transactions): make description optional 2025-01-14 10:04:46 -03:00
Ikko Eltociear Ashimine
526c2cb191 docs: update README.md
perfomance -> performance
2025-01-14 15:05:46 +09:00
Herculino Trotta
4fe62244cd docs(README): update README 2025-01-11 20:22:29 -03:00
Herculino Trotta
011e926e02 Merge pull request #37
locale(pt-BR): update translation
2025-01-11 13:42:11 -03:00
Herculino Trotta
cd1b872b27 locale(pt-BR): update translation 2025-01-11 13:41:40 -03:00
Herculino Trotta
3791edce63 Merge pull request #36
feat(recurring-transaction): when explicitly finishing, delete any upcoming unpaid transactions
2025-01-11 13:40:28 -03:00
Herculino Trotta
2cb8100129 feat(recurring-transaction): when explicitly finishing, delete any upcoming unpaid transactions 2025-01-11 13:40:10 -03:00
Herculino Trotta
e7e4ccafb6 Merge pull request #35 from eitchtee/dev
feat(recurring-transaction): when unpause start generating transactions from today or from existing date, whichever is higher
2025-01-11 13:39:26 -03:00
Herculino Trotta
afbbf7b25d feat(recurring-transaction): when unpause start generating transactions from today or from existing date, whichever is higher 2025-01-11 13:38:51 -03:00
Herculino Trotta
1eba2b8731 Merge pull request #34 from eitchtee/dev
feat(installment-plan): don't update paid transactions amount
2025-01-11 13:37:19 -03:00
Herculino Trotta
afe366c359 feat(installment-plan): don't update paid transactions amount 2025-01-11 13:35:52 -03:00
Herculino Trotta
3ee2bebc5c Merge pull request #33
feat(recurring-transaction): update unpaid transactions info when recurring transaction is updated
2025-01-11 13:35:14 -03:00
Herculino Trotta
b951e5f069 feat(recurring-transaction): update unpaid transactions info when recurring transaction is updated 2025-01-11 13:34:49 -03:00
Herculino Trotta
4005a83a0d Merge pull request #32
fix(calculator): rounding errors
2025-01-07 16:17:00 -03:00
Herculino Trotta
f81f1d83fd fix(calculator): rounding errors 2025-01-07 16:16:26 -03:00
Herculino Trotta
7816d6c55d Merge pull request #31
fix(transactions:action-bar): rounding errors when summing (again)
2025-01-06 00:50:41 -03:00
Herculino Trotta
6e3fdae4fe fix(transactions:action-bar): rounding errors when summing (again) 2025-01-06 00:50:17 -03:00
Herculino Trotta
e2da996217 Merge pull request #30
fix(networth): chart initializing multiple times resulting in weird animation
2025-01-06 00:14:48 -03:00
Herculino Trotta
cc2e2293ed fix(networth): chart initializing multiple times resulting in weird animation 2025-01-06 00:14:15 -03:00
Herculino Trotta
7060f07ccd Merge pull request #29
feat(calculator): localize result
2025-01-06 00:14:12 -03:00
Herculino Trotta
0adb991879 feat(calculator): localize result 2025-01-06 00:13:47 -03:00
Herculino Trotta
20e03df661 Merge pull request #28
fix(transactions:action-bar): rounding errors when summing
2025-01-06 00:11:55 -03:00
Herculino Trotta
71f59bfd68 fix(transactions:action-bar): rounding errors when summing 2025-01-06 00:10:40 -03:00
Herculino Trotta
6c76535f91 Merge pull request #27
fix(transactions:action-bar): min and max calculations take into account if value is income or expense
2025-01-05 15:53:21 -03:00
Herculino Trotta
5c8fbc9278 fix(transactions:action-bar): min and max calculations take into account if value is income or expense 2025-01-05 15:52:58 -03:00
Herculino Trotta
89b11421c2 Merge pull request #26
feat(transactions:action-bar): localize calculation results
2025-01-05 15:42:51 -03:00
Herculino Trotta
056fc4fced feat(transactions:action-bar): localize calculation results 2025-01-05 15:42:28 -03:00
Herculino Trotta
3f9765ec7b Merge pull request #25
refactor(transactions:action-bar): remove debug log
2025-01-05 15:22:52 -03:00
Herculino Trotta
0d9d13bf31 refactor(transactions:action-bar): remove debug log 2025-01-05 15:22:18 -03:00
Herculino Trotta
2f6c396eaf Merge pull request #24
fix(transactions:action-bar): sum button not copying correctly
2025-01-05 15:20:24 -03:00
Herculino Trotta
d12b920e54 fix(transactions:action-bar): sum button not copying correctly 2025-01-05 15:19:58 -03:00
Herculino Trotta
9edbf7bd5a Merge pull request #23
feat(transactions:action-bar): add more math options in a dropdown
2025-01-05 14:36:07 -03:00
Herculino Trotta
dbd3eea29a locale(pt-BR): update translation 2025-01-05 14:35:33 -03:00
Herculino Trotta
881fed1895 feat(transactions:action-bar): add more math options in a dropdown 2025-01-05 14:35:23 -03:00
Herculino Trotta
10a0ac42a2 Merge pull request #22
feat(api): add RecurringTransaction and InstallmentPlan endpoints
2025-01-05 11:14:03 -03:00
Herculino Trotta
1b47c12a22 feat(api): add RecurringTransaction and InstallmentPlan endpoints 2025-01-05 11:13:23 -03:00
Herculino Trotta
091f73bf8d feat(api): support string name and ids for installmentplan endpoint 2025-01-05 11:07:38 -03:00
Herculino Trotta
73fe17de64 feat(api): add auth permission to all api endpoint 2025-01-05 11:04:50 -03:00
Herculino Trotta
52af1b2260 Merge pull request #21
feat(api): add API endpoints to add DCA entries and strategies
2025-01-05 10:54:55 -03:00
Herculino Trotta
8efa087aee feat(api): add API endpoints to add DCA entries and strategies 2025-01-05 10:54:31 -03:00
Herculino Trotta
6f69f15474 Merge pull request #20
feat: archived tabs for categories, tags and entities
2025-01-05 01:46:01 -03:00
Herculino Trotta
905e80cffe fix: overflowing empty message 2025-01-05 01:45:11 -03:00
Herculino Trotta
baae6bb96a feat(entities): add tab to show archived entities 2025-01-05 01:43:24 -03:00
Herculino Trotta
f5132e24bd feat(tags): add tab to show archived tags 2025-01-05 01:36:30 -03:00
Herculino Trotta
41303f39a0 fix: typo 2025-01-05 01:35:34 -03:00
Herculino Trotta
0fc8b0ee49 feat(tags): add tab to show archived tags 2025-01-05 01:35:25 -03:00
Herculino Trotta
037014d024 feat(categories): add tab to show archived categories 2025-01-05 01:22:14 -03:00
Herculino Trotta
8c5a9efe05 Merge pull request #19 from eitchtee/dev
locale(pt-BR): update translation
2025-01-04 18:24:47 -03:00
Herculino Trotta
f940414b5c locale(pt-BR): update translation 2025-01-04 18:23:01 -03:00
Herculino Trotta
2d8e97a27e Merge pull request #18
feat: allow for deactivating Tags, Categories and Entities, hiding them from menus
2025-01-04 18:17:42 -03:00
Herculino Trotta
5ccb9ff152 locale: add lazy translations to missing ValidationErrors 2025-01-04 18:17:06 -03:00
Herculino Trotta
3c0a2d82ac feat: allow for deactivating Tags, Categories and Entities, hiding them from menus 2025-01-04 18:13:11 -03:00
Herculino Trotta
62f049cbb2 Merge pull request #17
feat(fields:forms:dynamic-select): support existing objects not currently on the queryset
2025-01-04 18:00:33 -03:00
Herculino Trotta
7a759be357 feat(fields:forms:dynamic-select): support existing objects not currently on the queryset
and add create_field to DynamicModelChoiceField
2025-01-04 17:59:59 -03:00
Herculino Trotta
6297e73307 Merge pull request #16
feat(transactions): properly sum income and expense when selected
2025-01-04 01:33:05 -03:00
Herculino Trotta
eb753bb30e feat(transactions): properly sum income and expense when selected
also added a flatTotal (old behavior) for future use
2025-01-04 01:32:09 -03:00
Herculino Trotta
1047fb23dd fix(networth): charts not changing between views 2025-01-03 17:50:41 -03:00
Herculino Trotta
c861b9ae07 fix(networth): charts not changing between views 2025-01-03 17:36:10 -03:00
Herculino Trotta
4be849f5de github(release): add gha cache 2024-12-28 02:42:43 -03:00
Herculino Trotta
3e73332a93 locale(pt-BR): update translation 2024-12-28 02:32:43 -03:00
Herculino Trotta
ae2217e760 feat(tools:currency-converter): add a button to invert the selected currencies 2024-12-28 00:56:15 -03:00
Herculino Trotta
e2bf699be0 feat(tools:currency-converter): make it responsive 2024-12-28 00:56:15 -03:00
Herculino Trotta
e760d42c2d github(release): drop ghcr.io in favor of DockerHub 2024-12-27 12:53:43 -03:00
Herculino Trotta
d541b30280 docs: registry changes (#12)
* docker(prod): update docker-compose.prod.yml to use registry image

* docs(README): update install instructions to use registry image
2024-12-27 12:25:12 -03:00
dependabot[bot]
366c0b475d build(deps): bump nanoid from 3.3.7 to 3.3.8 in /frontend (#8)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 12:17:41 -03:00
dependabot[bot]
8576b74aff build(deps): bump http-proxy-middleware from 2.0.6 to 2.0.7 in /frontend (#9)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 12:17:34 -03:00
dependabot[bot]
d4b11bd350 build(deps): bump path-to-regexp and express in /frontend (#10)
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `path-to-regexp` from 0.1.10 to 0.1.12
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v0.1.10...v0.1.12)

Updates `express` from 4.21.0 to 4.21.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.2/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.0...4.21.2)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 12:17:28 -03:00
dependabot[bot]
c8c34c2c56 build(deps): bump cookie and express in /frontend (#11)
Bumps [cookie](https://github.com/jshttp/cookie) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `cookie` from 0.6.0 to 0.7.1
- [Release notes](https://github.com/jshttp/cookie/releases)
- [Commits](https://github.com/jshttp/cookie/compare/v0.6.0...v0.7.1)

Updates `express` from 4.21.0 to 4.21.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.2/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.0...4.21.2)

---
updated-dependencies:
- dependency-name: cookie
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 12:17:18 -03:00
Herculino Trotta
023ceb898f Merge pull request #7
github(release): cache build process
2024-12-27 11:25:52 -03:00
Herculino Trotta
1243dddd5d github(release): cache build process 2024-12-27 11:24:56 -03:00
Herculino Trotta
8661fb39e8 github(release): disable provenance when building image 2024-12-27 11:20:44 -03:00
Herculino Trotta
5752606fec docker(prod): update docker-compose.prod.yml to use registry image 2024-12-27 11:20:14 -03:00
Herculino Trotta
7250ce0dbb Merge pull request #6
github(release): drop support for arm besides arm64
2024-12-27 03:24:25 -03:00
Herculino Trotta
b963a3cfb8 github(release): drop support for arm besides arm64 2024-12-27 03:23:51 -03:00
Herculino Trotta
1f14eb011f Merge pull request #5
github: fix "repository name must be lowercase"
2024-12-27 03:13:13 -03:00
Herculino Trotta
265af71ac5 github: fix "repository name must be lowercase" 2024-12-27 03:12:59 -03:00
Herculino Trotta
4c003d4456 Merge pull request #4
dev
2024-12-27 03:09:33 -03:00
Herculino Trotta
d66a2e2856 github: remove changelog creation from release.yml 2 2024-12-27 03:09:16 -03:00
Herculino Trotta
74bf6a655d Merge pull request #3
github: remove changelog creation from release.yml
2024-12-27 03:07:19 -03:00
Herculino Trotta
114cf2622e github: remove changelog creation from release.yml 2024-12-27 03:06:33 -03:00
59 changed files with 2186 additions and 602 deletions

View File

@@ -5,40 +5,23 @@ on:
types: [created]
env:
REGISTRY: ghcr.io
IMAGE_NAME: WYGIWYH
IMAGE_NAME: wygiwyh
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Generate changelog
id: changelog
uses: metcalfc/changelog-generator@v4.1.0
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Update release with changelog
uses: softprops/action-gh-release@v1
with:
body: ${{ steps.changelog.outputs.changelog }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to the Container registry
- name: Log in to Docker Hub
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -52,7 +35,10 @@ jobs:
context: .
file: ./docker/prod/django/Dockerfile
push: true
provenance: false
tags: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

261
README.md
View File

@@ -6,13 +6,15 @@
<br>
</h1>
<h4 align="center">An optionated and powerful finance tracker.</h4>
<h4 align="center">An opinionated and powerful finance tracker.</h4>
<p align="center">
<a href="#why-wygiwyh">Why</a> •
<a href="#key-features">Features</a> •
<a href="#how-to-use">Usage</a> •
<a href="#how-it-works">How</a>
<a href="#how-it-works">How</a>
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
<a href="#built-with">Built with</a>
</p>
**WYGIWYH** (_What You Get Is What You Have_) is a powerful, principles-first finance tracker designed for people who prefer a no-budget, straightforward approach to managing their money. With features like multi-currency support, customizable transactions, and a built-in dollar-cost averaging tracker, WYGIWYH helps you take control of your finances with simplicity and flexibility.
@@ -48,29 +50,28 @@ Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**
# How To Use
To run this application, you'll need [Git](https://git-scm.com) and [Docker](https://docs.docker.com/engine/install/) with the [docker-compose](https://docs.docker.com/compose/install/).
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
From your command line:
> [!NOTE]
> Docker images for this project are currently under development, but manual setup is available now.
```bash
# Clone this repository
$ git clone https://github.com/eitchtee/WYGIWYH
$ mkdir WYGIWYH
# Go into the repository
$ cd WYGIWYH
# Fill the .env file with your configurations
$ cp .env.example .env
$ nano .env # or any other editor you want to use
$ touch docker-compose.yml
$ nano docker-compose.yml
# Paste the contents of https://github.com/eitchtee/WYGIWYH/blob/main/docker-compose.prod.yml and edit according to your needs
# Create docker-compose file
$ cp docker-compose.prod.yml docker-compose.yml
# Fill the .env file with your configurations
$ touch .env
$ nano .env # or any other editor you want to use
# Paste the contents of https://github.com/eitchtee/WYGIWYH/blob/main/.env.example and edit accordingly
# Run the app
$ docker compose up -d --build
$ docker compose up -d
# Create the first admin account
$ docker compose exec -it web python manage.py createsuperuser
@@ -211,35 +212,61 @@ A Recurring Transaction is a helper model that generates recurring transactions
### Account
TO-DO
Accounts represent different financial entities where transactions occur. They have the following attributes:
- **Name**: A unique identifier for the account.
- **Group**: An optional [account group](#account-groups) the account belongs to for organizational purposes.
- **Currency**: The primary [currency](#currency) of the account.
- **Exchange Currency**: An optional currency used for exchange rate calculations.
- **Is Asset**: A boolean indicating if the account is considered an asset (counts towards net worth).
- **Is Archived**: A boolean indicating if the account is archived (doesn't show up in active lists or count towards net worth).
### Account Groups
TO-DO
Account Groups are used to organize accounts into logical categories. They consist of:
- **Name**: A unique identifier for the group.
### Currency
TO-DO
Currencies represent different monetary units. They include:
* **Code**: A unique identifier for the currency (e.g., USD, EUR).
* **Name**: The full name of the currency.
* **Decimal Place**: The number of decimal places used for the currency.
* **Prefix**: An optional symbol or text that comes before the amount.
* **Suffix**: An optional symbol or text that comes after the amount.
### Exchange Rate
TO-DO
Exchange Rates store conversion rates between currencies:
* **From Currency**: The source currency.
* **To Currency**: The target currency.
* **Rate**: The conversion rate.
* **Date**: The date the rate was recorded or is valid for.
### Category
TO-DO
Categories are used to classify transactions:
* **Name**: A unique identifier for the category.
* **Muted**: Muted categories won't count towards your monthly total.
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
### Tag
TO-DO
Tags provide additional labeling for transactions:
* **Name**: A unique identifier for the tag.
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
### Entity
TO-DO
Entities represent parties involved in transactions:
### Rule
TO-DO
* **Name**: A unique identifier for the entity.
* **Active**: A boolean indicating if the entity is currently in use. This will disable its use on new transactions.
---
@@ -265,37 +292,98 @@ This can be useful for savings accounts or other interest accruing investments.!
### Monthly
TO-DO
The Monthly view provides an overview of your financial activity for a specific month. It includes:
* Total income and expenses for the month
* Daily spending allowance calculation
* List of transactions for the month
> [!NOTE]
> Reference dates are taken into account here.
### Yearly by currency
TO-DO
This view gives you a yearly summary of your finances grouped by currency. It shows:
* Total income and expenses for each currency
* Monthly breakdown of income and expenses
### Yearly by account
TO-DO
Similar to the [yearly by currency](#yearly-by-currency) view, but groups the data by account instead.
### Calendar
TO-DO
The Calendar view presents your transactions in a monthly calendar format, allowing you to see your financial activity day by day. It includes:
* Visual representation of daily transaction totals
* Ability to view details of transactions for each day
> [!NOTE]
> Reference dates are **not** taken into account here.
### Networh
#### Current
TO-DO
The Current Net Worth view shows your present financial standing, including:
* Total value of all asset accounts
* Breakdown of assets by account and currency
* Historical net worth trend
#### Projected
TO-DO
The Projected Net Worth view estimates your future financial position based on current data and recurring transactions. It includes:
* Your total net worth with projected and current transactions
* Breakdown of assets by account and currency
* Historical and future net worth trend
### All Transactions
TO-DO
This view provides a comprehensive list of all transactions across all accounts. Features include:
* Advanced filtering and sorting options
* Detailed information
You can use this to see how much you spent on a given category, or a given day, etc..
### Configuration and Management
TO-DO
#### Management
The Management section in the navbar allows you to add and edit most elements of WYGIWYH, including:
* Accounts and Groups
* Currencies and Exchange Rates
* Categories, Tags and Entities
* Rules
#### User Settings
WYGIWYH allows users to personalize their experience through customizable settings. Each user can configure:
* **Language**: Choose your preferred interface language.
* **Timezone**: Set your local timezone for accurate date and time display.
* **Start Page**: Select which page you want to see first when you log in.
* **Sound Preferences**: Toggle sound effects on or off.
* **Amount Display**: Choose to show or hide monetary amounts by default.
To access and modify these settings:
1. Click on your username in the top-right corner of the page.
2. Select "Settings" from the dropdown menu.
3. Adjust your preferences as desired.
4. Click "Save" to apply your changes.
These settings ensure that WYGIWYH adapts to your personal preferences and working style.
#### Django Admin
From here you can also access Django's own admin site.
> [!WARNING]
> Most side effects aren't triggered from the admin.
> Only use it if you know what you're doing or were told by a developer to do so.
---
@@ -303,7 +391,7 @@ TO-DO
### Calculator
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar.
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar or by pressing <kbd>Alt</kbd> + <kbd>C</kbd> on any page.
It allows for any math expression supported by [math.js](https://mathjs.org).
@@ -337,16 +425,109 @@ You can add additional items by clicking the _Add_ button at the end of the page
### Currency Converter
TO-DO
The Currency Converter is a tool that allows you to quickly convert amounts between different currencies.
> [!NOTE]
> There's no external Exchange Rate fetching. This uses the Exchange Rates configured in the [Management](#configuration-and-management) page for [Exchange Rates](#exchange-rate)
## Automation
### API
WYGIWYH has a comprehensive API, it's documentation can be accessed on `<your-wygiwyh-url>/api/docs/`
> [!NOTE]
> While the API works, there's still much to be added to it to equipare functionality with the main web app.
### Transaction Rules
Transaction Rules are a powerful feature in WYGIWYH that allow for automatic modification of transactions based on specified criteria. This can save time and ensure consistency in your financial tracking.
Key Aspects of Transaction Rules:
* **Conditions**: Set specific criteria that a transaction must meet for the rule to apply. This can include attributes like description, amount, account, etc.
* **Actions**: Define what changes should be made to a transaction when the conditions are met. This can include setting categories, tags, or modifying other fields.
* **Activation Options**: Rules can be set to apply when transactions are created, updated, or both.
#### Actions and Conditions
When creating a new rule, you will need to add a Condition and, later, Actions.
Both use a limited subset of Python, via [SimpleEval](https://github.com/danthedeckie/simpleeval).
The Condition must evaluate to True or False, and the Action must evaluate to a value that will be set on the selected field.
You may use any of the available [variables](#available-variables) and [functions](#available-functions).
#### Available variables
* `account_name`
* `account_id`
* `account_group_name`
* `account_group_id`
* `is_asset_account`
* `is_archived_account`
* `category_name`
* `category_id`
* `tag_names`
* `tag_ids`
* `entities_names`
* `entities_ids`
* `is_expense`
* `is_income`
* `is_paid`
* `description`
* `amount`
* `notes`
* `date`
* `reference_date`
#### Available functions
* `relativedelta`
#### Examples
Add a tag to an income transaction if it happens in a specific account
```
If...
account_name == "My Investing Account" and is_income
Then...
Set Tags to
tag_names + ["Yield"]
```
---
Move credit card transactions to next month when they happen at a cutoff date
```
If...
account_name == "My credit card" and date.day >= 26 and reference_date.month == date.month
Then...
Set Reference Date to
reference_date + relativedelta(months=1)).replace(day=1)
```
# Caveats and Warnings
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
- Pretty much all calculations are done at run time, this can lead to some performance degradation. On my personal instance, I have 3000+ transactions over 4+ years and 4000+ exchange rates, and load times average at around 500ms for each page, not bad overall.
- This isn't a budgeting or double-entry-accounting application, if you need those features there's a lot of options out there, if you really need them in WYGIWYH, open a discussion.
# Built with
WYGIWYH is possible thanks to a lot of amazing open source tools, to name a few:
- Django
- HTMX
- _hyperscript
- Procrastinate
- Bootstrap
- Tailwind
- Webpack
* Django
* HTMX
* _hyperscript
* Procrastinate
* Bootstrap
* Tailwind
* Webpack
* PostgreSQL
* Django REST framework
* Alpine.js

View File

@@ -53,6 +53,7 @@ class AccountGroupForm(forms.ModelForm):
class AccountForm(forms.ModelForm):
group = DynamicModelChoiceField(
create_field="name",
label=_("Group"),
model=AccountGroup,
required=False,
@@ -112,6 +113,7 @@ class AccountBalanceForm(forms.Form):
max_digits=42, decimal_places=30, required=False, label=_("New balance")
)
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),

View File

@@ -1,6 +1,7 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from django.utils.translation import gettext_lazy as _
from apps.transactions.models import (
TransactionCategory,
@@ -25,13 +26,13 @@ class TransactionCategoryField(serializers.Field):
return TransactionCategory.objects.get(pk=data)
except TransactionCategory.DoesNotExist:
raise serializers.ValidationError(
"Category with this ID does not exist."
_("Category with this ID does not exist.")
)
elif isinstance(data, str):
category, created = TransactionCategory.objects.get_or_create(name=data)
return category
raise serializers.ValidationError(
"Invalid category data. Provide an ID or name."
_("Invalid category data. Provide an ID or name.")
)
@staticmethod
@@ -61,13 +62,13 @@ class TransactionTagField(serializers.Field):
tag = TransactionTag.objects.get(pk=item)
except TransactionTag.DoesNotExist:
raise serializers.ValidationError(
f"Tag with ID {item} does not exist."
_("Tag with this ID does not exist.")
)
elif isinstance(item, str):
tag, created = TransactionTag.objects.get_or_create(name=item)
else:
raise serializers.ValidationError(
"Invalid tag data. Provide an ID or name."
_("Invalid tag data. Provide an ID or name.")
)
tags.append(tag)
return tags
@@ -85,13 +86,13 @@ class TransactionEntityField(serializers.Field):
entity = TransactionEntity.objects.get(pk=item)
except TransactionTag.DoesNotExist:
raise serializers.ValidationError(
f"Entity with ID {item} does not exist."
_("Entity with this ID does not exist.")
)
elif isinstance(item, str):
entity, created = TransactionEntity.objects.get_or_create(name=item)
else:
raise serializers.ValidationError(
"Invalid entity data. Provide an ID or name."
_("Invalid entity data. Provide an ID or name.")
)
entities.append(entity)
return entities

View File

@@ -1,3 +1,4 @@
from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *

View File

@@ -1,4 +1,5 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.api.serializers.currencies import CurrencySerializer
from apps.accounts.models import AccountGroup, Account
@@ -6,6 +7,8 @@ from apps.currencies.models import Currency
class AccountGroupSerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta:
model = AccountGroup
fields = "__all__"
@@ -31,6 +34,8 @@ class AccountSerializer(serializers.ModelSerializer):
allow_null=True,
)
permission_classes = [IsAuthenticated]
class Meta:
model = Account
fields = [

View File

@@ -1,8 +1,12 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.currencies.models import Currency, ExchangeRate
class CurrencySerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta:
model = Currency
fields = "__all__"
@@ -24,6 +28,8 @@ class ExchangeRateSerializer(serializers.ModelSerializer):
queryset=Currency.objects.all(), source="to_currency", write_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = ExchangeRate
fields = "__all__"

View File

@@ -0,0 +1,85 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.dca.models import DCAEntry, DCAStrategy
class DCAEntrySerializer(serializers.ModelSerializer):
profit_loss = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
profit_loss_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
current_value = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
entry_price = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = DCAEntry
fields = [
"id",
"strategy",
"date",
"amount_paid",
"amount_received",
"notes",
"created_at",
"updated_at",
"profit_loss",
"profit_loss_percentage",
"current_value",
"entry_price",
]
read_only_fields = ["created_at", "updated_at"]
class DCAStrategySerializer(serializers.ModelSerializer):
entries = DCAEntrySerializer(many=True, read_only=True)
total_invested = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_received = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
average_entry_price = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_entries = serializers.IntegerField(read_only=True)
current_total_value = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_profit_loss = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_profit_loss_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = DCAStrategy
fields = [
"id",
"name",
"target_currency",
"payment_currency",
"notes",
"created_at",
"updated_at",
"entries",
"total_invested",
"total_received",
"average_entry_price",
"total_entries",
"current_total_value",
"total_profit_loss",
"total_profit_loss_percentage",
]
read_only_fields = ["created_at", "updated_at"]

View File

@@ -19,6 +19,7 @@ from apps.transactions.models import (
TransactionTag,
InstallmentPlan,
TransactionEntity,
RecurringTransaction,
)
@@ -47,11 +48,82 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class InstallmentPlanSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
permission_classes = [IsAuthenticated]
class Meta:
model = InstallmentPlan
fields = "__all__"
fields = [
"id",
"account",
"type",
"description",
"number_of_installments",
"installment_start",
"installment_total_number",
"start_date",
"reference_date",
"end_date",
"recurrence",
"installment_amount",
"category",
"tags",
"entities",
"notes",
]
read_only_fields = ["installment_total_number", "end_date"]
def create(self, validated_data):
instance = super().create(validated_data)
instance.create_transactions()
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_transactions()
return instance
class RecurringTransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
class Meta:
model = RecurringTransaction
fields = [
"id",
"is_paused",
"account",
"type",
"amount",
"description",
"category",
"tags",
"entities",
"notes",
"reference_date",
"start_date",
"end_date",
"recurrence_type",
"recurrence_interval",
"last_generated_date",
"last_generated_reference_date",
]
read_only_fields = ["last_generated_date", "last_generated_reference_date"]
def create(self, validated_data):
instance = super().create(validated_data)
instance.create_upcoming_transactions()
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_unpaid_transactions()
return instance
class TransactionSerializer(serializers.ModelSerializer):

View File

@@ -9,10 +9,13 @@ router.register(r"categories", views.TransactionCategoryViewSet)
router.register(r"tags", views.TransactionTagViewSet)
router.register(r"entities", views.TransactionEntityViewSet)
router.register(r"installment-plans", views.InstallmentPlanViewSet)
router.register(r"recurring-transactions", views.RecurringTransactionViewSet)
router.register(r"account-groups", views.AccountGroupViewSet)
router.register(r"accounts", views.AccountViewSet)
router.register(r"currencies", views.CurrencyViewSet)
router.register(r"exchange-rates", views.ExchangeRateViewSet)
router.register(r"dca/strategies", views.DCAStrategyViewSet)
router.register(r"dca/entries", views.DCAEntryViewSet)
urlpatterns = [
path("", include(router.urls)),

View File

@@ -1,3 +1,4 @@
from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *

41
app/apps/api/views/dca.py Normal file
View File

@@ -0,0 +1,41 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.dca.models import DCAStrategy, DCAEntry
from apps.api.serializers import DCAStrategySerializer, DCAEntrySerializer
class DCAStrategyViewSet(viewsets.ModelViewSet):
queryset = DCAStrategy.objects.all()
serializer_class = DCAStrategySerializer
@action(detail=True, methods=["get"])
def investment_frequency(self, request, pk=None):
strategy = self.get_object()
return Response(strategy.investment_frequency_data())
@action(detail=True, methods=["get"])
def price_comparison(self, request, pk=None):
strategy = self.get_object()
return Response(strategy.price_comparison_data())
@action(detail=True, methods=["get"])
def current_price(self, request, pk=None):
strategy = self.get_object()
price_data = strategy.current_price()
if price_data:
price, date = price_data
return Response({"price": price, "date": date})
return Response({"price": None, "date": None})
class DCAEntryViewSet(viewsets.ModelViewSet):
queryset = DCAEntry.objects.all()
serializer_class = DCAEntrySerializer
def get_queryset(self):
queryset = DCAEntry.objects.all()
strategy_id = self.request.query_params.get("strategy", None)
if strategy_id is not None:
queryset = queryset.filter(strategy_id=strategy_id)
return queryset

View File

@@ -1,4 +1,4 @@
from rest_framework import permissions, viewsets
from rest_framework import viewsets
from apps.api.serializers import (
TransactionSerializer,
@@ -6,6 +6,7 @@ from apps.api.serializers import (
TransactionTagSerializer,
InstallmentPlanSerializer,
TransactionEntitySerializer,
RecurringTransactionSerializer,
)
from apps.transactions.models import (
Transaction,
@@ -13,6 +14,7 @@ from apps.transactions.models import (
TransactionTag,
InstallmentPlan,
TransactionEntity,
RecurringTransaction,
)
from apps.rules.signals import transaction_updated, transaction_created
@@ -53,10 +55,7 @@ class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer
def perform_create(self, serializer):
instance = serializer.save()
instance.create_transactions()
def perform_update(self, serializer):
instance = serializer.save()
instance.create_transactions()
class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer

View File

@@ -1,6 +1,7 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
@@ -8,6 +9,12 @@ from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
class DynamicModelChoiceField(forms.ModelChoiceField):
def __init__(self, model, *args, **kwargs):
self.model = model
self.to_field_name = kwargs.pop("to_field_name", "pk")
self.create_field = kwargs.pop("create_field", None)
if not self.create_field:
raise ValueError("The 'create_field' parameter is required.")
self.queryset = kwargs.pop("queryset", model.objects.all())
super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None
@@ -18,8 +25,7 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
if value in self.empty_values:
return None
try:
key = self.to_field_name or "pk"
return self.model.objects.get(**{key: value})
return self.model.objects.get(**{self.to_field_name: value})
except (ValueError, TypeError, self.model.DoesNotExist):
return value # Return the raw value; we'll handle creation in clean()
@@ -49,7 +55,9 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
except self.model.DoesNotExist:
try:
with transaction.atomic():
instance = self.model.objects.create(name=value)
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
self._created_instance = instance
return instance
except Exception as e:
@@ -111,12 +119,12 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
try:
with transaction.atomic():
new_instance = self.queryset.model(**{self.create_field: value})
new_instance.full_clean()
new_instance.save()
return new_instance
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
return instance
except Exception as e:
raise ValidationError(f"Error creating new instance: {str(e)}")
raise ValidationError(_("Error creating new instance"))
def clean(self, value):
"""
@@ -152,6 +160,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
try:
new_objects.append(self._create_new_instance(new_value))
except ValidationError as e:
raise ValidationError(f"Error creating '{new_value}': {str(e)}")
raise ValidationError(_("Error creating new instance"))
return existing_objects + new_objects

View File

@@ -1,9 +1,11 @@
import datetime
from django import forms
from django.db import models
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.datepicker import AirMonthYearPickerInput
from apps.common.widgets.month_year import MonthYearWidget
@@ -18,7 +20,7 @@ class MonthYearModelField(models.DateField):
# Set the day to 1
return date.replace(day=1).date()
except ValueError:
raise ValidationError("Invalid date format. Use YYYY-MM.")
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
def formfield(self, **kwargs):
kwargs["widget"] = MonthYearWidget
@@ -27,7 +29,7 @@ class MonthYearModelField(models.DateField):
class MonthYearFormField(forms.DateField):
widget = MonthYearWidget
widget = AirMonthYearPickerInput
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -42,7 +44,11 @@ class MonthYearFormField(forms.DateField):
date = datetime.datetime.strptime(value, "%Y-%m")
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
try:
date = datetime.datetime.strptime(value, "%Y-%m-%d")
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
def prepare_value(self, value):
if isinstance(value, datetime.date):

View File

@@ -0,0 +1,32 @@
def django_to_python_datetime(django_format):
mapping = {
# Day
"j": "%d", # Day of the month without leading zeros
"d": "%d", # Day of the month with leading zeros
"D": "%a", # Day of the week, short version
"l": "%A", # Day of the week, full version
# Month
"n": "%m", # Month without leading zeros
"m": "%m", # Month with leading zeros
"M": "%b", # Month, short version
"F": "%B", # Month, full version
# Year
"y": "%y", # Year, 2 digits
"Y": "%Y", # Year, 4 digits
# Time
"g": "%I", # Hour (12-hour), without leading zeros
"G": "%H", # Hour (24-hour), without leading zeros
"h": "%I", # Hour (12-hour), with leading zeros
"H": "%H", # Hour (24-hour), with leading zeros
"i": "%M", # Minutes
"s": "%S", # Seconds
"a": "%p", # am/pm
"A": "%p", # AM/PM
"P": "%I:%M %p",
}
python_format = django_format
for django_code, python_code in mapping.items():
python_format = python_format.replace(django_code, python_code)
return python_format

View File

@@ -0,0 +1,185 @@
import datetime
from django.forms import widgets
from django.utils import formats, translation, dates
from django.utils.formats import get_format
from apps.common.utils.django import django_to_python_datetime
class AirDatePickerInput(widgets.DateInput):
def __init__(
self,
attrs=None,
format=None,
clear_button=True,
auto_close=True,
*args,
**kwargs,
):
attrs = attrs or {}
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.clear_button = clear_button
self.auto_close = auto_close
def _get_current_language(self):
"""Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language()
# AirDatepicker uses simple language codes
return lang_code.split("-")[0]
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = self.format or get_format(
"SHORT_DATE_FORMAT", use_l10n=True
)
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, (datetime.date, datetime.datetime)):
return formats.date_format(
value, format=self.format or "SHORT_DATE_FORMAT", use_l10n=True
)
return str(value)
class AirDateTimePickerInput(widgets.DateTimeInput):
def __init__(
self,
attrs=None,
format=None,
timepicker=True,
clear_button=True,
auto_close=True,
*args,
**kwargs,
):
attrs = attrs or {}
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.timepicker = timepicker
self.clear_button = clear_button
self.auto_close = auto_close
def _get_current_language(self):
"""Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language()
# AirDatepicker uses simple language codes
return lang_code.split("-")[0]
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = self.format or get_format(
"SHORT_DATETIME_FORMAT", use_l10n=True
)
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, (datetime.date, datetime.datetime)):
return formats.date_format(
value, format=self.format or "SHORT_DATETIME_FORMAT", use_l10n=True
)
return str(value)
def value_from_datadict(self, data, files, name):
"""Parse the datetime string from the form data."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the
# value to be read by Django. Probably could be improved
return datetime.datetime.strptime(
value,
self.format
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError) as e:
return value
return None
class AirMonthYearPickerInput(AirDatePickerInput):
def __init__(self, attrs=None, format=None, *args, **kwargs):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
# Store the display format for AirDatepicker
self.display_format = "MMMM yyyy"
# Store the Python format for internal use
self.python_format = "%B %Y"
def _get_month_names(self):
"""Get month names using Django's date translation"""
return {dates.MONTHS[i]: i for i in range(1, 13)}
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, str):
try:
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return value
if isinstance(value, (datetime.datetime, datetime.date)):
# Use Django's date translation
month_name = dates.MONTHS[value.month]
return f"{month_name} {value.year}"
return value
def value_from_datadict(self, data, files, name):
"""Convert the value from the widget format back to a format Django can handle."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# Split the value into month name and year
month_str, year_str = value.rsplit(" ", 1)
year = int(year_str)
# Get month number from translated month name
month_names = self._get_month_names()
month = month_names.get(month_str)
if month and year:
# Return the first day of the month in Django's expected format
return datetime.date(year, month, 1).strftime("%Y-%m-%d")
except (ValueError, KeyError):
return None
return None

View File

@@ -6,9 +6,10 @@ from django.forms import CharField
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDateTimePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.currencies.models import Currency, ExchangeRate
from apps.common.widgets.tom_select import TomSelect
from apps.currencies.models import Currency, ExchangeRate
class CurrencyForm(forms.ModelForm):
@@ -64,9 +65,10 @@ class CurrencyForm(forms.ModelForm):
class ExchangeRateForm(forms.ModelForm):
date = forms.DateTimeField(
widget=forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"
)
widget=AirDateTimePickerInput(
clear_button=False,
),
label=_("Date"),
)
class Meta:

View File

@@ -1,13 +1,14 @@
from crispy_forms.bootstrap import FormActions
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.crispy.submit import NoClassSubmit
class DCAStrategyForm(forms.ModelForm):
@@ -61,7 +62,7 @@ class DCAEntryForm(forms.ModelForm):
"notes",
]
widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"date": AirDatePickerInput(clear_button=False),
"notes": forms.Textarea(attrs={"rows": 3}),
}

View File

@@ -8,6 +8,7 @@ from django_filters import Filter
from apps.accounts.models import Account
from apps.common.fields.month_year import MonthYearFormField
from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelectMultiple
from apps.currencies.models import Currency
@@ -87,13 +88,13 @@ class TransactionsFilter(django_filters.FilterSet):
date_start = django_filters.DateFilter(
field_name="date",
lookup_expr="gte",
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
widget=AirDatePickerInput(),
label=_("Date from"),
)
date_end = django_filters.DateFilter(
field_name="date",
lookup_expr="lte",
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
widget=AirDatePickerInput(),
label=_("Until"),
)
reference_date_start = MonthYearFilter(

View File

@@ -8,6 +8,7 @@ from crispy_forms.layout import (
Field,
)
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
@@ -15,10 +16,11 @@ from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
from apps.common.fields.month_year import MonthYearFormField
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.models import (
Transaction,
TransactionCategory,
@@ -27,14 +29,15 @@ from apps.transactions.models import (
RecurringTransaction,
TransactionEntity,
)
from apps.rules.signals import transaction_created, transaction_updated
class TransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
@@ -42,6 +45,7 @@ class TransactionForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
@@ -55,7 +59,14 @@ class TransactionForm(forms.ModelForm):
label=_("Account"),
widget=TomSelect(clear_button=False, group_by="group"),
)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
date = forms.DateField(
widget=AirDatePickerInput(clear_button=False), label=_("Date")
)
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
)
class Meta:
model = Transaction
@@ -73,7 +84,6 @@ class TransactionForm(forms.ModelForm):
"entities",
]
widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"notes": forms.Textarea(attrs={"rows": 3}),
"account": TomSelect(clear_button=False, group_by="group"),
}
@@ -81,6 +91,24 @@ class TransactionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing a transaction display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(transactions=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions=self.instance.id)
).distinct()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -96,8 +124,8 @@ class TransactionForm(forms.ModelForm):
css_class="form-row",
),
Row(
Column("date", css_class="form-group col-md-6 mb-0"),
Column("reference_date", css_class="form-group col-md-6 mb-0"),
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
"description",
@@ -181,14 +209,18 @@ class TransferForm(forms.Form):
)
from_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
to_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
from_tags = DynamicModelMultipleChoiceField(
@@ -197,6 +229,7 @@ class TransferForm(forms.Form):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
to_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
@@ -204,13 +237,17 @@ class TransferForm(forms.Form):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
date = forms.DateField(
label=_("Date"),
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
widget=AirDatePickerInput(clear_button=False), label=_("Date")
)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
)
description = forms.CharField(max_length=500, label=_("Description"))
notes = forms.CharField(
required=False,
@@ -299,7 +336,7 @@ class TransferForm(forms.Form):
to_account = cleaned_data.get("to_account")
if from_account == to_account:
raise forms.ValidationError("From and To accounts must be different.")
raise forms.ValidationError(_("From and To accounts must be different."))
return cleaned_data
@@ -358,11 +395,14 @@ class InstallmentPlanForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
@@ -370,9 +410,13 @@ class InstallmentPlanForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Entities"),
queryset=TransactionEntity.objects.filter(active=True),
)
type = forms.ChoiceField(choices=Transaction.Type.choices)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
)
class Meta:
model = InstallmentPlan
@@ -392,15 +436,33 @@ class InstallmentPlanForm(forms.ModelForm):
"entities",
]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"account": TomSelect(),
"recurrence": TomSelect(clear_button=False),
"notes": forms.Textarea(attrs={"rows": 3}),
"start_date": AirDatePickerInput(clear_button=False),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -470,7 +532,7 @@ class InstallmentPlanForm(forms.ModelForm):
class TransactionTagForm(forms.ModelForm):
class Meta:
model = TransactionTag
fields = ["name"]
fields = ["name", "active"]
labels = {"name": _("Tag name")}
def __init__(self, *args, **kwargs):
@@ -479,7 +541,7 @@ class TransactionTagForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3"))
self.helper.layout = Layout(Field("name", css_class="mb-3"), Switch("active"))
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -502,7 +564,7 @@ class TransactionTagForm(forms.ModelForm):
class TransactionEntityForm(forms.ModelForm):
class Meta:
model = TransactionEntity
fields = ["name"]
fields = ["name", "active"]
labels = {"name": _("Entity name")}
def __init__(self, *args, **kwargs):
@@ -511,7 +573,7 @@ class TransactionEntityForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3"))
self.helper.layout = Layout(Field("name"), Switch("active"))
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -534,7 +596,7 @@ class TransactionEntityForm(forms.ModelForm):
class TransactionCategoryForm(forms.ModelForm):
class Meta:
model = TransactionCategory
fields = ["name", "mute"]
fields = ["name", "mute", "active"]
labels = {"name": _("Category name")}
help_texts = {
"mute": _("Muted categories won't count towards your monthly total")
@@ -546,7 +608,7 @@ class TransactionCategoryForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3"), Switch("mute"))
self.helper.layout = Layout(Field("name"), Switch("mute"), Switch("active"))
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -578,11 +640,14 @@ class RecurringTransactionForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
@@ -590,9 +655,9 @@ class RecurringTransactionForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Entities"),
queryset=TransactionEntity.objects.filter(active=True),
)
type = forms.ChoiceField(choices=Transaction.Type.choices)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
class Meta:
model = RecurringTransaction
@@ -612,8 +677,9 @@ class RecurringTransactionForm(forms.ModelForm):
"entities",
]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"end_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"start_date": AirDatePickerInput(clear_button=False),
"end_date": AirDatePickerInput(),
"reference_date": AirMonthYearPickerInput(),
"recurrence_type": TomSelect(clear_button=False),
"notes": forms.Textarea(
attrs={
@@ -624,6 +690,25 @@ class RecurringTransactionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
@@ -694,5 +779,7 @@ class RecurringTransactionForm(forms.ModelForm):
instance = super().save(**kwargs)
if is_new:
instance.create_upcoming_transactions()
else:
instance.update_unpaid_transactions()
return instance

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.3 on 2025-01-04 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0024_installmentplan_entities_and_more'),
]
operations = [
migrations.AddField(
model_name='transactioncategory',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated categories won't be able to be selected when creating new transactions", verbose_name='Active'),
),
migrations.AddField(
model_name='transactiontag',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated tags won't be able to be selected when creating new transactions", verbose_name='Active'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-04 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0025_transactioncategory_active_transactiontag_active'),
]
operations = [
migrations.AddField(
model_name='transactionentity',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated entities won't be able to be selected when creating new transactions", verbose_name='Active'),
),
]

View File

@@ -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'),
),
]

View File

@@ -18,6 +18,13 @@ logger = logging.getLogger()
class TransactionCategory(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated categories won't be able to be selected when creating new transactions"
),
)
class Meta:
verbose_name = _("Transaction Category")
@@ -30,6 +37,13 @@ class TransactionCategory(models.Model):
class TransactionTag(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated tags won't be able to be selected when creating new transactions"
),
)
class Meta:
verbose_name = _("Transaction Tags")
@@ -42,8 +56,13 @@ class TransactionTag(models.Model):
class TransactionEntity(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
# Add any other fields you might want for entities
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated entities won't be able to be selected when creating new transactions"
),
)
class Meta:
verbose_name = _("Entity")
@@ -82,7 +101,9 @@ class Transaction(models.Model):
validators=[validate_non_negative, validate_decimal_places],
)
description = models.CharField(max_length=500, verbose_name=_("Description"))
description = models.CharField(
max_length=500, verbose_name=_("Description"), blank=True
)
notes = models.TextField(blank=True, verbose_name=_("Notes"))
category = models.ForeignKey(
TransactionCategory,
@@ -315,10 +336,15 @@ class InstallmentPlan(models.Model):
existing_transaction.type = self.type
existing_transaction.date = transaction_date
existing_transaction.reference_date = transaction_reference_date
existing_transaction.amount = self.installment_amount
existing_transaction.description = self.description
existing_transaction.category = self.category
existing_transaction.notes = self.notes
if (
not existing_transaction.is_paid
): # Don't update value for paid transactions
existing_transaction.amount = self.installment_amount
existing_transaction.save()
# Update tags
@@ -521,3 +547,33 @@ class RecurringTransaction(models.Model):
recurring_transaction.save(
update_fields=["last_generated_date", "last_generated_reference_date"]
)
def update_unpaid_transactions(self):
"""
Updates all unpaid transactions associated with this RecurringTransaction.
Only unpaid transactions (`is_paid=False`) are modified. Updates fields like
amount, description, category, notes, and many-to-many relationships (tags, entities).
"""
unpaid_transactions = self.transactions.filter(is_paid=False)
for existing_transaction in unpaid_transactions:
# Update fields based on RecurringTransaction
existing_transaction.amount = self.amount
existing_transaction.description = self.description
existing_transaction.category = self.category
existing_transaction.notes = self.notes
# Update many-to-many relationships
existing_transaction.tags.set(self.tags.all())
existing_transaction.entities.set(self.entities.all())
# Save updated transaction
existing_transaction.save()
def delete_unpaid_transactions(self):
"""
Deletes all unpaid transactions associated with this RecurringTransaction.
"""
today = timezone.localdate(timezone.now())
self.transactions.filter(is_paid=False, date__gt=today).delete()

View File

@@ -53,6 +53,8 @@ urlpatterns = [
),
path("tags/", views.tags_index, name="tags_index"),
path("tags/list/", views.tags_list, name="tags_list"),
path("tags/table/active/", views.tags_table_active, name="tags_table_active"),
path("tags/table/archived/", views.tags_table_archived, name="tags_table_archived"),
path("tags/add/", views.tag_add, name="tag_add"),
path(
"tags/<int:tag_id>/edit/",
@@ -66,6 +68,16 @@ urlpatterns = [
),
path("entities/", views.entities_index, name="entities_index"),
path("entities/list/", views.entities_list, name="entities_list"),
path(
"entities/table/active/",
views.entities_table_active,
name="entities_table_active",
),
path(
"entities/table/archived/",
views.entities_table_archived,
name="entities_table_archived",
),
path("entities/add/", views.entity_add, name="entity_add"),
path(
"entities/<int:entity_id>/edit/",
@@ -79,6 +91,16 @@ urlpatterns = [
),
path("categories/", views.categories_index, name="categories_index"),
path("categories/list/", views.categories_list, name="categories_list"),
path(
"categories/table/active/",
views.categories_table_active,
name="categories_table_active",
),
path(
"categories/table/archived/",
views.categories_table_archived,
name="categories_table_archived",
),
path("categories/add/", views.category_add, name="category_add"),
path(
"categories/<int:category_id>/edit/",

View File

@@ -25,11 +25,33 @@ def categories_index(request):
@login_required
@require_http_methods(["GET"])
def categories_list(request):
categories = TransactionCategory.objects.all().order_by("id")
return render(
request,
"categories/fragments/list.html",
{"categories": categories},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def categories_table_active(request):
categories = TransactionCategory.objects.filter(active=True).order_by("id")
return render(
request,
"categories/fragments/table.html",
{"categories": categories, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def categories_table_archived(request):
categories = TransactionCategory.objects.filter(active=False).order_by("id")
return render(
request,
"categories/fragments/table.html",
{"categories": categories, "active": False},
)

View File

@@ -24,11 +24,33 @@ def entities_index(request):
@login_required
@require_http_methods(["GET"])
def entities_list(request):
entities = TransactionEntity.objects.all().order_by("id")
return render(
request,
"entities/fragments/list.html",
{"entities": entities},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def entities_table_active(request):
entities = TransactionEntity.objects.filter(active=True).order_by("id")
return render(
request,
"entities/fragments/table.html",
{"entities": entities, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def entities_table_archived(request):
entities = TransactionEntity.objects.filter(active=False).order_by("id")
return render(
request,
"entities/fragments/table.html",
{"entities": entities, "active": False},
)

View File

@@ -168,12 +168,26 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
)
current_paused = recurring_transaction.is_paused
recurring_transaction.is_paused = not current_paused
recurring_transaction.save(update_fields=["is_paused"])
if current_paused:
messages.success(request, _("Recurring transaction unpaused successfully"))
today = timezone.localdate(timezone.now())
recurring_transaction.last_generated_date = max(
recurring_transaction.last_generated_date, today
)
recurring_transaction.last_generated_reference_date = max(
recurring_transaction.last_generated_reference_date, today
)
recurring_transaction.save(
update_fields=[
"last_generated_date",
"last_generated_reference_date",
"is_paused",
]
)
generate_recurring_transactions.defer()
messages.success(request, _("Recurring transaction unpaused successfully"))
else:
recurring_transaction.save(update_fields=["is_paused"])
messages.success(request, _("Recurring transaction paused successfully"))
return HttpResponse(
@@ -188,7 +202,7 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
@login_required
@require_http_methods(["GET"])
def recurring_transaction_finish(request, recurring_transaction_id):
recurring_transaction = get_object_or_404(
recurring_transaction: RecurringTransaction = get_object_or_404(
RecurringTransaction, id=recurring_transaction_id
)
today = timezone.localdate(timezone.now()) - relativedelta(days=1)
@@ -197,6 +211,9 @@ def recurring_transaction_finish(request, recurring_transaction_id):
recurring_transaction.is_paused = True
recurring_transaction.save(update_fields=["end_date", "is_paused"])
# Delete all unpaid transactions associated with this RecurringTransaction
recurring_transaction.delete_unpaid_transactions()
messages.success(request, _("Recurring transaction finished successfully"))
return HttpResponse(

View File

@@ -24,11 +24,33 @@ def tags_index(request):
@login_required
@require_http_methods(["GET"])
def tags_list(request):
tags = TransactionTag.objects.all().order_by("id")
return render(
request,
"tags/fragments/list.html",
{"tags": tags},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def tags_table_active(request):
tags = TransactionTag.objects.filter(active=True).order_by("id")
return render(
request,
"tags/fragments/table.html",
{"tags": tags, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def tags_table_archived(request):
tags = TransactionTag.objects.filter(active=False).order_by("id")
return render(
request,
"tags/fragments/table.html",
{"tags": tags, "active": False},
)

File diff suppressed because it is too large Load Diff

View File

@@ -15,53 +15,18 @@
</div>
<div class="card">
<div class="card-body table-responsive">
{% if categories %}
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Muted' %}</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr class="category">
<td class="col-auto text-center">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'category_edit' category_id=category.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'category_delete' category_id=category.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ category.name }}</td>
<td class="col">
{% if category.mute %}<i class="fa-solid fa-check text-success"></i>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<c-msg.empty title="{% translate "No categories" %}" remove-padding></c-msg.empty>
{% endif %}
<div class="card-header">
<ul class="nav nav-pills card-header-pills" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" type="button" role="tab" aria-selected="true" hx-get="{% url 'categories_table_active' %}" hx-trigger="load, click" hx-target="#categories-table">{% translate 'Active' %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" hx-get="{% url 'categories_table_archived' %}" hx-target="#categories-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
</li>
</ul>
</div>
<div class="card-body">
<div id="categories-table"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,59 @@
{% load i18n %}
{% if active %}
<div class="show-loading" hx-get="{% url 'categories_table_active' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% else %}
<div class="show-loading" hx-get="{% url 'categories_table_archived' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% endif %}
{% if categories %}
<div class="table-responsive">
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Muted' %}</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr class="category">
<td class="col-auto text-center">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
hx-swap="innerHTML"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'category_edit' category_id=category.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'category_delete' category_id=category.id %}"
hx-trigger='confirmed'
hx-swap="innerHTML"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ category.name }}</td>
<td class="col">
{% if category.mute %}<i class="fa-solid fa-check text-success"></i>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No categories" %}" remove-padding></c-msg.empty>
{% endif %}
</div>

View File

@@ -4,5 +4,5 @@
{% block title %}{% translate 'Categories' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'categories_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
<div hx-get="{% url 'categories_list' %}" hx-trigger="load" class="show-loading"></div>
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% load i18n %}
<div class="transaction d-flex my-1">
<div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
{% if not disable_selection %}
<label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}" id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>

View File

@@ -11,70 +11,228 @@
<div class="card slide-in-left">
<div class="card-body p-2">
{% spaceless %}
<div class="btn-group" role="group">
<div class="btn-group" role="group">
<button class="btn btn-secondary btn-sm"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Select All' %}"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400"></i>
</button>
<button class="btn btn-secondary btn-sm"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Unselect All' %}"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square tw-text-red-400"></i>
</button>
</div>
<div class="vr mx-3 tw-align-middle"></div>
<div class="btn-group me-3" role="group">
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_pay' %}"
hx-include=".transaction"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as paid' %}">
<i class="fa-regular fa-circle-check tw-text-green-400"></i>
</button>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_unpay' %}"
hx-include=".transaction"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as unpaid' %}">
<i class="fa-regular fa-circle tw-text-red-400"></i>
</button>
</div>
<button class="btn btn-secondary btn-sm"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Select All' %}"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400"></i>
</button>
<button class="btn btn-secondary btn-sm"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Unselect All' %}"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square tw-text-red-400"></i>
</button>
</div>
<div class="vr mx-3 tw-align-middle"></div>
<div class="btn-group me-3" role="group">
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_pay' %}"
hx-include=".transaction"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as paid' %}">
<i class="fa-regular fa-circle-check tw-text-green-400"></i>
</button>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_unpay' %}"
hx-get="{% url 'transactions_bulk_delete' %}"
hx-include=".transaction"
hx-trigger="confirmed"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as unpaid' %}">
<i class="fa-regular fa-circle tw-text-red-400"></i>
data-bs-title="{% translate 'Delete' %}"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete them!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash text-danger"></i>
</button>
</div>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_delete' %}"
hx-include=".transaction"
hx-trigger="confirmed"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Delete' %}"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete them!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash text-danger"></i>
</button>
<div class="vr mx-3 tw-align-middle"></div>
<span _="on selected_transactions_updated from #actions-bar
set total to 0.0
for amt in <.transaction:has(input[name='transactions']:checked) .main-amount .amount/>
set amountValue to parseFloat(amt.getAttribute('data-amount'))
if not isNaN(amountValue)
set total to total + (amountValue * 100)
<div class="vr mx-3 tw-align-middle"></div>
<div class="btn-group"
_="on selected_transactions_updated from #actions-bar
set realTotal to math.bignumber(0)
set flatTotal to math.bignumber(0)
set transactions to <.transaction:has(input[name='transactions']:checked)/>
set flatAmountValues to []
set realAmountValues to []
for transaction in transactions
set amt to first <.main-amount .amount/> in transaction
set amountValue to parseFloat(amt.getAttribute('data-amount'))
append amountValue to flatAmountValues
if not isNaN(amountValue)
set flatTotal to math.chain(flatTotal).add(amountValue)
if transaction match .income
append amountValue to realAmountValues
set realTotal to math.chain(realTotal).add(amountValue)
else
append -amountValue to realAmountValues
set realTotal to math.chain(realTotal).subtract(amountValue)
end
end
end
end
set total to total / 100
put total.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into me
end
on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end"
class="" role="button"></span>
set mean to flatTotal.divide(flatAmountValues.length).done().toNumber()
set realTotal to realTotal.done().toNumber()
set flatTotal to flatTotal.done().toNumber()
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #real-total-front's innerText
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-real-total's innerText
put flatTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-flat-total's innerText
put Math.max.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-max's innerText
put Math.min.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-min's innerText
put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
end"
>
<button class="btn btn-secondary btn-sm" _="on click
set original_value to #real-total-front's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #real-total-front's innerText
wait 1s
put original_value into #real-total-front's innerText
end">
<i class="fa-solid fa-plus fa-fw me-2 text-primary"></i>
<span id="real-total-front">0</span>
</button>
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
</button>
<ul class="dropdown-menu">
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Flat Total" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-flat-total"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Real Total" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-real-total"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Mean" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-mean"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Max" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-max"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Min" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-min"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Count" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-count"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
</ul>
</div>
{% endspaceless %}
</div>
</div>

View File

@@ -15,49 +15,18 @@
</div>
<div class="card">
<div class="card-body table-responsive">
{% if entities %}
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for entity in entities %}
<tr class="entity">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'entity_edit' entity_id=entity.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'entity_delete' entity_id=entity.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ entity.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<c-msg.empty title="{% translate "No entities" %}" remove-padding></c-msg.empty>
{% endif %}
<div class="card-header">
<ul class="nav nav-pills card-header-pills" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" type="button" role="tab" aria-selected="true" hx-get="{% url 'entities_table_active' %}" hx-trigger="load, click" hx-target="#entities-table">{% translate 'Active' %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" hx-get="{% url 'entities_table_archived' %}" hx-target="#entities-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
</li>
</ul>
</div>
<div class="card-body">
<div id="entities-table"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,55 @@
{% load i18n %}
{% if active %}
<div class="show-loading" hx-get="{% url 'entities_table_active' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% else %}
<div class="show-loading" hx-get="{% url 'entities_table_archived' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% endif %}
{% if entities %}
<div class="table-responsive">
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for entity in entities %}
<tr class="entity">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'entity_edit' entity_id=entity.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'entity_delete' entity_id=entity.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ entity.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No entities" %}" remove-padding></c-msg.empty>
{% endif %}
</div>

View File

@@ -4,5 +4,5 @@
{% block title %}{% translate 'Entities' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'entities_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
<div hx-get="{% url 'entities_list' %}" hx-trigger="load" class="show-loading"></div>
{% endblock %}

View File

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

View File

@@ -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;

View File

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

View File

@@ -66,11 +66,10 @@
})
end
then set expr to it
then call math.evaluate(expr)
then call math.evaluate(expr).toNumber()
if result exists and result is a Number
js(result)
return result.toString().replace(new RegExp(',|\\.', 'g'),
match => match === '.' ? window.decimalSeparator : window.argSeparator)
return result.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40})
end
then set localizedResult to it
set #calculator-result.innerText to localizedResult

View File

@@ -8,12 +8,14 @@
{% block title %}{% translate 'Currency Converter' %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5" _="install init_tom_select">
<div class="container px-md-3 py-3 column-gap-5"
_="install init_tom_select
install init_datepicker">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
<div>{% translate 'Currency Converter' %}</div>
</div>
<div class="row">
<div class="col-5">
<div class="col-12 col-lg-5">
<div>
<input class="form-control form-control-lg mb-3"
type="text"
@@ -25,13 +27,14 @@
</div>
<div>{{ form.from_currency|as_crispy_field }}</div>
</div>
<div class="col text-primary tw-flex tw-items-center tw-justify-center">
<div class="col text-primary tw-flex tw-items-center tw-justify-center my-3 my-lg-0">
<i class="fa-solid fa-equals"></i>
</div>
<div class="col-5">
<div class="col-12 col-lg-5">
<div hx-get="{% url 'currency_converter_convert' %}"
hx-trigger="input from:#from_value, input from:#id_from_currency, input from:#id_to_currency"
hx-include="#from_value, #id_from_currency, #id_to_currency">
hx-trigger="input from:#from_value, input from:#id_from_currency, input from:#id_to_currency, updated"
hx-include="#from_value, #id_from_currency, #id_to_currency"
id="result">
<input class="form-control form-control-lg mb-3"
type="text"
name="to_value"
@@ -41,5 +44,19 @@
<div>{{ form.to_currency|as_crispy_field }}</div>
</div>
</div>
<div class="row">
<div class="tw-cursor-pointer text-primary text-end"
_="on click
set from_value to #id_from_currency's value
set to_value to #id_to_currency's value
set #id_from_currency's value to to_value
set #id_to_currency's value to from_value
call #id_from_currency.tomselect.sync()
call #id_to_currency.tomselect.sync()
trigger updated on #result
end">
<i class="fa-solid fa-rotate me-2"></i><span>{% trans 'Invert' %}</span>
</div>
</div>
</div>
{% endblock %}

View File

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

View File

@@ -9,7 +9,7 @@
{% block title %}{% if type == "current" %}{% translate 'Current Net Worth' %}{% else %}{% translate 'Projected Net Worth' %}{% endif %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3">
<div class="container px-md-3 py-3" _="init call initializeAccountChart() then initializeCurrencyChart() end">
<div class="row gx-xl-4 gy-3 mb-4">
<div class="col-12 col-xl-5">
<div class="row row-cols-1 g-4">
@@ -52,7 +52,7 @@
</div>
</div>
<div class="col-12 col-xl-7">
<div class="chart-container position-relativo tw-min-h-[40vh] tw-h-full">
<div class="chart-container position-relative tw-min-h-[40vh] tw-h-full">
<canvas id="currencyBalanceChart"></canvas>
</div>
</div>
@@ -136,7 +136,7 @@
</div>
</div>
<div class="col-12 col-xl-7">
<div class="chart-container position-relativo tw-min-h-[40vh] tw-h-full">
<div class="chart-container position-relative tw-min-h-[40vh] tw-h-full">
<canvas id="accountBalanceChart"></canvas>
</div>
</div>
@@ -144,13 +144,19 @@
</div>
<script>
document.body.addEventListener('htmx:load', function (evt) {
var currencyChart;
function initializeCurrencyChart() {
// Destroy existing chart if it exists
if (currencyChart) {
currencyChart.destroy();
}
var chartData = JSON.parse('{{ chart_data_currency_json|safe }}');
var currencies = {{ currencies|safe }};
var ctx = document.getElementById('currencyBalanceChart').getContext('2d');
new Chart(ctx, {
currencyChart = new Chart(ctx, {
type: 'line',
data: chartData,
options: {
@@ -197,17 +203,23 @@
}
}
});
});
}
</script>
<script>
document.body.addEventListener('htmx:load', function (evt) {
<script id="accountBalanceChartScript">
var accountChart;
function initializeAccountChart() {
// Destroy existing chart if it exists
if (accountChart) {
accountChart.destroy();
}
var chartData = JSON.parse('{{ chart_data_accounts_json|safe }}');
var accounts = {{ accounts|safe }};
var ctx = document.getElementById('accountBalanceChart').getContext('2d');
new Chart(ctx, {
accountChart = new Chart(ctx, {
type: 'line',
data: chartData,
options: {
@@ -256,43 +268,38 @@
}
}
});
});
}
</script>
<script type="text/hyperscript">
def showOnlyAccountDataset(datasetName)
set chart to Chart.getChart('accountBalanceChart')
for dataset in chart.data.datasets
for dataset in accountChart.data.datasets
set isMatch to dataset.label is datasetName
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), isMatch)
call accountChart.setDatasetVisibility(accountChart.data.datasets.indexOf(dataset), isMatch)
end
call chart.update()
call accountChart.update()
end
def showOnlyCurrencyDataset(datasetName)
set chart to Chart.getChart('currencyBalanceChart')
for dataset in chart.data.datasets
for dataset in currencyChart.data.datasets
set isMatch to dataset.label is datasetName
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), isMatch)
call currencyChart.setDatasetVisibility(currencyChart.data.datasets.indexOf(dataset), isMatch)
end
call chart.update()
call currencyChart.update()
end
def showAllDatasetsAccount()
set chart to Chart.getChart('accountBalanceChart')
for dataset in chart.data.datasets
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), true)
for dataset in accountChart.data.datasets
call accountChart.setDatasetVisibility(accountChart.data.datasets.indexOf(dataset), true)
end
call chart.update()
call accountChart.update()
end
def showAllDatasetsCurrency()
set chart to Chart.getChart('currencyBalanceChart')
for dataset in chart.data.datasets
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), true)
for dataset in currencyChart.data.datasets
call currencyChart.setDatasetVisibility(currencyChart.data.datasets.indexOf(dataset), true)
end
call chart.update()
call currencyChart.update()
end
</script>

View File

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

View File

@@ -15,49 +15,18 @@
</div>
<div class="card">
<div class="card-body table-responsive">
{% if tags %}
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr class="tag">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'tag_edit' tag_id=tag.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'tag_delete' tag_id=tag.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ tag.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<c-msg.empty title="{% translate "No tags" %}" remove-padding></c-msg.empty>
{% endif %}
<div class="card-header">
<ul class="nav nav-pills card-header-pills" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" type="button" role="tab" aria-selected="true" hx-get="{% url 'tags_table_active' %}" hx-trigger="load, click" hx-target="#tags-table">{% translate 'Active' %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" hx-get="{% url 'tags_table_archived' %}" hx-target="#tags-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
</li>
</ul>
</div>
<div class="card-body">
<div id="tags-table"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,55 @@
{% load i18n %}
{% if active %}
<div class="show-loading" hx-get="{% url 'tags_table_active' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% else %}
<div class="show-loading" hx-get="{% url 'tags_table_archived' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% endif %}
{% if tags %}
<div class="table-responsive">
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr class="tag">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'tag_edit' tag_id=tag.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
hx-swap="innerHTML"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'tag_delete' tag_id=tag.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ tag.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No tags" %}" remove-padding></c-msg.empty>
{% endif %}
</div>

View File

@@ -4,5 +4,5 @@
{% block title %}{% translate 'Tags' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'tags_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
<div hx-get="{% url 'tags_list' %}" hx-trigger="load" class="show-loading"></div>
{% endblock %}

View File

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

View File

@@ -1,9 +1,6 @@
services:
web: &django
build:
context: .
dockerfile: ./docker/prod/django/Dockerfile
image: ${SERVER_NAME}
image: eitchtee/wygiwyh:latest
container_name: ${SERVER_NAME}
command: /start
ports:
@@ -27,7 +24,6 @@ services:
procrastinate:
<<: *django
image: ${PROCRASTINATE_NAME}
container_name: ${PROCRASTINATE_NAME}
depends_on:
- db

View File

@@ -6,16 +6,16 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
&& rm -rf /var/lib/apt/lists/*
COPY ./requirements.txt .
RUN pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt
FROM node:lts-alpine AS webpack_build
WORKDIR /usr/src/frontend
COPY ./frontend .
COPY ./app/templates /usr/src/app/templates
RUN npm config set registry https://registry.npmmirror.com/ && \
RUN --mount=type=cache,target=/root/.npm \
npm install --verbose && \
npm run build && \
npm cache clean --force
npm run build
FROM python:3.11-slim-buster AS python-run-stage
COPY --from=webpack_build /usr/src/frontend/build /usr/src/frontend/build
@@ -29,12 +29,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
RUN apt-get update && \
RUN --mount=type=cache,target=/root/.cache/apt \
apt-get update && \
apt-get install --no-install-recommends -y gettext && \
rm -rf /var/lib/apt/lists/* && \
pip install --upgrade pip && \
pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* && \
rm -rf /wheels/ ~/.cache/pip/*
rm -rf /wheels/
COPY --chown=app:app ./docker/prod/django/start /start
COPY --chown=app:app ./docker/prod/procrastinate/start /start-procrastinate

View File

@@ -17,6 +17,7 @@
"@babel/preset-env": "^7.16.8",
"@fortawesome/fontawesome-free": "^6.6.0",
"@popperjs/core": "^2.11.8",
"air-datepicker": "^3.5.3",
"alpinejs": "^3.14.1",
"autoprefixer": "^10.4.14",
"autosize": "^6.0.1",
@@ -2703,6 +2704,12 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/air-datepicker": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.5.3.tgz",
"integrity": "sha512-Elf9gLhv/jidN1+TfeRJYMQRUfYx5apXw2dY5DuAMPRnNtQ4Iw9fTTJK772osmXSUB9xQ2Y8Q1Pt6pgBOQLPQw==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -3441,9 +3448,9 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"engines": {
"node": ">= 0.6"
}
@@ -4499,16 +4506,16 @@
}
},
"node_modules/express": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.6.0",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
@@ -4522,7 +4529,7 @@
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.10",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
@@ -4537,6 +4544,10 @@
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/debug": {
@@ -5219,9 +5230,9 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
"integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==",
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
@@ -6179,9 +6190,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [
{
"type": "github",
@@ -6535,9 +6546,9 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"node_modules/path-to-regexp": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w=="
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/path-type": {
"version": "4.0.0",

View File

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

View File

@@ -0,0 +1,148 @@
import AirDatepicker from 'air-datepicker';
import en from 'air-datepicker/locale/en';
import ptBr from 'air-datepicker/locale/pt-BR';
import {createPopper} from '@popperjs/core';
const locales = {
'pt': ptBr,
'en': en
};
function isMobileDevice() {
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
return mobileRegex.test(navigator.userAgent);
}
function isTouchDevice() {
return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
}
function isMobile() {
return isMobileDevice() || isTouchDevice();
}
window.DatePicker = function createDynamicDatePicker(element) {
let isOnMobile = isMobile();
let baseOpts = {
isMobile: isOnMobile,
timepicker: element.dataset.timepicker === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'],
locale: locales[element.dataset.language],
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
name: 'flip',
options: {
padding: {
top: 64
}
}
},
{
name: 'offset',
options: {
offset: [0, 20]
}
},
{
name: 'arrow',
options: {
element: $pointer
}
}
]
});
return function completeHide() {
popper.destroy();
done();
};
}
} : {};
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [element.dataset.value];
opts["startDate"] = [element.dataset.value];
}
return new AirDatepicker(element, opts);
};
window.MonthYearPicker = function createDynamicDatePicker(element) {
let isOnMobile = isMobile();
let baseOpts = {
isMobile: isOnMobile,
view: 'months',
minView: 'months',
dateFormat: 'MMMM yyyy',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'],
locale: locales[element.dataset.language],
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
name: 'flip',
options: {
padding: {
top: 64
}
}
},
{
name: 'offset',
options: {
offset: [0, 20]
}
},
{
name: 'arrow',
options: {
element: $pointer
}
}
]
});
return function completeHide() {
popper.destroy();
done();
};
}
} : {};
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [element.dataset.value];
opts["startDate"] = [element.dataset.value];
}
return new AirDatepicker(element, opts);
};

View File

@@ -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;

View 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
}

View File

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