mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 15:59:02 +00:00
- StatusPill: use functional `text-success` / `text-warning` tokens with matching icon colors and `px-2 py-1`, mirroring `app/views/budget_categories/_budget_category.html.erb:29-43`. - ProgressRing: rework center text to match `_budget_donut.html.erb` (small "Saved" label, `text-3xl font-medium` headline, "of $X" underline). Stroke color now derives from goal.status (yellow when behind, blue on track, green reached, gray for no-date). - GoalCard bar: track height + transition match budget category bar (`h-1.5`, `transition-all duration-500`, `inline-size`). - Index/show layouts: render page header inline (`<h1>` + actions). The default application layout doesn't yield `:page_actions`, so the CTA + kebab menu wouldn't appear when emitted via `content_for`. - Stepper review summary: target the actual form inputs by `name` rather than relying on the `data-target` Stimulus attribute, since `money_field` puts the attribute on the wrapper. Step 1 validation scoped to the step 1 panel. - Demo generator: filter Depository accounts via `where(accountable_type: "Depository")` — Rails delegated_type generates the `depository?` predicate, not a `.depository` scope.
130 lines
4.0 KiB
JavaScript
130 lines
4.0 KiB
JavaScript
import { Controller } from "@hotwired/stimulus";
|
|
|
|
// 2-step modal stepper for creating a savings goal.
|
|
//
|
|
// Single <form> with two panels. Step 1 collects identity (name, amount,
|
|
// date, color, notes). Step 2 collects ≥1 linked depository accounts and
|
|
// optionally an initial contribution. Submit button stays disabled until at
|
|
// least one linked account is selected. Step state lives entirely in the
|
|
// DOM — no half-records.
|
|
export default class extends Controller {
|
|
static targets = [
|
|
"step1Panel",
|
|
"step2Panel",
|
|
"step1Indicator",
|
|
"step2Indicator",
|
|
"step1Field",
|
|
"nameField",
|
|
"targetAmountField",
|
|
"linkedAccountCheckbox",
|
|
"initialContributionAmount",
|
|
"initialContributionAccountSelect",
|
|
"reviewPanel",
|
|
"reviewName",
|
|
"reviewAmount",
|
|
"reviewDate",
|
|
"reviewAccounts",
|
|
"submitButton",
|
|
];
|
|
|
|
next(event) {
|
|
event?.preventDefault?.();
|
|
if (!this.validateStep1()) return;
|
|
|
|
this.step1PanelTarget.classList.add("hidden");
|
|
this.step2PanelTarget.classList.remove("hidden");
|
|
this.markStepActive(2);
|
|
this.updateReview();
|
|
this.refreshSubmitState();
|
|
}
|
|
|
|
back(event) {
|
|
event?.preventDefault?.();
|
|
this.step2PanelTarget.classList.add("hidden");
|
|
this.step1PanelTarget.classList.remove("hidden");
|
|
this.markStepActive(1);
|
|
}
|
|
|
|
linkedAccountChanged() {
|
|
this.refreshAccountSelect();
|
|
this.refreshSubmitState();
|
|
this.updateReview();
|
|
}
|
|
|
|
validateStep1() {
|
|
const requiredInputs = this.step1PanelTarget.querySelectorAll(
|
|
'input[name="savings_goal[name]"], input[name="savings_goal[target_amount]"]'
|
|
);
|
|
let ok = true;
|
|
requiredInputs.forEach((input) => {
|
|
if (typeof input.checkValidity === "function" && !input.checkValidity()) {
|
|
input.reportValidity();
|
|
ok = false;
|
|
}
|
|
});
|
|
return ok;
|
|
}
|
|
|
|
refreshSubmitState() {
|
|
const anyChecked = this.linkedAccountCheckboxTargets.some((cb) => cb.checked);
|
|
this.submitButtonTarget.disabled = !anyChecked;
|
|
}
|
|
|
|
refreshAccountSelect() {
|
|
if (!this.hasInitialContributionAccountSelectTarget) return;
|
|
|
|
const select = this.initialContributionAccountSelectTarget;
|
|
const previous = select.value;
|
|
select.innerHTML = "";
|
|
const blank = document.createElement("option");
|
|
blank.value = "";
|
|
blank.textContent = select.dataset.blankLabel || "—";
|
|
select.appendChild(blank);
|
|
|
|
this.linkedAccountCheckboxTargets
|
|
.filter((cb) => cb.checked)
|
|
.forEach((cb) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = cb.value;
|
|
opt.textContent = cb.dataset.accountName || cb.value;
|
|
select.appendChild(opt);
|
|
});
|
|
|
|
if ([...select.options].some((o) => o.value === previous)) {
|
|
select.value = previous;
|
|
}
|
|
}
|
|
|
|
updateReview() {
|
|
if (!this.hasReviewPanelTarget) return;
|
|
|
|
if (this.hasReviewNameTarget) {
|
|
const nameInput = this.element.querySelector('input[name="savings_goal[name]"]');
|
|
this.reviewNameTarget.textContent = nameInput?.value || "—";
|
|
}
|
|
if (this.hasReviewAmountTarget) {
|
|
const amountInput = this.element.querySelector('input[name="savings_goal[target_amount]"]');
|
|
this.reviewAmountTarget.textContent = amountInput?.value || "—";
|
|
}
|
|
if (this.hasReviewDateTarget) {
|
|
const dateInput = this.element.querySelector('input[type="date"][name="savings_goal[target_date]"]');
|
|
this.reviewDateTarget.textContent = dateInput?.value || "—";
|
|
}
|
|
if (this.hasReviewAccountsTarget) {
|
|
const names = this.linkedAccountCheckboxTargets
|
|
.filter((cb) => cb.checked)
|
|
.map((cb) => cb.dataset.accountName || cb.value);
|
|
this.reviewAccountsTarget.textContent = names.length ? names.join(", ") : "—";
|
|
}
|
|
}
|
|
|
|
markStepActive(stepNumber) {
|
|
if (this.hasStep1IndicatorTarget) {
|
|
this.step1IndicatorTarget.classList.toggle("text-primary", stepNumber === 1);
|
|
}
|
|
if (this.hasStep2IndicatorTarget) {
|
|
this.step2IndicatorTarget.classList.toggle("text-primary", stepNumber === 2);
|
|
}
|
|
}
|
|
}
|