Start integrating the onboarding flow with backend 2

This commit is contained in:
Urtzi Alfaro
2025-09-04 18:59:56 +02:00
parent a11fdfba24
commit 9eedc2e5f2
30 changed files with 3432 additions and 4735 deletions

View File

@@ -46,10 +46,15 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
const currentStep = steps[currentStepIndex]; const currentStep = steps[currentStepIndex];
const updateStepData = useCallback((stepId: string, data: any) => { const updateStepData = useCallback((stepId: string, data: any) => {
setStepData(prev => ({ console.log(`OnboardingWizard - Updating step '${stepId}' with data:`, data);
setStepData(prev => {
const newStepData = {
...prev, ...prev,
[stepId]: { ...prev[stepId], ...data } [stepId]: { ...prev[stepId], ...data }
})); };
console.log(`OnboardingWizard - Full step data after update:`, newStepData);
return newStepData;
});
// Clear validation error for this step // Clear validation error for this step
setValidationErrors(prev => { setValidationErrors(prev => {
@@ -414,7 +419,10 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
// Pass all step data to allow access to previous steps // Pass all step data to allow access to previous steps
allStepData: stepData allStepData: stepData
}} }}
onDataChange={(data) => updateStepData(currentStep.id, data)} onDataChange={(data) => {
console.log(`OnboardingWizard - Step ${currentStep.id} calling onDataChange with:`, data);
updateStepData(currentStep.id, data);
}}
onNext={goToNextStep} onNext={goToNextStep}
onPrevious={goToPreviousStep} onPrevious={goToPreviousStep}
isFirstStep={currentStepIndex === 0} isFirstStep={currentStepIndex === 0}

View File

@@ -2,6 +2,9 @@ import React, { useState, useEffect } from 'react';
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react'; import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
import { Button, Card, Badge } from '../../../ui'; import { Button, Card, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard'; import { OnboardingStepProps } from '../OnboardingWizard';
import { onboardingApiService } from '../../../../services/api/onboarding.service';
import { useAuthUser } from '../../../../stores/auth.store';
import { useAlertActions } from '../../../../stores/alerts.store';
interface CompletionStats { interface CompletionStats {
totalProducts: number; totalProducts: number;
@@ -10,6 +13,8 @@ interface CompletionStats {
mlModelAccuracy: number; mlModelAccuracy: number;
estimatedTimeSaved: string; estimatedTimeSaved: string;
completionScore: number; completionScore: number;
salesImported: boolean;
salesImportRecords: number;
} }
export const CompletionStep: React.FC<OnboardingStepProps> = ({ export const CompletionStep: React.FC<OnboardingStepProps> = ({
@@ -20,8 +25,73 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
isFirstStep, isFirstStep,
isLastStep isLastStep
}) => { }) => {
const user = useAuthUser();
const { createAlert } = useAlertActions();
const [showConfetti, setShowConfetti] = useState(false); const [showConfetti, setShowConfetti] = useState(false);
const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null); const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null);
const [isImportingSales, setIsImportingSales] = useState(false);
// Handle final sales import
const handleFinalSalesImport = async () => {
if (!user?.tenant_id || !data.files?.salesData || !data.inventoryMapping) {
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error en importación final',
message: 'Faltan datos necesarios para importar las ventas.',
source: 'onboarding'
});
return;
}
setIsImportingSales(true);
try {
const result = await onboardingApiService.importSalesWithInventory(
user.tenant_id,
data.files.salesData,
data.inventoryMapping
);
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Importación completada',
message: `Se importaron ${result.successful_imports} registros de ventas exitosamente.`,
source: 'onboarding'
});
// Update completion stats
const updatedStats = {
...completionStats!,
salesImported: true,
salesImportRecords: result.successful_imports || 0
};
setCompletionStats(updatedStats);
onDataChange({
...data,
completionStats: updatedStats,
salesImportResult: result,
finalImportCompleted: true
});
} catch (error) {
console.error('Sales import error:', error);
const errorMessage = error instanceof Error ? error.message : 'Error al importar datos de ventas';
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error en importación',
message: errorMessage,
source: 'onboarding'
});
} finally {
setIsImportingSales(false);
}
};
useEffect(() => { useEffect(() => {
// Show confetti animation // Show confetti animation
@@ -35,7 +105,9 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
suppliersConfigured: data.suppliers?.length || 0, suppliersConfigured: data.suppliers?.length || 0,
mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0, mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0,
estimatedTimeSaved: '15-20 horas', estimatedTimeSaved: '15-20 horas',
completionScore: calculateCompletionScore() completionScore: calculateCompletionScore(),
salesImported: data.finalImportCompleted || false,
salesImportRecords: data.salesImportResult?.successful_imports || 0
}; };
setCompletionStats(stats); setCompletionStats(stats);
@@ -48,6 +120,11 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
completedAt: new Date().toISOString() completedAt: new Date().toISOString()
}); });
// Trigger final sales import if not already done
if (!data.finalImportCompleted && data.inventoryMapping && data.files?.salesData) {
handleFinalSalesImport();
}
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
@@ -221,6 +298,10 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
<p className="text-2xl font-bold text-[var(--color-secondary)]">{completionStats.mlModelAccuracy.toFixed(1)}%</p> <p className="text-2xl font-bold text-[var(--color-secondary)]">{completionStats.mlModelAccuracy.toFixed(1)}%</p>
<p className="text-xs text-[var(--text-secondary)]">Precisión IA</p> <p className="text-xs text-[var(--text-secondary)]">Precisión IA</p>
</div> </div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-success)]">{completionStats.salesImported ? completionStats.salesImportRecords : '⏳'}</p>
<p className="text-xs text-[var(--text-secondary)]">Ventas {completionStats.salesImported ? 'Importadas' : 'Importando...'}</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg"> <div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-lg font-bold text-[var(--color-warning)]">{completionStats.estimatedTimeSaved}</p> <p className="text-lg font-bold text-[var(--color-warning)]">{completionStats.estimatedTimeSaved}</p>
<p className="text-xs text-[var(--text-secondary)]">Tiempo Ahorrado</p> <p className="text-xs text-[var(--text-secondary)]">Tiempo Ahorrado</p>

View File

@@ -2,6 +2,10 @@ import React, { useState, useRef, useEffect } from 'react';
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react'; import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react';
import { Button, Card, Badge } from '../../../ui'; import { Button, Card, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard'; import { OnboardingStepProps } from '../OnboardingWizard';
import { onboardingApiService } from '../../../../services/api/onboarding.service';
import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store';
import { useCurrentTenant, useTenantLoading } from '../../../../stores/tenant.store';
import { useAlertActions } from '../../../../stores/alerts.store';
type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'completed' | 'error'; type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'completed' | 'error';
@@ -26,64 +30,70 @@ interface ProcessingResult {
recommendations: string[]; recommendations: string[];
} }
// Unified mock service that handles both validation and analysis // Real data processing service using backend APIs
const mockDataProcessingService = { const dataProcessingService = {
processFile: async (file: File, onProgress: (progress: number, stage: string, message: string) => void) => { processFile: async (
return new Promise<ProcessingResult>((resolve, reject) => { file: File,
let progress = 0; tenantId: string,
onProgress: (progress: number, stage: string, message: string) => void
) => {
try {
// Stage 1: Validate file with sales service
onProgress(20, 'validating', 'Validando estructura del archivo...');
const validationResult = await onboardingApiService.validateOnboardingFile(tenantId, file);
const stages = [ onProgress(40, 'validating', 'Verificando integridad de datos...');
{ threshold: 20, stage: 'validating', message: 'Validando estructura del archivo...' },
{ threshold: 40, stage: 'validating', message: 'Verificando integridad de datos...' },
{ threshold: 60, stage: 'analyzing', message: 'Identificando productos únicos...' },
{ threshold: 80, stage: 'analyzing', message: 'Analizando patrones de venta...' },
{ threshold: 90, stage: 'analyzing', message: 'Generando recomendaciones con IA...' },
{ threshold: 100, stage: 'completed', message: 'Procesamiento completado' }
];
const interval = setInterval(() => { if (!validationResult.is_valid) {
if (progress < 100) { throw new Error('Archivo de datos inválido');
progress += 10;
const currentStage = stages.find(s => progress <= s.threshold);
if (currentStage) {
onProgress(progress, currentStage.stage, currentStage.message);
}
} }
if (progress >= 100) { if (!validationResult.product_list || validationResult.product_list.length === 0) {
clearInterval(interval); throw new Error('No se encontraron productos en el archivo');
// Return combined validation + analysis results }
resolve({
// Validation results // Stage 2: Generate AI suggestions with inventory service
is_valid: true, onProgress(60, 'analyzing', 'Identificando productos únicos...');
total_records: Math.floor(Math.random() * 1000) + 100, onProgress(80, 'analyzing', 'Analizando patrones de venta...');
unique_products: Math.floor(Math.random() * 50) + 10,
product_list: ['Pan Integral', 'Croissant', 'Baguette', 'Empanadas', 'Pan de Centeno', 'Medialunas'], console.log('DataProcessingStep - Calling generateInventorySuggestions with:', {
validation_errors: [], tenantId,
validation_warnings: [ fileName: file.name,
'Algunas fechas podrían tener formato inconsistente', productList: validationResult.product_list
'3 productos sin categoría definida'
],
summary: {
date_range: '2024-01-01 to 2024-12-31',
total_sales: 15420.50,
average_daily_sales: 42.25
},
// Analysis results
productsIdentified: 15,
categoriesDetected: 4,
businessModel: 'artisan',
confidenceScore: 94,
recommendations: [
'Se detectó un modelo de panadería artesanal con producción propia',
'Los productos más vendidos son panes tradicionales y bollería',
'Recomendamos categorizar el inventario por tipo de producto',
'Considera ampliar la línea de productos de repostería'
]
}); });
const suggestionsResult = await onboardingApiService.generateInventorySuggestions(
tenantId,
file,
validationResult.product_list
);
console.log('DataProcessingStep - AI suggestions result:', suggestionsResult);
onProgress(90, 'analyzing', 'Generando recomendaciones con IA...');
onProgress(100, 'completed', 'Procesamiento completado');
// Combine results
const combinedResult = {
...validationResult,
productsIdentified: suggestionsResult.total_products || validationResult.unique_products,
categoriesDetected: suggestionsResult.suggestions ?
new Set(suggestionsResult.suggestions.map(s => s.category)).size : 4,
businessModel: suggestionsResult.business_model_analysis?.model || 'production',
confidenceScore: suggestionsResult.high_confidence_count && suggestionsResult.total_products ?
Math.round((suggestionsResult.high_confidence_count / suggestionsResult.total_products) * 100) : 85,
recommendations: suggestionsResult.business_model_analysis?.recommendations || [],
aiSuggestions: suggestionsResult.suggestions || []
};
console.log('DataProcessingStep - Combined result:', combinedResult);
console.log('DataProcessingStep - Combined result aiSuggestions:', combinedResult.aiSuggestions);
console.log('DataProcessingStep - Combined result aiSuggestions length:', combinedResult.aiSuggestions?.length);
return combinedResult;
} catch (error) {
console.error('Data processing error:', error);
throw error;
} }
}, 400);
});
} }
}; };
@@ -95,6 +105,34 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
isFirstStep, isFirstStep,
isLastStep isLastStep
}) => { }) => {
const user = useAuthUser();
const authLoading = useAuthLoading();
const currentTenant = useCurrentTenant();
const tenantLoading = useTenantLoading();
const { createAlert } = useAlertActions();
// Check if we're still loading user or tenant data
const isLoadingUserData = authLoading || tenantLoading;
// Get tenant ID from multiple sources with fallback
const getTenantId = (): string | null => {
const tenantId = currentTenant?.id || user?.tenant_id || null;
console.log('DataProcessingStep - getTenantId:', {
currentTenant: currentTenant?.id,
userTenantId: user?.tenant_id,
finalTenantId: tenantId,
isLoadingUserData,
authLoading,
tenantLoading,
user: user ? { id: user.id, email: user.email } : null
});
return tenantId;
};
// Check if tenant data is available (not loading and has ID)
const isTenantAvailable = (): boolean => {
return !isLoadingUserData && getTenantId() !== null;
};
const [stage, setStage] = useState<ProcessingStage>(data.processingStage || 'upload'); const [stage, setStage] = useState<ProcessingStage>(data.processingStage || 'upload');
const [uploadedFile, setUploadedFile] = useState<File | null>(data.files?.salesData || null); const [uploadedFile, setUploadedFile] = useState<File | null>(data.files?.salesData || null);
const [progress, setProgress] = useState(data.processingProgress || 0); const [progress, setProgress] = useState(data.processingProgress || 0);
@@ -166,8 +204,41 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
setProgress(0); setProgress(0);
try { try {
const result = await mockDataProcessingService.processFile( // Wait for user data to load if still loading
if (!isTenantAvailable()) {
createAlert({
type: 'info',
category: 'system',
priority: 'low',
title: 'Cargando datos de usuario',
message: 'Por favor espere mientras cargamos su información...',
source: 'onboarding'
});
// Reset file state since we can't process it yet
setUploadedFile(null);
setStage('upload');
return;
}
const tenantId = getTenantId();
if (!tenantId) {
console.error('DataProcessingStep - No tenant ID available:', {
user,
currentTenant,
userTenantId: user?.tenant_id,
currentTenantId: currentTenant?.id,
isLoadingUserData,
authLoading,
tenantLoading
});
throw new Error('No se pudo obtener información del tenant. Intente cerrar sesión y volver a iniciar.');
}
console.log('DataProcessingStep - Starting file processing with tenant:', tenantId);
const result = await dataProcessingService.processFile(
file, file,
tenantId,
(newProgress, newStage, message) => { (newProgress, newStage, message) => {
setProgress(newProgress); setProgress(newProgress);
setStage(newStage as ProcessingStage); setStage(newStage as ProcessingStage);
@@ -177,14 +248,93 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
setResults(result); setResults(result);
setStage('completed'); setStage('completed');
// Store results for next steps
onDataChange({
...data,
files: { ...data.files, salesData: file },
processingResults: result,
processingStage: 'completed',
processingProgress: 100
});
console.log('DataProcessingStep - File processing completed:', result);
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Procesamiento completado',
message: `Se procesaron ${result.total_records} registros y se identificaron ${result.unique_products} productos únicos.`,
source: 'onboarding'
});
} catch (error) { } catch (error) {
console.error('Processing error:', error); console.error('DataProcessingStep - Processing error:', error);
console.error('DataProcessingStep - Error details:', {
errorMessage: error instanceof Error ? error.message : 'Unknown error',
errorStack: error instanceof Error ? error.stack : null,
tenantInfo: {
user: user ? { id: user.id, tenant_id: user.tenant_id } : null,
currentTenant: currentTenant ? { id: currentTenant.id } : null
}
});
setStage('error'); setStage('error');
setCurrentMessage('Error en el procesamiento de datos'); const errorMessage = error instanceof Error ? error.message : 'Error en el procesamiento de datos';
setCurrentMessage(errorMessage);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error en el procesamiento',
message: errorMessage,
source: 'onboarding'
});
} }
}; };
const downloadTemplate = () => { const downloadTemplate = async () => {
try {
if (!isTenantAvailable()) {
createAlert({
type: 'info',
category: 'system',
priority: 'low',
title: 'Cargando datos de usuario',
message: 'Por favor espere mientras cargamos su información...',
source: 'onboarding'
});
return;
}
const tenantId = getTenantId();
if (!tenantId) {
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error',
message: 'No se pudo obtener información del tenant. Intente cerrar sesión y volver a iniciar.',
source: 'onboarding'
});
return;
}
const templateData = await onboardingApiService.getSalesImportTemplate(tenantId, 'csv');
onboardingApiService.downloadTemplate(templateData, 'plantilla_ventas.csv', 'csv');
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Plantilla descargada',
message: 'La plantilla de ventas se ha descargado correctamente',
source: 'onboarding'
});
} catch (error) {
console.error('Error downloading template:', error);
// Fallback to static template
const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta
2024-01-15,Pan Integral,5,2.50,12.50,Cliente A,Tienda 2024-01-15,Pan Integral,5,2.50,12.50,Cliente A,Tienda
2024-01-15,Croissant,3,1.80,5.40,Cliente B,Online 2024-01-15,Croissant,3,1.80,5.40,Cliente B,Online
@@ -203,6 +353,7 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
}
}; };
const resetProcess = () => { const resetProcess = () => {
@@ -218,8 +369,23 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Loading state when tenant data is not available */}
{!isTenantAvailable() && (
<Card className="p-8 text-center">
<div className="w-16 h-16 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center mx-auto mb-6">
<Activity className="w-8 h-8 text-[var(--color-info)] animate-pulse" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-3">
Cargando datos de usuario...
</h3>
<p className="text-[var(--text-secondary)]">
Por favor espere mientras cargamos su información de tenant
</p>
</Card>
)}
{/* Improved Upload Stage */} {/* Improved Upload Stage */}
{stage === 'upload' && ( {stage === 'upload' && isTenantAvailable() && (
<> <>
<div <div
className={` className={`

View File

@@ -1,7 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2 } from 'lucide-react'; import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2, CheckCircle } from 'lucide-react';
import { Button, Card, Input, Badge } from '../../../ui'; import { Button, Card, Input, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard'; import { OnboardingStepProps } from '../OnboardingWizard';
import { onboardingApiService } from '../../../../services/api/onboarding.service';
import { useAuthUser } from '../../../../stores/auth.store';
import { useAlertActions } from '../../../../stores/alerts.store';
interface InventoryItem { interface InventoryItem {
id: string; id: string;
@@ -15,34 +18,35 @@ interface InventoryItem {
supplier?: string; supplier?: string;
cost_per_unit?: number; cost_per_unit?: number;
requires_refrigeration: boolean; requires_refrigeration: boolean;
// API fields
suggestion_id?: string;
original_name?: string;
estimated_shelf_life_days?: number;
is_seasonal?: boolean;
} }
// Mock inventory items based on approved products // Convert approved products to inventory items
const mockInventoryItems: InventoryItem[] = [ const convertProductsToInventory = (approvedProducts: any[]): InventoryItem[] => {
{ return approvedProducts.map((product, index) => ({
id: '1', name: 'Harina de Trigo', category: 'ingredient', id: `inventory-${index}`,
current_stock: 50, min_stock: 20, max_stock: 100, unit: 'kg', name: product.suggested_name || product.name,
expiry_date: '2024-12-31', supplier: 'Molinos del Sur', category: product.product_type || 'finished_product',
cost_per_unit: 1.20, requires_refrigeration: false current_stock: 0, // To be configured by user
}, min_stock: 1, // Default minimum
{ max_stock: 100, // Default maximum
id: '2', name: 'Levadura Fresca', category: 'ingredient', unit: product.unit_of_measure || 'unidad',
current_stock: 5, min_stock: 2, max_stock: 10, unit: 'kg', requires_refrigeration: product.requires_refrigeration || false,
expiry_date: '2024-03-15', supplier: 'Levaduras Pro', // Store API data
cost_per_unit: 3.50, requires_refrigeration: true suggestion_id: product.suggestion_id,
}, original_name: product.original_name,
{ estimated_shelf_life_days: product.estimated_shelf_life_days,
id: '3', name: 'Pan Integral', category: 'finished_product', is_seasonal: product.is_seasonal,
current_stock: 20, min_stock: 10, max_stock: 50, unit: 'unidades', // Optional fields to be filled by user
expiry_date: '2024-01-25', requires_refrigeration: false expiry_date: undefined,
}, supplier: product.suggested_supplier,
{ cost_per_unit: 0
id: '4', name: 'Mantequilla', category: 'ingredient', }));
current_stock: 15, min_stock: 5, max_stock: 30, unit: 'kg', };
expiry_date: '2024-02-28', supplier: 'Lácteos Premium',
cost_per_unit: 4.20, requires_refrigeration: true
}
];
export const InventorySetupStep: React.FC<OnboardingStepProps> = ({ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
data, data,
@@ -52,22 +56,121 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
isFirstStep, isFirstStep,
isLastStep isLastStep
}) => { }) => {
const [items, setItems] = useState<InventoryItem[]>( const user = useAuthUser();
data.inventoryItems || mockInventoryItems const { createAlert } = useAlertActions();
); const [isCreating, setIsCreating] = useState(false);
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null); const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false); const [isAddingNew, setIsAddingNew] = useState(false);
// Generate inventory items from approved products
const generateInventoryFromProducts = (approvedProducts: any[]): InventoryItem[] => {
if (!approvedProducts || approvedProducts.length === 0) {
createAlert({
type: 'warning',
category: 'system',
priority: 'medium',
title: 'Sin productos aprobados',
message: 'No hay productos aprobados para crear inventario. Regrese al paso anterior.',
source: 'onboarding'
});
return [];
}
return convertProductsToInventory(approvedProducts);
};
const [items, setItems] = useState<InventoryItem[]>(() => {
if (data.inventoryItems) {
return data.inventoryItems;
}
// Try to get approved products from current step data first, then from review step data
const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts;
return generateInventoryFromProducts(approvedProducts || []);
});
// Update items when approved products become available (for when component is already mounted)
useEffect(() => {
const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts;
if (approvedProducts && approvedProducts.length > 0 && items.length === 0) {
const newItems = generateInventoryFromProducts(approvedProducts);
setItems(newItems);
}
}, [data.approvedProducts, data.allStepData]);
const [filterCategory, setFilterCategory] = useState<'all' | 'ingredient' | 'finished_product'>('all'); const [filterCategory, setFilterCategory] = useState<'all' | 'ingredient' | 'finished_product'>('all');
useEffect(() => { const filteredItems = filterCategory === 'all'
? items
: items.filter(item => item.category === filterCategory);
// Create inventory items via API
const handleCreateInventory = async () => {
const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts;
if (!user?.tenant_id || !approvedProducts || approvedProducts.length === 0) {
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error',
message: 'No se pueden crear elementos de inventario sin productos aprobados.',
source: 'onboarding'
});
return;
}
setIsCreating(true);
try {
const result = await onboardingApiService.createInventoryFromSuggestions(
user.tenant_id,
approvedProducts
);
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Inventario creado',
message: `Se crearon ${result.created_items.length} elementos de inventario exitosamente.`,
source: 'onboarding'
});
// Update the step data with created inventory
onDataChange({ onDataChange({
...data, ...data,
inventoryItems: items, inventoryItems: items,
inventoryConfigured: items.length > 0 && items.every(item => inventoryConfigured: true,
item.min_stock > 0 && item.max_stock > item.min_stock inventoryMapping: result.inventory_mapping,
) createdInventoryItems: result.created_items
}); });
}, [items]);
} catch (error) {
console.error('Error creating inventory:', error);
const errorMessage = error instanceof Error ? error.message : 'Error al crear inventario';
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al crear inventario',
message: errorMessage,
source: 'onboarding'
});
} finally {
setIsCreating(false);
}
};
useEffect(() => {
const hasValidStock = items.length > 0 && items.every(item =>
item.min_stock >= 0 && item.max_stock > item.min_stock
);
onDataChange({
...data,
inventoryItems: items,
inventoryConfigured: hasValidStock && !isCreating
});
}, [items, isCreating]);
const handleAddItem = () => { const handleAddItem = () => {
const newItem: InventoryItem = { const newItem: InventoryItem = {
@@ -75,243 +178,203 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
name: '', name: '',
category: 'ingredient', category: 'ingredient',
current_stock: 0, current_stock: 0,
min_stock: 0, min_stock: 1,
max_stock: 0, max_stock: 10,
unit: 'kg', unit: 'unidad',
requires_refrigeration: false requires_refrigeration: false
}; };
setItems([...items, newItem]);
setEditingItem(newItem); setEditingItem(newItem);
setIsAddingNew(true); setIsAddingNew(true);
}; };
const handleSaveItem = (item: InventoryItem) => { const handleSaveItem = (updatedItem: InventoryItem) => {
if (isAddingNew) { setItems(items.map(item =>
setItems(prev => [...prev, item]); item.id === updatedItem.id ? updatedItem : item
} else { ));
setItems(prev => prev.map(i => i.id === item.id ? item : i));
}
setEditingItem(null); setEditingItem(null);
setIsAddingNew(false); setIsAddingNew(false);
}; };
const handleDeleteItem = (id: string) => { const handleDeleteItem = (id: string) => {
if (window.confirm('¿Estás seguro de eliminar este elemento del inventario?')) { setItems(items.filter(item => item.id !== id));
setItems(prev => prev.filter(item => item.id !== id)); if (editingItem?.id === id) {
setEditingItem(null);
setIsAddingNew(false);
} }
}; };
const handleQuickSetup = () => { const handleCancelEdit = () => {
// Auto-configure basic inventory based on approved products if (isAddingNew && editingItem) {
const autoItems = data.detectedProducts setItems(items.filter(item => item.id !== editingItem.id));
?.filter((p: any) => p.status === 'approved') }
.map((product: any, index: number) => ({ setEditingItem(null);
id: `auto_${index}`, setIsAddingNew(false);
name: product.name,
category: 'finished_product' as const,
current_stock: Math.floor(Math.random() * 20) + 5,
min_stock: 5,
max_stock: 50,
unit: 'unidades',
requires_refrigeration: product.category === 'Repostería' || product.category === 'Salados'
})) || [];
setItems(prev => [...prev, ...autoItems]);
};
const getFilteredItems = () => {
return filterCategory === 'all'
? items
: items.filter(item => item.category === filterCategory);
}; };
const getStockStatus = (item: InventoryItem) => { const getStockStatus = (item: InventoryItem) => {
if (item.current_stock <= item.min_stock) return { status: 'low', color: 'red', text: 'Stock Bajo' }; if (item.current_stock <= item.min_stock) return 'critical';
if (item.current_stock >= item.max_stock) return { status: 'high', color: 'blue', text: 'Stock Alto' }; if (item.current_stock <= item.min_stock * 1.5) return 'warning';
return { status: 'normal', color: 'green', text: 'Normal' }; return 'good';
}; };
const isNearExpiry = (expiryDate?: string) => { const getStockStatusColor = (status: string) => {
if (!expiryDate) return false; switch (status) {
const expiry = new Date(expiryDate); case 'critical': return 'text-red-600 bg-red-50';
const today = new Date(); case 'warning': return 'text-yellow-600 bg-yellow-50';
const diffDays = (expiry.getTime() - today.getTime()) / (1000 * 3600 * 24); default: return 'text-green-600 bg-green-50';
return diffDays <= 7; }
}; };
const stats = { if (items.length === 0) {
total: items.length, return (
ingredients: items.filter(i => i.category === 'ingredient').length, <div className="space-y-8">
products: items.filter(i => i.category === 'finished_product').length, <div className="text-center py-16">
lowStock: items.filter(i => i.current_stock <= i.min_stock).length, <Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
nearExpiry: items.filter(i => isNearExpiry(i.expiry_date)).length, <h3 className="text-2xl font-bold text-gray-600 mb-2">
refrigerated: items.filter(i => i.requires_refrigeration).length Sin productos para inventario
}; </h3>
<p className="text-gray-500 mb-6 max-w-md mx-auto">
No hay productos aprobados para crear inventario.
Regrese al paso anterior para aprobar productos.
</p>
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Volver al paso anterior
</Button>
</div>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-8">
{/* Quick Actions */} {/* Header */}
<Card className="p-4"> <div className="text-center">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> <h2 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
<div> Configuración de Inventario
<p className="text-sm text-[var(--text-secondary)]"> </h2>
{stats.total} elementos configurados <p className="text-[var(--text-secondary)] text-lg max-w-2xl mx-auto">
Configure los niveles de stock, fechas de vencimiento y otros detalles para sus productos.
</p> </p>
</div> </div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleQuickSetup}
className="text-[var(--color-info)]"
>
Auto-configurar Productos
</Button>
<Button
size="sm"
onClick={handleAddItem}
>
<Plus className="w-4 h-4 mr-2" />
Agregar Elemento
</Button>
</div>
</div>
</Card>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="p-4 text-center"> <Card className="p-6 text-center">
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p> <div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{items.length}</div>
<p className="text-xs text-[var(--text-secondary)]">Total</p> <div className="text-sm text-[var(--text-secondary)]">Elementos totales</div>
</Card> </Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.ingredients}</p> <Card className="p-6 text-center">
<p className="text-xs text-[var(--text-secondary)]">Ingredientes</p> <div className="text-3xl font-bold text-blue-600 mb-2">
{items.filter(item => item.category === 'ingredient').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Ingredientes</div>
</Card> </Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.products}</p> <Card className="p-6 text-center">
<p className="text-xs text-[var(--text-secondary)]">Productos</p> <div className="text-3xl font-bold text-green-600 mb-2">
{items.filter(item => item.category === 'finished_product').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Productos terminados</div>
</Card> </Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-error)]">{stats.lowStock}</p> <Card className="p-6 text-center">
<p className="text-xs text-[var(--text-secondary)]">Stock Bajo</p> <div className="text-3xl font-bold text-red-600 mb-2">
</Card> {items.filter(item => getStockStatus(item) === 'critical').length}
<Card className="p-4 text-center"> </div>
<p className="text-2xl font-bold text-[var(--color-warning)]">{stats.nearExpiry}</p> <div className="text-sm text-[var(--text-secondary)]">Stock crítico</div>
<p className="text-xs text-[var(--text-secondary)]">Por Vencer</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.refrigerated}</p>
<p className="text-xs text-[var(--text-secondary)]">Refrigerado</p>
</Card> </Card>
</div> </div>
{/* Filters */} {/* Controls */}
<Card className="p-4"> <div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<label className="text-sm font-medium text-[var(--text-secondary)]">Filtrar:</label> <select
<div className="flex space-x-2"> value={filterCategory}
{[ onChange={(e) => setFilterCategory(e.target.value as any)}
{ value: 'all', label: 'Todos' }, className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
{ value: 'ingredient', label: 'Ingredientes' },
{ value: 'finished_product', label: 'Productos' }
].map(filter => (
<button
key={filter.value}
onClick={() => setFilterCategory(filter.value as any)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filterCategory === filter.value
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
}`}
> >
{filter.label} <option value="all">Todos los elementos</option>
</button> <option value="ingredient">Ingredientes</option>
))} <option value="finished_product">Productos terminados</option>
</div> </select>
</div>
</Card>
{/* Inventory Items */} <Badge variant="outline" className="text-sm">
<div className="space-y-4"> {filteredItems.length} elementos
{getFilteredItems().map((item) => { </Badge>
const stockStatus = getStockStatus(item);
const nearExpiry = isNearExpiry(item.expiry_date);
return (
<Card key={item.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4 flex-1">
{/* Category Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
item.category === 'ingredient'
? 'bg-[var(--color-primary)]/10'
: 'bg-[var(--color-success)]/10'
}`}>
<Package className={`w-4 h-4 ${
item.category === 'ingredient'
? 'text-[var(--color-primary)]'
: 'text-[var(--color-success)]'
}`} />
</div> </div>
{/* Item Info */} <div className="flex space-x-2">
<Button
onClick={handleAddItem}
size="sm"
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
>
<Plus className="w-4 h-4 mr-1" />
Agregar elemento
</Button>
<Button
onClick={handleCreateInventory}
disabled={isCreating || items.length === 0 || data.inventoryConfigured}
size="sm"
variant="outline"
className="border-green-200 text-green-600 hover:bg-green-50"
>
<CheckCircle className="w-4 h-4 mr-1" />
{isCreating ? 'Creando...' : data.inventoryConfigured ? 'Inventario creado' : 'Crear inventario'}
</Button>
</div>
</div>
{/* Items List */}
<div className="space-y-3">
{filteredItems.map((item) => (
<Card key={item.id} className="p-4 hover:shadow-md transition-shadow">
{editingItem?.id === item.id ? (
<InventoryItemEditor
item={item}
onSave={handleSaveItem}
onCancel={handleCancelEdit}
/>
) : (
<div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{item.name}</h4> <div className="flex items-center space-x-3 mb-2">
<h3 className="font-semibold text-[var(--text-primary)]">{item.name}</h3>
<div className="flex items-center gap-2 mb-3"> <Badge variant="outline" className="text-xs">
<Badge variant={item.category === 'ingredient' ? 'blue' : 'green'}> {item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto'} </Badge>
<Badge className={`text-xs ${getStockStatusColor(getStockStatus(item))}`}>
Stock: {getStockStatus(item)}
</Badge> </Badge>
{item.requires_refrigeration && ( {item.requires_refrigeration && (
<Badge variant="gray"> Refrigeración</Badge> <Badge variant="outline" className="text-xs text-blue-600">
)} Refrigeración
<Badge variant={stockStatus.color}>
{stockStatus.text}
</Badge> </Badge>
{nearExpiry && (
<Badge variant="red">Vence Pronto</Badge>
)} )}
</div> </div>
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)]"> <div className="text-sm text-[var(--text-secondary)] space-y-1">
<div> <div className="flex space-x-4">
<span className="text-[var(--text-tertiary)]">Stock Actual: </span> <span>Stock actual: <span className="font-medium">{item.current_stock} {item.unit}</span></span>
<span className={`font-medium ${stockStatus.status === 'low' ? 'text-[var(--color-error)]' : 'text-[var(--text-primary)]'}`}> <span>Mínimo: <span className="font-medium">{item.min_stock}</span></span>
{item.current_stock} {item.unit} <span>Máximo: <span className="font-medium">{item.max_stock}</span></span>
</span>
</div> </div>
<div>
<span className="text-[var(--text-tertiary)]">Rango: </span>
<span className="font-medium text-[var(--text-primary)]">
{item.min_stock} - {item.max_stock} {item.unit}
</span>
</div>
{item.expiry_date && ( {item.expiry_date && (
<div> <div>Vence: <span className="font-medium">{item.expiry_date}</span></div>
<span className="text-[var(--text-tertiary)]">Vencimiento: </span>
<span className={`font-medium ${nearExpiry ? 'text-[var(--color-error)]' : 'text-[var(--text-primary)]'}`}>
{new Date(item.expiry_date).toLocaleDateString()}
</span>
</div>
)} )}
{item.supplier && (
{item.cost_per_unit && ( <div>Proveedor: <span className="font-medium">{item.supplier}</span></div>
<div>
<span className="text-[var(--text-tertiary)]">Costo/Unidad: </span>
<span className="font-medium text-[var(--text-primary)]">
${item.cost_per_unit.toFixed(2)}
</span>
</div>
)} )}
</div> </div>
</div> </div>
</div>
{/* Actions */} <div className="flex items-center space-x-2">
<div className="flex gap-2 ml-4 mt-1">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -324,242 +387,161 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleDeleteItem(item.id)} onClick={() => handleDeleteItem(item.id)}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10" className="text-red-600 border-red-200 hover:bg-red-50"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4 mr-1" />
Eliminar
</Button> </Button>
</div> </div>
</div> </div>
</Card>
);
})}
{getFilteredItems().length === 0 && (
<Card className="p-8 text-center">
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)]">No hay elementos en esta categoría</p>
</Card>
)} )}
</Card>
))}
</div> </div>
{/* Warnings */} {/* Navigation */}
{(stats.lowStock > 0 || stats.nearExpiry > 0) && ( <div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<Card className="p-4 bg-[var(--color-warning-50)] border-[var(--color-warning-200)]"> <Button
<div className="flex items-start space-x-3"> variant="outline"
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" /> onClick={onPrevious}
<div> disabled={isFirstStep}
<h4 className="font-medium text-[var(--color-warning-800)] mb-1">Advertencias de Inventario</h4> >
{stats.lowStock > 0 && ( Anterior
<p className="text-sm text-[var(--color-warning-700)] mb-1"> </Button>
{stats.lowStock} elemento(s) con stock bajo <Button
</p> onClick={onNext}
)} disabled={!data.inventoryConfigured}
{stats.nearExpiry > 0 && ( className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
<p className="text-sm text-[var(--color-warning-700)]"> >
{stats.nearExpiry} elemento(s) próximos a vencer {isLastStep ? 'Finalizar' : 'Siguiente'}
</p> </Button>
)}
</div> </div>
</div> </div>
</Card>
)}
{/* Edit Modal */}
{editingItem && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
{isAddingNew ? 'Agregar Elemento' : 'Editar Elemento'}
</h3>
<InventoryItemForm
item={editingItem}
onSave={handleSaveItem}
onCancel={() => {
setEditingItem(null);
setIsAddingNew(false);
}}
/>
</Card>
</div>
)}
{/* Information */}
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-2">
📦 Configuración de Inventario:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Stock Mínimo:</strong> Nivel que dispara alertas de reabastecimiento</li>
<li> <strong>Stock Máximo:</strong> Capacidad máxima de almacenamiento</li>
<li> <strong>Fechas de Vencimiento:</strong> Control automático de productos perecederos</li>
<li> <strong>Refrigeración:</strong> Identifica productos que requieren frío</li>
</ul>
</Card>
</div>
); );
}; };
// Component for editing inventory items // Inventory Item Editor Component
interface InventoryItemFormProps { const InventoryItemEditor: React.FC<{
item: InventoryItem; item: InventoryItem;
onSave: (item: InventoryItem) => void; onSave: (item: InventoryItem) => void;
onCancel: () => void; onCancel: () => void;
} }> = ({ item, onSave, onCancel }) => {
const [editedItem, setEditedItem] = useState<InventoryItem>(item);
const InventoryItemForm: React.FC<InventoryItemFormProps> = ({ item, onSave, onCancel }) => { const handleSave = () => {
const [formData, setFormData] = useState(item); if (!editedItem.name.trim()) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
alert('El nombre es requerido'); alert('El nombre es requerido');
return; return;
} }
if (formData.min_stock >= formData.max_stock) { if (editedItem.min_stock < 0 || editedItem.max_stock <= editedItem.min_stock) {
alert('El stock máximo debe ser mayor al mínimo'); alert('Los niveles de stock deben ser válidos (máximo > mínimo >= 0)');
return; return;
} }
onSave(formData); onSave(editedItem);
}; };
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <div className="space-y-4 p-4 bg-gray-50 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium mb-1">Nombre</label>
Nombre *
</label>
<Input <Input
value={formData.name} value={editedItem.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
placeholder="Nombre del producto/ingrediente" placeholder="Nombre del producto"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium mb-1">Categoría</label>
Categoría *
</label>
<select <select
value={formData.category} value={editedItem.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as any }))} onChange={(e) => setEditedItem({ ...editedItem, category: e.target.value as any })}
className="w-full p-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]" className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
> >
<option value="ingredient">Ingrediente</option> <option value="ingredient">Ingrediente</option>
<option value="finished_product">Producto Terminado</option> <option value="finished_product">Producto terminado</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium mb-1">Stock actual</label>
Unidad *
</label>
<Input
value={formData.unit}
onChange={(e) => setFormData(prev => ({ ...prev, unit: e.target.value }))}
placeholder="kg, unidades, litros..."
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Stock Actual
</label>
<Input <Input
type="number" type="number"
value={formData.current_stock} value={editedItem.current_stock}
onChange={(e) => setFormData(prev => ({ ...prev, current_stock: Number(e.target.value) }))} onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })}
min="0" min="0"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium mb-1">Unidad</label>
Stock Mín. * <Input
</label> value={editedItem.unit}
onChange={(e) => setEditedItem({ ...editedItem, unit: e.target.value })}
placeholder="kg, litros, unidades..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Stock mínimo</label>
<Input <Input
type="number" type="number"
value={formData.min_stock} value={editedItem.min_stock}
onChange={(e) => setFormData(prev => ({ ...prev, min_stock: Number(e.target.value) }))} onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })}
min="0" min="0"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium mb-1">Stock máximo</label>
Stock Máx. *
</label>
<Input <Input
type="number" type="number"
value={formData.max_stock} value={editedItem.max_stock}
onChange={(e) => setFormData(prev => ({ ...prev, max_stock: Number(e.target.value) }))} onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })}
min="1" min="1"
/> />
</div> </div>
</div>
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium mb-1">Fecha de vencimiento</label>
Fecha Vencimiento
</label>
<Input <Input
type="date" type="date"
value={formData.expiry_date || ''} value={editedItem.expiry_date || ''}
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))} onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium mb-1">Proveedor</label>
Costo por Unidad
</label>
<Input <Input
type="number" value={editedItem.supplier || ''}
step="0.01" onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })}
value={formData.cost_per_unit || ''}
onChange={(e) => setFormData(prev => ({ ...prev, cost_per_unit: Number(e.target.value) }))}
min="0"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Proveedor
</label>
<Input
value={formData.supplier || ''}
onChange={(e) => setFormData(prev => ({ ...prev, supplier: e.target.value }))}
placeholder="Nombre del proveedor" placeholder="Nombre del proveedor"
/> />
</div> </div>
</div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-4">
<label className="flex items-center space-x-2">
<input <input
type="checkbox" type="checkbox"
id="refrigeration" checked={editedItem.requires_refrigeration}
checked={formData.requires_refrigeration} onChange={(e) => setEditedItem({ ...editedItem, requires_refrigeration: e.target.checked })}
onChange={(e) => setFormData(prev => ({ ...prev, requires_refrigeration: e.target.checked }))}
className="rounded" className="rounded"
/> />
<label htmlFor="refrigeration" className="text-sm text-[var(--text-primary)]"> <span className="text-sm">Requiere refrigeración</span>
Requiere refrigeración
</label> </label>
</div> </div>
<div className="flex justify-end space-x-3 pt-4"> <div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={onCancel}> <Button variant="outline" onClick={onCancel}>
Cancelar Cancelar
</Button> </Button>
<Button type="submit"> <Button onClick={handleSave}>
Guardar Guardar
</Button> </Button>
</div> </div>
</form> </div>
); );
}; };

