mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-21 22:29:38 +02:00
Scaffold out basic transactions model and UI (#478)
* Transaction scaffold * Rough in transaction views * Fix sort order * Fix mass assignment issue * Fix test * Simplify CI workflow * Don't seed db before test
This commit is contained in:
parent
e767aca37f
commit
87b97b3c41
26 changed files with 430 additions and 44 deletions
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
@ -85,18 +85,16 @@ jobs:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests and smoke test seed
|
||||||
env:
|
env:
|
||||||
RAILS_ENV: test
|
RAILS_ENV: test
|
||||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||||
# REDIS_URL: redis://localhost:6379/0
|
# REDIS_URL: redis://localhost:6379/0
|
||||||
run: bin/rails db:setup test test:system
|
run: |
|
||||||
|
bin/rails db:create
|
||||||
- name: Smoke test database seeds
|
bin/rails db:schema:load
|
||||||
env:
|
bin/rails test:all
|
||||||
RAILS_ENV: test
|
bin/rails db:seed
|
||||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
|
||||||
run: bin/rails db:drop db:create db:migrate db:seed
|
|
||||||
|
|
||||||
- name: Keep screenshots from failed system tests
|
- name: Keep screenshots from failed system tests
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|
64
app/controllers/transactions_controller.rb
Normal file
64
app/controllers/transactions_controller.rb
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
class TransactionsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_transaction, only: %i[ show edit update destroy ]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@transactions = Current.family.transactions
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@transaction = Transaction.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@transaction = Transaction.new(transaction_params)
|
||||||
|
account = Current.family.accounts.find(params[:transaction][:account_id])
|
||||||
|
|
||||||
|
raise ActiveRecord::RecordNotFound, "Account not found or not accessible" if account.nil?
|
||||||
|
|
||||||
|
@transaction.account = account
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
if @transaction.save
|
||||||
|
format.html { redirect_to transaction_url(@transaction), notice: "Transaction was successfully created." }
|
||||||
|
else
|
||||||
|
format.html { render :new, status: :unprocessable_entity }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
respond_to do |format|
|
||||||
|
if @transaction.update(transaction_params)
|
||||||
|
format.html { redirect_to transaction_url(@transaction), notice: "Transaction was successfully updated." }
|
||||||
|
else
|
||||||
|
format.html { render :edit, status: :unprocessable_entity }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@transaction.destroy!
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to transactions_url, notice: "Transaction was successfully destroyed." }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# Use callbacks to share common setup or constraints between actions.
|
||||||
|
def set_transaction
|
||||||
|
@transaction = Transaction.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only allow a list of trusted parameters through.
|
||||||
|
def transaction_params
|
||||||
|
params.require(:transaction).permit(:name, :date, :amount, :currency)
|
||||||
|
end
|
||||||
|
end
|
|
@ -34,6 +34,18 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||||
|
default_options = { class: "form-field__input" }
|
||||||
|
merged_options = default_options.merge(html_options)
|
||||||
|
|
||||||
|
return super(method, collection, value_method, text_method, options, merged_options) unless options[:label]
|
||||||
|
|
||||||
|
@template.form_field_tag do
|
||||||
|
label(method, *label_args(options)) +
|
||||||
|
super(method, collection, value_method, text_method, options, merged_options.except(:label))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def submit(value = nil, options = {})
|
def submit(value = nil, options = {})
|
||||||
value, options = nil, value if value.is_a?(Hash)
|
value, options = nil, value if value.is_a?(Hash)
|
||||||
default_options = { class: "form-field__submit" }
|
default_options = { class: "form-field__submit" }
|
||||||
|
|
2
app/helpers/transactions_helper.rb
Normal file
2
app/helpers/transactions_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module TransactionsHelper
|
||||||
|
end
|
33
app/javascript/controllers/tabs_controller.js
Normal file
33
app/javascript/controllers/tabs_controller.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Connects to data-controller="tabs"
|
||||||
|
export default class extends Controller {
|
||||||
|
static classes = ["active"];
|
||||||
|
static targets = ["btn", "tab"];
|
||||||
|
static values = { defaultTab: String };
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
const defaultTab = this.defaultTabValue;
|
||||||
|
this.tabTargets.forEach((tab) => {
|
||||||
|
if (tab.id === defaultTab) {
|
||||||
|
tab.hidden = false;
|
||||||
|
this.btnTargets
|
||||||
|
.find((btn) => btn.dataset.id === defaultTab)
|
||||||
|
.classList.add(...this.activeClasses);
|
||||||
|
} else {
|
||||||
|
tab.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
select(event) {
|
||||||
|
const selectedTabId = event.currentTarget.dataset.id;
|
||||||
|
this.tabTargets.forEach((tab) => (tab.hidden = tab.id !== selectedTabId));
|
||||||
|
this.btnTargets.forEach((btn) =>
|
||||||
|
btn.classList.toggle(
|
||||||
|
...this.activeClasses,
|
||||||
|
btn.dataset.id === selectedTabId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ class Account < ApplicationRecord
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
has_many :balances, class_name: "AccountBalance"
|
has_many :balances, class_name: "AccountBalance"
|
||||||
has_many :valuations
|
has_many :valuations
|
||||||
|
has_many :transactions
|
||||||
|
|
||||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
class Family < ApplicationRecord
|
class Family < ApplicationRecord
|
||||||
has_many :users, dependent: :destroy
|
has_many :users, dependent: :destroy
|
||||||
has_many :accounts, dependent: :destroy
|
has_many :accounts, dependent: :destroy
|
||||||
|
has_many :transactions, through: :accounts
|
||||||
end
|
end
|
||||||
|
|
3
app/models/transaction.rb
Normal file
3
app/models/transaction.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
class Transaction < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
end
|
29
app/views/accounts/_account_history.html.erb
Normal file
29
app/views/accounts/_account_history.html.erb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<%# locals: (account:, valuation_series:) %>
|
||||||
|
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="font-medium text-lg">History</h3>
|
||||||
|
<%= link_to new_account_valuation_path(account), data: { turbo_frame: dom_id(Valuation.new) }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
|
||||||
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||||
|
<span class="text-sm">New entry</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl bg-gray-25 p-1">
|
||||||
|
<div class="flex flex-col rounded-lg space-y-1">
|
||||||
|
<div class="text-xs font-medium text-gray-500 uppercase flex items-center px-4 py-2">
|
||||||
|
<div class="w-16">date</div>
|
||||||
|
<div class="flex items-center justify-between grow">
|
||||||
|
<div></div>
|
||||||
|
<div>value</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-56 text-right">change</div>
|
||||||
|
<div class="w-[72px]"></div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||||
|
<%= turbo_frame_tag dom_id(Valuation.new) %>
|
||||||
|
<%= turbo_frame_tag "valuations_list" do %>
|
||||||
|
<%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuation_series } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
19
app/views/accounts/_transactions.html.erb
Normal file
19
app/views/accounts/_transactions.html.erb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<%# locals: (transactions:)%>
|
||||||
|
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="font-medium text-lg">Transactions</h3>
|
||||||
|
<%= link_to new_transaction_path, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
|
||||||
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||||
|
<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">
|
||||||
|
<% transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %>
|
||||||
|
<%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
|
@ -59,31 +59,17 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
<div data-controller="tabs" data-tabs-active-class="bg-gray-100" data-tabs-default-tab-value="account-history-tab">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex gap-1 text-sm text-gray-900 font-medium mb-4">
|
||||||
<h3 class="font-medium text-lg">History</h3>
|
<button data-id="account-history-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">History</button>
|
||||||
<%= link_to new_account_valuation_path(@account), data: { turbo_frame: dom_id(Valuation.new) }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
|
<button data-id="account-transactions-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Transactions</button>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
|
||||||
<span class="text-sm">New entry</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-gray-25 p-1">
|
<div class="min-h-[800px]">
|
||||||
<div class="flex flex-col rounded-lg space-y-1">
|
<div data-tabs-target="tab" id="account-history-tab">
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase flex items-center px-4 py-2">
|
<%= render partial: "accounts/account_history", locals: { account: @account, valuation_series: @valuation_series } %>
|
||||||
<div class="w-16">date</div>
|
|
||||||
<div class="flex items-center justify-between grow">
|
|
||||||
<div></div>
|
|
||||||
<div>value</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-56 text-right">change</div>
|
|
||||||
<div class="w-[72px]"></div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
|
||||||
<%= turbo_frame_tag dom_id(Valuation.new) %>
|
|
||||||
<%= turbo_frame_tag "valuations_list" do %>
|
|
||||||
<%= render partial: "accounts/account_valuation_list", locals: { valuation_series: @valuation_series } %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div data-tabs-target="tab" id="account-transactions-tab">
|
||||||
|
<%= render partial: "accounts/transactions", locals: { transactions: @account.transactions.order(date: :desc) } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,10 +6,8 @@
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<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-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Maybe">
|
<meta name="apple-mobile-web-app-title" content="Maybe">
|
||||||
|
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<link rel="icon" href="/icon.png" type="image/png">
|
<link rel="icon" href="/icon.png" type="image/png">
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||||
|
@ -18,15 +16,12 @@
|
||||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||||
<%= javascript_importmap_tags %>
|
<%= javascript_importmap_tags %>
|
||||||
<%= hotwire_livereload_tags if Rails.env.development? %>
|
<%= hotwire_livereload_tags if Rails.env.development? %>
|
||||||
|
|
||||||
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="h-full">
|
<body class="h-full">
|
||||||
<div id="notification-tray" class="fixed top-6 right-6 space-y-1 z-50"></div>
|
<div id="notification-tray" class="fixed top-6 right-6 space-y-1 z-50"></div>
|
||||||
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %>
|
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-col p-5 min-w-80">
|
<div class="flex-col p-5 min-w-80">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
@ -37,14 +32,12 @@
|
||||||
<div class="flex" data-action="click->dropdown#toggleMenu">
|
<div class="flex" data-action="click->dropdown#toggleMenu">
|
||||||
<div class="mr-1.5 text-white w-8 h-8 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.email.first %></div>
|
<div class="mr-1.5 text-white w-8 h-8 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.email.first %></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute z-10 hidden w-screen px-2 mt-2 -translate-x-1/2 left-1/2 max-w-min" data-dropdown-target="menu">
|
<div class="absolute z-10 hidden w-screen px-2 mt-2 -translate-x-1/2 left-1/2 max-w-min" data-dropdown-target="menu">
|
||||||
<div class="w-48 px-3 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
<div class="w-48 px-3 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||||
<%= link_to "Settings", edit_settings_path, class: 'block p-2 hover:text-gray-600' %>
|
<%= link_to "Settings", edit_settings_path, class: 'block p-2 hover:text-gray-600' %>
|
||||||
<%= button_to "Log Out", session_path, method: :delete, class: 'block p-2 hover:text-gray-600' %>
|
<%= button_to "Log Out", session_path, method: :delete, class: 'block p-2 hover:text-gray-600' %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
|
@ -56,7 +49,7 @@
|
||||||
<%= sidebar_link_to t('.accounts'), accounts_path, icon: 'layers' %>
|
<%= sidebar_link_to t('.accounts'), accounts_path, icon: 'layers' %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= sidebar_link_to t('.transactions'), "#", icon: 'credit-card' %>
|
<%= sidebar_link_to t('.transactions'), transactions_path, icon: 'credit-card' %>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -69,13 +62,10 @@
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<%= link_to new_account_path, class: "flex items-center gap-4 px-2 py-3 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
|
<%= link_to new_account_path, class: "flex items-center gap-4 px-2 py-3 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
<p><%= t('.new_account') %></p>
|
<p><%= t('.new_account') %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% Accountable.types.each do |type| %>
|
<% Accountable.types.each do |type| %>
|
||||||
<%= render 'accounts/account_list', type: Accountable.from_type(type) %>
|
<%= render 'accounts/account_list', type: Accountable.from_type(type) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
7
app/views/transactions/_form.html.erb
Normal file
7
app/views/transactions/_form.html.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<%= form_with model: @transaction do |f| %>
|
||||||
|
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account" } %>
|
||||||
|
<%= f.date_field :date, label: "Date" %>
|
||||||
|
<%= f.text_field :name, label: "Name" %>
|
||||||
|
<%= f.number_field :amount, label: "Amount", step: :any, placeholder: number_to_currency(0), in: 0.00..100000000.00 %>
|
||||||
|
<%= f.submit %>
|
||||||
|
<% end %>
|
12
app/views/transactions/_transaction.html.erb
Normal file
12
app/views/transactions/_transaction.html.erb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<div id="<%= dom_id transaction %>" class="flex items-center gap-6 py-4 text-sm font-medium">
|
||||||
|
<div class="w-96 flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 flex items-center justify-center rounded-full bg-gray-600/5 text-gray-600"><%= transaction.name[0].upcase %></div>
|
||||||
|
<p class="text-gray-900"><%= transaction.name %></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p><%= transaction.account.name %></p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<p class="<%= transaction.amount < 0 ? "text-green-600" : "" %>"><%= number_to_currency(-transaction.amount, { precision: 2 }) %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
10
app/views/transactions/_transaction_group.html.erb
Normal file
10
app/views/transactions/_transaction_group.html.erb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<%# locals: (date:, transactions:) %>
|
||||||
|
<div class="bg-gray-25 rounded-xl p-1">
|
||||||
|
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
|
<h4><%= date.strftime('%b %d, %Y') %> · <%= transactions.size %></h4>
|
||||||
|
<span><%= number_to_currency(-transactions.sum(&:amount)) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50 px-4">
|
||||||
|
<%= render partial: "transactions/transaction", collection: transactions %>
|
||||||
|
</div>
|
||||||
|
</div>
|
8
app/views/transactions/edit.html.erb
Normal file
8
app/views/transactions/edit.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<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>
|
75
app/views/transactions/index.html.erb
Normal file
75
app/views/transactions/index.html.erb
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
<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-1 cursor-not-allowed">
|
||||||
|
<span class="text-sm">USD $</span>
|
||||||
|
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
|
||||||
|
</div>
|
||||||
|
<div class="border-r border-alpha-black-200 h-5"></div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= lucide_icon("settings-2", class: "cursor-not-allowed w-5 h-5 text-gray-500") %>
|
||||||
|
<%= link_to new_transaction_path, class: "rounded-full w-9 h-9 bg-gray-900 text-white flex items-center justify-center hover:bg-gray-700" do %>
|
||||||
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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"><%= @transactions.size %></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">
|
||||||
|
<%= number_to_currency(@transactions.select { |t| t.amount < 0 }.sum(&:amount).abs, precision: 2) %>
|
||||||
|
</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">
|
||||||
|
<%= number_to_currency(@transactions.select { |t| t.amount >= 0 }.sum(&:amount), precision: 2) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="transactions" class="bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4 space-y-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="grow cursor-not-allowed">
|
||||||
|
<%= form_with url: transactions_path, method: :get, local: true, html: { role: 'search' } do |form| %>
|
||||||
|
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||||
|
<%= form.text_field :search, placeholder: "Search transaction by merchant, category or amount", class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg cursor-not-allowed", 'data-action': "input->search#perform", disabled: true %>
|
||||||
|
<%= 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>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="cursor-not-allowed 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>
|
||||||
|
<%= form_with url: "#", method: :get, class: "flex items-center gap-4", html: { class: "" } do |f| %>
|
||||||
|
<%= f.select :period, options_for_select([['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']], selected: params[:period]), {}, { class: "block h-full w-full border border-gray-200 rounded-lg text-sm py-2 pr-8 pl-2 cursor-not-allowed", onchange: "this.form.submit();", disabled: true } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-25 rounded-xl px-5 py-3 text-xs font-medium text-gray-500 flex items-center gap-6">
|
||||||
|
<div class="w-96">
|
||||||
|
<p class="uppercase">transaction</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>
|
||||||
|
<% if @transactions.empty? %>
|
||||||
|
<p class="text-gray-500 py-4">No transactions for this account yet.</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<% @transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %>
|
||||||
|
<%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
7
app/views/transactions/new.html.erb
Normal file
7
app/views/transactions/new.html.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<div class="mx-auto md:w-2/3 w-full">
|
||||||
|
<h1 class="font-bold text-4xl mb-4">New transaction</h1>
|
||||||
|
<%= render "form", transaction: @transaction %>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<%= link_to "Back to transactions", transactions_path, class: "mt-8 underline text-lg font-bold" %>
|
||||||
|
</div>
|
10
app/views/transactions/show.html.erb
Normal file
10
app/views/transactions/show.html.erb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<div class="mx-auto md:w-2/3 w-full flex">
|
||||||
|
<div class="mx-auto">
|
||||||
|
<%= render @transaction %>
|
||||||
|
<%= link_to "Edit this transaction", edit_transaction_path(@transaction), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
||||||
|
<div class="inline-block ml-2">
|
||||||
|
<%= button_to "Destroy this transaction", transaction_path(@transaction), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
|
||||||
|
</div>
|
||||||
|
<%= link_to "Back to transactions", transactions_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -7,6 +7,7 @@ Rails.application.routes.draw do
|
||||||
resource :password
|
resource :password
|
||||||
resource :settings, only: %i[edit update]
|
resource :settings, only: %i[edit update]
|
||||||
|
|
||||||
|
resources :transactions
|
||||||
resources :accounts, shallow: true do
|
resources :accounts, shallow: true do
|
||||||
resources :valuations
|
resources :valuations
|
||||||
end
|
end
|
||||||
|
|
13
db/migrate/20240223162105_create_transactions.rb
Normal file
13
db/migrate/20240223162105_create_transactions.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
class CreateTransactions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :transactions, id: :uuid do |t|
|
||||||
|
t.string :name
|
||||||
|
t.date :date, null: false
|
||||||
|
t.decimal :amount, precision: 19, scale: 4, null: false
|
||||||
|
t.string :currency, default: "USD", null: false
|
||||||
|
t.references :account, null: false, type: :uuid, foreign_key: { on_delete: :cascade }
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
14
db/schema.rb
generated
14
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_02_22_144849) do
|
ActiveRecord::Schema[7.2].define(version: 2024_02_23_162105) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -196,6 +196,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_22_144849) do
|
||||||
t.index ["token"], name: "index_invite_codes_on_token", unique: true
|
t.index ["token"], name: "index_invite_codes_on_token", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "name"
|
||||||
|
t.date "date", null: false
|
||||||
|
t.decimal "amount", precision: 19, scale: 4, null: false
|
||||||
|
t.string "currency", default: "USD", null: false
|
||||||
|
t.uuid "account_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id"], name: "index_transactions_on_account_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "family_id", null: false
|
t.uuid "family_id", null: false
|
||||||
t.string "first_name"
|
t.string "first_name"
|
||||||
|
@ -220,6 +231,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_22_144849) do
|
||||||
|
|
||||||
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "accounts", "families"
|
add_foreign_key "accounts", "families"
|
||||||
|
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "users", "families"
|
add_foreign_key "users", "families"
|
||||||
add_foreign_key "valuations", "accounts", on_delete: :cascade
|
add_foreign_key "valuations", "accounts", on_delete: :cascade
|
||||||
end
|
end
|
||||||
|
|
22
db/seeds.rb
22
db/seeds.rb
|
@ -40,6 +40,15 @@ valuations = [
|
||||||
{ date: Date.today - 3, value: 301900 }
|
{ date: Date.today - 3, value: 301900 }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
transactions = [
|
||||||
|
{ date: Date.today - 27, amount: 7.56, currency: "USD", name: "Starbucks" },
|
||||||
|
{ date: Date.today - 18, amount: -2000, currency: "USD", name: "Paycheck" },
|
||||||
|
{ date: Date.today - 18, amount: 18.20, currency: "USD", name: "Walgreens" },
|
||||||
|
{ date: Date.today - 13, amount: 34.20, currency: "USD", name: "Chipotle" },
|
||||||
|
{ date: Date.today - 9, amount: -200, currency: "USD", name: "Birthday check" },
|
||||||
|
{ date: Date.today - 5, amount: 85.00, currency: "USD", name: "Amazon stuff" }
|
||||||
|
]
|
||||||
|
|
||||||
# Represent system-generated "Balances" at various dates, based on valuations
|
# Represent system-generated "Balances" at various dates, based on valuations
|
||||||
balances = [
|
balances = [
|
||||||
{ date: Date.today - 30, balance: 300000 },
|
{ date: Date.today - 30, balance: 300000 },
|
||||||
|
@ -75,6 +84,8 @@ balances = [
|
||||||
{ date: Date.today, balance: current_balance }
|
{ date: Date.today, balance: current_balance }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
valuations.each do |valuation|
|
valuations.each do |valuation|
|
||||||
Valuation.find_or_create_by(
|
Valuation.find_or_create_by(
|
||||||
account_id: account.id,
|
account_id: account.id,
|
||||||
|
@ -93,3 +104,14 @@ balances.each do |balance|
|
||||||
balance_record.balance = balance[:balance]
|
balance_record.balance = balance[:balance]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
transactions.each do |transaction|
|
||||||
|
Transaction.find_or_create_by(
|
||||||
|
account_id: account.id,
|
||||||
|
date: transaction[:date],
|
||||||
|
amount: transaction[:amount]
|
||||||
|
) do |transaction_record|
|
||||||
|
transaction_record.currency = transaction[:currency]
|
||||||
|
transaction_record.name = transaction[:name]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
49
test/controllers/transactions_controller_test.rb
Normal file
49
test/controllers/transactions_controller_test.rb
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:bob)
|
||||||
|
@transaction = transactions(:one)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get index" do
|
||||||
|
get transactions_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get new" do
|
||||||
|
get new_transaction_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should create transaction" do
|
||||||
|
assert_difference("Transaction.count") do
|
||||||
|
post transactions_url, params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: @transaction.name } }
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to transaction_url(Transaction.last)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should show transaction" do
|
||||||
|
get transaction_url(@transaction)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get edit" do
|
||||||
|
get edit_transaction_url(@transaction)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should update transaction" do
|
||||||
|
patch transaction_url(@transaction), params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: @transaction.name } }
|
||||||
|
assert_redirected_to transaction_url(@transaction)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should destroy transaction" do
|
||||||
|
assert_difference("Transaction.count", -1) do
|
||||||
|
delete transaction_url(@transaction)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to transactions_url
|
||||||
|
end
|
||||||
|
end
|
15
test/fixtures/transactions.yml
vendored
Normal file
15
test/fixtures/transactions.yml
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
name: MyString
|
||||||
|
date: 2024-02-23
|
||||||
|
amount: 9.99
|
||||||
|
currency: MyString
|
||||||
|
account: dylan_checking
|
||||||
|
|
||||||
|
two:
|
||||||
|
name: MyString
|
||||||
|
date: 2024-02-20
|
||||||
|
amount: 9.99
|
||||||
|
currency: MyString
|
||||||
|
account: dylan_checking
|
7
test/models/transaction_test.rb
Normal file
7
test/models/transaction_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TransactionTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
Loading…
Add table
Add a link
Reference in a new issue