Start integrating the onboarding flow with backend 2
This commit is contained in:
@@ -46,10 +46,15 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
const updateStepData = useCallback((stepId: string, data: any) => {
|
||||
setStepData(prev => ({
|
||||
...prev,
|
||||
[stepId]: { ...prev[stepId], ...data }
|
||||
}));
|
||||
console.log(`OnboardingWizard - Updating step '${stepId}' with data:`, data);
|
||||
setStepData(prev => {
|
||||
const newStepData = {
|
||||
...prev,
|
||||
[stepId]: { ...prev[stepId], ...data }
|
||||
};
|
||||
console.log(`OnboardingWizard - Full step data after update:`, newStepData);
|
||||
return newStepData;
|
||||
});
|
||||
|
||||
// Clear validation error for this step
|
||||
setValidationErrors(prev => {
|
||||
@@ -414,7 +419,10 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
// Pass all step data to allow access to previous steps
|
||||
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}
|
||||
onPrevious={goToPreviousStep}
|
||||
isFirstStep={currentStepIndex === 0}
|
||||
|
||||
@@ -2,6 +2,9 @@ import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../ui';
|
||||
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 {
|
||||
totalProducts: number;
|
||||
@@ -10,6 +13,8 @@ interface CompletionStats {
|
||||
mlModelAccuracy: number;
|
||||
estimatedTimeSaved: string;
|
||||
completionScore: number;
|
||||
salesImported: boolean;
|
||||
salesImportRecords: number;
|
||||
}
|
||||
|
||||
export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||
@@ -20,8 +25,73 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const user = useAuthUser();
|
||||
const { createAlert } = useAlertActions();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
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(() => {
|
||||
// Show confetti animation
|
||||
@@ -35,7 +105,9 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||
suppliersConfigured: data.suppliers?.length || 0,
|
||||
mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0,
|
||||
estimatedTimeSaved: '15-20 horas',
|
||||
completionScore: calculateCompletionScore()
|
||||
completionScore: calculateCompletionScore(),
|
||||
salesImported: data.finalImportCompleted || false,
|
||||
salesImportRecords: data.salesImportResult?.successful_imports || 0
|
||||
};
|
||||
|
||||
setCompletionStats(stats);
|
||||
@@ -48,6 +120,11 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||
completedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Trigger final sales import if not already done
|
||||
if (!data.finalImportCompleted && data.inventoryMapping && data.files?.salesData) {
|
||||
handleFinalSalesImport();
|
||||
}
|
||||
|
||||
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-xs text-[var(--text-secondary)]">Precisión IA</p>
|
||||
</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">
|
||||
<p className="text-lg font-bold text-[var(--color-warning)]">{completionStats.estimatedTimeSaved}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Tiempo Ahorrado</p>
|
||||
|
||||
@@ -2,6 +2,10 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../ui';
|
||||
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';
|
||||
|
||||
@@ -26,64 +30,70 @@ interface ProcessingResult {
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
// Unified mock service that handles both validation and analysis
|
||||
const mockDataProcessingService = {
|
||||
processFile: async (file: File, onProgress: (progress: number, stage: string, message: string) => void) => {
|
||||
return new Promise<ProcessingResult>((resolve, reject) => {
|
||||
let progress = 0;
|
||||
// Real data processing service using backend APIs
|
||||
const dataProcessingService = {
|
||||
processFile: async (
|
||||
file: File,
|
||||
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 = [
|
||||
{ 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' }
|
||||
];
|
||||
onProgress(40, 'validating', 'Verificando integridad de datos...');
|
||||
|
||||
if (!validationResult.is_valid) {
|
||||
throw new Error('Archivo de datos inválido');
|
||||
}
|
||||
|
||||
if (!validationResult.product_list || validationResult.product_list.length === 0) {
|
||||
throw new Error('No se encontraron productos en el archivo');
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (progress < 100) {
|
||||
progress += 10;
|
||||
const currentStage = stages.find(s => progress <= s.threshold);
|
||||
if (currentStage) {
|
||||
onProgress(progress, currentStage.stage, currentStage.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
// Return combined validation + analysis results
|
||||
resolve({
|
||||
// Validation results
|
||||
is_valid: true,
|
||||
total_records: Math.floor(Math.random() * 1000) + 100,
|
||||
unique_products: Math.floor(Math.random() * 50) + 10,
|
||||
product_list: ['Pan Integral', 'Croissant', 'Baguette', 'Empanadas', 'Pan de Centeno', 'Medialunas'],
|
||||
validation_errors: [],
|
||||
validation_warnings: [
|
||||
'Algunas fechas podrían tener formato inconsistente',
|
||||
'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'
|
||||
]
|
||||
});
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
// Stage 2: Generate AI suggestions with inventory service
|
||||
onProgress(60, 'analyzing', 'Identificando productos únicos...');
|
||||
onProgress(80, 'analyzing', 'Analizando patrones de venta...');
|
||||
|
||||
console.log('DataProcessingStep - Calling generateInventorySuggestions with:', {
|
||||
tenantId,
|
||||
fileName: file.name,
|
||||
productList: validationResult.product_list
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,6 +105,34 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
||||
isFirstStep,
|
||||
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 [uploadedFile, setUploadedFile] = useState<File | null>(data.files?.salesData || null);
|
||||
const [progress, setProgress] = useState(data.processingProgress || 0);
|
||||
@@ -166,8 +204,41 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
||||
setProgress(0);
|
||||
|
||||
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,
|
||||
tenantId,
|
||||
(newProgress, newStage, message) => {
|
||||
setProgress(newProgress);
|
||||
setStage(newStage as ProcessingStage);
|
||||
@@ -177,32 +248,112 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
||||
|
||||
setResults(result);
|
||||
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) {
|
||||
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');
|
||||
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 csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta
|
||||
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
|
||||
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,Baguette,2,3.00,6.00,Cliente C,Tienda
|
||||
2024-01-16,Pan de Centeno,4,2.80,11.20,Cliente A,Tienda
|
||||
2024-01-16,Empanadas,6,4.50,27.00,Cliente D,Delivery`;
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', 'plantilla_ventas.csv');
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', 'plantilla_ventas.csv');
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
const resetProcess = () => {
|
||||
@@ -218,8 +369,23 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
{stage === 'upload' && (
|
||||
{stage === 'upload' && isTenantAvailable() && (
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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 { OnboardingStepProps } from '../OnboardingWizard';
|
||||
import { onboardingApiService } from '../../../../services/api/onboarding.service';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
@@ -15,34 +18,35 @@ interface InventoryItem {
|
||||
supplier?: string;
|
||||
cost_per_unit?: number;
|
||||
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
|
||||
const mockInventoryItems: InventoryItem[] = [
|
||||
{
|
||||
id: '1', name: 'Harina de Trigo', category: 'ingredient',
|
||||
current_stock: 50, min_stock: 20, max_stock: 100, unit: 'kg',
|
||||
expiry_date: '2024-12-31', supplier: 'Molinos del Sur',
|
||||
cost_per_unit: 1.20, requires_refrigeration: false
|
||||
},
|
||||
{
|
||||
id: '2', name: 'Levadura Fresca', category: 'ingredient',
|
||||
current_stock: 5, min_stock: 2, max_stock: 10, unit: 'kg',
|
||||
expiry_date: '2024-03-15', supplier: 'Levaduras Pro',
|
||||
cost_per_unit: 3.50, requires_refrigeration: true
|
||||
},
|
||||
{
|
||||
id: '3', name: 'Pan Integral', category: 'finished_product',
|
||||
current_stock: 20, min_stock: 10, max_stock: 50, unit: 'unidades',
|
||||
expiry_date: '2024-01-25', requires_refrigeration: false
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
];
|
||||
// Convert approved products to inventory items
|
||||
const convertProductsToInventory = (approvedProducts: any[]): InventoryItem[] => {
|
||||
return approvedProducts.map((product, index) => ({
|
||||
id: `inventory-${index}`,
|
||||
name: product.suggested_name || product.name,
|
||||
category: product.product_type || 'finished_product',
|
||||
current_stock: 0, // To be configured by user
|
||||
min_stock: 1, // Default minimum
|
||||
max_stock: 100, // Default maximum
|
||||
unit: product.unit_of_measure || 'unidad',
|
||||
requires_refrigeration: product.requires_refrigeration || false,
|
||||
// Store API data
|
||||
suggestion_id: product.suggestion_id,
|
||||
original_name: product.original_name,
|
||||
estimated_shelf_life_days: product.estimated_shelf_life_days,
|
||||
is_seasonal: product.is_seasonal,
|
||||
// Optional fields to be filled by user
|
||||
expiry_date: undefined,
|
||||
supplier: product.suggested_supplier,
|
||||
cost_per_unit: 0
|
||||
}));
|
||||
};
|
||||
|
||||
export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
@@ -52,22 +56,121 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [items, setItems] = useState<InventoryItem[]>(
|
||||
data.inventoryItems || mockInventoryItems
|
||||
);
|
||||
const user = useAuthUser();
|
||||
const { createAlert } = useAlertActions();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||
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 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({
|
||||
...data,
|
||||
inventoryItems: items,
|
||||
inventoryConfigured: true,
|
||||
inventoryMapping: result.inventory_mapping,
|
||||
createdInventoryItems: result.created_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: items.length > 0 && items.every(item =>
|
||||
item.min_stock > 0 && item.max_stock > item.min_stock
|
||||
)
|
||||
inventoryConfigured: hasValidStock && !isCreating
|
||||
});
|
||||
}, [items]);
|
||||
}, [items, isCreating]);
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: InventoryItem = {
|
||||
@@ -75,491 +178,370 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
name: '',
|
||||
category: 'ingredient',
|
||||
current_stock: 0,
|
||||
min_stock: 0,
|
||||
max_stock: 0,
|
||||
unit: 'kg',
|
||||
min_stock: 1,
|
||||
max_stock: 10,
|
||||
unit: 'unidad',
|
||||
requires_refrigeration: false
|
||||
};
|
||||
setItems([...items, newItem]);
|
||||
setEditingItem(newItem);
|
||||
setIsAddingNew(true);
|
||||
};
|
||||
|
||||
const handleSaveItem = (item: InventoryItem) => {
|
||||
if (isAddingNew) {
|
||||
setItems(prev => [...prev, item]);
|
||||
} else {
|
||||
setItems(prev => prev.map(i => i.id === item.id ? item : i));
|
||||
}
|
||||
const handleSaveItem = (updatedItem: InventoryItem) => {
|
||||
setItems(items.map(item =>
|
||||
item.id === updatedItem.id ? updatedItem : item
|
||||
));
|
||||
setEditingItem(null);
|
||||
setIsAddingNew(false);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (id: string) => {
|
||||
if (window.confirm('¿Estás seguro de eliminar este elemento del inventario?')) {
|
||||
setItems(prev => prev.filter(item => item.id !== id));
|
||||
setItems(items.filter(item => item.id !== id));
|
||||
if (editingItem?.id === id) {
|
||||
setEditingItem(null);
|
||||
setIsAddingNew(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickSetup = () => {
|
||||
// Auto-configure basic inventory based on approved products
|
||||
const autoItems = data.detectedProducts
|
||||
?.filter((p: any) => p.status === 'approved')
|
||||
.map((product: any, index: number) => ({
|
||||
id: `auto_${index}`,
|
||||
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 handleCancelEdit = () => {
|
||||
if (isAddingNew && editingItem) {
|
||||
setItems(items.filter(item => item.id !== editingItem.id));
|
||||
}
|
||||
setEditingItem(null);
|
||||
setIsAddingNew(false);
|
||||
};
|
||||
|
||||
const getStockStatus = (item: InventoryItem) => {
|
||||
if (item.current_stock <= item.min_stock) return { status: 'low', color: 'red', text: 'Stock Bajo' };
|
||||
if (item.current_stock >= item.max_stock) return { status: 'high', color: 'blue', text: 'Stock Alto' };
|
||||
return { status: 'normal', color: 'green', text: 'Normal' };
|
||||
if (item.current_stock <= item.min_stock) return 'critical';
|
||||
if (item.current_stock <= item.min_stock * 1.5) return 'warning';
|
||||
return 'good';
|
||||
};
|
||||
|
||||
const isNearExpiry = (expiryDate?: string) => {
|
||||
if (!expiryDate) return false;
|
||||
const expiry = new Date(expiryDate);
|
||||
const today = new Date();
|
||||
const diffDays = (expiry.getTime() - today.getTime()) / (1000 * 3600 * 24);
|
||||
return diffDays <= 7;
|
||||
const getStockStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'critical': return 'text-red-600 bg-red-50';
|
||||
case 'warning': return 'text-yellow-600 bg-yellow-50';
|
||||
default: return 'text-green-600 bg-green-50';
|
||||
}
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: items.length,
|
||||
ingredients: items.filter(i => i.category === 'ingredient').length,
|
||||
products: items.filter(i => i.category === 'finished_product').length,
|
||||
lowStock: items.filter(i => i.current_stock <= i.min_stock).length,
|
||||
nearExpiry: items.filter(i => isNearExpiry(i.expiry_date)).length,
|
||||
refrigerated: items.filter(i => i.requires_refrigeration).length
|
||||
};
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="text-center py-16">
|
||||
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-gray-600 mb-2">
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{stats.total} elementos configurados
|
||||
</p>
|
||||
</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>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
|
||||
Configuración de Inventario
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Total</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="p-6 text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{items.length}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Elementos totales</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.ingredients}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Ingredientes</p>
|
||||
|
||||
<Card className="p-6 text-center">
|
||||
<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 className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.products}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Productos</p>
|
||||
|
||||
<Card className="p-6 text-center">
|
||||
<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 className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-error)]">{stats.lowStock}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Stock Bajo</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-warning)]">{stats.nearExpiry}</p>
|
||||
<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 className="p-6 text-center">
|
||||
<div className="text-3xl font-bold text-red-600 mb-2">
|
||||
{items.filter(item => getStockStatus(item) === 'critical').length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Stock crítico</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4">
|
||||
{/* Controls */}
|
||||
<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">
|
||||
<label className="text-sm font-medium text-[var(--text-secondary)]">Filtrar:</label>
|
||||
<div className="flex space-x-2">
|
||||
{[
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Inventory Items */}
|
||||
<div className="space-y-4">
|
||||
{getFilteredItems().map((item) => {
|
||||
const stockStatus = getStockStatus(item);
|
||||
const nearExpiry = isNearExpiry(item.expiry_date);
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value as any)}
|
||||
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
|
||||
>
|
||||
<option value="all">Todos los elementos</option>
|
||||
<option value="ingredient">Ingredientes</option>
|
||||
<option value="finished_product">Productos terminados</option>
|
||||
</select>
|
||||
|
||||
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>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{filteredItems.length} elementos
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Item Info */}
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{item.name}</h4>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant={item.category === 'ingredient' ? 'blue' : 'green'}>
|
||||
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto'}
|
||||
{/* 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 items-center space-x-3 mb-2">
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">{item.name}</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
|
||||
</Badge>
|
||||
<Badge className={`text-xs ${getStockStatusColor(getStockStatus(item))}`}>
|
||||
Stock: {getStockStatus(item)}
|
||||
</Badge>
|
||||
{item.requires_refrigeration && (
|
||||
<Badge variant="outline" className="text-xs text-blue-600">
|
||||
Refrigeración
|
||||
</Badge>
|
||||
{item.requires_refrigeration && (
|
||||
<Badge variant="gray">❄️ Refrigeración</Badge>
|
||||
)}
|
||||
<Badge variant={stockStatus.color}>
|
||||
{stockStatus.text}
|
||||
</Badge>
|
||||
{nearExpiry && (
|
||||
<Badge variant="red">Vence Pronto</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)]">
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Stock Actual: </span>
|
||||
<span className={`font-medium ${stockStatus.status === 'low' ? 'text-[var(--color-error)]' : 'text-[var(--text-primary)]'}`}>
|
||||
{item.current_stock} {item.unit}
|
||||
</span>
|
||||
</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 && (
|
||||
<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.cost_per_unit && (
|
||||
<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 className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||
<div className="flex space-x-4">
|
||||
<span>Stock actual: <span className="font-medium">{item.current_stock} {item.unit}</span></span>
|
||||
<span>Mínimo: <span className="font-medium">{item.min_stock}</span></span>
|
||||
<span>Máximo: <span className="font-medium">{item.max_stock}</span></span>
|
||||
</div>
|
||||
{item.expiry_date && (
|
||||
<div>Vence: <span className="font-medium">{item.expiry_date}</span></div>
|
||||
)}
|
||||
{item.supplier && (
|
||||
<div>Proveedor: <span className="font-medium">{item.supplier}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4 mt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditingItem(item)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{(stats.lowStock > 0 || stats.nearExpiry > 0) && (
|
||||
<Card className="p-4 bg-[var(--color-warning-50)] border-[var(--color-warning-200)]">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--color-warning-800)] mb-1">Advertencias de Inventario</h4>
|
||||
{stats.lowStock > 0 && (
|
||||
<p className="text-sm text-[var(--color-warning-700)] mb-1">
|
||||
• {stats.lowStock} elemento(s) con stock bajo
|
||||
</p>
|
||||
)}
|
||||
{stats.nearExpiry > 0 && (
|
||||
<p className="text-sm text-[var(--color-warning-700)]">
|
||||
• {stats.nearExpiry} elemento(s) próximos a vencer
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={isFirstStep}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!data.inventoryConfigured}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
|
||||
>
|
||||
{isLastStep ? 'Finalizar' : 'Siguiente'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for editing inventory items
|
||||
interface InventoryItemFormProps {
|
||||
// Inventory Item Editor Component
|
||||
const InventoryItemEditor: React.FC<{
|
||||
item: InventoryItem;
|
||||
onSave: (item: InventoryItem) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
}> = ({ item, onSave, onCancel }) => {
|
||||
const [editedItem, setEditedItem] = useState<InventoryItem>(item);
|
||||
|
||||
const InventoryItemForm: React.FC<InventoryItemFormProps> = ({ item, onSave, onCancel }) => {
|
||||
const [formData, setFormData] = useState(item);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim()) {
|
||||
const handleSave = () => {
|
||||
if (!editedItem.name.trim()) {
|
||||
alert('El nombre es requerido');
|
||||
return;
|
||||
}
|
||||
if (formData.min_stock >= formData.max_stock) {
|
||||
alert('El stock máximo debe ser mayor al mínimo');
|
||||
if (editedItem.min_stock < 0 || editedItem.max_stock <= editedItem.min_stock) {
|
||||
alert('Los niveles de stock deben ser válidos (máximo > mínimo >= 0)');
|
||||
return;
|
||||
}
|
||||
onSave(formData);
|
||||
onSave(editedItem);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Nombre *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Nombre del producto/ingrediente"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Categoría *
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, 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)]"
|
||||
<label className="block text-sm font-medium mb-1">Nombre</label>
|
||||
<Input
|
||||
value={editedItem.name}
|
||||
onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
|
||||
placeholder="Nombre del producto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Categoría</label>
|
||||
<select
|
||||
value={editedItem.category}
|
||||
onChange={(e) => setEditedItem({ ...editedItem, category: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
|
||||
>
|
||||
<option value="ingredient">Ingrediente</option>
|
||||
<option value="finished_product">Producto Terminado</option>
|
||||
<option value="finished_product">Producto terminado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
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>
|
||||
<label className="block text-sm font-medium mb-1">Stock actual</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.current_stock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, current_stock: Number(e.target.value) }))}
|
||||
value={editedItem.current_stock}
|
||||
onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Stock Mín. *
|
||||
</label>
|
||||
<label className="block text-sm font-medium mb-1">Unidad</label>
|
||||
<Input
|
||||
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
|
||||
type="number"
|
||||
value={formData.min_stock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, min_stock: Number(e.target.value) }))}
|
||||
value={editedItem.min_stock}
|
||||
onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Stock Máx. *
|
||||
</label>
|
||||
<label className="block text-sm font-medium mb-1">Stock máximo</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.max_stock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, max_stock: Number(e.target.value) }))}
|
||||
value={editedItem.max_stock}
|
||||
onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Fecha Vencimiento
|
||||
</label>
|
||||
<label className="block text-sm font-medium mb-1">Fecha de vencimiento</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.expiry_date || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
|
||||
value={editedItem.expiry_date || ''}
|
||||
onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Costo por Unidad
|
||||
</label>
|
||||
<label className="block text-sm font-medium mb-1">Proveedor</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.cost_per_unit || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cost_per_unit: Number(e.target.value) }))}
|
||||
min="0"
|
||||
value={editedItem.supplier || ''}
|
||||
onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })}
|
||||
placeholder="Nombre del proveedor"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="refrigeration"
|
||||
checked={formData.requires_refrigeration}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, requires_refrigeration: e.target.checked }))}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="refrigeration" className="text-sm text-[var(--text-primary)]">
|
||||
Requiere refrigeración
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editedItem.requires_refrigeration}
|
||||
onChange={(e) => setEditedItem({ ...editedItem, requires_refrigeration: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">Requiere refrigeración</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Button onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 { Button, Card, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
sales_count: number;
|
||||
estimated_price: number;
|
||||
sales_count?: number;
|
||||
estimated_price?: number;
|
||||
status: 'approved' | 'rejected' | 'pending';
|
||||
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
|
||||
const mockDetectedProducts: Product[] = [
|
||||
{ id: '1', name: 'Pan Integral', category: 'Panadería', confidence: 95, sales_count: 45, estimated_price: 2.50, status: 'pending' },
|
||||
{ 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' },
|
||||
{ id: '4', name: 'Empanada de Pollo', category: 'Salados', confidence: 85, sales_count: 31, estimated_price: 4.50, status: 'pending' },
|
||||
{ id: '5', name: 'Tarta de Manzana', category: 'Repostería', confidence: 78, sales_count: 12, estimated_price: 15.00, status: 'pending' },
|
||||
{ id: '6', name: 'Pan de Centeno', category: 'Panadería', confidence: 91, sales_count: 18, estimated_price: 2.80, status: 'pending' },
|
||||
{ id: '7', name: 'Medialunas', category: 'Bollería', confidence: 87, sales_count: 29, estimated_price: 1.20, status: 'pending' },
|
||||
{ id: '8', name: 'Sandwich Mixto', category: 'Salados', confidence: 82, sales_count: 25, estimated_price: 5.50, status: 'pending' }
|
||||
];
|
||||
// Convert API suggestions to Product interface
|
||||
const convertSuggestionsToProducts = (suggestions: any[]): Product[] => {
|
||||
console.log('ReviewStep - convertSuggestionsToProducts called with:', suggestions);
|
||||
|
||||
const products = suggestions.map((suggestion, index) => ({
|
||||
id: suggestion.suggestion_id || `product-${index}`,
|
||||
name: suggestion.suggested_name,
|
||||
category: suggestion.category,
|
||||
confidence: Math.round(suggestion.confidence_score * 100),
|
||||
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> = ({
|
||||
data,
|
||||
@@ -34,35 +72,128 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
// Generate products from processing results or use mock data
|
||||
const { createAlert } = useAlertActions();
|
||||
|
||||
// Generate products from AI suggestions in processing results
|
||||
const generateProductsFromResults = (results: any) => {
|
||||
if (!results?.product_list) return mockDetectedProducts;
|
||||
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));
|
||||
|
||||
return results.product_list.map((name: string, index: number) => ({
|
||||
id: (index + 1).toString(),
|
||||
name,
|
||||
category: index < 3 ? 'Panadería' : index < 5 ? 'Bollería' : 'Salados',
|
||||
confidence: Math.max(75, results.confidenceScore - Math.random() * 15),
|
||||
sales_count: Math.floor(Math.random() * 50) + 10,
|
||||
estimated_price: Math.random() * 5 + 1.5,
|
||||
status: 'pending' as const
|
||||
}));
|
||||
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) => ({
|
||||
id: `fallback-${index}`,
|
||||
name,
|
||||
original_name: name,
|
||||
suggested_name: name,
|
||||
category: 'Sin clasificar',
|
||||
confidence: 50,
|
||||
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[]>(
|
||||
data.detectedProducts || generateProductsFromResults(data.processingResults)
|
||||
);
|
||||
const [products, setProducts] = useState<Product[]>(() => {
|
||||
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 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(() => {
|
||||
onDataChange({
|
||||
...data,
|
||||
detectedProducts: products,
|
||||
reviewCompleted: products.every(p => p.status !== 'pending')
|
||||
});
|
||||
}, [products]);
|
||||
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({
|
||||
...data,
|
||||
detectedProducts: products,
|
||||
approvedProducts,
|
||||
reviewCompleted
|
||||
});
|
||||
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') => {
|
||||
setProducts(prev => prev.map(product =>
|
||||
@@ -95,191 +226,188 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Total</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<Card className="p-6 text-center">
|
||||
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{stats.total}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Productos detectados</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 text-center">
|
||||
<div className="text-3xl font-bold text-green-600 mb-2">{stats.approved}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Aprobados</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 text-center">
|
||||
<div className="text-3xl font-bold text-red-600 mb-2">{stats.rejected}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Rechazados</div>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Controls */}
|
||||
<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">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
|
||||
>
|
||||
{categories.map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category === 'all' ? 'Todas las categorías' : category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{getFilteredProducts().length} productos
|
||||
</Badge>
|
||||
</div>
|
||||
<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>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Aprobados</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--color-error)]/10 rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-error)]">{stats.rejected}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Rechazados</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--color-warning)]/10 rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-warning)]">{stats.pending}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Pendientes</p>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleBulkAction('approve')}
|
||||
className="text-green-600 border-green-200 hover:bg-green-50"
|
||||
>
|
||||
Aprobar todos
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleBulkAction('reject')}
|
||||
className="text-red-600 border-red-200 hover:bg-red-50"
|
||||
>
|
||||
Rechazar todos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Actions */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-2">
|
||||
Filtrar por categoría:
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
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)]"
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat === 'all' ? 'Todas las categorías' : cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleBulkAction('approve')}
|
||||
className="text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
Aprobar Visibles
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleBulkAction('reject')}
|
||||
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Rechazar Visibles
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Products List */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{getFilteredProducts().map((product) => (
|
||||
<Card key={product.id} className="p-4">
|
||||
<div className="flex items-start 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)]" />
|
||||
)}
|
||||
<Card key={product.id} className="p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">{product.name}</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{product.category}
|
||||
</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>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{product.name}</h4>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant="gray">{product.category}</Badge>
|
||||
{product.status !== 'pending' && (
|
||||
<Badge variant={product.status === 'approved' ? 'green' : 'red'}>
|
||||
{product.status === 'approved' ? 'Aprobado' : 'Rechazado'}
|
||||
</Badge>
|
||||
|
||||
<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 className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||
<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>
|
||||
{product.notes && (
|
||||
<div className="text-xs italic">Nota: {product.notes}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4 mt-1">
|
||||
{product.status === 'pending' ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleProductAction(product.id, 'approve')}
|
||||
className="bg-[var(--color-success)] hover:bg-[var(--color-success)]/90 text-white"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
Aprobar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleProductAction(product.id, 'reject')}
|
||||
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Rechazar
|
||||
</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 className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={product.status === 'approved' ? 'default' : 'outline'}
|
||||
onClick={() => handleProductAction(product.id, 'approve')}
|
||||
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" />
|
||||
Aprobar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={product.status === 'rejected' ? 'default' : 'outline'}
|
||||
onClick={() => handleProductAction(product.id, 'reject')}
|
||||
className={product.status === 'rejected' ? 'bg-red-600 hover:bg-red-700' : 'text-red-600 border-red-200 hover:bg-red-50'}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 mr-1" />
|
||||
Rechazar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
{stats.pending > 0 && (
|
||||
<Card className="p-4 bg-[var(--color-warning)]/5 border-[var(--color-warning)]/20">
|
||||
<div className="flex items-center space-x-3">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-warning)]" />
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">
|
||||
{stats.pending} productos pendientes de revisión
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Revisa todos los productos antes de continuar al siguiente paso
|
||||
</p>
|
||||
</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 (<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>
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={isFirstStep}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!data.reviewCompleted || stats.approved === 0}
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
|
||||
>
|
||||
{isLastStep ? 'Finalizar' : 'Siguiente'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +1,40 @@
|
||||
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 { 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 {
|
||||
id: string;
|
||||
// Frontend supplier interface that matches the form needs
|
||||
interface SupplierFormData {
|
||||
id?: string;
|
||||
name: string;
|
||||
contact_person: string;
|
||||
contact_name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
categories: string[];
|
||||
payment_terms: string;
|
||||
delivery_days: string[];
|
||||
min_order_amount?: number;
|
||||
notes?: string;
|
||||
status: 'active' | 'inactive';
|
||||
created_at: string;
|
||||
delivery_terms: string;
|
||||
tax_id?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
'Harinas', 'Lácteos', 'Levaduras', 'Azúcares', 'Grasas', 'Huevos',
|
||||
'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> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
@@ -69,13 +43,57 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(
|
||||
data.suppliers || mockSuppliers
|
||||
);
|
||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { createAlert } = useAlertActions();
|
||||
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [editingSupplier, setEditingSupplier] = useState<SupplierFormData | null>(null);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
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(() => {
|
||||
onDataChange({
|
||||
...data,
|
||||
@@ -85,60 +103,202 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
}, [suppliers]);
|
||||
|
||||
const handleAddSupplier = () => {
|
||||
const newSupplier: Supplier = {
|
||||
id: Date.now().toString(),
|
||||
const newSupplier: SupplierFormData = {
|
||||
name: '',
|
||||
contact_person: '',
|
||||
contact_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
categories: [],
|
||||
payment_terms: '30 días',
|
||||
delivery_days: [],
|
||||
status: 'active',
|
||||
created_at: new Date().toISOString().split('T')[0]
|
||||
delivery_terms: 'Recoger en tienda',
|
||||
tax_id: '',
|
||||
is_active: true
|
||||
};
|
||||
setEditingSupplier(newSupplier);
|
||||
setIsAddingNew(true);
|
||||
};
|
||||
|
||||
const handleSaveSupplier = (supplier: Supplier) => {
|
||||
const handleSaveSupplier = async (supplierData: SupplierFormData) => {
|
||||
if (isAddingNew) {
|
||||
setSuppliers(prev => [...prev, supplier]);
|
||||
setCreating(true);
|
||||
try {
|
||||
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 {
|
||||
setSuppliers(prev => prev.map(s => s.id === supplier.id ? supplier : s));
|
||||
// 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);
|
||||
setIsAddingNew(false);
|
||||
};
|
||||
|
||||
const handleDeleteSupplier = (id: string) => {
|
||||
if (window.confirm('¿Estás seguro de eliminar este proveedor?')) {
|
||||
setSuppliers(prev => prev.filter(s => s.id !== id));
|
||||
const handleDeleteSupplier = async (id: string) => {
|
||||
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));
|
||||
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) => {
|
||||
setSuppliers(prev => prev.map(s =>
|
||||
s.id === id
|
||||
? { ...s, status: s.status === 'active' ? 'inactive' : 'active' }
|
||||
: s
|
||||
));
|
||||
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 =>
|
||||
s.id === id ? response.data : 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 = () => {
|
||||
return filterStatus === 'all'
|
||||
? suppliers
|
||||
: suppliers.filter(s => s.status === filterStatus);
|
||||
if (filterStatus === 'all') {
|
||||
return suppliers;
|
||||
}
|
||||
return suppliers.filter(s =>
|
||||
filterStatus === 'active' ? s.is_active : !s.is_active
|
||||
);
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: suppliers.length,
|
||||
active: suppliers.filter(s => s.status === 'active').length,
|
||||
inactive: suppliers.filter(s => s.status === 'inactive').length,
|
||||
categories: Array.from(new Set(suppliers.flatMap(s => s.categories))).length
|
||||
active: suppliers.filter(s => s.is_active).length,
|
||||
inactive: suppliers.filter(s => !s.is_active).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 (
|
||||
<div className="space-y-6">
|
||||
{/* Optional Step Notice */}
|
||||
@@ -161,8 +321,13 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddSupplier}
|
||||
disabled={creating}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{creating ? (
|
||||
<Loader className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Agregar Proveedor
|
||||
</Button>
|
||||
</div>
|
||||
@@ -184,8 +349,8 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
<p className="text-xs text-[var(--text-secondary)]">Inactivos</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.categories}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Categorías</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.totalOrders}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Órdenes</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -223,12 +388,12 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
<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 ${
|
||||
supplier.status === 'active'
|
||||
supplier.is_active
|
||||
? 'bg-[var(--color-success)]/10'
|
||||
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
|
||||
}`}>
|
||||
<Truck className={`w-4 h-4 ${
|
||||
supplier.status === 'active'
|
||||
supplier.is_active
|
||||
? 'text-[var(--color-success)]'
|
||||
: '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>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant={supplier.status === 'active' ? 'green' : 'gray'}>
|
||||
{supplier.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
<Badge variant={supplier.is_active ? 'green' : 'gray'}>
|
||||
{supplier.is_active ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
{supplier.categories.slice(0, 2).map((cat, idx) => (
|
||||
<Badge key={idx} variant="blue">{cat}</Badge>
|
||||
))}
|
||||
{supplier.categories.length > 2 && (
|
||||
<Badge variant="gray">+{supplier.categories.length - 2}</Badge>
|
||||
{supplier.rating && (
|
||||
<Badge variant="blue">★ {supplier.rating.toFixed(1)}</Badge>
|
||||
)}
|
||||
{supplier.performance_metrics.total_orders > 0 && (
|
||||
<Badge variant="purple">{supplier.performance_metrics.total_orders} órdenes</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)] mb-2">
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Contacto: </span>
|
||||
<span className="font-medium text-[var(--text-primary)]">{supplier.contact_person}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Entrega: </span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{supplier.delivery_days.slice(0, 2).join(', ')}
|
||||
{supplier.delivery_days.length > 2 && ` +${supplier.delivery_days.length - 2}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Pago: </span>
|
||||
<span className="font-medium text-[var(--text-primary)]">{supplier.payment_terms}</span>
|
||||
</div>
|
||||
|
||||
{supplier.min_order_amount && (
|
||||
{supplier.contact_name && (
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Mín: </span>
|
||||
<span className="font-medium text-[var(--text-primary)]">${supplier.min_order_amount}</span>
|
||||
<span className="text-[var(--text-tertiary)]">Contacto: </span>
|
||||
<span className="font-medium text-[var(--text-primary)]">{supplier.contact_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supplier.delivery_terms && (
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Entrega: </span>
|
||||
<span className="font-medium text-[var(--text-primary)]">{supplier.delivery_terms}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supplier.payment_terms && (
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Pago: </span>
|
||||
<span className="font-medium text-[var(--text-primary)]">{supplier.payment_terms}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
<span>{supplier.phone}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" />
|
||||
<span>{supplier.email}</span>
|
||||
</div>
|
||||
{supplier.phone && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
<span>{supplier.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{supplier.email && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" />
|
||||
<span>{supplier.email}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>{supplier.address}</span>
|
||||
</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">
|
||||
<span className="text-[var(--text-tertiary)]">Notas: </span>
|
||||
<span className="text-[var(--text-primary)]">{supplier.notes}</span>
|
||||
<span className="text-[var(--text-tertiary)]">Rendimiento: </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>
|
||||
@@ -306,7 +478,24 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
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" />
|
||||
Editar
|
||||
@@ -315,13 +504,14 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleSupplierStatus(supplier.id)}
|
||||
className={supplier.status === 'active'
|
||||
onClick={() => toggleSupplierStatus(supplier.id, supplier.is_active)}
|
||||
className={supplier.is_active
|
||||
? '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'
|
||||
}
|
||||
disabled={updating}
|
||||
>
|
||||
{supplier.status === 'active' ? 'Pausar' : 'Activar'}
|
||||
{supplier.is_active ? 'Pausar' : 'Activar'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -329,19 +519,29 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteSupplier(supplier.id)}
|
||||
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
||||
disabled={deleting === supplier.id}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{deleting === supplier.id ? (
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{getFilteredSuppliers().length === 0 && (
|
||||
{getFilteredSuppliers().length === 0 && !loading && (
|
||||
<Card className="p-8 text-center">
|
||||
<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>
|
||||
<Button onClick={handleAddSupplier}>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{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" />
|
||||
Agregar Primer Proveedor
|
||||
</Button>
|
||||
@@ -364,6 +564,8 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
setEditingSupplier(null);
|
||||
setIsAddingNew(false);
|
||||
}}
|
||||
isCreating={creating}
|
||||
isUpdating={updating}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -388,45 +590,37 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
|
||||
// Component for editing suppliers
|
||||
interface SupplierFormProps {
|
||||
supplier: Supplier;
|
||||
onSave: (supplier: Supplier) => void;
|
||||
supplier: SupplierFormData;
|
||||
onSave: (supplier: SupplierFormData) => 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 handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
alert('El nombre es requerido');
|
||||
alert('El nombre de la empresa es requerido');
|
||||
return;
|
||||
}
|
||||
if (!formData.contact_person.trim()) {
|
||||
alert('El contacto es requerido');
|
||||
if (!formData.address.trim()) {
|
||||
alert('La dirección es requerida');
|
||||
return;
|
||||
}
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-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}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Molinos del Sur"
|
||||
disabled={isCreating || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Persona de Contacto *
|
||||
Persona de Contacto
|
||||
</label>
|
||||
<Input
|
||||
value={formData.contact_person}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, contact_person: e.target.value }))}
|
||||
value={formData.contact_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, contact_name: e.target.value }))}
|
||||
placeholder="Juan Pérez"
|
||||
disabled={isCreating || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,6 +658,7 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
|
||||
placeholder="+1 555-0123"
|
||||
disabled={isCreating || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -474,40 +671,23 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="ventas@proveedor.com"
|
||||
disabled={isCreating || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Dirección
|
||||
Dirección *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
|
||||
placeholder="Av. Industrial 123, Zona Sur"
|
||||
disabled={isCreating || isUpdating}
|
||||
/>
|
||||
</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>
|
||||
<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}
|
||||
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)]"
|
||||
disabled={isCreating || isUpdating}
|
||||
>
|
||||
<option value="Inmediato">Inmediato</option>
|
||||
<option value="15 días">15 días</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>
|
||||
{paymentTermsOptions.map(term => (
|
||||
<option key={term} value={term}>{term}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Pedido Mínimo
|
||||
Términos de Entrega
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.min_order_amount || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, min_order_amount: Number(e.target.value) }))}
|
||||
placeholder="200"
|
||||
min="0"
|
||||
value={formData.delivery_terms}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, delivery_terms: e.target.value }))}
|
||||
placeholder="Recoger en tienda"
|
||||
disabled={isCreating || isUpdating}
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Notas
|
||||
RUT/NIT (Opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
||||
placeholder="Información adicional sobre el proveedor..."
|
||||
className="w-full p-2 border border-[var(--border-secondary)] rounded resize-none focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
rows={3}
|
||||
<Input
|
||||
value={formData.tax_id || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, tax_id: e.target.value }))}
|
||||
placeholder="12345678-9"
|
||||
disabled={isCreating || isUpdating}
|
||||
/>
|
||||
</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">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isCreating || isUpdating}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Guardar Proveedor
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating || isUpdating}
|
||||
>
|
||||
{isCreating || isUpdating ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 mr-2 animate-spin" />
|
||||
{isCreating ? 'Creando...' : 'Actualizando...'}
|
||||
</>
|
||||
) : (
|
||||
'Guardar Proveedor'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,85 +1,17 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
|
||||
// Request/Response Types based on backend schemas
|
||||
export interface UserRegistration {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
tenant_name?: string;
|
||||
role?: 'user' | 'admin' | 'manager';
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
import {
|
||||
UserRegistration,
|
||||
UserLogin,
|
||||
UserData,
|
||||
TokenResponse,
|
||||
RefreshTokenRequest,
|
||||
PasswordChange,
|
||||
PasswordReset,
|
||||
PasswordResetConfirm,
|
||||
TokenVerification,
|
||||
UserResponse,
|
||||
UserUpdate
|
||||
} from '../../types/auth.types';
|
||||
|
||||
class AuthService {
|
||||
private readonly baseUrl = '/auth';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
const getAuthData = () => {
|
||||
@@ -27,22 +28,13 @@ const clearAuthData = () => {
|
||||
localStorage.removeItem('auth-storage');
|
||||
};
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data: T;
|
||||
// Client-specific error interface
|
||||
interface ClientError {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ErrorDetail {
|
||||
message: string;
|
||||
code?: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
success: boolean;
|
||||
error: ErrorDetail;
|
||||
error: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -116,7 +108,7 @@ class ApiClient {
|
||||
|
||||
// Handle network errors
|
||||
if (!error.response) {
|
||||
const networkError: ApiError = {
|
||||
const networkError: ClientError = {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Network error - please check your connection',
|
||||
|
||||
@@ -1,67 +1,27 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
|
||||
// External data 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 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 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;
|
||||
}
|
||||
import {
|
||||
WeatherData,
|
||||
WeatherDataParams,
|
||||
TrafficData,
|
||||
TrafficDataParams,
|
||||
TrafficPatternsParams,
|
||||
TrafficPattern,
|
||||
EventData,
|
||||
EventsParams,
|
||||
CustomEventCreate,
|
||||
LocationConfig,
|
||||
LocationCreate,
|
||||
ExternalFactorsImpact,
|
||||
ExternalFactorsParams,
|
||||
DataQualityReport,
|
||||
DataSettings,
|
||||
DataSettingsUpdate,
|
||||
RefreshDataResponse,
|
||||
DeleteResponse,
|
||||
WeatherCondition,
|
||||
EventType,
|
||||
RefreshInterval
|
||||
} from '../../types/data.types';
|
||||
|
||||
class DataService {
|
||||
private readonly baseUrl = '/data';
|
||||
@@ -75,16 +35,7 @@ class DataService {
|
||||
return apiClient.get(`${this.baseUrl}/locations/${locationId}`);
|
||||
}
|
||||
|
||||
async createLocation(locationData: {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
city: string;
|
||||
country?: string;
|
||||
is_primary?: boolean;
|
||||
data_sources?: LocationConfig['data_sources'];
|
||||
}): Promise<ApiResponse<LocationConfig>> {
|
||||
async createLocation(locationData: LocationCreate): Promise<ApiResponse<LocationConfig>> {
|
||||
return apiClient.post(`${this.baseUrl}/locations`, locationData);
|
||||
}
|
||||
|
||||
@@ -92,18 +43,12 @@ class DataService {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Weather data
|
||||
async getWeatherData(params?: {
|
||||
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 }>> {
|
||||
async getWeatherData(params?: WeatherDataParams): Promise<ApiResponse<{ items: WeatherData[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
@@ -129,7 +74,7 @@ class DataService {
|
||||
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
|
||||
? `${this.baseUrl}/weather/refresh/${locationId}`
|
||||
: `${this.baseUrl}/weather/refresh`;
|
||||
@@ -138,14 +83,7 @@ class DataService {
|
||||
}
|
||||
|
||||
// Traffic data
|
||||
async getTrafficData(params?: {
|
||||
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 }>> {
|
||||
async getTrafficData(params?: TrafficDataParams): Promise<ApiResponse<{ items: TrafficData[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
@@ -167,15 +105,7 @@ class DataService {
|
||||
return apiClient.get(`${this.baseUrl}/traffic/current/${locationId}`);
|
||||
}
|
||||
|
||||
async getTrafficPatterns(locationId: string, params?: {
|
||||
days_back?: number;
|
||||
granularity?: 'hourly' | 'daily';
|
||||
}): Promise<ApiResponse<Array<{
|
||||
period: string;
|
||||
average_traffic_level: number;
|
||||
peak_hours: number[];
|
||||
congestion_patterns: Record<string, number>;
|
||||
}>>> {
|
||||
async getTrafficPatterns(locationId: string, params?: TrafficPatternsParams): Promise<ApiResponse<TrafficPattern[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
@@ -193,7 +123,7 @@ class DataService {
|
||||
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
|
||||
? `${this.baseUrl}/traffic/refresh/${locationId}`
|
||||
: `${this.baseUrl}/traffic/refresh`;
|
||||
@@ -202,14 +132,7 @@ class DataService {
|
||||
}
|
||||
|
||||
// Events data
|
||||
async getEvents(params?: {
|
||||
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 }>> {
|
||||
async getEvents(params?: EventsParams): Promise<ApiResponse<{ items: EventData[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
@@ -231,16 +154,7 @@ class DataService {
|
||||
return apiClient.get(`${this.baseUrl}/events/upcoming/${locationId}?days=${days}`);
|
||||
}
|
||||
|
||||
async createCustomEvent(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>> {
|
||||
async createCustomEvent(eventData: CustomEventCreate): Promise<ApiResponse<EventData>> {
|
||||
return apiClient.post(`${this.baseUrl}/events`, eventData);
|
||||
}
|
||||
|
||||
@@ -248,11 +162,11 @@ class DataService {
|
||||
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}`);
|
||||
}
|
||||
|
||||
async refreshEventsData(locationId?: string): Promise<ApiResponse<{ message: string; updated_records: number }>> {
|
||||
async refreshEventsData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
|
||||
const url = locationId
|
||||
? `${this.baseUrl}/events/refresh/${locationId}`
|
||||
: `${this.baseUrl}/events/refresh`;
|
||||
@@ -261,27 +175,7 @@ class DataService {
|
||||
}
|
||||
|
||||
// Combined analytics
|
||||
async getExternalFactorsImpact(params?: {
|
||||
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;
|
||||
};
|
||||
}>> {
|
||||
async getExternalFactorsImpact(params?: ExternalFactorsParams): Promise<ApiResponse<ExternalFactorsImpact>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
@@ -299,64 +193,21 @@ class DataService {
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getDataQualityReport(): Promise<ApiResponse<{
|
||||
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;
|
||||
}>;
|
||||
}>> {
|
||||
async getDataQualityReport(): Promise<ApiResponse<DataQualityReport>> {
|
||||
return apiClient.get(`${this.baseUrl}/quality-report`);
|
||||
}
|
||||
|
||||
// Data configuration
|
||||
async getDataSettings(): Promise<ApiResponse<{
|
||||
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;
|
||||
};
|
||||
}>> {
|
||||
async getDataSettings(): Promise<ApiResponse<DataSettings>> {
|
||||
return apiClient.get(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
async updateDataSettings(settings: {
|
||||
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>> {
|
||||
async updateDataSettings(settings: DataSettingsUpdate): Promise<ApiResponse<DataSettings>> {
|
||||
return apiClient.put(`${this.baseUrl}/settings`, settings);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
getWeatherConditions(): { value: string; label: string; impact: 'positive' | 'negative' | 'neutral' }[] {
|
||||
getWeatherConditions(): WeatherCondition[] {
|
||||
return [
|
||||
{ value: 'sunny', label: 'Sunny', impact: 'positive' },
|
||||
{ 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 [
|
||||
{ value: 'festival', label: 'Festival', 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 [
|
||||
{ value: 5, label: '5 minutes', suitable_for: ['traffic'] },
|
||||
{ value: 15, label: '15 minutes', suitable_for: ['traffic'] },
|
||||
|
||||
@@ -1,68 +1,10 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
|
||||
// Request/Response Types
|
||||
export interface ForecastRequest {
|
||||
product_name: string;
|
||||
days_ahead: number;
|
||||
start_date?: string;
|
||||
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';
|
||||
}
|
||||
import {
|
||||
ForecastRequest,
|
||||
ForecastResponse,
|
||||
PredictionBatch,
|
||||
ModelPerformance
|
||||
} from '../../types/forecasting.types';
|
||||
|
||||
class ForecastingService {
|
||||
private readonly baseUrl = '/forecasting';
|
||||
|
||||
@@ -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
|
||||
export enum UnitOfMeasure {
|
||||
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 {
|
||||
// Service-specific types for Create/Update operations
|
||||
interface IngredientCreate {
|
||||
name: string;
|
||||
product_type?: ProductType;
|
||||
sku?: string;
|
||||
@@ -57,74 +42,11 @@ export interface IngredientCreate {
|
||||
allergen_info?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface IngredientUpdate {
|
||||
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;
|
||||
interface IngredientUpdate extends Partial<IngredientCreate> {
|
||||
is_active?: boolean;
|
||||
is_perishable?: boolean;
|
||||
allergen_info?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface IngredientResponse {
|
||||
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 {
|
||||
interface StockCreate {
|
||||
ingredient_id: string;
|
||||
batch_number?: string;
|
||||
lot_number?: string;
|
||||
@@ -140,50 +62,12 @@ export interface StockCreate {
|
||||
quality_status?: string;
|
||||
}
|
||||
|
||||
export interface StockUpdate {
|
||||
batch_number?: string;
|
||||
lot_number?: string;
|
||||
supplier_batch_ref?: string;
|
||||
current_quantity?: number;
|
||||
interface StockUpdate extends Partial<StockCreate> {
|
||||
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;
|
||||
quality_status?: string;
|
||||
}
|
||||
|
||||
export interface StockResponse {
|
||||
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 {
|
||||
interface StockMovementCreate {
|
||||
ingredient_id: string;
|
||||
stock_id?: string;
|
||||
movement_type: StockMovementType;
|
||||
@@ -196,107 +80,11 @@ export interface StockMovementCreate {
|
||||
movement_date?: string;
|
||||
}
|
||||
|
||||
export interface StockMovementResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
ingredient_id: string;
|
||||
stock_id?: string;
|
||||
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;
|
||||
}
|
||||
// Type aliases for response consistency
|
||||
type IngredientResponse = Ingredient;
|
||||
type StockResponse = Stock;
|
||||
type StockMovementResponse = StockMovement;
|
||||
type StockAlertResponse = StockAlert;
|
||||
|
||||
class InventoryService {
|
||||
private readonly baseUrl = '/inventory';
|
||||
|
||||
@@ -88,6 +88,7 @@ class OnboardingApiService {
|
||||
|
||||
/**
|
||||
* Step 1: Validate uploaded file and extract unique products
|
||||
* Now uses Sales Service directly
|
||||
*/
|
||||
async validateOnboardingFile(
|
||||
tenantId: string,
|
||||
@@ -98,7 +99,7 @@ class OnboardingApiService {
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post<OnboardingFileValidationResponse>(
|
||||
`${this.basePath}/${tenantId}/onboarding/validate-file`,
|
||||
`${this.salesBasePath}/${tenantId}/sales/import/validate`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
@@ -120,6 +121,7 @@ class OnboardingApiService {
|
||||
|
||||
/**
|
||||
* Step 2: Generate AI-powered inventory suggestions
|
||||
* Now uses Inventory Service directly
|
||||
*/
|
||||
async generateInventorySuggestions(
|
||||
tenantId: string,
|
||||
@@ -127,18 +129,24 @@ class OnboardingApiService {
|
||||
productList: string[]
|
||||
): Promise<ProductSuggestionsResponse> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('product_list', JSON.stringify(productList));
|
||||
if (!productList || !Array.isArray(productList) || productList.length === 0) {
|
||||
throw new Error('Product list is empty or invalid');
|
||||
}
|
||||
|
||||
// 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>(
|
||||
`${this.basePath}/${tenantId}/onboarding/generate-suggestions`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
`${this.basePath}/${tenantId}/inventory/classify-products-batch`,
|
||||
requestData
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
@@ -154,34 +162,56 @@ class OnboardingApiService {
|
||||
|
||||
/**
|
||||
* Step 3: Create inventory items from approved suggestions
|
||||
* Now uses Inventory Service directly
|
||||
*/
|
||||
async createInventoryFromSuggestions(
|
||||
tenantId: string,
|
||||
approvedSuggestions: any[]
|
||||
): Promise<InventoryCreationResponse> {
|
||||
try {
|
||||
const response = await apiClient.post<InventoryCreationResponse>(
|
||||
`${this.basePath}/${tenantId}/onboarding/create-inventory`,
|
||||
{
|
||||
suggestions: approvedSuggestions
|
||||
}
|
||||
);
|
||||
const createdItems: any[] = [];
|
||||
const failedItems: any[] = [];
|
||||
const inventoryMapping: { [productName: string]: string } = {};
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Inventory creation failed: ${response.error || 'Unknown error'}`);
|
||||
}
|
||||
// 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
|
||||
};
|
||||
|
||||
// Create inventory mapping if not provided
|
||||
if (!response.data.inventory_mapping) {
|
||||
response.data.inventory_mapping = {};
|
||||
response.data.created_items.forEach((item, index) => {
|
||||
if (approvedSuggestions[index]) {
|
||||
response.data.inventory_mapping![approvedSuggestions[index].original_name] = item.id;
|
||||
const response = await apiClient.post<any>(
|
||||
`${this.basePath}/${tenantId}/ingredients`,
|
||||
ingredientData
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
return response.data;
|
||||
const result = {
|
||||
created_items: createdItems,
|
||||
failed_items: failedItems,
|
||||
total_approved: approvedSuggestions.length,
|
||||
success_rate: createdItems.length / approvedSuggestions.length,
|
||||
inventory_mapping: inventoryMapping
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Inventory creation failed:', error);
|
||||
throw error;
|
||||
@@ -190,6 +220,7 @@ class OnboardingApiService {
|
||||
|
||||
/**
|
||||
* Step 4: Import sales data with inventory mapping
|
||||
* Now uses Sales Service directly with validation first
|
||||
*/
|
||||
async importSalesWithInventory(
|
||||
tenantId: string,
|
||||
@@ -197,12 +228,16 @@ class OnboardingApiService {
|
||||
inventoryMapping: { [productName: string]: string }
|
||||
): Promise<SalesImportResponse> {
|
||||
try {
|
||||
// First validate the file with inventory mapping
|
||||
await this.validateSalesData(tenantId, file);
|
||||
|
||||
// Then import the sales data
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('inventory_mapping', JSON.stringify(inventoryMapping));
|
||||
|
||||
formData.append('update_existing', 'true');
|
||||
|
||||
const response = await apiClient.post<SalesImportResponse>(
|
||||
`${this.basePath}/${tenantId}/onboarding/import-sales`,
|
||||
`${this.salesBasePath}/${tenantId}/sales/import`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
@@ -224,25 +259,80 @@ class OnboardingApiService {
|
||||
|
||||
/**
|
||||
* Get business model specific recommendations
|
||||
* Returns static recommendations since orchestration is removed
|
||||
*/
|
||||
async getBusinessModelGuide(
|
||||
tenantId: string,
|
||||
model: 'production' | 'retail' | 'hybrid'
|
||||
): Promise<BusinessModelGuide> {
|
||||
try {
|
||||
const response = await apiClient.get<BusinessModelGuide>(
|
||||
`${this.basePath}/${tenantId}/onboarding/business-model-guide?model=${model}`
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Failed to get business model guide: ${response.error || 'Unknown error'}`);
|
||||
// Return static business model guides since we removed orchestration
|
||||
const guides = {
|
||||
production: {
|
||||
title: 'Production Bakery Setup',
|
||||
description: 'Your bakery focuses on creating products from raw ingredients.',
|
||||
next_steps: [
|
||||
'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;
|
||||
} catch (error) {
|
||||
console.error('Failed to get business model guide:', error);
|
||||
throw error;
|
||||
}
|
||||
return guides[model] || guides.hybrid;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,14 +456,18 @@ class OnboardingApiService {
|
||||
|
||||
/**
|
||||
* 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[] }> {
|
||||
try {
|
||||
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) {
|
||||
console.warn('Could not check onboarding status:', error);
|
||||
return { completed: false, steps_completed: [] };
|
||||
@@ -382,11 +476,12 @@ class OnboardingApiService {
|
||||
|
||||
/**
|
||||
* Utility: Mark onboarding as complete
|
||||
* Uses Auth Service for user progress tracking
|
||||
*/
|
||||
async completeOnboarding(tenantId: string, metadata?: any): Promise<void> {
|
||||
try {
|
||||
await apiClient.post(
|
||||
`${this.basePath}/${tenantId}/onboarding/complete`,
|
||||
'/me/onboarding/complete',
|
||||
{ metadata }
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -394,6 +489,7 @@ class OnboardingApiService {
|
||||
// Don't throw error, this is not critical
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const onboardingApiService = new OnboardingApiService();
|
||||
|
||||
@@ -1,134 +1,25 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
|
||||
// Enums
|
||||
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',
|
||||
}
|
||||
|
||||
// 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;
|
||||
}>;
|
||||
}
|
||||
import { apiClient } from './client';
|
||||
import { ApiResponse } from '../../types/api.types';
|
||||
import {
|
||||
OrderStatus,
|
||||
OrderType,
|
||||
OrderItem,
|
||||
OrderCreate,
|
||||
OrderUpdate,
|
||||
OrderResponse,
|
||||
Customer,
|
||||
OrderAnalytics,
|
||||
OrderFilters,
|
||||
CustomerFilters,
|
||||
OrderTrendsParams,
|
||||
OrderTrendData
|
||||
} from '../../types/orders.types';
|
||||
|
||||
class OrdersService {
|
||||
private readonly baseUrl = '/orders';
|
||||
|
||||
// Order management
|
||||
async getOrders(params?: {
|
||||
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 }>> {
|
||||
async getOrders(params?: OrderFilters): Promise<ApiResponse<{ items: OrderResponse[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
@@ -179,11 +70,7 @@ class OrdersService {
|
||||
}
|
||||
|
||||
// Customer management
|
||||
async getCustomers(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
}): Promise<ApiResponse<{ items: Customer[]; total: number; page: number; size: number; pages: number }>> {
|
||||
async getCustomers(params?: CustomerFilters): Promise<ApiResponse<{ items: Customer[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
@@ -232,16 +119,7 @@ class OrdersService {
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getOrderTrends(params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
granularity?: 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
}): Promise<ApiResponse<Array<{
|
||||
period: string;
|
||||
orders: number;
|
||||
revenue: number;
|
||||
avg_order_value: number;
|
||||
}>>> {
|
||||
async getOrderTrends(params?: OrderTrendsParams): Promise<ApiResponse<OrderTrendData[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
|
||||
@@ -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 {
|
||||
private readonly baseUrl = '/procurement';
|
||||
|
||||
// Purchase Order management
|
||||
async getPurchaseOrders(params?: {
|
||||
page?: number;
|
||||
@@ -141,90 +43,88 @@ class ProcurementService {
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/purchase-orders?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/purchase-orders`;
|
||||
? `/purchase-orders?${queryParams.toString()}`
|
||||
: `/purchase-orders`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
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>> {
|
||||
return apiClient.post(`${this.baseUrl}/purchase-orders`, orderData);
|
||||
return apiClient.post(`/purchase-orders`, orderData);
|
||||
}
|
||||
|
||||
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>> {
|
||||
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 }>> {
|
||||
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>> {
|
||||
return apiClient.post(`${this.baseUrl}/purchase-orders/${orderId}/cancel`, { reason });
|
||||
return apiClient.post(`/purchase-orders/${orderId}/cancel`, { reason });
|
||||
}
|
||||
|
||||
// Supplier management
|
||||
async getSuppliers(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
}): Promise<ApiResponse<{ items: Supplier[]; total: number; page: number; size: number; pages: number }>> {
|
||||
async getSuppliers(params?: SupplierSearchParams): Promise<ApiResponse<SupplierSummary[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
if (params.search_term) queryParams.append('search_term', params.search_term);
|
||||
if (params.supplier_type) queryParams.append('supplier_type', params.supplier_type);
|
||||
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()
|
||||
? `${this.baseUrl}/suppliers?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/suppliers`;
|
||||
? `/suppliers?${queryParams.toString()}`
|
||||
: `/suppliers`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getSupplier(supplierId: string): Promise<ApiResponse<Supplier>> {
|
||||
return apiClient.get(`${this.baseUrl}/suppliers/${supplierId}`);
|
||||
async getSupplier(supplierId: string): Promise<ApiResponse<SupplierResponse>> {
|
||||
return apiClient.get(`/suppliers/${supplierId}`);
|
||||
}
|
||||
|
||||
async createSupplier(supplierData: Omit<Supplier, 'id' | 'tenant_id' | 'performance_metrics' | 'created_at' | 'updated_at'>): Promise<ApiResponse<Supplier>> {
|
||||
return apiClient.post(`${this.baseUrl}/suppliers`, supplierData);
|
||||
async createSupplier(supplierData: SupplierCreate): Promise<ApiResponse<SupplierResponse>> {
|
||||
return apiClient.post(`/suppliers`, supplierData);
|
||||
}
|
||||
|
||||
async updateSupplier(supplierId: string, supplierData: Partial<Supplier>): Promise<ApiResponse<Supplier>> {
|
||||
return apiClient.put(`${this.baseUrl}/suppliers/${supplierId}`, supplierData);
|
||||
async updateSupplier(supplierId: string, supplierData: SupplierUpdate): Promise<ApiResponse<SupplierResponse>> {
|
||||
return apiClient.put(`/suppliers/${supplierId}`, supplierData);
|
||||
}
|
||||
|
||||
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<{
|
||||
supplier: Supplier;
|
||||
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 approveSupplier(supplierId: string, approval: SupplierApproval): Promise<ApiResponse<SupplierResponse>> {
|
||||
return apiClient.post(`/suppliers/${supplierId}/approve`, approval);
|
||||
}
|
||||
|
||||
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
|
||||
async getDeliveries(params?: {
|
||||
page?: number;
|
||||
@@ -246,131 +146,32 @@ class ProcurementService {
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/deliveries?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/deliveries`;
|
||||
? `/deliveries?${queryParams.toString()}`
|
||||
: `/deliveries`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getDelivery(deliveryId: string): Promise<ApiResponse<DeliveryResponse>> {
|
||||
return apiClient.get(`${this.baseUrl}/deliveries/${deliveryId}`);
|
||||
return apiClient.get(`/deliveries/${deliveryId}`);
|
||||
}
|
||||
|
||||
async receiveDelivery(deliveryId: string, deliveryData: {
|
||||
delivered_items: Array<{
|
||||
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 createDelivery(deliveryData: DeliveryCreate): Promise<ApiResponse<DeliveryResponse>> {
|
||||
return apiClient.post(`/deliveries`, deliveryData);
|
||||
}
|
||||
|
||||
async reportDeliveryIssue(deliveryId: string, issue: {
|
||||
issue_type: 'late_delivery' | 'quality_issue' | 'quantity_mismatch' | 'damaged_goods' | 'other';
|
||||
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);
|
||||
async updateDelivery(deliveryId: string, deliveryData: Partial<DeliveryCreate>): Promise<ApiResponse<DeliveryResponse>> {
|
||||
return apiClient.put(`/deliveries/${deliveryId}`, deliveryData);
|
||||
}
|
||||
|
||||
// Analytics and reporting
|
||||
async getProcurementAnalytics(params?: {
|
||||
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()
|
||||
? `${this.baseUrl}/analytics?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/analytics`;
|
||||
|
||||
return apiClient.get(url);
|
||||
async updateDeliveryStatus(deliveryId: string, status: DeliveryStatus, notes?: string): Promise<ApiResponse<DeliveryResponse>> {
|
||||
return apiClient.put(`/deliveries/${deliveryId}/status`, { status, notes });
|
||||
}
|
||||
|
||||
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);
|
||||
async confirmDeliveryReceipt(deliveryId: string, confirmation: DeliveryReceiptConfirmation): Promise<ApiResponse<DeliveryResponse>> {
|
||||
return apiClient.post(`/deliveries/${deliveryId}/confirm-receipt`, confirmation);
|
||||
}
|
||||
|
||||
// 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
|
||||
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();
|
||||
@@ -1,27 +1,21 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
import {
|
||||
ProductionBatchStatus,
|
||||
QualityCheckStatus,
|
||||
ProductionPriority,
|
||||
ProductionBatch,
|
||||
ProductionSchedule,
|
||||
QualityCheck,
|
||||
Recipe
|
||||
} from '../../types/production.types';
|
||||
|
||||
// Enums
|
||||
export enum ProductionBatchStatus {
|
||||
PLANNED = 'planned',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
ON_HOLD = 'on_hold',
|
||||
}
|
||||
|
||||
export enum QualityCheckStatus {
|
||||
PASSED = 'passed',
|
||||
FAILED = 'failed',
|
||||
PENDING = 'pending',
|
||||
REQUIRES_REVIEW = 'requires_review',
|
||||
}
|
||||
|
||||
export enum ProductionPriority {
|
||||
LOW = 'low',
|
||||
NORMAL = 'normal',
|
||||
HIGH = 'high',
|
||||
URGENT = 'urgent',
|
||||
}
|
||||
// Type aliases for service compatibility
|
||||
type ProductionBatchCreate = Omit<ProductionBatch, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>;
|
||||
type ProductionBatchUpdate = Partial<ProductionBatchCreate>;
|
||||
type ProductionBatchResponse = ProductionBatch;
|
||||
type ProductionScheduleEntry = ProductionSchedule;
|
||||
type QualityCheckCreate = Omit<QualityCheck, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>;
|
||||
type QualityCheckResponse = QualityCheck;
|
||||
|
||||
// Request/Response Types
|
||||
export interface ProductionBatchCreate {
|
||||
|
||||
@@ -2,69 +2,76 @@
|
||||
* Training service for ML model training operations
|
||||
*/
|
||||
|
||||
import { ApiClient } from './client';
|
||||
import { apiClient } from './client';
|
||||
import { ApiResponse } from '../../types/api.types';
|
||||
import {
|
||||
TrainingJob,
|
||||
TrainingJobCreate,
|
||||
TrainingJobUpdate
|
||||
} from '../../types/training.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 class TrainingService {
|
||||
private getTenantId(): string {
|
||||
const tenantStorage = localStorage.getItem('tenant-storage');
|
||||
if (tenantStorage) {
|
||||
try {
|
||||
const { state } = JSON.parse(tenantStorage);
|
||||
return state?.currentTenant?.id;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export interface TrainingJobCreate {
|
||||
model_id: string;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface TrainingJobUpdate {
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class TrainingService extends ApiClient {
|
||||
constructor() {
|
||||
super('/ml/training');
|
||||
private getBaseUrl(): string {
|
||||
const tenantId = this.getTenantId();
|
||||
return `/tenants/${tenantId}/training`;
|
||||
}
|
||||
|
||||
async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> {
|
||||
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>> {
|
||||
return this.get(`/${id}`);
|
||||
return apiClient.get(`${this.getBaseUrl()}/jobs/${id}`);
|
||||
}
|
||||
|
||||
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>> {
|
||||
return this.put(`/${id}`, data);
|
||||
return apiClient.put(`${this.getBaseUrl()}/jobs/${id}`, data);
|
||||
}
|
||||
|
||||
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>> {
|
||||
return this.post(`/${id}/start`);
|
||||
return apiClient.post(`${this.getBaseUrl()}/jobs/${id}/start`);
|
||||
}
|
||||
|
||||
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[]>> {
|
||||
return this.get(`/${id}/logs`);
|
||||
return apiClient.get(`${this.getBaseUrl()}/jobs/${id}/logs`);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -75,7 +75,8 @@ export const useTenantStore = create<TenantState>()(
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// 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) {
|
||||
throw new Error('User not authenticated');
|
||||
@@ -130,7 +131,8 @@ export const useTenantStore = create<TenantState>()(
|
||||
if (!currentTenant) return false;
|
||||
|
||||
// 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
|
||||
if (user?.role === 'admin') return true;
|
||||
|
||||
@@ -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> {
|
||||
data: T;
|
||||
success: boolean;
|
||||
data?: T;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
error?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// Error response structure
|
||||
// FastAPI error response structure
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
error: ErrorDetail;
|
||||
timestamp: string;
|
||||
request_id?: string;
|
||||
detail: string | ValidationError[];
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ErrorDetail {
|
||||
message: string;
|
||||
code?: string;
|
||||
field?: string;
|
||||
details?: Record<string, any>;
|
||||
export interface ValidationError {
|
||||
loc: (string | number)[];
|
||||
msg: string;
|
||||
type: string;
|
||||
ctx?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Pagination types
|
||||
// Pagination types (used by backend services)
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
items?: T[];
|
||||
records?: T[]; // Some endpoints use 'records'
|
||||
data?: T[]; // Some endpoints use 'data'
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
pages?: number;
|
||||
has_next?: boolean;
|
||||
has_prev?: boolean;
|
||||
}
|
||||
|
||||
// Query parameters for API requests
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
@@ -42,368 +47,33 @@ export interface PaginationParams {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Sorting and filtering
|
||||
export interface SortParams {
|
||||
sort_by?: string;
|
||||
sort_order?: SortOrder;
|
||||
order_by?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
search?: string;
|
||||
search_term?: string;
|
||||
q?: string;
|
||||
filters?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface QueryParams extends PaginationParams, SortParams, FilterParams {}
|
||||
|
||||
// File upload types
|
||||
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
|
||||
// Task/Job status (used in ML training and other async operations)
|
||||
export interface TaskStatus {
|
||||
id: string;
|
||||
task_id: string;
|
||||
status: TaskStatusType;
|
||||
progress: number;
|
||||
message: string;
|
||||
progress?: number;
|
||||
message?: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
started_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 {
|
||||
@@ -411,153 +81,69 @@ export enum TaskStatusType {
|
||||
RUNNING = 'running',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
PAUSED = 'paused',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
export enum TaskType {
|
||||
DATA_IMPORT = 'data_import',
|
||||
DATA_EXPORT = 'data_export',
|
||||
REPORT_GENERATION = 'report_generation',
|
||||
MODEL_TRAINING = 'model_training',
|
||||
DATA_PROCESSING = 'data_processing',
|
||||
BULK_OPERATION = 'bulk_operation',
|
||||
SYNC_OPERATION = 'sync_operation',
|
||||
// Health check types (used by monitoring endpoints)
|
||||
export interface HealthCheckResponse {
|
||||
status: 'healthy' | 'unhealthy' | 'degraded';
|
||||
service: string;
|
||||
version: string;
|
||||
timestamp: string;
|
||||
dependencies?: ServiceHealth[];
|
||||
}
|
||||
|
||||
export enum HealthStatus {
|
||||
HEALTHY = 'healthy',
|
||||
DEGRADED = 'degraded',
|
||||
UNHEALTHY = 'unhealthy',
|
||||
UNKNOWN = 'unknown',
|
||||
export interface ServiceHealth {
|
||||
name: string;
|
||||
status: 'healthy' | 'unhealthy' | 'degraded';
|
||||
response_time?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export enum AuditAction {
|
||||
CREATE = 'create',
|
||||
READ = 'read',
|
||||
UPDATE = 'update',
|
||||
DELETE = 'delete',
|
||||
LOGIN = 'login',
|
||||
LOGOUT = 'logout',
|
||||
EXPORT = 'export',
|
||||
IMPORT = 'import',
|
||||
// File upload types
|
||||
export interface FileUploadResponse {
|
||||
file_id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
content_type: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export enum ResourceType {
|
||||
USER = 'user',
|
||||
TENANT = 'tenant',
|
||||
INGREDIENT = 'ingredient',
|
||||
STOCK = 'stock',
|
||||
PRODUCTION_BATCH = 'production_batch',
|
||||
SALES_RECORD = 'sales_record',
|
||||
FORECAST = 'forecast',
|
||||
ORDER = 'order',
|
||||
SUPPLIER = 'supplier',
|
||||
RECIPE = 'recipe',
|
||||
NOTIFICATION = 'notification',
|
||||
// Bulk operation response
|
||||
export interface BulkOperationResponse {
|
||||
total: number;
|
||||
processed: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
errors?: BulkOperationError[];
|
||||
}
|
||||
|
||||
export enum ChangeType {
|
||||
CREATED = 'created',
|
||||
UPDATED = 'updated',
|
||||
DELETED = 'deleted',
|
||||
ARCHIVED = 'archived',
|
||||
RESTORED = 'restored',
|
||||
export interface BulkOperationError {
|
||||
index: number;
|
||||
error: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export enum WebhookEvent {
|
||||
USER_CREATED = 'user.created',
|
||||
USER_UPDATED = 'user.updated',
|
||||
USER_DELETED = 'user.deleted',
|
||||
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',
|
||||
// Common enums
|
||||
export enum SortOrder {
|
||||
ASC = 'asc',
|
||||
DESC = 'desc'
|
||||
}
|
||||
|
||||
export enum DeliveryStatus {
|
||||
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
|
||||
// HTTP methods
|
||||
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
|
||||
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> => {
|
||||
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 => {
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
// Authentication related types - Updated to match backend exactly
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string; // Backend uses full_name, not name
|
||||
is_active: boolean;
|
||||
is_verified: boolean;
|
||||
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
|
||||
}
|
||||
/**
|
||||
* Authentication Types - Matching backend schemas exactly
|
||||
* Based on services/auth/app/schemas/auth.py
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST TYPES (Frontend -> Backend)
|
||||
// ============================================================================
|
||||
|
||||
export interface UserRegistration {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
tenant_name?: string; // Optional in backend
|
||||
role?: string; // Backend uses string, defaults to "user"
|
||||
tenant_name?: string;
|
||||
role?: 'user' | 'admin' | 'manager';
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
email: 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 {
|
||||
@@ -54,6 +38,64 @@ export interface PasswordResetConfirm {
|
||||
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 {
|
||||
valid: boolean;
|
||||
user_id?: string;
|
||||
@@ -62,27 +104,27 @@ export interface TokenVerification {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UserResponse extends UserData {
|
||||
last_login?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
full_name?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
// ============================================================================
|
||||
// FORM DATA TYPES (Frontend UI)
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
remember_me: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterFormData {
|
||||
@@ -90,17 +132,7 @@ export interface RegisterFormData {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
full_name: string;
|
||||
tenant_name?: string; // Optional to match backend
|
||||
phone?: string;
|
||||
acceptTerms: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileFormData {
|
||||
full_name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
tenant_name?: string;
|
||||
}
|
||||
|
||||
export interface PasswordChangeFormData {
|
||||
@@ -109,142 +141,40 @@ export interface PasswordChangeFormData {
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
export interface EmailVerificationRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
// ============================================================================
|
||||
// UTILITY TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthError {
|
||||
code: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
detail: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface MFASetup {
|
||||
enabled: boolean;
|
||||
secret?: string;
|
||||
backup_codes?: string[];
|
||||
qr_code?: string;
|
||||
}
|
||||
// Keep User as alias for UserData for backward compatibility
|
||||
export interface User extends UserData {}
|
||||
|
||||
export interface MFAVerification {
|
||||
token: string;
|
||||
backup_code?: string;
|
||||
}
|
||||
// ============================================================================
|
||||
// ENUMS
|
||||
// ============================================================================
|
||||
|
||||
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 {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
MANAGER = 'manager',
|
||||
MANAGER = 'manager'
|
||||
}
|
||||
|
||||
export enum AuthProvider {
|
||||
EMAIL = 'email',
|
||||
GOOGLE = 'google',
|
||||
MICROSOFT = 'microsoft',
|
||||
APPLE = 'apple',
|
||||
}
|
||||
// ============================================================================
|
||||
// TYPE GUARDS
|
||||
// ============================================================================
|
||||
|
||||
export enum Permission {
|
||||
// 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 => {
|
||||
export const isUser = (obj: any): obj is UserData => {
|
||||
return obj && typeof obj.id === 'string' && typeof obj.email === 'string';
|
||||
};
|
||||
|
||||
export const isTokenResponse = (obj: any): obj is TokenResponse => {
|
||||
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';
|
||||
};
|
||||
258
frontend/src/types/data.types.ts
Normal file
258
frontend/src/types/data.types.ts
Normal 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;
|
||||
}
|
||||
@@ -81,21 +81,85 @@ export type {
|
||||
DataPreprocessing
|
||||
} 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
|
||||
export type {
|
||||
ApiResponse,
|
||||
ApiError,
|
||||
ValidationError,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
SortParams,
|
||||
FilterParams,
|
||||
QueryParams,
|
||||
TaskStatus,
|
||||
HealthCheckResponse
|
||||
TaskStatusType,
|
||||
HealthCheckResponse,
|
||||
ServiceHealth,
|
||||
FileUploadResponse,
|
||||
BulkOperationResponse,
|
||||
SortOrder,
|
||||
HttpMethod
|
||||
} from './api.types';
|
||||
|
||||
// Re-export commonly used types for convenience
|
||||
export type {
|
||||
User,
|
||||
UserData,
|
||||
UserLogin,
|
||||
UserRegistration,
|
||||
TokenResponse,
|
||||
AuthState,
|
||||
AuthError,
|
||||
UserRole
|
||||
} from './auth.types';
|
||||
|
||||
export type {
|
||||
@@ -135,6 +199,16 @@ export type {
|
||||
BatchStatus,
|
||||
} from './forecasting.types';
|
||||
|
||||
export type {
|
||||
SupplierResponse,
|
||||
SupplierSummary,
|
||||
SupplierCreate,
|
||||
PurchaseOrderResponse,
|
||||
DeliveryResponse,
|
||||
PurchaseOrderStatus,
|
||||
DeliveryStatus,
|
||||
} from './suppliers.types';
|
||||
|
||||
export type {
|
||||
ApiResponse,
|
||||
ApiError,
|
||||
|
||||
166
frontend/src/types/orders.types.ts
Normal file
166
frontend/src/types/orders.types.ts
Normal 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';
|
||||
};
|
||||
379
frontend/src/types/suppliers.types.ts
Normal file
379
frontend/src/types/suppliers.types.ts
Normal 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 {}
|
||||
35
frontend/src/types/training.types.ts
Normal file
35
frontend/src/types/training.types.ts
Normal 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>;
|
||||
}
|
||||
@@ -151,6 +151,8 @@ async def validate_sales_data_universal(
|
||||
"errors": validation_result.errors,
|
||||
"warnings": validation_result.warnings,
|
||||
"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",
|
||||
"details": {
|
||||
"total_records": validation_result.total_records,
|
||||
|
||||
@@ -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)}")
|
||||
@@ -104,9 +104,7 @@ app.add_middleware(
|
||||
# Include routers - import router BEFORE sales router to avoid conflicts
|
||||
from app.api.sales import router as sales_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(onboarding_router, prefix="/api/v1", tags=["onboarding"])
|
||||
app.include_router(sales_router, prefix="/api/v1", tags=["sales"])
|
||||
|
||||
# Health check endpoint
|
||||
|
||||
@@ -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()
|
||||
@@ -26,7 +26,7 @@ logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Import result schemas (dataclass definitions)
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any
|
||||
|
||||
@dataclass
|
||||
@@ -38,6 +38,8 @@ class SalesValidationResult:
|
||||
errors: List[Dict[str, Any]]
|
||||
warnings: List[Dict[str, Any]]
|
||||
summary: Dict[str, Any]
|
||||
unique_products: int = 0
|
||||
product_list: List[str] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class SalesImportResult:
|
||||
@@ -99,7 +101,9 @@ class DataImportService:
|
||||
invalid_records=0,
|
||||
errors=[],
|
||||
warnings=[],
|
||||
summary={}
|
||||
summary={},
|
||||
unique_products=0,
|
||||
product_list=[]
|
||||
)
|
||||
|
||||
errors = []
|
||||
@@ -216,6 +220,22 @@ class DataImportService:
|
||||
"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'):
|
||||
warnings.append({
|
||||
"type": "missing_column",
|
||||
|
||||
Reference in New Issue
Block a user