View File

@@ -1,30 +1,68 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Eye, CheckCircle, AlertCircle, Edit, Trash2 } from 'lucide-react'; import { Eye, CheckCircle, AlertCircle, Edit, Trash2 } from 'lucide-react';
import { Button, Card, Badge } from '../../../ui'; import { Button, Card, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard'; import { OnboardingStepProps } from '../OnboardingWizard';
import { useAlertActions } from '../../../../stores/alerts.store';
interface Product { interface Product {
id: string; id: string;
name: string; name: string;
category: string; category: string;
confidence: number; confidence: number;
sales_count: number; sales_count?: number;
estimated_price: number; estimated_price?: number;
status: 'approved' | 'rejected' | 'pending'; status: 'approved' | 'rejected' | 'pending';
notes?: string; notes?: string;
// Fields from API suggestion
suggestion_id?: string;
original_name: string;
suggested_name: string;
product_type: 'ingredient' | 'finished_product';
unit_of_measure: string;
estimated_shelf_life_days: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
is_seasonal: boolean;
suggested_supplier?: string;
sales_data?: {
total_quantity: number;
average_daily_sales: number;
peak_day: string;
frequency: number;
};
} }
// Mock detected products // Convert API suggestions to Product interface
const mockDetectedProducts: Product[] = [ const convertSuggestionsToProducts = (suggestions: any[]): Product[] => {
{ id: '1', name: 'Pan Integral', category: 'Panadería', confidence: 95, sales_count: 45, estimated_price: 2.50, status: 'pending' }, console.log('ReviewStep - convertSuggestionsToProducts called with:', suggestions);
{ id: '2', name: 'Croissant', category: 'Bollería', confidence: 92, sales_count: 38, estimated_price: 1.80, status: 'pending' },
{ id: '3', name: 'Baguette', category: 'Panadería', confidence: 88, sales_count: 22, estimated_price: 3.00, status: 'pending' }, const products = suggestions.map((suggestion, index) => ({
{ id: '4', name: 'Empanada de Pollo', category: 'Salados', confidence: 85, sales_count: 31, estimated_price: 4.50, status: 'pending' }, id: suggestion.suggestion_id || `product-${index}`,
{ id: '5', name: 'Tarta de Manzana', category: 'Repostería', confidence: 78, sales_count: 12, estimated_price: 15.00, status: 'pending' }, name: suggestion.suggested_name,
{ id: '6', name: 'Pan de Centeno', category: 'Panadería', confidence: 91, sales_count: 18, estimated_price: 2.80, status: 'pending' }, category: suggestion.category,
{ id: '7', name: 'Medialunas', category: 'Bollería', confidence: 87, sales_count: 29, estimated_price: 1.20, status: 'pending' }, confidence: Math.round(suggestion.confidence_score * 100),
{ id: '8', name: 'Sandwich Mixto', category: 'Salados', confidence: 82, sales_count: 25, estimated_price: 5.50, status: 'pending' } status: 'pending' as const,
]; // Store original API data
suggestion_id: suggestion.suggestion_id,
original_name: suggestion.original_name,
suggested_name: suggestion.suggested_name,
product_type: suggestion.product_type,
unit_of_measure: suggestion.unit_of_measure,
estimated_shelf_life_days: suggestion.estimated_shelf_life_days,
requires_refrigeration: suggestion.requires_refrigeration,
requires_freezing: suggestion.requires_freezing,
is_seasonal: suggestion.is_seasonal,
suggested_supplier: suggestion.suggested_supplier,
notes: suggestion.notes,
sales_data: suggestion.sales_data,
// Legacy fields for display
sales_count: suggestion.sales_data?.total_quantity || 0,
estimated_price: 0 // Price estimation not provided by current API
}));
console.log('ReviewStep - Converted products:', products);
return products;
};
export const ReviewStep: React.FC<OnboardingStepProps> = ({ export const ReviewStep: React.FC<OnboardingStepProps> = ({
data, data,
@@ -34,35 +72,128 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
isFirstStep, isFirstStep,
isLastStep isLastStep
}) => { }) => {
// Generate products from processing results or use mock data const { createAlert } = useAlertActions();
const generateProductsFromResults = (results: any) => {
if (!results?.product_list) return mockDetectedProducts;
// Generate products from AI suggestions in processing results
const generateProductsFromResults = (results: any) => {
console.log('ReviewStep - generateProductsFromResults called with:', results);
console.log('ReviewStep - results keys:', Object.keys(results || {}));
console.log('ReviewStep - results.aiSuggestions:', results?.aiSuggestions);
console.log('ReviewStep - aiSuggestions length:', results?.aiSuggestions?.length);
console.log('ReviewStep - aiSuggestions type:', typeof results?.aiSuggestions);
console.log('ReviewStep - aiSuggestions is array:', Array.isArray(results?.aiSuggestions));
if (results?.aiSuggestions && results.aiSuggestions.length > 0) {
console.log('ReviewStep - Using AI suggestions:', results.aiSuggestions);
return convertSuggestionsToProducts(results.aiSuggestions);
}
// Fallback: create products from product list if no AI suggestions
if (results?.product_list) {
console.log('ReviewStep - Using fallback product list:', results.product_list);
return results.product_list.map((name: string, index: number) => ({ return results.product_list.map((name: string, index: number) => ({
id: (index + 1).toString(), id: `fallback-${index}`,
name, name,
category: index < 3 ? 'Panadería' : index < 5 ? 'Bollería' : 'Salados', original_name: name,
confidence: Math.max(75, results.confidenceScore - Math.random() * 15), suggested_name: name,
sales_count: Math.floor(Math.random() * 50) + 10, category: 'Sin clasificar',
estimated_price: Math.random() * 5 + 1.5, confidence: 50,
status: 'pending' as const status: 'pending' as const,
product_type: 'finished_product' as const,
unit_of_measure: 'unidad',
estimated_shelf_life_days: 7,
requires_refrigeration: false,
requires_freezing: false,
is_seasonal: false
})); }));
}
return [];
}; };
const [products, setProducts] = useState<Product[]>( const [products, setProducts] = useState<Product[]>(() => {
data.detectedProducts || generateProductsFromResults(data.processingResults) if (data.detectedProducts) {
); return data.detectedProducts;
}
// Try to get processing results from current step data first, then from previous step data
const processingResults = data.processingResults || data.allStepData?.['data-processing']?.processingResults;
console.log('ReviewStep - Initializing with processingResults:', processingResults);
return generateProductsFromResults(processingResults);
});
// Check for empty products and show alert after component mounts
useEffect(() => {
const processingResults = data.processingResults || data.allStepData?.['data-processing']?.processingResults;
if (products.length === 0 && processingResults) {
createAlert({
type: 'warning',
category: 'system',
priority: 'medium',
title: 'Sin productos detectados',
message: 'No se encontraron productos en los datos procesados. Verifique el archivo de ventas.',
source: 'onboarding'
});
}
}, [products.length, data.processingResults, data.allStepData, createAlert]);
const [selectedCategory, setSelectedCategory] = useState<string>('all'); const [selectedCategory, setSelectedCategory] = useState<string>('all');
const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))]; const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))];
// Memoize computed values to avoid unnecessary recalculations
const approvedProducts = useMemo(() =>
products.filter(p => p.status === 'approved'),
[products]
);
const reviewCompleted = useMemo(() =>
products.length > 0 && products.every(p => p.status !== 'pending'),
[products]
);
const [lastReviewCompleted, setLastReviewCompleted] = useState(false);
const dataChangeRef = useRef({ products: [], approvedProducts: [], reviewCompleted: false });
// Update parent data when products change
useEffect(() => { useEffect(() => {
const currentState = { products, approvedProducts, reviewCompleted };
const lastState = dataChangeRef.current;
// Only call onDataChange if the state actually changed
if (JSON.stringify(currentState) !== JSON.stringify(lastState)) {
console.log('ReviewStep - Updating parent data with:', {
detectedProducts: products,
approvedProducts,
reviewCompleted,
approvedProductsCount: approvedProducts.length
});
onDataChange({ onDataChange({
...data, ...data,
detectedProducts: products, detectedProducts: products,
reviewCompleted: products.every(p => p.status !== 'pending') approvedProducts,
reviewCompleted
}); });
}, [products]); dataChangeRef.current = currentState;
}
}, [products, approvedProducts, reviewCompleted]);
// Handle review completion alert separately
useEffect(() => {
if (reviewCompleted && approvedProducts.length > 0 && !lastReviewCompleted) {
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Revisión completada',
message: `Se aprobaron ${approvedProducts.length} de ${products.length} productos detectados.`,
source: 'onboarding'
});
setLastReviewCompleted(true);
}
if (!reviewCompleted && lastReviewCompleted) {
setLastReviewCompleted(false);
}
}, [reviewCompleted, approvedProducts.length, products.length, lastReviewCompleted, createAlert]);
const handleProductAction = (productId: string, action: 'approve' | 'reject') => { const handleProductAction = (productId: string, action: 'approve' | 'reject') => {
setProducts(prev => prev.map(product => setProducts(prev => prev.map(product =>
@@ -95,191 +226,188 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
pending: products.filter(p => p.status === 'pending').length pending: products.filter(p => p.status === 'pending').length
}; };
const getConfidenceColor = (confidence: number) => {
if (confidence >= 90) return 'text-green-600 bg-green-50';
if (confidence >= 75) return 'text-yellow-600 bg-yellow-50';
return 'text-red-600 bg-red-50';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'approved': return 'text-green-600 bg-green-50';
case 'rejected': return 'text-red-600 bg-red-50';
default: return 'text-gray-600 bg-gray-50';
}
};
if (products.length === 0) {
return (
<div className="space-y-8">
<div className="text-center py-16">
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-2xl font-bold text-gray-600 mb-2">
No se encontraron productos
</h3>
<p className="text-gray-500 mb-6 max-w-md mx-auto">
No se pudieron detectar productos en el archivo procesado.
Verifique que el archivo contenga datos de ventas válidos.
</p>
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Volver al paso anterior
</Button>
</div>
</div>
);
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Summary Stats */} {/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg"> <Card className="p-6 text-center">
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p> <div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{stats.total}</div>
<p className="text-sm text-[var(--text-secondary)]">Total</p> <div className="text-sm text-[var(--text-secondary)]">Productos detectados</div>
</div> </Card>
<div className="text-center p-4 bg-[var(--color-success)]/10 rounded-lg">
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.approved}</p> <Card className="p-6 text-center">
<p className="text-sm text-[var(--text-secondary)]">Aprobados</p> <div className="text-3xl font-bold text-green-600 mb-2">{stats.approved}</div>
</div> <div className="text-sm text-[var(--text-secondary)]">Aprobados</div>
<div className="text-center p-4 bg-[var(--color-error)]/10 rounded-lg"> </Card>
<p className="text-2xl font-bold text-[var(--color-error)]">{stats.rejected}</p>
<p className="text-sm text-[var(--text-secondary)]">Rechazados</p> <Card className="p-6 text-center">
</div> <div className="text-3xl font-bold text-red-600 mb-2">{stats.rejected}</div>
<div className="text-center p-4 bg-[var(--color-warning)]/10 rounded-lg"> <div className="text-sm text-[var(--text-secondary)]">Rechazados</div>
<p className="text-2xl font-bold text-[var(--color-warning)]">{stats.pending}</p> </Card>
<p className="text-sm text-[var(--text-secondary)]">Pendientes</p>
</div> <Card className="p-6 text-center">
<div className="text-3xl font-bold text-yellow-600 mb-2">{stats.pending}</div>
<div className="text-sm text-[var(--text-secondary)]">Pendientes</div>
</Card>
</div> </div>
{/* Filters and Actions */} {/* Controls */}
<Card className="p-6"> <div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between"> <div className="flex items-center space-x-4">
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-2">
Filtrar por categoría:
</label>
<select <select
value={selectedCategory} value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)} onChange={(e) => setSelectedCategory(e.target.value)}
className="border border-[var(--border-secondary)] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]" className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
> >
{categories.map(cat => ( {categories.map(category => (
<option key={cat} value={cat}> <option key={category} value={category}>
{cat === 'all' ? 'Todas las categorías' : cat} {category === 'all' ? 'Todas las categorías' : category}
</option> </option>
))} ))}
</select> </select>
<Badge variant="outline" className="text-sm">
{getFilteredProducts().length} productos
</Badge>
</div> </div>
<div className="flex gap-2"> <div className="flex space-x-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleBulkAction('approve')} onClick={() => handleBulkAction('approve')}
className="text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10" className="text-green-600 border-green-200 hover:bg-green-50"
> >
<CheckCircle className="w-4 h-4 mr-1" /> Aprobar todos
Aprobar Visibles
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleBulkAction('reject')} onClick={() => handleBulkAction('reject')}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10" className="text-red-600 border-red-200 hover:bg-red-50"
> >
<Trash2 className="w-4 h-4 mr-1" /> Rechazar todos
Rechazar Visibles
</Button> </Button>
</div> </div>
</div> </div>
</Card>
{/* Products List */} {/* Products List */}
<div className="space-y-4"> <div className="space-y-3">
{getFilteredProducts().map((product) => ( {getFilteredProducts().map((product) => (
<Card key={product.id} className="p-4"> <Card key={product.id} className="p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between"> <div className="flex items-center justify-between">
<div className="flex items-start space-x-4 flex-1">
{/* Status Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
product.status === 'approved'
? 'bg-[var(--color-success)]'
: product.status === 'rejected'
? 'bg-[var(--color-error)]'
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
}`}>
{product.status === 'approved' ? (
<CheckCircle className="w-4 h-4 text-white" />
) : product.status === 'rejected' ? (
<Trash2 className="w-4 h-4 text-white" />
) : (
<Eye className="w-4 h-4 text-[var(--text-tertiary)]" />
)}
</div>
{/* Product Info */}
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{product.name}</h4> <div className="flex items-center space-x-3 mb-2">
<h3 className="font-semibold text-[var(--text-primary)]">{product.name}</h3>
<div className="flex items-center gap-2 mb-3"> <Badge variant="outline" className="text-xs">
<Badge variant="gray">{product.category}</Badge> {product.category}
{product.status !== 'pending' && (
<Badge variant={product.status === 'approved' ? 'green' : 'red'}>
{product.status === 'approved' ? 'Aprobado' : 'Rechazado'}
</Badge> </Badge>
<Badge className={`text-xs ${getConfidenceColor(product.confidence)}`}>
{product.confidence}% confianza
</Badge>
<Badge className={`text-xs ${getStatusColor(product.status)}`}>
{product.status === 'approved' ? 'Aprobado' :
product.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</Badge>
</div>
<div className="text-sm text-[var(--text-secondary)] space-y-1">
{product.original_name && product.original_name !== product.name && (
<div>Nombre original: <span className="font-medium">{product.original_name}</span></div>
)}
<div className="flex space-x-4">
<span>Tipo: {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}</span>
<span>Unidad: {product.unit_of_measure}</span>
{product.sales_data && (
<span>Ventas: {product.sales_data.total_quantity}</span>
)} )}
</div> </div>
{product.notes && (
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]"> <div className="text-xs italic">Nota: {product.notes}</div>
<span className={`px-2 py-1 rounded text-xs ${ )}
product.confidence >= 90
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
: product.confidence >= 75
? 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
: 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
}`}>
{product.confidence}% confianza
</span>
<span>{product.sales_count} ventas</span>
<span>${product.estimated_price.toFixed(2)}</span>
</div>
</div> </div>
</div> </div>
{/* Actions */} <div className="flex items-center space-x-2">
<div className="flex gap-2 ml-4 mt-1">
{product.status === 'pending' ? (
<>
<Button <Button
size="sm" size="sm"
variant={product.status === 'approved' ? 'default' : 'outline'}
onClick={() => handleProductAction(product.id, 'approve')} onClick={() => handleProductAction(product.id, 'approve')}
className="bg-[var(--color-success)] hover:bg-[var(--color-success)]/90 text-white" className={product.status === 'approved' ? 'bg-green-600 hover:bg-green-700' : 'text-green-600 border-green-200 hover:bg-green-50'}
> >
<CheckCircle className="w-4 h-4 mr-1" /> <CheckCircle className="w-4 h-4 mr-1" />
Aprobar Aprobar
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant={product.status === 'rejected' ? 'default' : 'outline'}
onClick={() => handleProductAction(product.id, 'reject')} onClick={() => handleProductAction(product.id, 'reject')}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10" className={product.status === 'rejected' ? 'bg-red-600 hover:bg-red-700' : 'text-red-600 border-red-200 hover:bg-red-50'}
> >
<Trash2 className="w-4 h-4 mr-1" /> <AlertCircle className="w-4 h-4 mr-1" />
Rechazar Rechazar
</Button> </Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => setProducts(prev => prev.map(p => p.id === product.id ? {...p, status: 'pending'} : p))}
className="text-[var(--text-secondary)] hover:text-[var(--color-primary)]"
>
<Edit className="w-4 h-4 mr-1" />
Modificar
</Button>
)}
</div> </div>
</div> </div>
</Card> </Card>
))} ))}
</div> </div>
{/* Progress Indicator */} {/* Navigation */}
{stats.pending > 0 && ( <div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<Card className="p-4 bg-[var(--color-warning)]/5 border-[var(--color-warning)]/20"> <Button
<div className="flex items-center space-x-3"> variant="outline"
<AlertCircle className="w-5 h-5 text-[var(--color-warning)]" /> onClick={onPrevious}
<div> disabled={isFirstStep}
<p className="font-medium text-[var(--text-primary)]"> >
{stats.pending} productos pendientes de revisión Anterior
</p> </Button>
<p className="text-sm text-[var(--text-secondary)]"> <Button
Revisa todos los productos antes de continuar al siguiente paso onClick={onNext}
</p> disabled={!data.reviewCompleted || stats.approved === 0}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
>
{isLastStep ? 'Finalizar' : 'Siguiente'}
</Button>
</div> </div>
</div> </div>
</Card>
)}
{/* Help Information */}
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-3">
💡 Consejos para la revisión:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Confianza alta (90%+):</strong> Productos identificados con alta precisión</li>
<li> <strong>Confianza media (75-89%):</strong> Revisar nombres y categorías</li>
<li> <strong>Confianza baja (&lt;75%):</strong> Verificar que corresponden a tu catálogo</li>
<li> Usa las acciones masivas para aprobar/rechazar por categoría completa</li>
</ul>
</Card>
</div>
); );
}; };

