diff --git a/frontend/src/api/hooks/onboarding.ts b/frontend/src/api/hooks/onboarding.ts index 6931324c..cc0d9d42 100644 --- a/frontend/src/api/hooks/onboarding.ts +++ b/frontend/src/api/hooks/onboarding.ts @@ -2,8 +2,9 @@ * Onboarding React Query hooks */ import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; +import { useCallback, useRef } from 'react'; import { onboardingService } from '../services/onboarding'; -import { UserProgress, UpdateStepRequest } from '../types/onboarding'; +import { UserProgress, UpdateStepRequest, StepDraftResponse } from '../types/onboarding'; import { ApiError } from '../client'; // Query Keys @@ -12,6 +13,7 @@ export const onboardingKeys = { progress: (userId: string) => [...onboardingKeys.all, 'progress', userId] as const, steps: () => [...onboardingKeys.all, 'steps'] as const, stepDetail: (stepName: string) => [...onboardingKeys.steps(), stepName] as const, + stepDraft: (stepName: string) => [...onboardingKeys.all, 'draft', stepName] as const, } as const; // Queries @@ -122,8 +124,6 @@ export const useMarkStepCompleted = ( // Invalidate queries on error to ensure we get fresh data queryClient.invalidateQueries({ queryKey: onboardingKeys.progress(userId) }); }, - // Prevent duplicate requests by using the step name as a mutation key - mutationKey: (variables) => ['markStepCompleted', variables?.userId, variables?.stepName], ...options, }); }; @@ -132,7 +132,7 @@ export const useResetProgress = ( options?: UseMutationOptions ) => { const queryClient = useQueryClient(); - + return useMutation({ mutationFn: (userId: string) => onboardingService.resetProgress(userId), onSuccess: (data, userId) => { @@ -141,4 +141,110 @@ export const useResetProgress = ( }, ...options, }); +}; + +// Draft Queries and Mutations + +/** + * Query hook to get draft data for a specific step + */ +export const useStepDraft = ( + stepName: string, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: onboardingKeys.stepDraft(stepName), + queryFn: () => onboardingService.getStepDraft(stepName), + enabled: !!stepName, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +/** + * Mutation hook to save draft data for a step + */ +export const useSaveStepDraft = ( + options?: UseMutationOptions<{ success: boolean }, ApiError, { stepName: string; draftData: Record }> +) => { + const queryClient = useQueryClient(); + + return useMutation<{ success: boolean }, ApiError, { stepName: string; draftData: Record }>({ + mutationFn: ({ stepName, draftData }) => onboardingService.saveStepDraft(stepName, draftData), + onSuccess: (_, { stepName }) => { + // Invalidate the draft query to get fresh data + queryClient.invalidateQueries({ queryKey: onboardingKeys.stepDraft(stepName) }); + }, + ...options, + }); +}; + +/** + * Mutation hook to delete draft data for a step + */ +export const useDeleteStepDraft = ( + options?: UseMutationOptions<{ success: boolean }, ApiError, string> +) => { + const queryClient = useQueryClient(); + + return useMutation<{ success: boolean }, ApiError, string>({ + mutationFn: (stepName: string) => onboardingService.deleteStepDraft(stepName), + onSuccess: (_, stepName) => { + // Invalidate the draft query + queryClient.invalidateQueries({ queryKey: onboardingKeys.stepDraft(stepName) }); + }, + ...options, + }); +}; + +/** + * Custom hook with debounced draft auto-save functionality. + * Automatically saves draft data after a delay when form data changes. + */ +export const useAutoSaveDraft = (stepName: string, debounceMs: number = 2000) => { + const saveStepDraft = useSaveStepDraft(); + const timeoutRef = useRef(null); + + const saveDraft = useCallback( + (draftData: Record) => { + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Set a new timeout for debounced save + timeoutRef.current = setTimeout(() => { + saveStepDraft.mutate({ stepName, draftData }); + }, debounceMs); + }, + [stepName, debounceMs, saveStepDraft] + ); + + const saveDraftImmediately = useCallback( + (draftData: Record) => { + // Clear any pending debounced save + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + // Save immediately + saveStepDraft.mutate({ stepName, draftData }); + }, + [stepName, saveStepDraft] + ); + + const cancelPendingSave = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + return { + saveDraft, + saveDraftImmediately, + cancelPendingSave, + isSaving: saveStepDraft.isPending, + isError: saveStepDraft.isError, + error: saveStepDraft.error, + }; }; \ No newline at end of file diff --git a/frontend/src/api/services/onboarding.ts b/frontend/src/api/services/onboarding.ts index 2b84b2ad..d529aa74 100644 --- a/frontend/src/api/services/onboarding.ts +++ b/frontend/src/api/services/onboarding.ts @@ -3,7 +3,7 @@ * Frontend and backend step names now match directly! */ import { apiClient } from '../client'; -import { UserProgress, UpdateStepRequest } from '../types/onboarding'; +import { UserProgress, UpdateStepRequest, SaveStepDraftRequest, StepDraftResponse } from '../types/onboarding'; // Backend onboarding steps (full list from backend - UPDATED to match refactored flow) // NOTE: poi-detection removed - now happens automatically in background during tenant registration @@ -155,7 +155,7 @@ export class OnboardingService { async getResumeStep(): Promise<{ stepId: string; stepIndex: number }> { try { const progress = await this.getUserProgress(''); - + // If fully completed, go to completion if (progress.fully_completed) { return { stepId: 'completion', stepIndex: FRONTEND_STEP_ORDER.indexOf('completion') }; @@ -163,10 +163,10 @@ export class OnboardingService { // Get the current step from backend const currentStep = progress.current_step; - + // If current step is user_registered, start from setup const resumeStep = currentStep === 'user_registered' ? 'setup' : currentStep; - + // Find the step index in our frontend order let stepIndex = FRONTEND_STEP_ORDER.indexOf(resumeStep); if (stepIndex === -1) { @@ -179,6 +179,66 @@ export class OnboardingService { return { stepId: 'setup', stepIndex: 0 }; } } + + /** + * Save in-progress step data without marking the step as complete. + * This allows users to save their work and resume later. + */ + async saveStepDraft(stepName: string, draftData: Record): Promise<{ success: boolean }> { + const requestBody: SaveStepDraftRequest = { + step_name: stepName, + draft_data: draftData, + }; + + console.log(`💾 Saving draft for step "${stepName}":`, draftData); + + try { + const response = await apiClient.put<{ success: boolean }>(`${this.baseUrl}/step-draft`, requestBody); + console.log(`✅ Draft saved for step "${stepName}"`); + return response; + } catch (error) { + console.error(`❌ Error saving draft for step "${stepName}":`, error); + throw error; + } + } + + /** + * Get saved draft data for a specific step. + * Returns null if no draft exists or the step is already completed. + */ + async getStepDraft(stepName: string): Promise { + console.log(`📖 Getting draft for step "${stepName}"`); + + try { + const response = await apiClient.get(`${this.baseUrl}/step-draft/${stepName}`); + if (response.draft_data) { + console.log(`✅ Found draft for step "${stepName}":`, response.draft_data); + } else { + console.log(`ℹ️ No draft found for step "${stepName}"`); + } + return response; + } catch (error) { + console.error(`❌ Error getting draft for step "${stepName}":`, error); + throw error; + } + } + + /** + * Delete saved draft data for a specific step. + * Called after step is completed to clean up draft data. + */ + async deleteStepDraft(stepName: string): Promise<{ success: boolean }> { + console.log(`🗑️ Deleting draft for step "${stepName}"`); + + try { + const response = await apiClient.delete<{ success: boolean }>(`${this.baseUrl}/step-draft/${stepName}`); + console.log(`✅ Draft deleted for step "${stepName}"`); + return response; + } catch (error) { + console.error(`❌ Error deleting draft for step "${stepName}":`, error); + throw error; + } + } } export const onboardingService = new OnboardingService(); \ No newline at end of file diff --git a/frontend/src/api/types/onboarding.ts b/frontend/src/api/types/onboarding.ts index 8b6d397d..9544d9c6 100644 --- a/frontend/src/api/types/onboarding.ts +++ b/frontend/src/api/types/onboarding.ts @@ -9,6 +9,27 @@ export interface OnboardingStepStatus { data?: Record; } +/** + * Wizard context state extracted from backend step data. + * Used to restore frontend WizardContext on session restore. + */ +export interface WizardContextState { + bakery_type: string | null; + tenant_id: string | null; + subscription_tier: string | null; + ai_analysis_complete: boolean; + inventory_review_completed: boolean; + stock_entry_completed: boolean; + categorization_completed: boolean; + suppliers_completed: boolean; + inventory_setup_completed: boolean; + recipes_completed: boolean; + quality_completed: boolean; + team_completed: boolean; + child_tenants_completed: boolean; + ml_training_complete: boolean; +} + export interface UserProgress { user_id: string; steps: OnboardingStepStatus[]; @@ -17,10 +38,23 @@ export interface UserProgress { completion_percentage: number; fully_completed: boolean; last_updated: string; + context_state?: WizardContextState; } export interface UpdateStepRequest { step_name: string; completed: boolean; data?: Record; +} + +export interface SaveStepDraftRequest { + step_name: string; + draft_data: Record; +} + +export interface StepDraftResponse { + step_name: string; + draft_data: Record | null; + draft_saved_at?: string; + demo_mode?: boolean; } \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx b/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx index 262fed56..2b09dc9a 100644 --- a/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx @@ -22,6 +22,7 @@ import { // Import setup wizard steps import { SuppliersSetupStep, + InventorySetupStep, RecipesSetupStep, QualitySetupStep, TeamSetupStep, @@ -59,21 +60,20 @@ const OnboardingWizardContent: React.FC = () => { const subscriptionTier = localStorage.getItem('subscription_tier') as string; const isEnterprise = subscriptionTier === 'enterprise'; + // Fetch user progress early so it's available for step conditions + const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress( + user?.id || '', + { enabled: !!user?.id } + ); + // Auto-set bakeryType for enterprise users (central baker + retail outlets) - // Do this synchronously on mount AND in useEffect to ensure it's set before VISIBLE_STEPS calculation + // Use useEffect to avoid state updates during render React.useEffect(() => { if (isEnterprise && !wizardContext.state.bakeryType) { console.log('🏢 Auto-setting bakeryType to "mixed" for enterprise tier'); wizardContext.updateBakeryType('mixed'); // Enterprise is always mixed (production + retail) } - }, [isEnterprise, wizardContext]); - - // CRITICAL: Set bakeryType synchronously for enterprise on initial render - // This ensures the setup step's condition is satisfied immediately - if (isEnterprise && !wizardContext.state.bakeryType) { - console.log('🏢 [SYNC] Auto-setting bakeryType to "mixed" for enterprise tier'); - wizardContext.updateBakeryType('mixed'); - } + }, [isEnterprise, wizardContext.state.bakeryType]); // All possible steps with conditional visibility // All step IDs match backend ONBOARDING_STEPS exactly @@ -109,13 +109,9 @@ const OnboardingWizardContent: React.FC = () => { : t('onboarding:steps.setup.description', 'Información básica'); })(), component: RegisterTenantStep, - isConditional: true, - condition: (ctx) => { - // Allow setup step if bakeryType is set OR if user is enterprise tier - // (enterprise auto-sets bakeryType to 'mixed' automatically) - const currentTier = localStorage.getItem('subscription_tier'); - return ctx.state.bakeryType !== null || currentTier === 'enterprise'; - }, + // No condition - setup step should always be visible + // The natural flow is: bakery-type-selection → setup + // Enterprise users skip bakery-type-selection but still need setup }, // Enterprise-specific: Child Tenants Setup { @@ -154,7 +150,20 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.stock.description', 'Cantidades iniciales'), component: InitialStockEntryStep, isConditional: true, - condition: (ctx) => ctx.state.inventoryReviewCompleted, + // CRITICAL: Check backend completion status to prevent accessing step with only draft data + // Users can have draft data for inventory-review without completing it, + // which would populate inventoryItems in context but not mark the step as done in backend + condition: () => { + // Check if inventory-review is actually completed in backend + const inventoryReviewStep = userProgress?.steps?.find(s => s.step_name === 'inventory-review'); + const isBackendComplete = inventoryReviewStep?.completed === true; + + // Also check frontend context for real-time updates during current session + const isFrontendComplete = wizardContext.state.inventoryReviewCompleted; + + // Require BOTH to be true - this prevents showing step when only draft data exists + return isBackendComplete && isFrontendComplete; + }, }, { id: 'suppliers-setup', @@ -163,6 +172,14 @@ const OnboardingWizardContent: React.FC = () => { component: SuppliersSetupStep, // Always show - no conditional }, + { + id: 'inventory-setup', + title: t('onboarding:steps.inventory.title', 'Inventario'), + description: t('onboarding:steps.inventory.description', 'Configura tu inventario'), + component: InventorySetupStep, + isConditional: true, + condition: () => false, // Hidden: AI-assisted path is the only active flow + }, { id: 'recipes-setup', title: t('onboarding:steps.recipes.title', 'Recetas'), @@ -204,7 +221,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.completion.description', '¡Todo listo!'), component: CompletionStep, }, - ], [i18n.language]); // Re-create steps when language changes + ], [i18n.language, userProgress]); // Re-create steps when language changes or backend data updates // Filter visible steps based on wizard context // useMemo ensures VISIBLE_STEPS recalculates when wizard context state changes @@ -226,7 +243,7 @@ const OnboardingWizardContent: React.FC = () => { }); return visibleSteps; - }, [ALL_STEPS, wizardContext.state, isEnterprise]); // Added ALL_STEPS to re-filter when translations change + }, [ALL_STEPS, wizardContext.state, isEnterprise, userProgress]); // Include userProgress to recalculate when backend data changes const isNewTenant = searchParams.get('new') === 'true'; const [currentStepIndex, setCurrentStepIndex] = useState(0); @@ -235,11 +252,6 @@ const OnboardingWizardContent: React.FC = () => { useTenantInitializer(); - const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress( - user?.id || '', - { enabled: !!user?.id } - ); - const markStepCompleted = useMarkStepCompleted(); const { setCurrentTenant, loadUserTenants } = useTenantActions(); const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false); @@ -364,7 +376,18 @@ const OnboardingWizardContent: React.FC = () => { console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data); try { - // Update wizard context based on step + // CRITICAL: Mark step as completed in backend FIRST before updating frontend state + // This ensures backend dependencies are met before we move to dependent steps + console.log(`📤 Sending API request to complete step: "${currentStep.id}"`); + await markStepCompleted.mutateAsync({ + userId: user.id, + stepName: currentStep.id, + data + }); + + console.log(`✅ Successfully completed step: "${currentStep.id}"`); + + // Update wizard context based on step (AFTER backend confirms completion) if (currentStep.id === 'bakery-type-selection' && data?.bakeryType) { wizardContext.updateBakeryType(data.bakeryType as BakeryType); } @@ -393,7 +416,7 @@ const OnboardingWizardContent: React.FC = () => { wizardContext.markStepComplete('stockEntryCompleted'); } if (currentStep.id === 'inventory-setup') { - wizardContext.markStepComplete('inventoryCompleted'); + wizardContext.markStepComplete('inventorySetupCompleted'); } if (currentStep.id === 'setup') { console.log('✅ Setup step completed with data:', { hasTenant: !!data?.tenant, hasUser: !!user?.id, data }); @@ -474,16 +497,6 @@ const OnboardingWizardContent: React.FC = () => { } } - // Mark step as completed in backend - console.log(`📤 Sending API request to complete step: "${currentStep.id}"`); - await markStepCompleted.mutateAsync({ - userId: user.id, - stepName: currentStep.id, - data - }); - - console.log(`✅ Successfully completed step: "${currentStep.id}"`); - // Special handling for inventory-review - auto-complete suppliers if requested if (currentStep.id === 'inventory-review' && data?.shouldAutoCompleteSuppliers) { try { @@ -760,6 +773,20 @@ const OnboardingWizardContent: React.FC = () => { isFirstStep={currentStepIndex === 0} isLastStep={currentStepIndex === VISIBLE_STEPS.length - 1} canContinue={canContinue} + // Pass products directly as prop for InitialStockEntryStep + {...(currentStep.id === 'initial-stock-entry' && wizardContext.state.inventoryItems + ? { + products: wizardContext.state.inventoryItems.map(item => ({ + id: item.id, + name: item.name, + type: item.product_type === 'ingredient' ? 'ingredient' : 'finished_product', + category: item.category, + unit: item.unit_of_measure, + initialStock: undefined, + })) + } + : {} + )} initialData={ // Pass AI data and file to InventoryReviewStep currentStep.id === 'inventory-review' @@ -770,19 +797,7 @@ const OnboardingWizardContent: React.FC = () => { uploadedFileName: wizardContext.state.uploadedFileName || '', uploadedFileSize: wizardContext.state.uploadedFileSize || 0, } - : // Pass inventory items to InitialStockEntryStep - currentStep.id === 'initial-stock-entry' && wizardContext.state.inventoryItems - ? { - productsWithStock: wizardContext.state.inventoryItems.map(item => ({ - id: item.id, - name: item.name, - type: item.product_type === 'ingredient' ? 'ingredient' : 'finished_product', - category: item.category, - unit: item.unit_of_measure, - initialStock: undefined, - })) - } - : undefined + : undefined } /> @@ -792,8 +807,36 @@ const OnboardingWizardContent: React.FC = () => { }; export const UnifiedOnboardingWizard: React.FC = () => { + const { user } = useAuth(); + const [searchParams] = useSearchParams(); + const isNewTenant = searchParams.get('new') === 'true'; + + // Fetch user progress BEFORE rendering WizardProvider + // This ensures context_state is available to derive initial WizardState + const { data: userProgress, isLoading: isLoadingProgress } = useUserProgress( + user?.id || '', + { enabled: !!user?.id && !isNewTenant } + ); + + // Show loading while fetching progress (for returning users) + if (!isNewTenant && isLoadingProgress) { + return ( +
+ + +
+
+

