Improve onboarding flow

This commit is contained in:
Urtzi Alfaro
2026-01-04 21:37:44 +01:00
parent 47ccea4900
commit 429e724a2c
13 changed files with 1052 additions and 213 deletions

View File

@@ -2,8 +2,9 @@
* Onboarding React Query hooks * Onboarding React Query hooks
*/ */
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { useCallback, useRef } from 'react';
import { onboardingService } from '../services/onboarding'; import { onboardingService } from '../services/onboarding';
import { UserProgress, UpdateStepRequest } from '../types/onboarding'; import { UserProgress, UpdateStepRequest, StepDraftResponse } from '../types/onboarding';
import { ApiError } from '../client'; import { ApiError } from '../client';
// Query Keys // Query Keys
@@ -12,6 +13,7 @@ export const onboardingKeys = {
progress: (userId: string) => [...onboardingKeys.all, 'progress', userId] as const, progress: (userId: string) => [...onboardingKeys.all, 'progress', userId] as const,
steps: () => [...onboardingKeys.all, 'steps'] as const, steps: () => [...onboardingKeys.all, 'steps'] as const,
stepDetail: (stepName: string) => [...onboardingKeys.steps(), stepName] as const, stepDetail: (stepName: string) => [...onboardingKeys.steps(), stepName] as const,
stepDraft: (stepName: string) => [...onboardingKeys.all, 'draft', stepName] as const,
} as const; } as const;
// Queries // Queries
@@ -122,8 +124,6 @@ export const useMarkStepCompleted = (
// Invalidate queries on error to ensure we get fresh data // Invalidate queries on error to ensure we get fresh data
queryClient.invalidateQueries({ queryKey: onboardingKeys.progress(userId) }); 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, ...options,
}); });
}; };
@@ -142,3 +142,109 @@ export const useResetProgress = (
...options, ...options,
}); });
}; };
// Draft Queries and Mutations
/**
* Query hook to get draft data for a specific step
*/
export const useStepDraft = (
stepName: string,
options?: Omit<UseQueryOptions<StepDraftResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<StepDraftResponse, ApiError>({
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<string, any> }>
) => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean }, ApiError, { stepName: string; draftData: Record<string, any> }>({
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<NodeJS.Timeout | null>(null);
const saveDraft = useCallback(
(draftData: Record<string, any>) => {
// 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<string, any>) => {
// 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,
};
};

View File

@@ -3,7 +3,7 @@
* Frontend and backend step names now match directly! * Frontend and backend step names now match directly!
*/ */
import { apiClient } from '../client'; 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) // Backend onboarding steps (full list from backend - UPDATED to match refactored flow)
// NOTE: poi-detection removed - now happens automatically in background during tenant registration // NOTE: poi-detection removed - now happens automatically in background during tenant registration
@@ -179,6 +179,66 @@ export class OnboardingService {
return { stepId: 'setup', stepIndex: 0 }; 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<string, any>): 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<StepDraftResponse> {
console.log(`📖 Getting draft for step "${stepName}"`);
try {
const response = await apiClient.get<StepDraftResponse>(`${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(); export const onboardingService = new OnboardingService();

View File

@@ -9,6 +9,27 @@ export interface OnboardingStepStatus {
data?: Record<string, any>; data?: Record<string, any>;
} }
/**
* 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 { export interface UserProgress {
user_id: string; user_id: string;
steps: OnboardingStepStatus[]; steps: OnboardingStepStatus[];
@@ -17,6 +38,7 @@ export interface UserProgress {
completion_percentage: number; completion_percentage: number;
fully_completed: boolean; fully_completed: boolean;
last_updated: string; last_updated: string;
context_state?: WizardContextState;
} }
export interface UpdateStepRequest { export interface UpdateStepRequest {
@@ -24,3 +46,15 @@ export interface UpdateStepRequest {
completed: boolean; completed: boolean;
data?: Record<string, any>; data?: Record<string, any>;
} }
export interface SaveStepDraftRequest {
step_name: string;
draft_data: Record<string, any>;
}
export interface StepDraftResponse {
step_name: string;
draft_data: Record<string, any> | null;
draft_saved_at?: string;
demo_mode?: boolean;
}

View File

@@ -22,6 +22,7 @@ import {
// Import setup wizard steps // Import setup wizard steps
import { import {
SuppliersSetupStep, SuppliersSetupStep,
InventorySetupStep,
RecipesSetupStep, RecipesSetupStep,
QualitySetupStep, QualitySetupStep,
TeamSetupStep, TeamSetupStep,
@@ -59,21 +60,20 @@ const OnboardingWizardContent: React.FC = () => {
const subscriptionTier = localStorage.getItem('subscription_tier') as string; const subscriptionTier = localStorage.getItem('subscription_tier') as string;
const isEnterprise = subscriptionTier === 'enterprise'; 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) // 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(() => { React.useEffect(() => {
if (isEnterprise && !wizardContext.state.bakeryType) { if (isEnterprise && !wizardContext.state.bakeryType) {
console.log('🏢 Auto-setting bakeryType to "mixed" for enterprise tier'); console.log('🏢 Auto-setting bakeryType to "mixed" for enterprise tier');
wizardContext.updateBakeryType('mixed'); // Enterprise is always mixed (production + retail) wizardContext.updateBakeryType('mixed'); // Enterprise is always mixed (production + retail)
} }
}, [isEnterprise, wizardContext]); }, [isEnterprise, wizardContext.state.bakeryType]);
// 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');
}
// All possible steps with conditional visibility // All possible steps with conditional visibility
// All step IDs match backend ONBOARDING_STEPS exactly // 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'); : t('onboarding:steps.setup.description', 'Información básica');
})(), })(),
component: RegisterTenantStep, component: RegisterTenantStep,
isConditional: true, // No condition - setup step should always be visible
condition: (ctx) => { // The natural flow is: bakery-type-selection → setup
// Allow setup step if bakeryType is set OR if user is enterprise tier // Enterprise users skip bakery-type-selection but still need setup
// (enterprise auto-sets bakeryType to 'mixed' automatically)
const currentTier = localStorage.getItem('subscription_tier');
return ctx.state.bakeryType !== null || currentTier === 'enterprise';
},
}, },
// Enterprise-specific: Child Tenants Setup // Enterprise-specific: Child Tenants Setup
{ {
@@ -154,7 +150,20 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.stock.description', 'Cantidades iniciales'), description: t('onboarding:steps.stock.description', 'Cantidades iniciales'),
component: InitialStockEntryStep, component: InitialStockEntryStep,
isConditional: true, 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', id: 'suppliers-setup',
@@ -163,6 +172,14 @@ const OnboardingWizardContent: React.FC = () => {
component: SuppliersSetupStep, component: SuppliersSetupStep,
// Always show - no conditional // 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', id: 'recipes-setup',
title: t('onboarding:steps.recipes.title', 'Recetas'), title: t('onboarding:steps.recipes.title', 'Recetas'),
@@ -204,7 +221,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.completion.description', '¡Todo listo!'), description: t('onboarding:steps.completion.description', '¡Todo listo!'),
component: CompletionStep, 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 // Filter visible steps based on wizard context
// useMemo ensures VISIBLE_STEPS recalculates when wizard context state changes // useMemo ensures VISIBLE_STEPS recalculates when wizard context state changes
@@ -226,7 +243,7 @@ const OnboardingWizardContent: React.FC = () => {
}); });
return visibleSteps; 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 isNewTenant = searchParams.get('new') === 'true';
const [currentStepIndex, setCurrentStepIndex] = useState(0); const [currentStepIndex, setCurrentStepIndex] = useState(0);
@@ -235,11 +252,6 @@ const OnboardingWizardContent: React.FC = () => {
useTenantInitializer(); useTenantInitializer();
const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress(
user?.id || '',
{ enabled: !!user?.id }
);
const markStepCompleted = useMarkStepCompleted(); const markStepCompleted = useMarkStepCompleted();
const { setCurrentTenant, loadUserTenants } = useTenantActions(); const { setCurrentTenant, loadUserTenants } = useTenantActions();
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false); const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
@@ -364,7 +376,18 @@ const OnboardingWizardContent: React.FC = () => {
console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data); console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data);
try { 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) { if (currentStep.id === 'bakery-type-selection' && data?.bakeryType) {
wizardContext.updateBakeryType(data.bakeryType as BakeryType); wizardContext.updateBakeryType(data.bakeryType as BakeryType);
} }
@@ -393,7 +416,7 @@ const OnboardingWizardContent: React.FC = () => {
wizardContext.markStepComplete('stockEntryCompleted'); wizardContext.markStepComplete('stockEntryCompleted');
} }
if (currentStep.id === 'inventory-setup') { if (currentStep.id === 'inventory-setup') {
wizardContext.markStepComplete('inventoryCompleted'); wizardContext.markStepComplete('inventorySetupCompleted');
} }
if (currentStep.id === 'setup') { if (currentStep.id === 'setup') {
console.log('✅ Setup step completed with data:', { hasTenant: !!data?.tenant, hasUser: !!user?.id, data }); 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 // Special handling for inventory-review - auto-complete suppliers if requested
if (currentStep.id === 'inventory-review' && data?.shouldAutoCompleteSuppliers) { if (currentStep.id === 'inventory-review' && data?.shouldAutoCompleteSuppliers) {
try { try {
@@ -760,6 +773,20 @@ const OnboardingWizardContent: React.FC = () => {
isFirstStep={currentStepIndex === 0} isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === VISIBLE_STEPS.length - 1} isLastStep={currentStepIndex === VISIBLE_STEPS.length - 1}
canContinue={canContinue} 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={ initialData={
// Pass AI data and file to InventoryReviewStep // Pass AI data and file to InventoryReviewStep
currentStep.id === 'inventory-review' currentStep.id === 'inventory-review'
@@ -770,18 +797,6 @@ const OnboardingWizardContent: React.FC = () => {
uploadedFileName: wizardContext.state.uploadedFileName || '', uploadedFileName: wizardContext.state.uploadedFileName || '',
uploadedFileSize: wizardContext.state.uploadedFileSize || 0, 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 = () => { 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 ( return (
<WizardProvider> <div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card padding="lg" shadow="lg">
<CardBody>
<div className="flex items-center justify-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)] text-sm sm:text-base">Cargando tu progreso...</p>
</div>
</CardBody>
</Card>
</div>
);
}
// Pass backend progress to WizardProvider for state restoration
return (
<WizardProvider backendProgress={userProgress}>
<OnboardingWizardContent /> <OnboardingWizardContent />
</WizardProvider> </WizardProvider>
); );

View File

@@ -1,21 +1,11 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, useMemo, ReactNode } from 'react';
import type { ProductSuggestionResponse } from '../../../api/types/inventory'; import type { ProductSuggestionResponse } from '../../../../api/types/inventory';
import type { ImportValidationResponse } from '../../../api/types/dataImport'; import type { ImportValidationResponse } from '../../../../api/types/dataImport';
import type { UserProgress } from '../../../../api/types/onboarding';
export type BakeryType = 'production' | 'retail' | 'mixed' | null; export type BakeryType = 'production' | 'retail' | 'mixed' | null;
export type DataSource = 'ai-assisted' | 'manual' | 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 // Inventory item structure from InventoryReviewStep
export interface InventoryItemForm { export interface InventoryItemForm {
id: string; id: string;
@@ -60,21 +50,22 @@ export interface WizardState {
childTenantsCompleted: boolean; childTenantsCompleted: boolean;
// AI-Assisted Path Data // AI-Assisted Path Data
uploadedFile?: File; // NEW: The actual file object needed for sales import API uploadedFile?: File;
uploadedFileName?: string; uploadedFileName?: string;
uploadedFileSize?: number; uploadedFileSize?: number;
uploadedFileValidation?: ImportValidationResponse; // NEW: Validation result uploadedFileValidation?: ImportValidationResponse;
aiSuggestions: ProductSuggestionResponse[]; // UPDATED: Use full ProductSuggestionResponse type aiSuggestions: ProductSuggestionResponse[];
aiAnalysisComplete: boolean; aiAnalysisComplete: boolean;
categorizedProducts?: any[]; // Products with type classification categorizedProducts?: any[];
productsWithStock?: any[]; // Products with initial stock levels productsWithStock?: any[];
inventoryItems?: InventoryItemForm[]; // NEW: Inventory items created in InventoryReviewStep inventoryItems?: InventoryItemForm[];
// Setup Progress // Setup Progress
categorizationCompleted: boolean; categorizationCompleted: boolean;
inventoryReviewCompleted: boolean; // NEW: Tracks completion of InventoryReviewStep inventoryReviewCompleted: boolean;
stockEntryCompleted: boolean; stockEntryCompleted: boolean;
suppliersCompleted: boolean; suppliersCompleted: boolean;
inventorySetupCompleted: boolean;
inventoryCompleted: boolean; inventoryCompleted: boolean;
recipesCompleted: boolean; recipesCompleted: boolean;
processesCompleted: boolean; processesCompleted: boolean;
@@ -97,22 +88,20 @@ export interface WizardContextValue {
updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void; updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void;
updateLocation: (location: { latitude: number; longitude: number }) => void; updateLocation: (location: { latitude: number; longitude: number }) => void;
updateTenantId: (tenantId: string) => void; updateTenantId: (tenantId: string) => void;
updateChildTenants: (tenants: ChildTenantData[]) => void; // NEW: Store child tenants updateChildTenants: (tenants: ChildTenantData[]) => void;
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void;
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation updateUploadedFile: (file: File, validation: ImportValidationResponse) => void;
setAIAnalysisComplete: (complete: boolean) => void; setAIAnalysisComplete: (complete: boolean) => void;
updateCategorizedProducts: (products: any[]) => void; updateCategorizedProducts: (products: any[]) => void;
updateProductsWithStock: (products: any[]) => void; updateProductsWithStock: (products: any[]) => void;
updateInventoryItems: (items: InventoryItemForm[]) => void; // NEW: Store inventory items updateInventoryItems: (items: InventoryItemForm[]) => void;
markStepComplete: (step: keyof WizardState) => void; markStepComplete: (step: keyof WizardState) => void;
getVisibleSteps: () => string[];
shouldShowStep: (stepId: string) => boolean;
resetWizard: () => void; resetWizard: () => void;
} }
const initialState: WizardState = { const initialState: WizardState = {
bakeryType: null, bakeryType: null,
dataSource: 'ai-assisted', // Only AI-assisted path supported now dataSource: 'ai-assisted',
childTenants: undefined, childTenants: undefined,
childTenantsCompleted: false, childTenantsCompleted: false,
aiSuggestions: [], aiSuggestions: [],
@@ -120,9 +109,10 @@ const initialState: WizardState = {
categorizedProducts: undefined, categorizedProducts: undefined,
productsWithStock: undefined, productsWithStock: undefined,
categorizationCompleted: false, categorizationCompleted: false,
inventoryReviewCompleted: false, // NEW: Initially false inventoryReviewCompleted: false,
stockEntryCompleted: false, stockEntryCompleted: false,
suppliersCompleted: false, suppliersCompleted: false,
inventorySetupCompleted: false,
inventoryCompleted: false, inventoryCompleted: false,
recipesCompleted: false, recipesCompleted: false,
processesCompleted: false, processesCompleted: false,
@@ -137,17 +127,83 @@ const WizardContext = createContext<WizardContextValue | undefined>(undefined);
export interface WizardProviderProps { export interface WizardProviderProps {
children: ReactNode; children: ReactNode;
initialState?: Partial<WizardState>; initialState?: Partial<WizardState>;
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<WizardState>
): Partial<WizardState> {
if (!backendProgress?.context_state) {
return providedInitialState || {};
}
const ctx = backendProgress.context_state;
// Map backend context state to frontend WizardState
const derivedState: Partial<WizardState> = {
// 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<WizardProviderProps> = ({ export const WizardProvider: React.FC<WizardProviderProps> = ({
children, children,
initialState: providedInitialState, initialState: providedInitialState,
backendProgress,
}) => { }) => {
const [state, setState] = useState<WizardState>({ // Derive initial state from backend progress if available
const derivedInitialState = useMemo(() => {
const derived = deriveStateFromBackendProgress(backendProgress, providedInitialState);
return {
...initialState, ...initialState,
...providedInitialState, ...derived,
startedAt: providedInitialState?.startedAt || new Date().toISOString(), startedAt: providedInitialState?.startedAt || new Date().toISOString(),
}); };
}, [backendProgress, providedInitialState]);
const [state, setState] = useState<WizardState>(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 // SECURITY: Removed localStorage persistence for wizard state
// All onboarding progress is now tracked exclusively via backend API // All onboarding progress is now tracked exclusively via backend API
@@ -232,75 +288,6 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
setState(prev => ({ ...prev, [step]: true })); 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 * Resets wizard state to initial values
*/ */
@@ -327,8 +314,6 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
updateProductsWithStock, updateProductsWithStock,
updateInventoryItems, updateInventoryItems,
markStepComplete, markStepComplete,
getVisibleSteps,
shouldShowStep,
resetWizard, resetWizard,
}; };
@@ -350,23 +335,4 @@ export const useWizardContext = (): WizardContextValue => {
return context; 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; export default WizardContext;

View File

@@ -1,10 +1,8 @@
export { export {
WizardProvider, WizardProvider,
useWizardContext, useWizardContext,
useStepVisibility,
type BakeryType, type BakeryType,
type DataSource, type DataSource,
type AISuggestion,
type WizardState, type WizardState,
type WizardContextValue, type WizardContextValue,
} from './WizardContext'; } from './WizardContext';

View File

@@ -6,7 +6,7 @@ import Card from '../../../ui/Card/Card';
export interface BakeryTypeSelectionStepProps { export interface BakeryTypeSelectionStepProps {
onUpdate?: (data: { bakeryType: string }) => void; onUpdate?: (data: { bakeryType: string }) => void;
onComplete?: () => void; onComplete?: (data?: { bakeryType: string }) => void;
initialData?: { initialData?: {
bakeryType?: string; bakeryType?: string;
}; };
@@ -113,7 +113,7 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
const handleContinue = () => { const handleContinue = () => {
if (selectedType) { if (selectedType) {
onComplete?.(); onComplete?.({ bakeryType: selectedType });
} }
}; };

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Package, Salad, AlertCircle, ArrowRight, ArrowLeft, CheckCircle } from 'lucide-react'; import { Package, Salad, AlertCircle, ArrowRight, ArrowLeft, CheckCircle } from 'lucide-react';
import Button from '../../../ui/Button/Button'; import Button from '../../../ui/Button/Button';
@@ -6,8 +6,11 @@ import Card from '../../../ui/Card/Card';
import Input from '../../../ui/Input/Input'; import Input from '../../../ui/Input/Input';
import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAddStock, useStock } from '../../../../api/hooks/inventory'; import { useAddStock, useStock } from '../../../../api/hooks/inventory';
import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding';
import InfoCard from '../../../ui/InfoCard'; import InfoCard from '../../../ui/InfoCard';
const STEP_NAME = 'initial-stock-entry';
export interface ProductWithStock { export interface ProductWithStock {
id: string; id: string;
name: string; name: string;
@@ -55,6 +58,49 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
})); }));
}); });
// 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 // Merge existing stock from backend on mount
useEffect(() => { useEffect(() => {
if (!stockData?.items || products.length === 0) return; if (!stockData?.items || products.length === 0) return;
@@ -156,6 +202,19 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
console.log(`✅ Synced ${stockEntriesToSync.length} stock entries successfully`); 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?.(); onComplete?.();
} catch (error) { } catch (error) {
console.error('Error syncing stock entries:', error); console.error('Error syncing stock entries:', error);
@@ -255,7 +314,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
{hasStock && <CheckCircle className="w-4 h-4 text-[var(--color-success)]" />} {hasStock && <CheckCircle className="w-4 h-4 text-[var(--color-success)]" />}
</div> </div>
{product.category && ( {product.category && (
<div className="text-xs text-[var(--text-secondary)]">{product.category}</div> <div className="text-xs text-[var(--text-secondary)] uppercase">{t(`inventory:enums.ingredient_category.${product.category}`, product.category)}</div>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -269,7 +328,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
className="w-20 sm:w-24 text-right min-h-[44px]" className="w-20 sm:w-24 text-right min-h-[44px]"
/> />
<span className="text-sm text-[var(--text-secondary)] whitespace-nowrap"> <span className="text-sm text-[var(--text-secondary)] whitespace-nowrap">
{product.unit || 'kg'} {t(`inventory:enums.unit_of_measure.${product.unit}`, product.unit || 'kg')}
</span> </span>
</div> </div>
</div> </div>
@@ -306,7 +365,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
{hasStock && <CheckCircle className="w-4 h-4 text-[var(--color-info)]" />} {hasStock && <CheckCircle className="w-4 h-4 text-[var(--color-info)]" />}
</div> </div>
{product.category && ( {product.category && (
<div className="text-xs text-[var(--text-secondary)]">{product.category}</div> <div className="text-xs text-[var(--text-secondary)] uppercase">{t(`inventory:enums.ingredient_category.${product.category}`, product.category)}</div>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -320,7 +379,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
className="w-24 text-right" className="w-24 text-right"
/> />
<span className="text-sm text-[var(--text-secondary)] whitespace-nowrap"> <span className="text-sm text-[var(--text-secondary)] whitespace-nowrap">
{product.unit || t('common:units', 'unidades')} {t(`inventory:enums.unit_of_measure.${product.unit}`, product.unit || t('common:units', 'unidades'))}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,13 +1,16 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button'; import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateIngredient, useBulkCreateIngredients, useIngredients } from '../../../../api/hooks/inventory'; import { useCreateIngredient, useBulkCreateIngredients, useIngredients } from '../../../../api/hooks/inventory';
import { useImportSalesData } from '../../../../api/hooks/sales'; import { useImportSalesData } from '../../../../api/hooks/sales';
import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding';
import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory'; import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory';
import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } 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'; import { Package, ShoppingBag, AlertCircle, CheckCircle2, Edit2, Trash2, Plus, Sparkles } from 'lucide-react';
const STEP_NAME = 'inventory-review';
interface InventoryReviewStepProps { interface InventoryReviewStepProps {
onNext: () => void; onNext: () => void;
onPrevious: () => void; onPrevious: () => void;
@@ -144,11 +147,35 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
const importSalesMutation = useImportSalesData(); const importSalesMutation = useImportSalesData();
const { data: existingIngredients } = useIngredients(tenantId); 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(() => { 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[] = []; let items: InventoryItemForm[] = [];
// 1. Start with AI suggestions if available
if (initialData?.aiSuggestions && initialData.aiSuggestions.length > 0) { if (initialData?.aiSuggestions && initialData.aiSuggestions.length > 0) {
items = initialData.aiSuggestions.map((suggestion, index) => ({ items = initialData.aiSuggestions.map((suggestion, index) => ({
id: `ai-${index}-${Date.now()}`, id: `ai-${index}-${Date.now()}`,
@@ -199,7 +226,26 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
if (items.length > 0) { if (items.length > 0) {
setInventoryItems(items); 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 // Filter items
const filteredItems = inventoryItems.filter(item => { const filteredItems = inventoryItems.filter(item => {
@@ -426,6 +472,19 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
console.log('📦 Passing items with real IDs to next step:', allItemsWithRealIds); 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({ onComplete({
inventoryItemsCreated: newlyCreatedIngredients.length, inventoryItemsCreated: newlyCreatedIngredients.length,
salesDataImported: salesImported, salesDataImported: salesImported,

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button'; import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCurrentTenant } from '../../../../stores/tenant.store';
@@ -6,12 +6,15 @@ import { useTenantCurrency } from '../../../../hooks/useTenantCurrency';
import { useCreateIngredient, useClassifyBatch, useAddStock } from '../../../../api/hooks/inventory'; import { useCreateIngredient, useClassifyBatch, useAddStock } from '../../../../api/hooks/inventory';
import { useValidateImportFile, useImportSalesData } from '../../../../api/hooks/sales'; import { useValidateImportFile, useImportSalesData } from '../../../../api/hooks/sales';
import { useSuppliers } from '../../../../api/hooks/suppliers'; import { useSuppliers } from '../../../../api/hooks/suppliers';
import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding';
import type { ImportValidationResponse } from '../../../../api/types/dataImport'; import type { ImportValidationResponse } from '../../../../api/types/dataImport';
import type { ProductSuggestionResponse, StockCreate, StockResponse } from '../../../../api/types/inventory'; import type { ProductSuggestionResponse, StockCreate, StockResponse } from '../../../../api/types/inventory';
import { ProductionStage } from '../../../../api/types/inventory'; import { ProductionStage } from '../../../../api/types/inventory';
import { useAuth } from '../../../../contexts/AuthContext'; import { useAuth } from '../../../../contexts/AuthContext';
import { BatchAddIngredientsModal } from '../../inventory/BatchAddIngredientsModal'; import { BatchAddIngredientsModal } from '../../inventory/BatchAddIngredientsModal';
const STEP_NAME = 'upload-sales-data';
interface UploadSalesDataStepProps { interface UploadSalesDataStepProps {
onNext: () => void; onNext: () => void;
onPrevious: () => void; onPrevious: () => void;
@@ -115,6 +118,65 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
const [stockErrors, setStockErrors] = useState<Record<string, string>>({}); const [stockErrors, setStockErrors] = useState<Record<string, string>>({});
const [ingredientStocks, setIngredientStocks] = useState<Record<string, StockResponse[]>>({}); const [ingredientStocks, setIngredientStocks] = useState<Record<string, StockResponse[]>>({});
// 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<HTMLInputElement>) => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
@@ -572,6 +634,19 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
setProgressState(null); 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 // Complete step
onComplete({ onComplete({
createdIngredients, createdIngredients,

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SetupStepProps } from '../types'; import { SetupStepProps } from '../types';
import { useIngredients, useCreateIngredient, useUpdateIngredient, useSoftDeleteIngredient, useBulkAddStock } from '../../../../api/hooks/inventory'; 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 { UnitOfMeasure, IngredientCategory, ProductionStage } from '../../../../api/types/inventory';
import type { IngredientCreate, IngredientUpdate, StockCreate } 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 { 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<SetupStepProps> = ({ onUpdate, onComplete, onPrevious, canContinue }) => { export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplete, onPrevious, canContinue }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,6 +34,13 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
const deleteIngredientMutation = useSoftDeleteIngredient(); const deleteIngredientMutation = useSoftDeleteIngredient();
const bulkAddStockMutation = useBulkAddStock(); 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 // Form state
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@@ -64,6 +74,45 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
// Track stocks display per ingredient (using StockCreate for pending, before API submission) // Track stocks display per ingredient (using StockCreate for pending, before API submission)
const [ingredientStocks, setIngredientStocks] = useState<Record<string, Array<StockCreate & { _tempId: string }>>>({}); const [ingredientStocks, setIngredientStocks] = useState<Record<string, Array<StockCreate & { _tempId: string }>>>({});
// 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 // Notify parent when count changes
useEffect(() => { useEffect(() => {
const count = ingredients.length; const count = ingredients.length;
@@ -331,7 +380,18 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
// Submit all pending stocks when proceeding to next step // Submit all pending stocks when proceeding to next step
const handleSubmitPendingStocks = async (): Promise<boolean> => { const handleSubmitPendingStocks = async (): Promise<boolean> => {
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 { try {
await bulkAddStockMutation.mutateAsync({ await bulkAddStockMutation.mutateAsync({
@@ -340,6 +400,15 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
}); });
// Clear pending stocks after successful submission // Clear pending stocks after successful submission
setPendingStocks([]); setPendingStocks([]);
setIngredientStocks({});
// Delete draft data after successful submission
try {
await deleteStepDraft.mutateAsync(STEP_NAME);
} catch {
// Ignore errors when deleting draft
}
return true; return true;
} catch (error) { } catch (error) {
console.error('Error submitting stocks:', error); console.error('Error submitting stocks:', error);
@@ -1043,6 +1112,10 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onCompl
onClick={async () => { onClick={async () => {
const success = await handleSubmitPendingStocks(); const success = await handleSubmitPendingStocks();
if (success) { 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(); onComplete();
} }
}} }}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SetupStepProps } from '../types'; import { SetupStepProps } from '../types';
import { useSuppliers, useCreateSupplier, useUpdateSupplier, useDeleteSupplier } from '../../../../api/hooks/suppliers'; 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 { useAuthUser } from '../../../../stores/auth.store';
import type { SupplierCreate, SupplierUpdate } from '../../../../api/types/suppliers'; import type { SupplierCreate, SupplierUpdate } from '../../../../api/types/suppliers';
import { SupplierProductManager } from './SupplierProductManager'; import { SupplierProductManager } from './SupplierProductManager';
import { useAutoSaveDraft, useStepDraft, useDeleteStepDraft } from '../../../../api/hooks/onboarding';
const STEP_NAME = 'suppliers-setup';
export const SuppliersSetupStep: React.FC<SetupStepProps> = ({ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
onNext, onNext,
@@ -34,6 +37,13 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
const updateSupplierMutation = useUpdateSupplier(); const updateSupplierMutation = useUpdateSupplier();
const deleteSupplierMutation = useDeleteSupplier(); 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 // Form state
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@@ -46,6 +56,49 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
}); });
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
// 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 // Notify parent when count changes
useEffect(() => { useEffect(() => {
onUpdate?.({ onUpdate?.({
@@ -112,7 +165,10 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
} }
}; };
const resetForm = () => { const resetForm = (clearDraft: boolean = true) => {
// Cancel any pending auto-save
cancelPendingSave();
setFormData({ setFormData({
name: '', name: '',
supplier_type: SupplierType.INGREDIENTS, supplier_type: SupplierType.INGREDIENTS,
@@ -123,6 +179,11 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
setErrors({}); setErrors({});
setIsAdding(false); setIsAdding(false);
setEditingId(null); setEditingId(null);
// Delete draft if requested (after successful submission)
if (clearDraft) {
deleteStepDraft.mutate(STEP_NAME);
}
}; };
const handleEdit = (supplier: any) => { const handleEdit = (supplier: any) => {
@@ -287,7 +348,7 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
</h4> </h4>
<button <button
type="button" type="button"
onClick={resetForm} onClick={() => resetForm(false)}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]" className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
> >
{t('common:cancel', 'Cancel')} {t('common:cancel', 'Cancel')}
@@ -399,7 +460,7 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
</button> </button>
<button <button
type="button" type="button"
onClick={resetForm} onClick={() => resetForm(false)}
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors" className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
> >
{t('common:cancel', 'Cancel')} {t('common:cancel', 'Cancel')}
@@ -469,7 +530,13 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
)} )}
<button <button
type="button" type="button"
onClick={() => onComplete?.()} onClick={() => {
// CRITICAL: Mark step as completed BEFORE calling onComplete
// This prevents any pending auto-save from overwriting the completion
stepCompletedRef.current = true;
cancelPendingSave();
onComplete?.();
}}
disabled={!canContinue} disabled={!canContinue}
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2" className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2"
> >

View File

@@ -24,6 +24,26 @@ class OnboardingStepStatus(BaseModel):
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
data: Optional[Dict[str, Any]] = None data: Optional[Dict[str, Any]] = None
class WizardContextState(BaseModel):
"""
Wizard context state extracted from completed step data.
This is used to restore the frontend WizardContext on session restore.
"""
bakery_type: Optional[str] = None
tenant_id: Optional[str] = None
subscription_tier: Optional[str] = None
ai_analysis_complete: bool = False
inventory_review_completed: bool = False
stock_entry_completed: bool = False
categorization_completed: bool = False
suppliers_completed: bool = False
inventory_setup_completed: bool = False
recipes_completed: bool = False
quality_completed: bool = False
team_completed: bool = False
child_tenants_completed: bool = False
ml_training_complete: bool = False
class UserProgress(BaseModel): class UserProgress(BaseModel):
user_id: str user_id: str
steps: List[OnboardingStepStatus] steps: List[OnboardingStepStatus]
@@ -32,12 +52,17 @@ class UserProgress(BaseModel):
completion_percentage: float completion_percentage: float
fully_completed: bool fully_completed: bool
last_updated: datetime last_updated: datetime
context_state: Optional[WizardContextState] = None
class UpdateStepRequest(BaseModel): class UpdateStepRequest(BaseModel):
step_name: str step_name: str
completed: bool completed: bool
data: Optional[Dict[str, Any]] = None data: Optional[Dict[str, Any]] = None
class SaveStepDraftRequest(BaseModel):
step_name: str
draft_data: Dict[str, Any]
# Define the onboarding steps and their order - matching frontend UnifiedOnboardingWizard step IDs # Define the onboarding steps and their order - matching frontend UnifiedOnboardingWizard step IDs
ONBOARDING_STEPS = [ ONBOARDING_STEPS = [
# Phase 0: System Steps # Phase 0: System Steps
@@ -64,6 +89,9 @@ ONBOARDING_STEPS = [
# Phase 2c: Suppliers (shared by all paths) # Phase 2c: Suppliers (shared by all paths)
"suppliers-setup", # Suppliers configuration "suppliers-setup", # Suppliers configuration
# Phase 2d: Manual Inventory Setup (alternative to AI-assisted path)
"inventory-setup", # Manual ingredient/inventory management
# Phase 3: Advanced Configuration (all optional) # Phase 3: Advanced Configuration (all optional)
"recipes-setup", # Production recipes (conditional: production/mixed bakery) "recipes-setup", # Production recipes (conditional: production/mixed bakery)
"quality-setup", # Quality standards and templates "quality-setup", # Quality standards and templates
@@ -100,6 +128,9 @@ STEP_DEPENDENCIES = {
# Suppliers (after inventory review) # Suppliers (after inventory review)
"suppliers-setup": ["user_registered", "setup", "inventory-review"], "suppliers-setup": ["user_registered", "setup", "inventory-review"],
# Manual inventory setup (alternative to AI-assisted path)
"inventory-setup": ["user_registered", "setup"],
# Advanced configuration (optional, minimal dependencies) # Advanced configuration (optional, minimal dependencies)
"recipes-setup": ["user_registered", "setup"], "recipes-setup": ["user_registered", "setup"],
"quality-setup": ["user_registered", "setup"], "quality-setup": ["user_registered", "setup"],
@@ -181,6 +212,9 @@ class OnboardingService:
fully_completed = required_completed fully_completed = required_completed
# Extract wizard context state for frontend restoration
context_state = self._extract_context_state(user_progress_data)
return UserProgress( return UserProgress(
user_id=user_id, user_id=user_id,
steps=steps, steps=steps,
@@ -188,7 +222,8 @@ class OnboardingService:
next_step=next_step, next_step=next_step,
completion_percentage=completion_percentage, completion_percentage=completion_percentage,
fully_completed=fully_completed, fully_completed=fully_completed,
last_updated=datetime.now(timezone.utc) last_updated=datetime.now(timezone.utc),
context_state=context_state
) )
async def update_step(self, user_id: str, update_request: UpdateStepRequest) -> UserProgress: async def update_step(self, user_id: str, update_request: UpdateStepRequest) -> UserProgress:
@@ -348,6 +383,59 @@ class OnboardingService:
return None # No next step return None # No next step
def _extract_context_state(self, user_progress_data: Dict[str, Any]) -> WizardContextState:
"""
Extract wizard context state from completed step data.
This allows the frontend to restore WizardContext state on session restore,
ensuring conditional steps remain visible based on previous progress.
"""
# Extract bakeryType from bakery-type-selection step
bakery_step = user_progress_data.get("bakery-type-selection", {})
bakery_type = None
if bakery_step.get("completed"):
bakery_type = bakery_step.get("data", {}).get("bakeryType")
# Extract tenantId from setup step
setup_step = user_progress_data.get("setup", {})
tenant_id = None
if setup_step.get("completed"):
setup_data = setup_step.get("data", {})
tenant_id = setup_data.get("tenantId") or setup_data.get("tenant", {}).get("id")
# Extract subscription tier from user_registered step
user_registered_step = user_progress_data.get("user_registered", {})
subscription_tier = user_registered_step.get("data", {}).get("subscription_tier")
# Derive completion flags from step completion status
upload_step = user_progress_data.get("upload-sales-data", {})
inventory_review_step = user_progress_data.get("inventory-review", {})
stock_entry_step = user_progress_data.get("initial-stock-entry", {})
categorization_step = user_progress_data.get("product-categorization", {})
suppliers_step = user_progress_data.get("suppliers-setup", {})
inventory_setup_step = user_progress_data.get("inventory-setup", {})
recipes_step = user_progress_data.get("recipes-setup", {})
quality_step = user_progress_data.get("quality-setup", {})
team_step = user_progress_data.get("team-setup", {})
child_tenants_step = user_progress_data.get("child-tenants-setup", {})
ml_training_step = user_progress_data.get("ml-training", {})
return WizardContextState(
bakery_type=bakery_type,
tenant_id=tenant_id,
subscription_tier=subscription_tier,
ai_analysis_complete=upload_step.get("completed", False),
inventory_review_completed=inventory_review_step.get("completed", False),
stock_entry_completed=stock_entry_step.get("completed", False),
categorization_completed=categorization_step.get("completed", False),
suppliers_completed=suppliers_step.get("completed", False),
inventory_setup_completed=inventory_setup_step.get("completed", False),
recipes_completed=recipes_step.get("completed", False),
quality_completed=quality_step.get("completed", False),
team_completed=team_step.get("completed", False),
child_tenants_completed=child_tenants_step.get("completed", False),
ml_training_complete=ml_training_step.get("completed", False),
)
async def _can_complete_step(self, user_id: str, step_name: str) -> bool: async def _can_complete_step(self, user_id: str, step_name: str) -> bool:
"""Check if user can complete a specific step""" """Check if user can complete a specific step"""
@@ -381,8 +469,19 @@ class OnboardingService:
required_steps.remove("bakery-type-selection") required_steps.remove("bakery-type-selection")
for required_step in required_steps: for required_step in required_steps:
if not user_progress_data.get(required_step, {}).get("completed", False): is_completed = user_progress_data.get(required_step, {}).get("completed", False)
logger.debug(f"Step {step_name} blocked for user {user_id}: missing dependency {required_step}") if not is_completed:
logger.warning(
f"Step {step_name} blocked for user {user_id}: missing dependency {required_step}",
extra={
"user_id": user_id,
"step_name": step_name,
"missing_dependency": required_step,
"all_required_steps": required_steps,
"user_progress_keys": list(user_progress_data.keys()),
"dependency_data": user_progress_data.get(required_step, {})
}
)
return False return False
# SPECIAL VALIDATION FOR ML TRAINING STEP # SPECIAL VALIDATION FOR ML TRAINING STEP
@@ -537,6 +636,23 @@ async def get_user_progress(
data={"auto_completed": True, "demo_mode": True} data={"auto_completed": True, "demo_mode": True}
)) ))
# Create a fully completed context state for demo users
demo_context_state = WizardContextState(
bakery_type="mixed",
tenant_id="demo-tenant",
subscription_tier="professional",
ai_analysis_complete=True,
inventory_review_completed=True,
stock_entry_completed=True,
categorization_completed=True,
suppliers_completed=True,
recipes_completed=True,
quality_completed=True,
team_completed=True,
child_tenants_completed=False,
ml_training_complete=True,
)
return UserProgress( return UserProgress(
user_id=user_id, user_id=user_id,
steps=demo_steps, steps=demo_steps,
@@ -544,7 +660,8 @@ async def get_user_progress(
next_step=None, next_step=None,
completion_percentage=100.0, completion_percentage=100.0,
fully_completed=True, fully_completed=True,
last_updated=datetime.now(timezone.utc) last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
) )
# Regular user flow # Regular user flow
@@ -595,6 +712,23 @@ async def get_user_progress_by_id(
data={"auto_completed": True, "demo_mode": True} data={"auto_completed": True, "demo_mode": True}
)) ))
# Create a fully completed context state for demo users
demo_context_state = WizardContextState(
bakery_type="mixed",
tenant_id="demo-tenant",
subscription_tier="professional",
ai_analysis_complete=True,
inventory_review_completed=True,
stock_entry_completed=True,
categorization_completed=True,
suppliers_completed=True,
recipes_completed=True,
quality_completed=True,
team_completed=True,
child_tenants_completed=False,
ml_training_complete=True,
)
return UserProgress( return UserProgress(
user_id=user_id, user_id=user_id,
steps=demo_steps, steps=demo_steps,
@@ -602,7 +736,8 @@ async def get_user_progress_by_id(
next_step=None, next_step=None,
completion_percentage=100.0, completion_percentage=100.0,
fully_completed=True, fully_completed=True,
last_updated=datetime.now(timezone.utc) last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
) )
# Regular user flow # Regular user flow
@@ -643,6 +778,23 @@ async def update_onboarding_step(
data={"auto_completed": True, "demo_mode": True} data={"auto_completed": True, "demo_mode": True}
)) ))
# Create a fully completed context state for demo users
demo_context_state = WizardContextState(
bakery_type="mixed",
tenant_id="demo-tenant",
subscription_tier="professional",
ai_analysis_complete=True,
inventory_review_completed=True,
stock_entry_completed=True,
categorization_completed=True,
suppliers_completed=True,
recipes_completed=True,
quality_completed=True,
team_completed=True,
child_tenants_completed=False,
ml_training_complete=True,
)
return UserProgress( return UserProgress(
user_id=user_id, user_id=user_id,
steps=demo_steps, steps=demo_steps,
@@ -650,7 +802,8 @@ async def update_onboarding_step(
next_step=None, next_step=None,
completion_percentage=100.0, completion_percentage=100.0,
fully_completed=True, fully_completed=True,
last_updated=datetime.now(timezone.utc) last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
) )
# Regular user flow # Regular user flow
@@ -746,3 +899,149 @@ async def complete_onboarding(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to complete onboarding" detail="Failed to complete onboarding"
) )
@router.put("/api/v1/auth/me/onboarding/step-draft")
async def save_step_draft(
request: SaveStepDraftRequest,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Save in-progress step data without marking the step as complete.
This allows users to save their work and resume later.
"""
try:
user_id = current_user["user_id"]
is_demo = current_user.get("is_demo", False)
# Demo users don't save drafts
if is_demo or user_id.startswith("demo-user-"):
logger.info(f"Demo user {user_id} attempting to save draft - returning success (no-op)")
return {"success": True, "demo_mode": True}
# Validate step name
if request.step_name not in ONBOARDING_STEPS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid step name: {request.step_name}"
)
# Save the draft data
onboarding_repo = OnboardingRepository(db)
await onboarding_repo.upsert_user_step(
user_id=user_id,
step_name=request.step_name,
completed=False,
step_data={**request.draft_data, "is_draft": True, "draft_saved_at": datetime.now(timezone.utc).isoformat()}
)
logger.info(f"Saved draft for step {request.step_name} for user {user_id}")
return {"success": True, "step_name": request.step_name}
except HTTPException:
raise
except Exception as e:
logger.error(f"Save step draft error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save step draft"
)
@router.get("/api/v1/auth/me/onboarding/step-draft/{step_name}")
async def get_step_draft(
step_name: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get saved draft data for a specific step.
Returns null if no draft exists or the step is already completed.
"""
try:
user_id = current_user["user_id"]
is_demo = current_user.get("is_demo", False)
# Demo users don't have drafts
if is_demo or user_id.startswith("demo-user-"):
return {"step_name": step_name, "draft_data": None, "demo_mode": True}
# Validate step name
if step_name not in ONBOARDING_STEPS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid step name: {step_name}"
)
# Get the step data
onboarding_repo = OnboardingRepository(db)
step = await onboarding_repo.get_user_step(user_id, step_name)
# Return draft data only if step exists, is not completed, and has draft flag
if step and not step.completed and step.step_data and step.step_data.get("is_draft"):
return {
"step_name": step_name,
"draft_data": step.step_data,
"draft_saved_at": step.step_data.get("draft_saved_at")
}
return {"step_name": step_name, "draft_data": None}
except HTTPException:
raise
except Exception as e:
logger.error(f"Get step draft error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get step draft"
)
@router.delete("/api/v1/auth/me/onboarding/step-draft/{step_name}")
async def delete_step_draft(
step_name: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Delete saved draft data for a specific step.
Called after step is completed to clean up draft data.
"""
try:
user_id = current_user["user_id"]
is_demo = current_user.get("is_demo", False)
# Demo users don't have drafts
if is_demo or user_id.startswith("demo-user-"):
return {"success": True, "demo_mode": True}
# Validate step name
if step_name not in ONBOARDING_STEPS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid step name: {step_name}"
)
# Get the step data and remove draft flag
onboarding_repo = OnboardingRepository(db)
step = await onboarding_repo.get_user_step(user_id, step_name)
if step and step.step_data and step.step_data.get("is_draft"):
# Remove draft-related fields but keep other data
updated_data = {k: v for k, v in step.step_data.items() if k not in ["is_draft", "draft_saved_at"]}
await onboarding_repo.upsert_user_step(
user_id=user_id,
step_name=step_name,
completed=step.completed,
step_data=updated_data
)
logger.info(f"Deleted draft for step {step_name} for user {user_id}")
return {"success": True, "step_name": step_name}
except HTTPException:
raise
except Exception as e:
logger.error(f"Delete step draft error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete step draft"
)