mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 13:35:21 +02:00
Transaction rules engine V1 (#1900)
* Domain model sketch
* Scaffold out rules domain
* Migrations
* Remove existing data enrichment for clean slate
* Sketch out business logic and basic tests
* Simplify rule scope building and action executions
* Get generator working again
* Basic implementation + tests
* Remove manual merchant management (rules will replace)
* Revert "Remove manual merchant management (rules will replace)"
This reverts commit 83dcbd9ff0
.
* Family and Provider merchants model
* Fix brakeman warnings
* Fix notification loader
* Update notification position
* Add Rule action and condition registries
* Rule form with compound conditions and tests
* Split out notification types, add CTA type
* Rules form builder and Stimulus controller
* Clean up rule registry domain
* Clean up rules stimulus controller
* CTA message for rule when user changes transaction category
* Fix tests
* Lint updates
* Centralize notifications in Notifiable concern
* Implement category rule prompts with auto backoff and option to disable
* Fix layout bug caused by merge conflict
* Initialize rule with correct action for category CTA
* Add rule deletions, get rules working
* Complete dynamic rule form, split Stimulus controllers by resource
* Fix failing tests
* Change test password to avoid chromium conflicts
* Update integration tests
* Centralize all test password references
* Add re-apply rule action
* Rule confirm modal
* Run migrations
* Trigger rule notification after inline category updates
* Clean up rule styles
* Basic attribute locking for rules
* Apply attribute locks on user edits
* Log data enrichments, only apply rules to unlocked attributes
* Fix merge errors
* Additional merge conflict fixes
* Form UI improvements, ignore attribute locks on manual rule application
* Batch AI auto-categorization of transactions
* Auto merchant detection, ai enrichment in batches
* Fix Plaid merchant assignments
* Plaid category matching
* Cleanup 1
* Test cleanup
* Remove stale route
* Fix desktop chat UI issues
* Fix mobile nav styling issues
This commit is contained in:
parent
8edd7ecef0
commit
297a695d0f
152 changed files with 4502 additions and 612 deletions
|
@ -32,14 +32,7 @@
|
|||
</p>
|
||||
|
||||
<% unless account.scheduled_for_deletion? %>
|
||||
<%= form_with model: account,
|
||||
namespace: account.id,
|
||||
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", data: { "auto-submit-form-target": "auto" } } %>
|
||||
<%= form.label :is_active, " ".html_safe, class: "switch" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render "shared/toggle_form", model: account, attribute: :is_active, turbo_frame: "_top" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= button_to "Use default categories", bootstrap_categories_path, class: "btn btn--primary" %>
|
||||
<%= button_to "Use defaults (recommended)", bootstrap_categories_path, class: "btn btn--primary" %>
|
||||
|
||||
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
|
|
|
@ -10,20 +10,18 @@
|
|||
</div>
|
||||
<div class="justify-self-end">
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
|
||||
<%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
|
||||
|
||||
<% if category.transactions.any? %>
|
||||
<%= link_to new_category_deletion_path(category),
|
||||
<% if category.transactions.any? %>
|
||||
<%= link_to new_category_deletion_path(category),
|
||||
class: "flex items-center w-full rounded-lg text-red-600 hover:bg-red-50 py-2 px-3 gap-2",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
|
||||
<span class="text-sm"><%= t(".delete") %></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
|
||||
<%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
|
||||
<span class="text-sm"><%= t(".delete") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<%= modal_form_wrapper title: t(".edit") do %>
|
||||
<%= modal_form_wrapper title: t(".edit"), overflow_visible: true do %>
|
||||
<%= render "form", category: @category, categories: @categories %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
|
||||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-primary text-xl font-medium"><%= t(".categories") %></h1>
|
||||
|
||||
<%= link_to new_category_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
<p><%= t(".new") %></p>
|
||||
<% end %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= contextual_menu do %>
|
||||
<%= contextual_menu_destructive_item "Delete all", destroy_all_categories_path, turbo_confirm: {
|
||||
title: "Delete all categories?",
|
||||
body: "All of your transactions will become uncategorized and this cannot be undone.",
|
||||
accept: "Delete all categories",
|
||||
} %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_category_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
<p><%= t(".new") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<%= modal_form_wrapper title: t(".new_category") do %>
|
||||
<%= modal_form_wrapper title: t(".new_category"), overflow_visible: true do %>
|
||||
<%= render "form", category: @category, categories: @categories %>
|
||||
<% end %>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<% end %>
|
||||
</nav>
|
||||
|
||||
<div class="grow">
|
||||
<div class="grow flex flex-col">
|
||||
<h1 class="text-xl font-medium mb-6">Chats</h1>
|
||||
|
||||
<% if @chats.any? %>
|
||||
|
@ -22,7 +22,7 @@
|
|||
<h3 class="text-lg font-medium text-primary mb-1">No chats yet</h3>
|
||||
<p class="text-gray-500 mb-4">Start a new conversation with the AI assistant</p>
|
||||
</div>
|
||||
<div class="mt-auto p-4">
|
||||
<div class="mt-auto p-4 lg:mt-auto">
|
||||
<%= render "messages/chat_form", chat: nil %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="p-4 lg:mt-auto">
|
||||
<%= render "messages/chat_form", chat: @chat %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
31
app/views/family_merchants/_family_merchant.html.erb
Normal file
31
app/views/family_merchants/_family_merchant.html.erb
Normal file
|
@ -0,0 +1,31 @@
|
|||
<%# locals: (family_merchant:) %>
|
||||
|
||||
<div class="flex justify-between items-center p-4 bg-white">
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
<% if family_merchant.logo_url %>
|
||||
<div class="w-8 h-8 rounded-full flex justify-center items-center">
|
||||
<%= image_tag family_merchant.logo_url, class: "w-8 h-8 rounded-full" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render partial: "shared/color_avatar", locals: { name: family_merchant.name, color: family_merchant.color } %>
|
||||
<% end %>
|
||||
|
||||
<p class="text-primary text-sm truncate">
|
||||
<%= family_merchant.name %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="justify-self-end">
|
||||
<%= contextual_menu do %>
|
||||
<%= contextual_menu_modal_action_item t(".edit"), edit_family_merchant_path(family_merchant), icon: "pencil", turbo_frame: "modal" %>
|
||||
|
||||
<%= contextual_menu_destructive_item "Delete",
|
||||
family_merchant_path(family_merchant),
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: family_merchant.transactions.any? ? {
|
||||
title: "Delete #{family_merchant.name}?",
|
||||
body: "This will remove this merchant from all transactions it has been assigned to.",
|
||||
accept: "Delete"
|
||||
} : nil %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
|
@ -1,11 +1,14 @@
|
|||
<div data-controller="color-avatar">
|
||||
<%= styled_form_with model: @merchant, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
|
||||
<%= styled_form_with model: @merchant, class: "space-y-4" do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="w-fit m-auto">
|
||||
<% if @merchant.errors.any? %>
|
||||
<%= render "shared/form_errors", model: @merchant %>
|
||||
<% end %>
|
||||
<div class="w-fit m-auto mb-4">
|
||||
<%= render partial: "shared/color_avatar", locals: { name: @merchant.name, color: @merchant.color } %>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<% Merchant::COLORS.each do |color| %>
|
||||
<% FamilyMerchant::COLORS.each do |color| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
|
@ -1,9 +1,9 @@
|
|||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-primary text-xl font-medium"><%= t(".title") %></h1>
|
||||
<h1 class="text-primary text-xl font-medium">Merchants</h1>
|
||||
|
||||
<%= link_to new_merchant_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
<p><%= t(".new") %></p>
|
||||
<%= link_to new_family_merchant_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<p class="text-sm font-medium">New merchant</p>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
|
@ -18,7 +18,7 @@
|
|||
|
||||
<div class="border border-alpha-black-25 rounded-md bg-container shadow-border-xs">
|
||||
<div class="overflow-hidden rounded-md">
|
||||
<%= render partial: @merchants, spacer_template: "merchants/ruler" %>
|
||||
<%= render partial: "family_merchants/family_merchant", collection: @merchants, spacer_template: "family_merchants/ruler" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -26,7 +26,7 @@
|
|||
<div class="flex justify-center items-center py-20">
|
||||
<div class="text-center flex flex-col items-center max-w-[300px]">
|
||||
<p class="text-primary mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
||||
<%= link_to new_merchant_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 %>
|
||||
<%= link_to new_family_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span><%= t(".new") %></span>
|
||||
<% end %>
|
|
@ -5,56 +5,58 @@
|
|||
data-controller="sidebar"
|
||||
data-sidebar-user-id-value="<%= Current.user.id %>"
|
||||
data-sidebar-config-value="<%= sidebar_config.to_json %>">
|
||||
<button hidden data-controller="hotkey" data-hotkey="b" data-action="sidebar#toggleLeftPanel">Toggle accounts</button>
|
||||
<button hidden data-controller="hotkey" data-hotkey="l" data-action="sidebar#toggleRightPanel">Toggle chat</button>
|
||||
|
||||
<% unless controller_name == 'chats' %>
|
||||
<nav class="flex justify-between lg:justify-start lg:flex-col shrink-0 lg:w-[84px] p-3 lg:px-0 lg:py-4 lg:mr-3">
|
||||
<nav class="flex justify-between lg:justify-start lg:flex-col shrink-0 lg:w-[84px] p-3 lg:px-0 lg:py-4 lg:mr-3">
|
||||
<button data-action="sidebar#toggleLeftPanelMobile" class="lg:hidden inline-flex p-2 rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
|
||||
<%= icon("panel-left", color: "gray") %>
|
||||
</button>
|
||||
|
||||
<button data-action="sidebar#toggleLeftPanelMobile" class="lg:hidden inline-flex p-2 rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
|
||||
<%= icon("panel-left", color: "gray") %>
|
||||
</button>
|
||||
|
||||
<%# Mobile only account sidebar groups %>
|
||||
<%= tag.div class: class_names("hidden bg-surface z-20 absolute inset-0 h-dvh w-full p-4 overflow-y-auto transition-all duration-300 pt-safe"),
|
||||
<%# Mobile only account sidebar groups %>
|
||||
<%= tag.div class: class_names("hidden bg-surface z-20 absolute inset-0 h-dvh w-full p-4 overflow-y-auto transition-all duration-300 pt-safe"),
|
||||
data: { sidebar_target: "leftPanelMobile" } do %>
|
||||
<div id="account-sidebar-tabs">
|
||||
<div class="mb-4">
|
||||
<button data-action="sidebar#toggleLeftPanelMobile">
|
||||
<%= icon("x", color: "gray") %>
|
||||
</button>
|
||||
<div id="account-sidebar-tabs" class="pt-6">
|
||||
<div class="mb-4">
|
||||
<button data-action="sidebar#toggleLeftPanelMobile">
|
||||
<%= icon("x", color: "gray") %>
|
||||
</button>
|
||||
</div>
|
||||
<%= render "accounts/account_sidebar_tabs", family: Current.family %>
|
||||
</div>
|
||||
<%= render "accounts/account_sidebar_tabs", family: Current.family %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="lg:pl-2 lg:mb-3">
|
||||
<%= link_to root_path, class: "block" do %>
|
||||
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-0.5 hidden lg:block">
|
||||
<li>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Transactions", path: transactions_path, icon_key: "credit-card" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="lg:pl-2 lg:mt-auto lg:mx-auto">
|
||||
<div class="lg:hidden">
|
||||
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %>
|
||||
<div class="lg:pl-2 lg:mb-3">
|
||||
<%= link_to root_path, class: "block" do %>
|
||||
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block">
|
||||
<%= render "users/user_menu", user: Current.user %>
|
||||
<ul class="space-y-0.5 hidden lg:block">
|
||||
<li>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Transactions", path: transactions_path, icon_key: "credit-card" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="lg:pl-2 lg:mt-auto lg:mx-auto">
|
||||
<div class="lg:hidden">
|
||||
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block">
|
||||
<%= render "users/user_menu", user: Current.user %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<div class="flex justify-between lg:justify-normal grow overflow-y-auto">
|
||||
|
@ -110,7 +112,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<nav class="lg:hidden bg-surface md:bg-container shrink-0 z-10 pb-2 border border-tertiary pb-safe">
|
||||
<nav class="lg:hidden bg-surface md:bg-container shrink-0 z-10 pb-2 border-t border-tertiary pb-safe">
|
||||
<ul class="flex items-center justify-around gap-1">
|
||||
<li>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
|
||||
|
|
|
@ -6,17 +6,18 @@
|
|||
</head>
|
||||
|
||||
<body class="h-screen overflow-hidden lg:overflow-auto antialiased h-screen-safe ">
|
||||
<div class="fixed z-50 top-6 md:top-auto md:bottom-6 md:left-24 w-full md:w-80 mx-auto md:mx-0 md:right-auto mt-safe">
|
||||
<div class="fixed z-50 top-6 md:top-4 md:left-1/2 -translate-x-1/2 w-full md:w-80 mx-auto md:mx-0 md:right-auto mt-safe">
|
||||
<div id="notification-tray" class="space-y-1 w-full">
|
||||
<%= render_flash_notifications %>
|
||||
|
||||
<div id="cta"></div>
|
||||
|
||||
<% if Current.family&.syncing? %>
|
||||
<%= render "shared/syncing_notice" %>
|
||||
<% render "shared/notifications/loading", id: "syncing-notice", message: "Syncing accounts data..." %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= family_notifications_stream %>
|
||||
<%= family_stream %>
|
||||
|
||||
<%= turbo_frame_tag "modal" %>
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
<div class="fixed z-50 bottom-6 left-6">
|
||||
<div id="notification-tray" class="space-y-1">
|
||||
<%= render_flash_notifications %>
|
||||
|
||||
<% if Current.family&.syncing? %>
|
||||
<%= render "shared/syncing_notice" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= family_notifications_stream %>
|
||||
<%= family_stream %>
|
|
@ -1,18 +1,16 @@
|
|||
<%# locals: (name:, path:, icon_key:, is_custom: false) %>
|
||||
<%= link_to path, class: "space-y-1 lg:py-1 group block relative" do %>
|
||||
<% if page_active?(path) %>
|
||||
<%= tag.div class: "w-4 h-1 bg-nav-indicator rounded-bl-sm rounded-br-sm absolute top-0 left-1/2 -translate-x-1/2 lg:hidden" %>
|
||||
<% end %>
|
||||
<%= link_to path, class: "space-y-1 group block relative pb-1" do %>
|
||||
<div class="grow flex flex-col lg:flex-row gap-1 items-center">
|
||||
<%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => page_active?(path)) %>
|
||||
|
||||
<% icon_color = page_active?(path) ? "current" : "gray" %>
|
||||
|
||||
<%= tag.div class: class_names("w-8 h-8 flex items-center justify-center mx-auto rounded-lg", page_active?(path) ? "bg-container shadow-xs text-primary" : "group-hover:bg-container-hover text-secondary") do %>
|
||||
<%= is_custom ? icon_custom(icon_key, color: icon_color) : icon(icon_key, color: icon_color) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="grow flex justify-center">
|
||||
<div class="grow flex justify-center lg:pl-2">
|
||||
<%= tag.p class: class_names("font-medium text-[11px]", page_active?(path) ? "text-primary" : "text-secondary") do %>
|
||||
<%= name %>
|
||||
<% end %>
|
||||
|
|
|
@ -4,25 +4,32 @@
|
|||
<% model = chat && chat.persisted? ? [chat, Message.new] : Chat.new %>
|
||||
|
||||
<%= form_with model: model,
|
||||
class: "flex items-center gap-2 bg-container p-2 rounded-full shadow-sm border border-gray-100 h-11",
|
||||
class: "flex lg:flex-col gap-2 bg-container px-2 py-1.5 rounded-full lg:rounded-lg shadow-border-xs",
|
||||
data: { chat_target: "form" } do |f| %>
|
||||
|
||||
<%# In the future, this will be a dropdown with different AI models %>
|
||||
<%= f.hidden_field :ai_model, value: "gpt-4o" %>
|
||||
|
||||
<button type="button" class="flex-shrink-0 text-secondary p-1">
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
</button>
|
||||
|
||||
<%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint,
|
||||
class: "w-full border-0 focus:ring-0 text-sm resize-none bg-transparent py-0",
|
||||
class: "w-full border-0 focus:ring-0 text-sm resize-none px-1 bg-transparent",
|
||||
data: { chat_target: "input", action: "input->chat#autoResize keydown->chat#handleInputKeyDown" },
|
||||
rows: 1 %>
|
||||
|
||||
<button type="submit" class="flex-shrink-0 text-secondary bg-gray-50 rounded-full p-2">
|
||||
<%= lucide_icon("arrow-up", class: "w-4 h-4") %>
|
||||
</button>
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<div class="items-center gap-1 hidden lg:flex">
|
||||
<%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>
|
||||
<% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %>
|
||||
<button type="button" title="Coming soon" class="cursor-not-allowed w-8 h-8 flex justify-center items-center hover:bg-surface-hover rounded-lg">
|
||||
<%= icon(icon, color: "gray") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-8 h-8 flex justify-center items-center text-secondary hover:bg-surface-hover cursor-pointer rounded-lg">
|
||||
<%= icon("arrow-up") %>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="text-xs text-secondary mt-1">AI responses are informational only and are not financial advice.</p>
|
||||
</div>
|
||||
<p class="text-xs text-secondary">AI responses are informational only and are not financial advice.</p>
|
||||
</div>
|
27
app/views/rule/actions/_action.html.erb
Normal file
27
app/views/rule/actions/_action.html.erb
Normal file
|
@ -0,0 +1,27 @@
|
|||
<%# locals: (form:) %>
|
||||
|
||||
<% action = form.object %>
|
||||
<% rule = action.rule %>
|
||||
<% needs_value = action.executor.type == "select" %>
|
||||
|
||||
<li data-controller="rule--actions" data-rule--actions-action-executors-value="<%= rule.action_executors.to_json %>" class="flex items-center gap-3">
|
||||
<%= form.hidden_field :_destroy, value: false, data: { rule__actions_target: "destroyField" } %>
|
||||
|
||||
<div class="grow flex gap-2 items-center h-full">
|
||||
<div class="grow">
|
||||
<%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: "rule--actions#handleActionTypeChange" } %>
|
||||
</div>
|
||||
|
||||
<%= tag.div class: class_names("min-w-1/2 flex items-center gap-2", "hidden" => !needs_value),
|
||||
data: { rule__actions_target: "actionValue" } do %>
|
||||
<span class="font-medium uppercase text-xs">to</span>
|
||||
<%= form.select :value, action.options || [], {}, disabled: !needs_value %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-action="rule--actions#remove"
|
||||
data-rule--actions-destroy-param="<%= action.persisted? %>">
|
||||
<%= icon("trash-2", color: "gray", size: "sm") %>
|
||||
</button>
|
||||
</li>
|
40
app/views/rule/conditions/_condition.html.erb
Normal file
40
app/views/rule/conditions/_condition.html.erb
Normal file
|
@ -0,0 +1,40 @@
|
|||
<%# locals: (form:, show_prefix: true) %>
|
||||
|
||||
<% condition = form.object %>
|
||||
<% rule = condition.rule %>
|
||||
|
||||
<li data-controller="rule--conditions" data-rule--conditions-condition-filters-value="<%= rule.condition_filters.to_json %>" class="flex items-center gap-3">
|
||||
<% if form.index.to_i > 0 && show_prefix %>
|
||||
<div class="pl-4">
|
||||
<span class="font-medium uppercase text-xs">and</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grow flex gap-2 items-center h-full">
|
||||
<%= form.hidden_field :_destroy, value: false, data: { rule__conditions_target: "destroyField" } %>
|
||||
|
||||
<div class="w-2/5 shrink-0">
|
||||
<%= form.select :condition_type, rule.condition_filters.map { |filter| [ filter.label, filter.key ] }, {}, data: { action: "rule--conditions#handleConditionTypeChange" } %>
|
||||
</div>
|
||||
|
||||
<%= form.select :operator, condition.operators, { container_class: "w-fit min-w-36" }, data: { rule__conditions_target: "operatorSelect" } %>
|
||||
|
||||
<div data-rule--conditions-target="filterValue" class="grow">
|
||||
<% if condition.filter.type == "select" %>
|
||||
<%= form.select :value, condition.options, {} %>
|
||||
<% else %>
|
||||
<% if condition.filter.type == "number" %>
|
||||
<%= form.number_field :value, placeholder: "10", step: 0.01 %>
|
||||
<% else %>
|
||||
<%= form.text_field :value, placeholder: "Enter a value" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-action="rule--conditions#remove"
|
||||
data-rule--conditions-destroy-param="<%= condition.persisted? %>">
|
||||
<%= icon("trash-2", color: "gray", size: "sm") %>
|
||||
</button>
|
||||
</li>
|
44
app/views/rule/conditions/_condition_group.html.erb
Normal file
44
app/views/rule/conditions/_condition_group.html.erb
Normal file
|
@ -0,0 +1,44 @@
|
|||
<%# locals: (form:) %>
|
||||
|
||||
<% condition = form.object %>
|
||||
<% rule = condition.rule %>
|
||||
|
||||
<li data-controller="rule--conditions element-removal" class="border border-alpha-black-100 rounded-md p-4 space-y-3">
|
||||
|
||||
<%= form.hidden_field :condition_type, value: "compound" %>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<% unless form.index == 0 %>
|
||||
<div class="pl-2">
|
||||
<span class="font-medium uppercase text-xs">and</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-sm text-secondary">match</p>
|
||||
<%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>
|
||||
<p class="text-sm text-secondary">of the following conditions</p>
|
||||
</div>
|
||||
|
||||
<button type="button" data-action="element-removal#remove">
|
||||
<%= icon("trash-2", color: "gray", size: "sm") %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
|
||||
<template data-rule--conditions-target="subConditionTemplate">
|
||||
<%= form.fields_for :sub_conditions, Rule::Condition.new(parent: condition, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |scf| %>
|
||||
<%= render "rule/conditions/condition", form: scf %>
|
||||
<% end %>
|
||||
</template>
|
||||
|
||||
<ul data-rule--conditions-target="subConditionsList" class="space-y-3">
|
||||
<%= form.fields_for :sub_conditions do |scf| %>
|
||||
<%= render "rule/conditions/condition", form: scf, show_prefix: false %>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<button type="button" class="btn btn--ghost" data-action="rule--conditions#addSubCondition">
|
||||
<%= icon("plus", color: "gray", size: "sm") %>
|
||||
<span>Add condition</span>
|
||||
</button>
|
||||
</li>
|
20
app/views/rules/_category_rule_cta.html.erb
Normal file
20
app/views/rules/_category_rule_cta.html.erb
Normal file
|
@ -0,0 +1,20 @@
|
|||
<%# locals: (cta:) %>
|
||||
|
||||
<% message = "Updated to #{cta[:category_name]}" %>
|
||||
<% description = "You can create a rule to automatically categorize transactions like this one" %>
|
||||
|
||||
<%= render "shared/notifications/cta", message: message, description: description do %>
|
||||
<%= form_with model: Current.user, url: rule_prompt_settings_user_path(Current.user), method: :patch do |f| %>
|
||||
<div class="flex gap-2 items-center mb-3 -mt-1">
|
||||
<%= f.check_box :rule_prompts_disabled, class: "checkbox checkbox--light" %>
|
||||
<%= f.label :rule_prompts_disabled, "Don't show this again", class: "text-xs text-secondary" %>
|
||||
</div>
|
||||
|
||||
<%= f.hidden_field :rule_prompt_dismissed_at, value: Time.current %>
|
||||
|
||||
<%= tag.div class:"flex gap-2 justify-end" do %>
|
||||
<%= f.submit "Dismiss", class: "btn btn--secondary" %>
|
||||
<%= tag.a "Create rule", href: new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id]), class: "btn btn--primary", data: { turbo_frame: "modal" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
98
app/views/rules/_form.html.erb
Normal file
98
app/views/rules/_form.html.erb
Normal file
|
@ -0,0 +1,98 @@
|
|||
<%# locals: (rule:) %>
|
||||
|
||||
<%= styled_form_with model: rule, class: "space-y-4 w-[550px]",
|
||||
data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %>
|
||||
|
||||
<%= f.hidden_field :resource_type, value: rule.resource_type %>
|
||||
|
||||
<% if @rule.errors.any? %>
|
||||
<%= render "shared/form_errors", model: @rule %>
|
||||
<% end %>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-sm font-medium">If <%= rule.resource_type %></h3>
|
||||
|
||||
<%# Condition template, used by Stimulus controller to add new conditions dynamically %>
|
||||
<template data-rules-target="conditionGroupTemplate">
|
||||
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: "compound", operator: "and"), child_index: "IDX_PLACEHOLDER" do |cf| %>
|
||||
<%= render "rule/conditions/condition_group", form: cf %>
|
||||
<% end %>
|
||||
</template>
|
||||
|
||||
<%# Condition template, used by Stimulus controller to add new conditions dynamically %>
|
||||
<template data-rules-target="conditionTemplate">
|
||||
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |cf| %>
|
||||
<%= render "rule/conditions/condition", form: cf %>
|
||||
<% end %>
|
||||
</template>
|
||||
|
||||
<ul data-rules-target="conditionsList" class="space-y-3 mb-4">
|
||||
<%= f.fields_for :conditions do |cf| %>
|
||||
<% if cf.object.compound? %>
|
||||
<%= render "rule/conditions/condition_group", form: cf %>
|
||||
<% else %>
|
||||
<%= render "rule/conditions/condition", form: cf %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" data-action="rules#addCondition" class="btn btn--ghost">
|
||||
<%= icon("plus") %>
|
||||
<span>Add condition</span>
|
||||
</button>
|
||||
|
||||
<button type="button" data-action="rules#addConditionGroup" class="btn btn--ghost">
|
||||
<%= icon("boxes") %>
|
||||
<span>Add condition group</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-sm font-medium">Then</h3>
|
||||
|
||||
<%# Action template, used by Stimulus controller to add new actions dynamically %>
|
||||
<template data-rules-target="actionTemplate">
|
||||
<%= f.fields_for :actions, Rule::Action.new(rule: rule, action_type: rule.action_executors.first.key), child_index: "IDX_PLACEHOLDER" do |af| %>
|
||||
<%= render "rule/actions/action", form: af %>
|
||||
<% end %>
|
||||
</template>
|
||||
|
||||
<ul data-rules-target="actionsList" class="space-y-3">
|
||||
<%= f.fields_for :actions do |af| %>
|
||||
<%= render "rule/actions/action", form: af %>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="rules#addAction"
|
||||
class="btn btn--ghost">
|
||||
<%= icon("plus") %>
|
||||
<span>Add action</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-sm font-medium">Apply this</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %>
|
||||
<%= f.label :effective_date_enabled_false, "To all past and future #{rule.resource_type}s", class: "text-sm" %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.radio_button :effective_date_enabled, true, checked: rule.effective_date.present? %>
|
||||
<%= f.label :effective_date_enabled_true, "Starting from", class: "text-sm" %>
|
||||
</div>
|
||||
|
||||
<%= f.date_field :effective_date, container_class: "w-fit", data: { rules_target: "effectiveDateInput" } %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%= f.submit %>
|
||||
<% end %>
|
41
app/views/rules/_rule.html.erb
Normal file
41
app/views/rules/_rule.html.erb
Normal file
|
@ -0,0 +1,41 @@
|
|||
<%# locals: (rule:) %>
|
||||
|
||||
<div class="flex justify-between items-center gap-4 bg-white shadow-border-xs rounded-md p-4">
|
||||
<div class="text-sm space-y-1.5">
|
||||
<p class="flex items-center flex-wrap gap-1.5">
|
||||
<span class="px-2 py-1 border border-alpha-black-200 rounded-full">
|
||||
<%= rule.actions.first.executor.label %>
|
||||
</span>
|
||||
|
||||
<% if rule.actions.count > 1 %>
|
||||
and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<p class="flex items-center flex-wrap gap-1.5">
|
||||
<% if rule.effective_date.nil? %>
|
||||
To all past and future <%= rule.resource_type.pluralize %>
|
||||
<% else %>
|
||||
To all <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<%= render "shared/toggle_form", model: rule, attribute: :active %>
|
||||
|
||||
<%= contextual_menu icon: "more-vertical", id: "chat-menu" do %>
|
||||
<%= contextual_menu_item "Edit", url: edit_rule_path(rule), icon: "pencil", turbo_frame: "modal" %>
|
||||
|
||||
<%= contextual_menu_item "Re-apply rule", url: confirm_rule_path(rule), turbo_frame: "modal", icon: "refresh-cw" %>
|
||||
|
||||
<% turbo_confirm = {
|
||||
title: "Delete rule",
|
||||
body: "Are you sure you want to delete this rule? Data affected by this rule will no longer be automatically updated. This action cannot be undone.",
|
||||
accept: "Delete rule",
|
||||
} %>
|
||||
|
||||
<%= contextual_menu_destructive_item "Delete", rule_path(rule), turbo_confirm: turbo_confirm %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
20
app/views/rules/confirm.html.erb
Normal file
20
app/views/rules/confirm.html.erb
Normal file
|
@ -0,0 +1,20 @@
|
|||
<%= modal(reload_on_close: true) do %>
|
||||
<div class="space-y-4 p-4 max-w-[400px]">
|
||||
<div>
|
||||
<div class="flex justify-between mb-2 gap-4">
|
||||
<h3 class="font-medium text-md">Confirm changes</h3>
|
||||
<button data-action="mousedown->modal#close">
|
||||
<%= lucide_icon("x", class: "w-5 h-5 shrink-0 text-secondary") %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-secondary text-sm">
|
||||
<p>
|
||||
You are about to apply this rule to
|
||||
<span class="text-primary font-medium"><%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %></span>
|
||||
that meet the specified rule criteria. Please confirm if you wish to proceed with this change.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<%= button_to "Confirm changes", apply_rule_path(@rule), class: "btn btn--primary w-full justify-center", data: { turbo_frame: "_top"} %>
|
||||
</div>
|
||||
<% end %>
|
5
app/views/rules/edit.html.erb
Normal file
5
app/views/rules/edit.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
|||
<%= link_to "Back to rules", rules_path %>
|
||||
|
||||
<%= modal_form_wrapper title: "Edit #{@rule.resource_type} rule" do %>
|
||||
<%= render "rules/form", rule: @rule %>
|
||||
<% end %>
|
61
app/views/rules/index.html.erb
Normal file
61
app/views/rules/index.html.erb
Normal file
|
@ -0,0 +1,61 @@
|
|||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-primary text-xl font-medium">Rules</h1>
|
||||
|
||||
<% turbo_confirm = {
|
||||
title: "Delete all rules",
|
||||
body: "Are you sure you want to delete all rules? This action cannot be undone.",
|
||||
accept: "Delete all rules",
|
||||
} %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if @rules.any? %>
|
||||
<%= contextual_menu do %>
|
||||
<%= contextual_menu_destructive_item "Delete all rules", destroy_all_rules_path, turbo_confirm: turbo_confirm %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_rule_path(resource_type: "transaction"), class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon "plus", class: "w-5 h-5" %>
|
||||
<p>New rule</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<div class="flex items-center gap-2 mb-2 py-4">
|
||||
<%= lucide_icon("circle-alert", class: "w-4 h-4 text-secondary") %>
|
||||
<p class="text-sm text-secondary">
|
||||
AI-enabled rule actions will cost money. Be sure to filter as narrowly as possible to avoid unnecessary costs.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-white shadow-border-xs rounded-xl p-4">
|
||||
|
||||
<% if @rules.any? %>
|
||||
<div class="rounded-xl bg-gray-25 space-y-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||
<p>Rules</p>
|
||||
<span class="text-subdued">·</span>
|
||||
<p><%= @rules.count %></p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 p-1">
|
||||
<%= render @rules %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||
<p class="text-sm text-primary font-medium mb-1">No rules yet</p>
|
||||
<p class="text-sm text-secondary mb-4">Set up rules to perform actions to your transactions and other data on every account sync.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to new_rule_path(resource_type: "transaction"), class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span>New rule</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
5
app/views/rules/new.html.erb
Normal file
5
app/views/rules/new.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
|||
<%= link_to "Back to rules", rules_path %>
|
||||
|
||||
<%= modal_form_wrapper title: "New #{@rule.resource_type} rule" do %>
|
||||
<%= render "rules/form", rule: @rule %>
|
||||
<% end %>
|
|
@ -61,7 +61,10 @@
|
|||
<%= render "settings/settings_nav_item", name: t(".categories_label"), path: categories_path, icon: "shapes" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: merchants_path, icon: "store" %>
|
||||
<%= render "settings/settings_nav_item", name: "Rules", path: rules_path, icon: "git-branch" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: family_merchants_path, icon: "store" %>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
@ -87,72 +90,63 @@
|
|||
</section>
|
||||
</nav>
|
||||
<nav class="space-y-4 overflow-y-auto md:hidden" id="mobile-settings-nav" data-preserve-scroll data-controller="preserve-scroll">
|
||||
|
||||
<ul class="flex space-y-1">
|
||||
|
||||
<ul class="flex space-y-1">
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".profile_label"), path: settings_profile_path, icon: "circle-user" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".security_label"), path: settings_security_path, icon: "shield-check" %>
|
||||
</li>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".profile_label"), path: settings_profile_path, icon: "circle-user" %>
|
||||
<%= render "settings/settings_nav_item", name: t(".self_hosting_label"), path: settings_hosting_path, icon: "database" %>
|
||||
</li>
|
||||
|
||||
<% end %>
|
||||
<% unless self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".security_label"), path: settings_security_path, icon: "shield-check" %>
|
||||
</li>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".self_hosting_label"), path: settings_hosting_path, icon: "database" %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% unless self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".accounts_label"), path: accounts_path, icon: "layers" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".imports_label"), path: imports_path, icon: "download" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".tags_label"), path: tags_path, icon: "tags" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".categories_label"), path: categories_path, icon: "shapes" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: merchants_path, icon: "store" %>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".whats_new_label"), path: changelog_path, icon: "box" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".feedback_label"), path: feedback_path, icon: "megaphone" %>
|
||||
<%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".accounts_label"), path: accounts_path, icon: "layers" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".imports_label"), path: imports_path, icon: "download" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".tags_label"), path: tags_path, icon: "tags" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".categories_label"), path: categories_path, icon: "shapes" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".merchants_label"), path: family_merchants_path, icon: "store" %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".whats_new_label"), path: changelog_path, icon: "box" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".feedback_label"), path: feedback_path, icon: "megaphone" %>
|
||||
</li>
|
||||
|
||||
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 btn btn--ghost text-destructive w-full" do %>
|
||||
<%= lucide_icon("log-out", class: "w-5 h-5 shrink-0") %>
|
||||
<span><%= t(".logout") %></span>
|
||||
<% end %>
|
||||
|
||||
</ul>
|
||||
|
||||
</ul>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
<%# locals: (user:) %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm"><%= t(".title") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".description") %></p>
|
||||
<% if self_hosted? %>
|
||||
<p class="text-xs italic text-secondary"><%= t(".self_host_disclaimer") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with model: user,
|
||||
data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } do |form| %>
|
||||
<div class="relative inline-block select-none">
|
||||
<%= form.hidden_field :redirect_to, value: "preferences" %>
|
||||
<%= form.fields_for :family do |family_form| %>
|
||||
<%= family_form.check_box :data_enrichment_enabled, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input" %>
|
||||
<%= family_form.label :data_enrichment_enabled, " ".html_safe, class: "switch" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
|
@ -41,10 +41,6 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".data"), subtitle: t(".data_subtitle") do %>
|
||||
<%= render "settings/preferences/data_enrichment_settings", user: @user %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
|
||||
<div>
|
||||
<%= styled_form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", data: { controller: "auto-submit-form" } do |form| %>
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
<%= t(".body_html") %>
|
||||
</div>
|
||||
</div>
|
||||
<button id="turbo-confirm-accept" class="w-full text-red-600 rounded-xl text-center p-[10px] border mb-2" value="confirm"><%= t(".accept") %></button>
|
||||
<button class="w-full rounded-xl text-center p-[10px] border" value="cancel"><%= t(".cancel") %></button>
|
||||
<button id="turbo-confirm-accept" class="btn btn--outline-destructive justify-center w-full mb-2" value="confirm"><%= t(".accept") %></button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
|
|
@ -1,8 +1,19 @@
|
|||
<%# locals: (content:, classes:) -%>
|
||||
<%# locals: (content:, reload_on_close:, overflow_visible: false) -%>
|
||||
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<dialog class="md:m-auto bg-container shadow-border-xs rounded-none md:rounded-2xl max-w-screen max-h-screen md:max-w-max w-full h-full md:h-fit md:w-auto overflow-visible <%= classes %>" data-controller="modal" data-action="mousedown->modal#clickOutside">
|
||||
<%= tag.dialog(
|
||||
class: class_names(
|
||||
"focus:outline-none md:m-auto bg-container rounded-none md:rounded-2xl max-w-screen max-h-screen md:max-w-max w-full h-full md:h-fit md:w-auto shadow-border-xs",
|
||||
overflow_visible ? "overflow-visible" : "overflow-auto"
|
||||
),
|
||||
data: {
|
||||
controller: "modal",
|
||||
action: "mousedown->modal#clickOutside",
|
||||
modal_reload_on_close_value: reload_on_close
|
||||
}
|
||||
) do %>
|
||||
<div class="flex flex-col h-full md:h-auto mt-safe">
|
||||
<%= content %>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<%# locals: (title:, content:, subtitle: nil) %>
|
||||
<%# locals: (title:, content:, subtitle: nil, overflow_visible: false) %>
|
||||
|
||||
<%= modal do %>
|
||||
<%= modal overflow_visible: overflow_visible do %>
|
||||
<article class="mx-auto w-full p-4 space-y-4 md:min-w-[450px]">
|
||||
<div class="space-y-2">
|
||||
<header class="flex justify-between items-center">
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
<%# locals: (message:, type: "notice", **_opts) %>
|
||||
|
||||
<% type = type.to_sym %>
|
||||
<% action = "animationend->element-removal#remove" if type == :notice %>
|
||||
|
||||
<%= tag.div class: "flex gap-3 rounded-lg border bg-container p-4 group max-w-80 shadow-xs border-alpha-black-25 mx-auto md:mx-0",
|
||||
data: {
|
||||
controller: "element-removal",
|
||||
action: action
|
||||
} do %>
|
||||
|
||||
<div class="h-5 w-5 shrink-0 p-px text-white">
|
||||
<% case type %>
|
||||
<% when :notice %>
|
||||
<div class="flex h-full items-center justify-center rounded-full bg-success">
|
||||
<%= lucide_icon "check", class: "w-3 h-3" %>
|
||||
</div>
|
||||
<% when :alert %>
|
||||
<div class="flex h-full items-center justify-center rounded-full bg-destructive">
|
||||
<%= lucide_icon "x", class: "w-3 h-3" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= tag.p message, class: "text-primary text-sm font-medium" %>
|
||||
|
||||
<div class="ml-auto">
|
||||
<% if type.to_sym == :notice %>
|
||||
<div class="h-5 shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
|
||||
<path d="M18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10ZM3.6 10C3.6 13.5346 6.46538 16.4 10 16.4C13.5346 16.4 16.4 13.5346 16.4 10C16.4 6.46538 13.5346 3.6 10 3.6C6.46538 3.6 3.6 6.46538 3.6 10Z" fill="#E5E5E5" />
|
||||
<circle class="origin-center -rotate-90 animate-[stroke-fill_2.2s_300ms_forwards]" stroke="#141414" stroke-opacity="0.4" r="7.2" cx="10" cy="10" stroke-dasharray="43.9822971503" stroke-dashoffset="43.9822971503" />
|
||||
</svg>
|
||||
<div class="absolute -top-2 -right-2">
|
||||
<%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-container text-subdued cursor-pointer", data: { action: "click->element-removal#remove" } %>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif type.to_sym == :alert %>
|
||||
<%= lucide_icon "x", data: { action: "click->element-removal#remove" }, class: "w-5 h-5 text-secondary hover:text-gray-600 cursor-pointer" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
|
@ -1,7 +0,0 @@
|
|||
<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-container p-4 group w-full shadow-xs border-alpha-black-25" do %>
|
||||
<div class="h-5 w-5 shrink-0 p-px text-white">
|
||||
<%= lucide_icon "loader", class: "w-5 h-5 text-secondary animate-pulse" %>
|
||||
</div>
|
||||
|
||||
<%= tag.p t(".syncing"), class: "text-primary text-sm font-medium" %>
|
||||
<% end %>
|
11
app/views/shared/_toggle_form.html.erb
Normal file
11
app/views/shared/_toggle_form.html.erb
Normal file
|
@ -0,0 +1,11 @@
|
|||
<%# locals: (model:, attribute:, turbo_frame: nil) %>
|
||||
|
||||
<%= form_with model: model,
|
||||
namespace: model.id,
|
||||
class: "flex items-center",
|
||||
data: { controller: "auto-submit-form", turbo_frame: turbo_frame } do |form| %>
|
||||
<div class="relative inline-block select-none">
|
||||
<%= form.check_box attribute, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %>
|
||||
<%= form.label attribute, " ".html_safe, class: "switch" %>
|
||||
</div>
|
||||
<% end %>
|
16
app/views/shared/notifications/_alert.html.erb
Normal file
16
app/views/shared/notifications/_alert.html.erb
Normal file
|
@ -0,0 +1,16 @@
|
|||
<%# locals: (message:) %>
|
||||
|
||||
<%= tag.div class: "flex gap-3 rounded-lg bg-white p-4 group max-w-80 shadow-border-lg",
|
||||
data: { controller: "element-removal" } do %>
|
||||
<div class="h-5 w-5 shrink-0 p-px text-white">
|
||||
<div class="flex h-full items-center justify-center rounded-full bg-destructive">
|
||||
<%= lucide_icon "x", class: "w-3 h-3" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= tag.p message, class: "text-primary text-sm font-medium" %>
|
||||
|
||||
<div class="ml-auto">
|
||||
<%= lucide_icon "x", data: { action: "click->element-removal#remove" }, class: "w-5 h-5 text-secondary hover:text-gray-600 cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
20
app/views/shared/notifications/_cta.html.erb
Normal file
20
app/views/shared/notifications/_cta.html.erb
Normal file
|
@ -0,0 +1,20 @@
|
|||
<%# locals: (message:, description:) %>
|
||||
|
||||
<div id="cta">
|
||||
<%= tag.div class: "relative flex gap-3 rounded-lg bg-white p-4 group max-w-80 shadow-border-xs", data: { controller: "element-removal" } do %>
|
||||
<div class="h-5 w-5 shrink-0 p-px text-white">
|
||||
<div class="flex h-full items-center justify-center rounded-full bg-success">
|
||||
<%= lucide_icon "check", class: "w-3 h-3" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<%= tag.p message, class: "text-primary text-sm font-medium" %>
|
||||
<%= tag.p description, class: "text-secondary text-sm" %>
|
||||
</div>
|
||||
|
||||
<%= yield %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
9
app/views/shared/notifications/_loading.html.erb
Normal file
9
app/views/shared/notifications/_loading.html.erb
Normal file
|
@ -0,0 +1,9 @@
|
|||
<%# locals: (message:, id: nil) %>
|
||||
|
||||
<%= tag.div id: id, class: "flex gap-3 rounded-lg bg-white p-4 group w-full shadow-border-xs" do %>
|
||||
<div class="h-5 w-5 shrink-0 p-px text-white">
|
||||
<%= lucide_icon "loader", class: "w-5 h-5 text-secondary animate-pulse" %>
|
||||
</div>
|
||||
|
||||
<%= tag.p message, class: "text-primary text-sm font-medium" %>
|
||||
<% end %>
|
48
app/views/shared/notifications/_notice.html.erb
Normal file
48
app/views/shared/notifications/_notice.html.erb
Normal file
|
@ -0,0 +1,48 @@
|
|||
<%# locals: (message:, description: nil, cta: nil) %>
|
||||
|
||||
<%= tag.div class: "relative flex gap-3 rounded-lg bg-white p-4 group max-w-80 shadow-border-xs",
|
||||
data: {
|
||||
controller: "element-removal",
|
||||
action: "animationend->element-removal#remove"
|
||||
} do %>
|
||||
|
||||
<div class="h-5 w-5 shrink-0 p-px text-white">
|
||||
<div class="flex h-full items-center justify-center rounded-full bg-success">
|
||||
<%= lucide_icon "check", class: "w-3 h-3" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<%= tag.p message, class: "text-primary text-sm font-medium" %>
|
||||
|
||||
<% if description %>
|
||||
<%= tag.p description, class: "text-secondary text-sm" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if cta %>
|
||||
<%= tag.div class:"flex gap-2 justify-end" do %>
|
||||
<%= tag.button cta[:decline][:label], class: "btn btn--secondary", data: { action: "click->element-removal#remove" } %>
|
||||
<%= tag.a cta[:accept][:label], href: cta[:accept][:href], class: "btn btn--primary" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<div class="h-5 shrink-0">
|
||||
<% unless cta %>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
|
||||
<path d="M18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10ZM3.6 10C3.6 13.5346 6.46538 16.4 10 16.4C13.5346 16.4 16.4 13.5346 16.4 10C16.4 6.46538 13.5346 3.6 10 3.6C6.46538 3.6 3.6 6.46538 3.6 10Z" fill="#E5E5E5" />
|
||||
<circle class="origin-center -rotate-90 animate-stroke-fill" stroke="#141414" stroke-opacity="0.4" r="7.2" cx="10" cy="10" stroke-dasharray="43.9822971503" stroke-dashoffset="43.9822971503" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% unless cta %>
|
||||
<div class="absolute -top-2 -right-2">
|
||||
<%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-white text-subdued cursor-pointer", data: { action: "click->element-removal#remove" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -13,11 +13,11 @@
|
|||
<div class="max-w-full">
|
||||
<%= 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">
|
||||
<%= entry.display_name.first.upcase %>
|
||||
<%= entry.name.first.upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate">
|
||||
<%= link_to entry.display_name,
|
||||
<%= link_to entry.name,
|
||||
entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<%= drawer(reload_on_close: true) do %>
|
||||
<%= drawer do %>
|
||||
<%= render "trades/header", entry: @entry %>
|
||||
|
||||
<% trade = @entry.trade %>
|
||||
|
|
|
@ -20,4 +20,4 @@
|
|||
<span class="text-sm text-secondary">
|
||||
<%= I18n.l(entry.date, format: :long) %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -21,13 +21,13 @@
|
|||
|
||||
<div class="max-w-full">
|
||||
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
||||
<% if transaction.merchant&.icon_url %>
|
||||
<%= image_tag transaction.merchant.icon_url,
|
||||
<% if transaction.merchant&.logo_url.present? %>
|
||||
<%= image_tag transaction.merchant.logo_url,
|
||||
class: "w-6 h-6 rounded-full",
|
||||
loading: "lazy" %>
|
||||
<% else %>
|
||||
<%= render "shared/circle_logo",
|
||||
name: entry.display_name,
|
||||
name: entry.name,
|
||||
size: "sm" %>
|
||||
<% end %>
|
||||
|
||||
|
@ -35,7 +35,7 @@
|
|||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= link_to(
|
||||
transaction.transfer? ? transaction.transfer.name : entry.display_name,
|
||||
transaction.transfer? ? transaction.transfer.name : entry.name,
|
||||
transaction.transfer? ? transfer_path(transaction.transfer) : entry_path(entry),
|
||||
data: {
|
||||
turbo_frame: "drawer",
|
||||
|
|
|
@ -4,9 +4,14 @@
|
|||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= contextual_menu do %>
|
||||
<% if Rails.env.development? %>
|
||||
<%= button_to "Dev only: Sync all", sync_all_accounts_path, class: "btn btn--ghost w-full" %>
|
||||
<% end %>
|
||||
<%= contextual_menu_item "New rule", url: new_rule_path(resource_type: "transaction"), icon: "plus", turbo_frame: :modal %>
|
||||
<%= contextual_menu_item "Edit rules", url: rules_path, icon: "git-branch", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_merchants"), family_merchants_path, icon: "store", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
|
||||
<%= contextual_menu_modal_action_item t(".import"), new_import_path, icon: "download", turbo_frame: "modal", class_name: "md:!hidden" %>
|
||||
<% end %>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<%= lucide_icon("search", class: "w-5 h-5 text-secondary absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
|
||||
</div>
|
||||
<div class="my-2" id="list" data-list-filter-target="list">
|
||||
<% Current.family.merchants.alphabetically.each do |merchant| %>
|
||||
<% Current.family.assigned_merchants.alphabetically.each do |merchant| %>
|
||||
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= merchant.name %>">
|
||||
<%= form.check_box :merchants,
|
||||
{
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<%= drawer(reload_on_close: true) do %>
|
||||
<%= drawer do %>
|
||||
<%= render "transactions/header", entry: @entry %>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
@ -10,7 +10,7 @@
|
|||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
|
||||
<%= f.text_field @entry.enriched_at.present? ? :enriched_name : :name,
|
||||
<%= f.text_field :name,
|
||||
label: t(".name_label"),
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
|
@ -66,7 +66,7 @@
|
|||
<%= f.fields_for :entryable do |ef| %>
|
||||
|
||||
<%= ef.collection_select :merchant_id,
|
||||
Current.family.merchants.alphabetically,
|
||||
Current.family.assigned_merchants.alphabetically,
|
||||
:id, :name,
|
||||
{ include_blank: t(".none"),
|
||||
label: t(".merchant_label"),
|
||||
|
@ -81,7 +81,7 @@
|
|||
label: t(".tags_label"),
|
||||
container_class: "h-40"
|
||||
},
|
||||
{ "data-auto-submit-form-target": "auto" } %>
|
||||
{ "data-auto-submit-form-target": "auto" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<% end %>
|
||||
|
||||
<div class="truncate text-primary">
|
||||
<%= link_to entry.display_name,
|
||||
<%= link_to entry.name,
|
||||
entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<% entry, account = @entry, @entry.account %>
|
||||
|
||||
<%= drawer(reload_on_close: true) do %>
|
||||
<%= drawer do %>
|
||||
<%= render "valuations/header", entry: %>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue