From 396d342d57165eb200930dc8c50bf61aa0e69a42 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Apr 2026 21:10:07 +0200 Subject: [PATCH] feat(07-02): ReviewStep component and wizard completion/skip logic - ReviewStep shows read-only grouped summary with income, items, totals - handleComplete creates categories + template items with duplicate handling - handleSkipSetup marks setup_completed without creating data - Double-submit prevention via completing state - Toast notifications for success/error/partial failure - Query invalidation to prevent redirect loops --- src/components/setup/ReviewStep.tsx | 118 +++++++++++++++++++++++ src/pages/SetupPage.tsx | 143 ++++++++++++++++++++++++---- 2 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 src/components/setup/ReviewStep.tsx diff --git a/src/components/setup/ReviewStep.tsx b/src/components/setup/ReviewStep.tsx new file mode 100644 index 0000000..f4aa240 --- /dev/null +++ b/src/components/setup/ReviewStep.tsx @@ -0,0 +1,118 @@ +import { useTranslation } from "react-i18next" +import { PRESETS } from "@/data/presets" +import type { CategoryType } from "@/lib/types" +import { Separator } from "@/components/ui/separator" +import { CategoryGroupHeader } from "./CategoryGroupHeader" + +interface ReviewStepProps { + income: number + selectedItems: Record + currency: string +} + +const TYPE_ORDER: CategoryType[] = [ + "income", + "bill", + "variable_expense", + "debt", + "saving", + "investment", +] + +export function ReviewStep({ income, selectedItems, currency }: ReviewStepProps) { + const { t } = useTranslation() + + const fmt = (amount: number) => + new Intl.NumberFormat(undefined, { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(amount) + + const checkedPresets = PRESETS.filter((p) => selectedItems[p.slug]?.checked) + + // Group checked items by type + const grouped = TYPE_ORDER.reduce< + Record + >((acc, type) => { + const items = checkedPresets.filter((p) => p.type === type) + if (items.length > 0) acc[type] = items + return acc + }, {} as Record) + + const totalExpenses = checkedPresets.reduce( + (sum, p) => sum + (selectedItems[p.slug]?.amount ?? 0), + 0 + ) + const remaining = income - totalExpenses + + return ( +
+ {/* Income row */} +
+ + {t("setup.step3.incomeLabel")} + + {fmt(income)} +
+ + + {/* Grouped items */} + {checkedPresets.length === 0 ? ( +

+ {t("setup.step3.empty")} +

+ ) : ( + <> + {TYPE_ORDER.filter((type) => grouped[type]).map((type) => ( +
+ + {grouped[type].map((item) => ( +
+ + {t(`presets.${item.type}.${item.slug}`)} + + + {fmt(selectedItems[item.slug]?.amount ?? 0)} + +
+ ))} +
+ ))} + + )} + + + + {/* Totals */} +
+
+ + {t("setup.step3.totalLabel")} + + {fmt(totalExpenses)} +
+
+ + {t("setup.step3.remainingLabel")} + + = 0 ? "text-on-budget" : "text-destructive" + }`} + > + {fmt(remaining)} + +
+
+
+ ) +} diff --git a/src/pages/SetupPage.tsx b/src/pages/SetupPage.tsx index b682a55..18f8ba0 100644 --- a/src/pages/SetupPage.tsx +++ b/src/pages/SetupPage.tsx @@ -1,12 +1,19 @@ import { useState } from "react" import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" import { useAuth } from "@/hooks/useAuth" import { useWizardState } from "@/hooks/useWizardState" +import { useCategories } from "@/hooks/useCategories" +import { useTemplate } from "@/hooks/useTemplate" import { supabase } from "@/lib/supabase" +import { PRESETS } from "@/data/presets" import type { Profile } from "@/lib/types" import { WizardStepper } from "@/components/setup/WizardStepper" import { IncomeStep } from "@/components/setup/IncomeStep" import { RecurringItemsStep } from "@/components/setup/RecurringItemsStep" +import { ReviewStep } from "@/components/setup/ReviewStep" import { Button } from "@/components/ui/button" import { Card, @@ -20,8 +27,14 @@ import { useEffect } from "react" export default function SetupPage() { const { t } = useTranslation() const { user, loading: authLoading } = useAuth() + const navigate = useNavigate() + const queryClient = useQueryClient() const [profile, setProfile] = useState(null) const [profileLoading, setProfileLoading] = useState(true) + const [completing, setCompleting] = useState(false) + + const { categories, create } = useCategories() + const { createItem } = useTemplate() useEffect(() => { if (!user) return @@ -38,7 +51,7 @@ export default function SetupPage() { const userId = user?.id ?? "" const currency = profile?.currency ?? "EUR" - const { state, setStep, setIncome, toggleItem, setItemAmount } = + const { state, setStep, setIncome, toggleItem, setItemAmount, clearState } = useWizardState(userId) const [incomeError, setIncomeError] = useState(null) @@ -89,6 +102,7 @@ export default function SetupPage() { function handleSkipStep() { if (state.currentStep === 1) setStep(2) else if (state.currentStep === 2) setStep(3) + else if (state.currentStep === 3) handleSkipSetup() } function handleStepClick(step: 1 | 2 | 3) { @@ -97,10 +111,98 @@ export default function SetupPage() { } } + async function handleComplete() { + setCompleting(true) + try { + const checkedItems = PRESETS.filter( + (p) => state.selectedItems[p.slug]?.checked + ) + + // 1. Determine unique category types needed + const typesNeeded = [...new Set(checkedItems.map((i) => i.type))] + + // 2. Create one category per type + const categoryMap: Record = {} + for (const type of typesNeeded) { + try { + const cat = await create.mutateAsync({ + name: t(`categories.types.${type}`), + type, + }) + categoryMap[type] = cat.id + } catch (e: any) { + // Unique constraint violation (23505) = category already exists, fetch it + if (e?.code === "23505" || e?.message?.includes("duplicate")) { + const existing = categories.find((c) => c.type === type) + if (existing) categoryMap[type] = existing.id + } else { + throw e + } + } + } + + // 3. If categories were fetched from cache but not found, refetch + if (Object.keys(categoryMap).length < typesNeeded.length) { + await queryClient.invalidateQueries({ queryKey: ["categories"] }) + } + + // 4. Create template items for each checked preset + let partialFailure = false + for (const item of checkedItems) { + try { + await createItem.mutateAsync({ + category_id: categoryMap[item.type], + item_tier: item.item_tier, + budgeted_amount: state.selectedItems[item.slug].amount, + }) + } catch { + partialFailure = true + } + } + + // 5. Mark setup complete + await supabase + .from("profiles") + .update({ setup_completed: true }) + .eq("id", user!.id) + + // 6. Clear wizard state + clearState() + + // 7. Invalidate queries to prevent redirect loop + await queryClient.invalidateQueries({ queryKey: ["categories"] }) + await queryClient.invalidateQueries({ queryKey: ["template-items"] }) + + // 8. Toast and redirect + if (partialFailure) { + toast.error(t("setup.toast.partialError")) + } else { + toast.success(t("setup.toast.success")) + } + navigate("/", { replace: true }) + } catch { + toast.error(t("setup.toast.error")) + } finally { + setCompleting(false) + } + } + + async function handleSkipSetup() { + clearState() + await supabase + .from("profiles") + .update({ setup_completed: true }) + .eq("id", user!.id) + navigate("/", { replace: true }) + } + return (
- + @@ -132,35 +234,42 @@ export default function SetupPage() { )} {state.currentStep === 3 && ( -
-

Review step coming in next plan

-
+ )} {/* Bottom navigation */}
{state.currentStep > 1 && ( - )} - {state.currentStep < 3 && ( - - )} +
{state.currentStep < 3 && ( )} {state.currentStep === 3 && ( - + )}
@@ -170,6 +279,8 @@ export default function SetupPage() {