feat(i18n): extract hardcoded English strings to locale files (#1806)

* Extract hardcoded strings to i18n

Replace numerous hardcoded English strings with I18n lookups (t / I18n.t) across controllers, views, helpers, and components, and convert model validation error messages to symbol keys. Added multiple locale files under config/locales for models and views. This centralizes user-facing notices/alerts, UI text, import/validation messages, and prepares the app for localization and easier translation maintenance.

* Update en.yml

* Update preview-cleanup.yml

* Revert "Update preview-cleanup.yml"

This reverts commit 1ba6d3c34c.

* test: align i18n assertions with translated messages

* Standardize balance error key and tweak locales

Replace SophtronAccount's :requires_balance error key with :no_balance and update related locale strings for sophtron, plaid, and simplefin accounts to use the new key and clearer copy. Also switch the QIF upload redirect notice to use a relative translation key (t('.qif_uploaded')), remove an unused SSO providers help line, and fix a trailing-newline/whitespace issue in the subscriptions locale. These changes standardize validation keys and improve translation consistency and messaging.

---------

Co-authored-by: KiloClaw <kiloclaw@openclaw.ai>
This commit is contained in:
Brendon Scheiber
2026-05-17 09:52:49 +02:00
committed by GitHub
parent d74b1b2a11
commit 0c126b1674
201 changed files with 1845 additions and 835 deletions

View File

@@ -21,7 +21,7 @@
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium privacy-sensitive"><%= end_balance_money.format %></span>
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
<%= render DS::Tooltip.new(text: t(".balance_tooltip"), placement: "left", size: "sm") %>
</div>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
</div>
@@ -32,7 +32,7 @@
<% if balance %>
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
<% else %>
<p class="text-sm text-secondary">No balance data available for this date</p>
<p class="text-sm text-secondary"><%= t(".no_balance_data") %></p>
<% end %>
</div>
</details>

View File

@@ -50,7 +50,7 @@
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm">No data available</p>
<p class="text-secondary text-sm"><%= t(".no_data_available") %></p>
</div>
<% end %>
</div>

View File

@@ -58,7 +58,7 @@ class CategoriesController < ApplicationController
def destroy_all
Current.family.categories.destroy_all
redirect_back_or_to categories_path, notice: "All categories deleted"
redirect_back_or_to categories_path, notice: t(".success")
end
def bootstrap

View File

@@ -29,7 +29,7 @@ class ChatsController < ApplicationController
@chat.update!(chat_params)
respond_to do |format|
format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" }
format.html { redirect_back_or_to chat_path(@chat), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) }
end
end
@@ -38,7 +38,7 @@ class ChatsController < ApplicationController
@chat.destroy
clear_last_viewed_chat
redirect_to chats_path, notice: "Chat was successfully deleted"
redirect_to chats_path, notice: t(".notice")
end
def retry

View File

@@ -23,7 +23,7 @@ module SelfHostable
if controller_name == "pages" && action_name == "redis_configuration_error"
# If Redis is now working, redirect to home
if redis_connected?
redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Sure application."
redirect_to root_path, notice: t("concerns.self_hostable.redis_configured")
end
return

View File

@@ -42,7 +42,7 @@ class HoldingsController < ApplicationController
@holding.destroy_holding_and_entries!
flash[:notice] = t(".success")
else
flash[:alert] = "You cannot delete this holding"
flash[:alert] = t(".cannot_delete")
end
respond_to do |format|

View File

@@ -6,7 +6,7 @@ class Import::CleansController < ApplicationController
def show
unless @import.configured?
redirect_path = @import.is_a?(PdfImport) ? import_path(@import) : import_configuration_path(@import)
return redirect_to redirect_path, alert: "Please configure your import before proceeding."
return redirect_to redirect_path, alert: t(".not_configured")
end
rows = @import.rows_ordered

View File

@@ -8,7 +8,7 @@ class Import::ConfirmsController < ApplicationController
return redirect_to import_path(@import)
end
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
redirect_to import_clean_path(@import), alert: t(".invalid_data") unless @import.cleaned?
end
private

View File

@@ -54,7 +54,7 @@ class Import::QifCategorySelectionsController < ApplicationController
@import.sync_mappings unless format_changed
end
redirect_to import_clean_path(@import), notice: "Categories and tags saved."
redirect_to import_clean_path(@import), notice: t(".success")
end
private

View File

@@ -85,7 +85,7 @@ class Import::UploadsController < ApplicationController
@import.sync_mappings
end
redirect_to import_qif_category_selection_path(@import), notice: "QIF file uploaded successfully."
redirect_to import_qif_category_selection_path(@import), notice: t(".qif_uploaded")
end
def csv_str

View File

@@ -22,9 +22,9 @@ class ImportsController < ApplicationController
def publish
@import.publish_later
redirect_to import_path(@import), notice: "Your import has started in the background."
redirect_to import_path(@import), notice: t(".started")
rescue Import::MaxRowCountExceededError
redirect_back_or_to import_path(@import), alert: "Your import exceeds the maximum row count of #{@import.max_row_count}."
redirect_back_or_to import_path(@import), alert: t(".max_rows_exceeded", max: @import.max_row_count)
end
def index
@@ -112,22 +112,22 @@ class ImportsController < ApplicationController
def revert
@import.revert_later
redirect_to imports_path, notice: "Import is reverting in the background."
redirect_to imports_path, notice: t(".started")
end
def apply_template
if @import.suggested_template
@import.apply_template!(@import.suggested_template)
redirect_to import_configuration_path(@import), notice: "Template applied."
redirect_to import_configuration_path(@import), notice: t(".template_applied")
else
redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import."
redirect_to import_configuration_path(@import), alert: t(".no_template_found")
end
end
def destroy
@import.destroy
redirect_to imports_path, notice: "Your import has been deleted."
redirect_to imports_path, notice: t(".deleted")
end
private

View File

@@ -8,13 +8,13 @@ class InviteCodesController < ApplicationController
def create
InviteCode.generate!
redirect_back_or_to invite_codes_path, notice: "Code generated"
redirect_back_or_to invite_codes_path, notice: t(".success")
end
def destroy
code = InviteCode.find(params[:id])
code.destroy
redirect_back_or_to invite_codes_path, notice: "Code deleted"
redirect_back_or_to invite_codes_path, notice: t(".success")
end
private

View File

@@ -7,7 +7,7 @@ class OidcAccountsController < ApplicationController
@pending_auth = session[:pending_oidc_auth]
if @pending_auth.nil?
redirect_to new_session_path, alert: "No pending OIDC authentication found"
redirect_to new_session_path, alert: t(".no_pending_oidc")
return
end
@@ -26,7 +26,7 @@ class OidcAccountsController < ApplicationController
@pending_auth = session[:pending_oidc_auth]
if @pending_auth.nil?
redirect_to new_session_path, alert: "No pending OIDC authentication found"
redirect_to new_session_path, alert: t(".no_pending_oidc")
return
end
@@ -75,7 +75,7 @@ class OidcAccountsController < ApplicationController
@pending_auth = session[:pending_oidc_auth]
if @pending_auth.nil?
redirect_to new_session_path, alert: "No pending OIDC authentication found"
redirect_to new_session_path, alert: t(".no_pending_oidc")
return
end
@@ -91,7 +91,7 @@ class OidcAccountsController < ApplicationController
@pending_auth = session[:pending_oidc_auth]
if @pending_auth.nil?
redirect_to new_session_path, alert: "No pending OIDC authentication found"
redirect_to new_session_path, alert: t(".no_pending_oidc")
return
end
@@ -104,7 +104,7 @@ class OidcAccountsController < ApplicationController
# domain is not allowed, block JIT account creation—unless there's a
# pending invitation for this user.
unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email))
redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator."
redirect_to new_session_path, alert: t(".account_creation_disabled")
return
end
@@ -164,7 +164,7 @@ class OidcAccountsController < ApplicationController
elsif accept_pending_invitation_for(@user)
t("invitations.accept_choice.joined_household")
else
"Welcome! Your account has been created."
t(".account_created")
end
redirect_to root_path, notice: notice
else

View File

@@ -21,7 +21,7 @@ class PendingDuplicateMergesController < ApplicationController
# Manually merge the pending transaction with the selected posted transaction
unless merge_params[:posted_entry_id].present?
redirect_back_or_to transactions_path, alert: "Please select a posted transaction to merge with"
redirect_back_or_to transactions_path, alert: t(".no_posted_selected")
return
end
@@ -29,7 +29,7 @@ class PendingDuplicateMergesController < ApplicationController
posted_entry = find_eligible_posted_entry(merge_params[:posted_entry_id])
unless posted_entry
redirect_back_or_to transactions_path, alert: "Invalid transaction selected for merge"
redirect_back_or_to transactions_path, alert: t(".invalid_transaction")
return
end
@@ -48,9 +48,9 @@ class PendingDuplicateMergesController < ApplicationController
# Immediately merge
if @transaction.merge_with_duplicate!
redirect_back_or_to transactions_path, notice: "Pending transaction merged with posted transaction"
redirect_back_or_to transactions_path, notice: t(".merge_success")
else
redirect_back_or_to transactions_path, alert: "Could not merge transactions"
redirect_back_or_to transactions_path, alert: t(".merge_failed")
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed,
ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e
@@ -64,7 +64,7 @@ class PendingDuplicateMergesController < ApplicationController
@transaction = entry.entryable
unless @transaction.is_a?(Transaction) && @transaction.pending?
redirect_to transactions_path, alert: "This feature is only available for pending transactions"
redirect_to transactions_path, alert: t("pending_duplicate_merges.set_transaction.pending_only")
end
end

View File

@@ -62,7 +62,7 @@ class PlaidItemsController < ApplicationController
.select { |pa| pa.account_provider.nil? && pa.account.nil? } # Not linked via new or legacy system
if @available_plaid_accounts.empty?
redirect_to account_path(@account), alert: "No available Plaid accounts to link. Please connect a new Plaid account first."
redirect_to account_path(@account), alert: t(".no_available_accounts")
end
end
@@ -72,13 +72,13 @@ class PlaidItemsController < ApplicationController
# Verify the Plaid account belongs to this family's Plaid items
unless Current.family.plaid_items.include?(plaid_account.plaid_item)
redirect_to account_path(@account), alert: "Invalid Plaid account selected"
redirect_to account_path(@account), alert: t(".invalid_account")
return
end
# Verify the Plaid account is not already linked
if plaid_account.account_provider.present? || plaid_account.account.present?
redirect_to account_path(@account), alert: "This Plaid account is already linked"
redirect_to account_path(@account), alert: t(".already_linked")
return
end
@@ -88,7 +88,7 @@ class PlaidItemsController < ApplicationController
provider: plaid_account
)
redirect_to accounts_path, notice: "Account successfully linked to Plaid"
redirect_to accounts_path, notice: t(".success")
end
private

View File

@@ -86,8 +86,8 @@ class RulesController < ApplicationController
def update
if @rule.update(rule_params)
respond_to do |format|
format.html { redirect_back_or_to rules_path, notice: "Rule updated" }
format.turbo_stream { stream_redirect_back_or_to rules_path, notice: "Rule updated" }
format.html { redirect_back_or_to rules_path, notice: t(".success") }
format.turbo_stream { stream_redirect_back_or_to rules_path, notice: t(".success") }
end
else
render :edit, status: :unprocessable_entity
@@ -96,12 +96,12 @@ class RulesController < ApplicationController
def destroy
@rule.destroy
redirect_to rules_path, notice: "Rule deleted"
redirect_to rules_path, notice: t(".success")
end
def destroy_all
Current.family.rules.destroy_all
redirect_to rules_path, notice: "All rules deleted"
redirect_to rules_path, notice: t(".success")
end
def confirm_all

View File

@@ -31,7 +31,7 @@ class Settings::ApiKeysController < ApplicationController
existing_keys.each { |key| key.update_column(:revoked_at, Time.current) }
if @api_key.save
flash[:notice] = "Your API key has been created successfully"
flash[:notice] = t(".success")
redirect_to settings_api_key_path
else
# Restore existing keys if new key creation failed
@@ -42,13 +42,13 @@ class Settings::ApiKeysController < ApplicationController
def destroy
if @api_key.nil?
flash[:alert] = "API key not found"
flash[:alert] = t(".not_found")
elsif @api_key.demo_monitoring_key?
flash[:alert] = "This API key cannot be revoked"
flash[:alert] = t(".cannot_revoke")
elsif @api_key.revoke!
flash[:notice] = "API key has been revoked successfully"
flash[:notice] = t(".revoked_successfully")
else
flash[:alert] = "Failed to revoke API key"
flash[:alert] = t(".revoke_failed")
end
redirect_to settings_api_key_path
end