Cargando tu progreso...

+
+
+
+
+ ); + } + + // Pass backend progress to WizardProvider for state restoration return ( - + ); diff --git a/frontend/src/components/domain/onboarding/context/WizardContext.tsx b/frontend/src/components/domain/onboarding/context/WizardContext.tsx index 6d91fa1f..05b079e3 100644 --- a/frontend/src/components/domain/onboarding/context/WizardContext.tsx +++ b/frontend/src/components/domain/onboarding/context/WizardContext.tsx @@ -1,21 +1,11 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import type { ProductSuggestionResponse } from '../../../api/types/inventory'; -import type { ImportValidationResponse } from '../../../api/types/dataImport'; +import React, { createContext, useContext, useState, useEffect, useMemo, ReactNode } from 'react'; +import type { ProductSuggestionResponse } from '../../../../api/types/inventory'; +import type { ImportValidationResponse } from '../../../../api/types/dataImport'; +import type { UserProgress } from '../../../../api/types/onboarding'; export type BakeryType = 'production' | 'retail' | 'mixed' | null; export type DataSource = 'ai-assisted' | 'manual' | null; -// Legacy AISuggestion type - kept for backward compatibility -export interface AISuggestion { - id: string; - name: string; - category: string; - confidence: number; - suggestedUnit?: string; - suggestedCost?: number; - isAccepted?: boolean; -} - // Inventory item structure from InventoryReviewStep export interface InventoryItemForm { id: string; @@ -60,21 +50,22 @@ export interface WizardState { childTenantsCompleted: boolean; // AI-Assisted Path Data - uploadedFile?: File; // NEW: The actual file object needed for sales import API + uploadedFile?: File; uploadedFileName?: string; uploadedFileSize?: number; - uploadedFileValidation?: ImportValidationResponse; // NEW: Validation result - aiSuggestions: ProductSuggestionResponse[]; // UPDATED: Use full ProductSuggestionResponse type + uploadedFileValidation?: ImportValidationResponse; + aiSuggestions: ProductSuggestionResponse[]; aiAnalysisComplete: boolean; - categorizedProducts?: any[]; // Products with type classification - productsWithStock?: any[]; // Products with initial stock levels - inventoryItems?: InventoryItemForm[]; // NEW: Inventory items created in InventoryReviewStep + categorizedProducts?: any[]; + productsWithStock?: any[]; + inventoryItems?: InventoryItemForm[]; // Setup Progress categorizationCompleted: boolean; - inventoryReviewCompleted: boolean; // NEW: Tracks completion of InventoryReviewStep + inventoryReviewCompleted: boolean; stockEntryCompleted: boolean; suppliersCompleted: boolean; + inventorySetupCompleted: boolean; inventoryCompleted: boolean; recipesCompleted: boolean; processesCompleted: boolean; @@ -97,22 +88,20 @@ export interface WizardContextValue { updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void; updateLocation: (location: { latitude: number; longitude: number }) => void; updateTenantId: (tenantId: string) => void; - updateChildTenants: (tenants: ChildTenantData[]) => void; // NEW: Store child tenants - updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type - updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation + updateChildTenants: (tenants: ChildTenantData[]) => void; + updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; + updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; setAIAnalysisComplete: (complete: boolean) => void; updateCategorizedProducts: (products: any[]) => void; updateProductsWithStock: (products: any[]) => void; - updateInventoryItems: (items: InventoryItemForm[]) => void; // NEW: Store inventory items + updateInventoryItems: (items: InventoryItemForm[]) => void; markStepComplete: (step: keyof WizardState) => void; - getVisibleSteps: () => string[]; - shouldShowStep: (stepId: string) => boolean; resetWizard: () => void; } const initialState: WizardState = { bakeryType: null, - dataSource: 'ai-assisted', // Only AI-assisted path supported now + dataSource: 'ai-assisted', childTenants: undefined, childTenantsCompleted: false, aiSuggestions: [], @@ -120,9 +109,10 @@ const initialState: WizardState = { categorizedProducts: undefined, productsWithStock: undefined, categorizationCompleted: false, - inventoryReviewCompleted: false, // NEW: Initially false + inventoryReviewCompleted: false, stockEntryCompleted: false, suppliersCompleted: false, + inventorySetupCompleted: false, inventoryCompleted: false, recipesCompleted: false, processesCompleted: false, @@ -137,17 +127,83 @@ const WizardContext = createContext(undefined); export interface WizardProviderProps { children: ReactNode; initialState?: Partial; + backendProgress?: UserProgress; +} + +/** + * Derives WizardState from backend context_state. + * This ensures conditional steps remain visible based on previous progress. + */ +function deriveStateFromBackendProgress( + backendProgress: UserProgress | undefined, + providedInitialState?: Partial +): Partial { + if (!backendProgress?.context_state) { + return providedInitialState || {}; + } + + const ctx = backendProgress.context_state; + + // Map backend context state to frontend WizardState + const derivedState: Partial = { + // Discovery Phase + bakeryType: ctx.bakery_type as BakeryType, + + // Core Setup Data + tenantId: ctx.tenant_id || undefined, + + // AI-Assisted Path completion flags + aiAnalysisComplete: ctx.ai_analysis_complete, + inventoryReviewCompleted: ctx.inventory_review_completed, + stockEntryCompleted: ctx.stock_entry_completed, + categorizationCompleted: ctx.categorization_completed, + + // Setup Progress + suppliersCompleted: ctx.suppliers_completed, + inventorySetupCompleted: ctx.inventory_setup_completed, + recipesCompleted: ctx.recipes_completed, + qualityCompleted: ctx.quality_completed, + teamCompleted: ctx.team_completed, + childTenantsCompleted: ctx.child_tenants_completed, + + // ML Training + mlTrainingComplete: ctx.ml_training_complete, + }; + + // Merge with provided initial state (provided takes precedence for explicit overrides) + return { + ...derivedState, + ...providedInitialState, + }; } export const WizardProvider: React.FC = ({ children, initialState: providedInitialState, + backendProgress, }) => { - const [state, setState] = useState({ - ...initialState, - ...providedInitialState, - startedAt: providedInitialState?.startedAt || new Date().toISOString(), - }); + // Derive initial state from backend progress if available + const derivedInitialState = useMemo(() => { + const derived = deriveStateFromBackendProgress(backendProgress, providedInitialState); + return { + ...initialState, + ...derived, + startedAt: providedInitialState?.startedAt || new Date().toISOString(), + }; + }, [backendProgress, providedInitialState]); + + const [state, setState] = useState(derivedInitialState); + + // Update state when backend progress changes (e.g., on refetch) + useEffect(() => { + if (backendProgress?.context_state) { + const derived = deriveStateFromBackendProgress(backendProgress, providedInitialState); + setState(prev => ({ + ...prev, + ...derived, + })); + } + }, [backendProgress, providedInitialState]); // SECURITY: Removed localStorage persistence for wizard state // All onboarding progress is now tracked exclusively via backend API @@ -232,75 +288,6 @@ export const WizardProvider: React.FC = ({ setState(prev => ({ ...prev, [step]: true })); }; - /** - * Determines which steps should be visible based on current wizard state - */ - const getVisibleSteps = (): string[] => { - const steps: string[] = []; - - // Phase 1: Discovery (Always visible) - steps.push('bakery-type-selection'); - - if (!state.bakeryType) { - return steps; // Stop here until bakery type is selected - } - - steps.push('data-source-choice'); - - if (!state.dataSource) { - return steps; // Stop here until data source is selected - } - - // Phase 2a: AI-Assisted Path - if (state.dataSource === 'ai-assisted') { - steps.push('upload-sales-data'); - - if (state.uploadedFileName) { - steps.push('ai-analysis'); - } - - if (state.aiAnalysisComplete) { - steps.push('review-suggestions'); - steps.push('product-categorization'); - } - - if (state.categorizationCompleted) { - steps.push('initial-stock-entry'); - } - } - - // Phase 2b: Core Setup (Common for all paths) - steps.push('suppliers-setup'); - steps.push('inventory-setup'); - - // Conditional: Recipes - if (state.bakeryType === 'production' || state.bakeryType === 'mixed') { - steps.push('recipes-setup'); - } - - // Phase 3: Advanced Features (Optional) - steps.push('quality-setup'); - steps.push('team-setup'); - - // Phase 4: ML & Finalization - if (state.inventoryCompleted || state.aiAnalysisComplete) { - steps.push('ml-training'); - } - - // Revision step removed - not useful for user - steps.push('completion'); - - return steps; - }; - - /** - * Checks if a specific step should be visible - */ - const shouldShowStep = (stepId: string): boolean => { - const visibleSteps = getVisibleSteps(); - return visibleSteps.includes(stepId); - }; - /** * Resets wizard state to initial values */ @@ -327,8 +314,6 @@ export const WizardProvider: React.FC = ({ updateProductsWithStock, updateInventoryItems, markStepComplete, - getVisibleSteps, - shouldShowStep, resetWizard, }; @@ -350,23 +335,4 @@ export const useWizardContext = (): WizardContextValue => { return context; }; -/** - * Helper hook to get conditional visibility logic - */ -export const useStepVisibility = () => { - const { state, shouldShowStep } = useWizardContext(); - - return { - shouldShowRecipes: state.bakeryType === 'production' || state.bakeryType === 'mixed', - shouldShowProcesses: state.bakeryType === 'retail' || state.bakeryType === 'mixed', - shouldShowAIPath: state.dataSource === 'ai-assisted', - shouldShowManualPath: state.dataSource === 'manual', - isProductionBakery: state.bakeryType === 'production', - isRetailBakery: state.bakeryType === 'retail', - isMixedBakery: state.bakeryType === 'mixed', - hasAISuggestions: state.aiSuggestions.length > 0, - shouldShowStep, - }; -}; - export default WizardContext; diff --git a/frontend/src/components/domain/onboarding/context/index.ts b/frontend/src/components/domain/onboarding/context/index.ts index 50b90b06..ff8e0842 100644 --- a/frontend/src/components/domain/onboarding/context/index.ts +++ b/frontend/src/components/domain/onboarding/context/index.ts @@ -1,10 +1,8 @@ export { WizardProvider, useWizardContext, - useStepVisibility, type BakeryType, type DataSource, - type AISuggestion, type WizardState, type WizardContextValue, } from './WizardContext'; diff --git a/frontend/src/components/domain/onboarding/steps/BakeryTypeSelectionStep.tsx b/frontend/src/components/domain/onboarding/steps/BakeryTypeSelectionStep.tsx index 010bdcbd..ffdae8aa 100644 --- a/frontend/src/components/domain/onboarding/steps/BakeryTypeSelectionStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/BakeryTypeSelectionStep.tsx @@ -6,7 +6,7 @@ import Card from '../../../ui/Card/Card'; export interface BakeryTypeSelectionStepProps { onUpdate?: (data: { bakeryType: string }) => void; - onComplete?: () => void; + onComplete?: (data?: { bakeryType: string }) => void; initialData?: { bakeryType?: string; }; @@ -113,7 +113,7 @@ export const BakeryTypeSelectionStep: React.FC = ( const handleContinue = () => { if (selectedType) { - onComplete?.(); + onComplete?.({ bakeryType: selectedType }); } }; diff --git a/frontend/src/components/domain/onboarding/steps/InitialStockEntryStep.tsx b/frontend/src/components/domain/onboarding/steps/InitialStockEntryStep.tsx index 7d8ab380..d512a8dc 100644 --- a/frontend/src/components/domain/onboarding/steps/InitialStockEntryStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/InitialStockEntryStep.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Package, Salad, AlertCircle, ArrowRight, ArrowLeft, CheckCircle } from 'lucide-react'; import Button from '../../../ui/Button/Button'; @@ -6,8 +6,11 @@ import Card from '../../../ui/Card/Card'; import Input from '../../../ui/Input/Input'; import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useAddStock, useStock } from '../../../../api/hooks/inventory'; +import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding'; import InfoCard from '../../../ui/InfoCard'; +const STEP_NAME = 'initial-stock-entry'; + export interface ProductWithStock { id: string; name: string; @@ -55,6 +58,49 @@ export const InitialStockEntryStep: React.FC = ({ })); }); + // Draft auto-save hooks + const { data: draftData, isLoading: isLoadingDraft } = useStepDraft(STEP_NAME); + const { saveDraft, cancelPendingSave } = useAutoSaveDraft(STEP_NAME, 2000); + const deleteStepDraft = useDeleteStepDraft(); + const initializedRef = useRef(false); + const draftCheckedRef = useRef(false); + const stepCompletedRef = useRef(false); // Track if step has been completed to prevent draft saves after completion + + // Restore draft data on mount (only once) + useEffect(() => { + if (isLoadingDraft || initializedRef.current) return; + + initializedRef.current = true; + draftCheckedRef.current = true; + + if (draftData?.draft_data?.products && Array.isArray(draftData.draft_data.products)) { + // Restore products with stock values from draft + setProducts(draftData.draft_data.products); + console.log('✅ Restored initial-stock-entry draft:', draftData.draft_data.products.length, 'products'); + } + }, [isLoadingDraft, draftData]); + + // Auto-save draft when products change + useEffect(() => { + // CRITICAL: Do not save drafts after the step has been completed + // This prevents overwriting completed status with draft data + if (stepCompletedRef.current) { + console.log('⏸️ Skipping draft save - step already completed'); + return; + } + + if (!draftCheckedRef.current) return; + // Only save if we have products with stock values + const productsWithValues = products.filter(p => p.initialStock !== undefined); + if (productsWithValues.length === 0) return; + + const draftPayload = { + products, + }; + + saveDraft(draftPayload); + }, [products, saveDraft]); + // Merge existing stock from backend on mount useEffect(() => { if (!stockData?.items || products.length === 0) return; @@ -138,7 +184,7 @@ export const InitialStockEntryStep: React.FC = ({ if (stockEntriesToSync.length > 0) { // Create or update stock entries - // Note: useAddStock currently handles creation/initial set. + // Note: useAddStock currently handles creation/initial set. // If the backend requires a different endpoint for updates, this might need adjustment. // For onboarding, we assume addStock is a "set-and-forget" for initial levels. const stockPromises = stockEntriesToSync.map(product => @@ -156,6 +202,19 @@ export const InitialStockEntryStep: React.FC = ({ console.log(`✅ Synced ${stockEntriesToSync.length} stock entries successfully`); } + // CRITICAL: Mark step as completed BEFORE canceling auto-save + // This prevents any pending auto-save from overwriting the completion + stepCompletedRef.current = true; + + // Cancel any pending auto-save and delete draft on successful completion + cancelPendingSave(); + try { + await deleteStepDraft.mutateAsync(STEP_NAME); + console.log('✅ Deleted initial-stock-entry draft after successful completion'); + } catch { + // Ignore errors when deleting draft + } + onComplete?.(); } catch (error) { console.error('Error syncing stock entries:', error); @@ -255,7 +314,7 @@ export const InitialStockEntryStep: React.FC = ({ {hasStock && } {product.category && ( -
{product.category}
+
{t(`inventory:enums.ingredient_category.${product.category}`, product.category)}
)}
@@ -269,7 +328,7 @@ export const InitialStockEntryStep: React.FC = ({ className="w-20 sm:w-24 text-right min-h-[44px]" /> - {product.unit || 'kg'} + {t(`inventory:enums.unit_of_measure.${product.unit}`, product.unit || 'kg')}
@@ -306,7 +365,7 @@ export const InitialStockEntryStep: React.FC = ({ {hasStock && } {product.category && ( -
{product.category}
+
{t(`inventory:enums.ingredient_category.${product.category}`, product.category)}
)}
@@ -320,7 +379,7 @@ export const InitialStockEntryStep: React.FC = ({ className="w-24 text-right" /> - {product.unit || t('common:units', 'unidades')} + {t(`inventory:enums.unit_of_measure.${product.unit}`, product.unit || t('common:units', 'unidades'))}
diff --git a/frontend/src/components/domain/onboarding/steps/InventoryReviewStep.tsx b/frontend/src/components/domain/onboarding/steps/InventoryReviewStep.tsx index 751b5676..ede08140 100644 --- a/frontend/src/components/domain/onboarding/steps/InventoryReviewStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/InventoryReviewStep.tsx @@ -1,13 +1,16 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../../ui/Button'; import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCreateIngredient, useBulkCreateIngredients, useIngredients } from '../../../../api/hooks/inventory'; import { useImportSalesData } from '../../../../api/hooks/sales'; +import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding'; import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory'; import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../../api/types/inventory'; import { Package, ShoppingBag, AlertCircle, CheckCircle2, Edit2, Trash2, Plus, Sparkles } from 'lucide-react'; +const STEP_NAME = 'inventory-review'; + interface InventoryReviewStepProps { onNext: () => void; onPrevious: () => void; @@ -144,11 +147,35 @@ export const InventoryReviewStep: React.FC = ({ const importSalesMutation = useImportSalesData(); const { data: existingIngredients } = useIngredients(tenantId); - // Initialize with AI suggestions AND existing ingredients + // Draft auto-save hooks + const { data: draftData, isLoading: isLoadingDraft } = useStepDraft(STEP_NAME); + const { saveDraft, cancelPendingSave } = useAutoSaveDraft(STEP_NAME, 2000); + const deleteStepDraft = useDeleteStepDraft(); + const initializedRef = useRef(false); + const draftCheckedRef = useRef(false); + const stepCompletedRef = useRef(false); // Track if step has been completed to prevent draft saves after completion + + // Initialize with AI suggestions, existing ingredients, OR draft data (priority: draft > existing > AI) useEffect(() => { - // 1. Start with AI suggestions if available + // Wait for draft loading to complete before initializing + if (isLoadingDraft || initializedRef.current) return; + + // Mark as initialized to prevent re-running + initializedRef.current = true; + draftCheckedRef.current = true; + + // Check if we have draft data to restore + if (draftData?.draft_data?.inventoryItems && Array.isArray(draftData.draft_data.inventoryItems)) { + // Restore from draft - this takes priority + setInventoryItems(draftData.draft_data.inventoryItems); + console.log('✅ Restored inventory-review draft:', draftData.draft_data.inventoryItems.length, 'items'); + return; + } + + // No draft data - initialize from AI suggestions and existing ingredients let items: InventoryItemForm[] = []; + // 1. Start with AI suggestions if available if (initialData?.aiSuggestions && initialData.aiSuggestions.length > 0) { items = initialData.aiSuggestions.map((suggestion, index) => ({ id: `ai-${index}-${Date.now()}`, @@ -199,7 +226,26 @@ export const InventoryReviewStep: React.FC = ({ if (items.length > 0) { setInventoryItems(items); } - }, [initialData, existingIngredients]); + }, [isLoadingDraft, draftData, initialData, existingIngredients]); + + // Auto-save draft when inventoryItems change + useEffect(() => { + // CRITICAL: Do not save drafts after the step has been completed + // This prevents overwriting completed status with draft data + if (stepCompletedRef.current) { + console.log('⏸️ Skipping draft save - step already completed'); + return; + } + + // Only save after initialization is complete and we have items + if (!draftCheckedRef.current || inventoryItems.length === 0) return; + + const draftPayload = { + inventoryItems, + }; + + saveDraft(draftPayload); + }, [inventoryItems, saveDraft]); // Filter items const filteredItems = inventoryItems.filter(item => { @@ -426,6 +472,19 @@ export const InventoryReviewStep: React.FC = ({ console.log('📦 Passing items with real IDs to next step:', allItemsWithRealIds); + // CRITICAL: Mark step as completed BEFORE canceling auto-save + // This prevents any pending auto-save from overwriting the completion + stepCompletedRef.current = true; + + // Cancel any pending auto-save and delete draft on successful completion + cancelPendingSave(); + try { + await deleteStepDraft.mutateAsync(STEP_NAME); + console.log('✅ Deleted inventory-review draft after successful completion'); + } catch { + // Ignore errors when deleting draft + } + onComplete({ inventoryItemsCreated: newlyCreatedIngredients.length, salesDataImported: salesImported, diff --git a/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx b/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx index 981a5d80..896694aa 100644 --- a/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../../ui/Button'; import { useCurrentTenant } from '../../../../stores/tenant.store'; @@ -6,12 +6,15 @@ import { useTenantCurrency } from '../../../../hooks/useTenantCurrency'; import { useCreateIngredient, useClassifyBatch, useAddStock } from '../../../../api/hooks/inventory'; import { useValidateImportFile, useImportSalesData } from '../../../../api/hooks/sales'; import { useSuppliers } from '../../../../api/hooks/suppliers'; +import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding'; import type { ImportValidationResponse } from '../../../../api/types/dataImport'; import type { ProductSuggestionResponse, StockCreate, StockResponse } from '../../../../api/types/inventory'; import { ProductionStage } from '../../../../api/types/inventory'; import { useAuth } from '../../../../contexts/AuthContext'; import { BatchAddIngredientsModal } from '../../inventory/BatchAddIngredientsModal'; +const STEP_NAME = 'upload-sales-data'; + interface UploadSalesDataStepProps { onNext: () => void; onPrevious: () => void; @@ -115,6 +118,65 @@ export const UploadSalesDataStep: React.FC = ({ const [stockErrors, setStockErrors] = useState>({}); const [ingredientStocks, setIngredientStocks] = useState>({}); + // Draft auto-save hooks + const { data: draftData, isLoading: isLoadingDraft } = useStepDraft(STEP_NAME); + const { saveDraft, cancelPendingSave } = useAutoSaveDraft(STEP_NAME, 2000); + const deleteStepDraft = useDeleteStepDraft(); + const initializedRef = useRef(false); + const draftCheckedRef = useRef(false); + const stepCompletedRef = useRef(false); // Track if step has been completed to prevent draft saves after completion + + // Restore draft data on mount (only once) + useEffect(() => { + if (isLoadingDraft || initializedRef.current) return; + + initializedRef.current = true; + draftCheckedRef.current = true; + + if (draftData?.draft_data) { + const draft = draftData.draft_data; + + // Restore inventory items + if (draft.inventoryItems && Array.isArray(draft.inventoryItems)) { + setInventoryItems(draft.inventoryItems); + setShowInventoryStep(true); + console.log('✅ Restored upload-sales-data draft:', draft.inventoryItems.length, 'items'); + } + + // Restore ingredient stocks + if (draft.ingredientStocks && typeof draft.ingredientStocks === 'object') { + setIngredientStocks(draft.ingredientStocks); + } + + // Restore validation result metadata (not the file itself) + if (draft.validationResult) { + setValidationResult(draft.validationResult); + } + } + }, [isLoadingDraft, draftData]); + + // Auto-save draft when inventoryItems or ingredientStocks change + useEffect(() => { + // CRITICAL: Do not save drafts after the step has been completed + // This prevents overwriting completed status with draft data + if (stepCompletedRef.current) { + console.log('⏸️ Skipping draft save - step already completed'); + return; + } + + if (!draftCheckedRef.current) return; + // Only save if we have items to preserve + if (inventoryItems.length === 0 && Object.keys(ingredientStocks).length === 0) return; + + const draftPayload = { + inventoryItems, + ingredientStocks, + validationResult, + }; + + saveDraft(draftPayload); + }, [inventoryItems, ingredientStocks, validationResult, saveDraft]); + const handleFileSelect = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { @@ -572,6 +634,19 @@ export const UploadSalesDataStep: React.FC = ({ setProgressState(null); + // CRITICAL: Mark step as completed BEFORE canceling auto-save + // This prevents any pending auto-save from overwriting the completion + stepCompletedRef.current = true; + + // Cancel any pending auto-save and delete draft on successful completion + cancelPendingSave(); + try { + await deleteStepDraft.mutateAsync(STEP_NAME); + console.log('✅ Deleted upload-sales-data draft after successful completion'); + } catch { + // Ignore errors when deleting draft + } + // Complete step onComplete({ createdIngredients, diff --git a/frontend/src/components/domain/setup-wizard/steps/InventorySetupStep.tsx b/frontend/src/components/domain/setup-wizard/steps/InventorySetupStep.tsx index c76b12d2..4c9c10fb 100644 --- a/frontend/src/components/domain/setup-wizard/steps/InventorySetupStep.tsx +++ b/frontend/src/components/domain/setup-wizard/steps/InventorySetupStep.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { SetupStepProps } from '../types'; import { useIngredients, useCreateIngredient, useUpdateIngredient, useSoftDeleteIngredient, useBulkAddStock } from '../../../../api/hooks/inventory'; @@ -8,6 +8,9 @@ import { useAuthUser } from '../../../../stores/auth.store'; import { UnitOfMeasure, IngredientCategory, ProductionStage } from '../../../../api/types/inventory'; import type { IngredientCreate, IngredientUpdate, StockCreate } from '../../../../api/types/inventory'; import { ESSENTIAL_INGREDIENTS, COMMON_INGREDIENTS, PACKAGING_ITEMS, type IngredientTemplate, templateToIngredientCreate } from '../data/ingredientTemplates'; +import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding'; + +const STEP_NAME = 'inventory-setup'; export const InventorySetupStep: React.FC = ({ onUpdate, onComplete, onPrevious, canContinue }) => { const { t } = useTranslation(); @@ -31,6 +34,13 @@ export const InventorySetupStep: React.FC = ({ onUpdate, onCompl const deleteIngredientMutation = useSoftDeleteIngredient(); const bulkAddStockMutation = useBulkAddStock(); + // Draft auto-save hooks + const { data: draftData, isLoading: isLoadingDraft } = useStepDraft(STEP_NAME); + const { saveDraft, saveDraftImmediately, cancelPendingSave } = useAutoSaveDraft(STEP_NAME, 2000); + const deleteStepDraft = useDeleteStepDraft(); + const draftRestoredRef = useRef(false); + const stepCompletedRef = useRef(false); // Track if step has been completed to prevent draft saves after completion + // Form state const [isAdding, setIsAdding] = useState(false); const [editingId, setEditingId] = useState(null); @@ -64,6 +74,45 @@ export const InventorySetupStep: React.FC = ({ onUpdate, onCompl // Track stocks display per ingredient (using StockCreate for pending, before API submission) const [ingredientStocks, setIngredientStocks] = useState>>({}); + // Restore draft data on mount (only once) + useEffect(() => { + if (!isLoadingDraft && draftData?.draft_data && !draftRestoredRef.current) { + draftRestoredRef.current = true; + const draft = draftData.draft_data; + + if (draft.pendingStocks && Array.isArray(draft.pendingStocks)) { + setPendingStocks(draft.pendingStocks); + } + + if (draft.ingredientStocks && typeof draft.ingredientStocks === 'object') { + setIngredientStocks(draft.ingredientStocks); + } + + console.log('✅ Restored inventory-setup draft:', draft); + } + }, [isLoadingDraft, draftData]); + + // Auto-save draft when pendingStocks or ingredientStocks change + useEffect(() => { + // CRITICAL: Do not save drafts after the step has been completed + // This prevents overwriting completed status with draft data + if (stepCompletedRef.current) { + console.log('⏸️ Skipping draft save - step already completed'); + return; + } + + // Only save after initial draft restoration + if (!draftRestoredRef.current) return; + + // Save draft with current stock data + const draftPayload = { + pendingStocks, + ingredientStocks, + }; + + saveDraft(draftPayload); + }, [pendingStocks, ingredientStocks, saveDraft]); + // Notify parent when count changes useEffect(() => { const count = ingredients.length; @@ -331,7 +380,18 @@ export const InventorySetupStep: React.FC = ({ onUpdate, onCompl // Submit all pending stocks when proceeding to next step const handleSubmitPendingStocks = async (): Promise => { - if (pendingStocks.length === 0) return true; + // Cancel any pending auto-save + cancelPendingSave(); + + if (pendingStocks.length === 0) { + // No pending stocks, but still delete any draft data + try { + await deleteStepDraft.mutateAsync(STEP_NAME); + } catch { + // Ignore errors when deleting draft + } + return true; + } try { await bulkAddStockMutation.mutateAsync({ @@ -340,6 +400,15 @@ export const InventorySetupStep: React.FC = ({ onUpdate, onCompl }); // Clear pending stocks after successful submission setPendingStocks([]); + setIngredientStocks({}); + + // Delete draft data after successful submission + try { + await deleteStepDraft.mutateAsync(STEP_NAME); + } catch { + // Ignore errors when deleting draft + } + return true; } catch (error) { console.error('Error submitting stocks:', error); @@ -1043,6 +1112,10 @@ export const InventorySetupStep: React.FC = ({ onUpdate, onCompl onClick={async () => { const success = await handleSubmitPendingStocks(); if (success) { + // CRITICAL: Mark step as completed BEFORE calling onComplete + // This prevents any pending auto-save from overwriting the completion + stepCompletedRef.current = true; + cancelPendingSave(); onComplete(); } }} diff --git a/frontend/src/components/domain/setup-wizard/steps/SuppliersSetupStep.tsx b/frontend/src/components/domain/setup-wizard/steps/SuppliersSetupStep.tsx index 7e5e50b4..4ca6d5d4 100644 --- a/frontend/src/components/domain/setup-wizard/steps/SuppliersSetupStep.tsx +++ b/frontend/src/components/domain/setup-wizard/steps/SuppliersSetupStep.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { SetupStepProps } from '../types'; import { useSuppliers, useCreateSupplier, useUpdateSupplier, useDeleteSupplier } from '../../../../api/hooks/suppliers'; @@ -7,6 +7,9 @@ import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useAuthUser } from '../../../../stores/auth.store'; import type { SupplierCreate, SupplierUpdate } from '../../../../api/types/suppliers'; import { SupplierProductManager } from './SupplierProductManager'; +import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding'; + +const STEP_NAME = 'suppliers-setup'; export const SuppliersSetupStep: React.FC = ({ onNext, @@ -34,6 +37,13 @@ export const SuppliersSetupStep: React.FC = ({ const updateSupplierMutation = useUpdateSupplier(); const deleteSupplierMutation = useDeleteSupplier(); + // Draft auto-save hooks + const { data: draftData, isLoading: isLoadingDraft } = useStepDraft(STEP_NAME); + const { saveDraft, saveDraftImmediately, cancelPendingSave } = useAutoSaveDraft(STEP_NAME, 2000); + const deleteStepDraft = useDeleteStepDraft(); + const draftRestoredRef = useRef(false); + const stepCompletedRef = useRef(false); // Track if step has been completed to prevent draft saves after completion + // Form state const [isAdding, setIsAdding] = useState(false); const [editingId, setEditingId] = useState(null); @@ -46,6 +56,49 @@ export const SuppliersSetupStep: React.FC = ({ }); const [errors, setErrors] = useState>({}); + // Restore draft data on mount (only once) + useEffect(() => { + if (!isLoadingDraft && draftData?.draft_data && !draftRestoredRef.current) { + draftRestoredRef.current = true; + const draft = draftData.draft_data; + + if (draft.formData) { + setFormData(draft.formData); + } + if (draft.isAdding !== undefined) { + setIsAdding(draft.isAdding); + } + if (draft.editingId !== undefined) { + setEditingId(draft.editingId); + } + + console.log('✅ Restored suppliers-setup draft:', draft); + } + }, [isLoadingDraft, draftData]); + + // Auto-save draft when form data changes (only when form is open) + useEffect(() => { + // CRITICAL: Do not save drafts after the step has been completed + // This prevents overwriting completed status with draft data + if (stepCompletedRef.current) { + console.log('⏸️ Skipping draft save - step already completed'); + return; + } + + // Only save after initial draft restoration + if (!draftRestoredRef.current) return; + // Only save if form is open + if (!isAdding) return; + + const draftPayload = { + formData, + isAdding, + editingId, + }; + + saveDraft(draftPayload); + }, [formData, isAdding, editingId, saveDraft]); + // Notify parent when count changes useEffect(() => { onUpdate?.({ @@ -112,7 +165,10 @@ export const SuppliersSetupStep: React.FC = ({ } }; - const resetForm = () => { + const resetForm = (clearDraft: boolean = true) => { + // Cancel any pending auto-save + cancelPendingSave(); + setFormData({ name: '', supplier_type: SupplierType.INGREDIENTS, @@ -123,6 +179,11 @@ export const SuppliersSetupStep: React.FC = ({ setErrors({}); setIsAdding(false); setEditingId(null); + + // Delete draft if requested (after successful submission) + if (clearDraft) { + deleteStepDraft.mutate(STEP_NAME); + } }; const handleEdit = (supplier: any) => { @@ -287,7 +348,7 @@ export const SuppliersSetupStep: React.FC = ({