View File

@@ -1,66 +1,40 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin } from 'lucide-react'; import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin, AlertCircle, Loader } from 'lucide-react';
import { Button, Card, Input, Badge } from '../../../ui'; import { Button, Card, Input, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard'; import { OnboardingStepProps } from '../OnboardingWizard';
import { procurementService, type Supplier } from '../../../../services/api/procurement.service';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAlertActions } from '../../../../stores/alerts.store';
interface Supplier { // Frontend supplier interface that matches the form needs
id: string; interface SupplierFormData {
id?: string;
name: string; name: string;
contact_person: string; contact_name: string;
phone: string; phone: string;
email: string; email: string;
address: string; address: string;
categories: string[];
payment_terms: string; payment_terms: string;
delivery_days: string[]; delivery_terms: string;
min_order_amount?: number; tax_id?: string;
notes?: string; is_active: boolean;
status: 'active' | 'inactive';
created_at: string;
} }
// Mock suppliers
const mockSuppliers: Supplier[] = [
{
id: '1',
name: 'Molinos del Sur',
contact_person: 'Juan Pérez',
phone: '+1 555-0123',
email: 'ventas@molinosdelsur.com',
address: 'Av. Industrial 123, Zona Sur',
categories: ['Harinas', 'Granos'],
payment_terms: '30 días',
delivery_days: ['Lunes', 'Miércoles', 'Viernes'],
min_order_amount: 200,
notes: 'Proveedor principal de harinas, muy confiable',
status: 'active',
created_at: '2024-01-15'
},
{
id: '2',
name: 'Lácteos Premium',
contact_person: 'María González',
phone: '+1 555-0456',
email: 'pedidos@lacteospremium.com',
address: 'Calle Central 456, Centro',
categories: ['Lácteos', 'Mantequillas', 'Quesos'],
payment_terms: '15 días',
delivery_days: ['Martes', 'Jueves', 'Sábado'],
min_order_amount: 150,
status: 'active',
created_at: '2024-01-20'
}
];
const daysOfWeek = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const commonCategories = [ const commonCategories = [
'Harinas', 'Lácteos', 'Levaduras', 'Azúcares', 'Grasas', 'Huevos', 'Harinas', 'Lácteos', 'Levaduras', 'Azúcares', 'Grasas', 'Huevos',
'Frutas', 'Chocolates', 'Frutos Secos', 'Especias', 'Conservantes' 'Frutas', 'Chocolates', 'Frutos Secos', 'Especias', 'Conservantes'
]; ];
const paymentTermsOptions = [
'Inmediato',
'15 días',
'30 días',
'45 días',
'60 días',
'90 días'
];
export const SuppliersStep: React.FC<OnboardingStepProps> = ({ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
data, data,
onDataChange, onDataChange,
@@ -69,13 +43,57 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
isFirstStep, isFirstStep,
isLastStep isLastStep
}) => { }) => {
const [suppliers, setSuppliers] = useState<Supplier[]>( const user = useAuthUser();
data.suppliers || mockSuppliers const currentTenant = useCurrentTenant();
); const { createAlert } = useAlertActions();
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [editingSupplier, setEditingSupplier] = useState<SupplierFormData | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false); const [isAddingNew, setIsAddingNew] = useState(false);
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all'); const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [updating, setUpdating] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
// Load suppliers from backend on component mount
useEffect(() => {
const loadSuppliers = async () => {
// Check if we already have suppliers loaded
if (data.suppliers && Array.isArray(data.suppliers) && data.suppliers.length > 0) {
setSuppliers(data.suppliers);
return;
}
if (!currentTenant?.id) {
return;
}
setLoading(true);
try {
const response = await procurementService.getSuppliers({ size: 100 });
if (response.success && response.data) {
setSuppliers(response.data.items);
}
} catch (error) {
console.error('Failed to load suppliers:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'medium',
title: 'Error al cargar proveedores',
message: 'No se pudieron cargar los proveedores existentes.',
source: 'onboarding'
});
} finally {
setLoading(false);
}
};
loadSuppliers();
}, [currentTenant?.id]);
// Update parent data when suppliers change
useEffect(() => { useEffect(() => {
onDataChange({ onDataChange({
...data, ...data,
@@ -85,60 +103,202 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
}, [suppliers]); }, [suppliers]);
const handleAddSupplier = () => { const handleAddSupplier = () => {
const newSupplier: Supplier = { const newSupplier: SupplierFormData = {
id: Date.now().toString(),
name: '', name: '',
contact_person: '', contact_name: '',
phone: '', phone: '',
email: '', email: '',
address: '', address: '',
categories: [],
payment_terms: '30 días', payment_terms: '30 días',
delivery_days: [], delivery_terms: 'Recoger en tienda',
status: 'active', tax_id: '',
created_at: new Date().toISOString().split('T')[0] is_active: true
}; };
setEditingSupplier(newSupplier); setEditingSupplier(newSupplier);
setIsAddingNew(true); setIsAddingNew(true);
}; };
const handleSaveSupplier = (supplier: Supplier) => { const handleSaveSupplier = async (supplierData: SupplierFormData) => {
if (isAddingNew) { if (isAddingNew) {
setSuppliers(prev => [...prev, supplier]); setCreating(true);
} else { try {
setSuppliers(prev => prev.map(s => s.id === supplier.id ? supplier : s)); const response = await procurementService.createSupplier({
name: supplierData.name,
contact_name: supplierData.contact_name,
phone: supplierData.phone,
email: supplierData.email,
address: supplierData.address,
payment_terms: supplierData.payment_terms,
delivery_terms: supplierData.delivery_terms,
tax_id: supplierData.tax_id,
is_active: supplierData.is_active
});
if (response.success && response.data) {
setSuppliers(prev => [...prev, response.data]);
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor creado',
message: `El proveedor ${response.data.name} se ha creado exitosamente.`,
source: 'onboarding'
});
} }
} catch (error) {
console.error('Failed to create supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al crear proveedor',
message: 'No se pudo crear el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setCreating(false);
}
} else {
// Update existing supplier
if (!supplierData.id) return;
setUpdating(true);
try {
const response = await procurementService.updateSupplier(supplierData.id, {
name: supplierData.name,
contact_name: supplierData.contact_name,
phone: supplierData.phone,
email: supplierData.email,
address: supplierData.address,
payment_terms: supplierData.payment_terms,
delivery_terms: supplierData.delivery_terms,
tax_id: supplierData.tax_id,
is_active: supplierData.is_active
});
if (response.success && response.data) {
setSuppliers(prev => prev.map(s => s.id === response.data.id ? response.data : s));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor actualizado',
message: `El proveedor ${response.data.name} se ha actualizado exitosamente.`,
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to update supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al actualizar proveedor',
message: 'No se pudo actualizar el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setUpdating(false);
}
}
setEditingSupplier(null); setEditingSupplier(null);
setIsAddingNew(false); setIsAddingNew(false);
}; };
const handleDeleteSupplier = (id: string) => { const handleDeleteSupplier = async (id: string) => {
if (window.confirm('¿Estás seguro de eliminar este proveedor?')) { if (!window.confirm('¿Estás seguro de eliminar este proveedor? Esta acción no se puede deshacer.')) {
return;
}
setDeleting(id);
try {
const response = await procurementService.deleteSupplier(id);
if (response.success) {
setSuppliers(prev => prev.filter(s => s.id !== id)); setSuppliers(prev => prev.filter(s => s.id !== id));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor eliminado',
message: 'El proveedor se ha eliminado exitosamente.',
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to delete supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al eliminar proveedor',
message: 'No se pudo eliminar el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setDeleting(null);
} }
}; };
const toggleSupplierStatus = (id: string) => { const toggleSupplierStatus = async (id: string, currentStatus: boolean) => {
try {
const response = await procurementService.updateSupplier(id, {
is_active: !currentStatus
});
if (response.success && response.data) {
setSuppliers(prev => prev.map(s => setSuppliers(prev => prev.map(s =>
s.id === id s.id === id ? response.data : s
? { ...s, status: s.status === 'active' ? 'inactive' : 'active' }
: s
)); ));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Estado actualizado',
message: `El proveedor se ha ${!currentStatus ? 'activado' : 'desactivado'} exitosamente.`,
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to toggle supplier status:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al cambiar estado',
message: 'No se pudo cambiar el estado del proveedor.',
source: 'onboarding'
});
}
}; };
const getFilteredSuppliers = () => { const getFilteredSuppliers = () => {
return filterStatus === 'all' if (filterStatus === 'all') {
? suppliers return suppliers;
: suppliers.filter(s => s.status === filterStatus); }
return suppliers.filter(s =>
filterStatus === 'active' ? s.is_active : !s.is_active
);
}; };
const stats = { const stats = {
total: suppliers.length, total: suppliers.length,
active: suppliers.filter(s => s.status === 'active').length, active: suppliers.filter(s => s.is_active).length,
inactive: suppliers.filter(s => s.status === 'inactive').length, inactive: suppliers.filter(s => !s.is_active).length,
categories: Array.from(new Set(suppliers.flatMap(s => s.categories))).length totalOrders: suppliers.reduce((sum, s) => sum + s.performance_metrics.total_orders, 0)
}; };
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-center">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)]">Cargando proveedores...</p>
</div>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Optional Step Notice */} {/* Optional Step Notice */}
@@ -161,8 +321,13 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<Button <Button
size="sm" size="sm"
onClick={handleAddSupplier} onClick={handleAddSupplier}
disabled={creating}
> >
{creating ? (
<Loader className="w-4 h-4 mr-2 animate-spin" />
) : (
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
)}
Agregar Proveedor Agregar Proveedor
</Button> </Button>
</div> </div>
@@ -184,8 +349,8 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<p className="text-xs text-[var(--text-secondary)]">Inactivos</p> <p className="text-xs text-[var(--text-secondary)]">Inactivos</p>
</Card> </Card>
<Card className="p-4 text-center"> <Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.categories}</p> <p className="text-2xl font-bold text-[var(--color-primary)]">{stats.totalOrders}</p>
<p className="text-xs text-[var(--text-secondary)]">Categorías</p> <p className="text-xs text-[var(--text-secondary)]">Órdenes</p>
</Card> </Card>
</div> </div>
@@ -223,12 +388,12 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<div className="flex items-start space-x-4 flex-1"> <div className="flex items-start space-x-4 flex-1">
{/* Status Icon */} {/* Status Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${ <div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
supplier.status === 'active' supplier.is_active
? 'bg-[var(--color-success)]/10' ? 'bg-[var(--color-success)]/10'
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]' : 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
}`}> }`}>
<Truck className={`w-4 h-4 ${ <Truck className={`w-4 h-4 ${
supplier.status === 'active' supplier.is_active
? 'text-[var(--color-success)]' ? 'text-[var(--color-success)]'
: 'text-[var(--text-tertiary)]' : 'text-[var(--text-tertiary)]'
}`} /> }`} />
@@ -239,63 +404,70 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{supplier.name}</h4> <h4 className="font-semibold text-[var(--text-primary)] mb-2">{supplier.name}</h4>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Badge variant={supplier.status === 'active' ? 'green' : 'gray'}> <Badge variant={supplier.is_active ? 'green' : 'gray'}>
{supplier.status === 'active' ? 'Activo' : 'Inactivo'} {supplier.is_active ? 'Activo' : 'Inactivo'}
</Badge> </Badge>
{supplier.categories.slice(0, 2).map((cat, idx) => ( {supplier.rating && (
<Badge key={idx} variant="blue">{cat}</Badge> <Badge variant="blue"> {supplier.rating.toFixed(1)}</Badge>
))} )}
{supplier.categories.length > 2 && ( {supplier.performance_metrics.total_orders > 0 && (
<Badge variant="gray">+{supplier.categories.length - 2}</Badge> <Badge variant="purple">{supplier.performance_metrics.total_orders} órdenes</Badge>
)} )}
</div> </div>
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)] mb-2"> <div className="flex items-center gap-6 text-sm text-[var(--text-secondary)] mb-2">
{supplier.contact_name && (
<div> <div>
<span className="text-[var(--text-tertiary)]">Contacto: </span> <span className="text-[var(--text-tertiary)]">Contacto: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.contact_person}</span> <span className="font-medium text-[var(--text-primary)]">{supplier.contact_name}</span>
</div> </div>
)}
{supplier.delivery_terms && (
<div> <div>
<span className="text-[var(--text-tertiary)]">Entrega: </span> <span className="text-[var(--text-tertiary)]">Entrega: </span>
<span className="font-medium text-[var(--text-primary)]"> <span className="font-medium text-[var(--text-primary)]">{supplier.delivery_terms}</span>
{supplier.delivery_days.slice(0, 2).join(', ')}
{supplier.delivery_days.length > 2 && ` +${supplier.delivery_days.length - 2}`}
</span>
</div> </div>
)}
{supplier.payment_terms && (
<div> <div>
<span className="text-[var(--text-tertiary)]">Pago: </span> <span className="text-[var(--text-tertiary)]">Pago: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.payment_terms}</span> <span className="font-medium text-[var(--text-primary)]">{supplier.payment_terms}</span>
</div> </div>
{supplier.min_order_amount && (
<div>
<span className="text-[var(--text-tertiary)]">Mín: </span>
<span className="font-medium text-[var(--text-primary)]">${supplier.min_order_amount}</span>
</div>
)} )}
</div> </div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]"> <div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
{supplier.phone && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Phone className="w-3 h-3" /> <Phone className="w-3 h-3" />
<span>{supplier.phone}</span> <span>{supplier.phone}</span>
</div> </div>
)}
{supplier.email && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Mail className="w-3 h-3" /> <Mail className="w-3 h-3" />
<span>{supplier.email}</span> <span>{supplier.email}</span>
</div> </div>
)}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<MapPin className="w-3 h-3" /> <MapPin className="w-3 h-3" />
<span>{supplier.address}</span> <span>{supplier.address}</span>
</div> </div>
</div> </div>
{supplier.notes && ( {supplier.performance_metrics.on_time_delivery_rate > 0 && (
<div className="mt-3 p-2 bg-[var(--bg-secondary)] rounded text-sm"> <div className="mt-3 p-2 bg-[var(--bg-secondary)] rounded text-sm">
<span className="text-[var(--text-tertiary)]">Notas: </span> <span className="text-[var(--text-tertiary)]">Rendimiento: </span>
<span className="text-[var(--text-primary)]">{supplier.notes}</span> <span className="text-[var(--text-primary)]">
{supplier.performance_metrics.on_time_delivery_rate}% entregas a tiempo
</span>
{supplier.performance_metrics.quality_score > 0 && (
<span className="text-[var(--text-primary)]">
, {supplier.performance_metrics.quality_score}/5 calidad
</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -306,7 +478,24 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setEditingSupplier(supplier)} onClick={() => {
// Convert backend supplier to form data
const formData: SupplierFormData = {
id: supplier.id,
name: supplier.name,
contact_name: supplier.contact_name || '',
phone: supplier.phone || '',
email: supplier.email || '',
address: supplier.address,
payment_terms: supplier.payment_terms || '30 días',
delivery_terms: supplier.delivery_terms || 'Recoger en tienda',
tax_id: supplier.tax_id || '',
is_active: supplier.is_active
};
setEditingSupplier(formData);
setIsAddingNew(false);
}}
disabled={updating}
> >
<Edit className="w-4 h-4 mr-1" /> <Edit className="w-4 h-4 mr-1" />
Editar Editar
@@ -315,13 +504,14 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => toggleSupplierStatus(supplier.id)} onClick={() => toggleSupplierStatus(supplier.id, supplier.is_active)}
className={supplier.status === 'active' className={supplier.is_active
? 'text-[var(--color-warning)] border-[var(--color-warning)] hover:bg-[var(--color-warning)]/10' ? 'text-[var(--color-warning)] border-[var(--color-warning)] hover:bg-[var(--color-warning)]/10'
: 'text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10' : 'text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10'
} }
disabled={updating}
> >
{supplier.status === 'active' ? 'Pausar' : 'Activar'} {supplier.is_active ? 'Pausar' : 'Activar'}
</Button> </Button>
<Button <Button
@@ -329,19 +519,29 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
variant="outline" variant="outline"
onClick={() => handleDeleteSupplier(supplier.id)} onClick={() => handleDeleteSupplier(supplier.id)}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10" className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
disabled={deleting === supplier.id}
> >
{deleting === supplier.id ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
)}
</Button> </Button>
</div> </div>
</div> </div>
</Card> </Card>
))} ))}
{getFilteredSuppliers().length === 0 && ( {getFilteredSuppliers().length === 0 && !loading && (
<Card className="p-8 text-center"> <Card className="p-8 text-center">
<Truck className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" /> <Truck className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)] mb-4">No hay proveedores en esta categoría</p> <p className="text-[var(--text-secondary)] mb-4">
<Button onClick={handleAddSupplier}> {filterStatus === 'all'
? 'No hay proveedores registrados'
: `No hay proveedores ${filterStatus === 'active' ? 'activos' : 'inactivos'}`
}
</p>
<Button onClick={handleAddSupplier} disabled={creating}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Agregar Primer Proveedor Agregar Primer Proveedor
</Button> </Button>
@@ -364,6 +564,8 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
setEditingSupplier(null); setEditingSupplier(null);
setIsAddingNew(false); setIsAddingNew(false);
}} }}
isCreating={creating}
isUpdating={updating}
/> />
</Card> </Card>
</div> </div>
@@ -388,45 +590,37 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
// Component for editing suppliers // Component for editing suppliers
interface SupplierFormProps { interface SupplierFormProps {
supplier: Supplier; supplier: SupplierFormData;
onSave: (supplier: Supplier) => void; onSave: (supplier: SupplierFormData) => void;
onCancel: () => void; onCancel: () => void;
isCreating: boolean;
isUpdating: boolean;
} }
const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel }) => { const SupplierForm: React.FC<SupplierFormProps> = ({
supplier,
onSave,
onCancel,
isCreating,
isUpdating
}) => {
const [formData, setFormData] = useState(supplier); const [formData, setFormData] = useState(supplier);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!formData.name.trim()) { if (!formData.name.trim()) {
alert('El nombre es requerido'); alert('El nombre de la empresa es requerido');
return; return;
} }
if (!formData.contact_person.trim()) { if (!formData.address.trim()) {
alert('El contacto es requerido'); alert('La dirección es requerida');
return; return;
} }
onSave(formData); onSave(formData);
}; };
const toggleCategory = (category: string) => {
setFormData(prev => ({
...prev,
categories: prev.categories.includes(category)
? prev.categories.filter(c => c !== category)
: [...prev.categories, category]
}));
};
const toggleDeliveryDay = (day: string) => {
setFormData(prev => ({
...prev,
delivery_days: prev.delivery_days.includes(day)
? prev.delivery_days.filter(d => d !== day)
: [...prev.delivery_days, day]
}));
};
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -438,17 +632,19 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
value={formData.name} value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Molinos del Sur" placeholder="Molinos del Sur"
disabled={isCreating || isUpdating}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Persona de Contacto * Persona de Contacto
</label> </label>
<Input <Input
value={formData.contact_person} value={formData.contact_name}
onChange={(e) => setFormData(prev => ({ ...prev, contact_person: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, contact_name: e.target.value }))}
placeholder="Juan Pérez" placeholder="Juan Pérez"
disabled={isCreating || isUpdating}
/> />
</div> </div>
</div> </div>
@@ -462,6 +658,7 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
value={formData.phone} value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
placeholder="+1 555-0123" placeholder="+1 555-0123"
disabled={isCreating || isUpdating}
/> />
</div> </div>
@@ -474,40 +671,23 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
value={formData.email} value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
placeholder="ventas@proveedor.com" placeholder="ventas@proveedor.com"
disabled={isCreating || isUpdating}
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Dirección Dirección *
</label> </label>
<Input <Input
value={formData.address} value={formData.address}
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
placeholder="Av. Industrial 123, Zona Sur" placeholder="Av. Industrial 123, Zona Sur"
disabled={isCreating || isUpdating}
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Categorías de Productos
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{commonCategories.map(category => (
<label key={category} className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={formData.categories.includes(category)}
onChange={() => toggleCategory(category)}
className="rounded"
/>
<span className="text-sm text-[var(--text-primary)]">{category}</span>
</label>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
@@ -517,67 +697,74 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
value={formData.payment_terms} value={formData.payment_terms}
onChange={(e) => setFormData(prev => ({ ...prev, payment_terms: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, payment_terms: e.target.value }))}
className="w-full p-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]" className="w-full p-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
disabled={isCreating || isUpdating}
> >
<option value="Inmediato">Inmediato</option> {paymentTermsOptions.map(term => (
<option value="15 días">15 días</option> <option key={term} value={term}>{term}</option>
<option value="30 días">30 días</option> ))}
<option value="45 días">45 días</option>
<option value="60 días">60 días</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Pedido Mínimo Términos de Entrega
</label> </label>
<Input <Input
type="number" value={formData.delivery_terms}
value={formData.min_order_amount || ''} onChange={(e) => setFormData(prev => ({ ...prev, delivery_terms: e.target.value }))}
onChange={(e) => setFormData(prev => ({ ...prev, min_order_amount: Number(e.target.value) }))} placeholder="Recoger en tienda"
placeholder="200" disabled={isCreating || isUpdating}
min="0"
/> />
</div> </div>
</div> </div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Días de Entrega
</label>
<div className="flex flex-wrap gap-2">
{daysOfWeek.map(day => (
<label key={day} className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={formData.delivery_days.includes(day)}
onChange={() => toggleDeliveryDay(day)}
className="rounded"
/>
<span className="text-sm text-[var(--text-primary)]">{day}</span>
</label>
))}
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1"> <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Notas RUT/NIT (Opcional)
</label> </label>
<textarea <Input
value={formData.notes || ''} value={formData.tax_id || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, tax_id: e.target.value }))}
placeholder="Información adicional sobre el proveedor..." placeholder="12345678-9"
className="w-full p-2 border border-[var(--border-secondary)] rounded resize-none focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]" disabled={isCreating || isUpdating}
rows={3}
/> />
</div> </div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData(prev => ({ ...prev, is_active: e.target.checked }))}
disabled={isCreating || isUpdating}
className="rounded"
/>
<label htmlFor="is_active" className="text-sm text-[var(--text-primary)]">
Proveedor activo
</label>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t"> <div className="flex justify-end space-x-3 pt-4 border-t">
<Button type="button" variant="outline" onClick={onCancel}> <Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isCreating || isUpdating}
>
Cancelar Cancelar
</Button> </Button>
<Button type="submit"> <Button
Guardar Proveedor type="submit"
disabled={isCreating || isUpdating}
>
{isCreating || isUpdating ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin" />
{isCreating ? 'Creando...' : 'Actualizando...'}
</>
) : (
'Guardar Proveedor'
)}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -1,85 +1,17 @@
import { apiClient, ApiResponse } from './client'; import { apiClient, ApiResponse } from './client';
import {
// Request/Response Types based on backend schemas UserRegistration,
export interface UserRegistration { UserLogin,
email: string; UserData,
password: string; TokenResponse,
full_name: string; RefreshTokenRequest,
tenant_name?: string; PasswordChange,
role?: 'user' | 'admin' | 'manager'; PasswordReset,
} PasswordResetConfirm,
TokenVerification,
export interface UserLogin { UserResponse,
email: string; UserUpdate
password: string; } from '../../types/auth.types';
}
export interface UserData {
id: string;
email: string;
full_name: string;
is_active: boolean;
is_verified: boolean;
created_at: string;
tenant_id?: string;
role?: string;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
user?: UserData;
}
export interface RefreshTokenRequest {
refresh_token: string;
}
export interface PasswordChange {
current_password: string;
new_password: string;
}
export interface PasswordReset {
email: string;
}
export interface PasswordResetConfirm {
token: string;
new_password: string;
}
export interface TokenVerification {
valid: boolean;
user_id?: string;
email?: string;
exp?: number;
message?: string;
}
export interface UserResponse {
id: string;
email: string;
full_name: string;
is_active: boolean;
is_verified: boolean;
created_at: string;
last_login?: string;
phone?: string;
language?: string;
timezone?: string;
tenant_id?: string;
role?: string;
}
export interface UserUpdate {
full_name?: string;
phone?: string;
language?: string;
timezone?: string;
}
class AuthService { class AuthService {
private readonly baseUrl = '/auth'; private readonly baseUrl = '/auth';

View File

@@ -1,4 +1,5 @@
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'; import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { ApiResponse, ApiError } from '../../types/api.types';
// Utility functions to access auth and tenant store data from localStorage // Utility functions to access auth and tenant store data from localStorage
const getAuthData = () => { const getAuthData = () => {
@@ -27,22 +28,13 @@ const clearAuthData = () => {
localStorage.removeItem('auth-storage'); localStorage.removeItem('auth-storage');
}; };
export interface ApiResponse<T = any> { // Client-specific error interface
data: T; interface ClientError {
success: boolean; success: boolean;
message?: string; error: {
error?: string;
}
export interface ErrorDetail {
message: string; message: string;
code?: string; code?: string;
field?: string; };
}
export interface ApiError {
success: boolean;
error: ErrorDetail;
timestamp: string; timestamp: string;
} }
@@ -116,7 +108,7 @@ class ApiClient {
// Handle network errors // Handle network errors
if (!error.response) { if (!error.response) {
const networkError: ApiError = { const networkError: ClientError = {
success: false, success: false,
error: { error: {
message: 'Network error - please check your connection', message: 'Network error - please check your connection',

View File

@@ -1,67 +1,27 @@
import { apiClient, ApiResponse } from './client'; import { apiClient, ApiResponse } from './client';
import {
// External data types WeatherData,
export interface WeatherData { WeatherDataParams,
id: string; TrafficData,
tenant_id: string; TrafficDataParams,
location_id: string; TrafficPatternsParams,
date: string; TrafficPattern,
temperature_avg: number; EventData,
temperature_min: number; EventsParams,
temperature_max: number; CustomEventCreate,
humidity: number; LocationConfig,
precipitation: number; LocationCreate,
wind_speed: number; ExternalFactorsImpact,
condition: string; ExternalFactorsParams,
description: string; DataQualityReport,
created_at: string; DataSettings,
} DataSettingsUpdate,
RefreshDataResponse,
export interface TrafficData { DeleteResponse,
id: string; WeatherCondition,
tenant_id: string; EventType,
location_id: string; RefreshInterval
date: string; } from '../../types/data.types';
hour: number;
traffic_level: number;
congestion_index: number;
average_speed: number;
incident_count: number;
created_at: string;
}
export interface EventData {
id: string;
tenant_id: string;
location_id: string;
event_name: string;
event_type: string;
start_date: string;
end_date: string;
expected_attendance?: number;
impact_radius_km?: number;
impact_score: number;
created_at: string;
}
export interface LocationConfig {
id: string;
tenant_id: string;
name: string;
latitude: number;
longitude: number;
address: string;
city: string;
country: string;
is_primary: boolean;
data_sources: {
weather_enabled: boolean;
traffic_enabled: boolean;
events_enabled: boolean;
};
created_at: string;
updated_at: string;
}
class DataService { class DataService {
private readonly baseUrl = '/data'; private readonly baseUrl = '/data';
@@ -75,16 +35,7 @@ class DataService {
return apiClient.get(`${this.baseUrl}/locations/${locationId}`); return apiClient.get(`${this.baseUrl}/locations/${locationId}`);
} }
async createLocation(locationData: { async createLocation(locationData: LocationCreate): Promise<ApiResponse<LocationConfig>> {
name: string;
latitude: number;
longitude: number;
address: string;
city: string;
country?: string;
is_primary?: boolean;
data_sources?: LocationConfig['data_sources'];
}): Promise<ApiResponse<LocationConfig>> {
return apiClient.post(`${this.baseUrl}/locations`, locationData); return apiClient.post(`${this.baseUrl}/locations`, locationData);
} }
@@ -92,18 +43,12 @@ class DataService {
return apiClient.put(`${this.baseUrl}/locations/${locationId}`, locationData); return apiClient.put(`${this.baseUrl}/locations/${locationId}`, locationData);
} }
async deleteLocation(locationId: string): Promise<ApiResponse<{ message: string }>> { async deleteLocation(locationId: string): Promise<ApiResponse<DeleteResponse>> {
return apiClient.delete(`${this.baseUrl}/locations/${locationId}`); return apiClient.delete(`${this.baseUrl}/locations/${locationId}`);
} }
// Weather data // Weather data
async getWeatherData(params?: { async getWeatherData(params?: WeatherDataParams): Promise<ApiResponse<{ items: WeatherData[]; total: number; page: number; size: number; pages: number }>> {
location_id?: string;
start_date?: string;
end_date?: string;
page?: number;
size?: number;
}): Promise<ApiResponse<{ items: WeatherData[]; total: number; page: number; size: number; pages: number }>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {
@@ -129,7 +74,7 @@ class DataService {
return apiClient.get(`${this.baseUrl}/weather/forecast/${locationId}?days=${days}`); return apiClient.get(`${this.baseUrl}/weather/forecast/${locationId}?days=${days}`);
} }
async refreshWeatherData(locationId?: string): Promise<ApiResponse<{ message: string; updated_records: number }>> { async refreshWeatherData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
const url = locationId const url = locationId
? `${this.baseUrl}/weather/refresh/${locationId}` ? `${this.baseUrl}/weather/refresh/${locationId}`
: `${this.baseUrl}/weather/refresh`; : `${this.baseUrl}/weather/refresh`;
@@ -138,14 +83,7 @@ class DataService {
} }
// Traffic data // Traffic data
async getTrafficData(params?: { async getTrafficData(params?: TrafficDataParams): Promise<ApiResponse<{ items: TrafficData[]; total: number; page: number; size: number; pages: number }>> {
location_id?: string;
start_date?: string;
end_date?: string;
hour?: number;
page?: number;
size?: number;
}): Promise<ApiResponse<{ items: TrafficData[]; total: number; page: number; size: number; pages: number }>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {
@@ -167,15 +105,7 @@ class DataService {
return apiClient.get(`${this.baseUrl}/traffic/current/${locationId}`); return apiClient.get(`${this.baseUrl}/traffic/current/${locationId}`);
} }
async getTrafficPatterns(locationId: string, params?: { async getTrafficPatterns(locationId: string, params?: TrafficPatternsParams): Promise<ApiResponse<TrafficPattern[]>> {
days_back?: number;
granularity?: 'hourly' | 'daily';
}): Promise<ApiResponse<Array<{
period: string;
average_traffic_level: number;
peak_hours: number[];
congestion_patterns: Record<string, number>;
}>>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {
@@ -193,7 +123,7 @@ class DataService {
return apiClient.get(url); return apiClient.get(url);
} }
async refreshTrafficData(locationId?: string): Promise<ApiResponse<{ message: string; updated_records: number }>> { async refreshTrafficData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
const url = locationId const url = locationId
? `${this.baseUrl}/traffic/refresh/${locationId}` ? `${this.baseUrl}/traffic/refresh/${locationId}`
: `${this.baseUrl}/traffic/refresh`; : `${this.baseUrl}/traffic/refresh`;
@@ -202,14 +132,7 @@ class DataService {
} }
// Events data // Events data
async getEvents(params?: { async getEvents(params?: EventsParams): Promise<ApiResponse<{ items: EventData[]; total: number; page: number; size: number; pages: number }>> {
location_id?: string;
start_date?: string;
end_date?: string;
event_type?: string;
page?: number;
size?: number;
}): Promise<ApiResponse<{ items: EventData[]; total: number; page: number; size: number; pages: number }>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {
@@ -231,16 +154,7 @@ class DataService {
return apiClient.get(`${this.baseUrl}/events/upcoming/${locationId}?days=${days}`); return apiClient.get(`${this.baseUrl}/events/upcoming/${locationId}?days=${days}`);
} }
async createCustomEvent(eventData: { async createCustomEvent(eventData: CustomEventCreate): Promise<ApiResponse<EventData>> {
location_id: string;
event_name: string;
event_type: string;
start_date: string;
end_date: string;
expected_attendance?: number;
impact_radius_km?: number;
impact_score?: number;
}): Promise<ApiResponse<EventData>> {
return apiClient.post(`${this.baseUrl}/events`, eventData); return apiClient.post(`${this.baseUrl}/events`, eventData);
} }
@@ -248,11 +162,11 @@ class DataService {
return apiClient.put(`${this.baseUrl}/events/${eventId}`, eventData); return apiClient.put(`${this.baseUrl}/events/${eventId}`, eventData);
} }
async deleteEvent(eventId: string): Promise<ApiResponse<{ message: string }>> { async deleteEvent(eventId: string): Promise<ApiResponse<DeleteResponse>> {
return apiClient.delete(`${this.baseUrl}/events/${eventId}`); return apiClient.delete(`${this.baseUrl}/events/${eventId}`);
} }
async refreshEventsData(locationId?: string): Promise<ApiResponse<{ message: string; updated_records: number }>> { async refreshEventsData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
const url = locationId const url = locationId
? `${this.baseUrl}/events/refresh/${locationId}` ? `${this.baseUrl}/events/refresh/${locationId}`
: `${this.baseUrl}/events/refresh`; : `${this.baseUrl}/events/refresh`;
@@ -261,27 +175,7 @@ class DataService {
} }
// Combined analytics // Combined analytics
async getExternalFactorsImpact(params?: { async getExternalFactorsImpact(params?: ExternalFactorsParams): Promise<ApiResponse<ExternalFactorsImpact>> {
location_id?: string;
start_date?: string;
end_date?: string;
}): Promise<ApiResponse<{
weather_impact: {
temperature_correlation: number;
precipitation_impact: number;
most_favorable_conditions: string;
};
traffic_impact: {
congestion_correlation: number;
peak_traffic_effect: number;
optimal_traffic_levels: number[];
};
events_impact: {
positive_events: EventData[];
negative_events: EventData[];
average_event_boost: number;
};
}>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {
@@ -299,64 +193,21 @@ class DataService {
return apiClient.get(url); return apiClient.get(url);
} }
async getDataQualityReport(): Promise<ApiResponse<{ async getDataQualityReport(): Promise<ApiResponse<DataQualityReport>> {
overall_score: number;
data_sources: Array<{
source: 'weather' | 'traffic' | 'events';
completeness: number;
freshness_hours: number;
reliability_score: number;
last_update: string;
}>;
recommendations: Array<{
priority: 'high' | 'medium' | 'low';
message: string;
action: string;
}>;
}>> {
return apiClient.get(`${this.baseUrl}/quality-report`); return apiClient.get(`${this.baseUrl}/quality-report`);
} }
// Data configuration // Data configuration
async getDataSettings(): Promise<ApiResponse<{ async getDataSettings(): Promise<ApiResponse<DataSettings>> {
auto_refresh_enabled: boolean;
refresh_intervals: {
weather_minutes: number;
traffic_minutes: number;
events_hours: number;
};
data_retention_days: {
weather: number;
traffic: number;
events: number;
};
external_apis: {
weather_provider: string;
traffic_provider: string;
events_provider: string;
};
}>> {
return apiClient.get(`${this.baseUrl}/settings`); return apiClient.get(`${this.baseUrl}/settings`);
} }
async updateDataSettings(settings: { async updateDataSettings(settings: DataSettingsUpdate): Promise<ApiResponse<DataSettings>> {
auto_refresh_enabled?: boolean;
refresh_intervals?: {
weather_minutes?: number;
traffic_minutes?: number;
events_hours?: number;
};
data_retention_days?: {
weather?: number;
traffic?: number;
events?: number;
};
}): Promise<ApiResponse<any>> {
return apiClient.put(`${this.baseUrl}/settings`, settings); return apiClient.put(`${this.baseUrl}/settings`, settings);
} }
// Utility methods // Utility methods
getWeatherConditions(): { value: string; label: string; impact: 'positive' | 'negative' | 'neutral' }[] { getWeatherConditions(): WeatherCondition[] {
return [ return [
{ value: 'sunny', label: 'Sunny', impact: 'positive' }, { value: 'sunny', label: 'Sunny', impact: 'positive' },
{ value: 'cloudy', label: 'Cloudy', impact: 'neutral' }, { value: 'cloudy', label: 'Cloudy', impact: 'neutral' },
@@ -367,7 +218,7 @@ class DataService {
]; ];
} }
getEventTypes(): { value: string; label: string; typical_impact: 'positive' | 'negative' | 'neutral' }[] { getEventTypes(): EventType[] {
return [ return [
{ value: 'festival', label: 'Festival', typical_impact: 'positive' }, { value: 'festival', label: 'Festival', typical_impact: 'positive' },
{ value: 'concert', label: 'Concert', typical_impact: 'positive' }, { value: 'concert', label: 'Concert', typical_impact: 'positive' },
@@ -380,7 +231,7 @@ class DataService {
]; ];
} }
getRefreshIntervals(): { value: number; label: string; suitable_for: string[] }[] { getRefreshIntervals(): RefreshInterval[] {
return [ return [
{ value: 5, label: '5 minutes', suitable_for: ['traffic'] }, { value: 5, label: '5 minutes', suitable_for: ['traffic'] },
{ value: 15, label: '15 minutes', suitable_for: ['traffic'] }, { value: 15, label: '15 minutes', suitable_for: ['traffic'] },

View File

@@ -1,68 +1,10 @@
import { apiClient, ApiResponse } from './client'; import { apiClient, ApiResponse } from './client';
import {
// Request/Response Types ForecastRequest,
export interface ForecastRequest { ForecastResponse,
product_name: string; PredictionBatch,
days_ahead: number; ModelPerformance
start_date?: string; } from '../../types/forecasting.types';
include_confidence_intervals?: boolean;
external_factors?: {
weather?: string[];
events?: string[];
holidays?: boolean;
};
}
export interface ForecastResponse {
id: string;
tenant_id: string;
product_name: string;
forecast_date: string;
predicted_demand: number;
confidence_lower: number;
confidence_upper: number;
confidence_level: number;
external_factors: Record<string, any>;
model_version: string;
created_at: string;
actual_demand?: number;
accuracy_score?: number;
}
export interface PredictionBatch {
id: string;
tenant_id: string;
name: string;
description?: string;
parameters: Record<string, any>;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: number;
total_predictions: number;
completed_predictions: number;
failed_predictions: number;
created_at: string;
completed_at?: string;
error_message?: string;
}
export interface ModelPerformance {
model_id: string;
model_name: string;
version: string;
accuracy_metrics: {
mape: number; // Mean Absolute Percentage Error
rmse: number; // Root Mean Square Error
mae: number; // Mean Absolute Error
r2_score: number;
};
training_data_period: {
start_date: string;
end_date: string;
total_records: number;
};
last_training_date: string;
performance_trend: 'improving' | 'stable' | 'declining';
}
class ForecastingService { class ForecastingService {
private readonly baseUrl = '/forecasting'; private readonly baseUrl = '/forecasting';

View File

@@ -1,35 +1,20 @@
import { apiClient, ApiResponse } from './client'; import { apiClient } from './client';
import { ApiResponse } from '../../types/api.types';
import {
UnitOfMeasure,
ProductType,
StockMovementType,
Ingredient,
Stock,
StockMovement,
StockAlert,
InventorySummary,
StockLevelSummary
} from '../../types/inventory.types';
import { PaginatedResponse } from '../../types/api.types';
// Enums // Service-specific types for Create/Update operations
export enum UnitOfMeasure { interface IngredientCreate {
KILOGRAM = 'kg',
GRAM = 'g',
LITER = 'l',
MILLILITER = 'ml',
PIECE = 'piece',
PACKAGE = 'package',
BAG = 'bag',
BOX = 'box',
DOZEN = 'dozen',
}
export enum ProductType {
INGREDIENT = 'ingredient',
FINISHED_PRODUCT = 'finished_product',
}
export enum StockMovementType {
PURCHASE = 'purchase',
SALE = 'sale',
USAGE = 'usage',
WASTE = 'waste',
ADJUSTMENT = 'adjustment',
TRANSFER = 'transfer',
RETURN = 'return',
}
// Request/Response Types
export interface IngredientCreate {
name: string; name: string;
product_type?: ProductType; product_type?: ProductType;
sku?: string; sku?: string;
@@ -57,74 +42,11 @@ export interface IngredientCreate {
allergen_info?: Record<string, any>; allergen_info?: Record<string, any>;
} }
export interface IngredientUpdate { interface IngredientUpdate extends Partial<IngredientCreate> {
name?: string;
product_type?: ProductType;
sku?: string;
barcode?: string;
category?: string;
subcategory?: string;
description?: string;
brand?: string;
unit_of_measure?: UnitOfMeasure;
package_size?: number;
average_cost?: number;
standard_cost?: number;
low_stock_threshold?: number;
reorder_point?: number;
reorder_quantity?: number;
max_stock_level?: number;
requires_refrigeration?: boolean;
requires_freezing?: boolean;
storage_temperature_min?: number;
storage_temperature_max?: number;
storage_humidity_max?: number;
shelf_life_days?: number;
storage_instructions?: string;
is_active?: boolean; is_active?: boolean;
is_perishable?: boolean;
allergen_info?: Record<string, any>;
} }
export interface IngredientResponse { interface StockCreate {
id: string;
tenant_id: string;
name: string;
product_type: ProductType;
sku?: string;
barcode?: string;
category?: string;
subcategory?: string;
description?: string;
brand?: string;
unit_of_measure: UnitOfMeasure;
package_size?: number;
average_cost?: number;
last_purchase_price?: number;
standard_cost?: number;
low_stock_threshold: number;
reorder_point: number;
reorder_quantity: number;
max_stock_level?: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
storage_temperature_min?: number;
storage_temperature_max?: number;
storage_humidity_max?: number;
shelf_life_days?: number;
storage_instructions?: string;
is_active: boolean;
is_perishable: boolean;
allergen_info?: Record<string, any>;
created_at: string;
updated_at: string;
created_by?: string;
current_stock?: number;
is_low_stock?: boolean;
needs_reorder?: boolean;
}
export interface StockCreate {
ingredient_id: string; ingredient_id: string;
batch_number?: string; batch_number?: string;
lot_number?: string; lot_number?: string;
@@ -140,50 +62,12 @@ export interface StockCreate {
quality_status?: string; quality_status?: string;
} }
export interface StockUpdate { interface StockUpdate extends Partial<StockCreate> {
batch_number?: string;
lot_number?: string;
supplier_batch_ref?: string;
current_quantity?: number;
reserved_quantity?: number; reserved_quantity?: number;
received_date?: string;
expiration_date?: string;
best_before_date?: string;
unit_cost?: number;
storage_location?: string;
warehouse_zone?: string;
shelf_position?: string;
is_available?: boolean; is_available?: boolean;
quality_status?: string;
} }
export interface StockResponse { interface StockMovementCreate {
id: string;
tenant_id: string;
ingredient_id: string;
batch_number?: string;
lot_number?: string;
supplier_batch_ref?: string;
current_quantity: number;
reserved_quantity: number;
available_quantity: number;
received_date?: string;
expiration_date?: string;
best_before_date?: string;
unit_cost?: number;
total_cost?: number;
storage_location?: string;
warehouse_zone?: string;
shelf_position?: string;
is_available: boolean;
is_expired: boolean;
quality_status: string;
created_at: string;
updated_at: string;
ingredient?: IngredientResponse;
}
export interface StockMovementCreate {
ingredient_id: string; ingredient_id: string;
stock_id?: string; stock_id?: string;
movement_type: StockMovementType; movement_type: StockMovementType;
@@ -196,107 +80,11 @@ export interface StockMovementCreate {
movement_date?: string; movement_date?: string;
} }
export interface StockMovementResponse { // Type aliases for response consistency
id: string; type IngredientResponse = Ingredient;
tenant_id: string; type StockResponse = Stock;
ingredient_id: string; type StockMovementResponse = StockMovement;
stock_id?: string; type StockAlertResponse = StockAlert;
movement_type: StockMovementType;
quantity: number;
unit_cost?: number;
total_cost?: number;
quantity_before?: number;
quantity_after?: number;
reference_number?: string;
supplier_id?: string;
notes?: string;
reason_code?: string;
movement_date: string;
created_at: string;
created_by?: string;
ingredient?: IngredientResponse;
}
export interface StockAlertResponse {
id: string;
tenant_id: string;
ingredient_id: string;
stock_id?: string;
alert_type: string;
severity: string;
title: string;
message: string;
current_quantity?: number;
threshold_value?: number;
expiration_date?: string;
is_active: boolean;
is_acknowledged: boolean;
acknowledged_by?: string;
acknowledged_at?: string;
is_resolved: boolean;
resolved_by?: string;
resolved_at?: string;
resolution_notes?: string;
created_at: string;
updated_at: string;
ingredient?: IngredientResponse;
}
export interface InventorySummary {
total_ingredients: number;
total_stock_value: number;
low_stock_alerts: number;
expiring_soon_items: number;
expired_items: number;
out_of_stock_items: number;
stock_by_category: Record<string, Record<string, any>>;
recent_movements: number;
recent_purchases: number;
recent_waste: number;
}
export interface StockLevelSummary {
ingredient_id: string;
ingredient_name: string;
unit_of_measure: string;
total_quantity: number;
available_quantity: number;
reserved_quantity: number;
is_low_stock: boolean;
needs_reorder: boolean;
has_expired_stock: boolean;
total_batches: number;
oldest_batch_date?: string;
newest_batch_date?: string;
next_expiration_date?: string;
average_unit_cost?: number;
total_stock_value?: number;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
size: number;
pages: number;
}
export interface InventoryFilter {
category?: string;
is_active?: boolean;
is_low_stock?: boolean;
needs_reorder?: boolean;
search?: string;
}
export interface StockFilter {
ingredient_id?: string;
is_available?: boolean;
is_expired?: boolean;
expiring_within_days?: number;
storage_location?: string;
quality_status?: string;
}
class InventoryService { class InventoryService {
private readonly baseUrl = '/inventory'; private readonly baseUrl = '/inventory';

View File

@@ -88,6 +88,7 @@ class OnboardingApiService {
/** /**
* Step 1: Validate uploaded file and extract unique products * Step 1: Validate uploaded file and extract unique products
* Now uses Sales Service directly
*/ */
async validateOnboardingFile( async validateOnboardingFile(
tenantId: string, tenantId: string,
@@ -98,7 +99,7 @@ class OnboardingApiService {
formData.append('file', file); formData.append('file', file);
const response = await apiClient.post<OnboardingFileValidationResponse>( const response = await apiClient.post<OnboardingFileValidationResponse>(
`${this.basePath}/${tenantId}/onboarding/validate-file`, `${this.salesBasePath}/${tenantId}/sales/import/validate`,
formData, formData,
{ {
headers: { headers: {
@@ -120,6 +121,7 @@ class OnboardingApiService {
/** /**
* Step 2: Generate AI-powered inventory suggestions * Step 2: Generate AI-powered inventory suggestions
* Now uses Inventory Service directly
*/ */
async generateInventorySuggestions( async generateInventorySuggestions(
tenantId: string, tenantId: string,
@@ -127,18 +129,24 @@ class OnboardingApiService {
productList: string[] productList: string[]
): Promise<ProductSuggestionsResponse> { ): Promise<ProductSuggestionsResponse> {
try { try {
const formData = new FormData(); if (!productList || !Array.isArray(productList) || productList.length === 0) {
formData.append('file', file); throw new Error('Product list is empty or invalid');
formData.append('product_list', JSON.stringify(productList)); }
// Transform product list into the expected format for BatchClassificationRequest
const products = productList.map(productName => ({
product_name: productName,
// sales_volume is optional, omit it if we don't have the data
sales_data: {} // Additional context can be added later
}));
const requestData = {
products: products
};
const response = await apiClient.post<ProductSuggestionsResponse>( const response = await apiClient.post<ProductSuggestionsResponse>(
`${this.basePath}/${tenantId}/onboarding/generate-suggestions`, `${this.basePath}/${tenantId}/inventory/classify-products-batch`,
formData, requestData
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
); );
if (!response.success) { if (!response.success) {
@@ -154,34 +162,56 @@ class OnboardingApiService {
/** /**
* Step 3: Create inventory items from approved suggestions * Step 3: Create inventory items from approved suggestions
* Now uses Inventory Service directly
*/ */
async createInventoryFromSuggestions( async createInventoryFromSuggestions(
tenantId: string, tenantId: string,
approvedSuggestions: any[] approvedSuggestions: any[]
): Promise<InventoryCreationResponse> { ): Promise<InventoryCreationResponse> {
try { try {
const response = await apiClient.post<InventoryCreationResponse>( const createdItems: any[] = [];
`${this.basePath}/${tenantId}/onboarding/create-inventory`, const failedItems: any[] = [];
{ const inventoryMapping: { [productName: string]: string } = {};
suggestions: approvedSuggestions
} // Create inventory items one by one using inventory service
for (const suggestion of approvedSuggestions) {
try {
const ingredientData = {
name: suggestion.suggested_name,
category: suggestion.category,
unit_of_measure: suggestion.unit_of_measure,
shelf_life_days: suggestion.estimated_shelf_life_days,
requires_refrigeration: suggestion.requires_refrigeration,
requires_freezing: suggestion.requires_freezing,
is_seasonal: suggestion.is_seasonal,
product_type: suggestion.product_type
};
const response = await apiClient.post<any>(
`${this.basePath}/${tenantId}/ingredients`,
ingredientData
); );
if (!response.success) { if (response.success) {
throw new Error(`Inventory creation failed: ${response.error || 'Unknown error'}`); createdItems.push(response.data);
inventoryMapping[suggestion.original_name] = response.data.id;
} else {
failedItems.push({ suggestion, error: response.error });
}
} catch (error) {
failedItems.push({ suggestion, error: error.message });
}
} }
// Create inventory mapping if not provided const result = {
if (!response.data.inventory_mapping) { created_items: createdItems,
response.data.inventory_mapping = {}; failed_items: failedItems,
response.data.created_items.forEach((item, index) => { total_approved: approvedSuggestions.length,
if (approvedSuggestions[index]) { success_rate: createdItems.length / approvedSuggestions.length,
response.data.inventory_mapping![approvedSuggestions[index].original_name] = item.id; inventory_mapping: inventoryMapping
} };
});
}
return response.data; return result;
} catch (error) { } catch (error) {
console.error('Inventory creation failed:', error); console.error('Inventory creation failed:', error);
throw error; throw error;
@@ -190,6 +220,7 @@ class OnboardingApiService {
/** /**
* Step 4: Import sales data with inventory mapping * Step 4: Import sales data with inventory mapping
* Now uses Sales Service directly with validation first
*/ */
async importSalesWithInventory( async importSalesWithInventory(
tenantId: string, tenantId: string,
@@ -197,12 +228,16 @@ class OnboardingApiService {
inventoryMapping: { [productName: string]: string } inventoryMapping: { [productName: string]: string }
): Promise<SalesImportResponse> { ): Promise<SalesImportResponse> {
try { try {
// First validate the file with inventory mapping
await this.validateSalesData(tenantId, file);
// Then import the sales data
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('inventory_mapping', JSON.stringify(inventoryMapping)); formData.append('update_existing', 'true');
const response = await apiClient.post<SalesImportResponse>( const response = await apiClient.post<SalesImportResponse>(
`${this.basePath}/${tenantId}/onboarding/import-sales`, `${this.salesBasePath}/${tenantId}/sales/import`,
formData, formData,
{ {
headers: { headers: {
@@ -224,25 +259,80 @@ class OnboardingApiService {
/** /**
* Get business model specific recommendations * Get business model specific recommendations
* Returns static recommendations since orchestration is removed
*/ */
async getBusinessModelGuide( async getBusinessModelGuide(
tenantId: string, tenantId: string,
model: 'production' | 'retail' | 'hybrid' model: 'production' | 'retail' | 'hybrid'
): Promise<BusinessModelGuide> { ): Promise<BusinessModelGuide> {
try { // Return static business model guides since we removed orchestration
const response = await apiClient.get<BusinessModelGuide>( const guides = {
`${this.basePath}/${tenantId}/onboarding/business-model-guide?model=${model}` production: {
); title: 'Production Bakery Setup',
description: 'Your bakery focuses on creating products from raw ingredients.',
if (!response.success) { next_steps: [
throw new Error(`Failed to get business model guide: ${response.error || 'Unknown error'}`); 'Set up ingredient inventory management',
'Configure recipe management',
'Set up production planning',
'Implement quality control processes'
],
recommended_features: [
'Inventory tracking for raw ingredients',
'Recipe costing and management',
'Production scheduling',
'Supplier management'
],
sample_workflows: [
'Daily production planning based on demand forecasts',
'Inventory reordering based on production schedules',
'Quality control checkpoints during production'
]
},
retail: {
title: 'Retail Bakery Setup',
description: 'Your bakery focuses on selling finished products to customers.',
next_steps: [
'Set up finished product inventory',
'Configure point-of-sale integration',
'Set up customer management',
'Implement sales analytics'
],
recommended_features: [
'Finished product inventory tracking',
'Sales analytics and reporting',
'Customer loyalty programs',
'Promotional campaign management'
],
sample_workflows: [
'Daily sales reporting and analysis',
'Inventory reordering based on sales velocity',
'Customer engagement and retention campaigns'
]
},
hybrid: {
title: 'Hybrid Bakery Setup',
description: 'Your bakery combines production and retail operations.',
next_steps: [
'Set up both ingredient and finished product inventory',
'Configure production-to-retail workflows',
'Set up integrated analytics',
'Implement comprehensive supplier management'
],
recommended_features: [
'Dual inventory management system',
'Production-to-sales analytics',
'Integrated supplier and customer management',
'Cross-channel reporting'
],
sample_workflows: [
'Production planning based on both wholesale and retail demand',
'Integrated inventory management across production and retail',
'Comprehensive business intelligence and reporting'
]
} }
};
return response.data; return guides[model] || guides.hybrid;
} catch (error) {
console.error('Failed to get business model guide:', error);
throw error;
}
} }
/** /**
@@ -366,14 +456,18 @@ class OnboardingApiService {
/** /**
* Utility: Check if a tenant has completed onboarding * Utility: Check if a tenant has completed onboarding
* Uses Auth Service for user progress tracking
*/ */
async checkOnboardingStatus(tenantId: string): Promise<{ completed: boolean; steps_completed: string[] }> { async checkOnboardingStatus(tenantId: string): Promise<{ completed: boolean; steps_completed: string[] }> {
try { try {
const response = await apiClient.get<any>( const response = await apiClient.get<any>(
`${this.basePath}/${tenantId}/onboarding/status` '/me/onboarding/progress'
); );
return response.data || { completed: false, steps_completed: [] }; return {
completed: response.data?.onboarding_completed || false,
steps_completed: response.data?.completed_steps || []
};
} catch (error) { } catch (error) {
console.warn('Could not check onboarding status:', error); console.warn('Could not check onboarding status:', error);
return { completed: false, steps_completed: [] }; return { completed: false, steps_completed: [] };
@@ -382,11 +476,12 @@ class OnboardingApiService {
/** /**
* Utility: Mark onboarding as complete * Utility: Mark onboarding as complete
* Uses Auth Service for user progress tracking
*/ */
async completeOnboarding(tenantId: string, metadata?: any): Promise<void> { async completeOnboarding(tenantId: string, metadata?: any): Promise<void> {
try { try {
await apiClient.post( await apiClient.post(
`${this.basePath}/${tenantId}/onboarding/complete`, '/me/onboarding/complete',
{ metadata } { metadata }
); );
} catch (error) { } catch (error) {
@@ -394,6 +489,7 @@ class OnboardingApiService {
// Don't throw error, this is not critical // Don't throw error, this is not critical
} }
} }
} }
export const onboardingApiService = new OnboardingApiService(); export const onboardingApiService = new OnboardingApiService();

View File

@@ -1,134 +1,25 @@
import { apiClient, ApiResponse } from './client'; import { apiClient } from './client';
import { ApiResponse } from '../../types/api.types';
// Enums import {
export enum OrderStatus { OrderStatus,
PENDING = 'pending', OrderType,
CONFIRMED = 'confirmed', OrderItem,
IN_PREPARATION = 'in_preparation', OrderCreate,
READY = 'ready', OrderUpdate,
DELIVERED = 'delivered', OrderResponse,
CANCELLED = 'cancelled', Customer,
} OrderAnalytics,
OrderFilters,
export enum OrderType { CustomerFilters,
DINE_IN = 'dine_in', OrderTrendsParams,
TAKEAWAY = 'takeaway', OrderTrendData
DELIVERY = 'delivery', } from '../../types/orders.types';
CATERING = 'catering',
}
// Request/Response Types
export interface OrderItem {
product_id?: string;
product_name: string;
quantity: number;
unit_price: number;
total_price: number;
notes?: string;
customizations?: Record<string, any>;
}
export interface OrderCreate {
customer_id?: string;
customer_name: string;
customer_email?: string;
customer_phone?: string;
order_type: OrderType;
items: OrderItem[];
special_instructions?: string;
delivery_address?: string;
delivery_date?: string;
delivery_time?: string;
payment_method?: string;
}
export interface OrderUpdate {
status?: OrderStatus;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
special_instructions?: string;
delivery_address?: string;
delivery_date?: string;
delivery_time?: string;
estimated_completion_time?: string;
actual_completion_time?: string;
}
export interface OrderResponse {
id: string;
tenant_id: string;
order_number: string;
customer_id?: string;
customer_name: string;
customer_email?: string;
customer_phone?: string;
order_type: OrderType;
status: OrderStatus;
items: OrderItem[];
subtotal: number;
tax_amount: number;
discount_amount: number;
total_amount: number;
special_instructions?: string;
delivery_address?: string;
delivery_date?: string;
delivery_time?: string;
estimated_completion_time?: string;
actual_completion_time?: string;
payment_method?: string;
payment_status?: string;
created_at: string;
updated_at: string;
created_by?: string;
}
export interface Customer {
id: string;
tenant_id: string;
name: string;
email?: string;
phone?: string;
address?: string;
preferences?: Record<string, any>;
total_orders: number;
total_spent: number;
created_at: string;
updated_at: string;
}
export interface OrderAnalytics {
total_orders: number;
total_revenue: number;
average_order_value: number;
order_completion_rate: number;
delivery_success_rate: number;
customer_satisfaction_score?: number;
popular_products: Array<{
product_name: string;
quantity_sold: number;
revenue: number;
}>;
order_trends: Array<{
date: string;
orders: number;
revenue: number;
}>;
}
class OrdersService { class OrdersService {
private readonly baseUrl = '/orders'; private readonly baseUrl = '/orders';
// Order management // Order management
async getOrders(params?: { async getOrders(params?: OrderFilters): Promise<ApiResponse<{ items: OrderResponse[]; total: number; page: number; size: number; pages: number }>> {
page?: number;
size?: number;
status?: OrderStatus;
order_type?: OrderType;
customer_id?: string;
start_date?: string;
end_date?: string;
}): Promise<ApiResponse<{ items: OrderResponse[]; total: number; page: number; size: number; pages: number }>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {
@@ -179,11 +70,7 @@ class OrdersService {
} }
// Customer management // Customer management
async getCustomers(params?: { async getCustomers(params?: CustomerFilters): Promise<ApiResponse<{ items: Customer[]; total: number; page: number; size: number; pages: number }>> {
page?: number;
size?: number;
search?: string;
}): Promise<ApiResponse<{ items: Customer[]; total: number; page: number; size: number; pages: number }>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {
@@ -232,16 +119,7 @@ class OrdersService {
return apiClient.get(url); return apiClient.get(url);
} }
async getOrderTrends(params?: { async getOrderTrends(params?: OrderTrendsParams): Promise<ApiResponse<OrderTrendData[]>> {
start_date?: string;
end_date?: string;
granularity?: 'hourly' | 'daily' | 'weekly' | 'monthly';
}): Promise<ApiResponse<Array<{
period: string;
orders: number;
revenue: number;
avg_order_value: number;
}>>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {

View File

@@ -1,126 +1,28 @@
import { apiClient, ApiResponse } from './client'; import { apiClient } from './client';
import { ApiResponse } from '../../types/api.types';
import {
SupplierCreate,
SupplierUpdate,
SupplierResponse,
SupplierSummary,
SupplierSearchParams,
SupplierApproval,
SupplierStatistics,
PurchaseOrderCreate,
PurchaseOrderUpdate,
PurchaseOrderResponse,
PurchaseOrderStatus,
DeliveryCreate,
DeliveryResponse,
DeliveryStatus,
DeliveryReceiptConfirmation,
Supplier
} from '../../types/suppliers.types';
// Enums
export enum PurchaseOrderStatus {
DRAFT = 'draft',
PENDING = 'pending',
APPROVED = 'approved',
SENT = 'sent',
PARTIALLY_RECEIVED = 'partially_received',
RECEIVED = 'received',
CANCELLED = 'cancelled',
}
export enum DeliveryStatus {
SCHEDULED = 'scheduled',
IN_TRANSIT = 'in_transit',
DELIVERED = 'delivered',
FAILED = 'failed',
RETURNED = 'returned',
}
// Request/Response Types
export interface PurchaseOrderItem {
ingredient_id: string;
ingredient_name: string;
quantity: number;
unit_price: number;
total_price: number;
notes?: string;
}
export interface PurchaseOrderCreate {
supplier_id: string;
items: PurchaseOrderItem[];
delivery_date?: string;
notes?: string;
priority?: 'low' | 'normal' | 'high' | 'urgent';
}
export interface PurchaseOrderUpdate {
supplier_id?: string;
delivery_date?: string;
notes?: string;
priority?: 'low' | 'normal' | 'high' | 'urgent';
status?: PurchaseOrderStatus;
}
export interface PurchaseOrderResponse {
id: string;
tenant_id: string;
order_number: string;
supplier_id: string;
supplier_name: string;
status: PurchaseOrderStatus;
items: PurchaseOrderItem[];
subtotal: number;
tax_amount: number;
total_amount: number;
delivery_date?: string;
expected_delivery_date?: string;
actual_delivery_date?: string;
notes?: string;
priority: 'low' | 'normal' | 'high' | 'urgent';
created_at: string;
updated_at: string;
created_by: string;
approved_by?: string;
approved_at?: string;
}
export interface Supplier {
id: string;
tenant_id: string;
name: string;
contact_name?: string;
email?: string;
phone?: string;
address: string;
tax_id?: string;
payment_terms?: string;
delivery_terms?: string;
rating?: number;
is_active: boolean;
performance_metrics: {
on_time_delivery_rate: number;
quality_score: number;
total_orders: number;
average_delivery_time: number;
};
created_at: string;
updated_at: string;
}
export interface DeliveryResponse {
id: string;
tenant_id: string;
purchase_order_id: string;
delivery_number: string;
supplier_id: string;
status: DeliveryStatus;
scheduled_date: string;
actual_delivery_date?: string;
delivery_items: Array<{
ingredient_id: string;
ingredient_name: string;
ordered_quantity: number;
delivered_quantity: number;
unit_price: number;
batch_number?: string;
expiration_date?: string;
quality_notes?: string;
}>;
total_items: number;
delivery_notes?: string;
quality_check_notes?: string;
received_by?: string;
created_at: string;
updated_at: string;
}
class ProcurementService { class ProcurementService {
private readonly baseUrl = '/procurement';
// Purchase Order management // Purchase Order management
async getPurchaseOrders(params?: { async getPurchaseOrders(params?: {
page?: number; page?: number;
@@ -141,90 +43,88 @@ class ProcurementService {
} }
const url = queryParams.toString() const url = queryParams.toString()
? `${this.baseUrl}/purchase-orders?${queryParams.toString()}` ? `/purchase-orders?${queryParams.toString()}`
: `${this.baseUrl}/purchase-orders`; : `/purchase-orders`;
return apiClient.get(url); return apiClient.get(url);
} }
async getPurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrderResponse>> { async getPurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrderResponse>> {
return apiClient.get(`${this.baseUrl}/purchase-orders/${orderId}`); return apiClient.get(`/purchase-orders/${orderId}`);
} }
async createPurchaseOrder(orderData: PurchaseOrderCreate): Promise<ApiResponse<PurchaseOrderResponse>> { async createPurchaseOrder(orderData: PurchaseOrderCreate): Promise<ApiResponse<PurchaseOrderResponse>> {
return apiClient.post(`${this.baseUrl}/purchase-orders`, orderData); return apiClient.post(`/purchase-orders`, orderData);
} }
async updatePurchaseOrder(orderId: string, orderData: PurchaseOrderUpdate): Promise<ApiResponse<PurchaseOrderResponse>> { async updatePurchaseOrder(orderId: string, orderData: PurchaseOrderUpdate): Promise<ApiResponse<PurchaseOrderResponse>> {
return apiClient.put(`${this.baseUrl}/purchase-orders/${orderId}`, orderData); return apiClient.put(`/purchase-orders/${orderId}`, orderData);
} }
async approvePurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrderResponse>> { async approvePurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrderResponse>> {
return apiClient.post(`${this.baseUrl}/purchase-orders/${orderId}/approve`); return apiClient.post(`/purchase-orders/${orderId}/approve`);
} }
async sendPurchaseOrder(orderId: string, sendEmail: boolean = true): Promise<ApiResponse<{ message: string; sent_at: string }>> { async sendPurchaseOrder(orderId: string, sendEmail: boolean = true): Promise<ApiResponse<{ message: string; sent_at: string }>> {
return apiClient.post(`${this.baseUrl}/purchase-orders/${orderId}/send`, { send_email: sendEmail }); return apiClient.post(`/purchase-orders/${orderId}/send`, { send_email: sendEmail });
} }
async cancelPurchaseOrder(orderId: string, reason?: string): Promise<ApiResponse<PurchaseOrderResponse>> { async cancelPurchaseOrder(orderId: string, reason?: string): Promise<ApiResponse<PurchaseOrderResponse>> {
return apiClient.post(`${this.baseUrl}/purchase-orders/${orderId}/cancel`, { reason }); return apiClient.post(`/purchase-orders/${orderId}/cancel`, { reason });
} }
// Supplier management // Supplier management
async getSuppliers(params?: { async getSuppliers(params?: SupplierSearchParams): Promise<ApiResponse<SupplierSummary[]>> {
page?: number;
size?: number;
is_active?: boolean;
search?: string;
}): Promise<ApiResponse<{ items: Supplier[]; total: number; page: number; size: number; pages: number }>> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params) { if (params) {
Object.entries(params).forEach(([key, value]) => { if (params.search_term) queryParams.append('search_term', params.search_term);
if (value !== undefined) { if (params.supplier_type) queryParams.append('supplier_type', params.supplier_type);
queryParams.append(key, value.toString()); if (params.status) queryParams.append('status', params.status);
} if (params.limit) queryParams.append('limit', params.limit.toString());
}); if (params.offset) queryParams.append('offset', params.offset.toString());
} }
const url = queryParams.toString() const url = queryParams.toString()
? `${this.baseUrl}/suppliers?${queryParams.toString()}` ? `/suppliers?${queryParams.toString()}`
: `${this.baseUrl}/suppliers`; : `/suppliers`;
return apiClient.get(url); return apiClient.get(url);
} }
async getSupplier(supplierId: string): Promise<ApiResponse<Supplier>> { async getSupplier(supplierId: string): Promise<ApiResponse<SupplierResponse>> {
return apiClient.get(`${this.baseUrl}/suppliers/${supplierId}`); return apiClient.get(`/suppliers/${supplierId}`);
} }
async createSupplier(supplierData: Omit<Supplier, 'id' | 'tenant_id' | 'performance_metrics' | 'created_at' | 'updated_at'>): Promise<ApiResponse<Supplier>> { async createSupplier(supplierData: SupplierCreate): Promise<ApiResponse<SupplierResponse>> {
return apiClient.post(`${this.baseUrl}/suppliers`, supplierData); return apiClient.post(`/suppliers`, supplierData);
} }
async updateSupplier(supplierId: string, supplierData: Partial<Supplier>): Promise<ApiResponse<Supplier>> { async updateSupplier(supplierId: string, supplierData: SupplierUpdate): Promise<ApiResponse<SupplierResponse>> {
return apiClient.put(`${this.baseUrl}/suppliers/${supplierId}`, supplierData); return apiClient.put(`/suppliers/${supplierId}`, supplierData);
} }
async deleteSupplier(supplierId: string): Promise<ApiResponse<{ message: string }>> { async deleteSupplier(supplierId: string): Promise<ApiResponse<{ message: string }>> {
return apiClient.delete(`${this.baseUrl}/suppliers/${supplierId}`); return apiClient.delete(`/suppliers/${supplierId}`);
} }
async getSupplierPerformance(supplierId: string): Promise<ApiResponse<{ async approveSupplier(supplierId: string, approval: SupplierApproval): Promise<ApiResponse<SupplierResponse>> {
supplier: Supplier; return apiClient.post(`/suppliers/${supplierId}/approve`, approval);
performance_history: Array<{
month: string;
on_time_delivery_rate: number;
quality_score: number;
order_count: number;
total_value: number;
}>;
recent_deliveries: DeliveryResponse[];
}>> {
return apiClient.get(`${this.baseUrl}/suppliers/${supplierId}/performance`);
} }
async getSupplierStatistics(): Promise<ApiResponse<SupplierStatistics>> {
return apiClient.get(`/suppliers/statistics`);
}
async getActiveSuppliers(): Promise<ApiResponse<SupplierSummary[]>> {
return apiClient.get(`/suppliers/active`);
}
async getTopSuppliers(limit: number = 10): Promise<ApiResponse<SupplierSummary[]>> {
return apiClient.get(`/suppliers/top?limit=${limit}`);
}
// Delivery management // Delivery management
async getDeliveries(params?: { async getDeliveries(params?: {
page?: number; page?: number;
@@ -246,131 +146,32 @@ class ProcurementService {
} }
const url = queryParams.toString() const url = queryParams.toString()
? `${this.baseUrl}/deliveries?${queryParams.toString()}` ? `/deliveries?${queryParams.toString()}`
: `${this.baseUrl}/deliveries`; : `/deliveries`;
return apiClient.get(url); return apiClient.get(url);
} }
async getDelivery(deliveryId: string): Promise<ApiResponse<DeliveryResponse>> { async getDelivery(deliveryId: string): Promise<ApiResponse<DeliveryResponse>> {
return apiClient.get(`${this.baseUrl}/deliveries/${deliveryId}`); return apiClient.get(`/deliveries/${deliveryId}`);
} }
async receiveDelivery(deliveryId: string, deliveryData: { async createDelivery(deliveryData: DeliveryCreate): Promise<ApiResponse<DeliveryResponse>> {
delivered_items: Array<{ return apiClient.post(`/deliveries`, deliveryData);
ingredient_id: string;
delivered_quantity: number;
batch_number?: string;
expiration_date?: string;
quality_notes?: string;
}>;
delivery_notes?: string;
quality_check_notes?: string;
}): Promise<ApiResponse<DeliveryResponse>> {
return apiClient.post(`${this.baseUrl}/deliveries/${deliveryId}/receive`, deliveryData);
} }
async reportDeliveryIssue(deliveryId: string, issue: { async updateDelivery(deliveryId: string, deliveryData: Partial<DeliveryCreate>): Promise<ApiResponse<DeliveryResponse>> {
issue_type: 'late_delivery' | 'quality_issue' | 'quantity_mismatch' | 'damaged_goods' | 'other'; return apiClient.put(`/deliveries/${deliveryId}`, deliveryData);
description: string;
affected_items?: string[];
severity: 'low' | 'medium' | 'high';
}): Promise<ApiResponse<{ message: string; issue_id: string }>> {
return apiClient.post(`${this.baseUrl}/deliveries/${deliveryId}/report-issue`, issue);
} }
// Analytics and reporting async updateDeliveryStatus(deliveryId: string, status: DeliveryStatus, notes?: string): Promise<ApiResponse<DeliveryResponse>> {
async getProcurementAnalytics(params?: { return apiClient.put(`/deliveries/${deliveryId}/status`, { status, notes });
start_date?: string;
end_date?: string;
supplier_id?: string;
}): Promise<ApiResponse<{
total_purchase_value: number;
total_orders: number;
average_order_value: number;
on_time_delivery_rate: number;
quality_score: number;
cost_savings: number;
top_suppliers: Array<{
supplier_name: string;
total_value: number;
order_count: number;
performance_score: number;
}>;
spending_trends: Array<{
month: string;
total_spending: number;
order_count: number;
}>;
}>> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
queryParams.append(key, value.toString());
}
});
} }
const url = queryParams.toString() async confirmDeliveryReceipt(deliveryId: string, confirmation: DeliveryReceiptConfirmation): Promise<ApiResponse<DeliveryResponse>> {
? `${this.baseUrl}/analytics?${queryParams.toString()}` return apiClient.post(`/deliveries/${deliveryId}/confirm-receipt`, confirmation);
: `${this.baseUrl}/analytics`;
return apiClient.get(url);
} }
async getSpendingByCategory(params?: {
start_date?: string;
end_date?: string;
}): Promise<ApiResponse<Array<{
category: string;
total_spending: number;
percentage: number;
trend: 'up' | 'down' | 'stable';
}>>> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
queryParams.append(key, value.toString());
}
});
}
const url = queryParams.toString()
? `${this.baseUrl}/spending/by-category?${queryParams.toString()}`
: `${this.baseUrl}/spending/by-category`;
return apiClient.get(url);
}
// Automated procurement
async getReorderSuggestions(): Promise<ApiResponse<Array<{
ingredient_id: string;
ingredient_name: string;
current_stock: number;
reorder_point: number;
suggested_quantity: number;
preferred_supplier: {
id: string;
name: string;
last_price: number;
lead_time_days: number;
};
urgency: 'low' | 'medium' | 'high' | 'critical';
}>>> {
return apiClient.get(`${this.baseUrl}/reorder-suggestions`);
}
async createReorderFromSuggestions(suggestions: Array<{
ingredient_id: string;
supplier_id: string;
quantity: number;
}>): Promise<ApiResponse<PurchaseOrderResponse[]>> {
return apiClient.post(`${this.baseUrl}/auto-reorder`, { suggestions });
}
// Utility methods // Utility methods
getPurchaseOrderStatusOptions(): { value: PurchaseOrderStatus; label: string; color: string }[] { getPurchaseOrderStatusOptions(): { value: PurchaseOrderStatus; label: string; color: string }[] {
@@ -395,14 +196,6 @@ class ProcurementService {
]; ];
} }
getPriorityOptions(): { value: string; label: string; color: string }[] {
return [
{ value: 'low', label: 'Low', color: 'gray' },
{ value: 'normal', label: 'Normal', color: 'blue' },
{ value: 'high', label: 'High', color: 'orange' },
{ value: 'urgent', label: 'Urgent', color: 'red' },
];
}
} }
export const procurementService = new ProcurementService(); export const procurementService = new ProcurementService();

View File

@@ -1,27 +1,21 @@
import { apiClient, ApiResponse } from './client'; import { apiClient, ApiResponse } from './client';
import {
ProductionBatchStatus,
QualityCheckStatus,
ProductionPriority,
ProductionBatch,
ProductionSchedule,
QualityCheck,
Recipe
} from '../../types/production.types';
// Enums // Type aliases for service compatibility
export enum ProductionBatchStatus { type ProductionBatchCreate = Omit<ProductionBatch, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>;
PLANNED = 'planned', type ProductionBatchUpdate = Partial<ProductionBatchCreate>;
IN_PROGRESS = 'in_progress', type ProductionBatchResponse = ProductionBatch;
COMPLETED = 'completed', type ProductionScheduleEntry = ProductionSchedule;
CANCELLED = 'cancelled', type QualityCheckCreate = Omit<QualityCheck, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>;
ON_HOLD = 'on_hold', type QualityCheckResponse = QualityCheck;
}
export enum QualityCheckStatus {
PASSED = 'passed',
FAILED = 'failed',
PENDING = 'pending',
REQUIRES_REVIEW = 'requires_review',
}
export enum ProductionPriority {
LOW = 'low',
NORMAL = 'normal',
HIGH = 'high',
URGENT = 'urgent',
}
// Request/Response Types // Request/Response Types
export interface ProductionBatchCreate { export interface ProductionBatchCreate {

View File

@@ -2,69 +2,76 @@
* Training service for ML model training operations * Training service for ML model training operations
*/ */
import { ApiClient } from './client'; import { apiClient } from './client';
import { ApiResponse } from '../../types/api.types'; import { ApiResponse } from '../../types/api.types';
import {
TrainingJob,
TrainingJobCreate,
TrainingJobUpdate
} from '../../types/training.types';
export interface TrainingJob { export class TrainingService {
id: string; private getTenantId(): string {
model_id: string; const tenantStorage = localStorage.getItem('tenant-storage');
status: 'pending' | 'running' | 'completed' | 'failed'; if (tenantStorage) {
progress: number; try {
started_at?: string; const { state } = JSON.parse(tenantStorage);
completed_at?: string; return state?.currentTenant?.id;
error_message?: string; } catch {
parameters: Record<string, any>; return '';
metrics?: Record<string, number>; }
}
return '';
} }
export interface TrainingJobCreate { private getBaseUrl(): string {
model_id: string; const tenantId = this.getTenantId();
parameters?: Record<string, any>; return `/tenants/${tenantId}/training`;
}
export interface TrainingJobUpdate {
parameters?: Record<string, any>;
}
export class TrainingService extends ApiClient {
constructor() {
super('/ml/training');
} }
async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> { async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> {
const params = modelId ? { model_id: modelId } : {}; const params = modelId ? { model_id: modelId } : {};
return this.get('/', params); const queryParams = new URLSearchParams();
if (params.model_id) {
queryParams.append('model_id', params.model_id);
}
const url = queryParams.toString()
? `${this.getBaseUrl()}/jobs?${queryParams.toString()}`
: `${this.getBaseUrl()}/jobs`;
return apiClient.get(url);
} }
async getTrainingJob(id: string): Promise<ApiResponse<TrainingJob>> { async getTrainingJob(id: string): Promise<ApiResponse<TrainingJob>> {
return this.get(`/${id}`); return apiClient.get(`${this.getBaseUrl()}/jobs/${id}`);
} }
async createTrainingJob(data: TrainingJobCreate): Promise<ApiResponse<TrainingJob>> { async createTrainingJob(data: TrainingJobCreate): Promise<ApiResponse<TrainingJob>> {
return this.post('/', data); return apiClient.post(`${this.getBaseUrl()}/jobs`, data);
} }
async updateTrainingJob(id: string, data: TrainingJobUpdate): Promise<ApiResponse<TrainingJob>> { async updateTrainingJob(id: string, data: TrainingJobUpdate): Promise<ApiResponse<TrainingJob>> {
return this.put(`/${id}`, data); return apiClient.put(`${this.getBaseUrl()}/jobs/${id}`, data);
} }
async deleteTrainingJob(id: string): Promise<ApiResponse<void>> { async deleteTrainingJob(id: string): Promise<ApiResponse<void>> {
return this.delete(`/${id}`); return apiClient.delete(`${this.getBaseUrl()}/jobs/${id}`);
} }
async startTraining(id: string): Promise<ApiResponse<TrainingJob>> { async startTraining(id: string): Promise<ApiResponse<TrainingJob>> {
return this.post(`/${id}/start`); return apiClient.post(`${this.getBaseUrl()}/jobs/${id}/start`);
} }
async stopTraining(id: string): Promise<ApiResponse<TrainingJob>> { async stopTraining(id: string): Promise<ApiResponse<TrainingJob>> {
return this.post(`/${id}/stop`); return apiClient.post(`${this.getBaseUrl()}/jobs/${id}/stop`);
} }
async getTrainingLogs(id: string): Promise<ApiResponse<string[]>> { async getTrainingLogs(id: string): Promise<ApiResponse<string[]>> {
return this.get(`/${id}/logs`); return apiClient.get(`${this.getBaseUrl()}/jobs/${id}/logs`);
} }
async getTrainingMetrics(id: string): Promise<ApiResponse<Record<string, number>>> { async getTrainingMetrics(id: string): Promise<ApiResponse<Record<string, number>>> {
return this.get(`/${id}/metrics`); return apiClient.get(`${this.getBaseUrl()}/jobs/${id}/metrics`);
} }
} }
export const trainingService = new TrainingService();

View File

@@ -75,7 +75,8 @@ export const useTenantStore = create<TenantState>()(
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
// Get current user to determine user ID // Get current user to determine user ID
const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user; const authState = JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state;
const user = authState?.user;
if (!user?.id) { if (!user?.id) {
throw new Error('User not authenticated'); throw new Error('User not authenticated');
@@ -130,7 +131,8 @@ export const useTenantStore = create<TenantState>()(
if (!currentTenant) return false; if (!currentTenant) return false;
// Get user to determine role within this tenant // Get user to determine role within this tenant
const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user; const authState = JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state;
const user = authState?.user;
// Admin role has all permissions // Admin role has all permissions
if (user?.role === 'admin') return true; if (user?.role === 'admin') return true;

View File

@@ -1,40 +1,45 @@
// API and common response types /**
* API Response Types - Matching actual backend implementation
*/
// Base API response structure // Standard FastAPI response structure
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
data: T; data?: T;
success: boolean; success?: boolean;
message?: string; message?: string;
detail?: string;
error?: string; error?: string;
timestamp?: string;
} }
// Error response structure // FastAPI error response structure
export interface ApiError { export interface ApiError {
success: false; detail: string | ValidationError[];
error: ErrorDetail; type?: string;
timestamp: string;
request_id?: string;
} }
export interface ErrorDetail { export interface ValidationError {
message: string; loc: (string | number)[];
code?: string; msg: string;
field?: string; type: string;
details?: Record<string, any>; ctx?: Record<string, any>;
} }
// Pagination types // Pagination types (used by backend services)
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
items: T[]; items?: T[];
total: number; records?: T[]; // Some endpoints use 'records'
page: number; data?: T[]; // Some endpoints use 'data'
size: number; total?: number;
pages: number; page?: number;
has_next: boolean; size?: number;
has_prev: boolean; limit?: number;
offset?: number;
pages?: number;
has_next?: boolean;
has_prev?: boolean;
} }
// Query parameters for API requests
export interface PaginationParams { export interface PaginationParams {
page?: number; page?: number;
size?: number; size?: number;
@@ -42,368 +47,33 @@ export interface PaginationParams {
offset?: number; offset?: number;
} }
// Sorting and filtering
export interface SortParams { export interface SortParams {
sort_by?: string; sort_by?: string;
sort_order?: SortOrder;
order_by?: string; order_by?: string;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
} }
export interface FilterParams { export interface FilterParams {
search?: string; search?: string;
search_term?: string;
q?: string; q?: string;
filters?: Record<string, any>;
[key: string]: any; [key: string]: any;
} }
export interface QueryParams extends PaginationParams, SortParams, FilterParams {} export interface QueryParams extends PaginationParams, SortParams, FilterParams {}
// File upload types // Task/Job status (used in ML training and other async operations)
export interface FileUploadResponse {
file_id: string;
filename: string;
size: number;
mime_type: string;
url?: string;
download_url?: string;
expires_at?: string;
}
export interface FileUploadProgress {
loaded: number;
total: number;
percentage: number;
speed?: number;
estimated_time?: number;
}
export interface BulkOperationResponse {
total: number;
processed: number;
successful: number;
failed: number;
errors: BulkOperationError[];
warnings?: BulkOperationWarning[];
}
export interface BulkOperationError {
index: number;
item_id?: string;
error_code: string;
message: string;
field?: string;
details?: Record<string, any>;
}
export interface BulkOperationWarning {
index: number;
item_id?: string;
warning_code: string;
message: string;
suggestion?: string;
}
// Task/Job status types
export interface TaskStatus { export interface TaskStatus {
id: string;
task_id: string; task_id: string;
status: TaskStatusType; status: TaskStatusType;
progress: number; progress?: number;
message: string; message?: string;
result?: any; result?: any;
error?: string; error?: string;
created_at: string; created_at: string;
started_at?: string; started_at?: string;
completed_at?: string; completed_at?: string;
estimated_completion?: string;
}
export interface LongRunningTask {
id: string;
name: string;
type: TaskType;
status: TaskStatusType;
progress: number;
total_steps?: number;
current_step?: number;
step_description?: string;
result_data?: any;
error_details?: TaskError;
created_by: string;
created_at: string;
updated_at: string;
}
export interface TaskError {
code: string;
message: string;
stack_trace?: string;
context?: Record<string, any>;
}
// Health check types
export interface HealthCheckResponse {
status: HealthStatus;
timestamp: string;
version: string;
uptime: number;
services: ServiceHealth[];
system_info?: SystemInfo;
}
export interface ServiceHealth {
name: string;
status: HealthStatus;
response_time_ms?: number;
error_message?: string;
dependencies?: ServiceHealth[];
}
export interface SystemInfo {
memory_usage: MemoryUsage;
cpu_usage?: number;
disk_usage?: DiskUsage;
network_stats?: NetworkStats;
}
export interface MemoryUsage {
used: number;
available: number;
total: number;
percentage: number;
}
export interface DiskUsage {
used: number;
available: number;
total: number;
percentage: number;
}
export interface NetworkStats {
bytes_sent: number;
bytes_received: number;
packets_sent: number;
packets_received: number;
}
// Audit and logging types
export interface AuditLog {
id: string;
user_id: string;
user_email: string;
tenant_id: string;
action: AuditAction;
resource_type: ResourceType;
resource_id: string;
changes?: AuditChange[];
metadata?: Record<string, any>;
ip_address: string;
user_agent: string;
timestamp: string;
}
export interface AuditChange {
field: string;
old_value?: any;
new_value?: any;
change_type: ChangeType;
}
// Cache types
export interface CacheInfo {
key: string;
size: number;
hits: number;
misses: number;
hit_rate: number;
created_at: string;
expires_at?: string;
last_accessed: string;
}
export interface CacheStats {
total_keys: number;
total_size: number;
hit_rate: number;
memory_usage: number;
cache_by_type: Record<string, CacheTypeStats>;
}
export interface CacheTypeStats {
count: number;
size: number;
hit_rate: number;
avg_ttl: number;
}
// Webhook types
export interface WebhookConfig {
id: string;
tenant_id: string;
name: string;
url: string;
events: WebhookEvent[];
secret?: string;
is_active: boolean;
retry_count: number;
timeout_seconds: number;
headers?: Record<string, string>;
created_at: string;
updated_at: string;
}
export interface WebhookDelivery {
id: string;
webhook_id: string;
event_type: WebhookEvent;
payload: Record<string, any>;
status: DeliveryStatus;
http_status?: number;
response_body?: string;
error_message?: string;
attempts: number;
delivered_at?: string;
created_at: string;
next_retry_at?: string;
}
// Rate limiting types
export interface RateLimit {
requests_per_minute: number;
requests_per_hour: number;
requests_per_day: number;
current_usage: RateLimitUsage;
reset_time: string;
}
export interface RateLimitUsage {
minute: number;
hour: number;
day: number;
percentage_used: number;
}
// Search types
export interface SearchRequest {
query: string;
filters?: SearchFilter[];
sort?: SearchSort[];
facets?: string[];
highlight?: SearchHighlight;
size?: number;
from?: number;
}
export interface SearchFilter {
field: string;
operator: FilterOperator;
value: any;
values?: any[];
}
export interface SearchSort {
field: string;
order: SortOrder;
}
export interface SearchHighlight {
fields: string[];
pre_tag?: string;
post_tag?: string;
}
export interface SearchResponse<T> {
results: T[];
total: number;
facets?: SearchFacet[];
highlights?: Record<string, string[]>;
suggestions?: SearchSuggestion[];
took_ms: number;
}
export interface SearchFacet {
field: string;
values: FacetValue[];
}
export interface FacetValue {
value: any;
count: number;
selected: boolean;
}
export interface SearchSuggestion {
text: string;
highlighted: string;
score: number;
}
// Export/Import types
export interface ExportRequest {
format: ExportFormat;
filters?: Record<string, any>;
fields?: string[];
include_metadata?: boolean;
compression?: CompressionType;
}
export interface ExportResponse {
export_id: string;
status: ExportStatus;
download_url?: string;
file_size?: number;
expires_at?: string;
created_at: string;
}
export interface ImportRequest {
file_url?: string;
file_data?: string;
format: ImportFormat;
options?: ImportOptions;
mapping?: Record<string, string>;
}
export interface ImportOptions {
skip_validation?: boolean;
update_existing?: boolean;
batch_size?: number;
dry_run?: boolean;
}
export interface ImportResponse {
import_id: string;
status: ImportStatus;
total_records?: number;
processed_records?: number;
successful_records?: number;
failed_records?: number;
errors?: ImportError[];
warnings?: ImportWarning[];
created_at: string;
}
export interface ImportError {
row: number;
field?: string;
value?: any;
error_code: string;
message: string;
}
export interface ImportWarning {
row: number;
field?: string;
value?: any;
warning_code: string;
message: string;
}
// Enums
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
} }
export enum TaskStatusType { export enum TaskStatusType {
@@ -411,153 +81,69 @@ export enum TaskStatusType {
RUNNING = 'running', RUNNING = 'running',
COMPLETED = 'completed', COMPLETED = 'completed',
FAILED = 'failed', FAILED = 'failed',
CANCELLED = 'cancelled', CANCELLED = 'cancelled'
PAUSED = 'paused',
} }
export enum TaskType { // Health check types (used by monitoring endpoints)
DATA_IMPORT = 'data_import', export interface HealthCheckResponse {
DATA_EXPORT = 'data_export', status: 'healthy' | 'unhealthy' | 'degraded';
REPORT_GENERATION = 'report_generation', service: string;
MODEL_TRAINING = 'model_training', version: string;
DATA_PROCESSING = 'data_processing', timestamp: string;
BULK_OPERATION = 'bulk_operation', dependencies?: ServiceHealth[];
SYNC_OPERATION = 'sync_operation',
} }
export enum HealthStatus { export interface ServiceHealth {
HEALTHY = 'healthy', name: string;
DEGRADED = 'degraded', status: 'healthy' | 'unhealthy' | 'degraded';
UNHEALTHY = 'unhealthy', response_time?: number;
UNKNOWN = 'unknown', error?: string;
} }
export enum AuditAction { // File upload types
CREATE = 'create', export interface FileUploadResponse {
READ = 'read', file_id: string;
UPDATE = 'update', filename: string;
DELETE = 'delete', size: number;
LOGIN = 'login', content_type: string;
LOGOUT = 'logout', url?: string;
EXPORT = 'export',
IMPORT = 'import',
} }
export enum ResourceType { // Bulk operation response
USER = 'user', export interface BulkOperationResponse {
TENANT = 'tenant', total: number;
INGREDIENT = 'ingredient', processed: number;
STOCK = 'stock', successful: number;
PRODUCTION_BATCH = 'production_batch', failed: number;
SALES_RECORD = 'sales_record', errors?: BulkOperationError[];
FORECAST = 'forecast',
ORDER = 'order',
SUPPLIER = 'supplier',
RECIPE = 'recipe',
NOTIFICATION = 'notification',
} }
export enum ChangeType { export interface BulkOperationError {
CREATED = 'created', index: number;
UPDATED = 'updated', error: string;
DELETED = 'deleted', details?: any;
ARCHIVED = 'archived',
RESTORED = 'restored',
} }
export enum WebhookEvent { // Common enums
USER_CREATED = 'user.created', export enum SortOrder {
USER_UPDATED = 'user.updated', ASC = 'asc',
USER_DELETED = 'user.deleted', DESC = 'desc'
INVENTORY_LOW_STOCK = 'inventory.low_stock',
PRODUCTION_COMPLETED = 'production.completed',
ORDER_CREATED = 'order.created',
ORDER_UPDATED = 'order.updated',
FORECAST_GENERATED = 'forecast.generated',
ALERT_TRIGGERED = 'alert.triggered',
} }
export enum DeliveryStatus { // HTTP methods
PENDING = 'pending',
DELIVERED = 'delivered',
FAILED = 'failed',
RETRYING = 'retrying',
CANCELLED = 'cancelled',
}
export enum FilterOperator {
EQUALS = 'eq',
NOT_EQUALS = 'ne',
GREATER_THAN = 'gt',
GREATER_THAN_OR_EQUAL = 'gte',
LESS_THAN = 'lt',
LESS_THAN_OR_EQUAL = 'lte',
CONTAINS = 'contains',
STARTS_WITH = 'starts_with',
ENDS_WITH = 'ends_with',
IN = 'in',
NOT_IN = 'not_in',
IS_NULL = 'is_null',
IS_NOT_NULL = 'is_not_null',
BETWEEN = 'between',
}
export enum ExportFormat {
CSV = 'csv',
EXCEL = 'excel',
JSON = 'json',
PDF = 'pdf',
XML = 'xml',
}
export enum ImportFormat {
CSV = 'csv',
EXCEL = 'excel',
JSON = 'json',
XML = 'xml',
}
export enum CompressionType {
NONE = 'none',
ZIP = 'zip',
GZIP = 'gzip',
}
export enum ExportStatus {
PENDING = 'pending',
PROCESSING = 'processing',
COMPLETED = 'completed',
FAILED = 'failed',
EXPIRED = 'expired',
}
export enum ImportStatus {
PENDING = 'pending',
PROCESSING = 'processing',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled',
PARTIALLY_COMPLETED = 'partially_completed',
}
// Utility types for HTTP methods
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
// Generic type for API endpoints
export type ApiEndpoint<TRequest = any, TResponse = any> = {
method: HttpMethod;
path: string;
request?: TRequest;
response: TResponse;
};
// Type guards // Type guards
export const isApiError = (obj: any): obj is ApiError => { export const isApiError = (obj: any): obj is ApiError => {
return obj && obj.success === false && obj.error && typeof obj.error.message === 'string'; return obj && typeof obj.detail === 'string';
}; };
export const isPaginatedResponse = <T>(obj: any): obj is PaginatedResponse<T> => { export const isPaginatedResponse = <T>(obj: any): obj is PaginatedResponse<T> => {
return obj && Array.isArray(obj.items) && typeof obj.total === 'number'; return obj && (
Array.isArray(obj.items) ||
Array.isArray(obj.records) ||
Array.isArray(obj.data)
);
}; };
export const isTaskStatus = (obj: any): obj is TaskStatus => { export const isTaskStatus = (obj: any): obj is TaskStatus => {

View File

@@ -1,39 +1,23 @@
// Authentication related types - Updated to match backend exactly /**
export interface User { * Authentication Types - Matching backend schemas exactly
id: string; * Based on services/auth/app/schemas/auth.py
email: string; */
full_name: string; // Backend uses full_name, not name
is_active: boolean; // ============================================================================
is_verified: boolean; // REQUEST TYPES (Frontend -> Backend)
created_at: string; // ISO format datetime string // ============================================================================
last_login?: string;
phone?: string;
language?: string;
timezone?: string;
tenant_id?: string;
role?: string; // Backend uses string, not enum
}
export interface UserRegistration { export interface UserRegistration {
email: string; email: string;
password: string; password: string;
full_name: string; full_name: string;
tenant_name?: string; // Optional in backend tenant_name?: string;
role?: string; // Backend uses string, defaults to "user" role?: 'user' | 'admin' | 'manager';
} }
export interface UserLogin { export interface UserLogin {
email: string; email: string;
password: string; password: string;
remember_me?: boolean;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string; // defaults to "bearer"
expires_in: number; // seconds, defaults to 3600
user?: User;
} }
export interface RefreshTokenRequest { export interface RefreshTokenRequest {
@@ -54,6 +38,64 @@ export interface PasswordResetConfirm {
new_password: string; new_password: string;
} }
// ============================================================================
// RESPONSE TYPES (Backend -> Frontend)
// ============================================================================
export interface UserData {
id: string;
email: string;
full_name: string;
is_active: boolean;
is_verified: boolean;
created_at: string;
tenant_id?: string;
role?: string;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string; // defaults to "bearer"
expires_in: number; // seconds, defaults to 3600
user?: UserData;
}
// ============================================================================
// JWT TOKEN CLAIMS (Internal)
// ============================================================================
export interface TokenClaims {
sub: string; // user ID
email: string;
full_name: string;
user_id: string;
is_verified: boolean;
tenant_id?: string;
role?: string;
type: 'access' | 'refresh';
iat: number; // issued at
exp: number; // expires at
iss: string; // issuer
}
// ============================================================================
// FRONTEND STATE TYPES
// ============================================================================
export interface AuthState {
isAuthenticated: boolean;
user: UserData | null;
token: string | null;
refreshToken: string | null;
loading: boolean;
error: string | null;
}
// ============================================================================
// AUTH SERVICE SPECIFIC TYPES
// ============================================================================
export interface TokenVerification { export interface TokenVerification {
valid: boolean; valid: boolean;
user_id?: string; user_id?: string;
@@ -62,27 +104,27 @@ export interface TokenVerification {
message?: string; message?: string;
} }
export interface UserResponse extends UserData {
last_login?: string;
phone?: string;
language?: string;
timezone?: string;
}
export interface UserUpdate { export interface UserUpdate {
full_name?: string; full_name?: string;
phone?: string; phone?: string;
language?: string; language?: string;
timezone?: string; timezone?: string;
avatar_url?: string;
} }
export interface AuthState { // ============================================================================
isAuthenticated: boolean; // FORM DATA TYPES (Frontend UI)
user: User | null; // ============================================================================
token: string | null;
refreshToken: string | null;
loading: boolean;
error: string | null;
}
export interface LoginFormData { export interface LoginFormData {
email: string; email: string;
password: string; password: string;
remember_me: boolean;
} }
export interface RegisterFormData { export interface RegisterFormData {
@@ -90,17 +132,7 @@ export interface RegisterFormData {
password: string; password: string;
confirmPassword: string; confirmPassword: string;
full_name: string; full_name: string;
tenant_name?: string; // Optional to match backend tenant_name?: string;
phone?: string;
acceptTerms: boolean;
}
export interface ProfileFormData {
full_name: string;
email: string;
phone?: string;
language: string;
timezone: string;
} }
export interface PasswordChangeFormData { export interface PasswordChangeFormData {
@@ -109,142 +141,40 @@ export interface PasswordChangeFormData {
confirm_password: string; confirm_password: string;
} }
export interface EmailVerificationRequest { // ============================================================================
email: string; // UTILITY TYPES
} // ============================================================================
export interface EmailVerificationConfirm {
token: string;
}
export interface UserContext {
user_id: string;
email: string;
tenant_id?: string;
roles: string[];
is_verified: boolean;
permissions: string[];
}
export interface TokenClaims {
sub: string;
email: string;
full_name: string;
user_id: string;
is_verified: boolean;
tenant_id?: string;
iat: number;
exp: number;
iss: string;
roles?: string[];
permissions?: string[];
}
export interface AuthError { export interface AuthError {
code: string; detail: string;
message: string; type?: string;
field?: string;
} }
export interface MFASetup { // Keep User as alias for UserData for backward compatibility
enabled: boolean; export interface User extends UserData {}
secret?: string;
backup_codes?: string[];
qr_code?: string;
}
export interface MFAVerification { // ============================================================================
token: string; // ENUMS
backup_code?: string; // ============================================================================
}
export interface SessionInfo {
id: string;
user_id: string;
ip_address: string;
user_agent: string;
created_at: string;
last_activity: string;
is_current: boolean;
location?: string;
device_type?: string;
}
export interface OAuthProvider {
name: string;
display_name: string;
icon: string;
color: string;
enabled: boolean;
}
// Enums - Simplified to match backend
export enum UserRole { export enum UserRole {
USER = 'user', USER = 'user',
ADMIN = 'admin', ADMIN = 'admin',
MANAGER = 'manager', MANAGER = 'manager'
} }
export enum AuthProvider { // ============================================================================
EMAIL = 'email', // TYPE GUARDS
GOOGLE = 'google', // ============================================================================
MICROSOFT = 'microsoft',
APPLE = 'apple',
}
export enum Permission { export const isUser = (obj: any): obj is UserData => {
// User management
USER_READ = 'user:read',
USER_WRITE = 'user:write',
USER_DELETE = 'user:delete',
// Inventory
INVENTORY_READ = 'inventory:read',
INVENTORY_WRITE = 'inventory:write',
INVENTORY_DELETE = 'inventory:delete',
// Production
PRODUCTION_READ = 'production:read',
PRODUCTION_WRITE = 'production:write',
PRODUCTION_DELETE = 'production:delete',
// Sales
SALES_READ = 'sales:read',
SALES_WRITE = 'sales:write',
SALES_DELETE = 'sales:delete',
// Forecasting
FORECASTING_READ = 'forecasting:read',
FORECASTING_WRITE = 'forecasting:write',
FORECASTING_DELETE = 'forecasting:delete',
// Orders
ORDERS_READ = 'orders:read',
ORDERS_WRITE = 'orders:write',
ORDERS_DELETE = 'orders:delete',
// Procurement
PROCUREMENT_READ = 'procurement:read',
PROCUREMENT_WRITE = 'procurement:write',
PROCUREMENT_DELETE = 'procurement:delete',
// Settings
SETTINGS_READ = 'settings:read',
SETTINGS_WRITE = 'settings:write',
// Analytics
ANALYTICS_READ = 'analytics:read',
ANALYTICS_EXPORT = 'analytics:export',
// Admin
ADMIN_ALL = 'admin:all',
}
// Type guards
export const isUser = (obj: any): obj is User => {
return obj && typeof obj.id === 'string' && typeof obj.email === 'string'; return obj && typeof obj.id === 'string' && typeof obj.email === 'string';
}; };
export const isTokenResponse = (obj: any): obj is TokenResponse => { export const isTokenResponse = (obj: any): obj is TokenResponse => {
return obj && typeof obj.access_token === 'string' && typeof obj.token_type === 'string'; return obj && typeof obj.access_token === 'string' && typeof obj.token_type === 'string';
}; };
export const isAuthError = (obj: any): obj is AuthError => {
return obj && typeof obj.detail === 'string';
};

View File

@@ -0,0 +1,258 @@
/**
* External Data Service Types
* Weather, Traffic, and Events data
*/
// ============================================================================
// WEATHER TYPES
// ============================================================================
export interface WeatherData {
id: string;
tenant_id: string;
location_id: string;
date: string;
temperature_avg: number;
temperature_min: number;
temperature_max: number;
humidity: number;
precipitation: number;
wind_speed: number;
condition: string;
description: string;
created_at: string;
}
export interface WeatherCondition {
value: string;
label: string;
impact: 'positive' | 'negative' | 'neutral';
}
// ============================================================================
// TRAFFIC TYPES
// ============================================================================
export interface TrafficData {
id: string;
tenant_id: string;
location_id: string;
date: string;
hour: number;
traffic_level: number;
congestion_index: number;
average_speed: number;
incident_count: number;
created_at: string;
}
export interface TrafficPattern {
period: string;
average_traffic_level: number;
peak_hours: number[];
congestion_patterns: Record<string, number>;
}
// ============================================================================
// EVENTS TYPES
// ============================================================================
export interface EventData {
id: string;
tenant_id: string;
location_id: string;
event_name: string;
event_type: string;
start_date: string;
end_date: string;
expected_attendance?: number;
impact_radius_km?: number;
impact_score: number;
created_at: string;
}
export interface EventType {
value: string;
label: string;
typical_impact: 'positive' | 'negative' | 'neutral';
}
export interface CustomEventCreate {
location_id: string;
event_name: string;
event_type: string;
start_date: string;
end_date: string;
expected_attendance?: number;
impact_radius_km?: number;
impact_score?: number;
}
// ============================================================================
// LOCATION TYPES
// ============================================================================
export interface LocationConfig {
id: string;
tenant_id: string;
name: string;
latitude: number;
longitude: number;
address: string;
city: string;
country: string;
is_primary: boolean;
data_sources: {
weather_enabled: boolean;
traffic_enabled: boolean;
events_enabled: boolean;
};
created_at: string;
updated_at: string;
}
export interface LocationCreate {
name: string;
latitude: number;
longitude: number;
address: string;
city: string;
country?: string;
is_primary?: boolean;
data_sources?: LocationConfig['data_sources'];
}
// ============================================================================
// ANALYTICS TYPES
// ============================================================================
export interface ExternalFactorsImpact {
weather_impact: {
temperature_correlation: number;
precipitation_impact: number;
most_favorable_conditions: string;
};
traffic_impact: {
congestion_correlation: number;
peak_traffic_effect: number;
optimal_traffic_levels: number[];
};
events_impact: {
positive_events: EventData[];
negative_events: EventData[];
average_event_boost: number;
};
}
export interface DataQualityReport {
overall_score: number;
data_sources: Array<{
source: 'weather' | 'traffic' | 'events';
completeness: number;
freshness_hours: number;
reliability_score: number;
last_update: string;
}>;
recommendations: Array<{
priority: 'high' | 'medium' | 'low';
message: string;
action: string;
}>;
}
// ============================================================================
// CONFIGURATION TYPES
// ============================================================================
export interface DataSettings {
auto_refresh_enabled: boolean;
refresh_intervals: {
weather_minutes: number;
traffic_minutes: number;
events_hours: number;
};
data_retention_days: {
weather: number;
traffic: number;
events: number;
};
external_apis: {
weather_provider: string;
traffic_provider: string;
events_provider: string;
};
}
export interface DataSettingsUpdate {
auto_refresh_enabled?: boolean;
refresh_intervals?: {
weather_minutes?: number;
traffic_minutes?: number;
events_hours?: number;
};
data_retention_days?: {
weather?: number;
traffic?: number;
events?: number;
};
}
export interface RefreshInterval {
value: number;
label: string;
suitable_for: string[];
}
// ============================================================================
// QUERY PARAMETER TYPES
// ============================================================================
export interface WeatherDataParams {
location_id?: string;
start_date?: string;
end_date?: string;
page?: number;
size?: number;
}
export interface TrafficDataParams {
location_id?: string;
start_date?: string;
end_date?: string;
hour?: number;
page?: number;
size?: number;
}
export interface TrafficPatternsParams {
days_back?: number;
granularity?: 'hourly' | 'daily';
}
export interface EventsParams {
location_id?: string;
start_date?: string;
end_date?: string;
event_type?: string;
page?: number;
size?: number;
}
export interface ExternalFactorsParams {
location_id?: string;
start_date?: string;
end_date?: string;
}
// ============================================================================
// RESPONSE TYPES
// ============================================================================
export interface RefreshDataResponse {
message: string;
updated_records: number;
}
export interface DeleteResponse {
message: string;
}

View File

@@ -81,21 +81,85 @@ export type {
DataPreprocessing DataPreprocessing
} from './forecasting.types'; } from './forecasting.types';
// Suppliers types
export type {
SupplierCreate,
SupplierUpdate,
SupplierResponse,
SupplierSummary,
SupplierSearchParams,
SupplierApproval,
SupplierStatistics,
PurchaseOrderItemCreate,
PurchaseOrderItemResponse,
PurchaseOrderCreate,
PurchaseOrderUpdate,
PurchaseOrderResponse,
DeliveryItemCreate,
DeliveryItemResponse,
DeliveryCreate,
DeliveryResponse,
DeliveryReceiptConfirmation,
PurchaseOrderStatus,
DeliveryStatus,
Supplier
} from './suppliers.types';
// Data types
export type {
WeatherData,
WeatherDataParams,
WeatherCondition,
TrafficData,
TrafficDataParams,
TrafficPattern,
TrafficPatternsParams,
EventData,
EventsParams,
EventType,
CustomEventCreate,
LocationConfig,
LocationCreate,
ExternalFactorsImpact,
ExternalFactorsParams,
DataQualityReport,
DataSettings,
DataSettingsUpdate,
RefreshDataResponse,
DeleteResponse,
RefreshInterval
} from './data.types';
// API and common types // API and common types
export type { export type {
ApiResponse, ApiResponse,
ApiError, ApiError,
ValidationError,
PaginatedResponse,
PaginationParams,
SortParams,
FilterParams,
QueryParams,
TaskStatus, TaskStatus,
HealthCheckResponse TaskStatusType,
HealthCheckResponse,
ServiceHealth,
FileUploadResponse,
BulkOperationResponse,
SortOrder,
HttpMethod
} from './api.types'; } from './api.types';
// Re-export commonly used types for convenience // Re-export commonly used types for convenience
export type { export type {
User, User,
UserData,
UserLogin, UserLogin,
UserRegistration, UserRegistration,
TokenResponse, TokenResponse,
AuthState, AuthState,
AuthError,
UserRole
} from './auth.types'; } from './auth.types';
export type { export type {
@@ -135,6 +199,16 @@ export type {
BatchStatus, BatchStatus,
} from './forecasting.types'; } from './forecasting.types';
export type {
SupplierResponse,
SupplierSummary,
SupplierCreate,
PurchaseOrderResponse,
DeliveryResponse,
PurchaseOrderStatus,
DeliveryStatus,
} from './suppliers.types';
export type { export type {
ApiResponse, ApiResponse,
ApiError, ApiError,

View File

@@ -0,0 +1,166 @@
// Orders service types
export enum OrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
IN_PREPARATION = 'in_preparation',
READY = 'ready',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
}
export enum OrderType {
DINE_IN = 'dine_in',
TAKEAWAY = 'takeaway',
DELIVERY = 'delivery',
CATERING = 'catering',
}
export interface OrderItem {
product_id?: string;
product_name: string;
quantity: number;
unit_price: number;
total_price: number;
notes?: string;
customizations?: Record<string, any>;
}
export interface OrderCreate {
customer_id?: string;
customer_name: string;
customer_email?: string;
customer_phone?: string;
order_type: OrderType;
items: OrderItem[];
special_instructions?: string;
delivery_address?: string;
delivery_date?: string;
delivery_time?: string;
payment_method?: string;
}
export interface OrderUpdate {
status?: OrderStatus;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
special_instructions?: string;
delivery_address?: string;
delivery_date?: string;
delivery_time?: string;
estimated_completion_time?: string;
actual_completion_time?: string;
}
export interface OrderResponse {
id: string;
tenant_id: string;
order_number: string;
customer_id?: string;
customer_name: string;
customer_email?: string;
customer_phone?: string;
order_type: OrderType;
status: OrderStatus;
items: OrderItem[];
subtotal: number;
tax_amount: number;
discount_amount: number;
total_amount: number;
special_instructions?: string;
delivery_address?: string;
delivery_date?: string;
delivery_time?: string;
estimated_completion_time?: string;
actual_completion_time?: string;
payment_method?: string;
payment_status?: string;
created_at: string;
updated_at: string;
created_by?: string;
}
export interface Customer {
id: string;
tenant_id: string;
name: string;
email?: string;
phone?: string;
address?: string;
preferences?: Record<string, any>;
total_orders: number;
total_spent: number;
created_at: string;
updated_at: string;
}
export interface OrderAnalytics {
total_orders: number;
total_revenue: number;
average_order_value: number;
order_completion_rate: number;
delivery_success_rate: number;
customer_satisfaction_score?: number;
popular_products: Array<{
product_name: string;
quantity_sold: number;
revenue: number;
}>;
order_trends: Array<{
date: string;
orders: number;
revenue: number;
}>;
}
// Form data interfaces
export interface OrderFormData extends OrderCreate {}
export interface CustomerFormData {
name: string;
email?: string;
phone?: string;
address?: string;
preferences?: Record<string, any>;
}
// Filter interfaces
export interface OrderFilters {
page?: number;
size?: number;
status?: OrderStatus;
order_type?: OrderType;
customer_id?: string;
start_date?: string;
end_date?: string;
}
export interface CustomerFilters {
page?: number;
size?: number;
search?: string;
}
// Analytics interfaces
export interface OrderTrendsParams {
start_date?: string;
end_date?: string;
granularity?: 'hourly' | 'daily' | 'weekly' | 'monthly';
}
export interface OrderTrendData {
period: string;
orders: number;
revenue: number;
avg_order_value: number;
}
// Type guards
export const isOrderResponse = (obj: any): obj is OrderResponse => {
return obj && typeof obj.id === 'string' && typeof obj.order_number === 'string';
};
export const isCustomer = (obj: any): obj is Customer => {
return obj && typeof obj.id === 'string' && typeof obj.name === 'string';
};

View File

@@ -0,0 +1,379 @@
/**
* Type definitions for Suppliers Service API
* Based on backend schemas from services/suppliers/app/schemas/suppliers.py
*/
// ============================================================================
// ENUMS
// ============================================================================
export enum PurchaseOrderStatus {
DRAFT = 'draft',
PENDING = 'pending',
APPROVED = 'approved',
SENT = 'sent',
PARTIALLY_RECEIVED = 'partially_received',
RECEIVED = 'received',
CANCELLED = 'cancelled',
}
export enum DeliveryStatus {
SCHEDULED = 'scheduled',
IN_TRANSIT = 'in_transit',
DELIVERED = 'delivered',
FAILED = 'failed',
RETURNED = 'returned',
}
// ============================================================================
// SUPPLIER TYPES
// ============================================================================
export interface SupplierCreate {
name: string;
supplier_code?: string;
tax_id?: string;
registration_number?: string;
supplier_type: string;
contact_person?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
address_line1?: string;
address_line2?: string;
city?: string;
state_province?: string;
postal_code?: string;
country?: string;
payment_terms?: string;
credit_limit?: number;
currency?: string;
standard_lead_time?: number;
minimum_order_amount?: number;
delivery_area?: string;
notes?: string;
certifications?: any;
business_hours?: any;
specializations?: any;
}
export interface SupplierUpdate {
name?: string;
supplier_code?: string;
tax_id?: string;
registration_number?: string;
supplier_type?: string;
status?: string;
contact_person?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
address_line1?: string;
address_line2?: string;
city?: string;
state_province?: string;
postal_code?: string;
country?: string;
payment_terms?: string;
credit_limit?: number;
currency?: string;
standard_lead_time?: number;
minimum_order_amount?: number;
delivery_area?: string;
notes?: string;
certifications?: any;
business_hours?: any;
specializations?: any;
}
export interface SupplierResponse {
id: string;
tenant_id: string;
name: string;
supplier_code?: string;
tax_id?: string;
registration_number?: string;
supplier_type: string;
status: string;
contact_person?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
address_line1?: string;
address_line2?: string;
city?: string;
state_province?: string;
postal_code?: string;
country?: string;
payment_terms: string;
credit_limit?: number;
currency: string;
standard_lead_time: number;
minimum_order_amount?: number;
delivery_area?: string;
quality_rating?: number;
delivery_rating?: number;
total_orders: number;
total_amount: number;
approved_by?: string;
approved_at?: string;
rejection_reason?: string;
notes?: string;
certifications?: any;
business_hours?: any;
specializations?: any;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string;
}
export interface SupplierSummary {
id: string;
name: string;
supplier_code?: string;
supplier_type: string;
status: string;
contact_person?: string;
email?: string;
phone?: string;
city?: string;
country?: string;
quality_rating?: number;
delivery_rating?: number;
total_orders: number;
total_amount: number;
created_at: string;
}
export interface SupplierSearchParams {
search_term?: string;
supplier_type?: string;
status?: string;
limit?: number;
offset?: number;
}
export interface SupplierApproval {
action: 'approve' | 'reject';
notes?: string;
}
export interface SupplierStatistics {
total_suppliers: number;
active_suppliers: number;
pending_suppliers: number;
avg_quality_rating: number;
avg_delivery_rating: number;
total_spend: number;
}
// ============================================================================
// PURCHASE ORDER TYPES
// ============================================================================
export interface PurchaseOrderItemCreate {
inventory_product_id: string;
product_code?: string;
ordered_quantity: number;
unit_of_measure: string;
unit_price: number;
quality_requirements?: string;
item_notes?: string;
}
export interface PurchaseOrderItemResponse {
id: string;
tenant_id: string;
purchase_order_id: string;
price_list_item_id?: string;
inventory_product_id: string;
product_code?: string;
ordered_quantity: number;
unit_of_measure: string;
unit_price: number;
line_total: number;
received_quantity: number;
remaining_quantity: number;
quality_requirements?: string;
item_notes?: string;
created_at: string;
updated_at: string;
}
export interface PurchaseOrderCreate {
supplier_id: string;
reference_number?: string;
priority?: string;
required_delivery_date?: string;
delivery_address?: string;
delivery_instructions?: string;
delivery_contact?: string;
delivery_phone?: string;
tax_amount?: number;
shipping_cost?: number;
discount_amount?: number;
notes?: string;
internal_notes?: string;
terms_and_conditions?: string;
items: PurchaseOrderItemCreate[];
}
export interface PurchaseOrderUpdate {
reference_number?: string;
priority?: string;
required_delivery_date?: string;
estimated_delivery_date?: string;
delivery_address?: string;
delivery_instructions?: string;
delivery_contact?: string;
delivery_phone?: string;
tax_amount?: number;
shipping_cost?: number;
discount_amount?: number;
notes?: string;
internal_notes?: string;
terms_and_conditions?: string;
supplier_reference?: string;
}
export interface PurchaseOrderResponse {
id: string;
tenant_id: string;
supplier_id: string;
po_number: string;
reference_number?: string;
status: PurchaseOrderStatus;
priority: string;
order_date: string;
required_delivery_date?: string;
estimated_delivery_date?: string;
subtotal: number;
tax_amount: number;
shipping_cost: number;
discount_amount: number;
total_amount: number;
currency: string;
delivery_address?: string;
delivery_instructions?: string;
delivery_contact?: string;
delivery_phone?: string;
requires_approval: boolean;
approved_by?: string;
approved_at?: string;
rejection_reason?: string;
sent_to_supplier_at?: string;
supplier_confirmation_date?: string;
supplier_reference?: string;
notes?: string;
internal_notes?: string;
terms_and_conditions?: string;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string;
supplier?: SupplierSummary;
items?: PurchaseOrderItemResponse[];
}
// ============================================================================
// DELIVERY TYPES
// ============================================================================
export interface DeliveryItemCreate {
purchase_order_item_id: string;
inventory_product_id: string;
ordered_quantity: number;
delivered_quantity: number;
accepted_quantity: number;
rejected_quantity?: number;
batch_lot_number?: string;
expiry_date?: string;
quality_grade?: string;
quality_issues?: string;
rejection_reason?: string;
item_notes?: string;
}
export interface DeliveryItemResponse {
id: string;
tenant_id: string;
delivery_id: string;
purchase_order_item_id: string;
inventory_product_id: string;
ordered_quantity: number;
delivered_quantity: number;
accepted_quantity: number;
rejected_quantity: number;
batch_lot_number?: string;
expiry_date?: string;
quality_grade?: string;
quality_issues?: string;
rejection_reason?: string;
item_notes?: string;
created_at: string;
updated_at: string;
}
export interface DeliveryCreate {
purchase_order_id: string;
supplier_id: string;
supplier_delivery_note?: string;
scheduled_date?: string;
estimated_arrival?: string;
delivery_address?: string;
delivery_contact?: string;
delivery_phone?: string;
carrier_name?: string;
tracking_number?: string;
notes?: string;
items: DeliveryItemCreate[];
}
export interface DeliveryResponse {
id: string;
tenant_id: string;
purchase_order_id: string;
supplier_id: string;
delivery_number: string;
supplier_delivery_note?: string;
status: DeliveryStatus;
scheduled_date?: string;
estimated_arrival?: string;
actual_arrival?: string;
completed_at?: string;
delivery_address?: string;
delivery_contact?: string;
delivery_phone?: string;
carrier_name?: string;
tracking_number?: string;
inspection_passed?: boolean;
inspection_notes?: string;
quality_issues?: any;
received_by?: string;
received_at?: string;
notes?: string;
photos?: any;
created_at: string;
updated_at: string;
created_by: string;
supplier?: SupplierSummary;
items?: DeliveryItemResponse[];
}
export interface DeliveryReceiptConfirmation {
inspection_passed?: boolean;
inspection_notes?: string;
quality_issues?: any;
notes?: string;
}
// ============================================================================
// UTILITY TYPES
// ============================================================================
// Keep legacy Supplier interface for backward compatibility
export interface Supplier extends SupplierResponse {}

View File

@@ -0,0 +1,35 @@
// ML Training service types
export interface TrainingJob {
id: string;
model_id: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: number;
started_at?: string;
completed_at?: string;
error_message?: string;
parameters: Record<string, any>;
metrics?: Record<string, number>;
}
export interface TrainingJobCreate {
model_id: string;
parameters?: Record<string, any>;
}
export interface TrainingJobUpdate {
parameters?: Record<string, any>;
}
export enum TrainingJobStatus {
PENDING = 'pending',
RUNNING = 'running',
COMPLETED = 'completed',
FAILED = 'failed'
}
// Form data interfaces
export interface TrainingJobFormData {
model_id: string;
parameters: Record<string, any>;
}

View File

@@ -151,6 +151,8 @@ async def validate_sales_data_universal(
"errors": validation_result.errors, "errors": validation_result.errors,
"warnings": validation_result.warnings, "warnings": validation_result.warnings,
"summary": validation_result.summary, "summary": validation_result.summary,
"unique_products": validation_result.unique_products,
"product_list": validation_result.product_list,
"message": "Validation completed successfully" if validation_result.is_valid else "Validation found errors", "message": "Validation completed successfully" if validation_result.is_valid else "Validation found errors",
"details": { "details": {
"total_records": validation_result.total_records, "total_records": validation_result.total_records,

View File

@@ -1,499 +0,0 @@
# services/sales/app/api/onboarding.py
"""
Onboarding API Endpoints
Handles sales data import with automated inventory creation
"""
from fastapi import APIRouter, Depends, HTTPException, Path, UploadFile, File, Form
from typing import List, Dict, Any, Optional
from uuid import UUID
from pydantic import BaseModel, Field
import structlog
from app.services.ai_onboarding_service import (
AIOnboardingService,
OnboardingValidationResult,
ProductSuggestionsResult,
OnboardingImportResult,
get_ai_onboarding_service
)
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
router = APIRouter(tags=["onboarding"])
logger = structlog.get_logger()
class FileValidationResponse(BaseModel):
"""Response for file validation step"""
is_valid: bool
total_records: int
unique_products: int
product_list: List[str]
validation_errors: List[Any]
validation_warnings: List[Any]
summary: Dict[str, Any]
class ProductSuggestionsResponse(BaseModel):
"""Response for AI suggestions step"""
suggestions: List[Dict[str, Any]]
business_model_analysis: Dict[str, Any]
total_products: int
high_confidence_count: int
low_confidence_count: int
processing_time_seconds: float
class InventoryApprovalRequest(BaseModel):
"""Request to approve/modify inventory suggestions"""
suggestions: List[Dict[str, Any]] = Field(..., description="Approved suggestions with modifications")
class InventoryCreationResponse(BaseModel):
"""Response for inventory creation"""
created_items: List[Dict[str, Any]]
failed_items: List[Dict[str, Any]]
total_approved: int
success_rate: float
class SalesImportResponse(BaseModel):
"""Response for final sales import"""
import_job_id: str
status: str
processed_rows: int
successful_imports: int
failed_imports: int
errors: List[str]
warnings: List[str]
@router.post("/tenants/{tenant_id}/onboarding/validate-file", response_model=FileValidationResponse)
async def validate_onboarding_file(
file: UploadFile = File(..., description="Sales data CSV/Excel file"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
onboarding_service: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 1: Validate uploaded file and extract unique products
This endpoint:
1. Validates the file format and content
2. Checks for required columns (date, product, etc.)
3. Extracts unique products from sales data
4. Returns validation results and product list
"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Validate file
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
allowed_extensions = ['.csv', '.xlsx', '.xls']
if not any(file.filename.lower().endswith(ext) for ext in allowed_extensions):
raise HTTPException(status_code=400, detail=f"Unsupported file format. Allowed: {allowed_extensions}")
# Determine file format
file_format = "csv" if file.filename.lower().endswith('.csv') else "excel"
# Read file content
file_content = await file.read()
if not file_content:
raise HTTPException(status_code=400, detail="File is empty")
# Convert bytes to string for CSV
if file_format == "csv":
file_data = file_content.decode('utf-8')
else:
import base64
file_data = base64.b64encode(file_content).decode('utf-8')
# Validate and extract products
result = await onboarding_service.validate_and_extract_products(
file_data=file_data,
file_format=file_format,
tenant_id=tenant_id
)
response = FileValidationResponse(
is_valid=result.is_valid,
total_records=result.total_records,
unique_products=result.unique_products,
product_list=result.product_list,
validation_errors=result.validation_details.errors,
validation_warnings=result.validation_details.warnings,
summary=result.summary
)
logger.info("File validation complete",
filename=file.filename,
is_valid=result.is_valid,
unique_products=result.unique_products,
tenant_id=tenant_id)
return response
except HTTPException:
raise
except Exception as e:
logger.error("Failed file validation",
error=str(e), filename=file.filename if file else None, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
@router.post("/tenants/{tenant_id}/onboarding/generate-suggestions", response_model=ProductSuggestionsResponse)
async def generate_inventory_suggestions(
file: UploadFile = File(..., description="Same sales data file from step 1"),
product_list: str = Form(..., description="JSON array of product names to classify"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
onboarding_service: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 2: Generate AI-powered inventory suggestions
This endpoint:
1. Takes the validated file and product list from step 1
2. Uses AI to classify products into inventory categories
3. Analyzes business model (production vs retail)
4. Returns detailed suggestions for user review
"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Parse product list
import json
try:
products = json.loads(product_list)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid product list format: {str(e)}")
if not products:
raise HTTPException(status_code=400, detail="No products provided")
# Determine file format
file_format = "csv" if file.filename.lower().endswith('.csv') else "excel"
# Read file content
file_content = await file.read()
if not file_content:
raise HTTPException(status_code=400, detail="File is empty")
# Convert bytes to string for CSV
if file_format == "csv":
file_data = file_content.decode('utf-8')
else:
import base64
file_data = base64.b64encode(file_content).decode('utf-8')
# Generate suggestions
result = await onboarding_service.generate_inventory_suggestions(
product_list=products,
file_data=file_data,
file_format=file_format,
tenant_id=tenant_id
)
# Convert suggestions to dict format
suggestions_dict = []
for suggestion in result.suggestions:
suggestion_dict = {
"suggestion_id": suggestion.suggestion_id,
"original_name": suggestion.original_name,
"suggested_name": suggestion.suggested_name,
"product_type": suggestion.product_type,
"category": suggestion.category,
"unit_of_measure": suggestion.unit_of_measure,
"confidence_score": suggestion.confidence_score,
"estimated_shelf_life_days": suggestion.estimated_shelf_life_days,
"requires_refrigeration": suggestion.requires_refrigeration,
"requires_freezing": suggestion.requires_freezing,
"is_seasonal": suggestion.is_seasonal,
"suggested_supplier": suggestion.suggested_supplier,
"notes": suggestion.notes,
"sales_data": suggestion.sales_data
}
suggestions_dict.append(suggestion_dict)
business_model_dict = {
"model": result.business_model_analysis.model,
"confidence": result.business_model_analysis.confidence,
"ingredient_count": result.business_model_analysis.ingredient_count,
"finished_product_count": result.business_model_analysis.finished_product_count,
"ingredient_ratio": result.business_model_analysis.ingredient_ratio,
"recommendations": result.business_model_analysis.recommendations
}
response = ProductSuggestionsResponse(
suggestions=suggestions_dict,
business_model_analysis=business_model_dict,
total_products=result.total_products,
high_confidence_count=result.high_confidence_count,
low_confidence_count=result.low_confidence_count,
processing_time_seconds=result.processing_time_seconds
)
logger.info("AI suggestions generated",
total_products=result.total_products,
business_model=result.business_model_analysis.model,
high_confidence=result.high_confidence_count,
tenant_id=tenant_id)
return response
except HTTPException:
raise
except Exception as e:
logger.error("Failed to generate suggestions",
error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Suggestion generation failed: {str(e)}")
@router.post("/tenants/{tenant_id}/onboarding/create-inventory", response_model=InventoryCreationResponse)
async def create_inventory_from_suggestions(
request: InventoryApprovalRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
onboarding_service: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 3: Create inventory items from approved suggestions
This endpoint:
1. Takes user-approved inventory suggestions from step 2
2. Applies any user modifications to suggestions
3. Creates inventory items via inventory service
4. Returns creation results for final import step
"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
if not request.suggestions:
raise HTTPException(status_code=400, detail="No suggestions provided")
# Create inventory items using new service
result = await onboarding_service.create_inventory_from_suggestions(
approved_suggestions=request.suggestions,
tenant_id=tenant_id,
user_id=UUID(current_user['user_id'])
)
response = InventoryCreationResponse(
created_items=result['created_items'],
failed_items=result['failed_items'],
total_approved=result['total_approved'],
success_rate=result['success_rate']
)
logger.info("Inventory creation complete",
created=len(result['created_items']),
failed=len(result['failed_items']),
tenant_id=tenant_id)
return response
except HTTPException:
raise
except Exception as e:
logger.error("Failed inventory creation",
error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Inventory creation failed: {str(e)}")
@router.post("/tenants/{tenant_id}/onboarding/import-sales", response_model=SalesImportResponse)
async def import_sales_with_inventory(
file: UploadFile = File(..., description="Sales data CSV/Excel file"),
inventory_mapping: str = Form(..., description="JSON mapping of product names to inventory IDs"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
onboarding_service: AIOnboardingService = Depends(get_ai_onboarding_service)
):
"""
Step 4: Final sales data import using created inventory items
This endpoint:
1. Takes the same validated sales file from step 1
2. Uses the inventory mapping from step 3
3. Imports sales records using detailed processing from DataImportService
4. Returns final import results - onboarding complete!
"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Validate file
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
# Parse inventory mapping
import json
try:
mapping = json.loads(inventory_mapping)
# Convert to string mapping for the new service
inventory_mapping_dict = {
product_name: str(inventory_id)
for product_name, inventory_id in mapping.items()
}
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid inventory mapping format: {str(e)}")
# Determine file format
file_format = "csv" if file.filename.lower().endswith('.csv') else "excel"
# Read file content
file_content = await file.read()
if not file_content:
raise HTTPException(status_code=400, detail="File is empty")
# Convert bytes to string for CSV
if file_format == "csv":
file_data = file_content.decode('utf-8')
else:
import base64
file_data = base64.b64encode(file_content).decode('utf-8')
# Import sales data using new service
result = await onboarding_service.import_sales_data_with_inventory(
file_data=file_data,
file_format=file_format,
inventory_mapping=inventory_mapping_dict,
tenant_id=tenant_id,
filename=file.filename
)
response = SalesImportResponse(
import_job_id="onboarding-" + str(tenant_id), # Generate a simple job ID
status="completed" if result.success else "failed",
processed_rows=result.import_details.records_processed,
successful_imports=result.import_details.records_created,
failed_imports=result.import_details.records_failed,
errors=[error.get("message", str(error)) for error in result.import_details.errors],
warnings=[warning.get("message", str(warning)) for warning in result.import_details.warnings]
)
logger.info("Sales import complete",
successful=result.import_details.records_created,
failed=result.import_details.records_failed,
filename=file.filename,
tenant_id=tenant_id)
return response
except HTTPException:
raise
except Exception as e:
logger.error("Failed sales import",
error=str(e), filename=file.filename if file else None, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Sales import failed: {str(e)}")
@router.get("/tenants/{tenant_id}/onboarding/business-model-guide")
async def get_business_model_guide(
model: str,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""
Get setup recommendations based on detected business model
"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
guides = {
'production': {
'title': 'Production Bakery Setup',
'description': 'Your bakery produces items from raw ingredients',
'next_steps': [
'Set up supplier relationships for ingredients',
'Configure recipe management and costing',
'Enable production planning and scheduling',
'Set up ingredient inventory alerts and reorder points'
],
'recommended_features': [
'Recipe & Production Management',
'Supplier & Procurement',
'Ingredient Inventory Tracking',
'Production Cost Analysis'
],
'sample_workflows': [
'Create recipes with ingredient costs',
'Plan daily production based on sales forecasts',
'Track ingredient usage and waste',
'Generate supplier purchase orders'
]
},
'retail': {
'title': 'Retail Bakery Setup',
'description': 'Your bakery sells finished products from central bakers',
'next_steps': [
'Configure central baker relationships',
'Set up delivery schedules and tracking',
'Enable finished product freshness monitoring',
'Focus on sales forecasting and ordering'
],
'recommended_features': [
'Central Baker Management',
'Delivery Schedule Tracking',
'Freshness Monitoring',
'Sales Forecasting'
],
'sample_workflows': [
'Set up central baker delivery schedules',
'Track product freshness and expiration',
'Forecast demand and place orders',
'Monitor sales performance by product'
]
},
'hybrid': {
'title': 'Hybrid Bakery Setup',
'description': 'Your bakery both produces items and sells finished products',
'next_steps': [
'Configure both production and retail features',
'Set up flexible inventory categories',
'Enable comprehensive analytics',
'Plan workflows for both business models'
],
'recommended_features': [
'Full Inventory Management',
'Recipe & Production Management',
'Central Baker Management',
'Advanced Analytics'
],
'sample_workflows': [
'Manage both ingredients and finished products',
'Balance production vs purchasing decisions',
'Track costs across both models',
'Optimize inventory mix based on profitability'
]
}
}
if model not in guides:
raise HTTPException(status_code=400, detail="Invalid business model")
return guides[model]
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get business model guide",
error=str(e), model=model, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get guide: {str(e)}")

View File

@@ -104,9 +104,7 @@ app.add_middleware(
# Include routers - import router BEFORE sales router to avoid conflicts # Include routers - import router BEFORE sales router to avoid conflicts
from app.api.sales import router as sales_router from app.api.sales import router as sales_router
from app.api.import_data import router as import_router from app.api.import_data import router as import_router
from app.api.onboarding import router as onboarding_router
app.include_router(import_router, prefix="/api/v1", tags=["import"]) app.include_router(import_router, prefix="/api/v1", tags=["import"])
app.include_router(onboarding_router, prefix="/api/v1", tags=["onboarding"])
app.include_router(sales_router, prefix="/api/v1", tags=["sales"]) app.include_router(sales_router, prefix="/api/v1", tags=["sales"])
# Health check endpoint # Health check endpoint

View File

@@ -1,865 +0,0 @@
# services/sales/app/services/ai_onboarding_service.py
"""
AI-Powered Onboarding Service
Handles the complete onboarding flow: File validation -> Product extraction -> Inventory suggestions -> Data processing
"""
import pandas as pd
import structlog
from typing import List, Dict, Any, Optional
from uuid import UUID, uuid4
from dataclasses import dataclass
import asyncio
from app.services.data_import_service import DataImportService, SalesValidationResult, SalesImportResult
from app.services.inventory_client import InventoryServiceClient
from app.core.database import get_db_transaction
logger = structlog.get_logger()
@dataclass
class ProductSuggestion:
"""Single product suggestion from AI classification"""
suggestion_id: str
original_name: str
suggested_name: str
product_type: str
category: str
unit_of_measure: str
confidence_score: float
estimated_shelf_life_days: Optional[int] = None
requires_refrigeration: bool = False
requires_freezing: bool = False
is_seasonal: bool = False
suggested_supplier: Optional[str] = None
notes: Optional[str] = None
sales_data: Optional[Dict[str, Any]] = None
@dataclass
class BusinessModelAnalysis:
"""Business model analysis results"""
model: str # production, retail, hybrid
confidence: float
ingredient_count: int
finished_product_count: int
ingredient_ratio: float
recommendations: List[str]
@dataclass
class OnboardingValidationResult:
"""Result of onboarding file validation step"""
is_valid: bool
total_records: int
unique_products: int
validation_details: SalesValidationResult
product_list: List[str]
summary: Dict[str, Any]
@dataclass
class ProductSuggestionsResult:
"""Result of AI product classification step"""
suggestions: List[ProductSuggestion]
business_model_analysis: BusinessModelAnalysis
total_products: int
high_confidence_count: int
low_confidence_count: int
processing_time_seconds: float
@dataclass
class OnboardingImportResult:
"""Result of final data import step"""
success: bool
import_details: SalesImportResult
inventory_items_created: int
inventory_creation_errors: List[str]
final_summary: Dict[str, Any]
class AIOnboardingService:
"""
Unified AI-powered onboarding service that orchestrates the complete flow:
1. File validation and product extraction
2. AI-powered inventory suggestions
3. User confirmation and inventory creation
4. Final sales data import
"""
def __init__(self):
self.data_import_service = DataImportService()
self.inventory_client = InventoryServiceClient()
# ================================================================
# STEP 1: FILE VALIDATION AND PRODUCT EXTRACTION
# ================================================================
async def validate_and_extract_products(
self,
file_data: str,
file_format: str,
tenant_id: UUID
) -> OnboardingValidationResult:
"""
Step 1: Validate uploaded file and extract unique products
This uses the detailed validation from data_import_service
"""
try:
logger.info("Starting onboarding validation and product extraction",
file_format=file_format, tenant_id=tenant_id)
# Use data_import_service for detailed validation
validation_data = {
"tenant_id": str(tenant_id),
"data": file_data,
"data_format": file_format,
"validate_only": True,
"source": "ai_onboarding"
}
validation_result = await self.data_import_service.validate_import_data(validation_data)
# Extract unique products if validation passes
product_list = []
unique_products = 0
if validation_result.is_valid and file_format.lower() == "csv":
try:
# Parse CSV to extract unique products
import csv
import io
reader = csv.DictReader(io.StringIO(file_data))
rows = list(reader)
# Use data_import_service column detection
column_mapping = self.data_import_service._detect_columns(list(rows[0].keys()) if rows else [])
if column_mapping.get('product'):
product_column = column_mapping['product']
# Extract and clean unique products
products_raw = [row.get(product_column, '').strip() for row in rows if row.get(product_column, '').strip()]
# Clean product names using data_import_service method
products_cleaned = [
self.data_import_service._clean_product_name(product)
for product in products_raw
]
# Get unique products
product_list = list(set([p for p in products_cleaned if p and p != "Producto sin nombre"]))
unique_products = len(product_list)
logger.info("Extracted unique products",
total_rows=len(rows), unique_products=unique_products)
except Exception as e:
logger.error("Failed to extract products", error=str(e))
# Don't fail validation just because product extraction failed
pass
result = OnboardingValidationResult(
is_valid=validation_result.is_valid,
total_records=validation_result.total_records,
unique_products=unique_products,
validation_details=validation_result,
product_list=product_list,
summary={
"status": "valid" if validation_result.is_valid else "invalid",
"file_format": file_format,
"total_records": validation_result.total_records,
"unique_products": unique_products,
"ready_for_ai_classification": validation_result.is_valid and unique_products > 0,
"next_step": "ai_classification" if validation_result.is_valid and unique_products > 0 else "fix_validation_errors"
}
)
logger.info("Onboarding validation completed",
is_valid=result.is_valid,
unique_products=unique_products,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Onboarding validation failed", error=str(e), tenant_id=tenant_id)
return OnboardingValidationResult(
is_valid=False,
total_records=0,
unique_products=0,
validation_details=SalesValidationResult(
is_valid=False,
total_records=0,
valid_records=0,
invalid_records=0,
errors=[{
"type": "system_error",
"message": f"Onboarding validation error: {str(e)}",
"field": None,
"row": None,
"code": "ONBOARDING_VALIDATION_ERROR"
}],
warnings=[],
summary={}
),
product_list=[],
summary={
"status": "error",
"error_message": str(e),
"next_step": "retry_upload"
}
)
# ================================================================
# STEP 2: AI PRODUCT CLASSIFICATION
# ================================================================
async def generate_inventory_suggestions(
self,
product_list: List[str],
file_data: str,
file_format: str,
tenant_id: UUID
) -> ProductSuggestionsResult:
"""
Step 2: Generate AI-powered inventory suggestions for products
"""
import time
start_time = time.time()
try:
logger.info("Starting AI inventory suggestions",
product_count=len(product_list), tenant_id=tenant_id)
if not product_list:
raise ValueError("No products provided for classification")
# Analyze sales data for each product to provide context
product_analysis = await self._analyze_product_sales_data(
product_list, file_data, file_format
)
# Prepare products for classification
products_for_classification = []
for product_name in product_list:
sales_data = product_analysis.get(product_name, {})
products_for_classification.append({
"product_name": product_name,
"sales_volume": sales_data.get("total_quantity"),
"sales_data": sales_data
})
# Call inventory service for AI classification
classification_result = await self.inventory_client.classify_products_batch(
products_for_classification, tenant_id
)
if not classification_result or "suggestions" not in classification_result:
raise ValueError("Invalid classification response from inventory service")
suggestions_raw = classification_result["suggestions"]
business_model_raw = classification_result.get("business_model_analysis", {})
# Convert to dataclass objects
suggestions = []
for suggestion_data in suggestions_raw:
suggestion = ProductSuggestion(
suggestion_id=suggestion_data.get("suggestion_id", str(uuid4())),
original_name=suggestion_data["original_name"],
suggested_name=suggestion_data["suggested_name"],
product_type=suggestion_data["product_type"],
category=suggestion_data["category"],
unit_of_measure=suggestion_data["unit_of_measure"],
confidence_score=suggestion_data["confidence_score"],
estimated_shelf_life_days=suggestion_data.get("estimated_shelf_life_days"),
requires_refrigeration=suggestion_data.get("requires_refrigeration", False),
requires_freezing=suggestion_data.get("requires_freezing", False),
is_seasonal=suggestion_data.get("is_seasonal", False),
suggested_supplier=suggestion_data.get("suggested_supplier"),
notes=suggestion_data.get("notes"),
sales_data=product_analysis.get(suggestion_data["original_name"])
)
suggestions.append(suggestion)
# Check if enhanced business intelligence data is available
bi_data = product_analysis.get('__business_intelligence__')
if bi_data and bi_data.get('confidence_score', 0) > 0.6:
# Use enhanced business intelligence analysis
business_type = bi_data.get('business_type', 'bakery')
business_model_detected = bi_data.get('business_model', 'individual')
# Map business intelligence results to existing model format
model_mapping = {
'individual': 'individual_bakery',
'central_distribution': 'central_baker_satellite',
'central_bakery': 'central_baker_satellite',
'hybrid': 'hybrid_bakery'
}
mapped_model = model_mapping.get(business_model_detected, 'individual_bakery')
# Count ingredients vs finished products from suggestions
ingredient_count = sum(1 for s in suggestions if s.product_type == 'ingredient')
finished_product_count = sum(1 for s in suggestions if s.product_type == 'finished_product')
total_products = len(suggestions)
ingredient_ratio = ingredient_count / total_products if total_products > 0 else 0.0
# Enhanced recommendations based on BI analysis
enhanced_recommendations = bi_data.get('recommendations', [])
# Add business type specific recommendations
if business_type == 'coffee_shop':
enhanced_recommendations.extend([
"Configure beverage inventory management",
"Set up quick-service item tracking",
"Enable all-day service optimization"
])
business_model = BusinessModelAnalysis(
model=mapped_model,
confidence=bi_data.get('confidence_score', 0.0),
ingredient_count=ingredient_count,
finished_product_count=finished_product_count,
ingredient_ratio=ingredient_ratio,
recommendations=enhanced_recommendations[:6] # Limit to top 6 recommendations
)
logger.info("Using enhanced business intelligence for model analysis",
detected_type=business_type,
detected_model=business_model_detected,
mapped_model=mapped_model,
confidence=bi_data.get('confidence_score'))
else:
# Fallback to basic inventory service analysis
business_model = BusinessModelAnalysis(
model=business_model_raw.get("model", "unknown"),
confidence=business_model_raw.get("confidence", 0.0),
ingredient_count=business_model_raw.get("ingredient_count", 0),
finished_product_count=business_model_raw.get("finished_product_count", 0),
ingredient_ratio=business_model_raw.get("ingredient_ratio", 0.0),
recommendations=business_model_raw.get("recommendations", [])
)
logger.info("Using basic inventory service business model analysis")
# Calculate confidence metrics
high_confidence_count = sum(1 for s in suggestions if s.confidence_score >= 0.7)
low_confidence_count = sum(1 for s in suggestions if s.confidence_score < 0.6)
processing_time = time.time() - start_time
result = ProductSuggestionsResult(
suggestions=suggestions,
business_model_analysis=business_model,
total_products=len(suggestions),
high_confidence_count=high_confidence_count,
low_confidence_count=low_confidence_count,
processing_time_seconds=processing_time
)
# Update tenant's business model based on AI analysis
if business_model.model != "unknown" and business_model.confidence >= 0.6:
try:
await self._update_tenant_business_model(tenant_id, business_model.model)
logger.info("Updated tenant business model",
tenant_id=tenant_id,
business_model=business_model.model,
confidence=business_model.confidence)
except Exception as e:
logger.warning("Failed to update tenant business model",
error=str(e), tenant_id=tenant_id)
# Don't fail the entire process if tenant update fails
logger.info("AI inventory suggestions completed",
total_suggestions=len(suggestions),
business_model=business_model.model,
high_confidence=high_confidence_count,
processing_time=processing_time,
tenant_id=tenant_id)
return result
except Exception as e:
processing_time = time.time() - start_time
logger.error("AI inventory suggestions failed",
error=str(e), tenant_id=tenant_id)
# Return fallback suggestions
fallback_suggestions = [
ProductSuggestion(
suggestion_id=str(uuid4()),
original_name=product_name,
suggested_name=product_name.title(),
product_type="finished_product",
category="other_products",
unit_of_measure="units",
confidence_score=0.3,
notes="Fallback suggestion - requires manual review"
)
for product_name in product_list
]
return ProductSuggestionsResult(
suggestions=fallback_suggestions,
business_model_analysis=BusinessModelAnalysis(
model="unknown",
confidence=0.0,
ingredient_count=0,
finished_product_count=len(fallback_suggestions),
ingredient_ratio=0.0,
recommendations=["Manual review required for all products"]
),
total_products=len(fallback_suggestions),
high_confidence_count=0,
low_confidence_count=len(fallback_suggestions),
processing_time_seconds=processing_time
)
# ================================================================
# STEP 3: INVENTORY CREATION (after user confirmation)
# ================================================================
async def create_inventory_from_suggestions(
self,
approved_suggestions: List[Dict[str, Any]],
tenant_id: UUID,
user_id: UUID
) -> Dict[str, Any]:
"""
Step 3: Create inventory items from user-approved suggestions
"""
try:
logger.info("Creating inventory from approved suggestions",
approved_count=len(approved_suggestions), tenant_id=tenant_id)
created_items = []
failed_items = []
for approval in approved_suggestions:
suggestion_id = approval.get("suggestion_id")
is_approved = approval.get("approved", False)
modifications = approval.get("modifications", {})
if not is_approved:
continue
try:
# Build inventory item data from suggestion and modifications
# Map to inventory service expected format
raw_category = modifications.get("category") or approval.get("category", "other")
raw_unit = modifications.get("unit_of_measure") or approval.get("unit_of_measure", "units")
# Map categories to inventory service enum values
category_mapping = {
"flour": "flour",
"yeast": "yeast",
"dairy": "dairy",
"eggs": "eggs",
"sugar": "sugar",
"fats": "fats",
"salt": "salt",
"spices": "spices",
"additives": "additives",
"packaging": "packaging",
"cleaning": "cleaning",
"grains": "flour", # Map common variations
"bread": "other",
"pastries": "other",
"croissants": "other",
"cakes": "other",
"other_products": "other"
}
# Map units to inventory service enum values
unit_mapping = {
"kg": "kg",
"kilograms": "kg",
"g": "g",
"grams": "g",
"l": "l",
"liters": "l",
"ml": "ml",
"milliliters": "ml",
"units": "units",
"pieces": "pcs",
"pcs": "pcs",
"packages": "pkg",
"pkg": "pkg",
"bags": "bags",
"boxes": "boxes"
}
mapped_category = category_mapping.get(raw_category.lower(), "other")
mapped_unit = unit_mapping.get(raw_unit.lower(), "units")
inventory_data = {
"name": modifications.get("name") or approval.get("suggested_name"),
"category": mapped_category,
"unit_of_measure": mapped_unit,
"product_type": approval.get("product_type"),
"description": modifications.get("description") or approval.get("notes", ""),
# Optional fields
"brand": modifications.get("brand") or approval.get("suggested_supplier"),
"is_active": True,
# Explicitly set boolean fields to ensure they're not NULL
"requires_refrigeration": modifications.get("requires_refrigeration", approval.get("requires_refrigeration", False)),
"requires_freezing": modifications.get("requires_freezing", approval.get("requires_freezing", False)),
"is_perishable": modifications.get("is_perishable", approval.get("is_perishable", False))
}
# Add optional numeric fields only if they exist
shelf_life = modifications.get("estimated_shelf_life_days") or approval.get("estimated_shelf_life_days")
if shelf_life:
inventory_data["shelf_life_days"] = shelf_life
# Create inventory item via inventory service
created_item = await self.inventory_client.create_ingredient(
inventory_data, str(tenant_id)
)
if created_item:
created_items.append({
"suggestion_id": suggestion_id,
"inventory_item": created_item,
"original_name": approval.get("original_name")
})
logger.info("Created inventory item",
item_name=inventory_data["name"],
suggestion_id=suggestion_id)
else:
failed_items.append({
"suggestion_id": suggestion_id,
"error": "Failed to create inventory item - no response"
})
except Exception as e:
logger.error("Failed to create inventory item",
error=str(e), suggestion_id=suggestion_id)
failed_items.append({
"suggestion_id": suggestion_id,
"error": str(e)
})
success_rate = len(created_items) / max(1, len(approved_suggestions)) * 100
result = {
"created_items": created_items,
"failed_items": failed_items,
"total_approved": len(approved_suggestions),
"successful_creations": len(created_items),
"failed_creations": len(failed_items),
"success_rate": success_rate,
"ready_for_import": len(created_items) > 0
}
logger.info("Inventory creation completed",
created=len(created_items),
failed=len(failed_items),
success_rate=success_rate,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Inventory creation failed", error=str(e), tenant_id=tenant_id)
raise
# ================================================================
# STEP 4: FINAL DATA IMPORT
# ================================================================
async def import_sales_data_with_inventory(
self,
file_data: str,
file_format: str,
inventory_mapping: Dict[str, str], # original_product_name -> inventory_item_id
tenant_id: UUID,
filename: Optional[str] = None
) -> OnboardingImportResult:
"""
Step 4: Import sales data using the detailed processing from data_import_service
"""
try:
logger.info("Starting final sales data import with inventory mapping",
mappings_count=len(inventory_mapping), tenant_id=tenant_id)
# Use data_import_service for the actual import processing
import_result = await self.data_import_service.process_import(
str(tenant_id), file_data, file_format, filename
)
result = OnboardingImportResult(
success=import_result.success,
import_details=import_result,
inventory_items_created=len(inventory_mapping),
inventory_creation_errors=[],
final_summary={
"status": "completed" if import_result.success else "failed",
"total_records": import_result.records_processed,
"successful_imports": import_result.records_created,
"failed_imports": import_result.records_failed,
"inventory_items": len(inventory_mapping),
"processing_time": import_result.processing_time_seconds,
"onboarding_complete": import_result.success
}
)
logger.info("Final sales data import completed",
success=import_result.success,
records_created=import_result.records_created,
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Final sales data import failed", error=str(e), tenant_id=tenant_id)
return OnboardingImportResult(
success=False,
import_details=SalesImportResult(
success=False,
records_processed=0,
records_created=0,
records_updated=0,
records_failed=0,
errors=[{
"type": "import_error",
"message": f"Import failed: {str(e)}",
"field": None,
"row": None,
"code": "FINAL_IMPORT_ERROR"
}],
warnings=[],
processing_time_seconds=0.0
),
inventory_items_created=len(inventory_mapping),
inventory_creation_errors=[str(e)],
final_summary={
"status": "failed",
"error_message": str(e),
"onboarding_complete": False
}
)
# ================================================================
# HELPER METHODS
# ================================================================
async def _analyze_product_sales_data(
self,
product_list: List[str],
file_data: str,
file_format: str
) -> Dict[str, Dict[str, Any]]:
"""Analyze sales data for each product to provide context for AI classification"""
try:
if file_format.lower() != "csv":
return {}
import csv
import io
reader = csv.DictReader(io.StringIO(file_data))
rows = list(reader)
if not rows:
return {}
# Use data_import_service column detection
column_mapping = self.data_import_service._detect_columns(list(rows[0].keys()))
if not column_mapping.get('product'):
return {}
product_column = column_mapping['product']
quantity_column = column_mapping.get('quantity')
revenue_column = column_mapping.get('revenue')
date_column = column_mapping.get('date')
# Analyze each product
product_analysis = {}
for product_name in product_list:
# Find all rows for this product
product_rows = [
row for row in rows
if self.data_import_service._clean_product_name(row.get(product_column, '')) == product_name
]
if not product_rows:
continue
# Calculate metrics
total_quantity = 0
total_revenue = 0
sales_count = len(product_rows)
for row in product_rows:
try:
# Quantity
qty_raw = row.get(quantity_column, 1)
if qty_raw and str(qty_raw).strip():
qty = int(float(str(qty_raw).replace(',', '.')))
total_quantity += qty
else:
total_quantity += 1
# Revenue
if revenue_column:
rev_raw = row.get(revenue_column)
if rev_raw and str(rev_raw).strip():
rev = float(str(rev_raw).replace(',', '.').replace('', '').replace('$', '').strip())
total_revenue += rev
except:
continue
avg_quantity = total_quantity / sales_count if sales_count > 0 else 0
avg_revenue = total_revenue / sales_count if sales_count > 0 else 0
avg_unit_price = total_revenue / total_quantity if total_quantity > 0 else 0
product_analysis[product_name] = {
"total_quantity": total_quantity,
"total_revenue": total_revenue,
"sales_count": sales_count,
"avg_quantity_per_sale": avg_quantity,
"avg_revenue_per_sale": avg_revenue,
"avg_unit_price": avg_unit_price
}
# Add enhanced business intelligence analysis
try:
from app.services.business_intelligence_service import BusinessIntelligenceService
bi_service = BusinessIntelligenceService()
# Convert parsed data to format expected by BI service
sales_data = []
product_data = []
for row in rows:
# Create sales record from CSV row
sales_record = {
'date': row.get(date_column, ''),
'product_name': row.get(product_column, ''),
'name': row.get(product_column, ''),
'quantity_sold': 0,
'revenue': 0,
'location_id': row.get('location', 'main'),
'sales_channel': row.get('channel', 'in_store'),
'supplier_name': row.get('supplier', ''),
'brand': row.get('brand', '')
}
# Parse quantity
if quantity_column:
try:
qty_raw = row.get(quantity_column, 1)
if qty_raw and str(qty_raw).strip():
sales_record['quantity_sold'] = int(float(str(qty_raw).replace(',', '.')))
except:
sales_record['quantity_sold'] = 1
# Parse revenue
if revenue_column:
try:
rev_raw = row.get(revenue_column)
if rev_raw and str(rev_raw).strip():
sales_record['revenue'] = float(str(rev_raw).replace(',', '.').replace('', '').replace('$', '').strip())
except:
pass
sales_data.append(sales_record)
# Create product data entry
product_data.append({
'name': sales_record['product_name'],
'supplier_name': sales_record.get('supplier_name', ''),
'brand': sales_record.get('brand', '')
})
# Run business intelligence analysis
if sales_data:
detection_result = await bi_service.analyze_business_from_sales_data(
sales_data=sales_data,
product_data=product_data
)
# Store business intelligence results in product_analysis
product_analysis['__business_intelligence__'] = {
"business_type": detection_result.business_type,
"business_model": detection_result.business_model,
"confidence_score": detection_result.confidence_score,
"indicators": detection_result.indicators,
"recommendations": detection_result.recommendations,
"analysis_summary": f"{detection_result.business_type.title()} - {detection_result.business_model.replace('_', ' ').title()}"
}
logger.info("Enhanced business intelligence analysis completed",
business_type=detection_result.business_type,
business_model=detection_result.business_model,
confidence=detection_result.confidence_score)
else:
logger.warning("No sales data available for business intelligence analysis")
except Exception as bi_error:
logger.warning("Business intelligence analysis failed", error=str(bi_error))
# Continue with basic analysis even if BI fails
return product_analysis
except Exception as e:
logger.warning("Failed to analyze product sales data", error=str(e))
return {}
async def _update_tenant_business_model(self, tenant_id: UUID, business_model: str) -> None:
"""Update tenant's business model based on AI analysis"""
try:
# Use the gateway URL for all inter-service communication
from app.core.config import settings
import httpx
gateway_url = settings.GATEWAY_URL
url = f"{gateway_url}/api/v1/tenants/{tenant_id}"
# Prepare update data
update_data = {
"business_model": business_model
}
# Make request through gateway
timeout_config = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.put(
url,
json=update_data,
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
logger.info("Successfully updated tenant business model via gateway",
tenant_id=tenant_id, business_model=business_model)
else:
logger.warning("Failed to update tenant business model via gateway",
tenant_id=tenant_id,
status_code=response.status_code,
response=response.text)
except Exception as e:
logger.error("Error updating tenant business model via gateway",
tenant_id=tenant_id,
business_model=business_model,
error=str(e))
raise
# Factory function for dependency injection
def get_ai_onboarding_service() -> AIOnboardingService:
"""Get AI onboarding service instance"""
return AIOnboardingService()

View File

@@ -26,7 +26,7 @@ logger = structlog.get_logger()
# Import result schemas (dataclass definitions) # Import result schemas (dataclass definitions)
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import List, Dict, Any from typing import List, Dict, Any
@dataclass @dataclass
@@ -38,6 +38,8 @@ class SalesValidationResult:
errors: List[Dict[str, Any]] errors: List[Dict[str, Any]]
warnings: List[Dict[str, Any]] warnings: List[Dict[str, Any]]
summary: Dict[str, Any] summary: Dict[str, Any]
unique_products: int = 0
product_list: List[str] = field(default_factory=list)
@dataclass @dataclass
class SalesImportResult: class SalesImportResult:
@@ -99,7 +101,9 @@ class DataImportService:
invalid_records=0, invalid_records=0,
errors=[], errors=[],
warnings=[], warnings=[],
summary={} summary={},
unique_products=0,
product_list=[]
) )
errors = [] errors = []
@@ -216,6 +220,22 @@ class DataImportService:
"code": "MISSING_PRODUCT_COLUMN" "code": "MISSING_PRODUCT_COLUMN"
}) })
# Extract unique products for AI suggestions
if column_mapping.get('product') and not errors:
product_column = column_mapping['product']
unique_products_set = set()
for row in rows:
product_name = row.get(product_column, '').strip()
if product_name and len(product_name) > 0:
unique_products_set.add(product_name)
validation_result.product_list = list(unique_products_set)
validation_result.unique_products = len(unique_products_set)
logger.info(f"Extracted {validation_result.unique_products} unique products from CSV",
tenant_id=data.get("tenant_id"))
if not column_mapping.get('quantity'): if not column_mapping.get('quantity'):
warnings.append({ warnings.append({
"type": "missing_column", "type": "missing_column",