Compare commits
33 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28524b3f08 | ||
|
|
bcbb37a146 | ||
|
|
de53a50e45 | ||
|
|
32e647f0fb | ||
|
|
4ebc08e5a4 | ||
|
|
ee162bbef7 | ||
|
|
df391e0a14 | ||
|
|
6182a62573 | ||
|
|
981a1cb2ee | ||
|
|
e0d8499a8c | ||
|
|
483d67846c | ||
|
|
e9c8897eaf | ||
|
|
9e09931c0e | ||
|
|
98f3f172a9 | ||
|
|
0e15bda6eb | ||
|
|
8f356656fc | ||
|
|
6e59fdb369 | ||
|
|
457247da8e | ||
|
|
41c991384a | ||
|
|
77f166a5f8 | ||
|
|
ac27a1c87f | ||
|
|
32748b0632 | ||
|
|
444155c103 | ||
|
|
8654a98e6e | ||
|
|
3dd67d3ed6 | ||
|
|
4efbb58197 | ||
|
|
94345ddc3a | ||
|
|
6212d57915 | ||
|
|
5f75e2e14f | ||
|
|
55f7cb1bc2 | ||
|
|
5ac3a808b2 | ||
|
|
30c19b9d2e | ||
|
|
34811d8fd8 |
22
.github/DISCUSSION_TEMPLATE/feature-requests.yml
vendored
Normal file
22
.github/DISCUSSION_TEMPLATE/feature-requests.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
title: Feature Request
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in Maybe! Please follow the template below to submit your feature request. You can visit our [roadmap](https://github.com/orgs/maybe-finance/projects/13) to see what's currently planned.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature
|
||||
description: Provide a clear and concise description of the feature you would like.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Why is this feature important?
|
||||
description: Tell us what specific problem(s) this feature solves for you or other users.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context, screenshots, and relevant links
|
||||
description: Provide additional info to help us evaluate whether this feature is a good fit for the product.
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'Bug: '
|
||||
labels: ":bug: Bug, :rocket: Feature"
|
||||
labels: ":bug: Bug"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
33
.github/ISSUE_TEMPLATE/feature-specification.md
vendored
33
.github/ISSUE_TEMPLATE/feature-specification.md
vendored
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: Feature Specification
|
||||
about: A fully scoped feature with designs, requirements, and implementation plan
|
||||
title: 'Feature: '
|
||||
labels: ":rocket: Feature"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
_If your feature requires designs, UI changes, or input from the Maybe team, you should open a feature request instead. This template is for a **fully scoped and ready** feature. _
|
||||
|
||||
## Feature Overview
|
||||
|
||||
## Requirements
|
||||
|
||||
_If there is a missing / incorrect requirement, please leave a comment before starting work on this._
|
||||
|
||||
- [ ] Requirement 1
|
||||
|
||||
## Implementation Suggestions
|
||||
|
||||
_Below are some ideas for implementation to get you started. Use your best judgment here—if there's a better way to do things, go for it!_
|
||||
|
||||
## Designs
|
||||
|
||||
Below are the designs you should follow while implementing this:
|
||||
|
||||
## Reminders
|
||||
|
||||
- Make sure to review our [contributing guidelines](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) before starting on an issue
|
||||
- We do our best to define a clear spec for new features and fixes, but think of them as "suggestions", not "hard requirements". We welcome ideas and suggestions!
|
||||
- If you see missing requirements to this issue, please leave a comment below explaining what is missing and why it is important.
|
||||
- If you see a requirement that you think is _incorrect_ or _not optimal_, please leave a comment explaining what you think needs to change below.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: 'Feature Request: '
|
||||
labels: ":bulb: Feature Request"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
21
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Other
|
||||
about: All other issues
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**PLEASE READ before opening an issue:**
|
||||
|
||||
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
|
||||
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
|
||||
|
||||
----------------------
|
||||
|
||||
**Is this issue related to a problem? Please describe.**
|
||||
|
||||
**Describe the work that needs to be done to address this issue**
|
||||
|
||||
**Additional context**
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
scan_ruby:
|
||||
@@ -59,6 +59,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
RAILS_ENV: test
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
@@ -82,16 +86,19 @@ jobs:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run tests and smoke test seed
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
- name: DB setup and smoke test
|
||||
run: |
|
||||
bin/rails db:create
|
||||
bin/rails db:schema:load
|
||||
bin/rails test
|
||||
bin/rails db:seed
|
||||
|
||||
- name: Unit and integration tests
|
||||
run: bin/rails test
|
||||
|
||||
- name: System tests
|
||||
run: DISABLE_PARALLELIZATION=true bin/rails test:system
|
||||
continue-on-error: true # TODO: Eventually we'll enforce for PRs
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
|
||||
8
.github/workflows/pr.yml
vendored
Normal file
8
.github/workflows/pr.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
name: Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
52
.github/workflows/publish.yml
vendored
52
.github/workflows/publish.yml
vendored
@@ -15,56 +15,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run tests and smoke test seed
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
run: |
|
||||
bin/rails db:create
|
||||
bin/rails db:schema:load
|
||||
bin/rails test:all
|
||||
bin/rails db:seed
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: screenshots
|
||||
path: ${{ github.workspace }}/tmp/screenshots
|
||||
if-no-files-found: ignore
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
build:
|
||||
name: Build docker image
|
||||
needs: [ test ]
|
||||
needs: [ ci ]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -109,7 +65,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
3
Gemfile
3
Gemfile
@@ -25,9 +25,6 @@ gem "turbo-rails"
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
|
||||
# Search
|
||||
gem "ransack", github: "maybe-finance/ransack", branch: "main"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
gem "sentry-ruby"
|
||||
|
||||
31
Gemfile.lock
31
Gemfile.lock
@@ -5,19 +5,9 @@ GIT
|
||||
lucide-rails (0.2.0)
|
||||
railties (>= 4.1.0)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/maybe-finance/ransack.git
|
||||
revision: dec20edc9ccccac77f5b4b8a1c1a9f20dc58fa04
|
||||
branch: main
|
||||
specs:
|
||||
ransack (4.1.1)
|
||||
activerecord (>= 6.1.5)
|
||||
activesupport (>= 6.1.5)
|
||||
i18n
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: fb4300ce193c338e00c8fe3a8372dc594f6c5de8
|
||||
revision: c1f1b14adce5cd373ed63611486eb7a7db73c78c
|
||||
branch: 7-2-stable
|
||||
specs:
|
||||
actioncable (7.2.0.alpha)
|
||||
@@ -86,7 +76,7 @@ GIT
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1, < 5.22.0)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
rails (7.2.0.alpha)
|
||||
actioncable (= 7.2.0.alpha)
|
||||
@@ -199,7 +189,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (3.28.2)
|
||||
good_job (3.29.2)
|
||||
activejob (>= 6.0.0)
|
||||
activerecord (>= 6.0.0)
|
||||
concurrent-ruby (>= 1.0.2)
|
||||
@@ -261,8 +251,8 @@ GEM
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.21.2)
|
||||
mocha (2.2.0)
|
||||
minitest (5.23.1)
|
||||
mocha (2.3.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
@@ -300,7 +290,7 @@ GEM
|
||||
racc
|
||||
pg (1.5.6)
|
||||
prism (0.27.0)
|
||||
propshaft (0.8.0)
|
||||
propshaft (0.9.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
@@ -311,7 +301,7 @@ GEM
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.3)
|
||||
racc (1.8.0)
|
||||
rack (3.0.11)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -338,7 +328,7 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
rdoc (6.6.3.1)
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.7)
|
||||
@@ -444,7 +434,7 @@ GEM
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.23.0)
|
||||
webmock (3.23.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -455,7 +445,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.14)
|
||||
zeitwerk (2.6.15)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -494,7 +484,6 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
rails!
|
||||
rails-settings-cached
|
||||
ransack!
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
selenium-webdriver
|
||||
|
||||
@@ -74,12 +74,20 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def sync
|
||||
@account.sync_later if @account.can_sync?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } })
|
||||
if @account.can_sync?
|
||||
@account.sync_later
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } })
|
||||
end
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".cannot_sync") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "error", content: { body: t(".cannot_sync") } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
24
app/controllers/tags/deletions_controller.rb
Normal file
24
app/controllers/tags/deletions_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class Tags::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@tag.replace_and_destroy! @replacement_tag
|
||||
redirect_back_or_to tags_path, notice: t(".deleted")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tag
|
||||
@tag = Current.family.tags.find_by(id: params[:tag_id])
|
||||
end
|
||||
|
||||
def set_replacement_tag
|
||||
@replacement_tag = Current.family.tags.find_by(id: params[:replacement_tag_id])
|
||||
end
|
||||
end
|
||||
36
app/controllers/tags_controller.rb
Normal file
36
app/controllers/tags_controller.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class TagsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_tag, only: %i[ edit update ]
|
||||
|
||||
def index
|
||||
@tags = Current.family.tags.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@tag = Current.family.tags.new color: Tag::COLORS.sample
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.tags.create!(tag_params)
|
||||
redirect_to tags_path, notice: t(".created")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@tag.update!(tag_params)
|
||||
redirect_to tags_path, notice: t(".updated")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tag
|
||||
@tag = Current.family.tags.find(params[:id])
|
||||
end
|
||||
|
||||
def tag_params
|
||||
params.require(:tag).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,7 @@ class Transactions::Categories::DeletionsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.transaction_categories.find(params[:transaction_category_id])
|
||||
@category = Current.family.transaction_categories.find(params[:category_id])
|
||||
end
|
||||
|
||||
def set_replacement_category
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class Transactions::Categories::DropdownsController < ApplicationController
|
||||
before_action :set_from_params
|
||||
|
||||
def show
|
||||
@categories = categories_scope.to_a.excluding(@selected_category).prepend(@selected_category).compact
|
||||
end
|
||||
|
||||
private
|
||||
def set_from_params
|
||||
if params[:category_id]
|
||||
@selected_category = categories_scope.find(params[:category_id])
|
||||
end
|
||||
|
||||
if params[:transaction_id]
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
end
|
||||
end
|
||||
|
||||
def categories_scope
|
||||
Current.family.transaction_categories.alphabetically
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ class Transactions::MerchantsController < ApplicationController
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.transaction_merchants
|
||||
@merchants = Current.family.transaction_merchants.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
22
app/controllers/transactions/rows_controller.rb
Normal file
22
app/controllers/transactions/rows_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Transactions::RowsController < ApplicationController
|
||||
before_action :set_transaction, only: %i[ show update ]
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
@transaction.update! transaction_params
|
||||
|
||||
redirect_to transaction_row_path(@transaction)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:category_id)
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
@transaction = Current.family.transactions.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -4,56 +4,15 @@ class TransactionsController < ApplicationController
|
||||
before_action :set_transaction, only: %i[ show edit update destroy ]
|
||||
|
||||
def index
|
||||
search_params = session[ransack_session_key] || params[:q]
|
||||
@q = Current.family.transactions.ransack(search_params)
|
||||
result = @q.result.order(date: :desc)
|
||||
@pagy, @transactions = pagy(result, items: 10)
|
||||
@q = search_params
|
||||
result = Current.family.transactions.search(@q).ordered
|
||||
@pagy, @transactions = pagy(result, items: 50)
|
||||
|
||||
@totals = {
|
||||
count: result.count,
|
||||
income: result.inflows.sum(&:amount_money).abs,
|
||||
expense: result.outflows.sum(&:amount_money).abs
|
||||
}
|
||||
@filter_list = Transaction.build_filter_list(search_params, Current.family)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def search
|
||||
if params[:clear]
|
||||
session.delete(ransack_session_key)
|
||||
elsif params[:remove_param]
|
||||
current_params = session[ransack_session_key] || {}
|
||||
if params[:remove_param] == "date_range"
|
||||
updated_params = current_params.except("date_gteq", "date_lteq")
|
||||
elsif params[:remove_param_value]
|
||||
key_to_remove = params[:remove_param]
|
||||
value_to_remove = params[:remove_param_value]
|
||||
updated_params = current_params.deep_dup
|
||||
updated_params[key_to_remove] = updated_params[key_to_remove] - [ value_to_remove ]
|
||||
else
|
||||
updated_params = current_params.except(params[:remove_param])
|
||||
end
|
||||
session[ransack_session_key] = updated_params
|
||||
elsif params[:q]
|
||||
session[ransack_session_key] = params[:q]
|
||||
end
|
||||
|
||||
index
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render :index }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace("transactions_summary", partial: "transactions/summary", locals: { totals: @totals }),
|
||||
turbo_stream.replace("transactions_search_form", partial: "transactions/search_form", locals: { q: @q }),
|
||||
turbo_stream.replace("transactions_filters", partial: "transactions/filters", locals: { filters: @filter_list }),
|
||||
turbo_stream.replace("transactions_list", partial: "transactions/list", locals: { transactions: @transactions, pagy: @pagy })
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -72,73 +31,31 @@ class TransactionsController < ApplicationController
|
||||
|
||||
def create
|
||||
@transaction = Current.family.accounts
|
||||
.find(params[:transaction][:account_id])
|
||||
.transactions.build(transaction_params.merge(amount: amount))
|
||||
.find(params[:transaction][:account_id])
|
||||
.transactions.build(transaction_params.merge(amount: amount))
|
||||
|
||||
respond_to do |format|
|
||||
if @transaction.save
|
||||
@transaction.account.sync_later(@transaction.date)
|
||||
format.html { redirect_to transactions_url, notice: t(".success") }
|
||||
else
|
||||
format.html { render :new, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
@transaction.save!
|
||||
@transaction.sync_account_later
|
||||
redirect_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
respond_to do |format|
|
||||
sync_start_date = if transaction_params[:date]
|
||||
[ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
|
||||
else
|
||||
@transaction.date
|
||||
end
|
||||
@transaction.update! transaction_params
|
||||
@transaction.sync_account_later
|
||||
|
||||
if @transaction.update(transaction_params)
|
||||
@transaction.account.sync_later(sync_start_date)
|
||||
|
||||
format.html { redirect_to transaction_url(@transaction), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } }),
|
||||
turbo_stream.replace("transaction_#{@transaction.id}", partial: "transactions/transaction", locals: { transaction: @transaction })
|
||||
]
|
||||
end
|
||||
else
|
||||
format.html { render :edit, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
redirect_to transaction_url(@transaction), notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account = @transaction.account
|
||||
sync_start_date = @account.transactions.where("date < ?", @transaction.date).order(date: :desc).first&.date
|
||||
@transaction.destroy!
|
||||
@account.sync_later(sync_start_date)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to transactions_url, notice: t(".success") }
|
||||
end
|
||||
@transaction.sync_account_later
|
||||
redirect_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def delete_search_param(params, key, value: nil)
|
||||
if value
|
||||
params[key]&.delete(value)
|
||||
params.delete(key) if params[key].empty? # Remove key if it's empty after deleting value
|
||||
else
|
||||
params.delete(key)
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def ransack_session_key
|
||||
:ransack_transactions_q
|
||||
end
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_transaction
|
||||
@transaction = Transaction.find(params[:id])
|
||||
@transaction = Current.family.transactions.find(params[:id])
|
||||
end
|
||||
|
||||
def amount
|
||||
@@ -153,8 +70,11 @@ class TransactionsController < ApplicationController
|
||||
params[:transaction][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
# Only allow a list of trusted parameters through.
|
||||
def search_params
|
||||
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
|
||||
end
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id)
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [], taggings_attributes: [ :id, :tag_id, :_destroy ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,6 +39,8 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||
value: money&.amount,
|
||||
"data-money-field-target" => "amount",
|
||||
placeholder: Money.new(0, currency).format,
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
|
||||
@@ -20,23 +20,37 @@ module ApplicationHelper
|
||||
render partial: "shared/notification", locals: { type: options[:type], content: { body: content } }
|
||||
end
|
||||
|
||||
# Wrap view with <%= modal do %> ... <% end %> to have it open in a modal
|
||||
# Make sure to add data-turbo-frame="modal" to the link/button that opens the modal
|
||||
##
|
||||
# Helper to open a centered and overlayed modal with custom contents
|
||||
#
|
||||
# @example Basic usage
|
||||
# <%= modal classes: "custom-class" do %>
|
||||
# <div>Content here</div>
|
||||
# <% end %>
|
||||
#
|
||||
def modal(options = {}, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/modal", locals: { content:, classes: options[:classes] }
|
||||
end
|
||||
|
||||
##
|
||||
# Helper to open a drawer on the right side of the screen with custom contents
|
||||
#
|
||||
# @example Basic usage
|
||||
# <%= drawer do %>
|
||||
# <div>Content here</div>
|
||||
# <% end %>
|
||||
#
|
||||
def drawer(&block)
|
||||
content = capture &block
|
||||
render partial: "shared/drawer", locals: { content: content }
|
||||
end
|
||||
|
||||
def account_groups(period: nil)
|
||||
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ assets.children, liabilities.children ].flatten
|
||||
end
|
||||
|
||||
def sidebar_modal(&block)
|
||||
content = capture &block
|
||||
render partial: "shared/sidebar_modal", locals: { content: content }
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
|
||||
|
||||
7
app/helpers/tags_helper.rb
Normal file
7
app/helpers/tags_helper.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module TagsHelper
|
||||
def null_tag
|
||||
Tag.new \
|
||||
name: "Uncategorized",
|
||||
color: Tag::UNCATEGORIZED_COLOR
|
||||
end
|
||||
end
|
||||
37
app/helpers/transactions/searches_helper.rb
Normal file
37
app/helpers/transactions/searches_helper.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
module Transactions::SearchesHelper
|
||||
def transaction_search_filters
|
||||
[
|
||||
{ key: "account_filter", name: "Account", icon: "layers" },
|
||||
{ key: "date_filter", name: "Date", icon: "calendar" },
|
||||
{ key: "type_filter", name: "Type", icon: "shapes" },
|
||||
{ key: "amount_filter", name: "Amount", icon: "hash" },
|
||||
{ key: "category_filter", name: "Category", icon: "tag" },
|
||||
{ key: "merchant_filter", name: "Merchant", icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
def get_transaction_search_filter_partial_path(filter)
|
||||
"transactions/searches/filters/#{filter[:key]}"
|
||||
end
|
||||
|
||||
def get_default_transaction_search_filter
|
||||
transaction_search_filters[0]
|
||||
end
|
||||
|
||||
def transactions_path_without_param(param_key, param_value)
|
||||
updated_params = request.query_parameters.deep_dup
|
||||
|
||||
q_params = updated_params[:q] || {}
|
||||
|
||||
current_value = q_params[param_key]
|
||||
if current_value.is_a?(Array)
|
||||
q_params[param_key] = current_value - [ param_value ]
|
||||
else
|
||||
q_params.delete(param_key)
|
||||
end
|
||||
|
||||
updated_params[:q] = q_params
|
||||
|
||||
transactions_path(updated_params)
|
||||
end
|
||||
end
|
||||
@@ -1,24 +1,20 @@
|
||||
module TransactionsHelper
|
||||
def transaction_filters
|
||||
[
|
||||
{ name: "Account", partial: "account_filter", icon: "layers" },
|
||||
{ name: "Date", partial: "date_filter", icon: "calendar" },
|
||||
{ name: "Type", partial: "type_filter", icon: "shapes" },
|
||||
{ name: "Amount", partial: "amount_filter", icon: "hash" },
|
||||
{ name: "Category", partial: "category_filter", icon: "tag" },
|
||||
{ name: "Merchant", partial: "merchant_filter", icon: "store" }
|
||||
]
|
||||
end
|
||||
def transactions_group(date, transactions, transaction_partial_path = "transactions/transaction")
|
||||
header_left = content_tag :span do
|
||||
"#{date.strftime('%b %d, %Y')} · #{transactions.size}".html_safe
|
||||
end
|
||||
|
||||
def transaction_filter_id(filter)
|
||||
"txn-#{filter[:name].downcase}-filter"
|
||||
end
|
||||
header_right = content_tag :span do
|
||||
format_money(-transactions.sum(&:amount_money))
|
||||
end
|
||||
|
||||
def transaction_filter_by_name(name)
|
||||
transaction_filters.find { |filter| filter[:name] == name }
|
||||
end
|
||||
header = header_left.concat(header_right)
|
||||
|
||||
def full_width_transaction_row?(route)
|
||||
route != "/"
|
||||
content = render partial: transaction_partial_path, collection: transactions
|
||||
|
||||
render partial: "shared/list_group", locals: {
|
||||
header: header,
|
||||
content: content
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "replacementCategoryField", "submitButton" ]
|
||||
static targets = ["replacementField", "submitButton"]
|
||||
static classes = [ "dangerousAction", "safeAction" ]
|
||||
static values = {
|
||||
submitTextWhenReplacing: String,
|
||||
@@ -9,7 +9,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
updateSubmitButton() {
|
||||
if (this.replacementCategoryFieldTarget.value) {
|
||||
if (this.replacementFieldTarget.value) {
|
||||
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
|
||||
this.#markSafe()
|
||||
} else {
|
||||
@@ -22,10 +22,6 @@ class Account < ApplicationRecord
|
||||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def balance_on(date)
|
||||
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
class Account::Balance::Calculator
|
||||
attr_reader :daily_balances, :errors, :warnings
|
||||
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
|
||||
def initialize(account, options = {})
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
@account = account
|
||||
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
|
||||
end
|
||||
@@ -43,34 +42,50 @@ class Account::Balance::Calculator
|
||||
|
||||
private
|
||||
def convert_balances_to_family_currency
|
||||
rates = ExchangeRate.get_rate_series(
|
||||
rates = ExchangeRate.get_rates(
|
||||
@account.currency,
|
||||
@account.family.currency,
|
||||
@calc_start_date..Date.current
|
||||
).to_a
|
||||
|
||||
@daily_balances.map do |balance|
|
||||
rate = rates.find { |rate| rate.date == balance[:date] }
|
||||
raise "Rate for #{@account.currency} to #{@account.family.currency} on #{balance[:date]} not found" if rate.nil?
|
||||
converted_balance = balance[:balance] * rate.rate
|
||||
# Abort conversion if some required rates are missing
|
||||
if rates.length != @daily_balances.length
|
||||
@errors << :sync_message_missing_rates
|
||||
return []
|
||||
end
|
||||
|
||||
@daily_balances.map.with_index do |balance, index|
|
||||
converted_balance = balance[:balance] * rates[index].rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
end
|
||||
|
||||
# For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency)
|
||||
def normalize_entries_to_account_currency(entries, value_key)
|
||||
entries.map do |entry|
|
||||
currency = entry.currency
|
||||
date = entry.date
|
||||
value = entry.send(value_key)
|
||||
grouped_entries = entries.group_by(&:currency)
|
||||
normalized_entries = []
|
||||
|
||||
grouped_entries.each do |currency, entries|
|
||||
if currency != @account.currency
|
||||
value = ExchangeRate.convert(value:, from: currency, to: @account.currency, date:)
|
||||
currency = @account.currency
|
||||
dates = entries.map(&:date).uniq
|
||||
rates = ExchangeRate.get_rates(currency, @account.currency, dates).to_a
|
||||
if rates.length != dates.length
|
||||
@errors << :sync_message_missing_rates
|
||||
else
|
||||
entries.each do |entry|
|
||||
## There can be several entries on the same date so we cannot rely on indeces
|
||||
rate = rates.find { |rate| rate.date == entry.date }
|
||||
value = entry.send(value_key)
|
||||
value *= rate.rate
|
||||
normalized_entries << entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
end
|
||||
else
|
||||
normalized_entries.concat(entries)
|
||||
end
|
||||
|
||||
entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
|
||||
normalized_entries
|
||||
end
|
||||
|
||||
def normalized_valuations
|
||||
@@ -92,8 +107,8 @@ class Account::Balance::Calculator
|
||||
return @account.balance_on(@calc_start_date)
|
||||
end
|
||||
|
||||
oldest_valuation_date = normalized_valuations.first&.dig("date")
|
||||
oldest_transaction_date = normalized_transactions.first&.dig("date")
|
||||
oldest_valuation_date = normalized_valuations.first&.date
|
||||
oldest_transaction_date = normalized_transactions.first&.date
|
||||
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
|
||||
|
||||
if oldest_entry_date.present? && oldest_entry_date == oldest_valuation_date
|
||||
|
||||
@@ -1,88 +1,85 @@
|
||||
module Account::Syncable
|
||||
extend ActiveSupport::Concern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sync_later(start_date = nil)
|
||||
AccountSyncJob.perform_later(self, start_date)
|
||||
def sync_later(start_date = nil)
|
||||
AccountSyncJob.perform_later(self, start_date)
|
||||
end
|
||||
|
||||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today, balance: new_balance, sync_errors: calculator.errors, sync_warnings: calculator.warnings)
|
||||
rescue => e
|
||||
update!(status: "error", sync_errors: [ :sync_message_unknown_error ])
|
||||
logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
end
|
||||
|
||||
def can_sync?
|
||||
# Skip account sync if account is not active or the sync process is already running
|
||||
return false unless is_active
|
||||
return false if syncing?
|
||||
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
|
||||
return true if last_sync_date.blank?
|
||||
|
||||
# If last_sync_date is not today, allow syncing
|
||||
last_sync_date != Date.today
|
||||
end
|
||||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
def sync_exchange_rates
|
||||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
end
|
||||
|
||||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
self.balance = new_balance
|
||||
self.save!
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today)
|
||||
rescue => e
|
||||
update!(status: "error")
|
||||
Rails.logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
if foreign_currency?
|
||||
(effective_start_date..Date.current).each do |date|
|
||||
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
|
||||
end
|
||||
end
|
||||
|
||||
def can_sync?
|
||||
# Skip account sync if account is not active or the sync process is already running
|
||||
return false unless is_active
|
||||
return false if syncing?
|
||||
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
|
||||
return true if last_sync_date.blank?
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
date: rate_candidates.map { |rc| rc[:date] }
|
||||
).pluck(:base_currency, :converted_currency, :date)
|
||||
|
||||
# If last_sync_date is not today, allow syncing
|
||||
last_sync_date != Date.today
|
||||
# Convert to a set for faster lookup
|
||||
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
|
||||
|
||||
rate_candidates.each do |rate_candidate|
|
||||
rc_from = rate_candidate[:from_currency]
|
||||
rc_to = rate_candidate[:to_currency]
|
||||
rc_date = rate_candidate[:date]
|
||||
|
||||
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
|
||||
|
||||
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
|
||||
ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
|
||||
end
|
||||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
def sync_exchange_rates
|
||||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
end
|
||||
|
||||
if foreign_currency?
|
||||
(effective_start_date..Date.current).each do |date|
|
||||
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
|
||||
end
|
||||
end
|
||||
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
date: rate_candidates.map { |rc| rc[:date] }
|
||||
).pluck(:base_currency, :converted_currency, :date)
|
||||
|
||||
# Convert to a set for faster lookup
|
||||
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
|
||||
|
||||
rate_candidates.each do |rate_candidate|
|
||||
rc_from = rate_candidate[:from_currency]
|
||||
rc_to = rate_candidate[:to_currency]
|
||||
rc_date = rate_candidate[:date]
|
||||
|
||||
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
|
||||
|
||||
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
|
||||
rate = ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
|
||||
ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,11 +12,11 @@ class ExchangeRate < ApplicationRecord
|
||||
end
|
||||
|
||||
def find_rate_or_fetch(from:, to:, date:)
|
||||
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:).tap(&:save!)
|
||||
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:)&.tap(&:save!)
|
||||
end
|
||||
|
||||
def get_rate_series(from, to, date_range)
|
||||
where(base_currency: from, converted_currency: to, date: date_range).order(:date)
|
||||
def get_rates(from, to, dates)
|
||||
where(base_currency: from, converted_currency: to, date: dates).order(:date)
|
||||
end
|
||||
|
||||
def convert(value:, from:, to:, date:)
|
||||
|
||||
@@ -5,6 +5,8 @@ module ExchangeRate::Provided
|
||||
class_methods do
|
||||
private
|
||||
def fetch_rate_from_provider(from:, to:, date:)
|
||||
return nil unless exchange_rates_provider.configured?
|
||||
|
||||
response = exchange_rates_provider.fetch_exchange_rate \
|
||||
from: Money::Currency.new(from).iso_code,
|
||||
to: Money::Currency.new(to).iso_code,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Family < ApplicationRecord
|
||||
has_many :users, dependent: :destroy
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :imports, through: :accounts
|
||||
|
||||
@@ -11,6 +11,8 @@ class Import < ApplicationRecord
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
FALLBACK_TRANSACTION_NAME = "Imported transaction"
|
||||
|
||||
def publish_later
|
||||
ImportJob.perform_later(self)
|
||||
end
|
||||
@@ -54,6 +56,8 @@ class Import < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
self.account.sync
|
||||
|
||||
update!(status: "complete")
|
||||
rescue => e
|
||||
update!(status: "failed")
|
||||
@@ -108,14 +112,27 @@ class Import < ApplicationRecord
|
||||
|
||||
def generate_transactions
|
||||
transactions = []
|
||||
category_cache = {}
|
||||
tag_cache = {}
|
||||
|
||||
csv.table.each do |row|
|
||||
category = account.family.transaction_categories.find_or_initialize_by(name: row["category"])
|
||||
category_name = row["category"].presence
|
||||
tag_strings = row["tags"].presence&.split("|") || []
|
||||
tags = []
|
||||
|
||||
tag_strings.each do |tag_string|
|
||||
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
|
||||
end
|
||||
|
||||
category = category_cache[category_name] ||= account.family.transaction_categories.find_or_initialize_by(name: category_name) if category_name.present?
|
||||
|
||||
txn = account.transactions.build \
|
||||
name: row["name"] || "Imported transaction",
|
||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
||||
date: Date.iso8601(row["date"]),
|
||||
category: category,
|
||||
amount: BigDecimal(row["amount"]) * -1 # User inputs amounts with opposite signage of our internal representation
|
||||
tags: tags,
|
||||
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
|
||||
currency: account.currency
|
||||
|
||||
transactions << txn
|
||||
end
|
||||
@@ -137,12 +154,16 @@ class Import < ApplicationRecord
|
||||
key: "category",
|
||||
label: "Category"
|
||||
|
||||
tags_field = Import::Field.new \
|
||||
key: "tags",
|
||||
label: "Tags"
|
||||
|
||||
amount_field = Import::Field.new \
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
||||
|
||||
[ date_field, name_field, category_field, amount_field ]
|
||||
[ date_field, name_field, category_field, tags_field, amount_field ]
|
||||
end
|
||||
|
||||
def define_column_mapping_keys
|
||||
|
||||
@@ -5,6 +5,10 @@ class Provider::Synth
|
||||
@api_key = api_key || ENV["SYNTH_API_KEY"]
|
||||
end
|
||||
|
||||
def configured?
|
||||
@api_key.present?
|
||||
end
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
retrying Provider::Base.known_transient_errors do |on_last_attempt|
|
||||
response = Faraday.get("#{base_url}/rates/historical") do |req|
|
||||
|
||||
25
app/models/tag.rb
Normal file
25
app/models/tag.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class Tag < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :taggings, dependent: :destroy
|
||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction"
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :family }
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
UNCATEGORIZED_COLOR = "#737373"
|
||||
|
||||
def replace_and_destroy!(replacement)
|
||||
transaction do
|
||||
raise ActiveRecord::RecordInvalid, "Replacement tag cannot be the same as the tag being destroyed" if replacement == self
|
||||
|
||||
if replacement
|
||||
taggings.update_all tag_id: replacement.id
|
||||
end
|
||||
|
||||
destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
4
app/models/tagging.rb
Normal file
4
app/models/tagging.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class Tagging < ApplicationRecord
|
||||
belongs_to :tag
|
||||
belongs_to :taggable, polymorphic: true
|
||||
end
|
||||
@@ -1,17 +1,28 @@
|
||||
class Transaction < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
validates :name, :date, :amount, :account, presence: true
|
||||
|
||||
monetize :amount
|
||||
|
||||
scope :ordered, -> { order(date: :desc) }
|
||||
scope :active, -> { where(excluded: false) }
|
||||
scope :inflows, -> { where("amount <= 0") }
|
||||
scope :outflows, -> { where("amount > 0") }
|
||||
scope :active, -> { where(excluded: false) }
|
||||
scope :by_name, ->(name) { where("transactions.name ILIKE ?", "%#{name}%") }
|
||||
scope :with_categories, ->(categories) { joins(:category).where(transaction_categories: { name: categories }) }
|
||||
scope :with_accounts, ->(accounts) { joins(:account).where(accounts: { name: accounts }) }
|
||||
scope :with_account_ids, ->(account_ids) { joins(:account).where(accounts: { id: account_ids }) }
|
||||
scope :with_merchants, ->(merchants) { joins(:merchant).where(transaction_merchants: { name: merchants }) }
|
||||
scope :on_or_after_date, ->(date) { where("transactions.date >= ?", date) }
|
||||
scope :on_or_before_date, ->(date) { where("transactions.date <= ?", date) }
|
||||
scope :with_converted_amount, ->(currency = Current.family.currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
@@ -23,79 +34,74 @@ class Transaction < ApplicationRecord
|
||||
.where("er.rate IS NOT NULL OR transactions.currency = ?", currency)
|
||||
}
|
||||
|
||||
def self.daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(transactions.with_converted_amount(currency), :t)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
def inflow?
|
||||
amount <= 0
|
||||
end
|
||||
|
||||
def self.daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order("date")
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
def outflow?
|
||||
amount > 0
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name amount date]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[category merchant account]
|
||||
end
|
||||
|
||||
def self.build_filter_list(params, family)
|
||||
filters = []
|
||||
|
||||
date_filters = { gteq: nil, lteq: nil }
|
||||
|
||||
if params
|
||||
params.each do |key, value|
|
||||
next if value.blank?
|
||||
|
||||
case key
|
||||
when "account_id_in"
|
||||
value.each do |account_id|
|
||||
filters << { type: "account", value: family.accounts.find(account_id), original: { key: key, value: account_id } }
|
||||
end
|
||||
when "category_id_in"
|
||||
value.each do |category_id|
|
||||
filters << { type: "category", value: family.transaction_categories.find(category_id), original: { key: key, value: category_id } }
|
||||
end
|
||||
when "merchant_id_in"
|
||||
value.each do |merchant_id|
|
||||
filters << { type: "merchant", value: family.transaction_merchants.find(merchant_id), original: { key: key, value: merchant_id } }
|
||||
end
|
||||
when "category_name_or_merchant_name_or_account_name_or_name_cont"
|
||||
filters << { type: "search", value: value, original: { key: key, value: nil } }
|
||||
when "date_gteq"
|
||||
date_filters[:gteq] = value
|
||||
when "date_lteq"
|
||||
date_filters[:lteq] = value
|
||||
end
|
||||
end
|
||||
|
||||
unless date_filters.values.compact.empty?
|
||||
filters << { type: "date_range", value: date_filters, original: { key: "date_range", value: nil } }
|
||||
end
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_transaction_date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
filters
|
||||
account.sync_later(sync_start_date)
|
||||
end
|
||||
|
||||
class << self
|
||||
def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(transactions.with_converted_amount(currency), :t)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
||||
def daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order("date")
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
end
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.by_name(params[:search]) if params[:search].present?
|
||||
query = query.with_categories(params[:categories]) if params[:categories].present?
|
||||
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
|
||||
query = query.with_account_ids(params[:account_ids]) if params[:account_ids].present?
|
||||
query = query.with_merchants(params[:merchants]) if params[:merchants].present?
|
||||
query = query.on_or_after_date(params[:start_date]) if params[:start_date].present?
|
||||
query = query.on_or_before_date(params[:end_date]) if params[:end_date].present?
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_transaction_date
|
||||
self.account
|
||||
.transactions
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first&.date
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,14 +23,6 @@ class Transaction::Category < ApplicationRecord
|
||||
{ internal_category: "home_improvement", color: COLORS[7] }
|
||||
]
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[]
|
||||
end
|
||||
|
||||
def self.create_default_categories(family)
|
||||
if family.transaction_categories.size > 0
|
||||
raise ArgumentError, "Family already has some categories"
|
||||
|
||||
@@ -7,12 +7,4 @@ class Transaction::Merchant < ApplicationRecord
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<% group.children.each do |account_value_node| %>
|
||||
<% group.children.sort_by(&:name).each do |account_value_node| %>
|
||||
<% account = account_value_node.original %>
|
||||
<%= link_to account_path(account), class: "flex items-center w-full gap-3 px-3 py-2 mb-1 hover:bg-gray-100 rounded-[10px]" do %>
|
||||
<%= image_tag account_logo_url(account), class: "w-6 h-6" %>
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
<span class="text-sm">New transaction</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if transactions.empty? %>
|
||||
<p class="text-gray-500 py-4">No transactions for this account yet.</p>
|
||||
<% else %>
|
||||
<div class="space-y-6">
|
||||
<%= render partial: "transactions/transaction_group", collection: transactions.group_by(&:date), as: :transaction_group %>
|
||||
<% transactions.group_by(&:date).each do |date, transactions| %>
|
||||
<%= transactions_group(date, transactions) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,6 @@
|
||||
<% else %>
|
||||
<%= previous_setting("Billing", settings_billing_path) %>
|
||||
<% end %>
|
||||
<%= next_setting("Categories", transaction_categories_path) %>
|
||||
<%= next_setting("Tags", tags_path) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<%= f.text_field :name, placeholder: t(".name.placeholder"), required: "required", label: t(".name.label"), autofocus: true %>
|
||||
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
||||
<%= f.money_field :balance_money, label: t(".balance.label"), required: "required" %>
|
||||
<%= f.date_field :date, label: t(".start_date.label"), required: true, max: Date.today, value: Date.today %>
|
||||
<%= f.date_field :start_date, label: t(".start_date.label"), required: true, max: Date.today, value: Date.today %>
|
||||
</div>
|
||||
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
||||
<% end %>
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
<%= turbo_frame_tag "sync_message" do %>
|
||||
<%= render partial: "accounts/sync_message", locals: { is_syncing: @account.syncing? } %>
|
||||
<% end %>
|
||||
<% @account.sync_errors.each do |message| %>
|
||||
<%= render partial: "shared/alert", locals: { type: "error", content: t("." + message) } %>
|
||||
<% end %>
|
||||
<% @account.sync_warnings.each do |message| %>
|
||||
<%= render partial: "shared/alert", locals: { type: "warning", content: t("." + message) } %>
|
||||
<% end %>
|
||||
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
||||
<div class="p-4 flex justify-between">
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
<!--TODO: Once we have more styled tables for reference, refactor and DRY this up -->
|
||||
<div class="grid grid-cols-4 border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white">
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tl-md">Date</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">Name</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">Category</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tr-md">Amount</div>
|
||||
<div class="grid grid-cols-5 border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white">
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tl-md">date</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">name</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">category</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">tags</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tr-md">amount</div>
|
||||
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-01-01</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Amazon</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Shopping</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Tag1|Tag2</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-24.99</div>
|
||||
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-03-01</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Spotify</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-16.32</div>
|
||||
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-bl-md">2023-01-06</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Acme</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Income</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Tag3</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-br-md">151.22</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-8 space-y-4">
|
||||
<%= render partial: "imports/transactions/transaction_group", collection: @import.dry_run.group_by(&:date) %>
|
||||
<% @import.dry_run.group_by(&:date).each do |date, draft_transactions| %>
|
||||
<%= transactions_group(date, draft_transactions, "imports/transactions/transaction") %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||
|
||||
<div class="mx-auto max-w-[450px] w-full py-24 space-y-4">
|
||||
<div class="mx-auto max-w-[550px] w-full py-24 space-y-4">
|
||||
<h1 class="sr-only"><%= t(".load_title") %></h1>
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
@@ -30,6 +30,7 @@
|
||||
<ul class="list-disc text-sm pl-10">
|
||||
<li><%= t(".requirement1") %></li>
|
||||
<li><%= t(".requirement2") %></li>
|
||||
<li><%= t(".requirement3") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<%# locals: (transaction:) %>
|
||||
<div class="text-gray-900 flex items-center gap-6 py-4 text-sm font-medium px-4">
|
||||
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
|
||||
<div class="text-gray-900 grid grid-cols-8 items-center py-4 text-sm font-medium px-4">
|
||||
<div class="col-span-3">
|
||||
<%= render "transactions/name", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<div class="w-48">
|
||||
<div class="col-span-2">
|
||||
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<%= content_tag :p, format_money(-transaction.amount), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %>
|
||||
<div class="col-span-2 flex items-center gap-1">
|
||||
<% transaction.tags.each do |tag| %>
|
||||
<%= render partial: "tags/badge", locals: { tag: tag } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 justify-self-end">
|
||||
<%= render "transactions/amount", transaction: transaction %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<%# locals: (transaction_group:) %>
|
||||
<% date = transaction_group[0] %>
|
||||
<% transactions = transaction_group[1] %>
|
||||
|
||||
<div class="bg-gray-25 rounded-xl p-1 w-full">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<h4><%= date.strftime("%b %d, %Y") %> · <%= transactions.size %></h4>
|
||||
<span><%= format_money -transactions.sum { |t| t.amount } %></span>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= render partial: "imports/transactions/transaction", collection: transactions %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="h-full">
|
||||
<html class="h-full" lang="en">
|
||||
<head>
|
||||
<title><%= content_for(:title) || "Maybe" %></title>
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
<%= hotwire_livereload_tags if Rails.env.development? %>
|
||||
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
||||
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Maybe">
|
||||
@@ -30,10 +31,13 @@
|
||||
<%= content_for?(:content) ? yield(:content) : yield %>
|
||||
|
||||
<%= turbo_frame_tag "modal" %>
|
||||
<%= turbo_frame_tag "drawer" %>
|
||||
|
||||
<%= render "shared/confirm_modal" %>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<%= render "shared/app_version" %>
|
||||
<% end %>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -161,9 +161,12 @@
|
||||
<p><%= t(".no_transactions") %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-gray-500 flex items-center justify-center flex-col bg-gray-25 rounded-md">
|
||||
<%= render partial: "transactions/transaction_group", collection: @transactions.group_by(&:date), as: :transaction_group %>
|
||||
<p class="py-2 text-sm"><%= link_to t(".view_all"), transactions_path %></p>
|
||||
<div class="text-gray-500 p-1 space-y-1 bg-gray-25 rounded-xl">
|
||||
<% @transactions.group_by(&:date).each do |date, transactions| %>
|
||||
<%= transactions_group(date, transactions, "pages/dashboard/transactions/transaction") %>
|
||||
<% end %>
|
||||
|
||||
<p class="py-2 text-sm text-center"><%= link_to t(".view_all"), transactions_path %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="text-gray-900 flex items-center py-4 text-sm font-medium px-4">
|
||||
<div class="grow">
|
||||
<%= render "transactions/name", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<%= render "transactions/amount", transaction: transaction %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,6 +46,9 @@
|
||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<%= sidebar_link_to t(".tags_label"), tags_path, icon: "tags" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= sidebar_link_to t(".categories_label"), transaction_categories_path, icon: "tags" %>
|
||||
</li>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="space-y-3">
|
||||
<p><%= t(".profile_image_type") %></p>
|
||||
<%= form.label :profile_image, t(".profile_image_choose"), class: "inline-block cursor-pointer px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium" %>
|
||||
<%= form.file_field :profile_image, accept: "wimage/png, image/jpeg, image/gif", class: "hidden px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium", data: {profile_image_preview_target: "fileField", action: "change->profile-image-preview#preview"} %>
|
||||
<%= form.file_field :profile_image, accept: "image/png, image/jpeg, image/gif", class: "hidden px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium", data: {profile_image_preview_target: "fileField", action: "change->profile-image-preview#preview"} %>
|
||||
<%= form.hidden_field :delete_profile_image, value: false, data: {profile_image_preview_target: "deleteField"} %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
11
app/views/shared/_alert.html.erb
Normal file
11
app/views/shared/_alert.html.erb
Normal file
@@ -0,0 +1,11 @@
|
||||
<%# locals: (type: "error", content: "") -%>
|
||||
<%= content_tag :div,
|
||||
class: "flex justify-between rounded-xl p-3 #{type == "error" ? "bg-red-50" : "bg-yellow-50"}",
|
||||
data: {controller: "element-removal" },
|
||||
role: type == "error" ? "alert" : "status" do %>
|
||||
<div class="flex gap-3 items-center <%= type == "error" ? "text-red-500" : "text-yellow-500" %>">
|
||||
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
||||
<p class="text-sm"><%= content %></p>
|
||||
</div>
|
||||
<%= content_tag :a, lucide_icon("x", class: "w-5 h-5 shrink-0 #{type == "error" ? "text-red-500" : "text-yellow-500"}"), data: { action: "click->element-removal#remove" }, class:"flex gap-1 font-medium items-center text-gray-900 px-3 py-1.5 rounded-lg cursor-pointer" %>
|
||||
<% end %>
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= turbo_frame_tag "drawer" do %>
|
||||
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4" data-controller="modal" data-action="click->modal#clickOutside">
|
||||
<div class="flex flex-col h-full p-4">
|
||||
<div class="flex justify-end items-center h-9">
|
||||
9
app/views/shared/_list_group.html.erb
Normal file
9
app/views/shared/_list_group.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<%# locals: (header:, content:) %>
|
||||
<div class="bg-gray-25 rounded-xl p-1 w-full">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<%= header %>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= content %>
|
||||
</div>
|
||||
</div>
|
||||
10
app/views/tags/_badge.html.erb
Normal file
10
app/views/tags/_badge.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<%# locals: (tag:) %>
|
||||
<% tag ||= null_category %>
|
||||
|
||||
<span class="border text-sm font-medium px-2.5 py-1 rounded-full content-center"
|
||||
style="
|
||||
background-color: color-mix(in srgb, <%= tag.color %> 5%, white);
|
||||
border-color: color-mix(in srgb, <%= tag.color %> 10%, white);
|
||||
color: <%= tag.color %>;">
|
||||
<%= tag.name %>
|
||||
</span>
|
||||
38
app/views/tags/_form.html.erb
Normal file
38
app/views/tags/_form.html.erb
Normal file
@@ -0,0 +1,38 @@
|
||||
<%= form_with model: tag, data: { turbo: false } do |form| %>
|
||||
<div class="flex flex-col space-y-4 w-96" data-controller="color-select" data-color-select-selection-value="<%= tag.color %>">
|
||||
<fieldset class="relative">
|
||||
<span data-color-select-target="decoration" class="pointer-events-none absolute inset-y-3.5 left-3 flex items-center pl-1 block w-1 rounded-lg"></span>
|
||||
<%= form.text_field :name,
|
||||
value: tag.name,
|
||||
autofocus: "",
|
||||
required: true,
|
||||
placeholder: "Enter tag name",
|
||||
class: "rounded-lg w-full focus:ring-black focus:border-transparent placeholder:text-gray-500 pl-6" %>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<%= form.hidden_field :color, data: { color_select_target: "input" } %>
|
||||
|
||||
<ul role="radiogroup" class="flex justify-between items-center py-2">
|
||||
<% Tag::COLORS.each do |color| %>
|
||||
<li tabindex="0"
|
||||
role="radio"
|
||||
data-action="click->color-select#select keydown.enter->color-select#select keydown.space->color-select#select"
|
||||
data-value="<%= color %>"
|
||||
class="flex shrink-0 justify-center items-center w-5 h-5 cursor-pointer hover:bg-gray-200 rounded-full">
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<section>
|
||||
<%= hidden_field_tag :tag_id, params[:tag_id] %>
|
||||
|
||||
<% if tag.persisted? %>
|
||||
<%= form.submit t(".update") %>
|
||||
<% else %>
|
||||
<%= form.submit t(".create") %>
|
||||
<% end %>
|
||||
</section>
|
||||
</div>
|
||||
<% end %>
|
||||
23
app/views/tags/_tag.html.erb
Normal file
23
app/views/tags/_tag.html.erb
Normal file
@@ -0,0 +1,23 @@
|
||||
<div id="<%= dom_id(tag) %>" class="flex justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
|
||||
<%= render "badge", tag: tag %>
|
||||
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= link_to edit_tag_path(tag),
|
||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
|
||||
|
||||
<span><%= t(".edit") %></span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_tag_deletion_path(tag),
|
||||
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
|
||||
|
||||
<span><%= t(".delete") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
33
app/views/tags/deletions/new.html.erb
Normal file
33
app/views/tags/deletions/new.html.erb
Normal file
@@ -0,0 +1,33 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto p-4 w-screen max-w-md">
|
||||
<div class="space-y-2">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".delete_tag") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<p class="text-gray-500 font-light">
|
||||
<%= t(".explanation", tag_name: @tag.name) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: tag_deletions_path(@tag),
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %>
|
||||
<%= f.collection_select :replacement_tag_id,
|
||||
Current.family.tags.alphabetically.without(@tag),
|
||||
:id, :name,
|
||||
{ prompt: t(".replacement_tag_prompt"), label: t(".tag") },
|
||||
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||
|
||||
<%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
data: { deletion_target: "submitButton" } %>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
10
app/views/tags/edit.html.erb
Normal file
10
app/views/tags/edit.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".edit") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<%= render "form", tag: @tag %>
|
||||
</article>
|
||||
<% end %>
|
||||
49
app/views/tags/index.html.erb
Normal file
49
app/views/tags/index.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<% content_for :sidebar do %>
|
||||
<%= render "settings/nav" %>
|
||||
<% end %>
|
||||
|
||||
<section class="space-y-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-gray-900 text-xl font-medium"><%= t(".tags") %></h1>
|
||||
|
||||
<%= link_to new_tag_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
<p><%= t(".new") %></p>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||
|
||||
<% if @tags.any? %>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".tags") %> · <%= @tags.size %></h2>
|
||||
|
||||
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
||||
|
||||
<%= render @tags %>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<div class="text-center flex flex-col items-center max-w-[300px]">
|
||||
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
||||
<%= link_to new_tag_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span><%= t(".new") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-between gap-4">
|
||||
<%= previous_setting("Accounts", accounts_path) %>
|
||||
<%= next_setting("Categories", transaction_categories_path) %>
|
||||
</footer>
|
||||
</section>
|
||||
10
app/views/tags/new.html.erb
Normal file
10
app/views/tags/new.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl"><%= t(".new") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<%= render "form", tag: @tag %>
|
||||
</article>
|
||||
<% end %>
|
||||
3
app/views/transactions/_amount.html.erb
Normal file
3
app/views/transactions/_amount.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= content_tag :p,
|
||||
format_money(-transaction.amount_money),
|
||||
class: ["text-green-600": transaction.inflow?] %>
|
||||
4
app/views/transactions/_empty.html.erb
Normal file
4
app/views/transactions/_empty.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="flex flex-col items-center justify-center py-40">
|
||||
<p class="text-gray-500 mb-2"><%= t(".title") %></p>
|
||||
<p class="text-gray-400 max-w-xs text-center"><%= t(".description") %></p>
|
||||
</div>
|
||||
@@ -1,48 +0,0 @@
|
||||
<%# locals: (filter:) %>
|
||||
<div class="flex items-center gap-1 text-sm border border-alpha-black-200 rounded-3xl p-1.5">
|
||||
<% case filter[:type] %>
|
||||
<% when "account" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 bg-blue-600/10 text-xs flex items-center justify-center rounded-full"><%= filter[:value].name[0].upcase %></div>
|
||||
<p><%= filter[:value].name %></p>
|
||||
</div>
|
||||
<% when "category" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-4 text-xs flex items-center justify-center rounded-full" style="background-color: <%= filter[:value].color %>"></div>
|
||||
<p><%= filter[:value].name %></p>
|
||||
</div>
|
||||
<% when "merchant" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-4 text-xs flex items-center justify-center rounded-full" style="background-color: <%= filter[:value].color %>"></div>
|
||||
<p><%= filter[:value].name %></p>
|
||||
</div>
|
||||
<% when "search" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= lucide_icon "text", class: "w-5 h-5 text-gray-500" %>
|
||||
<p><%= "\"#{filter[:value]}\"".truncate(20) %></p>
|
||||
</div>
|
||||
<% when "date_range" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= lucide_icon "calendar", class: "w-5 h-5 text-gray-500" %>
|
||||
<p>
|
||||
<% if filter[:value][:gteq] && filter[:value][:lteq] %>
|
||||
<%= filter[:value][:gteq] %> → <%= filter[:value][:lteq] %>
|
||||
<% elsif filter[:value][:gteq] %>
|
||||
on or after <%= filter[:value][:gteq] %>
|
||||
<% elsif filter[:value][:lteq] %>
|
||||
on or before <%= filter[:value][:lteq] %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= form_with url: search_transactions_path, html: { class: "flex items-center" } do |form| %>
|
||||
<%= form.hidden_field :remove_param, value: filter[:original][:key] %>
|
||||
<% if filter[:original][:value] %>
|
||||
<%= form.hidden_field :remove_param_value, value: filter[:original][:value] %>
|
||||
<% else %>
|
||||
<% end %>
|
||||
<%= form.button type: "submit", class: "hover:text-gray-900" do %>
|
||||
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,10 +0,0 @@
|
||||
<%# locals: (filters:) %>
|
||||
<div>
|
||||
<%= turbo_frame_tag "transactions_filters" do %>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<% filters.each do |filter| %>
|
||||
<%= render partial: "transactions/filter", locals: { filter: filter } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
33
app/views/transactions/_header.html.erb
Normal file
33
app/views/transactions/_header.html.erb
Normal file
@@ -0,0 +1,33 @@
|
||||
<header class="flex justify-between items-center text-gray-900 font-medium">
|
||||
<h1 class="text-xl">Transactions</h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= link_to transaction_categories_path,
|
||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
|
||||
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
|
||||
<span class="text-black"><%= t(".edit_categories") %></span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to imports_path,
|
||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
|
||||
<%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %>
|
||||
<span class="text-black"><%= t(".edit_imports") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %>
|
||||
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<p class="text-sm font-medium">New transaction</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1,32 +0,0 @@
|
||||
<%# locals: (transactions:, pagy:) %>
|
||||
<div>
|
||||
<%= turbo_frame_tag "transactions_list" do %>
|
||||
<% if transactions.empty? %>
|
||||
<div class="flex flex-col items-center justify-center py-40">
|
||||
<p class="text-gray-500 mb-2">No transactions found</p>
|
||||
<p class="text-gray-400 max-w-xs text-center">Try adding a transaction, editing filters or refining your search</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-gray-25 rounded-xl px-5 py-3 text-xs font-medium text-gray-500 flex items-center gap-6 mb-4">
|
||||
<div class="w-96">
|
||||
<p class="uppercase">transaction</p>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<p class="uppercase">category</p>
|
||||
</div>
|
||||
<div class="grow uppercase flex justify-between items-center gap-5 text-xs font-medium text-gray-500">
|
||||
<p>account</p>
|
||||
<p>amount</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<%= render partial: "transactions/transaction_group", collection: transactions.group_by(&:date), as: :transaction_group %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if pagy.pages > 1 %>
|
||||
<nav class="flex items-center justify-center px-4 mt-4 sm:px-0">
|
||||
<%= render partial: "transactions/pagination", locals: { pagy: pagy } %>
|
||||
</nav>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
16
app/views/transactions/_name.html.erb
Normal file
16
app/views/transactions/_name.html.erb
Normal file
@@ -0,0 +1,16 @@
|
||||
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
||||
<div class="w-8 h-8 flex items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
||||
<%= transaction.name[0].upcase %>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-900 truncate">
|
||||
<% if transaction.new_record? %>
|
||||
<%= content_tag :p, transaction.name %>
|
||||
<% else %>
|
||||
<%= link_to transaction.name,
|
||||
transaction_path(transaction),
|
||||
data: { turbo_frame: "drawer" },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,24 +1,7 @@
|
||||
<!-- start mobile pagination -->
|
||||
<div class="flex flex-1 justify-center md:hidden">
|
||||
<% if pagy.prev %>
|
||||
<%= link_to "Previous", pagy_url_for(pagy, pagy.prev), class: "relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50" %>
|
||||
<% else %>
|
||||
<div class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-200">Previous</div>
|
||||
<% end %>
|
||||
|
||||
<% if pagy.next %>
|
||||
<%= link_to "Next", pagy_url_for(pagy, pagy.next), class: "relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50" %>
|
||||
<% else %>
|
||||
<div class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-200">Next</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<!-- end mobile pagination -->
|
||||
|
||||
<!-- start desktop pagination -->
|
||||
<div class="hidden md:-mt-px md:flex">
|
||||
<nav class="flex items-center justify-center px-4 mt-4 sm:px-0">
|
||||
<div>
|
||||
<% if pagy.prev %>
|
||||
<%= link_to pagy_url_for(pagy, pagy.prev), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= link_to pagy_url_for(pagy, pagy.prev), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= lucide_icon("chevron-left", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
@@ -30,7 +13,7 @@
|
||||
<div class="bg-gray-25 rounded-xl">
|
||||
<% pagy.series.each do |series_item| %>
|
||||
<% if series_item.is_a?(Integer) %>
|
||||
<%= link_to pagy_url_for(pagy, series_item), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= link_to pagy_url_for(pagy, series_item), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= series_item %>
|
||||
<% end %>
|
||||
<% elsif series_item.is_a?(String) %>
|
||||
@@ -42,16 +25,15 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<% if pagy.next %>
|
||||
<%= link_to pagy_url_for(pagy, pagy.next), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="inline-flex items-center px-3 py-3 text-sm font-medium hover:border-gray-300">
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-200") %>
|
||||
</div>
|
||||
<div>
|
||||
<% if pagy.next %>
|
||||
<%= link_to pagy_url_for(pagy, pagy.next), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<!-- end desktop pagination -->
|
||||
<% else %>
|
||||
<div class="inline-flex items-center px-3 py-3 text-sm font-medium hover:border-gray-300">
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-200") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<%# locals: (q:) %>
|
||||
<div>
|
||||
<%= turbo_frame_tag "transactions_search_form" do %>
|
||||
<%= search_form_for @q, url: search_transactions_path, html: { method: :post, data: { turbo_frame: "transactions_list" } } do |form| %>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="grow">
|
||||
<%= render partial: "transactions/search_form/search_filter", locals: { form: form } %>
|
||||
</div>
|
||||
<div data-controller="menu" class="relative">
|
||||
<button data-menu-target="button" type="button" class="border border-gray-200 block h-full rounded-lg flex items-center gap-2 px-4">
|
||||
<%= lucide_icon("list-filter", class: "w-5 h-5 text-gray-500") %>
|
||||
<p class="text-sm font-medium text-gray-900">Filter</p>
|
||||
</button>
|
||||
<div
|
||||
data-menu-target="content"
|
||||
data-controller="tabs"
|
||||
data-tabs-active-class="bg-gray-25 text-gray-900"
|
||||
data-tabs-default-tab-value="<%= transaction_filter_id(transaction_filter_by_name("Account")) %>"
|
||||
class="hidden absolute flex z-10 h-80 w-[540px] top-12 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs">
|
||||
<div class="flex w-44 flex-col items-start p-3 text-sm font-medium text-gray-500 border-r border-r-alpha-black-25">
|
||||
<% transaction_filters.each do |filter| %>
|
||||
<button
|
||||
class="flex text-gray-500 hover:bg-gray-25 items-center gap-2 px-3 rounded-md py-2 w-full"
|
||||
type="button"
|
||||
data-id="<%= transaction_filter_id(filter) %>"
|
||||
data-tabs-target="btn"
|
||||
data-action="tabs#select">
|
||||
<%= lucide_icon(filter[:icon], class: "w-5 h-5") %>
|
||||
<span class="text-sm font-medium"><%= filter[:name] %></span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex flex-col grow">
|
||||
<div class="grow p-2 border-b border-b-alpha-black-25 overflow-y-auto">
|
||||
<% transaction_filters.each do |filter| %>
|
||||
<div id="<%= transaction_filter_id(filter) %>" data-tabs-target="tab">
|
||||
<%= render partial: "transactions/search_form/#{filter[:partial]}", locals: { form: form } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex justify-end items-center gap-2 bg-white p-3">
|
||||
<%= button_tag type: "reset", data: { action: "menu#close" }, class: "py-2 px-3 bg-gray-50 rounded-lg text-sm text-gray-900 font-medium" do %>
|
||||
Cancel
|
||||
<% end %>
|
||||
<%= button_tag type: "submit", class: "py-2 px-3 bg-gray-900 rounded-lg text-sm text-white font-medium" do %>
|
||||
Apply
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,21 +1,19 @@
|
||||
<%# locals: (totals:) %>
|
||||
<%= turbo_frame_tag "transactions_summary" do %>
|
||||
<div class="grid grid-cols-3 bg-white rounded-xl border border-alpha-black-25 shadow-xs px-4 divide-x divide-alpha-black-100">
|
||||
<div class="p-4 space-y-2">
|
||||
<p class="text-sm text-gray-500">Total transactions</p>
|
||||
<p class="text-gray-900 font-medium text-xl"><%= totals[:count] %></p>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
<p class="text-sm text-gray-500">Income</p>
|
||||
<p class="text-gray-900 font-medium text-xl">
|
||||
<%= format_money totals[:income] %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
<p class="text-sm text-gray-500">Expenses</p>
|
||||
<p class="text-gray-900 font-medium text-xl">
|
||||
<%= format_money totals[:expense] %>
|
||||
</p>
|
||||
</div>
|
||||
<%# locals: (totals:) %>
|
||||
<div class="grid grid-cols-3 bg-white rounded-xl border border-alpha-black-25 shadow-xs px-4 divide-x divide-alpha-black-100">
|
||||
<div class="p-4 space-y-2">
|
||||
<p class="text-sm text-gray-500">Total transactions</p>
|
||||
<p class="text-gray-900 font-medium text-xl" id="total-transactions"><%= totals[:count] %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="p-4 space-y-2">
|
||||
<p class="text-sm text-gray-500">Income</p>
|
||||
<p class="text-gray-900 font-medium text-xl" id="total-income">
|
||||
<%= format_money totals[:income] %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
<p class="text-sm text-gray-500">Expenses</p>
|
||||
<p class="text-gray-900 font-medium text-xl" id="total-expense">
|
||||
<%= format_money totals[:expense] %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<%# locals: (transaction:) %>
|
||||
<%= turbo_frame_tag dom_id(transaction), class:"text-gray-900 flex items-center gap-6 py-4 text-sm font-medium px-4" do %>
|
||||
<% if full_width_transaction_row?(request.path) %>
|
||||
<%= link_to transaction_path(transaction), data: { turbo_frame: "modal" }, class: "group" do %>
|
||||
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
|
||||
<% end %>
|
||||
<div class="w-48">
|
||||
<%= render partial: "transactions/categories/menu", locals: { transaction: } %>
|
||||
</div>
|
||||
<div>
|
||||
<p><%= transaction.account.name %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render partial: "transactions/transaction_name", locals: { name: transaction.name } %>
|
||||
<div class="w-36">
|
||||
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="ml-auto">
|
||||
<%= content_tag :p, format_money(-transaction.amount_money), class: ["whitespace-nowrap", { "text-green-600": transaction.amount.negative? }] %>
|
||||
<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 py-4 text-sm font-medium px-4" do %>
|
||||
<div class="col-span-4">
|
||||
<%= render "transactions/name", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3">
|
||||
<%= render "transactions/categories/menu", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<%= link_to transaction.account.name,
|
||||
account_path(transaction.account),
|
||||
class: ["col-span-3 hover:underline"] %>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<%= render "transactions/amount", transaction: transaction %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<%# locals: (transaction_group:) %>
|
||||
<% date = transaction_group[0] %>
|
||||
<% transactions = transaction_group[1] %>
|
||||
|
||||
<div class="bg-gray-25 rounded-xl p-1 w-full">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<h4><%= date.strftime("%b %d, %Y") %> · <%= transactions.size %></h4>
|
||||
<span><%= format_money -transactions.sum(&:amount_money) %></span>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= render partial: "transactions/transaction", collection: transactions %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,10 +0,0 @@
|
||||
<%# locals: (name:) %>
|
||||
|
||||
<%= content_tag :div, class: ["flex items-center gap-2", { "w-40": !full_width_transaction_row?(request.path), "w-96": full_width_transaction_row?(request.path) }] do %>
|
||||
<div class="w-8 h-8 flex items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
||||
<%= name[0].upcase %>
|
||||
</div>
|
||||
<p class="text-gray-900 group-hover:underline group-hover:text-gray-800 truncate">
|
||||
<%= name %>
|
||||
</p>
|
||||
<% end %>
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# locals: (category:) %>
|
||||
<% category ||= null_category %>
|
||||
|
||||
<span class="border text-sm font-medium px-2.5 py-1 rounded-full cursor-pointer content-center"
|
||||
<span class="border text-sm font-medium px-2.5 py-1 rounded-full content-center"
|
||||
style="
|
||||
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
||||
border-color: color-mix(in srgb, <%= category.color %> 10%, white);
|
||||
|
||||
@@ -1,48 +1,15 @@
|
||||
<%# locals: (transaction:) %>
|
||||
<div class="relative" data-controller="menu">
|
||||
<button data-menu-target="button cursor-pointer" class="flex">
|
||||
<button data-menu-target="button" class="flex cursor-pointer">
|
||||
<%= render partial: "transactions/categories/badge", locals: { category: transaction.category } %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
||||
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<div class="flex flex-col relative" data-controller="list-filter">
|
||||
<div class="grow p-1.5">
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<input placeholder="Search" autocomplete="nope" type="search" class="placeholder:text-sm placeholder:text-gray-500 font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter">
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
<%= turbo_frame_tag "category_dropdown", src: transaction_category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
|
||||
<div class="p-6 flex items-center justify-center">
|
||||
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
|
||||
</div>
|
||||
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
||||
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
||||
No categories found
|
||||
</div>
|
||||
<% sorted_categories = Current.family.transaction_categories.sort_by { |category| category.id == transaction.category_id ? 0 : 1 } %>
|
||||
<% sorted_categories.each do |category| %>
|
||||
<%= render partial: "transactions/categories/dropdown/row", locals: { category:, transaction: } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="relative p-1.5 w-full">
|
||||
<%= link_to new_transaction_category_path(transaction_id: transaction),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".add_new") %>
|
||||
<% end %>
|
||||
|
||||
<% if transaction.category %>
|
||||
<%= button_to transaction_path(transaction),
|
||||
method: :patch,
|
||||
params: { transaction: { category_id: nil } },
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" do %>
|
||||
<%= lucide_icon "minus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".clear") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,20 +14,20 @@
|
||||
<%= form_with url: transaction_category_deletions_path(@category),
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "category-deletion",
|
||||
category_deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
category_deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
category_deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
category_deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||
<%= f.collection_select :replacement_category_id,
|
||||
Current.family.transaction_categories.alphabetically.without(@category),
|
||||
:id, :name,
|
||||
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
|
||||
{ data: { category_deletion_target: "replacementCategoryField", action: "category-deletion#updateSubmitButton" } } %>
|
||||
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||
|
||||
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
data: { category_deletion_target: "submitButton" } %>
|
||||
data: { deletion_target: "submitButton" } %>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<%# locals: (category:, transaction:) %>
|
||||
<% is_selected = transaction.category_id == category.id %>
|
||||
<%# locals: (category:) %>
|
||||
<% is_selected = category.id === @selected_category&.id %>
|
||||
|
||||
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
|
||||
<%= button_to transaction_path(transaction, transaction: { category_id: category.id }), method: :patch, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
<%= button_to transaction_row_path(@transaction, transaction: { category_id: category.id }), method: :patch, data: { turbo_frame: dom_id(@transaction) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
<span class="w-5 h-5">
|
||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||
</span>
|
||||
40
app/views/transactions/categories/dropdowns/show.html.erb
Normal file
40
app/views/transactions/categories/dropdowns/show.html.erb
Normal file
@@ -0,0 +1,40 @@
|
||||
<%= turbo_frame_tag "category_dropdown" do %>
|
||||
<div class="flex flex-col relative" data-controller="list-filter">
|
||||
<div class="grow p-1.5">
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<input placeholder="<%= t(".search_placeholder") %>" autocomplete="nope" type="search" class="placeholder:text-sm placeholder:text-gray-500 font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter">
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
</div>
|
||||
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
||||
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
||||
<%= t(".no_categories") %>
|
||||
</div>
|
||||
<% @categories.each do |category| %>
|
||||
<%= render partial: "transactions/categories/dropdowns/row", locals: { category: } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="relative p-1.5 w-full">
|
||||
<%= link_to new_transaction_category_path(transaction_id: @transaction),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".add_new") %>
|
||||
<% end %>
|
||||
|
||||
<% if @transaction.category %>
|
||||
<%= button_to transaction_row_path(@transaction),
|
||||
method: :patch,
|
||||
data: { turbo_frame: dom_id(@transaction) },
|
||||
params: { transaction: { category_id: nil } },
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" do %>
|
||||
<%= lucide_icon "minus", class: "w-5 h-5" %>
|
||||
|
||||
<%= t(".clear") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-between gap-4">
|
||||
<%= previous_setting("Accounts", accounts_path) %>
|
||||
<%= previous_setting("Tags", tags_path) %>
|
||||
<%= next_setting("Merchants", transaction_merchants_path) %>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<div class="mx-auto md:w-2/3 w-full">
|
||||
<h1 class="font-bold text-4xl">Editing transaction</h1>
|
||||
|
||||
<%= render "form", transaction: @transaction %>
|
||||
|
||||
<%= link_to "Show this transaction", @transaction, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
||||
<%= link_to "Back to transactions", transactions_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
||||
</div>
|
||||
@@ -1,43 +1,32 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center text-gray-900 font-medium">
|
||||
<h1 class="text-xl">Transactions</h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= link_to transaction_categories_path,
|
||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
|
||||
<%= lucide_icon "tags", class: "w-5 h-5 text-gray-500" %>
|
||||
<span class="text-black"><%= t(".edit_categories") %></span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to imports_path,
|
||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal" do %>
|
||||
<%= lucide_icon "hard-drive-upload", class: "w-5 h-5 text-gray-500" %>
|
||||
<span class="text-black"><%= t(".edit_imports") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render "header" %>
|
||||
|
||||
<% end %>
|
||||
<%= render partial: "transactions/summary", locals: { totals: @totals } %>
|
||||
|
||||
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-50 border border-gray-200 flex items-center gap-1 justify-center px-3 py-2", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("download", class: "text-gray-500 w-4 h-4") %>
|
||||
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
|
||||
<% end %>
|
||||
<div id="transactions" class="bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4 space-y-4">
|
||||
|
||||
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<p class="text-sm font-medium">New transaction</p>
|
||||
<%= render partial: "transactions/searches/search", locals: { transactions: @transactions } %>
|
||||
|
||||
<% if @transactions.present? %>
|
||||
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
|
||||
<p class="col-span-4">transaction</p>
|
||||
<p class="col-span-3 pl-4">category</p>
|
||||
<p class="col-span-3">account</p>
|
||||
<p class="col-span-2 justify-self-end">amount</p>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<% @transactions.group_by(&:date).each do |date, transactions| %>
|
||||
<%= transactions_group(date, transactions) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= render partial: "transactions/summary", locals: { totals: @totals } %>
|
||||
</div>
|
||||
<div id="transactions" class="bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4 space-y-4">
|
||||
<%= render partial: "transactions/search_form", locals: { q: @q } %>
|
||||
<%= render partial: "transactions/filters", locals: { filters: @filter_list } %>
|
||||
<%= render partial: "transactions/list", locals: { transactions: @transactions, pagy: @pagy } %>
|
||||
<% else %>
|
||||
<%= render "empty" %>
|
||||
<% end %>
|
||||
|
||||
<% if @pagy.pages > 1 %>
|
||||
<%= render "pagination", pagy: @pagy %>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
app/views/transactions/rows/show.html.erb
Normal file
1
app/views/transactions/rows/show.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render "transactions/transaction", transaction: @transaction %>
|
||||
@@ -1,5 +0,0 @@
|
||||
<%# locals: (form:) %>
|
||||
<div class="p-3">
|
||||
<%= form.date_field :date_gteq, placeholder: "Start date", class: "block w-full border border-gray-200 rounded-md py-2 pl-3 pr-3 focus:border-gray-500 focus:ring-gray-500 sm:text-sm" %>
|
||||
<%= form.date_field :date_lteq, placeholder: "End date", class: "block w-full border border-gray-200 rounded-md py-2 pl-3 pr-3 focus:border-gray-500 focus:ring-gray-500 sm:text-sm mt-2" %>
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
<%# locals: (form:) %>
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<%= form.search_field :category_name_or_merchant_name_or_account_name_or_name_cont,
|
||||
placeholder: "Search transaction by name, merchant, category or amount",
|
||||
class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
27
app/views/transactions/searches/_form.html.erb
Normal file
27
app/views/transactions/searches/_form.html.erb
Normal file
@@ -0,0 +1,27 @@
|
||||
<%# locals: (transactions:) %>
|
||||
<%= form_with url: transactions_path,
|
||||
id: "transactions-search",
|
||||
scope: :q,
|
||||
method: :get,
|
||||
data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="grow">
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<%= form.text_field :search,
|
||||
placeholder: "Search transactions by name",
|
||||
value: @q[:search],
|
||||
class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
</div>
|
||||
<div data-controller="menu" class="relative">
|
||||
<button id="transaction-filters-button" data-menu-target="button" type="button" class="border border-gray-200 block h-full rounded-lg flex items-center gap-2 px-4">
|
||||
<%= lucide_icon("list-filter", class: "w-5 h-5 text-gray-500") %>
|
||||
<p class="text-sm font-medium text-gray-900">Filter</p>
|
||||
</button>
|
||||
|
||||
<%= render "transactions/searches/menu", form: form %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
37
app/views/transactions/searches/_menu.html.erb
Normal file
37
app/views/transactions/searches/_menu.html.erb
Normal file
@@ -0,0 +1,37 @@
|
||||
<div
|
||||
id="transaction-filters-menu"
|
||||
data-menu-target="content"
|
||||
data-controller="tabs"
|
||||
data-tabs-active-class="bg-gray-25 text-gray-900"
|
||||
data-tabs-default-tab-value="<%= get_default_transaction_search_filter[:key] %>"
|
||||
class="hidden absolute flex z-10 h-80 w-[540px] top-12 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs">
|
||||
<div class="flex w-44 flex-col items-start p-3 text-sm font-medium text-gray-500 border-r border-r-alpha-black-25">
|
||||
<% transaction_search_filters.each do |filter| %>
|
||||
<button
|
||||
class="flex text-gray-500 hover:bg-gray-25 items-center gap-2 px-3 rounded-md py-2 w-full"
|
||||
type="button"
|
||||
data-id="<%= filter[:key] %>"
|
||||
data-tabs-target="btn"
|
||||
data-action="tabs#select">
|
||||
<%= lucide_icon(filter[:icon], class: "w-5 h-5") %>
|
||||
<span class="text-sm font-medium"><%= filter[:name] %></span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col grow">
|
||||
<div class="grow p-2 border-b border-b-alpha-black-25 overflow-y-auto">
|
||||
<% transaction_search_filters.each do |filter| %>
|
||||
<div id="<%= filter[:key] %>" data-tabs-target="tab">
|
||||
<%= render partial: get_transaction_search_filter_partial_path(filter), locals: { form: form } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex justify-end items-center gap-2 bg-white p-3">
|
||||
<%= button_tag type: "reset", data: { action: "menu#close" }, class: "py-2 px-3 bg-gray-50 rounded-lg text-sm text-gray-900 font-medium" do %>
|
||||
Cancel
|
||||
<% end %>
|
||||
<%= form.submit "Apply", name: nil, class: "py-2 px-3 bg-gray-900 rounded-lg text-sm text-white font-medium" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
11
app/views/transactions/searches/_search.html.erb
Normal file
11
app/views/transactions/searches/_search.html.erb
Normal file
@@ -0,0 +1,11 @@
|
||||
<%= render partial: "transactions/searches/form", locals: { transactions: transactions } %>
|
||||
|
||||
<ul id="transaction-search-filters" class="flex items-center flex-wrap gap-2">
|
||||
<% @q.each do |param_key, param_value| %>
|
||||
<% unless param_value.blank? %>
|
||||
<% Array(param_value).each do |value| %>
|
||||
<%= render partial: "transactions/searches/filters/badge", locals: { param_key: param_key, param_value: value } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</ul>
|
||||
@@ -5,10 +5,17 @@
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
|
||||
</div>
|
||||
<div class="my-2" id="list" data-list-filter-target="list">
|
||||
<% Current.family.accounts.each do |account| %>
|
||||
<% Current.family.accounts.alphabetically.each do |account| %>
|
||||
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= account.name %>">
|
||||
<%= form.check_box :account_id_in, { multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, account.id, nil %>
|
||||
<%= form.label :account_id_in, account.name, value: account.id, class: "text-sm text-gray-900" %>
|
||||
<%= form.check_box :accounts,
|
||||
{
|
||||
multiple: true,
|
||||
checked: @q[:accounts]&.include?(account.name),
|
||||
class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
},
|
||||
account.name,
|
||||
nil %>
|
||||
<%= form.label :accounts, account.name, value: account.name, class: "text-sm text-gray-900" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
34
app/views/transactions/searches/filters/_badge.html.erb
Normal file
34
app/views/transactions/searches/filters/_badge.html.erb
Normal file
@@ -0,0 +1,34 @@
|
||||
<%# locals: (param_key:, param_value:) %>
|
||||
<li class="flex items-center gap-1 text-sm border border-alpha-black-200 rounded-3xl p-1.5">
|
||||
|
||||
<% if param_key == "start_date" || param_key == "end_date" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= lucide_icon "calendar", class: "w-5 h-5 text-gray-500" %>
|
||||
<p>
|
||||
<% if param_key == "start_date" %>
|
||||
on or after <%= param_value %>
|
||||
<% else %>
|
||||
on or before <%= param_value %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<% elsif param_key == "search" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= lucide_icon "text", class: "w-5 h-5 text-gray-500" %>
|
||||
<p><%= "\"#{param_value}\"".truncate(20) %></p>
|
||||
</div>
|
||||
<% elsif param_key == "accounts" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 bg-blue-600/10 text-xs flex items-center justify-center rounded-full"><%= param_value[0].upcase %></div>
|
||||
<p><%= param_value %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-2">
|
||||
<p><%= param_value %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= link_to transactions_path_without_param(param_key, param_value), data: { id: "clear-param-btn", turbo: false }, class: "flex items-center" do %>
|
||||
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
|
||||
<% end %>
|
||||
</li>
|
||||
@@ -5,10 +5,17 @@
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
|
||||
</div>
|
||||
<div class="my-2" id="list" data-list-filter-target="list">
|
||||
<% Current.family.transaction_categories.each do |transaction_category| %>
|
||||
<% Current.family.transaction_categories.alphabetically.each do |transaction_category| %>
|
||||
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= transaction_category.name %>">
|
||||
<%= form.check_box :category_id_in, { "data-auto-submit-form-target": "auto", multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, transaction_category.id, nil %>
|
||||
<%= form.label :category_id_in, transaction_category.name, value: transaction_category.id, class: "text-sm text-gray-900 cursor-pointer" do %>
|
||||
<%= form.check_box :categories,
|
||||
{
|
||||
multiple: true,
|
||||
checked: @q[:categories]&.include?(transaction_category.name),
|
||||
class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
},
|
||||
transaction_category.name,
|
||||
nil %>
|
||||
<%= form.label :categories, transaction_category.name, value: transaction_category.name, class: "text-sm text-gray-900 cursor-pointer" do %>
|
||||
<%= render partial: "transactions/categories/badge", locals: { category: transaction_category } %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<%# locals: (form:) %>
|
||||
<div class="p-3">
|
||||
<%= form.date_field :start_date,
|
||||
placeholder: "Start date",
|
||||
value: @q[:start_date],
|
||||
class: "block w-full border border-gray-200 rounded-md py-2 pl-3 pr-3 focus:border-gray-500 focus:ring-gray-500 sm:text-sm" %>
|
||||
<%= form.date_field :end_date,
|
||||
placeholder: "End date",
|
||||
value: @q[:end_date],
|
||||
class: "block w-full border border-gray-200 rounded-md py-2 pl-3 pr-3 focus:border-gray-500 focus:ring-gray-500 sm:text-sm mt-2" %>
|
||||
</div>
|
||||
@@ -5,10 +5,17 @@
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
|
||||
</div>
|
||||
<div class="my-2" id="list" data-list-filter-target="list">
|
||||
<% Current.family.transaction_merchants.each do |merchant| %>
|
||||
<% Current.family.transaction_merchants.alphabetically.each do |merchant| %>
|
||||
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= merchant.name %>">
|
||||
<%= form.check_box :merchant_id_in, { multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, merchant.id, nil %>
|
||||
<%= form.label :merchant_id_in, merchant.name, value: merchant.id, class: "text-sm text-gray-900" %>
|
||||
<%= form.check_box :merchants,
|
||||
{
|
||||
multiple: true,
|
||||
checked: @q[:merchants]&.include?(merchant.name),
|
||||
class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
},
|
||||
merchant.name,
|
||||
nil %>
|
||||
<%= form.label :merchants, merchant.name, value: merchant.name, class: "text-sm text-gray-900" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,40 +1,47 @@
|
||||
<%= sidebar_modal do %>
|
||||
<%= drawer do %>
|
||||
<h3 class="font-medium mb-1">
|
||||
<span class="text-2xl"><%= format_money @transaction.amount_money %></span>
|
||||
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
||||
</h3>
|
||||
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
|
||||
<%= form_with model: @transaction, html: {data: {controller: "auto-submit-form"}} do |f| %>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
Overview
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
|
||||
<div class="h-2"></div>
|
||||
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-400" }, {class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled"} %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
Description
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
Overview
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<div class="space-y-2">
|
||||
<%= f.date_field :date, label: "Date", max: Date.today, "data-auto-submit-form-target": "auto" %>
|
||||
<%= f.collection_select :category_id, Current.family.transaction_categories, :id, :name, { prompt: "Select a category", label: "Category", class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
Description
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<%= f.text_field :name, label: "Name", "data-auto-submit-form-target": "auto" %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<span>Settings</span>
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<% end %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<span>Settings</span>
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<label class="flex items-center cursor-pointer justify-between mx-3">
|
||||
<%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
|
||||
<div class="flex flex-col justify-center text-sm w-[340px] py-3">
|
||||
@@ -43,16 +50,32 @@
|
||||
</div>
|
||||
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-100 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<span>Additional</span>
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
<% end %>
|
||||
</details>
|
||||
<details class="group" open>
|
||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
|
||||
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<span>Additional</span>
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="mb-2">
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<%= f.select :tag_ids,
|
||||
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), @transaction.tag_ids),
|
||||
{
|
||||
multiple: true,
|
||||
label: t(".select_tags"),
|
||||
class: "placeholder:text-gray-500"
|
||||
},
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note", "data-auto-submit-form-target": "auto" %>
|
||||
</details>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
@@ -28,3 +28,4 @@ ignore_unused:
|
||||
- 'activerecord.models.account*' # i18n-tasks does not detect use in dynamic model names (e.g. object.model_name.human)
|
||||
- 'helpers.submit.*' # i18n-tasks does not detect used at forms
|
||||
- 'helpers.label.*' # i18n-tasks does not detect used at forms
|
||||
- 'accounts.show.sync_message_*' # messages generated in the sync ActiveJob
|
||||
|
||||
3
config/initializers/pagy.rb
Normal file
3
config/initializers/pagy.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
require "pagy/extras/overflow"
|
||||
|
||||
Pagy::DEFAULT[:overflow] = :last_page
|
||||
@@ -10,7 +10,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.1.0-alpha.2"
|
||||
"0.1.0-alpha.4"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user