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
209 lines
7.2 KiB
TypeScript
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
|
|
);
|
|
}
|