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
*/
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<UserProgress, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<UserProgress, ApiError, string>({
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<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!
*/
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<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();

View File

@@ -9,6 +9,27 @@ export interface OnboardingStepStatus {
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 {
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<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 {
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
}
/>
</CardBody>
@@ -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 (
<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>
<WizardProvider backendProgress={userProgress}>
<OnboardingWizardContent />
</WizardProvider>
);

View File

@@ -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<WizardContextValue | undefined>(undefined);
export interface WizardProviderProps {
children: ReactNode;
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> = ({
children,
initialState: providedInitialState,
backendProgress,
}) => {
const [state, setState] = useState<WizardState>({
...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<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
// 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 }));
};
/**
* 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<WizardProviderProps> = ({
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;

View File

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

View File

@@ -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<BakeryTypeSelectionStepProps> = (
const handleContinue = () => {
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 { 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<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
useEffect(() => {
if (!stockData?.items || products.length === 0) return;
@@ -138,7 +184,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
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<InitialStockEntryStepProps> = ({
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<InitialStockEntryStepProps> = ({
{hasStock && <CheckCircle className="w-4 h-4 text-[var(--color-success)]" />}
</div>
{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 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]"
/>
<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>
</div>
</div>
@@ -306,7 +365,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
{hasStock && <CheckCircle className="w-4 h-4 text-[var(--color-info)]" />}
</div>
{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 className="flex items-center gap-2">
@@ -320,7 +379,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
className="w-24 text-right"
/>
<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>
</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 { 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<InventoryReviewStepProps> = ({
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<InventoryReviewStepProps> = ({
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<InventoryReviewStepProps> = ({
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,

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 { 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<UploadSalesDataStepProps> = ({
const [stockErrors, setStockErrors] = useState<Record<string, string>>({});
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 file = event.target.files?.[0];
if (file) {
@@ -572,6 +634,19 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
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,

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 { 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<SetupStepProps> = ({ onUpdate, onComplete, onPrevious, canContinue }) => {
const { t } = useTranslation();
@@ -31,6 +34,13 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ 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<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)
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
useEffect(() => {
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
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 {
await bulkAddStockMutation.mutateAsync({
@@ -340,6 +400,15 @@ export const InventorySetupStep: React.FC<SetupStepProps> = ({ 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<SetupStepProps> = ({ 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();
}
}}

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 { 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<SetupStepProps> = ({
onNext,
@@ -34,6 +37,13 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
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<string | null>(null);
@@ -46,6 +56,49 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
});
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
useEffect(() => {
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({
name: '',
supplier_type: SupplierType.INGREDIENTS,
@@ -123,6 +179,11 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
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<SetupStepProps> = ({
</h4>
<button
type="button"
onClick={resetForm}
onClick={() => resetForm(false)}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancel')}
@@ -399,7 +460,7 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
</button>
<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"
>
{t('common:cancel', 'Cancel')}
@@ -469,7 +530,13 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = ({
)}
<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}
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
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):
user_id: str
steps: List[OnboardingStepStatus]
@@ -32,12 +52,17 @@ class UserProgress(BaseModel):
completion_percentage: float
fully_completed: bool
last_updated: datetime
context_state: Optional[WizardContextState] = None
class UpdateStepRequest(BaseModel):
step_name: str
completed: bool
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
ONBOARDING_STEPS = [
# Phase 0: System Steps
@@ -64,6 +89,9 @@ ONBOARDING_STEPS = [
# Phase 2c: Suppliers (shared by all paths)
"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)
"recipes-setup", # Production recipes (conditional: production/mixed bakery)
"quality-setup", # Quality standards and templates
@@ -100,6 +128,9 @@ STEP_DEPENDENCIES = {
# Suppliers (after 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)
"recipes-setup": ["user_registered", "setup"],
"quality-setup": ["user_registered", "setup"],
@@ -181,6 +212,9 @@ class OnboardingService:
fully_completed = required_completed
# Extract wizard context state for frontend restoration
context_state = self._extract_context_state(user_progress_data)
return UserProgress(
user_id=user_id,
steps=steps,
@@ -188,7 +222,8 @@ class OnboardingService:
next_step=next_step,
completion_percentage=completion_percentage,
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:
@@ -342,12 +377,65 @@ class OnboardingService:
"""Determine next step based on completed steps"""
current_step = self._get_current_step(completed_steps)
current_index = ONBOARDING_STEPS.index(current_step)
if current_index < len(ONBOARDING_STEPS) - 1:
return ONBOARDING_STEPS[current_index + 1]
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:
"""Check if user can complete a specific step"""
@@ -381,8 +469,19 @@ class OnboardingService:
required_steps.remove("bakery-type-selection")
for required_step in required_steps:
if not user_progress_data.get(required_step, {}).get("completed", False):
logger.debug(f"Step {step_name} blocked for user {user_id}: missing dependency {required_step}")
is_completed = user_progress_data.get(required_step, {}).get("completed", False)
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
# SPECIAL VALIDATION FOR ML TRAINING STEP
@@ -537,6 +636,23 @@ async def get_user_progress(
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(
user_id=user_id,
steps=demo_steps,
@@ -544,7 +660,8 @@ async def get_user_progress(
next_step=None,
completion_percentage=100.0,
fully_completed=True,
last_updated=datetime.now(timezone.utc)
last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
)
# Regular user flow
@@ -595,6 +712,23 @@ async def get_user_progress_by_id(
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(
user_id=user_id,
steps=demo_steps,
@@ -602,7 +736,8 @@ async def get_user_progress_by_id(
next_step=None,
completion_percentage=100.0,
fully_completed=True,
last_updated=datetime.now(timezone.utc)
last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
)
# Regular user flow
@@ -643,6 +778,23 @@ async def update_onboarding_step(
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(
user_id=user_id,
steps=demo_steps,
@@ -650,7 +802,8 @@ async def update_onboarding_step(
next_step=None,
completion_percentage=100.0,
fully_completed=True,
last_updated=datetime.now(timezone.utc)
last_updated=datetime.now(timezone.utc),
context_state=demo_context_state
)
# Regular user flow
@@ -745,4 +898,150 @@ async def complete_onboarding(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
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"
)