Improve onboarding flow
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user