mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
* Feat: Implement manual sync prices functionality and enhance holdings display * Feat: Enhance sync prices functionality with error handling and update UI components * Feat: Update sync prices error handling and enhance Spanish locale messages * Fix: Address CodeRabbit review feedback - Set fallback @provider_error when prices_updated == 0 so turbo stream never fails silently without a visible error message - Move attr_reader :provider_error to class header in Price::Importer for conventional placement alongside other attribute declarations - Precompute @last_price_updated in controller (show + sync_prices) instead of running a DB query directly inside ERB templates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Replace bare rescue with explicit exception handling in turbo stream view Bare `rescue` silently swallows all exceptions, making debugging impossible. Match the pattern already used in show.html.erb: rescue ActiveRecord::RecordInvalid explicitly, then catch StandardError with logging (message + backtrace) before falling back to the unknown label. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Update test assertion to expect actual provider error message The stub returns "Yahoo Finance rate limit exceeded" as the provider error. After the @provider_error fallback fix, the controller now correctly surfaces the real provider error when present (using .presence || fallback), so the flash[:alert] is the actual error string, not the generic fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Assert scoped security_ids in sync_prices materializer test Replace loose stub with constructor expectation to verify that Balance::Materializer is instantiated with the single-security scope. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: Assert holding remap in remap_security test Add assertion that @holding.security_id is updated to the target security after remap, covering the core command outcome. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: CI test failure - Update disconnect external assistant test to use env overrides --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
304 lines
17 KiB
Plaintext
304 lines
17 KiB
Plaintext
<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
|
|
<% dialog.with_header(custom_header: true) do %>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-primary" %>
|
|
<%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<% if @holding.security.brandfetch_icon_url.present? %>
|
|
<%= image_tag @holding.security.brandfetch_icon_url, loading: "lazy", class: "w-9 h-9 rounded-full" %>
|
|
<% elsif @holding.security.logo_url.present? %>
|
|
<%= image_tag @holding.security.logo_url, loading: "lazy", class: "w-9 h-9 rounded-full" %>
|
|
<% else %>
|
|
<%= render DS::FilledIcon.new(variant: :text, text: @holding.name, size: "md", rounded: true) %>
|
|
<% end %>
|
|
<%= dialog.close_button %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% dialog.with_body do %>
|
|
<% dialog.with_section(title: t(".overview"), open: true) do %>
|
|
<div class="pb-4">
|
|
<dl class="space-y-3 px-3 py-2">
|
|
<div data-controller="holding-security-remap">
|
|
<div class="flex items-center justify-between text-sm">
|
|
<dt class="text-secondary"><%= t(".security_label") %></dt>
|
|
<dd class="text-primary flex items-center gap-2">
|
|
<%= @holding.ticker %>
|
|
<% if @holding.security_remapped? %>
|
|
<span class="text-xs text-secondary">
|
|
(<%= t(".originally", ticker: @holding.provider_security.ticker) %>)
|
|
</span>
|
|
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
|
<% end %>
|
|
<button type="button"
|
|
data-action="click->holding-security-remap#toggle"
|
|
class="text-secondary hover:text-primary"
|
|
aria-label="<%= t(".edit_security") %>">
|
|
<%= icon "pencil", size: "xs" %>
|
|
</button>
|
|
</dd>
|
|
</div>
|
|
<div data-holding-security-remap-target="form" class="hidden mt-3 space-y-3">
|
|
<% if Security.provider.present? %>
|
|
<%= form_with url: remap_security_holding_path(@holding), method: :patch, class: "space-y-3" do |f| %>
|
|
<div class="form-field combobox">
|
|
<%= f.combobox :security_id,
|
|
securities_path(country_code: Current.family.country),
|
|
label: t(".search_security"),
|
|
placeholder: t(".search_security_placeholder"),
|
|
required: true %>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<button type="button"
|
|
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
|
|
data-action="click->holding-security-remap#toggle">
|
|
<%= t(".cancel") %>
|
|
</button>
|
|
<%= f.submit t(".remap_security"), class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
|
|
</div>
|
|
<% end %>
|
|
<% else %>
|
|
<p class="text-xs text-secondary"><%= t(".no_security_provider") %></p>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-between text-sm">
|
|
<dt class="text-secondary"><%= t(".current_market_price_label") %></dt>
|
|
<dd id="<%= dom_id(@holding, :current_market_price) %>" class="text-primary">
|
|
<% begin %>
|
|
<%= @holding.security.current_price ? format_money(@holding.security.current_price) : t(".unknown") %>
|
|
<% rescue ActiveRecord::RecordInvalid %>
|
|
<%= t(".unknown") %>
|
|
<% rescue StandardError => e %>
|
|
<% logger.error "Error fetching current price for security #{@holding.security.id}: #{e.message}" %>
|
|
<% logger.error e.backtrace.first(5).join("\n") %>
|
|
<%= t(".unknown") %>
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between text-sm">
|
|
<dt class="text-secondary"><%= t(".shares_label") %></dt>
|
|
<dd class="text-primary"><%= format_quantity(@holding.qty) %></dd>
|
|
</div>
|
|
<div class="flex items-center justify-between text-sm">
|
|
<dt class="text-secondary"><%= t(".portfolio_weight_label") %></dt>
|
|
<dd class="text-primary"><%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %></dd>
|
|
</div>
|
|
<%# Average Cost with inline editor %>
|
|
<%
|
|
currency = Money::Currency.new(@holding.currency)
|
|
current_per_share = @holding.cost_basis.present? && @holding.cost_basis.positive? ? @holding.cost_basis : nil
|
|
current_total = current_per_share && @holding.qty.positive? ? (current_per_share * @holding.qty).round(2) : nil
|
|
%>
|
|
<div data-controller="drawer-cost-basis" data-drawer-cost-basis-qty-value="<%= @holding.qty %>">
|
|
<div class="flex items-center justify-between text-sm">
|
|
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
|
|
<dd class="text-primary flex items-center gap-1">
|
|
<%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %>
|
|
<% if @holding.cost_basis_locked? %>
|
|
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
|
<% end %>
|
|
<% if @holding.cost_basis_source.present? %>
|
|
<span class="text-xs text-secondary">(<%= @holding.cost_basis_source_label %>)</span>
|
|
<% end %>
|
|
<button type="button" class="ml-1" data-action="click->drawer-cost-basis#toggle">
|
|
<%= icon "pencil", size: "xs", class: "text-secondary hover:text-primary" %>
|
|
</button>
|
|
</dd>
|
|
</div>
|
|
<%# Inline cost basis editor (hidden by default) %>
|
|
<div class="hidden mt-3 space-y-3" data-drawer-cost-basis-target="form">
|
|
<%
|
|
drawer_form_data = { turbo: false }
|
|
if @holding.avg_cost
|
|
drawer_form_data[:turbo_confirm] = {
|
|
title: t("holdings.cost_basis_cell.overwrite_confirm_title"),
|
|
body: t("holdings.cost_basis_cell.overwrite_confirm_body", current: format_money(@holding.avg_cost))
|
|
}
|
|
end
|
|
%>
|
|
<%= styled_form_with model: @holding,
|
|
url: holding_path(@holding),
|
|
method: :patch,
|
|
class: "space-y-3",
|
|
data: drawer_form_data do |f| %>
|
|
<p class="text-xs text-secondary mb-2">
|
|
<%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: format_quantity(@holding.qty)) %>
|
|
</p>
|
|
<!-- Total cost basis input -->
|
|
<div class="form-field">
|
|
<div class="form-field__body">
|
|
<label class="form-field__label"><%= t("holdings.cost_basis_cell.total_cost_basis_label") %></label>
|
|
<div class="flex items-center gap-1">
|
|
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
|
<input type="text" inputmode="decimal"
|
|
name="holding[cost_basis]"
|
|
class="form-field__input grow"
|
|
placeholder="0.00"
|
|
autocomplete="off"
|
|
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
|
data-action="input->drawer-cost-basis#updatePerShare"
|
|
data-drawer-cost-basis-target="total">
|
|
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="text-xs text-secondary -mt-2">
|
|
= <%= currency.symbol %><span data-drawer-cost-basis-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t("holdings.cost_basis_cell.per_share") %>
|
|
</p>
|
|
<!-- Per-share input -->
|
|
<div class="pt-2 border-t border-tertiary">
|
|
<label class="text-xs text-secondary block mb-1"><%= t("holdings.cost_basis_cell.or_per_share_label") %></label>
|
|
<div class="flex items-center gap-1">
|
|
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
|
<input type="text" inputmode="decimal"
|
|
class="form-field__input grow"
|
|
placeholder="0.00"
|
|
autocomplete="off"
|
|
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
|
data-action="input->drawer-cost-basis#updateTotal"
|
|
data-drawer-cost-basis-target="perShare">
|
|
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<button type="button"
|
|
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
|
|
data-action="click->drawer-cost-basis#toggle">
|
|
<%= t("holdings.cost_basis_cell.cancel") %>
|
|
</button>
|
|
<%= f.submit t("holdings.cost_basis_cell.save"), class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-between text-sm">
|
|
<dt class="text-secondary"><%= t(".book_value_label") %></dt>
|
|
<dd class="text-primary">
|
|
<% book_value = @holding.avg_cost ? @holding.avg_cost * @holding.qty : nil %>
|
|
<%= book_value ? format_money(book_value) : t(".unknown") %>
|
|
</dd>
|
|
</div>
|
|
<div id="<%= dom_id(@holding, :market_value) %>" class="flex items-center justify-between text-sm">
|
|
<dt class="text-secondary"><%= t(".market_value_label") %></dt>
|
|
<dd class="text-primary"><%= format_money(@holding.amount_money) %></dd>
|
|
</div>
|
|
<div id="<%= dom_id(@holding, :total_return) %>" class="flex items-center justify-between text-sm">
|
|
<dt class="text-secondary"><%= t(".total_return_label") %></dt>
|
|
<% if @holding.trend %>
|
|
<dd style="color: <%= @holding.trend.color %>;">
|
|
<%= render("shared/trend_change", trend: @holding.trend) %>
|
|
</dd>
|
|
<% else %>
|
|
<dd class="text-secondary"><%= t(".unknown") %></dd>
|
|
<% end %>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
<% end %>
|
|
<% dialog.with_section(title: t(".history"), open: true) do %>
|
|
<div class="space-y-2">
|
|
<div class="px-3 py-4">
|
|
<% if @holding.trades.any? %>
|
|
<ul class="space-y-2">
|
|
<% @holding.trades.each_with_index do |trade_entry, index| %>
|
|
<li class="flex gap-4 text-sm space-y-1">
|
|
<div class="flex flex-col items-center gap-1.5 pt-2">
|
|
<div class="rounded-full h-1.5 w-1.5 bg-gray-300"></div>
|
|
<% unless index == @holding.trades.length - 1 %>
|
|
<div class="h-12 w-px bg-alpha-black-200"></div>
|
|
<% end %>
|
|
</div>
|
|
<div>
|
|
<p class="text-secondary text-xs uppercase"><%= l(trade_entry.date, format: :long) %></p>
|
|
<p class="text-primary"><%= t(
|
|
".trade_history_entry",
|
|
qty: trade_entry.trade.qty,
|
|
security: trade_entry.trade.security.ticker,
|
|
price: trade_entry.trade.price_money.format
|
|
) %></p>
|
|
</div>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
<% else %>
|
|
<p class="text-secondary"><%= t(".no_trade_history") %></p>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% if @holding.cost_basis_locked? || @holding.security_remapped? || @holding.account.can_delete_holdings? || !@holding.security.offline? %>
|
|
<% dialog.with_section(title: t(".settings"), open: true) do %>
|
|
<div class="pb-4">
|
|
<% if @holding.security_remapped? %>
|
|
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".security_remapped_label") %></h4>
|
|
<p class="text-secondary"><%= t(".provider_sent", ticker: @holding.provider_security.ticker) %></p>
|
|
</div>
|
|
<%= button_to t(".reset_to_provider"),
|
|
reset_security_holding_path(@holding),
|
|
method: :post,
|
|
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600",
|
|
form: { data: { turbo: false } },
|
|
data: { turbo_confirm: {
|
|
title: t(".reset_confirm_title"),
|
|
body: t(".reset_confirm_body", current: @holding.security.ticker, original: @holding.provider_security.ticker)
|
|
} } %>
|
|
</div>
|
|
<% end %>
|
|
<% unless @holding.security.offline? %>
|
|
<div id="<%= dom_id(@holding, :market_data_section) %>" class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".market_data_label") %></h4>
|
|
<p class="text-secondary">
|
|
<%= t(".last_price_update") %>: <%= @last_price_updated ? l(@last_price_updated, format: :long) : t(".never") %>
|
|
</p>
|
|
</div>
|
|
<%= button_to t(".market_data_sync_button"),
|
|
sync_prices_holding_path(@holding),
|
|
method: :post,
|
|
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600",
|
|
data: { loading_button_target: "button" },
|
|
form: { data: {
|
|
controller: "loading-button",
|
|
action: "submit->loading-button#showLoading",
|
|
loading_button_loading_text_value: t(".syncing")
|
|
} } %>
|
|
</div>
|
|
<% end %>
|
|
<% if @holding.cost_basis_locked? %>
|
|
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".cost_basis_locked_label") %></h4>
|
|
<p class="text-secondary"><%= t(".cost_basis_locked_description") %></p>
|
|
</div>
|
|
<%= button_to t(".unlock_cost_basis"),
|
|
unlock_cost_basis_holding_path(@holding),
|
|
method: :post,
|
|
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600",
|
|
form: { data: { turbo: false } },
|
|
data: { turbo_confirm: { title: t(".unlock_confirm_title"), body: t(".unlock_confirm_body") } } %>
|
|
</div>
|
|
<% end %>
|
|
<% if @holding.account.can_delete_holdings? %>
|
|
<div class="flex items-center justify-between gap-2 p-3">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
|
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
|
</div>
|
|
<%= button_to t(".delete"),
|
|
holding_path(@holding),
|
|
method: :delete,
|
|
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
|
|
data: { turbo_confirm: true } %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|