Simpl-Resultat/src/components/balance/StarterAccountsModal.tsx
le king fu cd0a2b826f feat(balance): starter accounts + opt-in modal + ADR 0012
Part 1 — New profiles: seed 4 starter accounts in
consolidated_schema.sql (Compte chèque/CELI/REER/Compte
non-enregistré, currency CAD, is_active=1) right after the
balance_categories seeds. Categories resolved via SELECT subquery
on the seeded `key` values for robustness.

Part 2 — Existing profiles: StarterAccountsModal proposes the same
4 starters at first /balance visit. Default-checked checkboxes,
collision rule (case-insensitive trim name + matching category)
disables matches with a "Déjà présent" tooltip. The atomic helper
`proposeStarterAccounts` wraps the inserts in BEGIN/COMMIT (rolls
back on error). user_preferences.balance_starter_proposed records
{shown_at, accepted} so the modal never reappears, dismissed or
confirmed.

Part 3 — docs/adr/0012-balance-two-level-model.md (Proposed):
captures the future vehicles × compositions model for reflection,
no code change. Numbered 0012 because 0011 was already taken by
the providers-best-effort-yahoo ADR. Linked from architecture.md
ADR table and Bilan section.

Tests: StarterAccountsModal.test.tsx covers STARTER_ACCOUNTS shape,
getStarterCollisions (case-insensitive trim, category-scoped) and
proposeStarterAccounts (insert order, COMMIT, ROLLBACK on failure).
No render tests — mirrors the BalanceOnboardingCard pattern (no
jsdom configured).

Resolves #179
2026-05-02 11:59:45 -04:00

209 lines
7.2 KiB
TypeScript

// StarterAccountsModal — one-shot opt-in modal proposing 4 starter accounts
// (Compte chèque, CELI, REER, Compte non-enregistré) to existing profiles
// when they first land on /balance. Issue #179.
//
// Behavior:
// - 4 checkboxes default-checked.
// - Collision rule (case-insensitive trim name + same category): the
// matching checkbox is disabled and uncheckable; tooltip explains why.
// - "Ajouter les comptes sélectionnés" → atomic BEGIN/COMMIT INSERT, then
// onClose(insertedIds).
// - "Plus tard" → no INSERT, onClose([]).
// - Parent owns isOpen state and writes user_preferences.balance_starter_proposed
// in onClose so the modal never re-appears.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Loader2 } from "lucide-react";
import {
STARTER_ACCOUNTS,
getStarterCollisions,
proposeStarterAccounts,
} from "../../services/balance.service";
export interface StarterAccountsModalProps {
/** Parent guard — modal renders only when true. */
isOpen: boolean;
/**
* Fired in both branches (confirm + dismiss). The parent uses the returned
* ids to write `user_preferences.balance_starter_proposed` so the modal
* never re-appears, regardless of which branch was taken.
*/
onClose: (acceptedIds: number[]) => void;
}
export default function StarterAccountsModal({
isOpen,
onClose,
}: StarterAccountsModalProps) {
const { t } = useTranslation();
const [collisions, setCollisions] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(
() => new Set(STARTER_ACCOUNTS.map((s) => s.key))
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [collisionsLoaded, setCollisionsLoaded] = useState(false);
// Load collisions once when the modal opens. We pre-uncheck colliding
// starters (and disable them) so the visible default-checked count matches
// what would actually be inserted.
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
void (async () => {
try {
const c = await getStarterCollisions();
if (cancelled) return;
setCollisions(c);
setSelected((prev) => {
const next = new Set(prev);
for (const k of c) next.delete(k);
return next;
});
setCollisionsLoaded(true);
} catch {
if (!cancelled) setCollisionsLoaded(true);
}
})();
return () => {
cancelled = true;
};
}, [isOpen]);
if (!isOpen) return null;
const toggle = (key: string) => {
if (collisions.has(key)) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const handleAdd = async () => {
if (submitting) return;
setError(null);
setSubmitting(true);
try {
const ids = await proposeStarterAccounts(Array.from(selected));
setSubmitting(false);
onClose(ids);
} catch {
setSubmitting(false);
setError(t("balance.starters.errors.insert"));
}
};
const handleLater = () => {
if (submitting) return;
onClose([]);
};
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="balance-starters-title"
data-testid="balance-starters-modal"
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full">
<div className="flex items-start justify-between p-5 border-b border-[var(--border)]">
<div>
<h2
id="balance-starters-title"
className="text-lg font-semibold"
>
{t("balance.starters.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{t("balance.starters.description")}
</p>
</div>
<button
type="button"
onClick={handleLater}
aria-label={t("common.close")}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</div>
<ul className="p-5 space-y-2" data-testid="balance-starters-list">
{STARTER_ACCOUNTS.map((s) => {
const isCollision = collisions.has(s.key);
const isChecked = selected.has(s.key);
return (
<li key={s.key}>
<label
className={`flex items-center gap-3 p-3 rounded-lg border ${
isCollision
? "border-[var(--border)] opacity-60 cursor-not-allowed"
: "border-[var(--border)] hover:bg-[var(--muted)]/30 cursor-pointer"
}`}
title={
isCollision
? t("balance.starters.collision_tooltip")
: undefined
}
data-testid={`balance-starter-row-${s.key}`}
data-collision={isCollision ? "true" : "false"}
>
<input
type="checkbox"
checked={isChecked}
disabled={isCollision || submitting}
onChange={() => toggle(s.key)}
data-testid={`balance-starter-checkbox-${s.key}`}
/>
<span className="text-sm font-medium">
{t(s.i18nKey)}
</span>
{isCollision && (
<span className="ml-auto text-xs italic text-[var(--muted-foreground)]">
{t("balance.starters.collision_tooltip")}
</span>
)}
</label>
</li>
);
})}
</ul>
{error && (
<div className="mx-5 mb-3 p-2 rounded text-sm bg-[var(--negative)]/10 text-[var(--negative)] border border-[var(--negative)]/20">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2 p-5 border-t border-[var(--border)]">
<button
type="button"
onClick={handleLater}
disabled={submitting}
data-testid="balance-starters-cta-later"
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)]/30 disabled:opacity-50"
>
{t("balance.starters.cta_later")}
</button>
<button
type="button"
onClick={handleAdd}
disabled={submitting || !collisionsLoaded || selected.size === 0}
data-testid="balance-starters-cta-add"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{submitting && <Loader2 size={14} className="animate-spin" />}
{t("balance.starters.cta_add")}
</button>
</div>
</div>
</div>,
document.body
);
}