View File

@@ -29,9 +29,9 @@ class Settings::ProfilesController < ApplicationController
if @user.destroy
# Also destroy the invitation associated with this user for this family
Current.family.invitations.find_by(email: @user.email)&.destroy
flash[:notice] = "Member removed successfully."
flash[:notice] = t(".member_removed")
else
flash[:alert] = "Failed to remove member."
flash[:alert] = t(".member_removal_failed")
end
redirect_to settings_profile_path

View File

@@ -66,9 +66,9 @@ class Settings::ProvidersController < ApplicationController
# Reload provider configurations if needed
reload_provider_configs(updated_fields)
redirect_to settings_providers_path, notice: "Provider settings updated successfully"
redirect_to settings_providers_path, notice: t(".updated_successfully")
else
redirect_to settings_providers_path, notice: "No changes were made"
redirect_to settings_providers_path, notice: t(".no_changes")
end
rescue => error
Rails.logger.error("Failed to update provider settings: #{error.class} - #{error.message}")

View File

@@ -380,7 +380,7 @@ class SnaptradeItemsController < ApplicationController
end
def link_accounts
redirect_to settings_providers_path, alert: "Use the account setup flow instead"
redirect_to settings_providers_path, alert: t(".use_setup_flow")
end
def select_existing_account

View File

@@ -8,7 +8,7 @@ class SubscriptionsController < ApplicationController
# Upgrade page for unsubscribed users
def upgrade
if Current.family.subscription&.active?
redirect_to root_path, notice: "You are already contributing. Thank you!"
redirect_to root_path, notice: t(".already_contributing")
else
@plan = params[:plan] || "annual"
render layout: "onboardings"
@@ -33,9 +33,9 @@ class SubscriptionsController < ApplicationController
def create
if Current.family.can_start_trial?
Current.family.start_trial_subscription!
redirect_to root_path, notice: "Welcome to Sure!"
redirect_to root_path, notice: t(".welcome")
else
redirect_to root_path, alert: "You have already started or completed a trial. Please upgrade to continue."
redirect_to root_path, alert: t(".trial_already_used")
end
end
@@ -54,9 +54,9 @@ class SubscriptionsController < ApplicationController
if checkout_result.success?
Current.family.start_subscription!(checkout_result.subscription_id)
redirect_to root_path, notice: "Welcome to Sure! Your contribution is appreciated."
redirect_to root_path, notice: t(".welcome_with_contribution")
else
redirect_to root_path, alert: "Something went wrong processing your contribution. Please try again."
redirect_to root_path, alert: t(".contribution_failed")
end
end

View File

@@ -36,7 +36,7 @@ class TagsController < ApplicationController
def destroy_all
Current.family.tags.destroy_all
redirect_back_or_to tags_path, notice: "All tags deleted"
redirect_back_or_to tags_path, notice: t(".all_deleted")
end
private

View File

