Compare commits

...

37 Commits

Author SHA1 Message Date
Zach Gollwitzer
773cd0da71 Bump to 0.1.0-alpha.6
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-06-14 16:50:08 -04:00
Mattia
5da34c4609 Changelog page that pulls from Github Release notes (#867)
* Changelog page that pulls from Github Release notes

* Review changelog page styles

* Move changelog page title to i18n translations
2024-06-14 16:40:50 -04:00
Zach Gollwitzer
957584b69c Clean up sync logic (#871) 2024-06-13 17:03:38 -04:00
Zach Gollwitzer
d0a15b8a98 Improve self hosting docs and UI (#870) 2024-06-13 16:19:05 -04:00
Zach Gollwitzer
9956a9540e Add institution management and account editing controls (#868)
* Add institution management

* Allow user to select institution on create or edit

* Improve redirect behavior

* Final cleanup

* i18n normalization
2024-06-13 14:37:27 -04:00
Zach Gollwitzer
8c1a7af37f Allow for optional start date on account creation (#866) 2024-06-13 09:16:00 -04:00
Zach Gollwitzer
c5704ffd45 Improve account internal linking and redirect behavior (#864)
* Fix transaction row link and overflow

* Allow user to access imports from account page

* Clean up accounts controller, add link to account page from settings

* Add link to accounts management from accounts summary page

* Cleanup styles
2024-06-11 18:47:38 -04:00
Zach Gollwitzer
8372e26864 Allow optional import fields (#865) 2024-06-11 18:46:44 -04:00
dependabot[bot]
6477c0f766 Bump aws-sdk-s3 from 1.151.0 to 1.152.0 (#854)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.151.0 to 1.152.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 12:39:15 -04:00
dependabot[bot]
2a8bb57c9c Bump pagy from 8.4.1 to 8.4.4 (#853)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.4.1 to 8.4.4.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/8.4.1...8.4.4)

---
updated-dependencies:
- dependency-name: pagy
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 12:38:44 -04:00
dependabot[bot]
2f432ec0c3 Bump good_job from 3.29.2 to 3.29.3 (#851)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.29.2 to 3.29.3.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v3.29.2...v3.29.3)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 12:38:25 -04:00
dependabot[bot]
e3269e8981 Bump faraday from 2.9.0 to 2.9.1 (#850)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.9.0 to 2.9.1.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.9.0...v2.9.1)

---
updated-dependencies:
- dependency-name: faraday
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 12:37:59 -04:00
dependabot[bot]
8f891b8d8c Bump tailwindcss-rails from 2.6.0 to 2.6.1 (#848)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.0 to 2.6.1.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.0...v2.6.1)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 12:37:04 -04:00
dependabot[bot]
775921092c Bump rails from 8e7eb03 to f9c847f (#855)
Bumps [rails](https://github.com/rails/rails) from `8e7eb03` to `f9c847f`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](8e7eb03d99...f9c847fac1)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 12:36:08 -04:00
dependabot[bot]
83e2bfceb8 Bump lucide-rails from 6170b3a to 79d9895 (#849)
Bumps [lucide-rails](https://github.com/maybe-finance/lucide-rails) from `6170b3a` to `79d9895`.
- [Commits](6170b3a0ec...79d989593e)

---
updated-dependencies:
- dependency-name: lucide-rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 12:32:25 -04:00
Zach Gollwitzer
87a40aafeb Bump to v0.1.0-alpha.5
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-06-07 19:29:01 -04:00
Zach Gollwitzer
a681e73fea Enable bulk editing of transactions (#846) 2024-06-07 18:59:46 -04:00
Zach Gollwitzer
d3f9be15f1 Bulk transaction deletion (#845)
* Clean up transaction show view, add delete button

* Clean up tailwind global styles, add switch

* Bulk deletion controller and tests

* Normalize translations

* Add bulk deletion button and form
2024-06-07 16:56:30 -04:00
Zach Gollwitzer
115f792198 Add bulk selection UI controls (#840)
* Add bulk selection UI

* Handle bulk selection with Stimulus controller instead of session

* Update tests

* Remove stale routes

* Remove old system test helper methods
2024-06-07 12:44:06 -04:00
dependabot[bot]
e4ac5c87e4 Bump rails from c1f1b14 to 8e7eb03 (#828)
Bumps [rails](https://github.com/rails/rails) from `c1f1b14` to `8e7eb03`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](c1f1b14adc...8e7eb03d99)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-03 08:41:06 -04:00
dependabot[bot]
a4fef176e8 Bump pagy from 8.4.0 to 8.4.1 (#825)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.4.0 to 8.4.1.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/8.4.0...8.4.1)

---
updated-dependencies:
- dependency-name: pagy
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-03 08:33:46 -04:00
dependabot[bot]
ee5fc2be38 Bump ruby-lsp-rails from 0.3.6 to 0.3.7 (#826)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.6 to 0.3.7.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.6...v0.3.7)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-03 08:33:35 -04:00
Zach Gollwitzer
28524b3f08 Bump to v0.1.0-alpha.4 (#822) 2024-05-31 14:09:12 -04:00
Zach Gollwitzer
bcbb37a146 Client-side validation for Decimal precision of 19,4 (#821) 2024-05-30 22:07:47 -04:00
Zach Gollwitzer
de53a50e45 Sync account after transaction import (#820) 2024-05-30 22:06:32 -04:00
Zach Gollwitzer
32e647f0fb Support 32 and 64 bit ARM architectures for Docker image
Fixes #816

Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-05-30 21:42:09 -04:00
Zach Gollwitzer
4ebc08e5a4 Transactions cleanup (#817)
An overhaul and cleanup of the transactions feature including:

- Simplification of transactions search and filtering
- Consolidation of account sync logic after transaction change
- Split sidebar modal and modal into "drawer" and "modal" concepts
- Refactor of transaction partials and folder organization
- Cleanup turbo frames and streams for transaction updates, including new Transactions::RowsController for inline updates
- Refactored and added several integration and systems tests
2024-05-30 20:55:18 -04:00
Zach Gollwitzer
ee162bbef7 Reuse ci workflow (#819) 2024-05-30 15:44:16 -04:00
Zach Gollwitzer
df391e0a14 Update issue templates 2024-05-28 13:23:15 -04:00
Jakub Kottnauer
6182a62573 Sort accounts in the sidebar (#815) 2024-05-28 13:22:04 -04:00
dependabot[bot]
981a1cb2ee Bump rails from ed50b93 to c1f1b14 (#814)
Bumps [rails](https://github.com/rails/rails) from `ed50b93` to `c1f1b14`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](ed50b93ebc...c1f1b14adc)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 12:11:53 -04:00
dependabot[bot]
e0d8499a8c Bump propshaft from 0.8.0 to 0.9.0 (#812)
Bumps [propshaft](https://github.com/rails/propshaft) from 0.8.0 to 0.9.0.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v0.8.0...v0.9.0)

---
updated-dependencies:
- dependency-name: propshaft
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 12:10:42 -04:00
Jakub Kottnauer
483d67846c Fix foreign account sync crash (#794)
* Fix foreign account sync crash

* Refactor synth provider and show UI error if not configured

* Generate error message on missing exchange rates while converting balances

* Ignore sync messaged in i18n-tasks unused

* Generate missing exchange rate error during entry normalization

* Update alert classes
2024-05-27 12:10:28 -04:00
dependabot[bot]
e9c8897eaf Bump webmock from 3.23.0 to 3.23.1 (#813)
Bumps [webmock](https://github.com/bblimke/webmock) from 3.23.0 to 3.23.1.
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.23.0...v3.23.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 11:28:46 -04:00
dependabot[bot]
9e09931c0e Bump good_job from 3.28.3 to 3.29.2 (#811)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.28.3 to 3.29.2.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v3.28.3...v3.29.2)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 10:32:54 -04:00
Jakub Kottnauer
98f3f172a9 Validate transaction filtering params (#810) 2024-05-27 10:01:08 -04:00
pea-sys
0e15bda6eb fix: png file can be selected as profile images (#809)
Signed-off-by: pea-sys <49807271+pea-sys@users.noreply.github.com>
2024-05-26 08:55:31 -05:00
141 changed files with 2589 additions and 1282 deletions

View File

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

View File

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

@@ -0,0 +1,8 @@
name: Pull Request
on:
pull_request:
jobs:
ci:
uses: ./.github/workflows/ci.yml

View File

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

View File

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

View File

@@ -1,48 +1,38 @@
GIT
remote: https://github.com/maybe-finance/lucide-rails.git
revision: 6170b3a0eceb43a8af6552638e9526673c356d0d
revision: 79d989593ee4ac6c50106ec5e4d2bd4ec8f5af87
specs:
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: ed50b93ebcf3c1a92ac3481297c07c33d9f7c161
revision: f9c847fac102039d9174106f44b59144da267751
branch: 7-2-stable
specs:
actioncable (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actioncable (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activejob (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
activestorage (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionmailbox (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
mail (>= 2.8.0)
actionmailer (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
actionview (= 7.2.0.alpha)
activejob (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionmailer (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
actionview (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.0.alpha)
actionview (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionpack (7.2.0.beta2)
actionview (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@@ -51,60 +41,61 @@ GIT
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
activestorage (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actiontext (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.0.alpha)
activesupport (= 7.2.0.alpha)
actionview (7.2.0.beta2)
activesupport (= 7.2.0.beta2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.0.alpha)
activesupport (= 7.2.0.alpha)
activejob (7.2.0.beta2)
activesupport (= 7.2.0.beta2)
globalid (>= 0.3.6)
activemodel (7.2.0.alpha)
activesupport (= 7.2.0.alpha)
activerecord (7.2.0.alpha)
activemodel (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
activemodel (7.2.0.beta2)
activesupport (= 7.2.0.beta2)
activerecord (7.2.0.beta2)
activemodel (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
timeout (>= 0.4.0)
activestorage (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activejob (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
activestorage (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
marcel (~> 1.0)
activesupport (7.2.0.alpha)
activesupport (7.2.0.beta2)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger
minitest (>= 5.1)
tzinfo (~> 2.0, >= 2.0.5)
rails (7.2.0.alpha)
actioncable (= 7.2.0.alpha)
actionmailbox (= 7.2.0.alpha)
actionmailer (= 7.2.0.alpha)
actionpack (= 7.2.0.alpha)
actiontext (= 7.2.0.alpha)
actionview (= 7.2.0.alpha)
activejob (= 7.2.0.alpha)
activemodel (= 7.2.0.alpha)
activerecord (= 7.2.0.alpha)
activestorage (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
rails (7.2.0.beta2)
actioncable (= 7.2.0.beta2)
actionmailbox (= 7.2.0.beta2)
actionmailer (= 7.2.0.beta2)
actionpack (= 7.2.0.beta2)
actiontext (= 7.2.0.beta2)
actionview (= 7.2.0.beta2)
activejob (= 7.2.0.beta2)
activemodel (= 7.2.0.beta2)
activerecord (= 7.2.0.beta2)
activestorage (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
bundler (>= 1.15.0)
railties (= 7.2.0.alpha)
railties (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
railties (= 7.2.0.beta2)
railties (7.2.0.beta2)
actionpack (= 7.2.0.beta2)
activesupport (= 7.2.0.beta2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -118,17 +109,17 @@ GEM
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.930.0)
aws-sdk-core (3.196.1)
aws-partitions (1.941.0)
aws-sdk-core (3.197.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.81.0)
aws-sdk-core (~> 3, >= 3.193.0)
aws-sdk-kms (1.83.0)
aws-sdk-core (~> 3, >= 3.197.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.151.0)
aws-sdk-core (~> 3, >= 3.194.0)
aws-sdk-s3 (1.152.0)
aws-sdk-core (~> 3, >= 3.197.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
@@ -148,7 +139,7 @@ GEM
msgpack (~> 1.2)
brakeman (6.1.2)
racc
builder (3.2.4)
builder (3.3.0)
capybara (3.40.0)
addressable
matrix
@@ -160,7 +151,7 @@ GEM
xpath (~> 3.2)
childprocess (5.0.0)
climate_control (1.2.0)
concurrent-ruby (1.2.3)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -187,7 +178,7 @@ GEM
erubi (1.12.0)
et-orbi (1.2.11)
tzinfo
faraday (2.9.0)
faraday (2.9.1)
faraday-net_http (>= 2.0, < 3.2)
faraday-net_http (3.1.0)
net-http
@@ -199,7 +190,7 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (3.28.3)
good_job (3.29.3)
activejob (>= 6.0.0)
activerecord (>= 6.0.0)
concurrent-ruby (>= 1.0.2)
@@ -249,6 +240,7 @@ GEM
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.0)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -261,13 +253,13 @@ GEM
matrix (0.4.2)
mini_magick (4.12.0)
mini_mime (1.1.5)
minitest (5.23.0)
minitest (5.23.1)
mocha (2.3.0)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
net-http (0.4.1)
uri
net-imap (0.4.11)
net-imap (0.4.12)
date
net-protocol
net-pop (0.1.2)
@@ -293,14 +285,14 @@ GEM
base64
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.4.0)
pagy (8.4.4)
parallel (1.24.0)
parser (3.3.1.0)
ast (~> 2.4.1)
racc
pg (1.5.6)
prism (0.27.0)
propshaft (0.8.0)
prism (0.29.0)
propshaft (0.9.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
@@ -338,10 +330,10 @@ 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)
reline (0.5.8)
io-console (~> 0.5)
rexml (3.2.8)
strscan (>= 3.0.9)
@@ -374,13 +366,12 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.16.6)
ruby-lsp (0.17.1)
language_server-protocol (~> 3.17.0)
prism (>= 0.23.0, < 0.28)
prism (>= 0.29.0, < 0.30)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.6)
ruby-lsp (>= 0.16.5, < 0.17.0)
sorbet-runtime (>= 0.5.9897)
ruby-lsp-rails (0.3.7)
ruby-lsp (>= 0.17.0, < 0.18.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.1)
ffi (~> 1.12)
@@ -407,23 +398,23 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11383)
sorbet-runtime (0.5.11406)
stackprof (0.2.26)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
stringio (3.1.0)
strscan (3.1.0)
tailwindcss-rails (2.6.0)
tailwindcss-rails (2.6.1)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-aarch64-linux)
tailwindcss-rails (2.6.1-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-arm-linux)
tailwindcss-rails (2.6.1-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-arm64-darwin)
tailwindcss-rails (2.6.1-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-x86_64-darwin)
tailwindcss-rails (2.6.1-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.0-x86_64-linux)
tailwindcss-rails (2.6.1-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@@ -444,7 +435,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 +446,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 +485,6 @@ DEPENDENCIES
puma (>= 5.0)
rails!
rails-settings-cached
ransack!
rubocop-rails-omakase
ruby-lsp-rails
selenium-webdriver

View File

@@ -35,6 +35,11 @@ There are 3 primary ways to use the Maybe app:
## Local Development Setup
**If you are trying to _self-host_ the Maybe app, stop here. You
should [read this guide to get started](docs/hosting/docker.md).**
The instructions below are for developers to get started with contributing to the app.
### Requirements
- Ruby 3.3.1

View File

@@ -10,40 +10,18 @@
}
@layer components {
.prose {
table {
@apply divide-y divide-gray-300;
}
tr {
@apply divide-x divide-gray-100;
}
th {
@apply whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900;
}
tbody {
@apply divide-y divide-gray-200;
}
td {
@apply px-2 py-2 text-sm text-gray-500 whitespace-nowrap;
}
}
.form-field {
@apply relative border border-alpha-black-100 bg-white rounded-md shadow-xs;
@apply focus-within:shadow-none focus-within:border-gray-900 focus-within:ring-4 focus-within:ring-gray-100;
@apply relative rounded-md border bg-white border-alpha-black-100 shadow-xs;
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
}
.form-field__label {
@apply px-3 pt-2 pb-0 block text-xs text-gray-500;
@apply block px-3 pt-2 pb-0 text-xs text-gray-500;
}
.form-field__input {
@apply px-3 pb-2 pt-1 text-sm w-full bg-transparent border-none opacity-100;
@apply focus:outline-none focus:ring-0 focus:opacity-100;
@apply w-full border-none bg-transparent px-3 pt-1 pb-2 text-sm opacity-100;
@apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:opacity-50;
}
@@ -53,12 +31,48 @@
}
.form-field__submit {
@apply w-full p-3 text-center text-white bg-black rounded-lg cursor-pointer hover:bg-gray-700;
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
input:checked + label + .toggle-switch-dot {
transform: translateX(100%);
}
[type='checkbox'].maybe-checkbox {
@apply rounded-sm;
}
[type='checkbox'].maybe-checkbox--light {
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
}
[type='checkbox'].maybe-checkbox--dark {
@apply ring-gray-900 checked:text-white;
}
[type='checkbox'].maybe-checkbox--dark:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
.maybe-switch {
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
}
.prose--github-release-notes {
.octicon {
@apply inline-block overflow-visible align-text-bottom fill-current;
}
.dropdown-caret {
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
}
.user-mention {
@apply font-bold;
}
}
}
/* Small, single purpose classes that should take precedence over other styles */

View File

@@ -2,10 +2,12 @@ class AccountsController < ApplicationController
layout "with_sidebar"
include Filterable
before_action :set_account, only: %i[ show update destroy sync ]
before_action :set_account, only: %i[ edit show destroy sync update ]
after_action :sync_account, only: :create
def index
@accounts = Current.family.accounts
@institutions = Current.family.institutions
@accounts = Current.family.accounts.ungrouped.alphabetically
end
def summary
@@ -25,6 +27,10 @@ class AccountsController < ApplicationController
balance: nil,
accountable: Accountable.from_type(params[:type])&.new
)
if params[:institution_id]
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
end
end
def show
@@ -36,36 +42,19 @@ class AccountsController < ApplicationController
end
def update
if @account.update(account_params.except(:accountable_type))
@account.sync_later if account_params[:is_active] == "1" && @account.can_sync?
respond_to do |format|
format.html { redirect_to accounts_path, 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("account_#{@account.id}", partial: "accounts/account", locals: { account: @account })
]
end
end
else
render "show", status: :unprocessable_entity
end
@account.update! account_params.except(:accountable_type)
redirect_back_or_to account_path(@account), notice: t(".success")
end
def create
@account = Current.family.accounts.build(account_params.except(:accountable_type, :start_date))
@account.accountable = Accountable.from_type(account_params[:accountable_type])&.new
@account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
if @account.save
@valuation = @account.valuations.new(date: account_params[:start_date] || Date.today, value: @account.balance, currency: @account.currency)
@valuation.save!
redirect_to accounts_path, notice: t(".success")
else
render "new", status: :unprocessable_entity
end
redirect_back_or_to account_path(@account), notice: t(".success")
end
def destroy
@@ -74,22 +63,11 @@ class AccountsController < ApplicationController
end
def sync
if @account.can_sync?
unless @account.syncing?
@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
redirect_to account_path(@account), notice: t(".success")
end
private
@@ -99,6 +77,10 @@ class AccountsController < ApplicationController
end
def account_params
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :currency, :subtype, :is_active)
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
end
def sync_account
@account.sync_later
end
end

View File

@@ -2,20 +2,8 @@ class ApplicationController < ActionController::Base
include Authentication, Invitable, SelfHostable
include Pagy::Backend
before_action :sync_accounts
default_form_builder ApplicationFormBuilder
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
private
def sync_accounts
return if Current.user.blank?
if Current.user.last_login_at.nil? || Current.user.last_login_at.before?(Date.current.beginning_of_day)
Current.family.sync_accounts
end
end
end

View File

@@ -9,7 +9,8 @@ class ImportsController < ApplicationController
end
def new
@import = Import.new
account = Current.family.accounts.find_by(id: params[:account_id])
@import = Import.new account: account
end
def edit

View File

@@ -0,0 +1,35 @@
class InstitutionsController < ApplicationController
before_action :set_institution, except: %i[ new create ]
def new
@institution = Institution.new
end
def create
Current.family.institutions.create!(institution_params)
redirect_to accounts_path, notice: t(".success")
end
def edit
end
def update
@institution.update!(institution_params)
redirect_to accounts_path, notice: t(".success")
end
def destroy
@institution.destroy!
redirect_to accounts_path, notice: t(".success")
end
private
def institution_params
params.require(:institution).permit(:name, :logo)
end
def set_institution
@institution = Current.family.institutions.find(params[:id])
end
end

View File

@@ -31,6 +31,7 @@ class PagesController < ApplicationController
end
def changelog
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
end
def feedback

View File

@@ -47,7 +47,7 @@ class Settings::HostingsController < SettingsController
end
end
if hosting_params[:upgrades_mode] != "manual" && hosting_params[:render_deploy_hook].blank?
if hosting_params[:upgrades_mode] == "auto" && hosting_params[:render_deploy_hook].blank?
@errors.add(:render_deploy_hook, t("settings.hostings.update.render_deploy_hook_error"))
end

View File

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

View 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

View File

@@ -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
@@ -75,80 +34,46 @@ class TransactionsController < ApplicationController
.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 params[:transaction][:tag_id].present?
tag = Current.family.tags.find(params[:transaction][:tag_id])
@transaction.tags << tag unless @transaction.tags.include?(tag)
end
if params[:transaction][:remove_tag_id].present?
@transaction.tags.delete(params[:transaction][:remove_tag_id])
end
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)
@transaction.sync_account_later
redirect_to transactions_url, notice: t(".success")
end
respond_to do |format|
format.html { redirect_to transactions_url, notice: t(".success") }
def bulk_delete
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
redirect_to transactions_url, notice: t(".success", count: destroyed.count)
end
def bulk_edit
end
def bulk_update
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!)
redirect_to transactions_url, notice: t(".success", count: transactions.count)
else
flash.now[:error] = t(".failure")
render :index, status: :unprocessable_entity
end
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
@@ -163,7 +88,19 @@ class TransactionsController < ApplicationController
params[:transaction][:nature].to_s.inquiry
end
def bulk_delete_params
params.require(:bulk_delete).permit(transaction_ids: [])
end
def bulk_update_params
params.require(:bulk_update).permit(:date, :notes, :excluded, :category_id, :merchant_id, transaction_ids: [])
end
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, :tag_id, :remove_tag_id).except(:tag_id, :remove_tag_id)
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [])
end
end

View File

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

View File

@@ -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 != "/")

View File

@@ -0,0 +1,5 @@
module InstitutionsHelper
def institution_logo(institution)
institution.logo.attached? ? institution.logo : institution.logo_url
end
end

View 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

View File

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

View File

@@ -0,0 +1,126 @@
import {Controller} from "@hotwired/stimulus"
// Connects to data-controller="bulk-select"
export default class extends Controller {
static targets = ["row", "group", "selectionBar", "selectionBarText", "bulkEditDrawerTitle"]
static values = {
resource: String,
selectedIds: {type: Array, default: []}
}
connect() {
document.addEventListener("turbo:load", this.#updateView)
this.#updateView()
}
disconnect() {
document.removeEventListener("turbo:load", this.#updateView)
}
bulkEditDrawerTitleTargetConnected(element) {
element.innerText = `Edit ${this.selectedIdsValue.length} ${this.#pluralizedResourceName()}`
}
submitBulkRequest(e) {
const form = e.target.closest("form");
const scope = e.params.scope
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[transaction_ids][]`, this.selectedIdsValue)
form.requestSubmit()
}
togglePageSelection(e) {
if (e.target.checked) {
this.#selectAll()
} else {
this.deselectAll()
}
}
toggleGroupSelection(e) {
const group = this.groupTargets.find(group => group.contains(e.target))
this.#rowsForGroup(group).forEach(row => {
if (e.target.checked) {
this.#addToSelection(row.dataset.id)
} else {
this.#removeFromSelection(row.dataset.id)
}
})
}
toggleRowSelection(e) {
if (e.target.checked) {
this.#addToSelection(e.target.dataset.id)
} else {
this.#removeFromSelection(e.target.dataset.id)
}
}
deselectAll() {
this.selectedIdsValue = []
}
selectedIdsValueChanged() {
this.#updateView()
}
#addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
transactionIds.forEach(id => {
const input = document.createElement("input");
input.type = 'hidden'
input.name = paramName
input.value = id
form.appendChild(input)
})
}
#rowsForGroup(group) {
return this.rowTargets.filter(row => group.contains(row))
}
#addToSelection(idToAdd) {
this.selectedIdsValue = Array.from(
new Set([...this.selectedIdsValue, idToAdd])
)
}
#removeFromSelection(idToRemove) {
this.selectedIdsValue = this.selectedIdsValue.filter(id => id !== idToRemove)
}
#selectAll() {
this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id)
}
#updateView = () => {
this.#updateSelectionBar()
this.#updateGroups()
this.#updateRows()
}
#updateSelectionBar() {
const count = this.selectedIdsValue.length
this.selectionBarTextTarget.innerText = `${count} ${this.#pluralizedResourceName()} selected`
this.selectionBarTarget.hidden = count === 0
this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0
}
#pluralizedResourceName() {
return `${this.resourceValue}${this.selectedIdsValue.length === 1 ? "" : "s"}`
}
#updateGroups() {
this.groupTargets.forEach(group => {
const rows = this.rowTargets.filter(row => group.contains(row))
const groupSelected = rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
group.querySelector("input[type='checkbox']").checked = groupSelected
})
}
#updateRows() {
this.rowTargets.forEach(row => {
row.checked = this.selectedIdsValue.includes(row.dataset.id)
})
}
}

View File

@@ -8,7 +8,7 @@ export default class extends Controller {
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-24 h-24 rounded-full object-cover" />`;
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
this.templateTarget.classList.add("hidden");
this.clearBtnTarget.classList.remove("hidden");
};

View File

@@ -2,10 +2,12 @@ class Account < ApplicationRecord
include Syncable
include Monetizable
broadcasts_refreshes
validates :family, presence: true
broadcasts_refreshes
belongs_to :family
belongs_to :institution, optional: true
has_many :balances, dependent: :destroy
has_many :valuations, dependent: :destroy
has_many :transactions, dependent: :destroy
@@ -19,13 +21,10 @@ class Account < ApplicationRecord
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
scope :ungrouped, -> { where(institution_id: nil) }
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
@@ -84,4 +83,20 @@ class Account < ApplicationRecord
grouped_accounts
end
def self.create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
account = self.new(attributes.except(:accountable_type))
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
# Always build the initial valuation
account.valuations.build(date: Date.current, value: attributes[:balance], currency: account.currency)
# Conditionally build the optional start valuation
if start_date.present? && start_balance.present?
account.valuations.build(date: start_date, value: start_balance, currency: account.currency)
end
account.save!
account
end
end

View File

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

View File

@@ -18,10 +18,10 @@ module Account::Syncable
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)
update!(status: "ok", last_sync_date: Date.today, balance: new_balance, sync_errors: calculator.errors, sync_warnings: calculator.warnings)
rescue => e
update!(status: "error")
Rails.logger.error("Failed to sync account #{id}: #{e.message}")
update!(status: "error", sync_errors: [ :sync_message_unknown_error ])
logger.error("Failed to sync account #{id}: #{e.message}")
end
def can_sync?
@@ -77,8 +77,7 @@ module Account::Syncable
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
ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
end
nil

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ class Family < ApplicationRecord
has_many :users, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :institutions, dependent: :destroy
has_many :transactions, through: :accounts
has_many :imports, through: :accounts
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"

View File

@@ -56,6 +56,8 @@ class Import < ApplicationRecord
end
end
self.account.sync
update!(status: "complete")
rescue => e
update!(status: "failed")
@@ -146,15 +148,18 @@ class Import < ApplicationRecord
name_field = Import::Field.new \
key: "name",
label: "Name"
label: "Name",
is_optional: true
category_field = Import::Field.new \
key: "category",
label: "Category"
label: "Category",
is_optional: true
tags_field = Import::Field.new \
key: "tags",
label: "Tags"
label: "Tags",
is_optional: true
amount_field = Import::Field.new \
key: "amount",

View File

@@ -15,12 +15,17 @@ class Import::Field
attr_reader :key, :label, :validator
def initialize(key:, label:, validator: nil)
def initialize(key:, label:, is_optional: false, validator: nil)
@key = key.to_s
@label = label
@is_optional = is_optional
@validator = validator
end
def optional?
@is_optional
end
def define_validator(validator = nil, &block)
@validator = validator || block
end

View File

@@ -0,0 +1,7 @@
class Institution < ApplicationRecord
belongs_to :family
has_many :accounts, dependent: :nullify
has_one_attached :logo
scope :alphabetically, -> { order(name: :asc) }
end

View File

@@ -40,6 +40,26 @@ class Provider::Github
end
end
def fetch_latest_releases_notes
begin
Rails.cache.fetch("latest_github_releases_notes", expires_in: 2.hours) do
releases = Octokit.releases(repo)
releases.map do |release|
{
avatar: release.author.avatar_url,
name: release.name,
published_at: release.published_at,
body: Octokit.markdown(release.body, mode: "gfm", context: repo)
}
end
end
rescue => e
Rails.logger.error "Failed to fetch latest GitHub releases notes: #{e.message}"
[]
end
end
private
def repo
"#{owner}/#{name}"

View File

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

View File

@@ -1,20 +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
@@ -26,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

View File

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

View File

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

View File

@@ -1,21 +1,27 @@
<%= turbo_frame_tag dom_id(account) do %>
<div class="p-4 flex items-center justify-between gap-3">
<div class="p-4 flex items-center justify-between gap-3 group/account">
<div class="flex items-center gap-3">
<div class="w-8 h-8 flex items-center justify-center rounded-full text-xs font-medium <%= account.is_active ? "bg-blue-500/10 text-blue-500" : "bg-gray-500/10 text-gray-500" %>">
<%= account.name[0].upcase %>
</div>
<p class="text-sm font-medium <%= account.is_active ? "text-gray-900" : "text-gray-400" %>">
<%= account.name %>
</p>
<%= link_to account.name, account, class: [(account.is_active ? "text-gray-900" : "text-gray-400"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<%= link_to edit_account_path(account), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %>
<%= lucide_icon "pencil-line", class: "w-4 h-4 text-gray-500" %>
<% end %>
</div>
<div class="flex items-center gap-8">
<p class="text-sm font-medium <%= account.is_active ? "text-gray-900" : "text-gray-400" %>">
<%= format_money account.balance_money %>
</p>
<%= form_with model: account, method: :patch, html: { class: "flex items-center", data: { turbo_frame: "_top" } } do |form| %>
<%= form_with model: account,
namespace: account.id,
builder: ActionView::Helpers::FormBuilder,
data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %>
<div class="relative inline-block select-none">
<%= form.check_box :is_active, class: "sr-only peer", id: "is_active_#{account.id}", onchange: "this.form.requestSubmit();" %>
<label for="is_active_<%= account.id %>" class="block bg-gray-100 w-9 h-5 rounded-full cursor-pointer after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out peer-checked:bg-green-600 peer-checked:after:translate-x-4"></label>
<%= form.check_box :is_active, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %>
<%= form.label :is_active, "&nbsp;".html_safe, class: "maybe-switch" %>
</div>
<% end %>
</div>

View File

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

View File

@@ -1,4 +1,4 @@
<%= link_to new_account_path(step: "method", type: type.class.name.demodulize), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-25 border border-transparent focus:border focus:border-gray-200 block px-2 hover:bg-gray-25 rounded-lg p-2" do %>
<%= link_to new_account_path(step: "method", type: type.class.name.demodulize, institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-25 border border-transparent focus:border focus:border-gray-200 block px-2 hover:bg-gray-25 rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg <%= bg_color %> border border-alpha-black-25">
<%= lucide_icon(icon, class: "#{text_color} w-5 h-5") %>
</span>

View File

@@ -0,0 +1,17 @@
<%# locals: (accounts:) %>
<% accounts.group_by(&:accountable_type).each do |group, accounts| %>
<div class="bg-gray-25 p-1 rounded-xl">
<div class="flex items-center px-4 py-2 text-xs font-medium text-gray-500">
<p><%= to_accountable_title(Accountable.from_type(group)) %></p>
<span class="text-gray-400 mx-2">&middot;</span>
<p><%= accounts.count %></p>
<p class="ml-auto"><%= format_money accounts.sum(&:balance_money) %></p>
</div>
<div class="bg-white">
<% accounts.each do |account| %>
<%= render account %>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,11 @@
<div class="flex justify-center items-center h-[800px] text-sm">
<div class="text-center flex flex-col items-center max-w-[300px]">
<%= tag.p t(".no_accounts"), class: "text-gray-900 mb-1 font-medium" %>
<%= tag.p t(".empty_message"), class: "text-gray-500 mb-4" %>
<%= link_to new_account_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_account") %></span>
<% end %>
</div>
</div>

View File

@@ -6,7 +6,7 @@
<%= text %>
</span>
<% else %>
<%= link_to new_account_path(type: type.class.name.demodulize), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %>
<%= link_to new_account_path(type: type.class.name.demodulize, institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %>
</span>

View File

@@ -0,0 +1,21 @@
<header class="flex justify-between items-center text-gray-900 font-medium">
<h1 class="text-xl"><%= t(".accounts") %></h1>
<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 accounts_path(return_to: summary_accounts_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 "settings", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".manage") %></span>
<% end %>
</div>
<% end %>
<%= link_to new_account_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"><%= t(".new") %></p>
<% end %>
</div>
</header>

View File

@@ -0,0 +1,69 @@
<%# locals: (institution:) %>
<details open class="group bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center gap-2 focus-visible:outline-none">
<%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %>
<div class="flex items-center justify-center h-8 w-8 bg-blue-600/10 rounded-full bg-black/5">
<% if institution_logo(institution) %>
<%= image_tag institution_logo(institution), class: "rounded-full h-full w-full" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p institution.name.first.upcase, class: "text-blue-600 text-xs font-medium" %>
</div>
<% end %>
</div>
<%= link_to institution.name, edit_institution_path(institution), data: { turbo_frame: :modal }, class: "text-sm font-medium text-gray-900 ml-1 mr-auto hover:underline" %>
<%= 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 new_account_path(institution_id: institution.id),
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 "plus", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".add_account_to_institution") %></span>
<% end %>
<%= link_to edit_institution_path(institution),
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 %>
<%= button_to institution_path(institution),
method: :delete,
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_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body"),
accept: t(".confirm_accept")
}
} do %>
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
<span><%= t(".delete") %></span>
<% end %>
</div>
<% end %>
</summary>
<div class="space-y-4 mt-4">
<% if institution.accounts.any? %>
<%= render "accountable_group", accounts: institution.accounts %>
<% else %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-gray-500 text-sm">There are no accounts in this financial institution</p>
<%= link_to new_account_path(institution_id: institution.id), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-1.5 pr-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-4 h-4") %>
<span><%= t(".new_account") %></span>
<% end %>
</div>
<% end %>
</div>
</details>

View File

@@ -0,0 +1,17 @@
<%# locals: (accounts:) %>
<details open class="group bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center gap-2 focus-visible:outline-none">
<%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %>
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-black/5">
<%= lucide_icon("folder-pen", class: "w-5 h-5 text-gray-500") %>
</div>
<span class="mr-auto text-sm font-medium text-gray-900"><%= t(".other_accounts") %></span>
</summary>
<div class="space-y-4 mt-4">
<%= render "accountable_group", accounts: accounts %>
</div>
</details>

View File

@@ -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, "accounts/transactions/transaction") %>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,21 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit", account: @account.name) %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= form_with model: @account, data: { turbo_frame: "_top" } do |f| %>
<%= f.text_field :name, label: "Name" %>
<div class="relative">
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
<%= link_to new_institution_path do %>
<%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %>
<% end %>
</div>
<%= f.submit %>
<% end %>
</article>
<% end %>

View File

@@ -1,62 +1,45 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-medium text-gray-900">Accounts</h1>
<%= link_to new_account_path, class: "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_account") %></span>
<% end %>
</div>
<% if @accounts.empty? %>
<div class="flex justify-center items-center h-[800px] text-sm">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-gray-900 mb-1 font-medium">No accounts yet</p>
<p class="text-gray-500 mb-4">Add an account either via connection, importing or entering manually.</p>
<%= link_to new_account_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 %>
<header class="flex justify-between items-center text-gray-900 font-medium">
<h1 class="text-xl"><%= t(".accounts") %></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 new_institution_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",
data: { turbo_frame: "modal" } do %>
<%= lucide_icon "building-2", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".add_institution") %></span>
<% end %>
</div>
<% end %>
<%= link_to new_account_path,
data: { turbo_frame: "modal" },
class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2" do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new_account") %></span>
<p class="text-sm font-medium"><%= t(".new_account") %></p>
<% end %>
</div>
</div>
</header>
<% if @accounts.empty? && @institutions.empty? %>
<%= render "empty" %>
<% else %>
<div>
<% @accounts.by_provider.each do |item| %>
<details open class="bg-white group p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center gap-2">
<%= lucide_icon("chevron-down", class: "hidden group-open:block w-5 h-5 text-gray-500") %>
<%= lucide_icon("chevron-right", class: "group-open:hidden w-5 h-5 text-gray-500") %>
<% if item[:name] == "Manual accounts" %>
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-black/5">
<%= lucide_icon("folder-pen", class: "w-5 h-5 text-gray-500") %>
</div>
<% end %>
<span class="text-sm font-medium text-gray-900">
<%= item[:name] %>
</span>
</summary>
<div class="space-y-4 mt-4">
<% item[:accounts].each do |group, accounts| %>
<div class="bg-gray-25 p-1 rounded-xl">
<div class="flex items-center px-4 py-2 text-xs font-medium text-gray-500">
<p><%= to_accountable_title(Accountable.from_type(group)) %></p>
<span class="text-gray-400 mx-2">&middot;</span>
<p><%= accounts.count %></p>
<p class="ml-auto"><%= format_money accounts.sum(&:balance_money) %></p>
</div>
<div class="bg-white">
<% accounts.each do |account| %>
<%= render account %>
<% end %>
</div>
</div>
<% end %>
</div>
</details>
<div class="space-y-2">
<% @institutions.each do |institution| %>
<%= render "institution_accounts", institution: %>
<% end %>
<%= render "institutionless_accounts", accounts: @accounts %>
</div>
<% end %>
<div class="flex justify-between gap-4">
<% if self_hosted? %>
<%= previous_setting("Self-Hosting", settings_hosting_path) %>

View File

@@ -20,14 +20,18 @@
<div class="border-t border-alpha-black-25 p-4 text-gray-500 text-sm flex justify-between">
<div class="flex space-x-5">
<div class="flex items-center space-x-2">
<span>Select</span> <kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %></kbd>
<span>Select</span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %></kbd>
</div>
<div class="flex items-center space-x-2">
<span>Navigate</span> <kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-up", class: "inline w-3 h-3") %></kbd> <kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-down", class: "inline w-3 h-3") %></kbd>
<span>Navigate</span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-up", class: "inline w-3 h-3") %></kbd>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-down", class: "inline w-3 h-3") %></kbd>
</div>
</div>
<div class="flex items-center space-x-2">
<button data-action="modal#close">Close</button> <kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
<button data-action="modal#close">Close</button>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
</div>
</div>
<% elsif params[:step] == 'method' && @account.accountable.present? %>
@@ -47,14 +51,18 @@
<div class="border-t border-alpha-black-25 p-4 text-gray-500 text-sm flex justify-between">
<div class="flex space-x-5">
<div class="flex items-center space-x-2">
<span>Select</span> <kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %></kbd>
<span>Select</span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %></kbd>
</div>
<div class="flex items-center space-x-2">
<span>Navigate</span> <kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-up", class: "inline w-3 h-3") %></kbd> <kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-down", class: "inline w-3 h-3") %></kbd>
<span>Navigate</span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-up", class: "inline w-3 h-3") %></kbd>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-down", class: "inline w-3 h-3") %></kbd>
</div>
</div>
<div class="flex items-center space-x-2">
<button data-action="modal#close">Close</button> <kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
<button data-action="modal#close">Close</button>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
</div>
</div>
<% else %>
@@ -68,9 +76,19 @@
<div class="space-y-4 grow">
<%= f.hidden_field :accountable_type %>
<%= f.text_field :name, placeholder: t(".name.placeholder"), required: "required", label: t(".name.label"), autofocus: true %>
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
<%= f.money_field :balance_money, label: t(".balance.label"), required: "required" %>
<%= f.date_field :start_date, label: t(".start_date.label"), required: true, max: Date.today, value: Date.today %>
<%= f.money_field :balance_money, label: t(".balance"), required: "required" %>
<div>
<%= check_box_tag :add_start_values, class: "maybe-checkbox maybe-checkbox--light peer mb-1" %>
<%= label_tag :add_start_values, t(".optional_start_balance_message"), class: "pl-1 text-sm text-gray-500" %>
<div class="hidden peer-checked:flex items-center gap-2 mt-3 mb-6">
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.current %></div>
<div class="w-1/2"><%= f.number_field :start_balance, label: t(".start_balance") %></div>
</div>
</div>
</div>
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
<% end %>

View File

@@ -9,38 +9,49 @@
<%= button_to sync_account_path(@account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %>
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-900 hover:text-gray-500" %>
<% end %>
<div class="relative cursor-not-allowed">
<div class="flex items-center gap-2 px-3 py-2">
<span class="text-gray-900"><%= @account.balance_money.currency.iso_code %> <%= @account.balance_money.currency.symbol %></span>
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
<%= 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_account_path(@account),
data: { turbo_frame: :modal },
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= link_to new_import_path(account_id: @account.id),
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".import") %></span>
<% end %>
<%= button_to account_path(@account),
method: :delete,
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_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept", name: @account.name)
}
} do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<% end %>
</div>
</div>
<div class="relative cursor-pointer" data-controller="menu">
<button data-menu-target="button" class="flex hover:bg-gray-100 p-2 rounded">
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
</button>
<div data-menu-target="content" class="absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden">
<div class="w-48 px-3 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= button_to account_path(@account),
method: :delete,
class: "block w-full py-2 text-red-600 hover:text-red-800 flex items-center",
data: {
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept", name: @account.name)
}
} do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</div>
<%= 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">

View File

@@ -1,11 +1,6 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-medium text-gray-900">Accounts</h1>
<%= link_to new_account_path, class: "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>
<%= render "header" %>
<% if @accounts.empty? %>
<%= render "shared/no_account_empty_state" %>

View File

@@ -0,0 +1,17 @@
<%= 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/badge", category: transaction.category %>
</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 %>

View File

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

View File

@@ -14,7 +14,8 @@
<% @import.expected_fields.each do |field| %>
<%= mappings.select field.key,
options_for_select(@import.available_headers, @import.get_selected_header_for_field(field)),
label: field.label %>
label: field.label,
include_blank: field.optional? ? t(".optional") : false %>
<% end %>
<% end %>
</div>

View File

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

View File

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

View File

@@ -1,18 +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="w-48 flex gap-1">
<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="ml-auto">
<%= content_tag :p, format_money(Money.new(-transaction.amount, @import.account.currency)), class: ["whitespace-nowrap", BigDecimal(transaction.amount).negative? ? "text-green-600" : "text-red-600"] %>
<div class="col-span-1 justify-self-end">
<%= render "transactions/amount", transaction: transaction %>
</div>
</div>

View File

@@ -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") %> &middot; <%= transactions.size %></h4>
<span><%= format_money Money.new(-transactions.sum { |t| t.amount }, @import.account.currency) %></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>

View File

@@ -0,0 +1,27 @@
<%= form_with model: institution, data: { turbo_frame: "_top", controller: "profile-image-preview" } do |f| %>
<div class="flex justify-center items-center py-4">
<%= f.label :logo do %>
<div class="relative cursor-pointer hover:opacity-80 w-16 h-16 rounded-full bg-gray-50">
<% persisted_logo = institution_logo(institution) %>
<% if persisted_logo %>
<%= image_tag persisted_logo, class: "absolute inset-0 rounded-full w-full h-full object-cover" %>
<% end %>
<div data-profile-image-preview-target="imagePreview" class="absolute inset-0 h-full w-full flex items-center justify-center">
<% unless persisted_logo %>
<%= lucide_icon "image-plus", class: "w-5 h-5 text-gray-500 cursor-pointer", data: { profile_image_preview_target: "template" } %>
<% end %>
</div>
</div>
<% end %>
</div>
<%= f.file_field :logo,
accept: "image/png, image/jpeg",
class: "hidden",
data: { profile_image_preview_target: "fileField", action: "profile-image-preview#preview" } %>
<%= f.text_field :name, label: t(".name") %>
<%= f.submit %>
<% end %>

View File

@@ -0,0 +1,10 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit", institution: @institution.name) %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", institution: @institution %>
</article>
<% end %>

View File

@@ -0,0 +1,10 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".new_institution") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", institution: @institution %>
</article>
<% end %>

View File

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

View File

@@ -2,11 +2,27 @@
<%= render "settings/nav" %>
<% end %>
<div class="space-y-4">
<h1 class="text-gray-900 text-xl font-medium mb-4">What's New</h1>
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".title") %></h1>
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
<div class="flex justify-center items-center py-20">
<p class="text-gray-500">Changelog coming soon...</p>
</div>
<% @releases_notes.each do |release_notes| %>
<div class="flex justify-between gap-4 mb-12 last:mb-0">
<div class="w-1/3">
<div class="px-3 flex items-center gap-3">
<div class="text-white shrink-0 w-9 h-9">
<%= image_tag release_notes[:avatar], class: "rounded-full w-full h-full object-cover" %>
</div>
<div>
<div class="text-gray-900 font-medium text-sm"><%= release_notes[:name] %></div>
<div class="text-gray-500 text-sm"><%= release_notes[:published_at].strftime("%B %d, %Y") %></div>
</div>
</div>
</div>
<div class="w-2/3 text-gray-500 text-sm prose prose--github-release-notes">
<h2 class="mb-5 text-xl text-gray-900"><%= release_notes[:name] %></h2>
<%= release_notes[:body].html_safe %>
</div>
</div>
<% end %>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Imports", imports_path) %>

View File

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

View File

@@ -0,0 +1,9 @@
<div class="text-gray-900 flex items-center py-4 text-sm font-medium px-4">
<div class="grow max-w-72">
<%= render "transactions/name", transaction: transaction %>
</div>
<div class="ml-auto">
<%= render "transactions/amount", transaction: transaction %>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div class="flex items-center gap-1 mb-6">
<%= link_to root_path, class: "flex items-center gap-1 text-gray-900 font-medium text-sm" do %>
<%= link_to return_to_path(params), class: "flex items-center gap-1 text-gray-900 font-medium text-sm" do %>
<%= lucide_icon "chevron-left", class: "w-5 h-5 text-gray-500" %>
<span>Back</span>
<% end %>

View File

@@ -5,47 +5,52 @@
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
<%= settings_section title: t(".general_settings_title") do %>
<%= form_with model: Setting.new, url: settings_hosting_path, method: :patch, local: true, html: { class: "space-y-6", data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } } do |form| %>
<div>
<h2 class="font-medium mb-1"><%= t(".upgrades.title") %></h2>
<p class="text-gray-500 text-sm mb-4"><%= t(".upgrades.description") %></p>
<div class="space-y-4">
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_mode, "manual", checked: Setting.upgrades_mode == "manual", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_manual, t(".upgrades.manual.title"), class: "text-gray-900 text-sm" do %>
<span class="font-medium"><%= t(".upgrades.manual.title") %></span>
<br>
<span class="text-gray-500">
<% if ENV["HOSTING_PLATFORM"] == "render" %>
<div>
<h2 class="font-medium mb-1"><%= t(".upgrades.title") %></h2>
<p class="text-gray-500 text-sm mb-4"><%= t(".upgrades.description") %></p>
<div class="space-y-4">
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_mode, "manual", checked: Setting.upgrades_mode == "manual", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_manual, t(".upgrades.manual.title"), class: "text-gray-900 text-sm" do %>
<span class="font-medium"><%= t(".upgrades.manual.title") %></span>
<br>
<span class="text-gray-500">
<%= t(".upgrades.manual.description") %>
</span>
<% end %>
</div>
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_mode, "release", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "release", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_release, t(".upgrades.latest_release.title"), class: "text-gray-900 text-sm" do %>
<span class="font-medium"><%= t(".upgrades.latest_release.title") %></span>
<br>
<span class="text-gray-500">
<% end %>
</div>
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_mode, "release", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "release", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_release, t(".upgrades.latest_release.title"), class: "text-gray-900 text-sm" do %>
<span class="font-medium"><%= t(".upgrades.latest_release.title") %></span>
<br>
<span class="text-gray-500">
<%= t(".upgrades.latest_release.description") %>
</span>
<% end %>
</div>
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_mode, "commit", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "commit", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_commit, t(".upgrades.latest_commit.title"), class: "text-gray-900 text-sm" do %>
<span class="font-medium"><%= t(".upgrades.latest_commit.title") %></span>
<br>
<span class="text-gray-500">
<% end %>
</div>
<div class="flex items-center gap-4">
<%= form.radio_button :upgrades_mode, "commit", checked: Setting.upgrades_mode == "auto" && Setting.upgrades_target == "commit", data: { "auto-submit-form-target" => "auto", "autosubmit-trigger-event": "input" } %>
<%= form.label :upgrades_mode_commit, t(".upgrades.latest_commit.title"), class: "text-gray-900 text-sm" do %>
<span class="font-medium"><%= t(".upgrades.latest_commit.title") %></span>
<br>
<span class="text-gray-500">
<%= t(".upgrades.latest_commit.description") %>
</span>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
<div>
<h2 class="font-medium mb-1"><%= t(".provider_settings.title") %></h2>
<p class="text-gray-500 text-sm mb-4"><%= t(".render_deploy_hook_description") %></p>
<%= form.url_field :render_deploy_hook, label: t(".render_deploy_hook_label"), placeholder: t(".render_deploy_hook_placeholder"), value: Setting.render_deploy_hook, data: { "auto-submit-form-target" => "auto" } %>
</div>
<div>
<h2 class="font-medium mb-1"><%= t(".provider_settings.title") %></h2>
<p class="text-gray-500 text-sm mb-4"><%= t(".render_deploy_hook_description") %></p>
<%= form.url_field :render_deploy_hook, label: t(".render_deploy_hook_label"), placeholder: t(".render_deploy_hook_placeholder"), value: Setting.render_deploy_hook, data: { "auto-submit-form-target" => "auto" } %>
</div>
<% end %>
<div>
<h2 class="font-medium mb-1"><%= t(".smtp_settings.title") %></h2>
<p class="text-gray-500 text-sm mb-4"><%= t(".smtp_settings.description") %></p>
@@ -69,13 +74,14 @@
</div>
</div>
<div>
<%= link_to t(".smtp_settings.send_test_email_button"), send_test_email_settings_hosting_path, data: { turbo_method: :post }, class:"bg-gray-50 text-gray-900 text-sm font-medium rounded-lg px-3 py-2" %>
<%= link_to t(".smtp_settings.send_test_email_button"), send_test_email_settings_hosting_path, data: { turbo_method: :post }, class: "bg-gray-50 text-gray-900 text-sm font-medium rounded-lg px-3 py-2" %>
</div>
</div>
</div>
</div>
<% end %>
<% end %>
<div class="flex justify-between gap-4">
<%= previous_setting("Billing", settings_billing_path) %>
<%= next_setting("Accounts", accounts_path) %>

View File

@@ -8,7 +8,7 @@
<%= form_with model: Current.user, url: settings_profile_path, html: {data: { controller: "profile-image-preview" }} do |form| %>
<div class="flex items-center gap-4">
<div class="relative flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border border-alpha-black-25">
<div data-profile-image-preview-target="imagePreview">
<div data-profile-image-preview-target="imagePreview" class="h-full w-full flex justify-center items-center">
<% profile_image_attached = Current.user.profile_image.attached? %>
<% if profile_image_attached %>
<div class="h-24 w-24">
@@ -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>

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

View File

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

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

View File

@@ -1,6 +1,6 @@
<%# locals: (content:, classes:) -%>
<%= turbo_frame_tag "modal" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[648px] max-w-[580px] w-min-content shadow-xs h-fit <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-w-[580px] w-min-content shadow-xs h-fit <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
<div class="flex flex-col">
<%= content %>
</div>

View File

@@ -0,0 +1,3 @@
<%= content_tag :p,
format_money(-transaction.amount_money),
class: ["text-green-600": transaction.inflow?] %>

View File

@@ -0,0 +1,17 @@
<%# locals: (date:, transactions:) %>
<div class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<div class="flex pl-0.5 items-center gap-4">
<%= check_box_tag "#{date}_transactions_selection",
class: "maybe-checkbox maybe-checkbox--light",
id: "selection_transaction_#{date}",
data: { action: "bulk-select#toggleGroupSelection" } %>
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{transactions.size}" %>
</div>
<%= tag.span format_money(-transactions.sum(&:amount_money)) %>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render transactions %>
</div>
</div>

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

View File

@@ -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] %> &rarr; <%= 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>

View File

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

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

View File

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

View File

@@ -0,0 +1,16 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= transaction.name[0].upcase %>
</div>
<div class="truncate text-gray-900">
<% 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 %>

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
<div class="flex items-center gap-2">
<%= check_box_tag "transaction_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
<p data-bulk-select-target="selectionBarText"></p>
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
<%= link_to bulk_edit_transactions_path,
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
title: "Edit",
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
<% end %>
<%= form_with url: bulk_delete_transactions_path, builder: ActionView::Helpers::FormBuilder, data: { turbo_confirm: true } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>
<% end %>
</div>
</div>

View File

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

View File

@@ -1,22 +1,24 @@
<%# 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: } %>
<%= 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 flex items-center gap-4">
<%= check_box_tag dom_id(transaction, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<div class="max-w-full pr-10">
<%= render "transactions/name", transaction: 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? }] %>
</div>
<div class="col-span-3">
<%= render "transactions/categories/menu", transaction: transaction %>
</div>
<%= link_to transaction.account.name,
account_path(transaction.account),
data: { turbo_frame: "_top" },
class: ["col-span-3 hover:underline"] %>
<div class="col-span-2 ml-auto">
<%= render "transactions/amount", transaction: transaction %>
</div>
<% end %>

View File

@@ -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") %> &middot; <%= 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>

View File

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

View File

@@ -0,0 +1,80 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %>
<dialog data-controller="modal"
data-action="click->modal#clickOutside"
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">
<%= form_with url: bulk_update_transactions_path, scope: "bulk_update", html: { class: "h-full" }, data: { turbo_frame: "_top" } do |form| %>
<div class="flex h-full flex-col justify-between p-4">
<div>
<div class="flex h-9 items-center justify-end">
<div data-action="click->modal#close" class="cursor-pointer">
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
</div>
</div>
<div class="flex flex-col overflow-scroll">
<div>
<header class="mb-4 space-y-1">
<h3 class="text-2xl font-medium" data-bulk-select-target="bulkEditDrawerTitle">
Edit transactions
</h3>
</header>
<div class="space-y-2">
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6 space-y-2">
<%= form.date_field :date, label: t(".date"), max: Date.current %>
<%= form.collection_select :category_id, Current.family.transaction_categories, :id, :name, { prompt: t(".select_category"), label: t(".category"), class: "text-gray-400" } %>
<%= form.collection_select :merchant_id, Current.family.transaction_merchants, :id, :name, { prompt: t(".select_merchant"), label: t(".merchant"), class: "text-gray-400" } %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".additional") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<%= form.text_area :notes, label: t(".note"), placeholder: t(".note_placeholder"), rows: 5 %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="flex cursor-pointer items-center justify-between gap-4 p-3 pb-6">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
</div>
<div class="relative inline-block select-none">
<%= form.check_box :excluded, class: "sr-only peer" %>
<label for="bulk_update_excluded" class="maybe-switch"></label>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<div class="flex justify-end items-center gap-2">
<%= link_to t(".cancel"), transactions_path, class: "text-sm font-medium text-gray-900 px-3 py-2" %>
<%= tag.button t(".save"),
type: "button",
data: { "bulk-select-scope-param": "bulk_update", action: "bulk-select#submitBulkRequest" },
class: "px-3 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg" %>
</div>
</div>
<% end %>
</dialog>
<% end %>

View File

@@ -2,7 +2,7 @@
<% 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>

View File

@@ -25,8 +25,9 @@
<% end %>
<% if @transaction.category %>
<%= button_to transaction_path(@transaction),
<%= 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" %>

View File

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

View File

@@ -1,43 +1,41 @@
<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 %>
<%= render "header" %>
<%= 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 partial: "transactions/summary", locals: { totals: @totals } %>
<% end %>
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>" class="bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4 space-y-4">
<%= 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 %>
<%= render partial: "transactions/searches/search", locals: { transactions: @transactions } %>
<%= 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>
<% if @transactions.present? %>
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "selection_bar" %>
</div>
<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">
<div class="pl-0.5 col-span-4 flex items-center gap-4">
<%= check_box_tag "selection_transaction",
class: "maybe-checkbox maybe-checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %>
<p class="col-span-4">transaction</p>
</div>
<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| %>
<%= render partial: "date_group", locals: { 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>

View File

@@ -0,0 +1 @@
<%= render "transactions/transaction", transaction: @transaction %>

View File

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

View File

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More