mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* feat(bills): the bills pages, calendar feed and in-page AI helpers Second of three chunks carved out of #3083, stacked on the schema and domain core. This is everything a user sees and clicks. The whole surface sits behind the preview flag, so it is unreachable until someone opts in. Pages, all under one nav entry: - the pay run, a month calendar, the full bills table, and the paycheck planner - a detail drawer per bill, with payment history, price changes and cost analytics - create and edit flows for bills, subscriptions, installment plans and income The overview marks pay periods inside the month, so a weekly paycheck no longer reads as one undifferentiated month of bills. Markers appear only when income actually subdivides the month, which means monthly and undeclared income render exactly as before and there is no new setting to configure. Navigation and design system: - one preview-gated nav item shared by the desktop rail and the mobile bar - DS::Sparkline for payment-history charts, replacing raw SVG in views - status badges render through DS::Pill rather than hand-rolled spans - the suggestions panel is a disclosure that remembers being collapsed, per device, the way privacy mode and the sidebar width already do - every surface reflows to phone widths without horizontal scroll Calendar feed: a signed ICS feed per family, served sessionless by token, with a reset that revokes previously shared URLs. In-page AI helpers: smart fill on the bill form and a smart configuration proposal on an existing bill, each reading a bounded slice of charge history. Provider-side prompt assembly sits behind the existing LlmConcept interface, with an implementation for each of the two providers. These belong here rather than with the assistant tools because they are buttons on these pages and lean on the provider suggester, not on the tool registry. Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces. * Render the suggested strip through DS::Disclosure The hand-rolled details pair predates the component. The card_inset variant is the same shape, so the strip now inherits the design system chrome, and the persisted-disclosure controller rides along unchanged. * Route the remaining hand-rolled chips through the design system The subscription-state chips, rule-match chips and match-reason chips become DS::Pill, with the state chips extracted to one shared partial so the drawer and the summary tab stop carrying copy-pasted markup. The AI prompt chips become DS::Button and the bills-index filter becomes DS::SearchInput, both of which this PR already uses elsewhere for the same shapes. * Fix erb_lint whitespace offenses in bills views * Address the post-ready review round * Require a writable destination account and gate the feed on preview * Reject an unresolvable declared account out loud
458 lines
22 KiB
ERB
458 lines
22 KiB
ERB
<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
|
|
<% dialog.with_header(custom_header: true) do %>
|
|
<div class="flex items-start justify-between gap-4">
|
|
<%= render "transactions/header", entry: @entry %>
|
|
<%= dialog.close_button %>
|
|
</div>
|
|
<% end %>
|
|
<% dialog.with_body do %>
|
|
<%# Potential duplicate alert %>
|
|
<% if @entry.transaction.has_potential_duplicate? %>
|
|
<% potential_match = @entry.transaction.potential_duplicate_entry %>
|
|
<% if potential_match %>
|
|
<div class="mx-4 my-3 p-4 rounded-lg border border-warning bg-warning/5">
|
|
<div class="flex items-start gap-3">
|
|
<%= icon "alert-triangle", size: "md", color: "warning" %>
|
|
<div class="flex-1 space-y-2">
|
|
<h4 class="text-sm font-medium text-primary"><%= t("transactions.show.potential_duplicate_title") %></h4>
|
|
<p class="text-sm text-secondary"><%= t("transactions.show.potential_duplicate_description") %></p>
|
|
<div class="mt-3 p-3 rounded bg-container border border-primary">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-primary"><%= potential_match.name %></p>
|
|
<p class="text-xs text-secondary"><%= potential_match.date.strftime("%b %d, %Y") %> • <%= potential_match.account.name %></p>
|
|
</div>
|
|
<p class="text-sm font-medium privacy-sensitive <%= potential_match.amount.negative? ? "text-green-600" : "text-primary" %>">
|
|
<%= format_money(-potential_match.amount_money) %>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-3">
|
|
<%= render DS::Button.new(
|
|
text: t("transactions.show.merge_duplicate"),
|
|
href: merge_duplicate_transaction_path(@entry.transaction),
|
|
variant: :primary,
|
|
size: :sm,
|
|
data: { turbo_method: :post, turbo_frame: "_top" }
|
|
) %>
|
|
<%= render DS::Button.new(
|
|
text: t("transactions.show.keep_both"),
|
|
href: dismiss_duplicate_transaction_path(@entry.transaction),
|
|
variant: :outline,
|
|
size: :sm,
|
|
data: { turbo_method: :post, turbo_frame: "_top" }
|
|
) %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %>
|
|
<% dialog.with_section(title: t(".overview"), open: true) do %>
|
|
<div>
|
|
<% split_locked = @entry.split_child? || @entry.split_parent? %>
|
|
<% edit_locked = !can_edit_entry? %>
|
|
<% annotate_locked = !can_annotate_entry? %>
|
|
<%= styled_form_with model: @entry,
|
|
url: transaction_path(@entry),
|
|
class: "space-y-2",
|
|
data: { controller: "auto-submit-form" } do |f| %>
|
|
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
|
|
<%= f.text_field :name,
|
|
label: t(".name_label"),
|
|
disabled: @entry.split_child? || edit_locked,
|
|
"data-auto-submit-form-target": "auto" %>
|
|
<%= f.date_field :date,
|
|
label: t(".date_label"),
|
|
max: Date.current,
|
|
disabled: @entry.linked? || split_locked || edit_locked,
|
|
"data-auto-submit-form-target": "auto" %>
|
|
<% unless @entry.transaction.transfer? %>
|
|
<div class="flex items-center gap-2">
|
|
<%= f.select :nature,
|
|
[[t("shared.transaction_tabs.expense"), "outflow"], [t("shared.transaction_tabs.income"), "inflow"]],
|
|
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
|
|
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? || split_locked || edit_locked } %>
|
|
<%= f.money_field :amount, label: t(".amount"),
|
|
container_class: "w-2/3",
|
|
auto_submit: true,
|
|
min: 0,
|
|
value: @entry.amount.abs,
|
|
disabled: @entry.linked? || split_locked || edit_locked,
|
|
disable_currency: @entry.linked? || split_locked || edit_locked %>
|
|
</div>
|
|
<%= f.fields_for :entryable do |ef| %>
|
|
<%= render DS::CategorySelect.new(
|
|
form: ef,
|
|
categories: Current.family.categories.alphabetically_by_hierarchy,
|
|
selected_id: ef.object.category_id,
|
|
disabled: @entry.split_child? || annotate_locked,
|
|
auto_submit: true,
|
|
blank_label: t(".uncategorized")
|
|
) %>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% dialog.with_section(title: t(".details")) do %>
|
|
<div class="space-y-2">
|
|
<%= styled_form_with model: @entry,
|
|
url: transaction_path(@entry),
|
|
class: "space-y-2",
|
|
data: { controller: "auto-submit-form" } do |f| %>
|
|
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
|
|
<% unless @entry.transaction.transfer? %>
|
|
<%= f.select :account,
|
|
options_for_select(
|
|
accessible_accounts.alphabetically.pluck(:name, :id),
|
|
@entry.account_id
|
|
),
|
|
{ label: t(".account_label") },
|
|
{ disabled: true } %>
|
|
<%= f.fields_for :entryable do |ef| %>
|
|
<%= render DS::MerchantSelect.new(
|
|
form: ef,
|
|
method: :merchant_id,
|
|
merchants: Current.family.available_merchants_for(Current.user).alphabetically,
|
|
selected_id: ef.object.merchant_id,
|
|
include_blank: t(".none"),
|
|
label: t(".merchant_label"),
|
|
disabled: @entry.split_child? || !can_annotate_entry?,
|
|
auto_submit: true
|
|
) %>
|
|
<%= render DS::TagSelect.new(
|
|
form: ef,
|
|
tags: Current.family.tags.alphabetically,
|
|
selected_ids: ef.object.tag_ids,
|
|
disabled: !can_annotate_entry?,
|
|
auto_submit: true,
|
|
update_url: tags_transaction_path(@entry)
|
|
) %>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
<%= render "transactions/notes", entry: @entry, can_annotate: can_annotate_entry? %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<% dialog.with_section(title: t(".attachments")) do %>
|
|
<%= render "transactions/attachments", transaction: @entry.transaction, can_upload: can_annotate_entry?, can_delete: can_edit_entry? %>
|
|
<% end %>
|
|
|
|
<% if (details = build_transaction_extra_details(@entry)) %>
|
|
<% dialog.with_section(title: t(".additional_details"), open: false) do %>
|
|
<div class="px-3 py-2 space-y-3">
|
|
<% if details[:kind] == :simplefin %>
|
|
<% sf = details[:simplefin] %>
|
|
<% if sf.present? %>
|
|
<dl class="space-y-2">
|
|
<% if sf[:payee].present? %>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<dt class="text-secondary text-sm"><%= t(".payee") %></dt>
|
|
<dd class="text-sm text-primary"><%= sf[:payee] %></dd>
|
|
</div>
|
|
<% end %>
|
|
<% if sf[:description].present? %>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<dt class="text-secondary text-sm"><%= t(".description") %></dt>
|
|
<dd class="text-sm text-primary"><%= sf[:description] %></dd>
|
|
</div>
|
|
<% end %>
|
|
<% if sf[:memo].present? %>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<dt class="text-secondary text-sm"><%= t(".memo") %></dt>
|
|
<dd class="text-sm text-primary"><%= sf[:memo] %></dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
<% end %>
|
|
<% if details[:provider_extras].present? %>
|
|
<div class="pt-2">
|
|
<h4 class="text-sm text-secondary mb-1"><%= t(".provider_extras") %></h4>
|
|
<dl class="space-y-2">
|
|
<% details[:provider_extras].each do |ex| %>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<dt class="text-secondary text-sm"><%= ex[:key] %></dt>
|
|
<dd class="text-sm text-primary truncate max-w-[60%]" title="<%= ex[:title] %>"><%= ex[:value] %></dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
<% end %>
|
|
<% else %>
|
|
<pre class="text-xs text-secondary bg-surface-inset rounded p-2 overflow-auto"><%= details[:raw] %></pre>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
<%# Split children list for split parent %>
|
|
<% if @entry.split_parent? %>
|
|
<% dialog.with_section(title: t("splits.show.title"), open: true) do %>
|
|
<div class="px-3 py-2 space-y-2">
|
|
<p class="text-xs text-secondary"><%= t("splits.show.description") %></p>
|
|
<% @entry.child_entries.includes(:entryable).each do |child| %>
|
|
<div class="flex items-center justify-between p-2 rounded-lg border border-primary">
|
|
<div>
|
|
<p class="text-sm font-medium text-primary"><%= child.name %></p>
|
|
<p class="text-xs text-secondary"><%= child.entryable.try(:category)&.name || t("splits.new.uncategorized") %></p>
|
|
</div>
|
|
<p class="text-sm font-medium privacy-sensitive <%= child.amount.negative? ? "text-green-600" : "text-primary" %>">
|
|
<%= format_money(-child.amount_money) %>
|
|
</p>
|
|
</div>
|
|
<% end %>
|
|
<div class="flex items-center gap-3 pt-2">
|
|
<%= render DS::Link.new(
|
|
text: t("splits.child.edit_split"),
|
|
icon: "pencil",
|
|
variant: "ghost",
|
|
size: :sm,
|
|
href: edit_transaction_split_path(@entry),
|
|
frame: :modal
|
|
) %>
|
|
<%= render DS::Button.new(
|
|
text: t("splits.show.unsplit_button"),
|
|
icon: "undo-2",
|
|
variant: "ghost",
|
|
size: :sm,
|
|
class: "text-destructive",
|
|
href: transaction_split_path(@entry),
|
|
method: :delete,
|
|
confirm: CustomConfirm.new(title: t("splits.show.unsplit_title"), body: t("splits.show.unsplit_confirm"), btn_text: t("splits.show.unsplit_button"), destructive: true),
|
|
frame: "_top"
|
|
) %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<%# For split child, show parent info and actions %>
|
|
<% if @entry.split_child? %>
|
|
<% dialog.with_section(title: t("splits.child.title"), open: true) do %>
|
|
<div class="px-3 py-2">
|
|
<% parent = @entry.parent_entry %>
|
|
<% if parent %>
|
|
<div class="p-3 rounded-lg border border-secondary space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-primary"><%= parent.name %></p>
|
|
<p class="text-xs text-secondary"><%= parent.date.strftime("%b %d, %Y") %></p>
|
|
</div>
|
|
<p class="text-sm font-medium privacy-sensitive text-primary">
|
|
<%= format_money(-parent.amount_money) %>
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-3 border-t border-primary pt-2">
|
|
<%= render DS::Link.new(
|
|
text: t("splits.child.edit_split"),
|
|
icon: "pencil",
|
|
variant: "ghost",
|
|
size: :sm,
|
|
href: edit_transaction_split_path(parent),
|
|
frame: :modal
|
|
) %>
|
|
<%= render DS::Button.new(
|
|
text: t("splits.child.unsplit"),
|
|
icon: "undo-2",
|
|
variant: "ghost",
|
|
size: :sm,
|
|
class: "text-destructive",
|
|
href: transaction_split_path(parent),
|
|
method: :delete,
|
|
confirm: CustomConfirm.new(title: t("splits.show.unsplit_title"), body: t("splits.show.unsplit_confirm"), btn_text: t("splits.show.unsplit_button"), destructive: true),
|
|
frame: "_top"
|
|
) %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<% if can_edit_entry? %>
|
|
<% dialog.with_section(title: t(".settings")) do %>
|
|
<% unless @entry.split_parent? %>
|
|
<div class="pb-4">
|
|
<%= styled_form_with model: @entry,
|
|
url: transaction_path(@entry),
|
|
class: "p-3",
|
|
data: { controller: "auto-submit-form" } do |f| %>
|
|
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
|
|
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".exclude") %></h4>
|
|
<p class="text-secondary"><%= t(".exclude_description") %></p>
|
|
</div>
|
|
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% if @entry.account.investment? || @entry.account.crypto? %>
|
|
<div class="pb-4">
|
|
<%= styled_form_with model: @entry,
|
|
url: transaction_path(@entry),
|
|
class: "p-3",
|
|
data: { controller: "auto-submit-form" } do |f| %>
|
|
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
|
|
<%= f.fields_for :entryable do |ef| %>
|
|
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".activity_type") %></h4>
|
|
<p class="text-secondary"><%= t(".activity_type_description") %></p>
|
|
</div>
|
|
<%= ef.select :investment_activity_label,
|
|
options_for_select(
|
|
[["—", nil]] + Transaction::ACTIVITY_LABELS.map { |l| [t("transactions.activity_labels.#{l.parameterize(separator: '_')}"), l] },
|
|
@entry.entryable.investment_activity_label
|
|
),
|
|
{ label: false },
|
|
{ class: "form-field__input border border-secondary rounded-lg px-3 py-1.5 max-w-40 text-sm",
|
|
data: { auto_submit_form_target: "auto" } } %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% unless @entry.split_child? %>
|
|
<div class="pb-4">
|
|
<%= styled_form_with model: @entry,
|
|
url: transaction_path(@entry),
|
|
class: "p-3",
|
|
data: { controller: "auto-submit-form" } do |f| %>
|
|
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
|
|
<%= f.fields_for :entryable do |ef| %>
|
|
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %></h4>
|
|
<p class="text-secondary"><%= t(".one_time_description") %></p>
|
|
</div>
|
|
<%= ef.toggle :kind, {
|
|
checked: @entry.transaction.one_time?,
|
|
data: { auto_submit_form_target: "auto" }
|
|
}, "one_time", "standard" %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<%# Split Transaction %>
|
|
<% if @entry.transaction.splittable? %>
|
|
<div class="flex items-center justify-between gap-4 p-3">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t("splits.show.button_title") %></h4>
|
|
<p class="text-secondary"><%= t("splits.show.button_description") %></p>
|
|
</div>
|
|
<%= render DS::Link.new(
|
|
text: t("splits.show.button"),
|
|
icon: "split",
|
|
variant: "outline",
|
|
href: new_transaction_split_path(@entry),
|
|
frame: :modal
|
|
) %>
|
|
</div>
|
|
<% end %>
|
|
<% unless @entry.split_child? %>
|
|
<div class="flex items-center justify-between gap-4 p-3">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".transfer_or_debt_payment") %></h4>
|
|
<p class="text-secondary"><%= t(".transfer_matcher_description") %></p>
|
|
</div>
|
|
<%= render DS::Link.new(
|
|
text: t(".open_matcher"),
|
|
icon: "arrow-left-right",
|
|
variant: "outline",
|
|
href: new_transaction_transfer_match_path(@entry),
|
|
frame: :modal
|
|
) %>
|
|
</div>
|
|
<!-- Pending Duplicate Merger -->
|
|
<% if @entry.entryable.is_a?(Transaction) && @entry.entryable.pending? %>
|
|
<div class="flex items-center justify-between gap-4 p-3">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t("transactions.show.pending_duplicate_merger_title") %></h4>
|
|
<p class="text-secondary"><%= t("transactions.show.pending_duplicate_merger_description") %></p>
|
|
</div>
|
|
<%= render DS::Link.new(
|
|
text: t("transactions.show.pending_duplicate_merger_button"),
|
|
icon: "merge",
|
|
variant: "outline",
|
|
href: new_transaction_pending_duplicate_merges_path(@entry),
|
|
frame: :modal
|
|
) %>
|
|
</div>
|
|
<% end %>
|
|
<!-- Convert to Trade (Investment Accounts Only, not already converted) -->
|
|
<% if @entry.account.investment? && @entry.entryable.is_a?(Transaction) && !@entry.excluded? %>
|
|
<div class="flex items-center justify-between gap-2 p-3">
|
|
<div class="text-sm space-y-1">
|
|
<h4 class="text-primary"><%= t(".convert_to_trade_title") %></h4>
|
|
<p class="text-secondary"><%= t(".convert_to_trade_description") %></p>
|
|
</div>
|
|
<%= render DS::Button.new(
|
|
text: t(".convert"),
|
|
variant: "outline",
|
|
icon: "arrow-right-left",
|
|
href: convert_to_trade_transaction_path(@entry.transaction),
|
|
method: :get,
|
|
frame: :modal
|
|
) %>
|
|
</div>
|
|
<% end %>
|
|
<%# Which bills this transaction paid. Bills has always linked out to
|
|
transactions; without this the trip back was a dead end. Loading
|
|
and preview-gating live with BillsHelper#entry_bill_allocations. %>
|
|
<% applied_to = entry_bill_allocations(@entry) %>
|
|
<% if applied_to.any? %>
|
|
<div class="p-3 space-y-2">
|
|
<h4 class="text-sm text-primary"><%= t(".applied_to_title") %></h4>
|
|
<% applied_to.each do |allocation| %>
|
|
<% occurrence = allocation.recurring_occurrence %>
|
|
<div class="flex items-center justify-between gap-2 text-sm">
|
|
<div>
|
|
<%= link_to occurrence.recurring_transaction.display_name,
|
|
bill_path(occurrence.recurring_transaction),
|
|
class: "text-primary font-medium hover:underline",
|
|
data: { turbo_frame: "_top" } %>
|
|
<p class="text-secondary text-xs privacy-sensitive">
|
|
<%= t(".applied_to_detail",
|
|
amount: format_money(allocation.allocated_amount_money),
|
|
date: l(occurrence.due_on, format: :long)) %>
|
|
</p>
|
|
</div>
|
|
<% if allocation.allocation_suggested? %>
|
|
<span class="text-xs text-warning"><%= t(".applied_to_unreviewed") %></span>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Mark as Recurring Form -->
|
|
<%= render "transactions/mark_recurring", entry: @entry %>
|
|
<% end %>
|
|
<%# Delete Transaction Form - hidden for split children %>
|
|
<% unless @entry.split_child? %>
|
|
<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>
|
|
<%= render DS::Button.new(
|
|
text: t(".delete"),
|
|
variant: "outline-destructive",
|
|
href: entry_path(@entry),
|
|
method: :delete,
|
|
confirm: CustomConfirm.for_resource_deletion("transaction"),
|
|
frame: "_top"
|
|
) %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|