@@ -107,7 +107,7 @@ class TransactionsController < ApplicationController
@entry.mark_user_modified!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
flash[:notice] = "Transaction created"
flash[:notice] = t(".created")
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
@@ -141,7 +141,7 @@ class TransactionsController < ApplicationController
@entry.reload
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
format.html { redirect_back_or_to account_path(@entry.account), notice: t(".updated") }
format.turbo_stream do
in_split_group = helpers.in_split_group?(@entry, params[:grouped])
render turbo_stream: [

View File

@@ -32,7 +32,7 @@ class TransferMatchesController < ApplicationController
@transfer.sync_account_later
redirect_back_or_to transactions_path, notice: "Transfer created"
redirect_back_or_to transactions_path, notice: t(".success")
end
private

View File

@@ -6,7 +6,7 @@ class UsersController < ApplicationController
if @user.resend_confirmation_email
redirect_to settings_profile_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: t("no_pending_change")
redirect_to settings_profile_path, alert: t(".no_pending_change")
end
end

View File

@@ -57,8 +57,8 @@ class ValuationsController < ApplicationController
if result.success?
respond_to do |format|
format.html { redirect_back_or_to account_path(account), notice: "Account updated" }
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") }
format.html { redirect_back_or_to account_path(account), notice: t(".account_updated") }
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: t(".account_updated")) }
end
else
@error_message = result.error_message
@@ -84,7 +84,7 @@ class ValuationsController < ApplicationController
@entry.reload
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" }
format.html { redirect_back_or_to account_path(@entry.account), notice: t(".entry_updated") }
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(

View File

@@ -74,7 +74,7 @@ module ApplicationHelper
def family_moniker
Current.family&.moniker_label || "Family"
Current.family&.moniker_label || I18n.t("shared.family_moniker.singular")
end
def family_moniker_downcase
@@ -82,7 +82,7 @@ module ApplicationHelper
end
def family_moniker_plural
Current.family&.moniker_label_plural || "Families"
Current.family&.moniker_label_plural || I18n.t("shared.family_moniker.plural")
end
def family_moniker_plural_downcase

View File

@@ -1,21 +1,21 @@
module CategoriesHelper
def transfer_category
Category.new \
name: "Transfer",
name: I18n.t("categories.virtual.transfer"),
color: Category::TRANSFER_COLOR,
lucide_icon: "arrow-right-left"
end
def payment_category
Category.new \
name: "Payment",
name: I18n.t("categories.virtual.payment"),
color: Category::PAYMENT_COLOR,
lucide_icon: "arrow-right"
end
def trade_category
Category.new \
name: "Trade",
name: I18n.t("categories.virtual.trade"),
color: Category::TRADE_COLOR
end

View File

@@ -38,14 +38,14 @@ class CustomConfirm
end
def default_title
"Are you sure?"
I18n.t("shared.custom_confirm.default_title")
end
def default_body
"This is not reversible."
I18n.t("shared.custom_confirm.default_body")
end
def default_btn_text
"Confirm"
I18n.t("shared.custom_confirm.default_btn_text")
end
end

View File

@@ -1,31 +1,31 @@
module ImportsHelper
def mapping_label(mapping_class)
{
"Import::AccountTypeMapping" => "Account Type",
"Import::AccountMapping" => "Account",
"Import::CategoryMapping" => "Category",
"Import::TagMapping" => "Tag"
"Import::AccountTypeMapping" => I18n.t("imports.mapping_labels.account_type"),
"Import::AccountMapping" => I18n.t("imports.mapping_labels.account"),
"Import::CategoryMapping" => I18n.t("imports.mapping_labels.category"),
"Import::TagMapping" => I18n.t("imports.mapping_labels.tag")
}.fetch(mapping_class.name)
end
def import_col_label(key)
{
date: "Date",
amount: "Amount",
name: "Name",
currency: "Currency",
category: "Category",
tags: "Tags",
account: "Account",
notes: "Notes",
qty: "Quantity",
ticker: "Ticker",
exchange: "Exchange",
price: "Price",
entity_type: "Type",
category_parent: "Parent category",
category_color: "Color",
category_icon: "Lucide icon"
date: I18n.t("imports.column_labels.date"),
amount: I18n.t("imports.column_labels.amount"),
name: I18n.t("imports.column_labels.name"),
currency: I18n.t("imports.column_labels.currency"),
category: I18n.t("imports.column_labels.category"),
tags: I18n.t("imports.column_labels.tags"),
account: I18n.t("imports.column_labels.account"),
notes: I18n.t("imports.column_labels.notes"),
qty: I18n.t("imports.column_labels.qty"),
ticker: I18n.t("imports.column_labels.ticker"),
exchange: I18n.t("imports.column_labels.exchange"),
price: I18n.t("imports.column_labels.price"),
entity_type: I18n.t("imports.column_labels.entity_type"),
category_parent: I18n.t("imports.column_labels.category_parent"),
category_color: I18n.t("imports.column_labels.category_color"),
category_icon: I18n.t("imports.column_labels.category_icon")
}[key]
end

View File

@@ -112,7 +112,7 @@ class ApiKey < ApplicationRecord
def prevent_demo_monitoring_key_destroy!
return unless demo_monitoring_key?
errors.add(:base, "Cannot destroy demo monitoring API key")
errors.add(:base, :cannot_destroy_demo_key)
throw(:abort)
end
end

View File

@@ -17,7 +17,7 @@ class CategoryImport < Import
parent = ensure_placeholder_category(row.category_parent)
if parent && parent == category
errors.add(:base, "Category '#{category.name}' cannot be its own parent")
errors.add(:base, :own_parent, name: category.name)
raise ActiveRecord::RecordInvalid.new(self)
end
@@ -82,7 +82,7 @@ class CategoryImport < Import
missing_headers = required_column_keys.map(&:to_s).reject { |key| header_for(key).present? }
return if missing_headers.empty?
errors.add(:base, "Missing required columns: #{missing_headers.join(', ')}")
errors.add(:base, :missing_columns, columns: missing_headers.join(", "))
raise ActiveRecord::RecordInvalid.new(self)
end

View File

@@ -418,7 +418,7 @@ class Import < ApplicationRecord
end
if duplicate_headers.any?
errors.add(:base, "CSV headers normalize to duplicate columns: #{duplicate_headers.map { |headers| headers.join(', ') }.join('; ')}")
errors.add(:base, :duplicate_headers, columns: duplicate_headers.map { |headers| headers.join(", ") }.join("; "))
raise ActiveRecord::RecordInvalid, self
end

View File

@@ -176,6 +176,6 @@ class IndexaCapitalItem < ApplicationRecord
def credentials_present_on_create
return if credentials_configured?
errors.add(:base, "Either INDEXA_API_TOKEN env var or username/document/password credentials are required")
errors.add(:base, :credentials_required)
end
end

View File

@@ -70,6 +70,6 @@ class PlaidAccount < ApplicationRecord
# Plaid guarantees at least one of these. This validation is a sanity check for that guarantee.
def has_balance
return if current_balance.present? || available_balance.present?
errors.add(:base, "Plaid account must have either current or available balance")
errors.add(:base, :no_balance)
end
end

View File

@@ -24,7 +24,7 @@ class RecurringTransaction < ApplicationRecord
def merchant_or_name_present
if merchant_id.blank? && name.blank?
errors.add(:base, "Either merchant or name must be present")
errors.add(:base, :merchant_or_name_required)
end
end

View File

@@ -140,14 +140,14 @@ class Rule < ApplicationRecord
return if new_record? && !actions.empty?
if actions.reject(&:marked_for_destruction?).empty?
errors.add(:base, "must have at least one action")
errors.add(:base, :min_actions)
end
end
def no_duplicate_actions
action_types = actions.reject(&:marked_for_destruction?).map(&:action_type)
errors.add(:base, "Rule cannot have duplicate actions #{action_types.inspect}") if action_types.uniq.count != action_types.count
errors.add(:base, :duplicate_actions, types: action_types.inspect) if action_types.uniq.count != action_types.count
end
# Validation: To keep rules simple and easy to understand, we don't allow nested compound conditions.
@@ -157,7 +157,7 @@ class Rule < ApplicationRecord
conditions.each do |condition|
if condition.compound?
if condition.sub_conditions.any? { |sub_condition| sub_condition.compound? }
errors.add(:base, "Compound conditions cannot be nested")
errors.add(:base, :nested_conditions)
end
end
end

View File

@@ -115,7 +115,7 @@ class RuleImport < Import
# Validate resource type
unless resource_type == "transaction"
errors.add(:base, "Unsupported resource type: #{resource_type}")
errors.add(:base, :unsupported_resource_type, resource_type: resource_type)
raise ActiveRecord::RecordInvalid.new(self)
end
@@ -124,13 +124,13 @@ class RuleImport < Import
conditions_data = parse_json_safely(row.conditions, "conditions")
actions_data = parse_json_safely(row.actions, "actions")
rescue JSON::ParserError => e
errors.add(:base, "Invalid JSON in conditions or actions: #{e.message}")
errors.add(:base, :invalid_json, message: e.message)
raise ActiveRecord::RecordInvalid.new(self)
end
# Validate we have at least one action
if actions_data.empty?
errors.add(:base, "Rule must have at least one action")
errors.add(:base, :min_actions)
raise ActiveRecord::RecordInvalid.new(self)
end

View File

@@ -137,6 +137,6 @@ class SimplefinAccount < ApplicationRecord
end
def has_balance
return if current_balance.present? || available_balance.present?
errors.add(:base, "SimpleFin account must have either current or available balance")
errors.add(:base, :no_balance)
end
end

View File

@@ -158,7 +158,7 @@ class SophtronAccount < ApplicationRecord
end
def has_balance
return if balance.present? || available_balance.present?
errors.add(:base, "Sophtron account must have either current or available balance")
errors.add(:base, :no_balance)
end
def first_present(hash, *keys)

View File

@@ -90,7 +90,7 @@ class SsoProvider < ApplicationRecord
idp_sso_url = settings&.dig("idp_sso_url")
if idp_metadata_url.blank? && idp_sso_url.blank?
errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers")
errors.add(:settings, :saml_url_required)
end
# If using manual config, require certificate
@@ -99,17 +99,17 @@ class SsoProvider < ApplicationRecord
idp_fingerprint = settings&.dig("idp_cert_fingerprint")
if idp_cert.blank? && idp_fingerprint.blank?
errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL")
errors.add(:settings, :saml_cert_required)
end
end
# Validate URL formats if provided
if idp_metadata_url.present? && !valid_url?(idp_metadata_url)
errors.add(:settings, "IdP Metadata URL must be a valid URL")
errors.add(:settings, :metadata_url_invalid)
end
if idp_sso_url.present? && !valid_url?(idp_sso_url)
errors.add(:settings, "IdP SSO URL must be a valid URL")
errors.add(:settings, :sso_url_invalid)
end
end

View File

@@ -107,12 +107,12 @@ class Transfer < ApplicationRecord
private
def transfer_has_different_accounts
return unless inflow_transaction&.entry && outflow_transaction&.entry
errors.add(:base, "Must be from different accounts") if to_account == from_account
errors.add(:base, :different_accounts) if to_account == from_account
end
def transfer_has_same_family
return unless inflow_transaction&.entry && outflow_transaction&.entry
errors.add(:base, "Must be from same family") unless to_account&.family == from_account&.family
errors.add(:base, :same_family) unless to_account&.family == from_account&.family
end
def transfer_has_opposite_amounts
@@ -126,10 +126,10 @@ class Transfer < ApplicationRecord
if inflow_entry.currency == outflow_entry.currency
# For same currency, amounts must be exactly opposite
errors.add(:base, "Must have opposite amounts") if inflow_amount + outflow_amount != 0
errors.add(:base, :opposite_amounts) if inflow_amount + outflow_amount != 0
else
# For different currencies, just check the signs are opposite
errors.add(:base, "Must have opposite amounts") unless inflow_amount.negative? && outflow_amount.positive?
errors.add(:base, :opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive?
end
end
@@ -138,6 +138,6 @@ class Transfer < ApplicationRecord
date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs
max_days = status == "confirmed" ? 30 : 4
errors.add(:base, "Must be within #{max_days} days") if date_diff > max_days
errors.add(:base, :within_days, count: max_days) if date_diff > max_days
end
end

View File

@@ -54,7 +54,7 @@
<% if account.draft? %>
<%= render DS::Link.new(
text: "Complete setup",
text: t(".complete_setup"),
href: edit_account_path(account, return_to: return_to),
variant: :outline,
frame: :modal

View File

@@ -29,13 +29,13 @@
<div class="border-t border-alpha-black-25 px-4 pt-4 text-secondary text-sm justify-between hidden md:flex">
<div class="flex space-x-5">
<div class="flex items-center space-x-2">
<span>Select</span>
<span><%= t(".select") %></span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
<%= icon("corner-down-left", size: "xs") %>
</kbd>
</div>
<div class="flex items-center space-x-2">
<span>Navigate</span>
<span><%= t(".navigate") %></span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
<%= icon("arrow-up", size: "xs") %>
</kbd>
@@ -45,7 +45,7 @@
</div>
</div>
<div class="flex items-center space-x-2">
<button data-action="DS--dialog#close">Close</button>
<button data-action="DS--dialog#close"><%= t(".close") %></button>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
</div>
</div>

View File

@@ -7,11 +7,11 @@
<% unless @account.linked? %>
<% if @account.permission_for(Current.user).in?([ :owner, :full_control ]) %>
<%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
<% menu.with_button(text: t(".new"), variant: "secondary", icon: "plus") %>
<% menu.with_item(
variant: "link",
text: "New balance",
text: t(".new_balance"),
icon: "circle-dollar-sign",
href: new_valuation_path(account_id: @account.id),
data: { turbo_frame: :modal }) %>
@@ -48,7 +48,7 @@
<%= icon("search") %>
<%= hidden_field_tag :account_id, @account.id %>
<%= form.search_field :search,
placeholder: "Search entries by name",
placeholder: t(".search_placeholder"),
value: @q[:search],
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
"data-auto-submit-form-target": "auto" %>

View File

@@ -17,7 +17,7 @@
<% end %>
<% if account.draft? %>
<%= render DS::Link.new(
text: "Complete setup",
text: t(".complete_setup"),
href: edit_account_path(account),
variant: :outline,
size: :sm,

View File

@@ -3,9 +3,9 @@
<% permission = account.permission_for(Current.user) %>
<%= render DS::Menu.new(testid: "account-menu") do |menu| %>
<% if permission.in?([ :owner, :full_control ]) %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% menu.with_item(variant: "link", text: t(".edit"), href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% end %>
<% menu.with_item(variant: "link", text: "Sharing", href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %>
<% menu.with_item(variant: "link", text: t(".sharing"), href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %>
<% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %>
<% if permission.in?([ :owner, :full_control ]) %>
@@ -31,7 +31,7 @@
<% if account.owned_by?(Current.user) && !account.linked? %>
<% menu.with_item(
variant: "button",
text: "Delete account",
text: t(".delete_account"),
href: account_path(account),
method: :delete,
icon: "trash-2",

View File

@@ -6,7 +6,7 @@
<%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>
<div>
<p class="text-sm font-medium text-destructive">
<%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved:
<%= t("admin.sso_providers.form.errors_title", count: sso_provider.errors.count) %>
</p>
<ul class="mt-2 text-sm text-destructive list-disc list-inside">
<% sso_provider.errors.full_messages.each do |message| %>
@@ -20,38 +20,38 @@
<%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
<div class="space-y-4">
<h3 class="font-medium text-primary">Basic Information</h3>
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.basic_information") %></h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= form.select :strategy,
options_for_select([
["OpenID Connect", "openid_connect"],
["SAML 2.0", "saml"],
["Google OAuth2", "google_oauth2"],
["GitHub", "github"]
[t("admin.sso_providers.form.strategy_openid_connect"), "openid_connect"],
[t("admin.sso_providers.form.strategy_saml"), "saml"],
[t("admin.sso_providers.form.strategy_google_oauth2"), "google_oauth2"],
[t("admin.sso_providers.form.strategy_github"), "github"]
], sso_provider.strategy),
{ label: "Strategy" },
{ label: t("admin.sso_providers.form.strategy_label") },
{ data: { action: "change->admin-sso-form#toggleFields" } } %>
<%= form.text_field :name,
label: "Name",
placeholder: "e.g., keycloak, authentik",
label: t("admin.sso_providers.form.name_label"),
placeholder: t("admin.sso_providers.form.name_placeholder"),
required: true,
data: { action: "input->admin-sso-form#updateCallbackUrl" } %>
</div>
<p class="text-xs text-secondary -mt-2">Unique identifier (lowercase, numbers, underscores only)</p>
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.name_help") %></p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= form.text_field :label,
label: "Button Label",
placeholder: "e.g., Sign in with Keycloak",
label: t("admin.sso_providers.form.label_label"),
placeholder: t("admin.sso_providers.form.label_placeholder"),
required: true %>
<div>
<%= form.text_field :icon,
label: "Icon (optional)",
placeholder: "e.g., key, shield" %>
<p class="text-xs text-secondary mt-1">Lucide icon name for the login button</p>
label: t("admin.sso_providers.form.icon_label"),
placeholder: t("admin.sso_providers.form.icon_placeholder") %>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.icon_help") %></p>
</div>
</div>
@@ -65,42 +65,42 @@
</div>
<div class="border-t border-primary pt-4 space-y-4">
<h3 class="font-medium text-primary">OAuth/OIDC Configuration</h3>
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.oauth_configuration") %></h3>
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<%= form.text_field :issuer,
label: "Issuer URL",
placeholder: "https://your-idp.example.com/realms/your-realm",
label: t("admin.sso_providers.form.issuer_label"),
placeholder: t("admin.sso_providers.form.issuer_placeholder"),
data: { action: "blur->admin-sso-form#validateIssuer" } %>
<p class="text-xs text-secondary mt-1">OIDC issuer URL (validates .well-known/openid-configuration)</p>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.issuer_help") %></p>
</div>
<%= form.text_field :client_id,
label: "Client ID",
placeholder: "your-client-id",
label: t("admin.sso_providers.form.client_id_label"),
placeholder: t("admin.sso_providers.form.client_id_placeholder"),
required: true %>
<%= form.password_field :client_secret,
label: "Client Secret",
placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret",
label: t("admin.sso_providers.form.client_secret_label"),
placeholder: sso_provider.persisted? ? t("admin.sso_providers.form.client_secret_placeholder_existing") : t("admin.sso_providers.form.client_secret_placeholder_new"),
required: !sso_provider.persisted? %>
<% if sso_provider.persisted? %>
<p class="text-xs text-secondary -mt-2">Leave blank to keep existing secret</p>
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.client_secret_help_existing") %></p>
<% end %>
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<label class="block text-sm font-medium text-primary mb-1">Callback URL</label>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.redirect_uri_label") %></label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
data-admin-sso-form-target="callbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
<button type="button"
data-action="click->admin-sso-form#copyCallback"
class="p-2 text-secondary hover:text-primary shrink-0"
title="Copy to clipboard">
title="<%= t("admin.sso_providers.form.copy_button") %>">
<%= icon "copy", class: "w-4 h-4" %>
</button>
</div>
<p class="text-xs text-secondary mt-1">Configure this URL in your identity provider</p>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.redirect_uri_help") %></p>
</div>
</div>
@@ -172,18 +172,18 @@
</details>
<div>
<label class="block text-sm font-medium text-primary mb-1">SP Callback URL (ACS URL)</label>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.saml_sp_callback_url_label") %></label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
data-admin-sso-form-target="samlCallbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
<button type="button"
data-action="click->admin-sso-form#copySamlCallback"
class="p-2 text-secondary hover:text-primary shrink-0"
title="Copy to clipboard">
title="<%= t("admin.sso_providers.form.copy_button") %>">
<%= icon "copy", class: "w-4 h-4" %>
</button>
</div>
<p class="text-xs text-secondary mt-1">Configure this URL as the Assertion Consumer Service URL in your IdP</p>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.saml_sp_callback_url_help") %></p>
</div>
</div>
@@ -282,8 +282,8 @@
</div>
<div class="flex gap-3">
<%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %>
<%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider",
<%= link_to t("admin.sso_providers.form.cancel"), admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %>
<%= form.submit sso_provider.persisted? ? t("admin.sso_providers.form.update_provider") : t("admin.sso_providers.form.create_provider"),
class: "px-4 py-2 button-bg-primary text-inverse rounded-lg text-sm font-medium hover:button-bg-primary-hover" %>
</div>
</div>

View File

@@ -1,14 +1,14 @@
<%= content_for :page_title, "SSO Providers" %>
<%= content_for :page_title, t(".page_title") %>
<div class="space-y-4">
<p class="text-secondary mb-4">
Manage single sign-on authentication providers for your instance.
<%= t(".description") %>
<% unless FeatureFlags.db_sso_providers? %>
<span class="text-warning">Changes require a server restart to take effect.</span>
<span class="text-warning"><%= t(".restart_required") %></span>
<% end %>
</p>
<%= settings_section title: "Configured Providers" do %>
<%= settings_section title: t(".configured_providers") do %>
<% if @sso_providers.any? %>
<div class="divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200">
<% @sso_providers.each do |provider| %>
@@ -27,20 +27,20 @@
<div class="flex items-center gap-2">
<% if provider.enabled? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Enabled
<%= t(".enabled") %>
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
Disabled
<%= t(".disabled") %>
</span>
<% end %>
<%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %>
<%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: t(".edit") do %>
<%= icon "pencil", class: "w-4 h-4" %>
<% end %>
<%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %>
<%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? t(".disable") : t(".enable"), form: { data: { turbo_confirm: provider.enabled? ? t("admin.sso_providers.toggle.confirm_disable") : t("admin.sso_providers.toggle.confirm_enable") } } do %>
<%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %>
<% end %>
<%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %>
<%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: t(".delete"), form: { data: { turbo_confirm: t("admin.sso_providers.destroy.confirm") } } do %>
<%= icon "trash-2", class: "w-4 h-4" %>
<% end %>
</div>
@@ -50,14 +50,14 @@
<% else %>
<div class="text-center py-6">
<%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
<p class="text-secondary">No SSO providers configured yet.</p>
<p class="text-secondary"><%= t(".no_providers_message") %></p>
</div>
<% end %>
<div class="pt-4 border-t border-primary">
<%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %>
<%= icon "plus", class: "w-4 h-4" %>
Add Provider
<%= t(".add_provider") %>
<% end %>
</div>
<% end %>
@@ -100,26 +100,25 @@
<% end %>
<% end %>
<%= settings_section title: "Configuration Mode", collapsible: true, open: false do %>
<%= settings_section title: t(".configuration_mode"), collapsible: true, open: false do %>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-primary">Database-backed providers</p>
<p class="text-sm text-secondary">Load providers from database instead of YAML config</p>
<p class="font-medium text-primary"><%= t(".db_backed_providers") %></p>
<p class="text-sm text-secondary"><%= t(".db_backed_providers_description") %></p>
</div>
<% if FeatureFlags.db_sso_providers? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Enabled
<%= t(".enabled") %>
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
Disabled
<%= t(".disabled") %>
</span>
<% end %>
</div>
<p class="text-sm text-secondary">
Set <code class="bg-surface px-1 py-0.5 rounded text-xs">AUTH_PROVIDERS_SOURCE=db</code> to enable database-backed providers.
This allows changes without server restarts.
<%= t(".db_backed_providers_help_html") %>
</p>
</div>
<% end %>

View File

@@ -9,7 +9,7 @@
<% elsif assistant_message.reasoning? %>
<details class="group mb-1">
<summary class="flex items-center gap-2">
<p class="text-secondary text-sm">Assistant reasoning</p>
<p class="text-secondary text-sm"><%= t(".assistant_reasoning") %></p>
<%= icon("chevron-down", class: "group-open:transform group-open:rotate-180") %>
</summary>

View File

@@ -3,15 +3,15 @@
<details class="my-2 group mb-4">
<summary class="text-secondary text-xs cursor-pointer flex items-center gap-2">
<%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %>
<p>Tool Calls</p>
<p><%= t(".tool_calls") %></p>
</summary>
<div class="mt-2">
<% message.tool_calls.each do |tool_call| %>
<div class="bg-blue-50 border-blue-200 px-3 py-2 rounded-lg border mb-2">
<p class="text-secondary text-xs">Function:</p>
<p class="text-secondary text-xs"><%= t(".function") %></p>
<p class="text-primary text-sm font-mono"><%= tool_call.function_name %></p>
<p class="text-secondary text-xs mt-2">Arguments:</p>
<p class="text-secondary text-xs mt-2"><%= t(".arguments") %></p>
<pre class="text-primary text-sm font-mono whitespace-pre-wrap"><%= tool_call.function_arguments %></pre>
</div>
<% end %>

View File

@@ -1,6 +1,6 @@
<div id="<%= dom_id(budget, :confirm_button) %>">
<%= render DS::Button.new(
text: "Confirm",
text: t(".confirm"),
variant: "primary",
full_width: true,
href: budget_path(budget),

View File

@@ -1,18 +1,18 @@
<div class="flex justify-center items-center">
<div class="text-center flex flex-col items-center max-w-[500px]">
<h2 class="text-lg text-primary font-medium">Oops!</h2>
<h2 class="text-lg text-primary font-medium"><%= t(".oops") %></h2>
<p class="text-secondary text-sm max-w-sm mx-auto mb-4">
You have not created or assigned any expense categories to your transactions yet.
<%= t(".no_categories_message") %>
</p>
<div class="flex items-center gap-2">
<%= render DS::Button.new(
text: "Use defaults (recommended)",
text: t(".use_defaults"),
href: bootstrap_categories_path,
) %>
<%= render DS::Link.new(
text: "New category",
text: t(".new_category"),
variant: "outline",
icon: "plus",
href: new_category_path,

View File

@@ -8,9 +8,9 @@
<div>
<div class="space-y-6">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium">Edit your category budgets</h1>
<h1 class="text-3xl text-primary font-medium"><%= t(".title") %></h1>
<p class="text-secondary text-sm max-w-md mx-auto">
Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized.
<%= t(".description") %>
</p>
</div>

View File

@@ -1,7 +1,7 @@
<%= render DS::Dialog.new(variant: :drawer) do |dialog| %>
<% dialog.with_header do %>
<div>
<p class="text-sm text-secondary">Category</p>
<p class="text-sm text-secondary"><%= t(".category") %></p>
<h3 class="text-2xl font-medium text-primary">
<%= @budget_category.name %>
</h3>
@@ -26,12 +26,12 @@
<% end %>
<% dialog.with_body do %>
<% dialog.with_section(title: "Overview", open: true) do %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">
<%= @budget_category.budget.start_date.strftime("%b %Y") %> spending
<%= t(".spending", date: @budget_category.budget.start_date.strftime("%b %Y")) %>
</dt>
<dd class="text-primary font-medium privacy-sensitive">
<%= format_money @budget_category.actual_spending_money %>
@@ -40,30 +40,30 @@
<% if @budget_category.budget.initialized? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Status</dt>
<dt class="text-secondary"><%= t(".status") %></dt>
<% if @budget_category.available_to_spend.negative? %>
<dd class="flex items-center gap-1 text-red-500 font-medium privacy-sensitive">
<%= icon "alert-circle", size: "sm", color: "destructive" %>
<%= format_money @budget_category.available_to_spend_money.abs %>
<span>overspent</span>
<span><%= t(".overspent") %></span>
</dd>
<% elsif @budget_category.available_to_spend.zero? %>
<dd class="flex items-center gap-1 text-orange-500 font-medium privacy-sensitive">
<%= icon "x-circle", size: "sm", color: "warning" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
<span><%= t(".left") %></span>
</dd>
<% else %>
<dd class="text-primary flex items-center gap-1 text-green-500 font-medium privacy-sensitive">
<%= icon "check-circle", size: "sm", color: "success" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
<span><%= t(".left") %></span>
</dd>
<% end %>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Budgeted</dt>
<dt class="text-secondary"><%= t(".budgeted") %></dt>
<dd class="text-primary font-medium privacy-sensitive">
<%= format_money @budget_category.budgeted_spending_money %>
</dd>
@@ -71,14 +71,14 @@
<% end %>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Monthly average spending</dt>
<dt class="text-secondary"><%= t(".monthly_average_spending") %></dt>
<dd class="text-primary font-medium privacy-sensitive">
<%= @budget_category.avg_monthly_expense_money.format %>
</dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Monthly median spending</dt>
<dt class="text-secondary"><%= t(".monthly_median_spending") %></dt>
<dd class="text-primary font-medium privacy-sensitive">
<%= @budget_category.median_monthly_expense_money.format %>
</dd>
@@ -87,7 +87,7 @@
</div>
<% end %>
<% dialog.with_section(title: "Recent Transactions", open: true) do %>
<% dialog.with_section(title: t(".recent_transactions"), open: true) do %>
<div class="space-y-2">
<div class="px-3 py-4 space-y-2">
<% if @recent_transactions.any? %>
@@ -120,7 +120,7 @@
</ul>
<%= render DS::Link.new(
text: "View all category transactions",
text: t(".view_all_transactions"),
variant: "outline",
full_width: true,
href: transactions_path(q: {
@@ -132,7 +132,7 @@
) %>
<% else %>
<p class="text-secondary text-sm mb-4">
No transactions found for this budget period.
<%= t(".no_transactions") %>
</p>
<% end %>
</div>

View File

@@ -2,7 +2,7 @@
<div>
<div class="p-4 border-b border-secondary">
<h3 class="text-sm text-secondary mb-2">Income</h3>
<h3 class="text-sm text-secondary mb-2"><%= t(".income") %></h3>
<span class="inline-block mb-2 text-xl font-medium text-primary privacy-sensitive">
<%= budget.actual_income_money.format %>
@@ -30,7 +30,7 @@
</div>
<div class="p-4">
<h3 class="text-sm text-secondary mb-2">Expenses</h3>
<h3 class="text-sm text-secondary mb-2"><%= t(".expenses") %></h3>
<span class="inline-block mb-2 text-xl font-medium text-primary privacy-sensitive"><%= budget.actual_spending_money.format %></span>

View File

@@ -5,7 +5,7 @@
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
<% if budget.initialized? %>
<div class="text-secondary text-sm mb-2">
<span>Spent</span>
<span><%= t(".spent") %></span>
</div>
<div class="mb-2 text-3xl font-medium privacy-sensitive <%= budget.available_to_spend.negative? ? "text-red-500" : "text-primary" %>">
@@ -13,7 +13,7 @@
</div>
<%= render DS::Link.new(
text: "of #{budget.budgeted_spending_money.format}",
text: t(".of_budget", amount: budget.budgeted_spending_money.format),
variant: "secondary",
icon: "pencil",
icon_position: "right",
@@ -26,7 +26,7 @@
</div>
<%= render DS::Link.new(
text: "New budget",
text: t(".new_budget"),
size: "sm",
icon: "plus",
href: edit_budget_path(budget)
@@ -47,7 +47,7 @@
</p>
<%= render DS::Link.new(
text: "of #{bc.budgeted_spending_money.format(precision: 0)}",
text: t(".of_budget", amount: bc.budgeted_spending_money.format(precision: 0)),
variant: "secondary",
icon: "pencil",
icon_position: "right",
@@ -59,7 +59,7 @@
<% end %>
<div id="segment_unused" class="hidden">
<p class="text-sm text-secondary text-center mb-2">Unused</p>
<p class="text-sm text-secondary text-center mb-2"><%= t(".unused") %></p>
<p class="text-3xl font-medium text-primary privacy-sensitive">
<%= format_money(budget.available_to_spend_money) %>

View File

@@ -40,7 +40,7 @@
<div class="ml-auto">
<%= render DS::Link.new(
text: "Today",
text: t(".today"),
variant: "outline",
href: budget_path(Budget.date_to_param(Date.current)),
) %>

View File

@@ -2,7 +2,7 @@
<div>
<div class="p-4 border-b border-secondary">
<h3 class="text-sm text-secondary mb-2">Expected income</h3>
<h3 class="text-sm text-secondary mb-2"><%= t(".expected_income") %></h3>
<span class="inline-block mb-2 text-xl font-medium text-primary privacy-sensitive">
<%= format_money(budget.expected_income_money) %>
@@ -19,12 +19,12 @@
<% end %>
</div>
<div class="flex justify-between text-sm privacy-sensitive">
<p class="text-secondary"><%= format_money(budget.actual_income_money) %> earned</p>
<p class="text-secondary"><%= t(".earned", amount: format_money(budget.actual_income_money)) %></p>
<p class="font-medium">
<% if budget.remaining_expected_income.negative? %>
<span class="text-green-500"><%= format_money(budget.remaining_expected_income_money.abs) %> over</span>
<span class="text-green-500"><%= t(".over", amount: format_money(budget.remaining_expected_income_money.abs)) %></span>
<% else %>
<span class="text-primary"><%= format_money(budget.remaining_expected_income_money) %> left</span>
<span class="text-primary"><%= t(".left", amount: format_money(budget.remaining_expected_income_money)) %></span>
<% end %>
</p>
</div>
@@ -32,7 +32,7 @@
</div>
<div class="p-4">
<h3 class="text-sm text-secondary mb-2">Budgeted</h3>
<h3 class="text-sm text-secondary mb-2"><%= t(".budgeted") %></h3>
<span class="inline-block mb-2 text-xl font-medium text-primary privacy-sensitive">
<%= format_money(budget.budgeted_spending_money) %>
@@ -49,12 +49,12 @@
<% end %>
</div>
<div class="flex justify-between text-sm privacy-sensitive">
<p class="text-secondary"><%= format_money(budget.actual_spending_money) %> spent</p>
<p class="text-secondary"><%= t(".spent", amount: format_money(budget.actual_spending_money)) %></p>
<p class="font-medium">
<% if budget.available_to_spend.negative? %>
<span class="text-destructive"><%= format_money(budget.available_to_spend_money.abs) %> over</span>
<span class="text-destructive"><%= t(".over", amount: format_money(budget.available_to_spend_money.abs)) %></span>
<% else %>
<span class="text-primary"><%= format_money(budget.available_to_spend_money) %> left</span>
<span class="text-primary"><%= t(".left", amount: format_money(budget.available_to_spend_money)) %></span>
<% end %>
</p>
</div>

View File

@@ -2,10 +2,10 @@
<div class="flex flex-col gap-4 items-center justify-center h-full">
<%= icon "alert-triangle", size: "lg", color: "destructive" %>
<p class="text-secondary text-sm text-center">You have over-allocated your budget. Please fix your allocations.</p>
<p class="text-secondary text-sm text-center"><%= t(".over_allocated_message") %></p>
<%= render DS::Link.new(
text: "Fix allocations",
text: t(".fix_allocations"),
variant: "secondary",
size: "sm",
icon: "pencil",

View File

@@ -8,24 +8,24 @@
<div>
<div class="space-y-4">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium">Setup your budget</h1>
<h1 class="text-3xl text-primary font-medium"><%= t(".setup_title") %></h1>
<p class="text-secondary text-sm max-w-sm mx-auto">
Enter your monthly earnings and planned spending below to setup your budget.
<%= t(".setup_description") %>
</p>
</div>
<div class="mx-auto max-w-lg">
<%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %>
<%= f.money_field :budgeted_spending, label: "Budgeted spending", required: true, disable_currency: true %>
<%= f.money_field :expected_income, label: "Expected income", required: true, disable_currency: true %>
<%= f.money_field :budgeted_spending, label: t(".budgeted_spending"), required: true, disable_currency: true %>
<%= f.money_field :expected_income, label: t(".expected_income"), required: true, disable_currency: true %>
<% if @budget.estimated_income && @budget.estimated_spending %>
<div class="border border-tertiary rounded-lg p-3 flex">
<%= icon "sparkles" %>
<div class="ml-2 space-y-1 text-sm">
<h4 class="text-primary">Autosuggest income & spending budget</h4>
<h4 class="text-primary"><%= t(".autosuggest_title") %></h4>
<p class="text-secondary">
This will be based on transaction history. AI can make mistakes, verify before continuing.
<%= t(".autosuggest_description") %>
</p>
</div>
@@ -40,7 +40,7 @@
</div>
<% end %>
<%= f.submit "Continue" %>
<%= f.submit t(".continue") %>
<% end %>
</div>
</div>

View File

@@ -14,7 +14,7 @@
<div class="fixed right-0 sm:right-auto mx-2 sm:ml-8 sm:mr-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit" data-category-target="popup">
<div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
<div data-category-target="pickerSection"></div>
<h4 class="text-secondary text-sm">Color</h4>
<h4 class="text-secondary text-sm"><%= t(".color") %></h4>
<div class="flex flex-wrap md:flex-nowrap gap-2 items-center" data-category-target="colorsSection">
<% Category::COLORS.each do |color| %>
<label class="relative">
@@ -34,14 +34,14 @@
<%= icon "palette", size: "2xl", data: { action: "click->category#toggleSections" } %>
</div>
<div data-category-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive ">
<span>Poor contrast, choose darker color or</span>
<span><%= t(".poor_contrast") %></span>
<button type="button" class="underline cursor-pointer" data-action="category#autoAdjust">auto-adjust.</button>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-center flex-col w-auto md:w-87">
<h4 class="text-secondary text-sm">Icon</h4>
<h4 class="text-secondary text-sm"><%= t(".icon") %></h4>
<div class="flex flex-wrap gap-0.5 max-h-52 overflow-auto">
<% Category.icon_codes.each do |icon| %>
<label class="relative">
@@ -62,9 +62,9 @@
<% end %>
<div class="space-y-2">
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: t(".name_label"), data: { color_avatar_target: "name" } %>
<% unless category.parent? %>
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %>
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: t(".unassigned"), label: t(".parent_category_label") }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %>
<% end %>
</div>
</section>

View File

@@ -3,7 +3,7 @@
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: "Delete all",
text: t(".delete_all"),
href: destroy_all_categories_path,
method: :delete,
icon: "trash-2",

View File

@@ -65,7 +65,7 @@
data: { turbo_frame: "modal" } do %>
<%= icon("refresh-cw") %>
<p>Match transfer/payment</p>
<p><%= t(".match_transfer") %></p>
<% end %>
<% end %>
@@ -93,7 +93,7 @@
<% end %>
</div>
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
<p><%= t(".one_time", type: @transaction.entry.amount.negative? ? t(".income") : t(".expense")) %></p>
<span class="text-orange-500 ml-auto">
<%= icon("asterisk", color: "current") %>

View File

@@ -3,14 +3,13 @@
<%= render "chats/ai_avatar" %>
</div>
<h3 class="text-sm font-medium text-primary mb-1 -mt-2 text-center">Enable AI Chats</h3>
<h3 class="text-sm font-medium text-primary mb-1 -mt-2 text-center"><%= t(".title") %></h3>
<p class="text-secondary mb-4 text-sm text-center">
<% if Current.user.ai_available? %>
AI chat can answer financial questions and provide insights based on your data. To use this feature you'll need to explicitly enable it.
<%= t(".available_description") %>
<% else %>
To use the AI assistant, you need to set the <code class="bg-surface-inset px-1 py-0.5 rounded font-mono text-xs">OPENAI_ACCESS_TOKEN</code>
environment variable or configure it in the Self-Hosting settings of your instance.
<%= t(".unavailable_description_html") %>
<% end %>
</p>
@@ -18,9 +17,9 @@
<%= form_with url: user_path(Current.user), method: :patch, class: "w-full", data: { turbo: false } do |form| %>
<%= form.hidden_field "user[ai_enabled]", value: true %>
<%= form.hidden_field "user[redirect_to]", value: "home" %>
<%= form.submit "Enable AI Chats", class: "cursor-pointer hover:bg-inverse-hover w-full py-2 px-4 bg-inverse text-inverse rounded-lg text-sm font-medium" %>
<%= form.submit t(".enable_button"), class: "cursor-pointer hover:bg-inverse-hover w-full py-2 px-4 bg-inverse text-inverse rounded-lg text-sm font-medium" %>
<% end %>
<% end %>
<p class="text-xs text-secondary text-center mt-2">Disable anytime. All data sent to our LLM providers is anonymized.</p>
<p class="text-xs text-secondary text-center mt-2"><%= t(".disable_note") %></p>
</div>

View File

@@ -2,27 +2,27 @@
<%= render "chats/ai_avatar" %>
<div class="max-w-[85%] text-sm space-y-4 text-primary">
<p>Hey <%= Current.user&.first_name || "there" %>! I'm an AI/large-language-model that can help with your finances. I have access to the web and your account data.</p>
<p><%= t(".greeting", name: Current.user&.first_name || t(".there")) %></p>
<p>
You can use <span class="bg-container border border-secondary px-1.5 py-0.5 rounded font-mono text-xs">/</span> to access commands
<%= t(".commands_hint_html") %>
</p>
<div class="space-y-3">
<p>Here's a few questions you can ask:</p>
<p><%= t(".questions_intro") %></p>
<% questions = [
{
icon: "chart-area",
text: "Evaluate investment portfolio"
text: t(".evaluate_portfolio")
},
{
icon: "wallet-minimal",
text: "Show spending insights"
text: t(".spending_insights")
},
{
icon: "alert-triangle",
text: "Find unusual patterns"
text: t(".unusual_patterns")
}
] %>

View File

@@ -14,14 +14,14 @@
<%= render DS::Menu.new(icon_vertical: true) do |menu| %>
<% menu.with_item(
variant: "link",
text: "Edit chat title",
text: t(".edit_chat_title"),
href: edit_chat_path(chat, ctx: "list"),
icon: "pencil",
frame: dom_id(chat, "title")) %>
<% menu.with_item(
variant: "button",
text: "Delete chat",
text: t(".delete_chat"),
href: chat_path(chat),
icon: "trash-2",
method: :delete,

View File

@@ -10,7 +10,7 @@
icon: "menu",
href: path,
frame: chat_frame,
text: "All chats"
text: t(".all_chats")
) %>
<div class="grow">
@@ -19,19 +19,19 @@
</div>
<%= render DS::Menu.new(icon_vertical: true) do |menu| %>
<% menu.with_item(variant: "link", text: "Start new chat", href: new_chat_path, icon: "plus") %>
<% menu.with_item(variant: "link", text: t(".start_new_chat"), href: new_chat_path, icon: "plus") %>
<% unless chat.new_record? %>
<% menu.with_item(
variant: "link",
text: "Edit chat title",
text: t(".edit_chat_title"),
href: edit_chat_path(chat, ctx: "chat"),
icon: "pencil",
frame: dom_id(chat, "title")) %>
<% menu.with_item(
variant: "button",
text: "Delete chat",
text: t(".delete_chat"),
href: chat_path(chat),
icon: "trash-2",
method: :delete,

View File

@@ -11,7 +11,7 @@
<p class="text-xs text-red-500"><%= chat.presentable_error_message %></p>
<%= render DS::Button.new(
text: "Retry",
text: t(".retry"),
href: retry_chat_path(chat),
) %>
</div>

View File

@@ -10,14 +10,14 @@
<% if @chats.any? %>
<div class="grow flex flex-col">
<div class="flex items-center justify-between my-6">
<h1 class="text-xl font-medium">Chats</h1>
<h1 class="text-xl font-medium"><%= t(".chats") %></h1>
<%= render DS::Link.new(
id: "new-chat",
icon: "plus",
variant: "icon",
href: new_chat_path,
frame: chat_frame,
text: "New chat"
text: t(".new_chat")
) %>
</div>
<div class="space-y-2 px-0.5">
@@ -26,7 +26,7 @@
</div>
<% else %>
<div class="grow flex flex-col">
<h1 class="sr-only">Chats</h1>
<h1 class="sr-only"><%= t(".chats") %></h1>
<div class="mt-auto py-8">
<%= render "chats/ai_greeting" %>
</div>

View File

@@ -28,7 +28,7 @@
<div class="flex justify-center py-8">
<%= render DS::Link.new(
text: "Edit account details",
text: t(".edit_account_details"),
variant: "ghost",
href: edit_credit_card_path(account),
frame: :modal

View File

@@ -14,7 +14,7 @@
<div class="text-center">
<%= render DS::Link.new(
text: "Go back",
text: t("doorkeeper.authorizations.error.go_back"),
href: "javascript:history.back()",
variant: :secondary
) %>

View File

@@ -7,11 +7,11 @@
</div>
<div class="bg-surface-inset rounded-lg p-4">
<p class="text-xs text-secondary mb-2">Authorization Code:</p>
<p class="text-xs text-secondary mb-2"><%= t(".authorization_code_label") %></p>
<code id="authorization_code" class="block text-sm font-mono text-primary break-all"><%= params[:code] %></code>
</div>
<p class="text-sm text-secondary text-center">
Copy this code and paste it into the application.
<%= t(".copy_instructions") %>
</p>
</div>

View File

@@ -20,29 +20,29 @@
<div class="flex items-center gap-2">
<%= tag.p enable_banking_item.institution_display_name, class: "font-medium text-primary" %>
<% if enable_banking_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse">Deletion in progress</p>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<p class="text-xs text-secondary">Enable Banking</p>
<p class="text-xs text-secondary"><%= t(".provider_name") %></p>
<% if enable_banking_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span "Syncing..." %>
<%= tag.span t(".syncing") %>
</div>
<% elsif enable_banking_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span "Reconnect" %>
<%= tag.span t(".reconnect") %>
</div>
<% else %>
<p class="text-secondary">
<% if enable_banking_item.last_synced_at %>
Last synced <%= time_ago_in_words(enable_banking_item.last_synced_at) %> ago
<%= t(".last_synced", time: time_ago_in_words(enable_banking_item.last_synced_at)) %>
<% if enable_banking_item.sync_status_summary %>
· <%= enable_banking_item.sync_status_summary %>
<% end %>
<% else %>
Never synced
<%= t(".never_synced") %>
<% end %>
</p>
<% end %>
@@ -57,7 +57,7 @@
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
<%= icon "refresh-cw", size: "sm" %>
Update
<%= t(".update") %>
<% end %>
<% elsif Rails.env.development? %>
<%= icon(
@@ -70,7 +70,7 @@
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: "Delete",
text: t(".delete"),
icon: "trash-2",
href: enable_banking_item_path(enable_banking_item),
method: :delete,
@@ -109,10 +109,10 @@
<% if enable_banking_item.unlinked_accounts_count > 0 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">Setup needed</p>
<p class="text-secondary text-sm"><%= pluralize(enable_banking_item.unlinked_accounts_count, "account") %> imported from Enable Banking need to be set up</p>
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_needed_description", count: enable_banking_item.unlinked_accounts_count) %></p>
<%= render DS::Link.new(
text: "Set up accounts",
text: t(".set_up_accounts"),
icon: "settings",
variant: "primary",
href: setup_accounts_enable_banking_item_path(enable_banking_item),
@@ -121,8 +121,8 @@
</div>
<% elsif enable_banking_item.accounts.empty? && enable_banking_item.enable_banking_accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">No accounts found</p>
<p class="text-secondary text-sm">No accounts were found from Enable Banking. Try syncing again.</p>
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_found") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_found_description") %></p>
</div>
<% end %>
</div>

View File

@@ -17,22 +17,22 @@
<% if item.session_valid? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connected Bank" %></p>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || t(".connected_bank") %></p>
<p class="text-xs text-secondary">
Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %>
<%= t(".session_expires") %>: <%= item.session_expires_at&.strftime("%b %d, %Y") || t(".unknown") %>
</p>
</div>
<% elsif item.session_expired? %>
<div class="w-2 h-2 bg-warning rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connection" %></p>
<p class="text-xs text-destructive">Session expired - re-authorization required</p>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || t(".connection") %></p>
<p class="text-xs text-destructive"><%= t(".session_expired") %></p>
</div>
<% else %>
<div class="w-2 h-2 bg-secondary rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary">Configured</p>
<p class="text-xs text-secondary">Ready to connect a bank</p>
<p class="text-sm font-medium text-primary"><%= t(".configured") %></p>
<p class="text-xs text-secondary"><%= t(".ready_to_connect") %></p>
</div>
<% end %>
</div>
@@ -43,28 +43,28 @@
method: :post,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-primary bg-container border border-primary hover:bg-surface-inset transition-colors",
data: { turbo: false } do %>
Sync
<%= t(".sync") %>
<% end %>
<% elsif item.session_expired? %>
<%= button_to reauthorize_enable_banking_item_path(item),
method: :post,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
Reconnect
<%= t(".reconnect") %>
<% end %>
<% else %>
<%= link_to select_bank_enable_banking_item_path(item),
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors",
data: { turbo_frame: "modal" } do %>
Connect Bank
<%= t(".connect_bank") %>
<% end %>
<% end %>
<%= button_to enable_banking_item_path(item),
method: :delete,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors",
data: { turbo_confirm: "Are you sure you want to remove this connection?" } do %>
Remove
data: { turbo_confirm: t(".remove_confirm") } do %>
<%= t(".remove") %>
<% end %>
</div>
</div>
@@ -78,7 +78,7 @@
class: "inline-flex items-center gap-2 justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors",
data: { turbo_frame: "modal" } do %>
<%= icon "plus", size: "sm" %>
Add Connection
<%= t(".add_connection") %>
<% end %>
</div>
<% end %>
@@ -88,18 +88,18 @@
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm text-secondary">
<p class="font-medium text-primary mb-2">Enable Banking connection not configured</p>
<p>Before you can link Enable Banking accounts, you need to configure your Enable Banking connection.</p>
<p class="font-medium text-primary mb-2"><%= t(".not_configured") %></p>
<p><%= t(".not_configured_description") %></p>
</div>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Setup Steps:</p>
<p class="font-medium text-primary"><%= t(".setup_steps_title") %></p>
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li>Go to <strong>Settings → Providers</strong></li>
<li>Find the <strong>Enable Banking</strong> section</li>
<li>Enter your Enable Banking credentials</li>
<li>Return here to link your accounts</li>
<li><%= t(".setup_step_1_html") %></li>
<li><%= t(".setup_step_2_html") %></li>
<li><%= t(".setup_step_3") %></li>
<li><%= t(".setup_step_4") %></li>
</ol>
</div>
@@ -107,7 +107,7 @@
<%= link_to settings_providers_path,
class: "w-full inline-flex items-center justify-center rounded-lg font-medium whitespace-nowrap rounded-lg hidden md:inline-flex px-3 py-2 text-sm text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
data: { turbo: false } do %>
Go to Provider Settings
<%= t(".go_to_provider_settings") %>
<% end %>
</div>
</div>

View File

@@ -1,15 +1,15 @@
<%# Modal: Link an existing manual account to a Enable Banking account %>
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Link Enable Banking account") %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<% if @available_enable_banking_accounts.blank? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2">All Enable Banking accounts appear to be linked already.</p>
<p class="mb-2"><%= t(".all_linked") %></p>
<ul class="list-disc list-inside space-y-1">
<li>If you just connected or synced, try again after the sync completes.</li>
<li>To link a different account, first unlink it from the accounts actions menu.</li>
<li><%= t(".try_after_sync") %></li>
<li><%= t(".unlink_to_move") %></li>
</ul>
</div>
<% else %>
@@ -22,7 +22,7 @@
<div class="flex flex-col">
<span class="text-sm text-primary font-medium"><%= eba.name.presence || eba.account_id %></span>
<span class="text-xs text-secondary">
<%= eba.currency %> • Balance: <%= number_to_currency((eba.current_balance || 0), unit: eba.currency) %>
<%= eba.currency %> • <%= t(".balance") %>: <%= number_to_currency((eba.current_balance || 0), unit: eba.currency) %>
</span>
</div>
</label>
@@ -30,8 +30,8 @@
</div>
<div class="flex items-center justify-end gap-2">
<%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
<%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
</div>
<% end %>
<% end %>

View File

@@ -1,8 +1,8 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Set Up Your Enable Banking Accounts") do %>
<% dialog.with_header(title: t(".title")) do %>
<div class="flex items-center gap-2">
<%= icon "building-2", class: "text-primary" %>
<span class="text-primary">Choose the correct account types for your imported accounts</span>
<span class="text-primary"><%= t(".header_subtitle") %></span>
</div>
<% end %>
@@ -13,7 +13,7 @@
data: {
controller: "loading-button",
action: "submit->loading-button#showLoading",
loading_button_loading_text_value: "Creating Accounts...",
loading_button_loading_text_value: t(".creating_accounts"),
turbo_frame: "_top"
},
class: "space-y-6" do |form| %>
@@ -24,7 +24,7 @@
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary mb-2">
<strong>Choose the correct account type for each Enable Banking account:</strong>
<strong><%= t(".choose_account_type") %></strong>
</p>
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
<% @account_type_options.reject { |_, type| type == "skip" }.each do |label, _| %>
@@ -53,15 +53,15 @@
<%= icon "calendar", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div class="flex-1">
<p class="text-sm text-primary mb-3">
<strong>Historical Data Range:</strong>
<strong><%= t(".historical_data_range") %></strong>
</p>
<%= form.date_field :sync_start_date,
label: "Start syncing transactions from:",
label: t(".sync_start_date_label"),
value: @enable_banking_item.sync_start_date || 3.months.ago.to_date,
min: 2.years.ago.to_date,
max: Date.current,
class: "w-full max-w-xs rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary",
help_text: "Select how far back you want to sync transaction history. Maximum 2 years of history available." %>
help_text: t(".sync_start_date_help") %>
</div>
</div>
</div>
@@ -81,7 +81,7 @@
<p><%= enable_banking_account.account_type_display %></p>
<% end %>
<% if enable_banking_account.current_balance.present? %>
<p>Balance: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %></p>
<p><%= t(".balance") %>: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %></p>
<% end %>
</div>
</div>
@@ -92,7 +92,7 @@
data-account-type-selector-account-id-value="<%= enable_banking_account.id %>"
data-account-type-selector-suggested-subtype-value="<%= enable_banking_account.suggested_subtype %>">
<div>
<%= label_tag "account_types[#{enable_banking_account.id}]", "Account Type:",
<%= label_tag "account_types[#{enable_banking_account.id}]", t(".account_type_label"),
class: "block text-sm font-medium text-primary mb-2" %>
<%= select_tag "account_types[#{enable_banking_account.id}]",
options_for_select(@account_type_options, enable_banking_account.suggested_account_type || "skip"),
@@ -115,7 +115,7 @@
<div class="flex gap-3">
<%= render DS::Button.new(
text: "Create Accounts",
text: t(".create_accounts"),
variant: "primary",
icon: "plus",
type: "submit",
@@ -123,7 +123,7 @@
data: { loading_button_target: "button" }
) %>
<%= render DS::Link.new(
text: "Cancel",
text: t(".cancel"),
variant: "secondary",
href: accounts_path
) %>

View File

@@ -1,40 +1,40 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Export your data", subtitle: "Download all your financial data") %>
<% dialog.with_header(title: t(".dialog_title"), subtitle: t(".dialog_subtitle")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="bg-container-inset rounded-lg p-4 space-y-3">
<h3 class="font-medium text-primary">What's included:</h3>
<h3 class="font-medium text-primary"><%= t(".whats_included") %></h3>
<ul class="space-y-2 text-sm text-secondary">
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>All accounts and balances</span>
<span><%= t(".accounts_and_balances") %></span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Transaction history</span>
<span><%= t(".transaction_history") %></span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Investment trades</span>
<span><%= t(".investment_trades") %></span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Categories, tags and rules</span>
<span><%= t(".categories_tags_rules") %></span>
</li>
</ul>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-sm text-amber-800">
<strong>Note:</strong> This export includes all of your data, but only some of the data can be imported back via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only.
<strong><%= t(".note_label") %>:</strong> <%= t(".note_description") %>
</p>
</div>
<%= form_with url: family_exports_path, method: :post, class: "space-y-4" do |form| %>
<div class="flex gap-3">
<%= link_to "Cancel", "#", class: "flex-1 text-center px-4 py-2 text-primary bg-surface border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %>
<%= form.submit "Export data", class: "flex-1 bg-inverse text-inverse rounded-lg px-4 py-2 cursor-pointer hover:bg-inverse-hover" %>
<%= link_to t(".cancel"), "#", class: "flex-1 text-center px-4 py-2 text-primary bg-surface border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %>
<%= form.submit t(".export_data"), class: "flex-1 bg-inverse text-inverse rounded-lg px-4 py-2 cursor-pointer hover:bg-inverse-hover" %>
</div>
<% end %>
</div>

View File

@@ -18,10 +18,10 @@
</td>
<td class="py-3 px-4 text-sm text-right align-middle">
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(variant: "link", text: t(".edit"), href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(
variant: "button",
text: "Delete",
text: t(".delete"),
href: family_merchant_path(family_merchant),
icon: "trash-2",
method: :delete,

View File

@@ -30,7 +30,7 @@
<% else %>
<div class="flex items-center gap-1 px-2 py-0.5 rounded text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors">
<%= icon "pencil", size: "xs" %>
<span class="text-xs">Set</span>
<span class="text-xs"><%= t(".set") %></span>
</div>
<% end %>
<% end %>

View File

@@ -1,14 +1,14 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Link Interactive Brokers account") %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<% if @available_ibkr_accounts.blank? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2">No unlinked Interactive Brokers accounts are available yet.</p>
<p class="mb-2"><%= t(".no_accounts_available") %></p>
<ul class="list-disc list-inside space-y-1">
<li>Run a sync from Settings &gt; Providers after updating your Flex query.</li>
<li>Wait for the account discovery sync to finish.</li>
<li><%= t(".run_sync_hint") %></li>
<li><%= t(".wait_for_sync") %></li>
</ul>
</div>
<% else %>
@@ -21,7 +21,7 @@
<div class="flex flex-col">
<span class="text-sm text-primary font-medium"><%= ibkr_account.name.presence || ibkr_account.ibkr_account_id %></span>
<span class="text-xs text-secondary">
<%= ibkr_account.currency %> • Balance: <%= number_to_currency((ibkr_account.current_balance || 0), unit: Money::Currency.new(ibkr_account.currency || "USD").symbol) %>
<%= ibkr_account.currency %> • <%= t(".balance") %>: <%= number_to_currency((ibkr_account.current_balance || 0), unit: Money::Currency.new(ibkr_account.currency || "USD").symbol) %>
</span>
</div>
</label>
@@ -29,8 +29,8 @@
</div>
<div class="flex items-center justify-end gap-2">
<%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
<%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
</div>
<% end %>
<% end %>

View File

@@ -1,20 +1,20 @@
<div class="sticky top-0 left-0 w-full bg-black flex items-center justify-between font-mono">
<div class="flex items-center bg-red-600 px-6 py-4">
<%= icon "alert-triangle", size: "lg", color: "current", class: "mr-2" %>
<span class="text-inverse font-semibold uppercase">Super Admin</span>
<span class="text-inverse font-semibold uppercase"><%= t(".super_admin") %></span>
</div>
<div>
<%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
<%= link_to t(".jobs"), sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
</div>
<div class="flex items-center space-x-2 px-2 py-2 text-white">
<% if Current.session.active_impersonator_session.present? %>
<div class="flex items-center space-x-3 bg-gray-800 border border-gray-700 rounded-md pl-3">
<div class="text-sm">
Impersonating: <span class="font-semibold text-red-400"><%= Current.impersonated_user.email %></span>
<%= t(".impersonating") %>: <span class="font-semibold text-red-400"><%= Current.impersonated_user.email %></span>
</div>
<%= button_to "Leave", leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= button_to "Terminate", complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= button_to t(".leave"), leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= button_to t(".terminate"), complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
</div>
<% else %>
<% if Current.true_user.impersonator_support_sessions.in_progress.any? %>
@@ -23,16 +23,16 @@
Current.true_user.impersonator_support_sessions.in_progress.map { |session|
["#{session.impersonated.email} (#{session.status})", session.id]
},
{ prompt: "Join a session" },
{ prompt: t(".join_a_session") },
{ class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono" } %>
<%= f.submit "Join",
<%= f.submit t(".join"),
class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% end %>
<% end %>
<%= form_with model: ImpersonationSession.new, class: "flex items-center space-x-2" do |f| %>
<%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: "UUID", autocomplete: "off" %>
<%= f.submit "Request Impersonation", class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: t(".uuid_placeholder"), autocomplete: "off" %>
<%= f.submit t(".request_impersonation"), class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% end %>
<% end %>
</div>

View File

@@ -14,11 +14,11 @@
<div class="bg-container border border-tertiary rounded-lg p-3 flex flex-col md:flex-row items-start md:items-center justify-between gap-2 md:gap-0">
<div class="flex items-center gap-2">
<%= icon "check-circle", size: "sm", color: "success" %>
<p class="text-success text-sm">Your data has been cleaned</p>
<p class="text-success text-sm"><%= t(".data_cleaned") %></p>
</div>
<%= render DS::Link.new(
text: "Next step",
text: t(".next_step"),
variant: "primary",
href: @import.is_a?(PdfImport) ? import_path(@import) : import_confirm_path(@import),
frame: :_top,
@@ -35,8 +35,8 @@
<div class="flex justify-center w-full md:w-auto">
<div class="bg-surface-inset rounded-lg inline-flex p-1 space-x-2 text-sm text-primary font-medium w-full md:w-auto">
<%= link_to "All rows", import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] != 'errors' ? 'bg-container' : ''}" %>
<%= link_to "Error rows", import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] == 'errors' ? 'bg-container' : ''}" %>
<%= link_to t(".all_rows"), import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] != 'errors' ? 'bg-container' : ''}" %>
<%= link_to t(".error_rows"), import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] == 'errors' ? 'bg-container' : ''}" %>
</div>
</div>
</div>

View File

@@ -1,18 +1,18 @@
<%# locals: (import:) %>
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %>
<%= form.select :entity_type_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Entity Type" } %>
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" }, required: true %>
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" }, required: true %>
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %>
<%= form.select :entity_type_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".entity_type") } %>
<%= form.select :name_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".name") }, required: true %>
<%= form.select :amount_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".balance") }, required: true %>
<%= form.select :currency_col_label, import.csv_headers, { include_blank: t(".default"), label: t(".currency") } %>
<div class="flex items-center gap-4">
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance Date" } %>
<%= form.select :date_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".balance_date") } %>
<%= form.select :date_format,
Family::DATE_FORMATS,
{ label: "Date Format", prompt: "Select format" },
{ label: t(".date_format"), prompt: t(".select_format") },
required: @import.date_col_label.present? %>
</div>
<%= form.submit "Apply configuration", disabled: import.complete? %>
<%= form.submit t(".apply_configuration"), disabled: import.complete? %>
<% end %>

View File

@@ -3,38 +3,38 @@
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %>
<div class="space-y-4">
<div class="flex items-center gap-4">
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Select column", label: "Date" }, required: true %>
<%= form.select :date_col_label, import.csv_headers, { include_blank: t(".select_column"), label: t(".date_label") }, required: true %>
<%= form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format_label")}, label: true, required: true %>
</div>
<div class="flex items-center gap-4">
<%= form.select :qty_col_label, import.csv_headers, { include_blank: "Select column", label: "Quantity" }, required: true %>
<%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true, required: true %>
<%= form.select :qty_col_label, import.csv_headers, { include_blank: t(".select_column"), label: t(".quantity_label") }, required: true %>
<%= form.select :signage_convention, [[t(".buys_are_positive"), "inflows_positive"], [t(".buys_are_negative"), "inflows_negative"]], label: true, required: true %>
</div>
<div class="flex items-center gap-4">
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %>
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
<%= form.select :currency_col_label, import.csv_headers, { include_blank: t(".default"), label: t(".currency_label") } %>
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: t(".format_label"), prompt: t(".select_format") }, required: true %>
</div>
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Select column", label: "Ticker" }, required: true %>
<%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Stock exchange code" } %>
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Select column", label: "Price" }, required: true %>
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: t(".select_column"), label: t(".ticker_label") }, required: true %>
<%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".stock_exchange_code_label") } %>
<%= form.select :price_col_label, import.csv_headers, { include_blank: t(".select_column"), label: t(".price_label") }, required: true %>
<% unless import.account.present? %>
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account" } %>
<%= form.select :account_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".account_label") } %>
<% end %>
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" } %>
<%= form.select :name_col_label, import.csv_headers, { include_blank: t(".leave_empty"), label: t(".name_label") } %>
<% unless Security.providers.any? %>
<div class="alert alert-warning">
<p>
<strong>Note:</strong> The security prices provider is not configured. Your trade imports will work, but Sure will not backfill price history. Please go to your settings to configure this.
<strong><%= t(".note_label") %>:</strong> <%= t(".no_security_provider_warning") %>
</p>
</div>
<% end %>
</div>
<%= form.submit "Apply configuration", disabled: import.complete? %>
<%= form.submit t(".apply_configuration"), disabled: import.complete? %>
<% end %>

View File

@@ -19,11 +19,11 @@
<div class="flex items-center gap-4">
<%= form.select :date_col_label,
import.csv_headers,
{ label: "Date", prompt: "Select column" },
{ label: t(".date_label"), prompt: t(".select_column") },
required: true %>
<%= form.select :date_format,
Family::DATE_FORMATS,
{ label: t(".date_format_label"), prompt: "Select format" },
{ label: t(".date_format_label"), prompt: t(".select_format") },
required: true %>
</div>
@@ -31,21 +31,21 @@
<div class="flex items-center gap-4">
<%= form.select :amount_col_label,
import.csv_headers,
{ label: "Amount", container_class: "w-2/5", prompt: "Select column" },
{ label: t(".amount_label"), container_class: "w-2/5", prompt: t(".select_column") },
required: true %>
<%= form.select :currency_col_label,
import.csv_headers,
{ include_blank: "Default", label: "Currency", container_class: "w-1/5" } %>
{ include_blank: t(".default"), label: t(".currency_label"), container_class: "w-1/5" } %>
<%= form.select :number_format,
Import::NUMBER_FORMATS.keys,
{ label: "Format", prompt: "Select format", container_class: "w-2/5" },
{ label: t(".format_label"), prompt: t(".select_format"), container_class: "w-2/5" },
required: true %>
</div>
<%# Amount Type Strategy %>
<%= form.select :amount_type_strategy,
Import::AMOUNT_TYPE_STRATEGIES.map { |strategy| [strategy.humanize, strategy] },
{ label: "Amount type strategy", prompt: "Select strategy" },
{ label: t(".amount_type_strategy_label"), prompt: t(".select_strategy") },
required: true,
data: {
action: "import#handleAmountTypeStrategyChange",
@@ -58,8 +58,8 @@
<div class="flex items-center gap-2">
<span class="text-sm shrink-0 text-secondary">↪</span>
<%= form.select :signage_convention,
[["Incomes are positive", "inflows_positive"], ["Incomes are negative", "inflows_negative"]],
{ label: "Amount type", prompt: "Select convention" },
[[t(".incomes_are_positive"), "inflows_positive"], [t(".incomes_are_negative"), "inflows_negative"]],
{ label: t(".amount_type_label"), prompt: t(".select_convention") },
required: @import.amount_type_strategy == "signed_amount" %>
</div>
<% end %>
@@ -70,32 +70,32 @@
<div class="space-y-2">
<div class="flex items-center gap-2 text-sm">
<span class="shrink-0 text-secondary">↪</span>
<span class="text-secondary">Set</span>
<span class="text-secondary"><%= t(".set") %></span>
<%= form.select :entity_type_col_label,
import.csv_headers,
{ prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
{ prompt: t(".select_column"), container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
required: @import.amount_type_strategy == "custom_column",
data: { action: "import#handleAmountTypeChange" } %>
<span class="text-secondary">as amount type column</span>
<span class="text-secondary"><%= t(".as_amount_type_column") %></span>
</div>
<div class="items-center gap-2 text-sm <%= @import.entity_type_col_label.nil? ? "hidden" : "flex" %>" data-import-target="amountTypeValue">
<span class="shrink-0 text-secondary">↪</span>
<span class="text-secondary">Set</span>
<span class="text-secondary"><%= t(".set") %></span>
<%= form.select :amount_type_identifier_value,
@import.selectable_amount_type_values,
{ prompt: "Select value", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
{ prompt: t(".select_value"), container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
required: @import.amount_type_strategy == "custom_column",
data: { action: "import#handleAmountTypeIdentifierChange" } %>
<span class="text-secondary">as identifier value</span>
<span class="text-secondary"><%= t(".as_identifier_value") %></span>
</div>
<div class="items-center gap-2 text-sm <%= @import.amount_type_identifier_value.nil? ? "hidden" : "flex" %>" data-import-target="amountTypeInflowValue">
<span class="shrink-0 text-secondary">↪</span>
<span class="text-secondary">Treat "<span class="font-medium"><%= @import.amount_type_identifier_value %></span>" as</span>
<span class="text-secondary"><%= t(".treat_as_html", value: @import.amount_type_identifier_value) %></span>
<%= form.select :amount_type_inflow_value,
[["Income (inflow)", "inflows_positive"], ["Expense (outflow)", "inflows_negative"]],
{ prompt: "Select type", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
[[t(".income_inflow"), "inflows_positive"], [t(".expense_outflow"), "inflows_negative"]],
{ prompt: t(".select_type"), container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
required: @import.amount_type_strategy == "custom_column" %>
</div>
</div>
@@ -105,21 +105,21 @@
<% unless import.account.present? %>
<%= form.select :account_col_label,
import.csv_headers,
{ include_blank: "Leave empty", label: "Account" } %>
{ include_blank: t(".leave_empty"), label: t(".account_label") } %>
<% end %>
<%= form.select :name_col_label,
import.csv_headers,
{ include_blank: "Leave empty", label: "Name" } %>
{ include_blank: t(".leave_empty"), label: t(".name_label") } %>
<%= form.select :category_col_label,
import.csv_headers,
{ include_blank: "Leave empty", label: "Category" } %>
{ include_blank: t(".leave_empty"), label: t(".category_label") } %>
<%= form.select :tags_col_label,
import.csv_headers,
{ include_blank: "Leave empty", label: "Tags" } %>
{ include_blank: t(".leave_empty"), label: t(".tags_label") } %>
<%= form.select :notes_col_label,
import.csv_headers,
{ include_blank: "Leave empty", label: "Notes" } %>
{ include_blank: t(".leave_empty"), label: t(".notes_label") } %>
<%= form.submit "Apply configuration", disabled: import.complete? %>
<%= form.submit t(".apply_configuration"), disabled: import.complete? %>
<% end %>

View File

@@ -12,7 +12,7 @@
<%= tag.p t(".no_accounts"), class: "text-sm" %>
<%= render DS::Link.new(
text: "Create account",
text: t(".create_account"),
variant: "primary",
href: new_account_path(return_to: import_confirm_path(import)),
frame: :modal
@@ -60,7 +60,7 @@
<div class="flex justify-center w-full">
<%= render DS::Link.new(
text: "Next",
text: t(".next"),
variant: "primary",
href: is_last_step ? import_path(import) : url_for(step: step_idx + 2),
icon: "arrow-right",

View File

@@ -93,7 +93,7 @@
<%# ── Standard CSV upload ── %>
<div class="space-y-4" data-controller="drag-and-drop-import">
<!-- Overlay -->
<%= render "imports/drag_drop_overlay", title: "Drop CSV to upload", subtitle: "Your file will be uploaded automatically" %>
<%= render "imports/drag_drop_overlay", title: t(".drop_csv_title"), subtitle: t(".drop_csv_subtitle") %>
<div class="space-y-4 mx-auto max-w-md">
<div class="text-center space-y-2">
@@ -103,8 +103,8 @@
<%= render DS::Tabs.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %>
<% tabs.with_nav do |nav| %>
<% nav.with_btn(id: "csv-upload", label: "Upload CSV") %>
<% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %>
<% nav.with_btn(id: "csv-upload", label: t(".upload_csv_tab")) %>
<% nav.with_btn(id: "csv-paste", label: t(".copy_paste_tab")) %>
<% end %>
<% tabs.with_panel(tab_id: "csv-upload") do %>
@@ -112,7 +112,7 @@
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: t(".account_optional_label"), include_blank: t(".multi_account_import"), selected: @import.account_id } %>
<% end %>
<label for="import_import_file_csv" class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-file-upload-target="uploadArea">
@@ -135,7 +135,7 @@
</div>
</label>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<%= form.submit t(".upload_csv_button"), disabled: @import.complete? %>
<% end %>
<% end %>
@@ -144,16 +144,16 @@
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: t(".account_optional_label"), include_blank: t(".multi_account_import"), selected: @import.account_id } %>
<% end %>
<%= form.text_area :raw_file_str,
rows: 10,
required: true,
placeholder: "Paste your CSV file contents here",
placeholder: t(".paste_csv_placeholder"),
"data-auto-submit-form-target": "auto" %>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<%= form.submit t(".upload_csv_button"), disabled: @import.complete? %>
<% end %>
<% end %>
<% end %>
@@ -161,7 +161,7 @@
<div class="flex justify-center">
<span class="text-secondary text-sm">
<%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format
<%= link_to t(".download_sample_csv"), "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> <%= t(".to_see_format") %>
</span>
</div>
</div>

View File

@@ -7,10 +7,10 @@
</div>
<div class="text-center space-y-2">
<h1 class="font-medium text-primary text-center text-3xl">Import failed</h1>
<p class="text-sm text-secondary">Please check that your file format, for any errors and that all required fields are filled, then come back and try again.</p>
<h1 class="font-medium text-primary text-center text-3xl"><%= t(".title") %></h1>
<p class="text-sm text-secondary"><%= t(".description") %></p>
</div>
<%= render DS::Button.new(text: "Try again", href: publish_import_path(import), full_width: true) %>
<%= render DS::Button.new(text: t(".try_again"), href: publish_import_path(import), full_width: true) %>
</div>
</div>

View File

@@ -7,13 +7,13 @@
</div>
<div class="text-center space-y-2">
<h1 class="font-medium text-primary text-center text-3xl">Import in progress</h1>
<p class="text-sm text-secondary">Your import is in progress. Check the imports menu for status updates or click 'Check Status' to refresh the page for updates. Feel free to continue using the app.</p>
<h1 class="font-medium text-primary text-center text-3xl"><%= t(".title") %></h1>
<p class="text-sm text-secondary"><%= t(".description") %></p>
</div>
<div class="space-y-2 flex flex-col">
<%= render DS::Link.new(text: "Check status", href: import_path(import), variant: "primary", full_width: true) %>
<%= render DS::Link.new(text: "Back to dashboard", href: root_path, variant: "secondary", full_width: true) %>
<%= render DS::Link.new(text: t(".check_status"), href: import_path(import), variant: "primary", full_width: true) %>
<%= render DS::Link.new(text: t(".back_to_dashboard"), href: root_path, variant: "secondary", full_width: true) %>
</div>
</div>
</div>

View File

@@ -7,12 +7,12 @@
</div>
<div class="text-center space-y-2">
<h1 class="font-medium text-primary text-center text-3xl">Reverting import failed</h1>
<p class="text-sm text-secondary">Please try again</p>
<h1 class="font-medium text-primary text-center text-3xl"><%= t(".title") %></h1>
<p class="text-sm text-secondary"><%= t(".description") %></p>
</div>
<%= render DS::Button.new(
text: "Try again",
text: t(".try_again"),
full_width: true,
href: revert_import_path(import)
) %>

View File

@@ -7,12 +7,12 @@
</div>
<div class="text-center space-y-2">
<h1 class="font-medium text-primary text-center text-3xl">Import successful</h1>
<p class="text-sm text-secondary">Your imported data has been successfully added to the app and is now ready for use.</p>
<h1 class="font-medium text-primary text-center text-3xl"><%= t(".title") %></h1>
<p class="text-sm text-secondary"><%= t(".description") %></p>
</div>
<%= render DS::Link.new(
text: "Back to dashboard",
text: t(".back_to_dashboard"),
variant: "primary",
full_width: true,
href: root_path

View File

@@ -5,17 +5,17 @@
<form method="dialog" class="space-y-4">
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<h3 class="font-medium text-primary" data-confirm-dialog-target="title">Are you sure?</h3>
<h3 class="font-medium text-primary" data-confirm-dialog-target="title"><%= t(".are_you_sure") %></h3>
<%= icon("x", as_button: true, type: "submit", value: "cancel") %>
</div>
<p class="text-sm text-secondary" data-confirm-dialog-target="subtitle">This action cannot be undone.</p>
<p class="text-sm text-secondary" data-confirm-dialog-target="subtitle"><%= t(".cannot_be_undone") %></p>
</div>
<div>
<% ["primary", "outline-destructive", "destructive"].each do |variant| %>
<%= render DS::Button.new(
text: "Confirm",
text: t(".confirm"),
variant: variant,
autofocus: true,
full_width: true,

View File

@@ -46,7 +46,7 @@
<div class="flex justify-center py-8">
<%= render DS::Link.new(
text: "Edit loan details",
text: t(".edit_loan_details"),
variant: "ghost",
href: edit_loan_path(account),
frame: :modal

View File

@@ -1,22 +1,22 @@
<%# locals: (error_message:, return_path:) %>
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Lunch Flow Connection Error") %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<%= render DS::Alert.new(
title: "Unable to connect to Lunch Flow",
title: t(".unable_to_connect"),
message: error_message,
variant: :error
) %>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Common Issues:</p>
<p class="font-medium text-primary"><%= t(".common_issues") %></p>
<ul class="list-disc list-inside space-y-1 text-secondary">
<li><strong>Invalid API Key:</strong> Check your API key in Provider Settings</li>
<li><strong>Expired Credentials:</strong> Generate a new API key from Lunch Flow</li>
<li><strong>Network Issue:</strong> Check your internet connection</li>
<li><strong>Service Down:</strong> Lunch Flow API may be temporarily unavailable</li>
<li><strong><%= t(".invalid_api_key_label") %>:</strong> <%= t(".invalid_api_key_desc") %></li>
<li><strong><%= t(".expired_credentials_label") %>:</strong> <%= t(".expired_credentials_desc") %></li>
<li><strong><%= t(".network_issue_label") %>:</strong> <%= t(".network_issue_desc") %></li>
<li><strong><%= t(".service_down_label") %>:</strong> <%= t(".service_down_desc") %></li>
</ul>
</div>
@@ -24,7 +24,7 @@
<%= link_to settings_providers_path,
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors",
data: { turbo: false } do %>
Check Provider Settings
<%= t(".check_provider_settings") %>
<% end %>
</div>
</div>

View File

@@ -1,21 +1,21 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Lunch Flow Setup Required") %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<%= render DS::Alert.new(
title: "API Key Not Configured",
message: "Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.",
title: t(".api_key_not_configured"),
message: t(".api_key_description"),
variant: :warning
) %>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Setup Steps:</p>
<p class="font-medium text-primary"><%= t(".setup_steps_title") %></p>
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li>Go to <strong>Settings → Providers</strong></li>
<li>Find the <strong>Lunch Flow</strong> section</li>
<li>Enter your Lunch Flow API key</li>
<li>Return here to link your accounts</li>
<li><%= t(".setup_step_1_html") %></li>
<li><%= t(".setup_step_2_html") %></li>
<li><%= t(".setup_step_3") %></li>
<li><%= t(".setup_step_4") %></li>
</ol>
</div>
@@ -23,7 +23,7 @@
<%= link_to settings_providers_path,
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors",
data: { turbo: false } do %>
Go to Provider Settings
<%= t(".go_to_provider_settings") %>
<% end %>
</div>
</div>

View File

@@ -1,25 +1,25 @@
<%# locals: (error_message:, return_path:) %>
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Mercury Connection Error") %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm">
<p class="font-medium text-primary mb-2">Unable to connect to Mercury</p>
<p class="font-medium text-primary mb-2"><%= t(".unable_to_connect") %></p>
<p class="text-secondary"><%= error_message %></p>
</div>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Common Issues:</p>
<p class="font-medium text-primary"><%= t(".common_issues") %></p>
<ul class="list-disc list-inside space-y-1 text-secondary">
<li><strong>Invalid API Token:</strong> Check your API token in Provider Settings</li>
<li><strong>Expired Credentials:</strong> Generate a new API token from Mercury</li>
<li><strong>Insufficient Permissions:</strong> Ensure your token has read-only access</li>
<li><strong>Network Issue:</strong> Check your internet connection</li>
<li><strong>Service Down:</strong> Mercury API may be temporarily unavailable</li>
<li><strong><%= t(".invalid_api_token_label") %>:</strong> <%= t(".invalid_api_token_desc") %></li>
<li><strong><%= t(".expired_credentials_label") %>:</strong> <%= t(".expired_credentials_desc") %></li>
<li><strong><%= t(".insufficient_permissions_label") %>:</strong> <%= t(".insufficient_permissions_desc") %></li>
<li><strong><%= t(".network_issue_label") %>:</strong> <%= t(".network_issue_desc") %></li>
<li><strong><%= t(".service_down_label") %>:</strong> <%= t(".service_down_desc") %></li>
</ul>
</div>
@@ -27,7 +27,7 @@
<%= link_to settings_providers_path,
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors",
data: { turbo: false } do %>
Check Provider Settings
<%= t(".check_provider_settings") %>
<% end %>
</div>
</div>

View File

@@ -1,23 +1,23 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Mercury Setup Required") %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm text-secondary">
<p class="font-medium text-primary mb-2">API Token Not Configured</p>
<p>Before you can link Mercury accounts, you need to configure your Mercury API token.</p>
<p class="font-medium text-primary mb-2"><%= t(".api_token_not_configured") %></p>
<p><%= t(".api_token_description") %></p>
</div>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Setup Steps:</p>
<p class="font-medium text-primary"><%= t(".setup_steps_title") %></p>
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li>Go to <strong>Settings > Providers</strong></li>
<li>Find the <strong>Mercury</strong> section</li>
<li>Enter your Mercury API token</li>
<li>Return here to link your accounts</li>
<li><%= t(".setup_step_1_html") %></li>
<li><%= t(".setup_step_2_html") %></li>
<li><%= t(".setup_step_3") %></li>
<li><%= t(".setup_step_4") %></li>
</ol>
</div>
@@ -25,7 +25,7 @@
<%= link_to settings_providers_path,
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors",
data: { turbo: false } do %>
Go to Provider Settings
<%= t(".go_to_provider_settings") %>
<% end %>
</div>
</div>

View File

@@ -10,7 +10,7 @@
<%# In the future, this will be a dropdown with different AI models %>
<%= f.hidden_field :ai_model, value: default_ai_model %>
<%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint,
<%= f.text_area :content, placeholder: t(".placeholder"), value: message_hint,
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 %>
@@ -27,5 +27,5 @@
</div>
<% end %>
<p class="text-xs text-secondary">AI responses are informational only. Not financial advice!</p>
<p class="text-xs text-secondary"><%= t(".disclaimer") %></p>
</div>

View File

@@ -37,13 +37,13 @@
<div class="bg-container-inset rounded-xl p-1 space-y-1 overflow-x-auto">
<div class="px-4 py-2 flex items-center uppercase text-xs font-medium text-secondary">
<div class="w-40 shrink-0">Name</div>
<div class="w-40 shrink-0"><%= t(".name") %></div>
<div class="ml-auto text-right flex items-center gap-2">
<div class="w-20 shrink-0">
<p>Weight</p>
<p><%= t(".weight") %></p>
</div>
<div class="w-24 shrink-0">
<p>Value</p>
<p><%= t(".value") %></p>
</div>
</div>
</div>

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