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
139 lines
6.7 KiB
ERB
139 lines
6.7 KiB
ERB
<%# locals: (occurrence:, date_label: false, suggestion: nil, disambiguate: false) %>
|
|
<% series = occurrence.recurring_transaction %>
|
|
<% expected = occurrence.resolved_expected_amount_money %>
|
|
<% paid = occurrence.confirmed_allocated_money %>
|
|
<% pane_frame = dom_id(occurrence, :pane) %>
|
|
|
|
<%# SCAN. What is this, and does it need me?
|
|
Everything past that question lives one tap deeper, in the expansion.
|
|
|
|
An autopaying bill is still worth seeing and still counts toward the total,
|
|
but it recedes so the rows that want something from you carry the weight. %>
|
|
<div>
|
|
<%# The row reads as interactive without dressing up as a button: a hover tint
|
|
and the name underlining, the same cues the transaction list uses.
|
|
|
|
The tint hangs off this element rather than a group on the wrapper. The
|
|
wrapper also holds the expansion, so a group hover kept the row lit while
|
|
the pointer was down in the expanded pane, and the lit band clipped against
|
|
the pane's own fill. Hover still propagates from every child of the row, so
|
|
the trailing action tints it exactly as before. %>
|
|
<div class="flex items-center gap-3 lg:gap-4 p-3 lg:p-4 transition-colors hover:bg-surface-hover <%= "opacity-70" if series.autopay? %>">
|
|
<%# The bulk of the row is the inspect control. Only the trailing verb sits
|
|
outside it, so there are no anchors inside anchors. %>
|
|
<%# Names the cycle it was opened from. Without it the expansion falls back to
|
|
the series' current occurrence, so expanding a settled row described the
|
|
NEXT one and reported it unpaid. %>
|
|
<%= link_to bill_path(series, display: "pane", frame: pane_frame, occurrence: occurrence.id),
|
|
data: { turbo_frame: pane_frame, turbo_prefetch: false },
|
|
class: "flex items-center gap-3 lg:gap-4 min-w-0 flex-1 group" do %>
|
|
<%# A date rail, and only a date. It used to print "Overdue" beside a
|
|
subline already reading "14 days overdue", which spent the row's one
|
|
piece of temporal context saying the same word twice. The colour still
|
|
carries the state; the rail now carries the date the state is about.
|
|
|
|
Desktop-only: on a phone the subline says it in words instead. %>
|
|
<% if date_label %>
|
|
<div class="hidden @lg:block w-14 shrink-0 text-xs <%= occurrence.derived_state == :overdue ? "text-destructive font-medium" : "text-secondary" %>">
|
|
<% if occurrence.due_on == Date.current %>
|
|
<%= t("bills.row_today") %>
|
|
<% else %>
|
|
<%= l(occurrence.due_on, format: "%b %-d") %>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<% if series.merchant&.logo_url.present? %>
|
|
<%= image_tag Setting.transform_brand_fetch_url(series.merchant.logo_url),
|
|
class: "w-9 h-9 rounded-full shrink-0",
|
|
loading: "lazy" %>
|
|
<% else %>
|
|
<%= render DS::FilledIcon.new(
|
|
variant: :text,
|
|
text: series.display_name,
|
|
size: "lg",
|
|
rounded: true
|
|
) %>
|
|
<% end %>
|
|
|
|
<% reason = date_label ? bills_attention_reason(occurrence, suggestion: suggestion) : nil %>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-primary text-sm font-medium truncate group-hover:underline">
|
|
<%= series.display_name %>
|
|
<% if series.transfer? %>
|
|
<span class="text-xs text-secondary font-normal"><%= t("bills.debt_payment") %></span>
|
|
<% end %>
|
|
</p>
|
|
<%# In a dated section the subline says WHY this row needs attention,
|
|
which is the whole point of pulling those rows out of the run. %>
|
|
<p class="text-xs truncate <%= reason && occurrence.derived_state == :overdue ? "text-destructive" : "text-secondary" %>">
|
|
<% if reason %>
|
|
<%= reason %>
|
|
<%# Two rows reading the same name and the same amount need telling
|
|
apart, and the schedule is what actually differs between
|
|
subscription tiers. %>
|
|
<% if disambiguate %>
|
|
<span class="text-secondary">· <%= frequency_label(series) %></span>
|
|
<% end %>
|
|
<% else %>
|
|
<%= date_label ? frequency_label(series) : occurrence_due_label(occurrence) %><%= bills_paid_from_label(series) %><%= bills_autopay_label(series) %>
|
|
<% if disambiguate && !date_label %>
|
|
· <%= frequency_label(series) %>
|
|
<% end %>
|
|
<% end %>
|
|
<% if (progress = series.installment_progress) %>
|
|
· <%= t("bills.installment_progress", done_plus_one: progress.first + 1, total: progress.last) %>
|
|
<% end %>
|
|
</p>
|
|
<% if series.notes.present? %>
|
|
<p class="text-xs text-subdued truncate"><%= series.notes %></p>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%# One amount, and it is the one the next decision turns on. Payment state
|
|
used to be printed twice: a coloured subline on the left and a figure
|
|
on the right. %>
|
|
<div class="shrink-0 text-right min-w-0">
|
|
<p class="text-sm font-medium text-primary privacy-sensitive whitespace-nowrap">
|
|
<% if occurrence.paid? %>
|
|
<%= format_money(paid) %>
|
|
<% elsif occurrence.partially_paid? %>
|
|
<%= format_money(occurrence.remaining_amount_money) %>
|
|
<% else %>
|
|
<%= "~" if occurrence_amount_estimated?(occurrence) %><%= format_money(expected) %>
|
|
<% end %>
|
|
</p>
|
|
<% if occurrence.paid? %>
|
|
<p class="text-xs text-success"><%= occurrence.overpaid? ? t("bills.paid_over_short") : t("bills.paid_label") %></p>
|
|
<% elsif occurrence.partially_paid? %>
|
|
<%# Only when the left-hand subline has not already said it. In a dated
|
|
section the attention reason reads "Partial · $1,612.50 remaining",
|
|
and printing the same arithmetic again on the right is what was
|
|
squeezing the bill's name out of the row. %>
|
|
<% if reason.present? %>
|
|
<p class="text-xs text-secondary"><%= t("bills.remaining_label") %></p>
|
|
<% else %>
|
|
<p class="text-xs text-secondary privacy-sensitive truncate">
|
|
<%= t("bills.partial_progress", paid: format_money(paid), expected: format_money(expected)) %>
|
|
</p>
|
|
<% end %>
|
|
<% elsif occurrence_amount_estimated?(occurrence) && series.has_amount_variance? %>
|
|
<p class="text-xs text-secondary privacy-sensitive">
|
|
<%= t("bills.amount_range",
|
|
min: format_money(series.expected_amount_min_money),
|
|
max: format_money(series.expected_amount_max_money)) %>
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="shrink-0 flex items-center gap-2">
|
|
<%= render "bills/row_action", occurrence: occurrence, suggestion: suggestion %>
|
|
</div>
|
|
</div>
|
|
|
|
<%# The row's expansion: empty until the row is clicked, then the bill's
|
|
current state slides in under the line item. %>
|
|
<%= turbo_frame_tag pane_frame %>
|
|
</div>
|