mirror of
https://github.com/we-promise/sure.git
synced 2026-07-18 07:45:24 +00:00
* feat(goals): earmark a portion of an account toward a goal Goals currently count each linked account's whole balance, so an account shared across goals double-counts and one account can't fund several goals in distinct slices. Add a per-account earmark — the "GoalBacking" the v1 model already foreshadowed (goal.rb). - goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole balance" (the v1 default: no backfill, existing goals unchanged); a set amount reserves a fixed slice. - Goal#current_balance is now the single chokepoint computing each account's backing under a family-wide shared pool: fixed earmarks take their slice, an unallocated link takes the remainder, and when fixed earmarks exceed the balance every slice is scaled down pro-rata so the goals' shares can never sum past the account balance (no double-counting). - Account#free_to_earmark / #goal_earmarked_total (mirror Budget's available_to_allocate) back a soft, non-blocking over-allocation hint. - GoalsController threads a goal[allocations] hash through create/update. Phase 1 of the goals earmarking work; investment-backed goals follow. * feat(goals): earmark UI on the goal form + backing-aware funding breakdown - Goal form: a per-account "earmark amount" input (blank = whole balance) next to each funding-account checkbox, prefilled from the saved allocation on edit. - Goal#account_backing exposes a single linked account's share so the funding-accounts breakdown shows each account's earmarked contribution and percent instead of its whole balance — keeping the show page consistent with the (now allocation-aware) progress ring. - English strings for the earmark controls and the "earmarked of balance" breakdown line. * fix(goals): address review on the earmark shared-pool math - Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and whole-balance paths. The fixed path previously produced negative backing and let a goal claim money the account doesn't hold. - An archived goal reads its OWN earmark from its own goal_accounts instead of the shared pool (which excludes archived goals), so it no longer mis-reports the whole account balance for itself. - goals#index injects one family-wide earmark pool into every card (Goal.pooled_allocations_for) instead of querying once per goal (N+1), and preloads goal_accounts. - The projection chart scales its whole-account historical series by the backing ratio so the saved line meets current_balance at "today" rather than dropping off a cliff for earmarked goals. - Honest comments: free_to_earmark no longer claims a form warning that doesn't exist yet; pace documents its deliberate whole-account basis. * fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped * fix(goals): address review on #2490 - autosave: true on goal_accounts so earmark edits to already-linked accounts persist through goal.save! (Rails only auto-saves newly built children, so changing/clearing an existing earmark was silently dropped). + test. - Reset the balance/progress memos on AASM transitions, not just the status memos, so a same-instance render after complete!/archive! isn't stale. + test. - backing_ratio is 0 (not 1) when the linked-account total is non-positive, so the projection saved series ends at 0 to match the forced-zero current_balance. - Localize the funding-row subtype label via goals.form.subtypes.*. - Add the earmark strings to zh-CN (the maintained second locale; goals has no ca locale, so Catalan keeps falling back to en like the rest of goals). --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
101 lines
6.1 KiB
Plaintext
101 lines
6.1 KiB
Plaintext
<%# locals: (goal:, linkable_accounts:, currently_linked_account_ids: []) %>
|
|
|
|
<div data-controller="goal-form"
|
|
data-goal-form-currency-value="<%= Current.family.primary_currency_code %>"
|
|
data-goal-form-require-account-value="<%= !goal.persisted? %>"
|
|
data-goal-form-suggested-with-date-value="<%= t("goals.form.suggested_with_date") %>"
|
|
data-goal-form-suggested-no-date-value="<%= t("goals.form.suggested_no_date") %>">
|
|
<%= styled_form_with model: goal,
|
|
url: goal.persisted? ? goal_path(goal) : goals_path,
|
|
method: goal.persisted? ? :patch : :post,
|
|
class: "space-y-5",
|
|
data: { action: "submit->goal-form#validateOnSubmit" } do |f| %>
|
|
<div class="flex justify-center">
|
|
<%= render "color_picker", form: f, colors: Goal::COLORS, icons: Goal::ICONS %>
|
|
</div>
|
|
|
|
<div>
|
|
<%= f.text_field :name,
|
|
placeholder: t("goals.form.fields.name_placeholder"),
|
|
autofocus: true,
|
|
required: true,
|
|
label: t("goals.form.fields.name"),
|
|
data: { goal_form_target: "nameInput", action: "input->goal-form#nameChanged" } %>
|
|
<p class="<%= "hidden" unless goal.errors[:name].any? %> mt-1.5 text-xs text-destructive" data-goal-form-target="nameError"><%= t("goals.form.errors.name_required") %></p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-3 items-start">
|
|
<div>
|
|
<%= f.money_field :target_amount,
|
|
label: t("goals.form.fields.target_amount"),
|
|
hide_currency: true,
|
|
required: true,
|
|
amount_data: { goal_form_target: "amountInput", action: "input->goal-form#suggestedChanged" } %>
|
|
<p class="<%= "hidden" unless goal.errors[:target_amount].any? %> mt-1.5 text-xs text-destructive" data-goal-form-target="amountError"><%= t("goals.form.errors.amount_required") %></p>
|
|
</div>
|
|
<%= f.date_field :target_date,
|
|
label: t("goals.form.fields.target_date"),
|
|
data: { goal_form_target: "dateInput", action: "input->goal-form#suggestedChanged" } %>
|
|
</div>
|
|
|
|
<p class="text-xs text-secondary italic tabular-nums privacy-sensitive hidden" data-goal-form-target="suggested"></p>
|
|
|
|
<div>
|
|
<div class="mb-2">
|
|
<span class="block text-sm font-medium text-primary"><%= t("goals.form.fields.funding_accounts") %></span>
|
|
<p class="text-xs text-secondary mt-0.5"><%= t("goals.form.fields.funding_accounts_hint") %></p>
|
|
<p class="text-xs text-subdued mt-0.5"><%= t("goals.form.fields.earmark_hint") %></p>
|
|
</div>
|
|
<div class="bg-container-inset rounded-lg p-1">
|
|
<% linked_allocation_by_account = goal.goal_accounts.index_by(&:account_id) %>
|
|
<% grouped = linkable_accounts.group_by { |a| a.subtype.to_s.presence || "other" } %>
|
|
<% grouped.each_with_index do |(subtype, accts), group_idx| %>
|
|
<div class="px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t("goals.form.subtypes.#{subtype}", default: subtype.titleize) %></div>
|
|
<div class="bg-container rounded-md <%= "mb-1" if group_idx < grouped.size - 1 %>">
|
|
<% accts.each_with_index do |account, idx| %>
|
|
<% linked_ga = linked_allocation_by_account[account.id] %>
|
|
<div class="flex items-center gap-3 px-3 py-2.5 hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>">
|
|
<label class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
|
|
<%= check_box_tag "goal[account_ids][]",
|
|
account.id,
|
|
currently_linked_account_ids.include?(account.id.to_s),
|
|
id: "goal_account_ids_#{account.id}",
|
|
class: "checkbox checkbox--light shrink-0",
|
|
data: {
|
|
goal_form_target: "linkedAccountCheckbox",
|
|
action: "change->goal-form#linkedAccountChanged"
|
|
} %>
|
|
<%= render Goals::AvatarComponent.new(name: account.name, color: Goals::AvatarComponent.color_for(account.name), size: "md") %>
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
|
|
<% row_subtype = account.subtype.to_s.presence || subtype %>
|
|
<p class="text-xs text-secondary tabular-nums privacy-sensitive"><%= t("goals.form.subtypes.#{row_subtype}", default: row_subtype.titleize) %> · <%= Money.new(account.balance, account.currency).format %></p>
|
|
</div>
|
|
</label>
|
|
<%= text_field_tag "goal[allocations][#{account.id}]",
|
|
linked_ga&.allocated_amount&.to_s("F"),
|
|
placeholder: t("goals.form.fields.whole_balance"),
|
|
inputmode: "decimal",
|
|
autocomplete: "off",
|
|
aria: { label: t("goals.form.fields.earmark_for", account: account.name) },
|
|
class: "shrink-0 w-36 rounded-md border border-primary bg-container px-2.5 py-1.5 text-sm text-right tabular-nums text-primary placeholder:text-subdued focus-ring privacy-sensitive" %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<p class="<%= "hidden" unless goal.errors[:base].any? %> mt-1.5 text-xs text-destructive" data-goal-form-target="accountsError"><%= t("goals.form.errors.accounts_required") %></p>
|
|
</div>
|
|
|
|
<%= f.text_area :notes,
|
|
label: t("goals.form.fields.notes"),
|
|
rows: 2,
|
|
placeholder: t("goals.form.fields.notes_placeholder") %>
|
|
|
|
<div class="flex justify-end pt-2">
|
|
<%= f.submit goal.persisted? ? t("goals.form.save") : t("goals.form.create"),
|
|
data: { goal_form_target: "submitButton" } %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|