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 currentStep = steps[currentStepIndex];
|
||||||
|
|
||||||
const updateStepData = useCallback((stepId: string, data: any) => {
|
const updateStepData = useCallback((stepId: string, data: any) => {
|
||||||
setStepData(prev => ({
|
console.log(`OnboardingWizard - Updating step '${stepId}' with data:`, data);
|
||||||
...prev,
|
setStepData(prev => {
|
||||||
[stepId]: { ...prev[stepId], ...data }
|
const newStepData = {
|
||||||
}));
|
...prev,
|
||||||
|
[stepId]: { ...prev[stepId], ...data }
|
||||||
|
};
|
||||||
|
console.log(`OnboardingWizard - Full step data after update:`, newStepData);
|
||||||
|
return newStepData;
|
||||||
|
});
|
||||||
|
|
||||||
// Clear validation error for this step
|
// Clear validation error for this step
|
||||||
setValidationErrors(prev => {
|
setValidationErrors(prev => {
|
||||||
@@ -414,7 +419,10 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
|||||||
// Pass all step data to allow access to previous steps
|
// Pass all step data to allow access to previous steps
|
||||||
allStepData: stepData
|
allStepData: stepData
|
||||||
}}
|
}}
|
||||||
onDataChange={(data) => updateStepData(currentStep.id, data)}
|
onDataChange={(data) => {
|
||||||
|
console.log(`OnboardingWizard - Step ${currentStep.id} calling onDataChange with:`, data);
|
||||||
|
updateStepData(currentStep.id, data);
|
||||||
|
}}
|
||||||
onNext={goToNextStep}
|
onNext={goToNextStep}
|
||||||
onPrevious={goToPreviousStep}
|
onPrevious={goToPreviousStep}
|
||||||
isFirstStep={currentStepIndex === 0}
|
isFirstStep={currentStepIndex === 0}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
|
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
|
||||||
import { Button, Card, Badge } from '../../../ui';
|
import { Button, Card, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||||
|
import { onboardingApiService } from '../../../../services/api/onboarding.service';
|
||||||
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
|
|
||||||
interface CompletionStats {
|
interface CompletionStats {
|
||||||
totalProducts: number;
|
totalProducts: number;
|
||||||
@@ -10,6 +13,8 @@ interface CompletionStats {
|
|||||||
mlModelAccuracy: number;
|
mlModelAccuracy: number;
|
||||||
estimatedTimeSaved: string;
|
estimatedTimeSaved: string;
|
||||||
completionScore: number;
|
completionScore: number;
|
||||||
|
salesImported: boolean;
|
||||||
|
salesImportRecords: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||||
@@ -20,8 +25,73 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
|||||||
isFirstStep,
|
isFirstStep,
|
||||||
isLastStep
|
isLastStep
|
||||||
}) => {
|
}) => {
|
||||||
|
const user = useAuthUser();
|
||||||
|
const { createAlert } = useAlertActions();
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null);
|
const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null);
|
||||||
|
const [isImportingSales, setIsImportingSales] = useState(false);
|
||||||
|
|
||||||
|
// Handle final sales import
|
||||||
|
const handleFinalSalesImport = async () => {
|
||||||
|
if (!user?.tenant_id || !data.files?.salesData || !data.inventoryMapping) {
|
||||||
|
createAlert({
|
||||||
|
type: 'error',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'high',
|
||||||
|
title: 'Error en importación final',
|
||||||
|
message: 'Faltan datos necesarios para importar las ventas.',
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsImportingSales(true);
|
||||||
|
try {
|
||||||
|
const result = await onboardingApiService.importSalesWithInventory(
|
||||||
|
user.tenant_id,
|
||||||
|
data.files.salesData,
|
||||||
|
data.inventoryMapping
|
||||||
|
);
|
||||||
|
|
||||||
|
createAlert({
|
||||||
|
type: 'success',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'medium',
|
||||||
|
title: 'Importación completada',
|
||||||
|
message: `Se importaron ${result.successful_imports} registros de ventas exitosamente.`,
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update completion stats
|
||||||
|
const updatedStats = {
|
||||||
|
...completionStats!,
|
||||||
|
salesImported: true,
|
||||||
|
salesImportRecords: result.successful_imports || 0
|
||||||
|
};
|
||||||
|
setCompletionStats(updatedStats);
|
||||||
|
|
||||||
|
onDataChange({
|
||||||
|
...data,
|
||||||
|
completionStats: updatedStats,
|
||||||
|
salesImportResult: result,
|
||||||
|
finalImportCompleted: true
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sales import error:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error al importar datos de ventas';
|
||||||
|
createAlert({
|
||||||
|
type: 'error',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'high',
|
||||||
|
title: 'Error en importación',
|
||||||
|
message: errorMessage,
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsImportingSales(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Show confetti animation
|
// Show confetti animation
|
||||||
@@ -35,7 +105,9 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
|||||||
suppliersConfigured: data.suppliers?.length || 0,
|
suppliersConfigured: data.suppliers?.length || 0,
|
||||||
mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0,
|
mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0,
|
||||||
estimatedTimeSaved: '15-20 horas',
|
estimatedTimeSaved: '15-20 horas',
|
||||||
completionScore: calculateCompletionScore()
|
completionScore: calculateCompletionScore(),
|
||||||
|
salesImported: data.finalImportCompleted || false,
|
||||||
|
salesImportRecords: data.salesImportResult?.successful_imports || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
setCompletionStats(stats);
|
setCompletionStats(stats);
|
||||||
@@ -48,6 +120,11 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
|||||||
completedAt: new Date().toISOString()
|
completedAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trigger final sales import if not already done
|
||||||
|
if (!data.finalImportCompleted && data.inventoryMapping && data.files?.salesData) {
|
||||||
|
handleFinalSalesImport();
|
||||||
|
}
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -221,6 +298,10 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<p className="text-2xl font-bold text-[var(--color-secondary)]">{completionStats.mlModelAccuracy.toFixed(1)}%</p>
|
<p className="text-2xl font-bold text-[var(--color-secondary)]">{completionStats.mlModelAccuracy.toFixed(1)}%</p>
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Precisión IA</p>
|
<p className="text-xs text-[var(--text-secondary)]">Precisión IA</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-[var(--color-success)]">{completionStats.salesImported ? completionStats.salesImportRecords : '⏳'}</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Ventas {completionStats.salesImported ? 'Importadas' : 'Importando...'}</p>
|
||||||
|
</div>
|
||||||
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
<p className="text-lg font-bold text-[var(--color-warning)]">{completionStats.estimatedTimeSaved}</p>
|
<p className="text-lg font-bold text-[var(--color-warning)]">{completionStats.estimatedTimeSaved}</p>
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Tiempo Ahorrado</p>
|
<p className="text-xs text-[var(--text-secondary)]">Tiempo Ahorrado</p>
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react';
|
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react';
|
||||||
import { Button, Card, Badge } from '../../../ui';
|
import { Button, Card, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||||
|
import { onboardingApiService } from '../../../../services/api/onboarding.service';
|
||||||
|
import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store';
|
||||||
|
import { useCurrentTenant, useTenantLoading } from '../../../../stores/tenant.store';
|
||||||
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
|
|
||||||
type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'completed' | 'error';
|
type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'completed' | 'error';
|
||||||
|
|
||||||
@@ -26,64 +30,70 @@ interface ProcessingResult {
|
|||||||
recommendations: string[];
|
recommendations: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified mock service that handles both validation and analysis
|
// Real data processing service using backend APIs
|
||||||
const mockDataProcessingService = {
|
const dataProcessingService = {
|
||||||
processFile: async (file: File, onProgress: (progress: number, stage: string, message: string) => void) => {
|
processFile: async (
|
||||||
return new Promise<ProcessingResult>((resolve, reject) => {
|
file: File,
|
||||||
let progress = 0;
|
tenantId: string,
|
||||||
|
onProgress: (progress: number, stage: string, message: string) => void
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// Stage 1: Validate file with sales service
|
||||||
|
onProgress(20, 'validating', 'Validando estructura del archivo...');
|
||||||
|
const validationResult = await onboardingApiService.validateOnboardingFile(tenantId, file);
|
||||||
|
|
||||||
const stages = [
|
onProgress(40, 'validating', 'Verificando integridad de datos...');
|
||||||
{ threshold: 20, stage: 'validating', message: 'Validando estructura del archivo...' },
|
|
||||||
{ threshold: 40, stage: 'validating', message: 'Verificando integridad de datos...' },
|
if (!validationResult.is_valid) {
|
||||||
{ threshold: 60, stage: 'analyzing', message: 'Identificando productos únicos...' },
|
throw new Error('Archivo de datos inválido');
|
||||||
{ 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' }
|
if (!validationResult.product_list || validationResult.product_list.length === 0) {
|
||||||
];
|
throw new Error('No se encontraron productos en el archivo');
|
||||||
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
// Stage 2: Generate AI suggestions with inventory service
|
||||||
if (progress < 100) {
|
onProgress(60, 'analyzing', 'Identificando productos únicos...');
|
||||||
progress += 10;
|
onProgress(80, 'analyzing', 'Analizando patrones de venta...');
|
||||||
const currentStage = stages.find(s => progress <= s.threshold);
|
|
||||||
if (currentStage) {
|
console.log('DataProcessingStep - Calling generateInventorySuggestions with:', {
|
||||||
onProgress(progress, currentStage.stage, currentStage.message);
|
tenantId,
|
||||||
}
|
fileName: file.name,
|
||||||
}
|
productList: validationResult.product_list
|
||||||
|
});
|
||||||
if (progress >= 100) {
|
|
||||||
clearInterval(interval);
|
const suggestionsResult = await onboardingApiService.generateInventorySuggestions(
|
||||||
// Return combined validation + analysis results
|
tenantId,
|
||||||
resolve({
|
file,
|
||||||
// Validation results
|
validationResult.product_list
|
||||||
is_valid: true,
|
);
|
||||||
total_records: Math.floor(Math.random() * 1000) + 100,
|
|
||||||
unique_products: Math.floor(Math.random() * 50) + 10,
|
console.log('DataProcessingStep - AI suggestions result:', suggestionsResult);
|
||||||
product_list: ['Pan Integral', 'Croissant', 'Baguette', 'Empanadas', 'Pan de Centeno', 'Medialunas'],
|
|
||||||
validation_errors: [],
|
onProgress(90, 'analyzing', 'Generando recomendaciones con IA...');
|
||||||
validation_warnings: [
|
onProgress(100, 'completed', 'Procesamiento completado');
|
||||||
'Algunas fechas podrían tener formato inconsistente',
|
|
||||||
'3 productos sin categoría definida'
|
// Combine results
|
||||||
],
|
const combinedResult = {
|
||||||
summary: {
|
...validationResult,
|
||||||
date_range: '2024-01-01 to 2024-12-31',
|
productsIdentified: suggestionsResult.total_products || validationResult.unique_products,
|
||||||
total_sales: 15420.50,
|
categoriesDetected: suggestionsResult.suggestions ?
|
||||||
average_daily_sales: 42.25
|
new Set(suggestionsResult.suggestions.map(s => s.category)).size : 4,
|
||||||
},
|
businessModel: suggestionsResult.business_model_analysis?.model || 'production',
|
||||||
// Analysis results
|
confidenceScore: suggestionsResult.high_confidence_count && suggestionsResult.total_products ?
|
||||||
productsIdentified: 15,
|
Math.round((suggestionsResult.high_confidence_count / suggestionsResult.total_products) * 100) : 85,
|
||||||
categoriesDetected: 4,
|
recommendations: suggestionsResult.business_model_analysis?.recommendations || [],
|
||||||
businessModel: 'artisan',
|
aiSuggestions: suggestionsResult.suggestions || []
|
||||||
confidenceScore: 94,
|
};
|
||||||
recommendations: [
|
|
||||||
'Se detectó un modelo de panadería artesanal con producción propia',
|
console.log('DataProcessingStep - Combined result:', combinedResult);
|
||||||
'Los productos más vendidos son panes tradicionales y bollería',
|
console.log('DataProcessingStep - Combined result aiSuggestions:', combinedResult.aiSuggestions);
|
||||||
'Recomendamos categorizar el inventario por tipo de producto',
|
console.log('DataProcessingStep - Combined result aiSuggestions length:', combinedResult.aiSuggestions?.length);
|
||||||
'Considera ampliar la línea de productos de repostería'
|
return combinedResult;
|
||||||
]
|
} catch (error) {
|
||||||
});
|
console.error('Data processing error:', error);
|
||||||
}
|
throw error;
|
||||||
}, 400);
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,6 +105,34 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
isFirstStep,
|
isFirstStep,
|
||||||
isLastStep
|
isLastStep
|
||||||
}) => {
|
}) => {
|
||||||
|
const user = useAuthUser();
|
||||||
|
const authLoading = useAuthLoading();
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
|
const tenantLoading = useTenantLoading();
|
||||||
|
const { createAlert } = useAlertActions();
|
||||||
|
|
||||||
|
// Check if we're still loading user or tenant data
|
||||||
|
const isLoadingUserData = authLoading || tenantLoading;
|
||||||
|
|
||||||
|
// Get tenant ID from multiple sources with fallback
|
||||||
|
const getTenantId = (): string | null => {
|
||||||
|
const tenantId = currentTenant?.id || user?.tenant_id || null;
|
||||||
|
console.log('DataProcessingStep - getTenantId:', {
|
||||||
|
currentTenant: currentTenant?.id,
|
||||||
|
userTenantId: user?.tenant_id,
|
||||||
|
finalTenantId: tenantId,
|
||||||
|
isLoadingUserData,
|
||||||
|
authLoading,
|
||||||
|
tenantLoading,
|
||||||
|
user: user ? { id: user.id, email: user.email } : null
|
||||||
|
});
|
||||||
|
return tenantId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if tenant data is available (not loading and has ID)
|
||||||
|
const isTenantAvailable = (): boolean => {
|
||||||
|
return !isLoadingUserData && getTenantId() !== null;
|
||||||
|
};
|
||||||
const [stage, setStage] = useState<ProcessingStage>(data.processingStage || 'upload');
|
const [stage, setStage] = useState<ProcessingStage>(data.processingStage || 'upload');
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(data.files?.salesData || null);
|
const [uploadedFile, setUploadedFile] = useState<File | null>(data.files?.salesData || null);
|
||||||
const [progress, setProgress] = useState(data.processingProgress || 0);
|
const [progress, setProgress] = useState(data.processingProgress || 0);
|
||||||
@@ -166,8 +204,41 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await mockDataProcessingService.processFile(
|
// Wait for user data to load if still loading
|
||||||
|
if (!isTenantAvailable()) {
|
||||||
|
createAlert({
|
||||||
|
type: 'info',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'low',
|
||||||
|
title: 'Cargando datos de usuario',
|
||||||
|
message: 'Por favor espere mientras cargamos su información...',
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
|
// Reset file state since we can't process it yet
|
||||||
|
setUploadedFile(null);
|
||||||
|
setStage('upload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = getTenantId();
|
||||||
|
if (!tenantId) {
|
||||||
|
console.error('DataProcessingStep - No tenant ID available:', {
|
||||||
|
user,
|
||||||
|
currentTenant,
|
||||||
|
userTenantId: user?.tenant_id,
|
||||||
|
currentTenantId: currentTenant?.id,
|
||||||
|
isLoadingUserData,
|
||||||
|
authLoading,
|
||||||
|
tenantLoading
|
||||||
|
});
|
||||||
|
throw new Error('No se pudo obtener información del tenant. Intente cerrar sesión y volver a iniciar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('DataProcessingStep - Starting file processing with tenant:', tenantId);
|
||||||
|
|
||||||
|
const result = await dataProcessingService.processFile(
|
||||||
file,
|
file,
|
||||||
|
tenantId,
|
||||||
(newProgress, newStage, message) => {
|
(newProgress, newStage, message) => {
|
||||||
setProgress(newProgress);
|
setProgress(newProgress);
|
||||||
setStage(newStage as ProcessingStage);
|
setStage(newStage as ProcessingStage);
|
||||||
@@ -177,32 +248,112 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
|
|
||||||
setResults(result);
|
setResults(result);
|
||||||
setStage('completed');
|
setStage('completed');
|
||||||
|
|
||||||
|
// Store results for next steps
|
||||||
|
onDataChange({
|
||||||
|
...data,
|
||||||
|
files: { ...data.files, salesData: file },
|
||||||
|
processingResults: result,
|
||||||
|
processingStage: 'completed',
|
||||||
|
processingProgress: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('DataProcessingStep - File processing completed:', result);
|
||||||
|
|
||||||
|
createAlert({
|
||||||
|
type: 'success',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'medium',
|
||||||
|
title: 'Procesamiento completado',
|
||||||
|
message: `Se procesaron ${result.total_records} registros y se identificaron ${result.unique_products} productos únicos.`,
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Processing error:', error);
|
console.error('DataProcessingStep - Processing error:', error);
|
||||||
|
console.error('DataProcessingStep - Error details:', {
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
errorStack: error instanceof Error ? error.stack : null,
|
||||||
|
tenantInfo: {
|
||||||
|
user: user ? { id: user.id, tenant_id: user.tenant_id } : null,
|
||||||
|
currentTenant: currentTenant ? { id: currentTenant.id } : null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setStage('error');
|
setStage('error');
|
||||||
setCurrentMessage('Error en el procesamiento de datos');
|
const errorMessage = error instanceof Error ? error.message : 'Error en el procesamiento de datos';
|
||||||
|
setCurrentMessage(errorMessage);
|
||||||
|
|
||||||
|
createAlert({
|
||||||
|
type: 'error',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'high',
|
||||||
|
title: 'Error en el procesamiento',
|
||||||
|
message: errorMessage,
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadTemplate = () => {
|
const downloadTemplate = async () => {
|
||||||
const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta
|
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,Pan Integral,5,2.50,12.50,Cliente A,Tienda
|
||||||
2024-01-15,Croissant,3,1.80,5.40,Cliente B,Online
|
2024-01-15,Croissant,3,1.80,5.40,Cliente B,Online
|
||||||
2024-01-15,Baguette,2,3.00,6.00,Cliente C,Tienda
|
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,Pan de Centeno,4,2.80,11.20,Cliente A,Tienda
|
||||||
2024-01-16,Empanadas,6,4.50,27.00,Cliente D,Delivery`;
|
2024-01-16,Empanadas,6,4.50,27.00,Cliente D,Delivery`;
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
link.setAttribute('href', url);
|
link.setAttribute('href', url);
|
||||||
link.setAttribute('download', 'plantilla_ventas.csv');
|
link.setAttribute('download', 'plantilla_ventas.csv');
|
||||||
link.style.visibility = 'hidden';
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetProcess = () => {
|
const resetProcess = () => {
|
||||||
@@ -218,8 +369,23 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
{/* Loading state when tenant data is not available */}
|
||||||
|
{!isTenantAvailable() && (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<div className="w-16 h-16 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Activity className="w-8 h-8 text-[var(--color-info)] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-3">
|
||||||
|
Cargando datos de usuario...
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
Por favor espere mientras cargamos su información de tenant
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Improved Upload Stage */}
|
{/* Improved Upload Stage */}
|
||||||
{stage === 'upload' && (
|
{stage === 'upload' && isTenantAvailable() && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2 } from 'lucide-react';
|
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2, CheckCircle } from 'lucide-react';
|
||||||
import { Button, Card, Input, Badge } from '../../../ui';
|
import { Button, Card, Input, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||||
|
import { onboardingApiService } from '../../../../services/api/onboarding.service';
|
||||||
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
|
|
||||||
interface InventoryItem {
|
interface InventoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,34 +18,35 @@ interface InventoryItem {
|
|||||||
supplier?: string;
|
supplier?: string;
|
||||||
cost_per_unit?: number;
|
cost_per_unit?: number;
|
||||||
requires_refrigeration: boolean;
|
requires_refrigeration: boolean;
|
||||||
|
// API fields
|
||||||
|
suggestion_id?: string;
|
||||||
|
original_name?: string;
|
||||||
|
estimated_shelf_life_days?: number;
|
||||||
|
is_seasonal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock inventory items based on approved products
|
// Convert approved products to inventory items
|
||||||
const mockInventoryItems: InventoryItem[] = [
|
const convertProductsToInventory = (approvedProducts: any[]): InventoryItem[] => {
|
||||||
{
|
return approvedProducts.map((product, index) => ({
|
||||||
id: '1', name: 'Harina de Trigo', category: 'ingredient',
|
id: `inventory-${index}`,
|
||||||
current_stock: 50, min_stock: 20, max_stock: 100, unit: 'kg',
|
name: product.suggested_name || product.name,
|
||||||
expiry_date: '2024-12-31', supplier: 'Molinos del Sur',
|
category: product.product_type || 'finished_product',
|
||||||
cost_per_unit: 1.20, requires_refrigeration: false
|
current_stock: 0, // To be configured by user
|
||||||
},
|
min_stock: 1, // Default minimum
|
||||||
{
|
max_stock: 100, // Default maximum
|
||||||
id: '2', name: 'Levadura Fresca', category: 'ingredient',
|
unit: product.unit_of_measure || 'unidad',
|
||||||
current_stock: 5, min_stock: 2, max_stock: 10, unit: 'kg',
|
requires_refrigeration: product.requires_refrigeration || false,
|
||||||
expiry_date: '2024-03-15', supplier: 'Levaduras Pro',
|
// Store API data
|
||||||
cost_per_unit: 3.50, requires_refrigeration: true
|
suggestion_id: product.suggestion_id,
|
||||||
},
|
original_name: product.original_name,
|
||||||
{
|
estimated_shelf_life_days: product.estimated_shelf_life_days,
|
||||||
id: '3', name: 'Pan Integral', category: 'finished_product',
|
is_seasonal: product.is_seasonal,
|
||||||
current_stock: 20, min_stock: 10, max_stock: 50, unit: 'unidades',
|
// Optional fields to be filled by user
|
||||||
expiry_date: '2024-01-25', requires_refrigeration: false
|
expiry_date: undefined,
|
||||||
},
|
supplier: product.suggested_supplier,
|
||||||
{
|
cost_per_unit: 0
|
||||||
id: '4', name: 'Mantequilla', category: 'ingredient',
|
}));
|
||||||
current_stock: 15, min_stock: 5, max_stock: 30, unit: 'kg',
|
};
|
||||||
expiry_date: '2024-02-28', supplier: 'Lácteos Premium',
|
|
||||||
cost_per_unit: 4.20, requires_refrigeration: true
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||||
data,
|
data,
|
||||||
@@ -52,22 +56,121 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
isFirstStep,
|
isFirstStep,
|
||||||
isLastStep
|
isLastStep
|
||||||
}) => {
|
}) => {
|
||||||
const [items, setItems] = useState<InventoryItem[]>(
|
const user = useAuthUser();
|
||||||
data.inventoryItems || mockInventoryItems
|
const { createAlert } = useAlertActions();
|
||||||
);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
|
|
||||||
|
// Generate inventory items from approved products
|
||||||
|
const generateInventoryFromProducts = (approvedProducts: any[]): InventoryItem[] => {
|
||||||
|
if (!approvedProducts || approvedProducts.length === 0) {
|
||||||
|
createAlert({
|
||||||
|
type: 'warning',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'medium',
|
||||||
|
title: 'Sin productos aprobados',
|
||||||
|
message: 'No hay productos aprobados para crear inventario. Regrese al paso anterior.',
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertProductsToInventory(approvedProducts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, setItems] = useState<InventoryItem[]>(() => {
|
||||||
|
if (data.inventoryItems) {
|
||||||
|
return data.inventoryItems;
|
||||||
|
}
|
||||||
|
// Try to get approved products from current step data first, then from review step data
|
||||||
|
const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts;
|
||||||
|
return generateInventoryFromProducts(approvedProducts || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update items when approved products become available (for when component is already mounted)
|
||||||
|
useEffect(() => {
|
||||||
|
const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts;
|
||||||
|
|
||||||
|
if (approvedProducts && approvedProducts.length > 0 && items.length === 0) {
|
||||||
|
const newItems = generateInventoryFromProducts(approvedProducts);
|
||||||
|
setItems(newItems);
|
||||||
|
}
|
||||||
|
}, [data.approvedProducts, data.allStepData]);
|
||||||
|
|
||||||
const [filterCategory, setFilterCategory] = useState<'all' | 'ingredient' | 'finished_product'>('all');
|
const [filterCategory, setFilterCategory] = useState<'all' | 'ingredient' | 'finished_product'>('all');
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
|
const hasValidStock = items.length > 0 && items.every(item =>
|
||||||
|
item.min_stock >= 0 && item.max_stock > item.min_stock
|
||||||
|
);
|
||||||
|
|
||||||
onDataChange({
|
onDataChange({
|
||||||
...data,
|
...data,
|
||||||
inventoryItems: items,
|
inventoryItems: items,
|
||||||
inventoryConfigured: items.length > 0 && items.every(item =>
|
inventoryConfigured: hasValidStock && !isCreating
|
||||||
item.min_stock > 0 && item.max_stock > item.min_stock
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
}, [items]);
|
}, [items, isCreating]);
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const newItem: InventoryItem = {
|
const newItem: InventoryItem = {
|
||||||
@@ -75,491 +178,370 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
name: '',
|
name: '',
|
||||||
category: 'ingredient',
|
category: 'ingredient',
|
||||||
current_stock: 0,
|
current_stock: 0,
|
||||||
min_stock: 0,
|
min_stock: 1,
|
||||||
max_stock: 0,
|
max_stock: 10,
|
||||||
unit: 'kg',
|
unit: 'unidad',
|
||||||
requires_refrigeration: false
|
requires_refrigeration: false
|
||||||
};
|
};
|
||||||
|
setItems([...items, newItem]);
|
||||||
setEditingItem(newItem);
|
setEditingItem(newItem);
|
||||||
setIsAddingNew(true);
|
setIsAddingNew(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveItem = (item: InventoryItem) => {
|
const handleSaveItem = (updatedItem: InventoryItem) => {
|
||||||
if (isAddingNew) {
|
setItems(items.map(item =>
|
||||||
setItems(prev => [...prev, item]);
|
item.id === updatedItem.id ? updatedItem : item
|
||||||
} else {
|
));
|
||||||
setItems(prev => prev.map(i => i.id === item.id ? item : i));
|
|
||||||
}
|
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setIsAddingNew(false);
|
setIsAddingNew(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteItem = (id: string) => {
|
const handleDeleteItem = (id: string) => {
|
||||||
if (window.confirm('¿Estás seguro de eliminar este elemento del inventario?')) {
|
setItems(items.filter(item => item.id !== id));
|
||||||
setItems(prev => prev.filter(item => item.id !== id));
|
if (editingItem?.id === id) {
|
||||||
|
setEditingItem(null);
|
||||||
|
setIsAddingNew(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickSetup = () => {
|
const handleCancelEdit = () => {
|
||||||
// Auto-configure basic inventory based on approved products
|
if (isAddingNew && editingItem) {
|
||||||
const autoItems = data.detectedProducts
|
setItems(items.filter(item => item.id !== editingItem.id));
|
||||||
?.filter((p: any) => p.status === 'approved')
|
}
|
||||||
.map((product: any, index: number) => ({
|
setEditingItem(null);
|
||||||
id: `auto_${index}`,
|
setIsAddingNew(false);
|
||||||
name: product.name,
|
|
||||||
category: 'finished_product' as const,
|
|
||||||
current_stock: Math.floor(Math.random() * 20) + 5,
|
|
||||||
min_stock: 5,
|
|
||||||
max_stock: 50,
|
|
||||||
unit: 'unidades',
|
|
||||||
requires_refrigeration: product.category === 'Repostería' || product.category === 'Salados'
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
setItems(prev => [...prev, ...autoItems]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFilteredItems = () => {
|
|
||||||
return filterCategory === 'all'
|
|
||||||
? items
|
|
||||||
: items.filter(item => item.category === filterCategory);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStockStatus = (item: InventoryItem) => {
|
const getStockStatus = (item: InventoryItem) => {
|
||||||
if (item.current_stock <= item.min_stock) return { status: 'low', color: 'red', text: 'Stock Bajo' };
|
if (item.current_stock <= item.min_stock) return 'critical';
|
||||||
if (item.current_stock >= item.max_stock) return { status: 'high', color: 'blue', text: 'Stock Alto' };
|
if (item.current_stock <= item.min_stock * 1.5) return 'warning';
|
||||||
return { status: 'normal', color: 'green', text: 'Normal' };
|
return 'good';
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNearExpiry = (expiryDate?: string) => {
|
const getStockStatusColor = (status: string) => {
|
||||||
if (!expiryDate) return false;
|
switch (status) {
|
||||||
const expiry = new Date(expiryDate);
|
case 'critical': return 'text-red-600 bg-red-50';
|
||||||
const today = new Date();
|
case 'warning': return 'text-yellow-600 bg-yellow-50';
|
||||||
const diffDays = (expiry.getTime() - today.getTime()) / (1000 * 3600 * 24);
|
default: return 'text-green-600 bg-green-50';
|
||||||
return diffDays <= 7;
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stats = {
|
if (items.length === 0) {
|
||||||
total: items.length,
|
return (
|
||||||
ingredients: items.filter(i => i.category === 'ingredient').length,
|
<div className="space-y-8">
|
||||||
products: items.filter(i => i.category === 'finished_product').length,
|
<div className="text-center py-16">
|
||||||
lowStock: items.filter(i => i.current_stock <= i.min_stock).length,
|
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
nearExpiry: items.filter(i => isNearExpiry(i.expiry_date)).length,
|
<h3 className="text-2xl font-bold text-gray-600 mb-2">
|
||||||
refrigerated: items.filter(i => i.requires_refrigeration).length
|
Sin productos para inventario
|
||||||
};
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-6 max-w-md mx-auto">
|
||||||
|
No hay productos aprobados para crear inventario.
|
||||||
|
Regrese al paso anterior para aprobar productos.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={isFirstStep}
|
||||||
|
>
|
||||||
|
Volver al paso anterior
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* Quick Actions */}
|
{/* Header */}
|
||||||
<Card className="p-4">
|
<div className="text-center">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
|
||||||
<div>
|
Configuración de Inventario
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
</h2>
|
||||||
{stats.total} elementos configurados
|
<p className="text-[var(--text-secondary)] text-lg max-w-2xl mx-auto">
|
||||||
</p>
|
Configure los niveles de stock, fechas de vencimiento y otros detalles para sus productos.
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<Card className="p-4 text-center">
|
<Card className="p-6 text-center">
|
||||||
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p>
|
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{items.length}</div>
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Total</p>
|
<div className="text-sm text-[var(--text-secondary)]">Elementos totales</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.ingredients}</p>
|
<Card className="p-6 text-center">
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Ingredientes</p>
|
<div className="text-3xl font-bold text-blue-600 mb-2">
|
||||||
|
{items.filter(item => item.category === 'ingredient').length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Ingredientes</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.products}</p>
|
<Card className="p-6 text-center">
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Productos</p>
|
<div className="text-3xl font-bold text-green-600 mb-2">
|
||||||
|
{items.filter(item => item.category === 'finished_product').length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Productos terminados</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-error)]">{stats.lowStock}</p>
|
<Card className="p-6 text-center">
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Stock Bajo</p>
|
<div className="text-3xl font-bold text-red-600 mb-2">
|
||||||
</Card>
|
{items.filter(item => getStockStatus(item) === 'critical').length}
|
||||||
<Card className="p-4 text-center">
|
</div>
|
||||||
<p className="text-2xl font-bold text-[var(--color-warning)]">{stats.nearExpiry}</p>
|
<div className="text-sm text-[var(--text-secondary)]">Stock crítico</div>
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Por Vencer</p>
|
|
||||||
</Card>
|
|
||||||
<Card className="p-4 text-center">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.refrigerated}</p>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Refrigerado</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Controls */}
|
||||||
<Card className="p-4">
|
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<label className="text-sm font-medium text-[var(--text-secondary)]">Filtrar:</label>
|
<select
|
||||||
<div className="flex space-x-2">
|
value={filterCategory}
|
||||||
{[
|
onChange={(e) => setFilterCategory(e.target.value as any)}
|
||||||
{ value: 'all', label: 'Todos' },
|
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
|
||||||
{ value: 'ingredient', label: 'Ingredientes' },
|
>
|
||||||
{ value: 'finished_product', label: 'Productos' }
|
<option value="all">Todos los elementos</option>
|
||||||
].map(filter => (
|
<option value="ingredient">Ingredientes</option>
|
||||||
<button
|
<option value="finished_product">Productos terminados</option>
|
||||||
key={filter.value}
|
</select>
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
<Badge variant="outline" className="text-sm">
|
||||||
<Card key={item.id} className="p-4">
|
{filteredItems.length} elementos
|
||||||
<div className="flex items-start justify-between">
|
</Badge>
|
||||||
<div className="flex items-start space-x-4 flex-1">
|
</div>
|
||||||
{/* Category Icon */}
|
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
|
<div className="flex space-x-2">
|
||||||
item.category === 'ingredient'
|
<Button
|
||||||
? 'bg-[var(--color-primary)]/10'
|
onClick={handleAddItem}
|
||||||
: 'bg-[var(--color-success)]/10'
|
size="sm"
|
||||||
}`}>
|
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
|
||||||
<Package className={`w-4 h-4 ${
|
>
|
||||||
item.category === 'ingredient'
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
? 'text-[var(--color-primary)]'
|
Agregar elemento
|
||||||
: 'text-[var(--color-success)]'
|
</Button>
|
||||||
}`} />
|
<Button
|
||||||
</div>
|
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 */}
|
{/* Items List */}
|
||||||
<div className="flex-1">
|
<div className="space-y-3">
|
||||||
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{item.name}</h4>
|
{filteredItems.map((item) => (
|
||||||
|
<Card key={item.id} className="p-4 hover:shadow-md transition-shadow">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
{editingItem?.id === item.id ? (
|
||||||
<Badge variant={item.category === 'ingredient' ? 'blue' : 'green'}>
|
<InventoryItemEditor
|
||||||
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto'}
|
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>
|
</Badge>
|
||||||
{item.requires_refrigeration && (
|
)}
|
||||||
<Badge variant="gray">❄️ Refrigeración</Badge>
|
</div>
|
||||||
)}
|
|
||||||
<Badge variant={stockStatus.color}>
|
<div className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||||
{stockStatus.text}
|
<div className="flex space-x-4">
|
||||||
</Badge>
|
<span>Stock actual: <span className="font-medium">{item.current_stock} {item.unit}</span></span>
|
||||||
{nearExpiry && (
|
<span>Mínimo: <span className="font-medium">{item.min_stock}</span></span>
|
||||||
<Badge variant="red">Vence Pronto</Badge>
|
<span>Máximo: <span className="font-medium">{item.max_stock}</span></span>
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex gap-2 ml-4 mt-1">
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setEditingItem(item)}
|
onClick={() => setEditingItem(item)}
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleDeleteItem(item.id)}
|
onClick={() => handleDeleteItem(item.id)}
|
||||||
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
className="text-red-600 border-red-200 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
Eliminar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{getFilteredItems().length === 0 && (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
|
||||||
<p className="text-[var(--text-secondary)]">No hay elementos en esta categoría</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* Navigation */}
|
||||||
{(stats.lowStock > 0 || stats.nearExpiry > 0) && (
|
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
|
||||||
<Card className="p-4 bg-[var(--color-warning-50)] border-[var(--color-warning-200)]">
|
<Button
|
||||||
<div className="flex items-start space-x-3">
|
variant="outline"
|
||||||
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" />
|
onClick={onPrevious}
|
||||||
<div>
|
disabled={isFirstStep}
|
||||||
<h4 className="font-medium text-[var(--color-warning-800)] mb-1">Advertencias de Inventario</h4>
|
>
|
||||||
{stats.lowStock > 0 && (
|
Anterior
|
||||||
<p className="text-sm text-[var(--color-warning-700)] mb-1">
|
</Button>
|
||||||
• {stats.lowStock} elemento(s) con stock bajo
|
<Button
|
||||||
</p>
|
onClick={onNext}
|
||||||
)}
|
disabled={!data.inventoryConfigured}
|
||||||
{stats.nearExpiry > 0 && (
|
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
|
||||||
<p className="text-sm text-[var(--color-warning-700)]">
|
>
|
||||||
• {stats.nearExpiry} elemento(s) próximos a vencer
|
{isLastStep ? 'Finalizar' : 'Siguiente'}
|
||||||
</p>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Modal */}
|
|
||||||
{editingItem && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<Card className="p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
|
||||||
{isAddingNew ? 'Agregar Elemento' : 'Editar Elemento'}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<InventoryItemForm
|
|
||||||
item={editingItem}
|
|
||||||
onSave={handleSaveItem}
|
|
||||||
onCancel={() => {
|
|
||||||
setEditingItem(null);
|
|
||||||
setIsAddingNew(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Information */}
|
|
||||||
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
|
||||||
<h4 className="font-medium text-[var(--color-info)] mb-2">
|
|
||||||
📦 Configuración de Inventario:
|
|
||||||
</h4>
|
|
||||||
<ul className="text-sm text-[var(--color-info)] space-y-1">
|
|
||||||
<li>• <strong>Stock Mínimo:</strong> Nivel que dispara alertas de reabastecimiento</li>
|
|
||||||
<li>• <strong>Stock Máximo:</strong> Capacidad máxima de almacenamiento</li>
|
|
||||||
<li>• <strong>Fechas de Vencimiento:</strong> Control automático de productos perecederos</li>
|
|
||||||
<li>• <strong>Refrigeración:</strong> Identifica productos que requieren frío</li>
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Component for editing inventory items
|
// Inventory Item Editor Component
|
||||||
interface InventoryItemFormProps {
|
const InventoryItemEditor: React.FC<{
|
||||||
item: InventoryItem;
|
item: InventoryItem;
|
||||||
onSave: (item: InventoryItem) => void;
|
onSave: (item: InventoryItem) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}> = ({ item, onSave, onCancel }) => {
|
||||||
|
const [editedItem, setEditedItem] = useState<InventoryItem>(item);
|
||||||
|
|
||||||
const InventoryItemForm: React.FC<InventoryItemFormProps> = ({ item, onSave, onCancel }) => {
|
const handleSave = () => {
|
||||||
const [formData, setFormData] = useState(item);
|
if (!editedItem.name.trim()) {
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
alert('El nombre es requerido');
|
alert('El nombre es requerido');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (formData.min_stock >= formData.max_stock) {
|
if (editedItem.min_stock < 0 || editedItem.max_stock <= editedItem.min_stock) {
|
||||||
alert('El stock máximo debe ser mayor al mínimo');
|
alert('Los niveles de stock deben ser válidos (máximo > mínimo >= 0)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(formData);
|
onSave(editedItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="space-y-4 p-4 bg-gray-50 rounded-lg">
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium mb-1">Nombre</label>
|
||||||
Categoría *
|
<Input
|
||||||
</label>
|
value={editedItem.name}
|
||||||
<select
|
onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
|
||||||
value={formData.category}
|
placeholder="Nombre del producto"
|
||||||
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)]"
|
</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="ingredient">Ingrediente</option>
|
||||||
<option value="finished_product">Producto Terminado</option>
|
<option value="finished_product">Producto terminado</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium mb-1">Stock actual</label>
|
||||||
Unidad *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={formData.unit}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, unit: e.target.value }))}
|
|
||||||
placeholder="kg, unidades, litros..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
||||||
Stock Actual
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.current_stock}
|
value={editedItem.current_stock}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, current_stock: Number(e.target.value) }))}
|
onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })}
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium mb-1">Unidad</label>
|
||||||
Stock Mín. *
|
<Input
|
||||||
</label>
|
value={editedItem.unit}
|
||||||
|
onChange={(e) => setEditedItem({ ...editedItem, unit: e.target.value })}
|
||||||
|
placeholder="kg, litros, unidades..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Stock mínimo</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.min_stock}
|
value={editedItem.min_stock}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, min_stock: Number(e.target.value) }))}
|
onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })}
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium mb-1">Stock máximo</label>
|
||||||
Stock Máx. *
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.max_stock}
|
value={editedItem.max_stock}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, max_stock: Number(e.target.value) }))}
|
onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })}
|
||||||
min="1"
|
min="1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium mb-1">Fecha de vencimiento</label>
|
||||||
Fecha Vencimiento
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.expiry_date || ''}
|
value={editedItem.expiry_date || ''}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
|
onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium mb-1">Proveedor</label>
|
||||||
Costo por Unidad
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
value={editedItem.supplier || ''}
|
||||||
step="0.01"
|
onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })}
|
||||||
value={formData.cost_per_unit || ''}
|
placeholder="Nombre del proveedor"
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, cost_per_unit: Number(e.target.value) }))}
|
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center space-x-4">
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="flex items-center space-x-2">
|
||||||
Proveedor
|
<input
|
||||||
</label>
|
type="checkbox"
|
||||||
<Input
|
checked={editedItem.requires_refrigeration}
|
||||||
value={formData.supplier || ''}
|
onChange={(e) => setEditedItem({ ...editedItem, requires_refrigeration: e.target.checked })}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, supplier: e.target.value }))}
|
className="rounded"
|
||||||
placeholder="Nombre del proveedor"
|
/>
|
||||||
/>
|
<span className="text-sm">Requiere refrigeración</span>
|
||||||
</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
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
<div className="flex justify-end space-x-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button variant="outline" onClick={onCancel}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">
|
<Button onClick={handleSave}>
|
||||||
Guardar
|
Guardar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
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 { Eye, CheckCircle, AlertCircle, Edit, Trash2 } from 'lucide-react';
|
||||||
import { Button, Card, Badge } from '../../../ui';
|
import { Button, Card, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||||
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
sales_count: number;
|
sales_count?: number;
|
||||||
estimated_price: number;
|
estimated_price?: number;
|
||||||
status: 'approved' | 'rejected' | 'pending';
|
status: 'approved' | 'rejected' | 'pending';
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
// Fields from API suggestion
|
||||||
|
suggestion_id?: string;
|
||||||
|
original_name: string;
|
||||||
|
suggested_name: string;
|
||||||
|
product_type: 'ingredient' | 'finished_product';
|
||||||
|
unit_of_measure: string;
|
||||||
|
estimated_shelf_life_days: number;
|
||||||
|
requires_refrigeration: boolean;
|
||||||
|
requires_freezing: boolean;
|
||||||
|
is_seasonal: boolean;
|
||||||
|
suggested_supplier?: string;
|
||||||
|
sales_data?: {
|
||||||
|
total_quantity: number;
|
||||||
|
average_daily_sales: number;
|
||||||
|
peak_day: string;
|
||||||
|
frequency: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock detected products
|
// Convert API suggestions to Product interface
|
||||||
const mockDetectedProducts: Product[] = [
|
const convertSuggestionsToProducts = (suggestions: any[]): Product[] => {
|
||||||
{ id: '1', name: 'Pan Integral', category: 'Panadería', confidence: 95, sales_count: 45, estimated_price: 2.50, status: 'pending' },
|
console.log('ReviewStep - convertSuggestionsToProducts called with:', suggestions);
|
||||||
{ id: '2', name: 'Croissant', category: 'Bollería', confidence: 92, sales_count: 38, estimated_price: 1.80, status: 'pending' },
|
|
||||||
{ id: '3', name: 'Baguette', category: 'Panadería', confidence: 88, sales_count: 22, estimated_price: 3.00, status: 'pending' },
|
const products = suggestions.map((suggestion, index) => ({
|
||||||
{ id: '4', name: 'Empanada de Pollo', category: 'Salados', confidence: 85, sales_count: 31, estimated_price: 4.50, status: 'pending' },
|
id: suggestion.suggestion_id || `product-${index}`,
|
||||||
{ id: '5', name: 'Tarta de Manzana', category: 'Repostería', confidence: 78, sales_count: 12, estimated_price: 15.00, status: 'pending' },
|
name: suggestion.suggested_name,
|
||||||
{ id: '6', name: 'Pan de Centeno', category: 'Panadería', confidence: 91, sales_count: 18, estimated_price: 2.80, status: 'pending' },
|
category: suggestion.category,
|
||||||
{ id: '7', name: 'Medialunas', category: 'Bollería', confidence: 87, sales_count: 29, estimated_price: 1.20, status: 'pending' },
|
confidence: Math.round(suggestion.confidence_score * 100),
|
||||||
{ id: '8', name: 'Sandwich Mixto', category: 'Salados', confidence: 82, sales_count: 25, estimated_price: 5.50, status: 'pending' }
|
status: 'pending' as const,
|
||||||
];
|
// Store original API data
|
||||||
|
suggestion_id: suggestion.suggestion_id,
|
||||||
|
original_name: suggestion.original_name,
|
||||||
|
suggested_name: suggestion.suggested_name,
|
||||||
|
product_type: suggestion.product_type,
|
||||||
|
unit_of_measure: suggestion.unit_of_measure,
|
||||||
|
estimated_shelf_life_days: suggestion.estimated_shelf_life_days,
|
||||||
|
requires_refrigeration: suggestion.requires_refrigeration,
|
||||||
|
requires_freezing: suggestion.requires_freezing,
|
||||||
|
is_seasonal: suggestion.is_seasonal,
|
||||||
|
suggested_supplier: suggestion.suggested_supplier,
|
||||||
|
notes: suggestion.notes,
|
||||||
|
sales_data: suggestion.sales_data,
|
||||||
|
// Legacy fields for display
|
||||||
|
sales_count: suggestion.sales_data?.total_quantity || 0,
|
||||||
|
estimated_price: 0 // Price estimation not provided by current API
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('ReviewStep - Converted products:', products);
|
||||||
|
return products;
|
||||||
|
};
|
||||||
|
|
||||||
export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
||||||
data,
|
data,
|
||||||
@@ -34,35 +72,128 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
isFirstStep,
|
isFirstStep,
|
||||||
isLastStep
|
isLastStep
|
||||||
}) => {
|
}) => {
|
||||||
// Generate products from processing results or use mock data
|
const { createAlert } = useAlertActions();
|
||||||
|
|
||||||
|
// Generate products from AI suggestions in processing results
|
||||||
const generateProductsFromResults = (results: any) => {
|
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) => ({
|
if (results?.aiSuggestions && results.aiSuggestions.length > 0) {
|
||||||
id: (index + 1).toString(),
|
console.log('ReviewStep - Using AI suggestions:', results.aiSuggestions);
|
||||||
name,
|
return convertSuggestionsToProducts(results.aiSuggestions);
|
||||||
category: index < 3 ? 'Panadería' : index < 5 ? 'Bollería' : 'Salados',
|
}
|
||||||
confidence: Math.max(75, results.confidenceScore - Math.random() * 15),
|
// Fallback: create products from product list if no AI suggestions
|
||||||
sales_count: Math.floor(Math.random() * 50) + 10,
|
if (results?.product_list) {
|
||||||
estimated_price: Math.random() * 5 + 1.5,
|
console.log('ReviewStep - Using fallback product list:', results.product_list);
|
||||||
status: 'pending' as const
|
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[]>(
|
const [products, setProducts] = useState<Product[]>(() => {
|
||||||
data.detectedProducts || generateProductsFromResults(data.processingResults)
|
if (data.detectedProducts) {
|
||||||
);
|
return data.detectedProducts;
|
||||||
|
}
|
||||||
|
// Try to get processing results from current step data first, then from previous step data
|
||||||
|
const processingResults = data.processingResults || data.allStepData?.['data-processing']?.processingResults;
|
||||||
|
console.log('ReviewStep - Initializing with processingResults:', processingResults);
|
||||||
|
return generateProductsFromResults(processingResults);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for empty products and show alert after component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
const processingResults = data.processingResults || data.allStepData?.['data-processing']?.processingResults;
|
||||||
|
if (products.length === 0 && processingResults) {
|
||||||
|
createAlert({
|
||||||
|
type: 'warning',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'medium',
|
||||||
|
title: 'Sin productos detectados',
|
||||||
|
message: 'No se encontraron productos en los datos procesados. Verifique el archivo de ventas.',
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [products.length, data.processingResults, data.allStepData, createAlert]);
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
|
|
||||||
const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))];
|
const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))];
|
||||||
|
|
||||||
|
// Memoize computed values to avoid unnecessary recalculations
|
||||||
|
const approvedProducts = useMemo(() =>
|
||||||
|
products.filter(p => p.status === 'approved'),
|
||||||
|
[products]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewCompleted = useMemo(() =>
|
||||||
|
products.length > 0 && products.every(p => p.status !== 'pending'),
|
||||||
|
[products]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [lastReviewCompleted, setLastReviewCompleted] = useState(false);
|
||||||
|
|
||||||
|
const dataChangeRef = useRef({ products: [], approvedProducts: [], reviewCompleted: false });
|
||||||
|
|
||||||
|
// Update parent data when products change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDataChange({
|
const currentState = { products, approvedProducts, reviewCompleted };
|
||||||
...data,
|
const lastState = dataChangeRef.current;
|
||||||
detectedProducts: products,
|
|
||||||
reviewCompleted: products.every(p => p.status !== 'pending')
|
// Only call onDataChange if the state actually changed
|
||||||
});
|
if (JSON.stringify(currentState) !== JSON.stringify(lastState)) {
|
||||||
}, [products]);
|
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') => {
|
const handleProductAction = (productId: string, action: 'approve' | 'reject') => {
|
||||||
setProducts(prev => prev.map(product =>
|
setProducts(prev => prev.map(product =>
|
||||||
@@ -95,191 +226,188 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
pending: products.filter(p => p.status === 'pending').length
|
pending: products.filter(p => p.status === 'pending').length
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: number) => {
|
||||||
|
if (confidence >= 90) return 'text-green-600 bg-green-50';
|
||||||
|
if (confidence >= 75) return 'text-yellow-600 bg-yellow-50';
|
||||||
|
return 'text-red-600 bg-red-50';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved': return 'text-green-600 bg-green-50';
|
||||||
|
case 'rejected': return 'text-red-600 bg-red-50';
|
||||||
|
default: return 'text-gray-600 bg-gray-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (products.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-2xl font-bold text-gray-600 mb-2">
|
||||||
|
No se encontraron productos
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-6 max-w-md mx-auto">
|
||||||
|
No se pudieron detectar productos en el archivo procesado.
|
||||||
|
Verifique que el archivo contenga datos de ventas válidos.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={isFirstStep}
|
||||||
|
>
|
||||||
|
Volver al paso anterior
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
<Card className="p-6 text-center">
|
||||||
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p>
|
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{stats.total}</div>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Total</p>
|
<div className="text-sm text-[var(--text-secondary)]">Productos detectados</div>
|
||||||
|
</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>
|
||||||
<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>
|
<div className="flex space-x-2">
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Aprobados</p>
|
<Button
|
||||||
</div>
|
size="sm"
|
||||||
<div className="text-center p-4 bg-[var(--color-error)]/10 rounded-lg">
|
variant="outline"
|
||||||
<p className="text-2xl font-bold text-[var(--color-error)]">{stats.rejected}</p>
|
onClick={() => handleBulkAction('approve')}
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Rechazados</p>
|
className="text-green-600 border-green-200 hover:bg-green-50"
|
||||||
</div>
|
>
|
||||||
<div className="text-center p-4 bg-[var(--color-warning)]/10 rounded-lg">
|
Aprobar todos
|
||||||
<p className="text-2xl font-bold text-[var(--color-warning)]">{stats.pending}</p>
|
</Button>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Pendientes</p>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleBulkAction('reject')}
|
||||||
|
className="text-red-600 border-red-200 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Rechazar todos
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Products List */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{getFilteredProducts().map((product) => (
|
{getFilteredProducts().map((product) => (
|
||||||
<Card key={product.id} className="p-4">
|
<Card key={product.id} className="p-4 hover:shadow-md transition-shadow">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-start space-x-4 flex-1">
|
<div className="flex-1">
|
||||||
{/* Status Icon */}
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
|
<h3 className="font-semibold text-[var(--text-primary)]">{product.name}</h3>
|
||||||
product.status === 'approved'
|
<Badge variant="outline" className="text-xs">
|
||||||
? 'bg-[var(--color-success)]'
|
{product.category}
|
||||||
: product.status === 'rejected'
|
</Badge>
|
||||||
? 'bg-[var(--color-error)]'
|
<Badge className={`text-xs ${getConfidenceColor(product.confidence)}`}>
|
||||||
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
|
{product.confidence}% confianza
|
||||||
}`}>
|
</Badge>
|
||||||
{product.status === 'approved' ? (
|
<Badge className={`text-xs ${getStatusColor(product.status)}`}>
|
||||||
<CheckCircle className="w-4 h-4 text-white" />
|
{product.status === 'approved' ? 'Aprobado' :
|
||||||
) : product.status === 'rejected' ? (
|
product.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
|
||||||
<Trash2 className="w-4 h-4 text-white" />
|
</Badge>
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 text-[var(--text-tertiary)]" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Info */}
|
<div className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||||
<div className="flex-1">
|
{product.original_name && product.original_name !== product.name && (
|
||||||
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{product.name}</h4>
|
<div>Nombre original: <span className="font-medium">{product.original_name}</span></div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex space-x-4">
|
||||||
<Badge variant="gray">{product.category}</Badge>
|
<span>Tipo: {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}</span>
|
||||||
{product.status !== 'pending' && (
|
<span>Unidad: {product.unit_of_measure}</span>
|
||||||
<Badge variant={product.status === 'approved' ? 'green' : 'red'}>
|
{product.sales_data && (
|
||||||
{product.status === 'approved' ? 'Aprobado' : 'Rechazado'}
|
<span>Ventas: {product.sales_data.total_quantity}</span>
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{product.notes && (
|
||||||
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
<div className="text-xs italic">Nota: {product.notes}</div>
|
||||||
<span className={`px-2 py-1 rounded text-xs ${
|
)}
|
||||||
product.confidence >= 90
|
|
||||||
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
|
||||||
: product.confidence >= 75
|
|
||||||
? 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
|
|
||||||
: 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
|
|
||||||
}`}>
|
|
||||||
{product.confidence}% confianza
|
|
||||||
</span>
|
|
||||||
<span>{product.sales_count} ventas</span>
|
|
||||||
<span>${product.estimated_price.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex gap-2 ml-4 mt-1">
|
<Button
|
||||||
{product.status === 'pending' ? (
|
size="sm"
|
||||||
<>
|
variant={product.status === 'approved' ? 'default' : 'outline'}
|
||||||
<Button
|
onClick={() => handleProductAction(product.id, 'approve')}
|
||||||
size="sm"
|
className={product.status === 'approved' ? 'bg-green-600 hover:bg-green-700' : 'text-green-600 border-green-200 hover:bg-green-50'}
|
||||||
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
|
||||||
<CheckCircle className="w-4 h-4 mr-1" />
|
</Button>
|
||||||
Aprobar
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
<Button
|
variant={product.status === 'rejected' ? 'default' : 'outline'}
|
||||||
size="sm"
|
onClick={() => handleProductAction(product.id, 'reject')}
|
||||||
variant="outline"
|
className={product.status === 'rejected' ? 'bg-red-600 hover:bg-red-700' : 'text-red-600 border-red-200 hover:bg-red-50'}
|
||||||
onClick={() => handleProductAction(product.id, 'reject')}
|
>
|
||||||
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
<AlertCircle className="w-4 h-4 mr-1" />
|
||||||
>
|
Rechazar
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
</Button>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Indicator */}
|
{/* Navigation */}
|
||||||
{stats.pending > 0 && (
|
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
|
||||||
<Card className="p-4 bg-[var(--color-warning)]/5 border-[var(--color-warning)]/20">
|
<Button
|
||||||
<div className="flex items-center space-x-3">
|
variant="outline"
|
||||||
<AlertCircle className="w-5 h-5 text-[var(--color-warning)]" />
|
onClick={onPrevious}
|
||||||
<div>
|
disabled={isFirstStep}
|
||||||
<p className="font-medium text-[var(--text-primary)]">
|
>
|
||||||
{stats.pending} productos pendientes de revisión
|
Anterior
|
||||||
</p>
|
</Button>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
<Button
|
||||||
Revisa todos los productos antes de continuar al siguiente paso
|
onClick={onNext}
|
||||||
</p>
|
disabled={!data.reviewCompleted || stats.approved === 0}
|
||||||
</div>
|
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
|
||||||
</div>
|
>
|
||||||
</Card>
|
{isLastStep ? 'Finalizar' : 'Siguiente'}
|
||||||
)}
|
</Button>
|
||||||
|
</div>
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,66 +1,40 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin } from 'lucide-react';
|
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin, AlertCircle, Loader } from 'lucide-react';
|
||||||
import { Button, Card, Input, Badge } from '../../../ui';
|
import { Button, Card, Input, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||||
|
import { procurementService, type Supplier } from '../../../../services/api/procurement.service';
|
||||||
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||||
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
|
|
||||||
interface Supplier {
|
// Frontend supplier interface that matches the form needs
|
||||||
id: string;
|
interface SupplierFormData {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
contact_person: string;
|
contact_name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
address: string;
|
address: string;
|
||||||
categories: string[];
|
|
||||||
payment_terms: string;
|
payment_terms: string;
|
||||||
delivery_days: string[];
|
delivery_terms: string;
|
||||||
min_order_amount?: number;
|
tax_id?: string;
|
||||||
notes?: string;
|
is_active: boolean;
|
||||||
status: 'active' | 'inactive';
|
|
||||||
created_at: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock suppliers
|
|
||||||
const mockSuppliers: Supplier[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'Molinos del Sur',
|
|
||||||
contact_person: 'Juan Pérez',
|
|
||||||
phone: '+1 555-0123',
|
|
||||||
email: 'ventas@molinosdelsur.com',
|
|
||||||
address: 'Av. Industrial 123, Zona Sur',
|
|
||||||
categories: ['Harinas', 'Granos'],
|
|
||||||
payment_terms: '30 días',
|
|
||||||
delivery_days: ['Lunes', 'Miércoles', 'Viernes'],
|
|
||||||
min_order_amount: 200,
|
|
||||||
notes: 'Proveedor principal de harinas, muy confiable',
|
|
||||||
status: 'active',
|
|
||||||
created_at: '2024-01-15'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Lácteos Premium',
|
|
||||||
contact_person: 'María González',
|
|
||||||
phone: '+1 555-0456',
|
|
||||||
email: 'pedidos@lacteospremium.com',
|
|
||||||
address: 'Calle Central 456, Centro',
|
|
||||||
categories: ['Lácteos', 'Mantequillas', 'Quesos'],
|
|
||||||
payment_terms: '15 días',
|
|
||||||
delivery_days: ['Martes', 'Jueves', 'Sábado'],
|
|
||||||
min_order_amount: 150,
|
|
||||||
status: 'active',
|
|
||||||
created_at: '2024-01-20'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const daysOfWeek = [
|
|
||||||
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
|
|
||||||
];
|
|
||||||
|
|
||||||
const commonCategories = [
|
const commonCategories = [
|
||||||
'Harinas', 'Lácteos', 'Levaduras', 'Azúcares', 'Grasas', 'Huevos',
|
'Harinas', 'Lácteos', 'Levaduras', 'Azúcares', 'Grasas', 'Huevos',
|
||||||
'Frutas', 'Chocolates', 'Frutos Secos', 'Especias', 'Conservantes'
|
'Frutas', 'Chocolates', 'Frutos Secos', 'Especias', 'Conservantes'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const paymentTermsOptions = [
|
||||||
|
'Inmediato',
|
||||||
|
'15 días',
|
||||||
|
'30 días',
|
||||||
|
'45 días',
|
||||||
|
'60 días',
|
||||||
|
'90 días'
|
||||||
|
];
|
||||||
|
|
||||||
export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||||
data,
|
data,
|
||||||
onDataChange,
|
onDataChange,
|
||||||
@@ -69,13 +43,57 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
isFirstStep,
|
isFirstStep,
|
||||||
isLastStep
|
isLastStep
|
||||||
}) => {
|
}) => {
|
||||||
const [suppliers, setSuppliers] = useState<Supplier[]>(
|
const user = useAuthUser();
|
||||||
data.suppliers || mockSuppliers
|
const currentTenant = useCurrentTenant();
|
||||||
);
|
const { createAlert } = useAlertActions();
|
||||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||||
|
const [editingSupplier, setEditingSupplier] = useState<SupplierFormData | null>(null);
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
|
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load suppliers from backend on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSuppliers = async () => {
|
||||||
|
// Check if we already have suppliers loaded
|
||||||
|
if (data.suppliers && Array.isArray(data.suppliers) && data.suppliers.length > 0) {
|
||||||
|
setSuppliers(data.suppliers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentTenant?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await procurementService.getSuppliers({ size: 100 });
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setSuppliers(response.data.items);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load suppliers:', error);
|
||||||
|
createAlert({
|
||||||
|
type: 'error',
|
||||||
|
category: 'system',
|
||||||
|
priority: 'medium',
|
||||||
|
title: 'Error al cargar proveedores',
|
||||||
|
message: 'No se pudieron cargar los proveedores existentes.',
|
||||||
|
source: 'onboarding'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSuppliers();
|
||||||
|
}, [currentTenant?.id]);
|
||||||
|
|
||||||
|
// Update parent data when suppliers change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDataChange({
|
onDataChange({
|
||||||
...data,
|
...data,
|
||||||
@@ -85,60 +103,202 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
}, [suppliers]);
|
}, [suppliers]);
|
||||||
|
|
||||||
const handleAddSupplier = () => {
|
const handleAddSupplier = () => {
|
||||||
const newSupplier: Supplier = {
|
const newSupplier: SupplierFormData = {
|
||||||
id: Date.now().toString(),
|
|
||||||
name: '',
|
name: '',
|
||||||
contact_person: '',
|
contact_name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
address: '',
|
address: '',
|
||||||
categories: [],
|
|
||||||
payment_terms: '30 días',
|
payment_terms: '30 días',
|
||||||
delivery_days: [],
|
delivery_terms: 'Recoger en tienda',
|
||||||
status: 'active',
|
tax_id: '',
|
||||||
created_at: new Date().toISOString().split('T')[0]
|
is_active: true
|
||||||
};
|
};
|
||||||
setEditingSupplier(newSupplier);
|
setEditingSupplier(newSupplier);
|
||||||
setIsAddingNew(true);
|
setIsAddingNew(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveSupplier = (supplier: Supplier) => {
|
const handleSaveSupplier = async (supplierData: SupplierFormData) => {
|
||||||
if (isAddingNew) {
|
if (isAddingNew) {
|
||||||
setSuppliers(prev => [...prev, supplier]);
|
setCreating(true);
|
||||||
|
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 {
|
} 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);
|
setEditingSupplier(null);
|
||||||
setIsAddingNew(false);
|
setIsAddingNew(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSupplier = (id: string) => {
|
const handleDeleteSupplier = async (id: string) => {
|
||||||
if (window.confirm('¿Estás seguro de eliminar este proveedor?')) {
|
if (!window.confirm('¿Estás seguro de eliminar este proveedor? Esta acción no se puede deshacer.')) {
|
||||||
setSuppliers(prev => prev.filter(s => s.id !== id));
|
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) => {
|
const toggleSupplierStatus = async (id: string, currentStatus: boolean) => {
|
||||||
setSuppliers(prev => prev.map(s =>
|
try {
|
||||||
s.id === id
|
const response = await procurementService.updateSupplier(id, {
|
||||||
? { ...s, status: s.status === 'active' ? 'inactive' : 'active' }
|
is_active: !currentStatus
|
||||||
: s
|
});
|
||||||
));
|
|
||||||
|
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 = () => {
|
const getFilteredSuppliers = () => {
|
||||||
return filterStatus === 'all'
|
if (filterStatus === 'all') {
|
||||||
? suppliers
|
return suppliers;
|
||||||
: suppliers.filter(s => s.status === filterStatus);
|
}
|
||||||
|
return suppliers.filter(s =>
|
||||||
|
filterStatus === 'active' ? s.is_active : !s.is_active
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: suppliers.length,
|
total: suppliers.length,
|
||||||
active: suppliers.filter(s => s.status === 'active').length,
|
active: suppliers.filter(s => s.is_active).length,
|
||||||
inactive: suppliers.filter(s => s.status === 'inactive').length,
|
inactive: suppliers.filter(s => !s.is_active).length,
|
||||||
categories: Array.from(new Set(suppliers.flatMap(s => s.categories))).length
|
totalOrders: suppliers.reduce((sum, s) => sum + s.performance_metrics.total_orders, 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)] mx-auto mb-4" />
|
||||||
|
<p className="text-[var(--text-secondary)]">Cargando proveedores...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Optional Step Notice */}
|
{/* Optional Step Notice */}
|
||||||
@@ -161,8 +321,13 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleAddSupplier}
|
onClick={handleAddSupplier}
|
||||||
|
disabled={creating}
|
||||||
>
|
>
|
||||||
<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
|
Agregar Proveedor
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,8 +349,8 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<p className="text-xs text-[var(--text-secondary)]">Inactivos</p>
|
<p className="text-xs text-[var(--text-secondary)]">Inactivos</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center">
|
<Card className="p-4 text-center">
|
||||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.categories}</p>
|
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.totalOrders}</p>
|
||||||
<p className="text-xs text-[var(--text-secondary)]">Categorías</p>
|
<p className="text-xs text-[var(--text-secondary)]">Órdenes</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,12 +388,12 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<div className="flex items-start space-x-4 flex-1">
|
<div className="flex items-start space-x-4 flex-1">
|
||||||
{/* Status Icon */}
|
{/* Status Icon */}
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
|
||||||
supplier.status === 'active'
|
supplier.is_active
|
||||||
? 'bg-[var(--color-success)]/10'
|
? 'bg-[var(--color-success)]/10'
|
||||||
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
|
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
|
||||||
}`}>
|
}`}>
|
||||||
<Truck className={`w-4 h-4 ${
|
<Truck className={`w-4 h-4 ${
|
||||||
supplier.status === 'active'
|
supplier.is_active
|
||||||
? 'text-[var(--color-success)]'
|
? 'text-[var(--color-success)]'
|
||||||
: 'text-[var(--text-tertiary)]'
|
: 'text-[var(--text-tertiary)]'
|
||||||
}`} />
|
}`} />
|
||||||
@@ -239,63 +404,70 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{supplier.name}</h4>
|
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{supplier.name}</h4>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Badge variant={supplier.status === 'active' ? 'green' : 'gray'}>
|
<Badge variant={supplier.is_active ? 'green' : 'gray'}>
|
||||||
{supplier.status === 'active' ? 'Activo' : 'Inactivo'}
|
{supplier.is_active ? 'Activo' : 'Inactivo'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{supplier.categories.slice(0, 2).map((cat, idx) => (
|
{supplier.rating && (
|
||||||
<Badge key={idx} variant="blue">{cat}</Badge>
|
<Badge variant="blue">★ {supplier.rating.toFixed(1)}</Badge>
|
||||||
))}
|
)}
|
||||||
{supplier.categories.length > 2 && (
|
{supplier.performance_metrics.total_orders > 0 && (
|
||||||
<Badge variant="gray">+{supplier.categories.length - 2}</Badge>
|
<Badge variant="purple">{supplier.performance_metrics.total_orders} órdenes</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)] mb-2">
|
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)] mb-2">
|
||||||
<div>
|
{supplier.contact_name && (
|
||||||
<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 && (
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--text-tertiary)]">Mín: </span>
|
<span className="text-[var(--text-tertiary)]">Contacto: </span>
|
||||||
<span className="font-medium text-[var(--text-primary)]">${supplier.min_order_amount}</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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||||
<div className="flex items-center gap-1">
|
{supplier.phone && (
|
||||||
<Phone className="w-3 h-3" />
|
<div className="flex items-center gap-1">
|
||||||
<span>{supplier.phone}</span>
|
<Phone className="w-3 h-3" />
|
||||||
</div>
|
<span>{supplier.phone}</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
<Mail className="w-3 h-3" />
|
)}
|
||||||
<span>{supplier.email}</span>
|
{supplier.email && (
|
||||||
</div>
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
<MapPin className="w-3 h-3" />
|
<MapPin className="w-3 h-3" />
|
||||||
<span>{supplier.address}</span>
|
<span>{supplier.address}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{supplier.notes && (
|
{supplier.performance_metrics.on_time_delivery_rate > 0 && (
|
||||||
<div className="mt-3 p-2 bg-[var(--bg-secondary)] rounded text-sm">
|
<div className="mt-3 p-2 bg-[var(--bg-secondary)] rounded text-sm">
|
||||||
<span className="text-[var(--text-tertiary)]">Notas: </span>
|
<span className="text-[var(--text-tertiary)]">Rendimiento: </span>
|
||||||
<span className="text-[var(--text-primary)]">{supplier.notes}</span>
|
<span className="text-[var(--text-primary)]">
|
||||||
|
{supplier.performance_metrics.on_time_delivery_rate}% entregas a tiempo
|
||||||
|
</span>
|
||||||
|
{supplier.performance_metrics.quality_score > 0 && (
|
||||||
|
<span className="text-[var(--text-primary)]">
|
||||||
|
, {supplier.performance_metrics.quality_score}/5 calidad
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -306,7 +478,24 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setEditingSupplier(supplier)}
|
onClick={() => {
|
||||||
|
// Convert backend supplier to form data
|
||||||
|
const formData: SupplierFormData = {
|
||||||
|
id: supplier.id,
|
||||||
|
name: supplier.name,
|
||||||
|
contact_name: supplier.contact_name || '',
|
||||||
|
phone: supplier.phone || '',
|
||||||
|
email: supplier.email || '',
|
||||||
|
address: supplier.address,
|
||||||
|
payment_terms: supplier.payment_terms || '30 días',
|
||||||
|
delivery_terms: supplier.delivery_terms || 'Recoger en tienda',
|
||||||
|
tax_id: supplier.tax_id || '',
|
||||||
|
is_active: supplier.is_active
|
||||||
|
};
|
||||||
|
setEditingSupplier(formData);
|
||||||
|
setIsAddingNew(false);
|
||||||
|
}}
|
||||||
|
disabled={updating}
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
Editar
|
Editar
|
||||||
@@ -315,13 +504,14 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => toggleSupplierStatus(supplier.id)}
|
onClick={() => toggleSupplierStatus(supplier.id, supplier.is_active)}
|
||||||
className={supplier.status === 'active'
|
className={supplier.is_active
|
||||||
? 'text-[var(--color-warning)] border-[var(--color-warning)] hover:bg-[var(--color-warning)]/10'
|
? 'text-[var(--color-warning)] border-[var(--color-warning)] hover:bg-[var(--color-warning)]/10'
|
||||||
: 'text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10'
|
: 'text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10'
|
||||||
}
|
}
|
||||||
|
disabled={updating}
|
||||||
>
|
>
|
||||||
{supplier.status === 'active' ? 'Pausar' : 'Activar'}
|
{supplier.is_active ? 'Pausar' : 'Activar'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -329,19 +519,29 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleDeleteSupplier(supplier.id)}
|
onClick={() => handleDeleteSupplier(supplier.id)}
|
||||||
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
||||||
|
disabled={deleting === supplier.id}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
{deleting === supplier.id ? (
|
||||||
|
<Loader className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{getFilteredSuppliers().length === 0 && (
|
{getFilteredSuppliers().length === 0 && !loading && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<Truck className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
<Truck className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||||
<p className="text-[var(--text-secondary)] mb-4">No hay proveedores en esta categoría</p>
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
<Button onClick={handleAddSupplier}>
|
{filterStatus === 'all'
|
||||||
|
? 'No hay proveedores registrados'
|
||||||
|
: `No hay proveedores ${filterStatus === 'active' ? 'activos' : 'inactivos'}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleAddSupplier} disabled={creating}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Agregar Primer Proveedor
|
Agregar Primer Proveedor
|
||||||
</Button>
|
</Button>
|
||||||
@@ -364,6 +564,8 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
setEditingSupplier(null);
|
setEditingSupplier(null);
|
||||||
setIsAddingNew(false);
|
setIsAddingNew(false);
|
||||||
}}
|
}}
|
||||||
|
isCreating={creating}
|
||||||
|
isUpdating={updating}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,45 +590,37 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
|
|
||||||
// Component for editing suppliers
|
// Component for editing suppliers
|
||||||
interface SupplierFormProps {
|
interface SupplierFormProps {
|
||||||
supplier: Supplier;
|
supplier: SupplierFormData;
|
||||||
onSave: (supplier: Supplier) => void;
|
onSave: (supplier: SupplierFormData) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
isCreating: boolean;
|
||||||
|
isUpdating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel }) => {
|
const SupplierForm: React.FC<SupplierFormProps> = ({
|
||||||
|
supplier,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isCreating,
|
||||||
|
isUpdating
|
||||||
|
}) => {
|
||||||
const [formData, setFormData] = useState(supplier);
|
const [formData, setFormData] = useState(supplier);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
alert('El nombre es requerido');
|
alert('El nombre de la empresa es requerido');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!formData.contact_person.trim()) {
|
if (!formData.address.trim()) {
|
||||||
alert('El contacto es requerido');
|
alert('La dirección es requerida');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(formData);
|
onSave(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategory = (category: string) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
categories: prev.categories.includes(category)
|
|
||||||
? prev.categories.filter(c => c !== category)
|
|
||||||
: [...prev.categories, category]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDeliveryDay = (day: string) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
delivery_days: prev.delivery_days.includes(day)
|
|
||||||
? prev.delivery_days.filter(d => d !== day)
|
|
||||||
: [...prev.delivery_days, day]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -438,17 +632,19 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
placeholder="Molinos del Sur"
|
placeholder="Molinos del Sur"
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
Persona de Contacto *
|
Persona de Contacto
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.contact_person}
|
value={formData.contact_name}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, contact_person: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, contact_name: e.target.value }))}
|
||||||
placeholder="Juan Pérez"
|
placeholder="Juan Pérez"
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -462,6 +658,7 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
|
|||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
|
||||||
placeholder="+1 555-0123"
|
placeholder="+1 555-0123"
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -474,40 +671,23 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||||
placeholder="ventas@proveedor.com"
|
placeholder="ventas@proveedor.com"
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
Dirección
|
Dirección *
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
|
||||||
placeholder="Av. Industrial 123, Zona Sur"
|
placeholder="Av. Industrial 123, Zona Sur"
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
|
||||||
Categorías de Productos
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
||||||
{commonCategories.map(category => (
|
|
||||||
<label key={category} className="flex items-center space-x-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.categories.includes(category)}
|
|
||||||
onChange={() => toggleCategory(category)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-[var(--text-primary)]">{category}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
@@ -517,67 +697,74 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
|
|||||||
value={formData.payment_terms}
|
value={formData.payment_terms}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, payment_terms: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, payment_terms: e.target.value }))}
|
||||||
className="w-full p-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
className="w-full p-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
>
|
>
|
||||||
<option value="Inmediato">Inmediato</option>
|
{paymentTermsOptions.map(term => (
|
||||||
<option value="15 días">15 días</option>
|
<option key={term} value={term}>{term}</option>
|
||||||
<option value="30 días">30 días</option>
|
))}
|
||||||
<option value="45 días">45 días</option>
|
|
||||||
<option value="60 días">60 días</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
Pedido Mínimo
|
Términos de Entrega
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
value={formData.delivery_terms}
|
||||||
value={formData.min_order_amount || ''}
|
onChange={(e) => setFormData(prev => ({ ...prev, delivery_terms: e.target.value }))}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, min_order_amount: Number(e.target.value) }))}
|
placeholder="Recoger en tienda"
|
||||||
placeholder="200"
|
disabled={isCreating || isUpdating}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
|
||||||
Días de Entrega
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{daysOfWeek.map(day => (
|
|
||||||
<label key={day} className="flex items-center space-x-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.delivery_days.includes(day)}
|
|
||||||
onChange={() => toggleDeliveryDay(day)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-[var(--text-primary)]">{day}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
Notas
|
RUT/NIT (Opcional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<Input
|
||||||
value={formData.notes || ''}
|
value={formData.tax_id || ''}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, tax_id: e.target.value }))}
|
||||||
placeholder="Información adicional sobre el proveedor..."
|
placeholder="12345678-9"
|
||||||
className="w-full p-2 border border-[var(--border-secondary)] rounded resize-none focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
disabled={isCreating || isUpdating}
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_active"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, is_active: e.target.checked }))}
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_active" className="text-sm text-[var(--text-primary)]">
|
||||||
|
Proveedor activo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">
|
<Button
|
||||||
Guardar Proveedor
|
type="submit"
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
|
>
|
||||||
|
{isCreating || isUpdating ? (
|
||||||
|
<>
|
||||||
|
<Loader className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
{isCreating ? 'Creando...' : 'Actualizando...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Guardar Proveedor'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,85 +1,17 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
import {
|
||||||
// Request/Response Types based on backend schemas
|
UserRegistration,
|
||||||
export interface UserRegistration {
|
UserLogin,
|
||||||
email: string;
|
UserData,
|
||||||
password: string;
|
TokenResponse,
|
||||||
full_name: string;
|
RefreshTokenRequest,
|
||||||
tenant_name?: string;
|
PasswordChange,
|
||||||
role?: 'user' | 'admin' | 'manager';
|
PasswordReset,
|
||||||
}
|
PasswordResetConfirm,
|
||||||
|
TokenVerification,
|
||||||
export interface UserLogin {
|
UserResponse,
|
||||||
email: string;
|
UserUpdate
|
||||||
password: string;
|
} from '../../types/auth.types';
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserData {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_verified: boolean;
|
|
||||||
created_at: string;
|
|
||||||
tenant_id?: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
token_type: string;
|
|
||||||
expires_in: number;
|
|
||||||
user?: UserData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RefreshTokenRequest {
|
|
||||||
refresh_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordChange {
|
|
||||||
current_password: string;
|
|
||||||
new_password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordReset {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetConfirm {
|
|
||||||
token: string;
|
|
||||||
new_password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenVerification {
|
|
||||||
valid: boolean;
|
|
||||||
user_id?: string;
|
|
||||||
email?: string;
|
|
||||||
exp?: number;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserResponse {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_verified: boolean;
|
|
||||||
created_at: string;
|
|
||||||
last_login?: string;
|
|
||||||
phone?: string;
|
|
||||||
language?: string;
|
|
||||||
timezone?: string;
|
|
||||||
tenant_id?: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserUpdate {
|
|
||||||
full_name?: string;
|
|
||||||
phone?: string;
|
|
||||||
language?: string;
|
|
||||||
timezone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
private readonly baseUrl = '/auth';
|
private readonly baseUrl = '/auth';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import { ApiResponse, ApiError } from '../../types/api.types';
|
||||||
|
|
||||||
// Utility functions to access auth and tenant store data from localStorage
|
// Utility functions to access auth and tenant store data from localStorage
|
||||||
const getAuthData = () => {
|
const getAuthData = () => {
|
||||||
@@ -27,22 +28,13 @@ const clearAuthData = () => {
|
|||||||
localStorage.removeItem('auth-storage');
|
localStorage.removeItem('auth-storage');
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
// Client-specific error interface
|
||||||
data: T;
|
interface ClientError {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
error: {
|
||||||
error?: string;
|
message: string;
|
||||||
}
|
code?: string;
|
||||||
|
};
|
||||||
export interface ErrorDetail {
|
|
||||||
message: string;
|
|
||||||
code?: string;
|
|
||||||
field?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiError {
|
|
||||||
success: boolean;
|
|
||||||
error: ErrorDetail;
|
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +108,7 @@ class ApiClient {
|
|||||||
|
|
||||||
// Handle network errors
|
// Handle network errors
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
const networkError: ApiError = {
|
const networkError: ClientError = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
message: 'Network error - please check your connection',
|
message: 'Network error - please check your connection',
|
||||||
|
|||||||
@@ -1,67 +1,27 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
import {
|
||||||
// External data types
|
WeatherData,
|
||||||
export interface WeatherData {
|
WeatherDataParams,
|
||||||
id: string;
|
TrafficData,
|
||||||
tenant_id: string;
|
TrafficDataParams,
|
||||||
location_id: string;
|
TrafficPatternsParams,
|
||||||
date: string;
|
TrafficPattern,
|
||||||
temperature_avg: number;
|
EventData,
|
||||||
temperature_min: number;
|
EventsParams,
|
||||||
temperature_max: number;
|
CustomEventCreate,
|
||||||
humidity: number;
|
LocationConfig,
|
||||||
precipitation: number;
|
LocationCreate,
|
||||||
wind_speed: number;
|
ExternalFactorsImpact,
|
||||||
condition: string;
|
ExternalFactorsParams,
|
||||||
description: string;
|
DataQualityReport,
|
||||||
created_at: string;
|
DataSettings,
|
||||||
}
|
DataSettingsUpdate,
|
||||||
|
RefreshDataResponse,
|
||||||
export interface TrafficData {
|
DeleteResponse,
|
||||||
id: string;
|
WeatherCondition,
|
||||||
tenant_id: string;
|
EventType,
|
||||||
location_id: string;
|
RefreshInterval
|
||||||
date: string;
|
} from '../../types/data.types';
|
||||||
hour: number;
|
|
||||||
traffic_level: number;
|
|
||||||
congestion_index: number;
|
|
||||||
average_speed: number;
|
|
||||||
incident_count: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventData {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
location_id: string;
|
|
||||||
event_name: string;
|
|
||||||
event_type: string;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string;
|
|
||||||
expected_attendance?: number;
|
|
||||||
impact_radius_km?: number;
|
|
||||||
impact_score: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocationConfig {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
is_primary: boolean;
|
|
||||||
data_sources: {
|
|
||||||
weather_enabled: boolean;
|
|
||||||
traffic_enabled: boolean;
|
|
||||||
events_enabled: boolean;
|
|
||||||
};
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class DataService {
|
class DataService {
|
||||||
private readonly baseUrl = '/data';
|
private readonly baseUrl = '/data';
|
||||||
@@ -75,16 +35,7 @@ class DataService {
|
|||||||
return apiClient.get(`${this.baseUrl}/locations/${locationId}`);
|
return apiClient.get(`${this.baseUrl}/locations/${locationId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createLocation(locationData: {
|
async createLocation(locationData: LocationCreate): Promise<ApiResponse<LocationConfig>> {
|
||||||
name: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
country?: string;
|
|
||||||
is_primary?: boolean;
|
|
||||||
data_sources?: LocationConfig['data_sources'];
|
|
||||||
}): Promise<ApiResponse<LocationConfig>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/locations`, locationData);
|
return apiClient.post(`${this.baseUrl}/locations`, locationData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,18 +43,12 @@ class DataService {
|
|||||||
return apiClient.put(`${this.baseUrl}/locations/${locationId}`, locationData);
|
return apiClient.put(`${this.baseUrl}/locations/${locationId}`, locationData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteLocation(locationId: string): Promise<ApiResponse<{ message: string }>> {
|
async deleteLocation(locationId: string): Promise<ApiResponse<DeleteResponse>> {
|
||||||
return apiClient.delete(`${this.baseUrl}/locations/${locationId}`);
|
return apiClient.delete(`${this.baseUrl}/locations/${locationId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weather data
|
// Weather data
|
||||||
async getWeatherData(params?: {
|
async getWeatherData(params?: WeatherDataParams): Promise<ApiResponse<{ items: WeatherData[]; total: number; page: number; size: number; pages: number }>> {
|
||||||
location_id?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
}): Promise<ApiResponse<{ items: WeatherData[]; total: number; page: number; size: number; pages: number }>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -129,7 +74,7 @@ class DataService {
|
|||||||
return apiClient.get(`${this.baseUrl}/weather/forecast/${locationId}?days=${days}`);
|
return apiClient.get(`${this.baseUrl}/weather/forecast/${locationId}?days=${days}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshWeatherData(locationId?: string): Promise<ApiResponse<{ message: string; updated_records: number }>> {
|
async refreshWeatherData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
|
||||||
const url = locationId
|
const url = locationId
|
||||||
? `${this.baseUrl}/weather/refresh/${locationId}`
|
? `${this.baseUrl}/weather/refresh/${locationId}`
|
||||||
: `${this.baseUrl}/weather/refresh`;
|
: `${this.baseUrl}/weather/refresh`;
|
||||||
@@ -138,14 +83,7 @@ class DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Traffic data
|
// Traffic data
|
||||||
async getTrafficData(params?: {
|
async getTrafficData(params?: TrafficDataParams): Promise<ApiResponse<{ items: TrafficData[]; total: number; page: number; size: number; pages: number }>> {
|
||||||
location_id?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
hour?: number;
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
}): Promise<ApiResponse<{ items: TrafficData[]; total: number; page: number; size: number; pages: number }>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -167,15 +105,7 @@ class DataService {
|
|||||||
return apiClient.get(`${this.baseUrl}/traffic/current/${locationId}`);
|
return apiClient.get(`${this.baseUrl}/traffic/current/${locationId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrafficPatterns(locationId: string, params?: {
|
async getTrafficPatterns(locationId: string, params?: TrafficPatternsParams): Promise<ApiResponse<TrafficPattern[]>> {
|
||||||
days_back?: number;
|
|
||||||
granularity?: 'hourly' | 'daily';
|
|
||||||
}): Promise<ApiResponse<Array<{
|
|
||||||
period: string;
|
|
||||||
average_traffic_level: number;
|
|
||||||
peak_hours: number[];
|
|
||||||
congestion_patterns: Record<string, number>;
|
|
||||||
}>>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -193,7 +123,7 @@ class DataService {
|
|||||||
return apiClient.get(url);
|
return apiClient.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshTrafficData(locationId?: string): Promise<ApiResponse<{ message: string; updated_records: number }>> {
|
async refreshTrafficData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
|
||||||
const url = locationId
|
const url = locationId
|
||||||
? `${this.baseUrl}/traffic/refresh/${locationId}`
|
? `${this.baseUrl}/traffic/refresh/${locationId}`
|
||||||
: `${this.baseUrl}/traffic/refresh`;
|
: `${this.baseUrl}/traffic/refresh`;
|
||||||
@@ -202,14 +132,7 @@ class DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Events data
|
// Events data
|
||||||
async getEvents(params?: {
|
async getEvents(params?: EventsParams): Promise<ApiResponse<{ items: EventData[]; total: number; page: number; size: number; pages: number }>> {
|
||||||
location_id?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
event_type?: string;
|
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
}): Promise<ApiResponse<{ items: EventData[]; total: number; page: number; size: number; pages: number }>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -231,16 +154,7 @@ class DataService {
|
|||||||
return apiClient.get(`${this.baseUrl}/events/upcoming/${locationId}?days=${days}`);
|
return apiClient.get(`${this.baseUrl}/events/upcoming/${locationId}?days=${days}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCustomEvent(eventData: {
|
async createCustomEvent(eventData: CustomEventCreate): Promise<ApiResponse<EventData>> {
|
||||||
location_id: string;
|
|
||||||
event_name: string;
|
|
||||||
event_type: string;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string;
|
|
||||||
expected_attendance?: number;
|
|
||||||
impact_radius_km?: number;
|
|
||||||
impact_score?: number;
|
|
||||||
}): Promise<ApiResponse<EventData>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/events`, eventData);
|
return apiClient.post(`${this.baseUrl}/events`, eventData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,11 +162,11 @@ class DataService {
|
|||||||
return apiClient.put(`${this.baseUrl}/events/${eventId}`, eventData);
|
return apiClient.put(`${this.baseUrl}/events/${eventId}`, eventData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteEvent(eventId: string): Promise<ApiResponse<{ message: string }>> {
|
async deleteEvent(eventId: string): Promise<ApiResponse<DeleteResponse>> {
|
||||||
return apiClient.delete(`${this.baseUrl}/events/${eventId}`);
|
return apiClient.delete(`${this.baseUrl}/events/${eventId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshEventsData(locationId?: string): Promise<ApiResponse<{ message: string; updated_records: number }>> {
|
async refreshEventsData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
|
||||||
const url = locationId
|
const url = locationId
|
||||||
? `${this.baseUrl}/events/refresh/${locationId}`
|
? `${this.baseUrl}/events/refresh/${locationId}`
|
||||||
: `${this.baseUrl}/events/refresh`;
|
: `${this.baseUrl}/events/refresh`;
|
||||||
@@ -261,27 +175,7 @@ class DataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combined analytics
|
// Combined analytics
|
||||||
async getExternalFactorsImpact(params?: {
|
async getExternalFactorsImpact(params?: ExternalFactorsParams): Promise<ApiResponse<ExternalFactorsImpact>> {
|
||||||
location_id?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}): Promise<ApiResponse<{
|
|
||||||
weather_impact: {
|
|
||||||
temperature_correlation: number;
|
|
||||||
precipitation_impact: number;
|
|
||||||
most_favorable_conditions: string;
|
|
||||||
};
|
|
||||||
traffic_impact: {
|
|
||||||
congestion_correlation: number;
|
|
||||||
peak_traffic_effect: number;
|
|
||||||
optimal_traffic_levels: number[];
|
|
||||||
};
|
|
||||||
events_impact: {
|
|
||||||
positive_events: EventData[];
|
|
||||||
negative_events: EventData[];
|
|
||||||
average_event_boost: number;
|
|
||||||
};
|
|
||||||
}>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -299,64 +193,21 @@ class DataService {
|
|||||||
return apiClient.get(url);
|
return apiClient.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDataQualityReport(): Promise<ApiResponse<{
|
async getDataQualityReport(): Promise<ApiResponse<DataQualityReport>> {
|
||||||
overall_score: number;
|
|
||||||
data_sources: Array<{
|
|
||||||
source: 'weather' | 'traffic' | 'events';
|
|
||||||
completeness: number;
|
|
||||||
freshness_hours: number;
|
|
||||||
reliability_score: number;
|
|
||||||
last_update: string;
|
|
||||||
}>;
|
|
||||||
recommendations: Array<{
|
|
||||||
priority: 'high' | 'medium' | 'low';
|
|
||||||
message: string;
|
|
||||||
action: string;
|
|
||||||
}>;
|
|
||||||
}>> {
|
|
||||||
return apiClient.get(`${this.baseUrl}/quality-report`);
|
return apiClient.get(`${this.baseUrl}/quality-report`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data configuration
|
// Data configuration
|
||||||
async getDataSettings(): Promise<ApiResponse<{
|
async getDataSettings(): Promise<ApiResponse<DataSettings>> {
|
||||||
auto_refresh_enabled: boolean;
|
|
||||||
refresh_intervals: {
|
|
||||||
weather_minutes: number;
|
|
||||||
traffic_minutes: number;
|
|
||||||
events_hours: number;
|
|
||||||
};
|
|
||||||
data_retention_days: {
|
|
||||||
weather: number;
|
|
||||||
traffic: number;
|
|
||||||
events: number;
|
|
||||||
};
|
|
||||||
external_apis: {
|
|
||||||
weather_provider: string;
|
|
||||||
traffic_provider: string;
|
|
||||||
events_provider: string;
|
|
||||||
};
|
|
||||||
}>> {
|
|
||||||
return apiClient.get(`${this.baseUrl}/settings`);
|
return apiClient.get(`${this.baseUrl}/settings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDataSettings(settings: {
|
async updateDataSettings(settings: DataSettingsUpdate): Promise<ApiResponse<DataSettings>> {
|
||||||
auto_refresh_enabled?: boolean;
|
|
||||||
refresh_intervals?: {
|
|
||||||
weather_minutes?: number;
|
|
||||||
traffic_minutes?: number;
|
|
||||||
events_hours?: number;
|
|
||||||
};
|
|
||||||
data_retention_days?: {
|
|
||||||
weather?: number;
|
|
||||||
traffic?: number;
|
|
||||||
events?: number;
|
|
||||||
};
|
|
||||||
}): Promise<ApiResponse<any>> {
|
|
||||||
return apiClient.put(`${this.baseUrl}/settings`, settings);
|
return apiClient.put(`${this.baseUrl}/settings`, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
getWeatherConditions(): { value: string; label: string; impact: 'positive' | 'negative' | 'neutral' }[] {
|
getWeatherConditions(): WeatherCondition[] {
|
||||||
return [
|
return [
|
||||||
{ value: 'sunny', label: 'Sunny', impact: 'positive' },
|
{ value: 'sunny', label: 'Sunny', impact: 'positive' },
|
||||||
{ value: 'cloudy', label: 'Cloudy', impact: 'neutral' },
|
{ value: 'cloudy', label: 'Cloudy', impact: 'neutral' },
|
||||||
@@ -367,7 +218,7 @@ class DataService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
getEventTypes(): { value: string; label: string; typical_impact: 'positive' | 'negative' | 'neutral' }[] {
|
getEventTypes(): EventType[] {
|
||||||
return [
|
return [
|
||||||
{ value: 'festival', label: 'Festival', typical_impact: 'positive' },
|
{ value: 'festival', label: 'Festival', typical_impact: 'positive' },
|
||||||
{ value: 'concert', label: 'Concert', typical_impact: 'positive' },
|
{ value: 'concert', label: 'Concert', typical_impact: 'positive' },
|
||||||
@@ -380,7 +231,7 @@ class DataService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
getRefreshIntervals(): { value: number; label: string; suitable_for: string[] }[] {
|
getRefreshIntervals(): RefreshInterval[] {
|
||||||
return [
|
return [
|
||||||
{ value: 5, label: '5 minutes', suitable_for: ['traffic'] },
|
{ value: 5, label: '5 minutes', suitable_for: ['traffic'] },
|
||||||
{ value: 15, label: '15 minutes', suitable_for: ['traffic'] },
|
{ value: 15, label: '15 minutes', suitable_for: ['traffic'] },
|
||||||
|
|||||||
@@ -1,68 +1,10 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
import {
|
||||||
// Request/Response Types
|
ForecastRequest,
|
||||||
export interface ForecastRequest {
|
ForecastResponse,
|
||||||
product_name: string;
|
PredictionBatch,
|
||||||
days_ahead: number;
|
ModelPerformance
|
||||||
start_date?: string;
|
} from '../../types/forecasting.types';
|
||||||
include_confidence_intervals?: boolean;
|
|
||||||
external_factors?: {
|
|
||||||
weather?: string[];
|
|
||||||
events?: string[];
|
|
||||||
holidays?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ForecastResponse {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
product_name: string;
|
|
||||||
forecast_date: string;
|
|
||||||
predicted_demand: number;
|
|
||||||
confidence_lower: number;
|
|
||||||
confidence_upper: number;
|
|
||||||
confidence_level: number;
|
|
||||||
external_factors: Record<string, any>;
|
|
||||||
model_version: string;
|
|
||||||
created_at: string;
|
|
||||||
actual_demand?: number;
|
|
||||||
accuracy_score?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PredictionBatch {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
parameters: Record<string, any>;
|
|
||||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
|
||||||
progress: number;
|
|
||||||
total_predictions: number;
|
|
||||||
completed_predictions: number;
|
|
||||||
failed_predictions: number;
|
|
||||||
created_at: string;
|
|
||||||
completed_at?: string;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelPerformance {
|
|
||||||
model_id: string;
|
|
||||||
model_name: string;
|
|
||||||
version: string;
|
|
||||||
accuracy_metrics: {
|
|
||||||
mape: number; // Mean Absolute Percentage Error
|
|
||||||
rmse: number; // Root Mean Square Error
|
|
||||||
mae: number; // Mean Absolute Error
|
|
||||||
r2_score: number;
|
|
||||||
};
|
|
||||||
training_data_period: {
|
|
||||||
start_date: string;
|
|
||||||
end_date: string;
|
|
||||||
total_records: number;
|
|
||||||
};
|
|
||||||
last_training_date: string;
|
|
||||||
performance_trend: 'improving' | 'stable' | 'declining';
|
|
||||||
}
|
|
||||||
|
|
||||||
class ForecastingService {
|
class ForecastingService {
|
||||||
private readonly baseUrl = '/forecasting';
|
private readonly baseUrl = '/forecasting';
|
||||||
|
|||||||
@@ -1,35 +1,20 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
import { apiClient } from './client';
|
||||||
|
import { ApiResponse } from '../../types/api.types';
|
||||||
|
import {
|
||||||
|
UnitOfMeasure,
|
||||||
|
ProductType,
|
||||||
|
StockMovementType,
|
||||||
|
Ingredient,
|
||||||
|
Stock,
|
||||||
|
StockMovement,
|
||||||
|
StockAlert,
|
||||||
|
InventorySummary,
|
||||||
|
StockLevelSummary
|
||||||
|
} from '../../types/inventory.types';
|
||||||
|
import { PaginatedResponse } from '../../types/api.types';
|
||||||
|
|
||||||
// Enums
|
// Service-specific types for Create/Update operations
|
||||||
export enum UnitOfMeasure {
|
interface IngredientCreate {
|
||||||
KILOGRAM = 'kg',
|
|
||||||
GRAM = 'g',
|
|
||||||
LITER = 'l',
|
|
||||||
MILLILITER = 'ml',
|
|
||||||
PIECE = 'piece',
|
|
||||||
PACKAGE = 'package',
|
|
||||||
BAG = 'bag',
|
|
||||||
BOX = 'box',
|
|
||||||
DOZEN = 'dozen',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ProductType {
|
|
||||||
INGREDIENT = 'ingredient',
|
|
||||||
FINISHED_PRODUCT = 'finished_product',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum StockMovementType {
|
|
||||||
PURCHASE = 'purchase',
|
|
||||||
SALE = 'sale',
|
|
||||||
USAGE = 'usage',
|
|
||||||
WASTE = 'waste',
|
|
||||||
ADJUSTMENT = 'adjustment',
|
|
||||||
TRANSFER = 'transfer',
|
|
||||||
RETURN = 'return',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request/Response Types
|
|
||||||
export interface IngredientCreate {
|
|
||||||
name: string;
|
name: string;
|
||||||
product_type?: ProductType;
|
product_type?: ProductType;
|
||||||
sku?: string;
|
sku?: string;
|
||||||
@@ -57,74 +42,11 @@ export interface IngredientCreate {
|
|||||||
allergen_info?: Record<string, any>;
|
allergen_info?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IngredientUpdate {
|
interface IngredientUpdate extends Partial<IngredientCreate> {
|
||||||
name?: string;
|
|
||||||
product_type?: ProductType;
|
|
||||||
sku?: string;
|
|
||||||
barcode?: string;
|
|
||||||
category?: string;
|
|
||||||
subcategory?: string;
|
|
||||||
description?: string;
|
|
||||||
brand?: string;
|
|
||||||
unit_of_measure?: UnitOfMeasure;
|
|
||||||
package_size?: number;
|
|
||||||
average_cost?: number;
|
|
||||||
standard_cost?: number;
|
|
||||||
low_stock_threshold?: number;
|
|
||||||
reorder_point?: number;
|
|
||||||
reorder_quantity?: number;
|
|
||||||
max_stock_level?: number;
|
|
||||||
requires_refrigeration?: boolean;
|
|
||||||
requires_freezing?: boolean;
|
|
||||||
storage_temperature_min?: number;
|
|
||||||
storage_temperature_max?: number;
|
|
||||||
storage_humidity_max?: number;
|
|
||||||
shelf_life_days?: number;
|
|
||||||
storage_instructions?: string;
|
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
is_perishable?: boolean;
|
|
||||||
allergen_info?: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IngredientResponse {
|
interface StockCreate {
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
product_type: ProductType;
|
|
||||||
sku?: string;
|
|
||||||
barcode?: string;
|
|
||||||
category?: string;
|
|
||||||
subcategory?: string;
|
|
||||||
description?: string;
|
|
||||||
brand?: string;
|
|
||||||
unit_of_measure: UnitOfMeasure;
|
|
||||||
package_size?: number;
|
|
||||||
average_cost?: number;
|
|
||||||
last_purchase_price?: number;
|
|
||||||
standard_cost?: number;
|
|
||||||
low_stock_threshold: number;
|
|
||||||
reorder_point: number;
|
|
||||||
reorder_quantity: number;
|
|
||||||
max_stock_level?: number;
|
|
||||||
requires_refrigeration: boolean;
|
|
||||||
requires_freezing: boolean;
|
|
||||||
storage_temperature_min?: number;
|
|
||||||
storage_temperature_max?: number;
|
|
||||||
storage_humidity_max?: number;
|
|
||||||
shelf_life_days?: number;
|
|
||||||
storage_instructions?: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_perishable: boolean;
|
|
||||||
allergen_info?: Record<string, any>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
created_by?: string;
|
|
||||||
current_stock?: number;
|
|
||||||
is_low_stock?: boolean;
|
|
||||||
needs_reorder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockCreate {
|
|
||||||
ingredient_id: string;
|
ingredient_id: string;
|
||||||
batch_number?: string;
|
batch_number?: string;
|
||||||
lot_number?: string;
|
lot_number?: string;
|
||||||
@@ -140,50 +62,12 @@ export interface StockCreate {
|
|||||||
quality_status?: string;
|
quality_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockUpdate {
|
interface StockUpdate extends Partial<StockCreate> {
|
||||||
batch_number?: string;
|
|
||||||
lot_number?: string;
|
|
||||||
supplier_batch_ref?: string;
|
|
||||||
current_quantity?: number;
|
|
||||||
reserved_quantity?: number;
|
reserved_quantity?: number;
|
||||||
received_date?: string;
|
|
||||||
expiration_date?: string;
|
|
||||||
best_before_date?: string;
|
|
||||||
unit_cost?: number;
|
|
||||||
storage_location?: string;
|
|
||||||
warehouse_zone?: string;
|
|
||||||
shelf_position?: string;
|
|
||||||
is_available?: boolean;
|
is_available?: boolean;
|
||||||
quality_status?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockResponse {
|
interface StockMovementCreate {
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
ingredient_id: string;
|
|
||||||
batch_number?: string;
|
|
||||||
lot_number?: string;
|
|
||||||
supplier_batch_ref?: string;
|
|
||||||
current_quantity: number;
|
|
||||||
reserved_quantity: number;
|
|
||||||
available_quantity: number;
|
|
||||||
received_date?: string;
|
|
||||||
expiration_date?: string;
|
|
||||||
best_before_date?: string;
|
|
||||||
unit_cost?: number;
|
|
||||||
total_cost?: number;
|
|
||||||
storage_location?: string;
|
|
||||||
warehouse_zone?: string;
|
|
||||||
shelf_position?: string;
|
|
||||||
is_available: boolean;
|
|
||||||
is_expired: boolean;
|
|
||||||
quality_status: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
ingredient?: IngredientResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockMovementCreate {
|
|
||||||
ingredient_id: string;
|
ingredient_id: string;
|
||||||
stock_id?: string;
|
stock_id?: string;
|
||||||
movement_type: StockMovementType;
|
movement_type: StockMovementType;
|
||||||
@@ -196,107 +80,11 @@ export interface StockMovementCreate {
|
|||||||
movement_date?: string;
|
movement_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockMovementResponse {
|
// Type aliases for response consistency
|
||||||
id: string;
|
type IngredientResponse = Ingredient;
|
||||||
tenant_id: string;
|
type StockResponse = Stock;
|
||||||
ingredient_id: string;
|
type StockMovementResponse = StockMovement;
|
||||||
stock_id?: string;
|
type StockAlertResponse = StockAlert;
|
||||||
movement_type: StockMovementType;
|
|
||||||
quantity: number;
|
|
||||||
unit_cost?: number;
|
|
||||||
total_cost?: number;
|
|
||||||
quantity_before?: number;
|
|
||||||
quantity_after?: number;
|
|
||||||
reference_number?: string;
|
|
||||||
supplier_id?: string;
|
|
||||||
notes?: string;
|
|
||||||
reason_code?: string;
|
|
||||||
movement_date: string;
|
|
||||||
created_at: string;
|
|
||||||
created_by?: string;
|
|
||||||
ingredient?: IngredientResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockAlertResponse {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
ingredient_id: string;
|
|
||||||
stock_id?: string;
|
|
||||||
alert_type: string;
|
|
||||||
severity: string;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
current_quantity?: number;
|
|
||||||
threshold_value?: number;
|
|
||||||
expiration_date?: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_acknowledged: boolean;
|
|
||||||
acknowledged_by?: string;
|
|
||||||
acknowledged_at?: string;
|
|
||||||
is_resolved: boolean;
|
|
||||||
resolved_by?: string;
|
|
||||||
resolved_at?: string;
|
|
||||||
resolution_notes?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
ingredient?: IngredientResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InventorySummary {
|
|
||||||
total_ingredients: number;
|
|
||||||
total_stock_value: number;
|
|
||||||
low_stock_alerts: number;
|
|
||||||
expiring_soon_items: number;
|
|
||||||
expired_items: number;
|
|
||||||
out_of_stock_items: number;
|
|
||||||
stock_by_category: Record<string, Record<string, any>>;
|
|
||||||
recent_movements: number;
|
|
||||||
recent_purchases: number;
|
|
||||||
recent_waste: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockLevelSummary {
|
|
||||||
ingredient_id: string;
|
|
||||||
ingredient_name: string;
|
|
||||||
unit_of_measure: string;
|
|
||||||
total_quantity: number;
|
|
||||||
available_quantity: number;
|
|
||||||
reserved_quantity: number;
|
|
||||||
is_low_stock: boolean;
|
|
||||||
needs_reorder: boolean;
|
|
||||||
has_expired_stock: boolean;
|
|
||||||
total_batches: number;
|
|
||||||
oldest_batch_date?: string;
|
|
||||||
newest_batch_date?: string;
|
|
||||||
next_expiration_date?: string;
|
|
||||||
average_unit_cost?: number;
|
|
||||||
total_stock_value?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
items: T[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
size: number;
|
|
||||||
pages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InventoryFilter {
|
|
||||||
category?: string;
|
|
||||||
is_active?: boolean;
|
|
||||||
is_low_stock?: boolean;
|
|
||||||
needs_reorder?: boolean;
|
|
||||||
search?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockFilter {
|
|
||||||
ingredient_id?: string;
|
|
||||||
is_available?: boolean;
|
|
||||||
is_expired?: boolean;
|
|
||||||
expiring_within_days?: number;
|
|
||||||
storage_location?: string;
|
|
||||||
quality_status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class InventoryService {
|
class InventoryService {
|
||||||
private readonly baseUrl = '/inventory';
|
private readonly baseUrl = '/inventory';
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class OnboardingApiService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 1: Validate uploaded file and extract unique products
|
* Step 1: Validate uploaded file and extract unique products
|
||||||
|
* Now uses Sales Service directly
|
||||||
*/
|
*/
|
||||||
async validateOnboardingFile(
|
async validateOnboardingFile(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -98,7 +99,7 @@ class OnboardingApiService {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
const response = await apiClient.post<OnboardingFileValidationResponse>(
|
const response = await apiClient.post<OnboardingFileValidationResponse>(
|
||||||
`${this.basePath}/${tenantId}/onboarding/validate-file`,
|
`${this.salesBasePath}/${tenantId}/sales/import/validate`,
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -120,6 +121,7 @@ class OnboardingApiService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 2: Generate AI-powered inventory suggestions
|
* Step 2: Generate AI-powered inventory suggestions
|
||||||
|
* Now uses Inventory Service directly
|
||||||
*/
|
*/
|
||||||
async generateInventorySuggestions(
|
async generateInventorySuggestions(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -127,18 +129,24 @@ class OnboardingApiService {
|
|||||||
productList: string[]
|
productList: string[]
|
||||||
): Promise<ProductSuggestionsResponse> {
|
): Promise<ProductSuggestionsResponse> {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
if (!productList || !Array.isArray(productList) || productList.length === 0) {
|
||||||
formData.append('file', file);
|
throw new Error('Product list is empty or invalid');
|
||||||
formData.append('product_list', JSON.stringify(productList));
|
}
|
||||||
|
|
||||||
|
// Transform product list into the expected format for BatchClassificationRequest
|
||||||
|
const products = productList.map(productName => ({
|
||||||
|
product_name: productName,
|
||||||
|
// sales_volume is optional, omit it if we don't have the data
|
||||||
|
sales_data: {} // Additional context can be added later
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
products: products
|
||||||
|
};
|
||||||
|
|
||||||
const response = await apiClient.post<ProductSuggestionsResponse>(
|
const response = await apiClient.post<ProductSuggestionsResponse>(
|
||||||
`${this.basePath}/${tenantId}/onboarding/generate-suggestions`,
|
`${this.basePath}/${tenantId}/inventory/classify-products-batch`,
|
||||||
formData,
|
requestData
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
@@ -154,34 +162,56 @@ class OnboardingApiService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 3: Create inventory items from approved suggestions
|
* Step 3: Create inventory items from approved suggestions
|
||||||
|
* Now uses Inventory Service directly
|
||||||
*/
|
*/
|
||||||
async createInventoryFromSuggestions(
|
async createInventoryFromSuggestions(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
approvedSuggestions: any[]
|
approvedSuggestions: any[]
|
||||||
): Promise<InventoryCreationResponse> {
|
): Promise<InventoryCreationResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post<InventoryCreationResponse>(
|
const createdItems: any[] = [];
|
||||||
`${this.basePath}/${tenantId}/onboarding/create-inventory`,
|
const failedItems: any[] = [];
|
||||||
{
|
const inventoryMapping: { [productName: string]: string } = {};
|
||||||
suggestions: approvedSuggestions
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.success) {
|
// Create inventory items one by one using inventory service
|
||||||
throw new Error(`Inventory creation failed: ${response.error || 'Unknown error'}`);
|
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
|
const response = await apiClient.post<any>(
|
||||||
if (!response.data.inventory_mapping) {
|
`${this.basePath}/${tenantId}/ingredients`,
|
||||||
response.data.inventory_mapping = {};
|
ingredientData
|
||||||
response.data.created_items.forEach((item, index) => {
|
);
|
||||||
if (approvedSuggestions[index]) {
|
|
||||||
response.data.inventory_mapping![approvedSuggestions[index].original_name] = item.id;
|
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) {
|
} catch (error) {
|
||||||
console.error('Inventory creation failed:', error);
|
console.error('Inventory creation failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -190,6 +220,7 @@ class OnboardingApiService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 4: Import sales data with inventory mapping
|
* Step 4: Import sales data with inventory mapping
|
||||||
|
* Now uses Sales Service directly with validation first
|
||||||
*/
|
*/
|
||||||
async importSalesWithInventory(
|
async importSalesWithInventory(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -197,12 +228,16 @@ class OnboardingApiService {
|
|||||||
inventoryMapping: { [productName: string]: string }
|
inventoryMapping: { [productName: string]: string }
|
||||||
): Promise<SalesImportResponse> {
|
): Promise<SalesImportResponse> {
|
||||||
try {
|
try {
|
||||||
|
// First validate the file with inventory mapping
|
||||||
|
await this.validateSalesData(tenantId, file);
|
||||||
|
|
||||||
|
// Then import the sales data
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('inventory_mapping', JSON.stringify(inventoryMapping));
|
formData.append('update_existing', 'true');
|
||||||
|
|
||||||
const response = await apiClient.post<SalesImportResponse>(
|
const response = await apiClient.post<SalesImportResponse>(
|
||||||
`${this.basePath}/${tenantId}/onboarding/import-sales`,
|
`${this.salesBasePath}/${tenantId}/sales/import`,
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -224,25 +259,80 @@ class OnboardingApiService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get business model specific recommendations
|
* Get business model specific recommendations
|
||||||
|
* Returns static recommendations since orchestration is removed
|
||||||
*/
|
*/
|
||||||
async getBusinessModelGuide(
|
async getBusinessModelGuide(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
model: 'production' | 'retail' | 'hybrid'
|
model: 'production' | 'retail' | 'hybrid'
|
||||||
): Promise<BusinessModelGuide> {
|
): Promise<BusinessModelGuide> {
|
||||||
try {
|
// Return static business model guides since we removed orchestration
|
||||||
const response = await apiClient.get<BusinessModelGuide>(
|
const guides = {
|
||||||
`${this.basePath}/${tenantId}/onboarding/business-model-guide?model=${model}`
|
production: {
|
||||||
);
|
title: 'Production Bakery Setup',
|
||||||
|
description: 'Your bakery focuses on creating products from raw ingredients.',
|
||||||
if (!response.success) {
|
next_steps: [
|
||||||
throw new Error(`Failed to get business model guide: ${response.error || 'Unknown error'}`);
|
'Set up ingredient inventory management',
|
||||||
|
'Configure recipe management',
|
||||||
|
'Set up production planning',
|
||||||
|
'Implement quality control processes'
|
||||||
|
],
|
||||||
|
recommended_features: [
|
||||||
|
'Inventory tracking for raw ingredients',
|
||||||
|
'Recipe costing and management',
|
||||||
|
'Production scheduling',
|
||||||
|
'Supplier management'
|
||||||
|
],
|
||||||
|
sample_workflows: [
|
||||||
|
'Daily production planning based on demand forecasts',
|
||||||
|
'Inventory reordering based on production schedules',
|
||||||
|
'Quality control checkpoints during production'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
retail: {
|
||||||
|
title: 'Retail Bakery Setup',
|
||||||
|
description: 'Your bakery focuses on selling finished products to customers.',
|
||||||
|
next_steps: [
|
||||||
|
'Set up finished product inventory',
|
||||||
|
'Configure point-of-sale integration',
|
||||||
|
'Set up customer management',
|
||||||
|
'Implement sales analytics'
|
||||||
|
],
|
||||||
|
recommended_features: [
|
||||||
|
'Finished product inventory tracking',
|
||||||
|
'Sales analytics and reporting',
|
||||||
|
'Customer loyalty programs',
|
||||||
|
'Promotional campaign management'
|
||||||
|
],
|
||||||
|
sample_workflows: [
|
||||||
|
'Daily sales reporting and analysis',
|
||||||
|
'Inventory reordering based on sales velocity',
|
||||||
|
'Customer engagement and retention campaigns'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
hybrid: {
|
||||||
|
title: 'Hybrid Bakery Setup',
|
||||||
|
description: 'Your bakery combines production and retail operations.',
|
||||||
|
next_steps: [
|
||||||
|
'Set up both ingredient and finished product inventory',
|
||||||
|
'Configure production-to-retail workflows',
|
||||||
|
'Set up integrated analytics',
|
||||||
|
'Implement comprehensive supplier management'
|
||||||
|
],
|
||||||
|
recommended_features: [
|
||||||
|
'Dual inventory management system',
|
||||||
|
'Production-to-sales analytics',
|
||||||
|
'Integrated supplier and customer management',
|
||||||
|
'Cross-channel reporting'
|
||||||
|
],
|
||||||
|
sample_workflows: [
|
||||||
|
'Production planning based on both wholesale and retail demand',
|
||||||
|
'Integrated inventory management across production and retail',
|
||||||
|
'Comprehensive business intelligence and reporting'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return response.data;
|
return guides[model] || guides.hybrid;
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get business model guide:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -366,14 +456,18 @@ class OnboardingApiService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility: Check if a tenant has completed onboarding
|
* Utility: Check if a tenant has completed onboarding
|
||||||
|
* Uses Auth Service for user progress tracking
|
||||||
*/
|
*/
|
||||||
async checkOnboardingStatus(tenantId: string): Promise<{ completed: boolean; steps_completed: string[] }> {
|
async checkOnboardingStatus(tenantId: string): Promise<{ completed: boolean; steps_completed: string[] }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<any>(
|
const response = await apiClient.get<any>(
|
||||||
`${this.basePath}/${tenantId}/onboarding/status`
|
'/me/onboarding/progress'
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data || { completed: false, steps_completed: [] };
|
return {
|
||||||
|
completed: response.data?.onboarding_completed || false,
|
||||||
|
steps_completed: response.data?.completed_steps || []
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not check onboarding status:', error);
|
console.warn('Could not check onboarding status:', error);
|
||||||
return { completed: false, steps_completed: [] };
|
return { completed: false, steps_completed: [] };
|
||||||
@@ -382,11 +476,12 @@ class OnboardingApiService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility: Mark onboarding as complete
|
* Utility: Mark onboarding as complete
|
||||||
|
* Uses Auth Service for user progress tracking
|
||||||
*/
|
*/
|
||||||
async completeOnboarding(tenantId: string, metadata?: any): Promise<void> {
|
async completeOnboarding(tenantId: string, metadata?: any): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await apiClient.post(
|
await apiClient.post(
|
||||||
`${this.basePath}/${tenantId}/onboarding/complete`,
|
'/me/onboarding/complete',
|
||||||
{ metadata }
|
{ metadata }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -394,6 +489,7 @@ class OnboardingApiService {
|
|||||||
// Don't throw error, this is not critical
|
// Don't throw error, this is not critical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onboardingApiService = new OnboardingApiService();
|
export const onboardingApiService = new OnboardingApiService();
|
||||||
|
|||||||
@@ -1,134 +1,25 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
import { apiClient } from './client';
|
||||||
|
import { ApiResponse } from '../../types/api.types';
|
||||||
// Enums
|
import {
|
||||||
export enum OrderStatus {
|
OrderStatus,
|
||||||
PENDING = 'pending',
|
OrderType,
|
||||||
CONFIRMED = 'confirmed',
|
OrderItem,
|
||||||
IN_PREPARATION = 'in_preparation',
|
OrderCreate,
|
||||||
READY = 'ready',
|
OrderUpdate,
|
||||||
DELIVERED = 'delivered',
|
OrderResponse,
|
||||||
CANCELLED = 'cancelled',
|
Customer,
|
||||||
}
|
OrderAnalytics,
|
||||||
|
OrderFilters,
|
||||||
export enum OrderType {
|
CustomerFilters,
|
||||||
DINE_IN = 'dine_in',
|
OrderTrendsParams,
|
||||||
TAKEAWAY = 'takeaway',
|
OrderTrendData
|
||||||
DELIVERY = 'delivery',
|
} from '../../types/orders.types';
|
||||||
CATERING = 'catering',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request/Response Types
|
|
||||||
export interface OrderItem {
|
|
||||||
product_id?: string;
|
|
||||||
product_name: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
total_price: number;
|
|
||||||
notes?: string;
|
|
||||||
customizations?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderCreate {
|
|
||||||
customer_id?: string;
|
|
||||||
customer_name: string;
|
|
||||||
customer_email?: string;
|
|
||||||
customer_phone?: string;
|
|
||||||
order_type: OrderType;
|
|
||||||
items: OrderItem[];
|
|
||||||
special_instructions?: string;
|
|
||||||
delivery_address?: string;
|
|
||||||
delivery_date?: string;
|
|
||||||
delivery_time?: string;
|
|
||||||
payment_method?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderUpdate {
|
|
||||||
status?: OrderStatus;
|
|
||||||
customer_name?: string;
|
|
||||||
customer_email?: string;
|
|
||||||
customer_phone?: string;
|
|
||||||
special_instructions?: string;
|
|
||||||
delivery_address?: string;
|
|
||||||
delivery_date?: string;
|
|
||||||
delivery_time?: string;
|
|
||||||
estimated_completion_time?: string;
|
|
||||||
actual_completion_time?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderResponse {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
order_number: string;
|
|
||||||
customer_id?: string;
|
|
||||||
customer_name: string;
|
|
||||||
customer_email?: string;
|
|
||||||
customer_phone?: string;
|
|
||||||
order_type: OrderType;
|
|
||||||
status: OrderStatus;
|
|
||||||
items: OrderItem[];
|
|
||||||
subtotal: number;
|
|
||||||
tax_amount: number;
|
|
||||||
discount_amount: number;
|
|
||||||
total_amount: number;
|
|
||||||
special_instructions?: string;
|
|
||||||
delivery_address?: string;
|
|
||||||
delivery_date?: string;
|
|
||||||
delivery_time?: string;
|
|
||||||
estimated_completion_time?: string;
|
|
||||||
actual_completion_time?: string;
|
|
||||||
payment_method?: string;
|
|
||||||
payment_status?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
created_by?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Customer {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
address?: string;
|
|
||||||
preferences?: Record<string, any>;
|
|
||||||
total_orders: number;
|
|
||||||
total_spent: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderAnalytics {
|
|
||||||
total_orders: number;
|
|
||||||
total_revenue: number;
|
|
||||||
average_order_value: number;
|
|
||||||
order_completion_rate: number;
|
|
||||||
delivery_success_rate: number;
|
|
||||||
customer_satisfaction_score?: number;
|
|
||||||
popular_products: Array<{
|
|
||||||
product_name: string;
|
|
||||||
quantity_sold: number;
|
|
||||||
revenue: number;
|
|
||||||
}>;
|
|
||||||
order_trends: Array<{
|
|
||||||
date: string;
|
|
||||||
orders: number;
|
|
||||||
revenue: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class OrdersService {
|
class OrdersService {
|
||||||
private readonly baseUrl = '/orders';
|
private readonly baseUrl = '/orders';
|
||||||
|
|
||||||
// Order management
|
// Order management
|
||||||
async getOrders(params?: {
|
async getOrders(params?: OrderFilters): Promise<ApiResponse<{ items: OrderResponse[]; total: number; page: number; size: number; pages: number }>> {
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
status?: OrderStatus;
|
|
||||||
order_type?: OrderType;
|
|
||||||
customer_id?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}): Promise<ApiResponse<{ items: OrderResponse[]; total: number; page: number; size: number; pages: number }>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -179,11 +70,7 @@ class OrdersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Customer management
|
// Customer management
|
||||||
async getCustomers(params?: {
|
async getCustomers(params?: CustomerFilters): Promise<ApiResponse<{ items: Customer[]; total: number; page: number; size: number; pages: number }>> {
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
search?: string;
|
|
||||||
}): Promise<ApiResponse<{ items: Customer[]; total: number; page: number; size: number; pages: number }>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -232,16 +119,7 @@ class OrdersService {
|
|||||||
return apiClient.get(url);
|
return apiClient.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrderTrends(params?: {
|
async getOrderTrends(params?: OrderTrendsParams): Promise<ApiResponse<OrderTrendData[]>> {
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
granularity?: 'hourly' | 'daily' | 'weekly' | 'monthly';
|
|
||||||
}): Promise<ApiResponse<Array<{
|
|
||||||
period: string;
|
|
||||||
orders: number;
|
|
||||||
revenue: number;
|
|
||||||
avg_order_value: number;
|
|
||||||
}>>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
|
|||||||
@@ -1,126 +1,28 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
import { apiClient } from './client';
|
||||||
|
import { ApiResponse } from '../../types/api.types';
|
||||||
|
import {
|
||||||
|
SupplierCreate,
|
||||||
|
SupplierUpdate,
|
||||||
|
SupplierResponse,
|
||||||
|
SupplierSummary,
|
||||||
|
SupplierSearchParams,
|
||||||
|
SupplierApproval,
|
||||||
|
SupplierStatistics,
|
||||||
|
PurchaseOrderCreate,
|
||||||
|
PurchaseOrderUpdate,
|
||||||
|
PurchaseOrderResponse,
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
DeliveryCreate,
|
||||||
|
DeliveryResponse,
|
||||||
|
DeliveryStatus,
|
||||||
|
DeliveryReceiptConfirmation,
|
||||||
|
Supplier
|
||||||
|
} from '../../types/suppliers.types';
|
||||||
|
|
||||||
// Enums
|
|
||||||
export enum PurchaseOrderStatus {
|
|
||||||
DRAFT = 'draft',
|
|
||||||
PENDING = 'pending',
|
|
||||||
APPROVED = 'approved',
|
|
||||||
SENT = 'sent',
|
|
||||||
PARTIALLY_RECEIVED = 'partially_received',
|
|
||||||
RECEIVED = 'received',
|
|
||||||
CANCELLED = 'cancelled',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum DeliveryStatus {
|
|
||||||
SCHEDULED = 'scheduled',
|
|
||||||
IN_TRANSIT = 'in_transit',
|
|
||||||
DELIVERED = 'delivered',
|
|
||||||
FAILED = 'failed',
|
|
||||||
RETURNED = 'returned',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request/Response Types
|
|
||||||
export interface PurchaseOrderItem {
|
|
||||||
ingredient_id: string;
|
|
||||||
ingredient_name: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
total_price: number;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PurchaseOrderCreate {
|
|
||||||
supplier_id: string;
|
|
||||||
items: PurchaseOrderItem[];
|
|
||||||
delivery_date?: string;
|
|
||||||
notes?: string;
|
|
||||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PurchaseOrderUpdate {
|
|
||||||
supplier_id?: string;
|
|
||||||
delivery_date?: string;
|
|
||||||
notes?: string;
|
|
||||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
status?: PurchaseOrderStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PurchaseOrderResponse {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
order_number: string;
|
|
||||||
supplier_id: string;
|
|
||||||
supplier_name: string;
|
|
||||||
status: PurchaseOrderStatus;
|
|
||||||
items: PurchaseOrderItem[];
|
|
||||||
subtotal: number;
|
|
||||||
tax_amount: number;
|
|
||||||
total_amount: number;
|
|
||||||
delivery_date?: string;
|
|
||||||
expected_delivery_date?: string;
|
|
||||||
actual_delivery_date?: string;
|
|
||||||
notes?: string;
|
|
||||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
created_by: string;
|
|
||||||
approved_by?: string;
|
|
||||||
approved_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Supplier {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
contact_name?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
address: string;
|
|
||||||
tax_id?: string;
|
|
||||||
payment_terms?: string;
|
|
||||||
delivery_terms?: string;
|
|
||||||
rating?: number;
|
|
||||||
is_active: boolean;
|
|
||||||
performance_metrics: {
|
|
||||||
on_time_delivery_rate: number;
|
|
||||||
quality_score: number;
|
|
||||||
total_orders: number;
|
|
||||||
average_delivery_time: number;
|
|
||||||
};
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeliveryResponse {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
purchase_order_id: string;
|
|
||||||
delivery_number: string;
|
|
||||||
supplier_id: string;
|
|
||||||
status: DeliveryStatus;
|
|
||||||
scheduled_date: string;
|
|
||||||
actual_delivery_date?: string;
|
|
||||||
delivery_items: Array<{
|
|
||||||
ingredient_id: string;
|
|
||||||
ingredient_name: string;
|
|
||||||
ordered_quantity: number;
|
|
||||||
delivered_quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
batch_number?: string;
|
|
||||||
expiration_date?: string;
|
|
||||||
quality_notes?: string;
|
|
||||||
}>;
|
|
||||||
total_items: number;
|
|
||||||
delivery_notes?: string;
|
|
||||||
quality_check_notes?: string;
|
|
||||||
received_by?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProcurementService {
|
class ProcurementService {
|
||||||
private readonly baseUrl = '/procurement';
|
|
||||||
|
|
||||||
// Purchase Order management
|
// Purchase Order management
|
||||||
async getPurchaseOrders(params?: {
|
async getPurchaseOrders(params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -141,90 +43,88 @@ class ProcurementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = queryParams.toString()
|
const url = queryParams.toString()
|
||||||
? `${this.baseUrl}/purchase-orders?${queryParams.toString()}`
|
? `/purchase-orders?${queryParams.toString()}`
|
||||||
: `${this.baseUrl}/purchase-orders`;
|
: `/purchase-orders`;
|
||||||
|
|
||||||
return apiClient.get(url);
|
return apiClient.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrderResponse>> {
|
async getPurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrderResponse>> {
|
||||||
return apiClient.get(`${this.baseUrl}/purchase-orders/${orderId}`);
|
return apiClient.get(`/purchase-orders/${orderId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPurchaseOrder(orderData: PurchaseOrderCreate): Promise<ApiResponse<PurchaseOrderResponse>> {
|
async createPurchaseOrder(orderData: PurchaseOrderCreate): Promise<ApiResponse<PurchaseOrderResponse>> {
|
||||||
return apiClient.post(`${this.baseUrl}/purchase-orders`, orderData);
|
return apiClient.post(`/purchase-orders`, orderData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePurchaseOrder(orderId: string, orderData: PurchaseOrderUpdate): Promise<ApiResponse<PurchaseOrderResponse>> {
|
async updatePurchaseOrder(orderId: string, orderData: PurchaseOrderUpdate): Promise<ApiResponse<PurchaseOrderResponse>> {
|
||||||
return apiClient.put(`${this.baseUrl}/purchase-orders/${orderId}`, orderData);
|
return apiClient.put(`/purchase-orders/${orderId}`, orderData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async approvePurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrderResponse>> {
|
async approvePurchaseOrder(orderId: string): Promise<ApiResponse<PurchaseOrderResponse>> {
|
||||||
return apiClient.post(`${this.baseUrl}/purchase-orders/${orderId}/approve`);
|
return apiClient.post(`/purchase-orders/${orderId}/approve`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPurchaseOrder(orderId: string, sendEmail: boolean = true): Promise<ApiResponse<{ message: string; sent_at: string }>> {
|
async sendPurchaseOrder(orderId: string, sendEmail: boolean = true): Promise<ApiResponse<{ message: string; sent_at: string }>> {
|
||||||
return apiClient.post(`${this.baseUrl}/purchase-orders/${orderId}/send`, { send_email: sendEmail });
|
return apiClient.post(`/purchase-orders/${orderId}/send`, { send_email: sendEmail });
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelPurchaseOrder(orderId: string, reason?: string): Promise<ApiResponse<PurchaseOrderResponse>> {
|
async cancelPurchaseOrder(orderId: string, reason?: string): Promise<ApiResponse<PurchaseOrderResponse>> {
|
||||||
return apiClient.post(`${this.baseUrl}/purchase-orders/${orderId}/cancel`, { reason });
|
return apiClient.post(`/purchase-orders/${orderId}/cancel`, { reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supplier management
|
// Supplier management
|
||||||
async getSuppliers(params?: {
|
async getSuppliers(params?: SupplierSearchParams): Promise<ApiResponse<SupplierSummary[]>> {
|
||||||
page?: number;
|
|
||||||
size?: number;
|
|
||||||
is_active?: boolean;
|
|
||||||
search?: string;
|
|
||||||
}): Promise<ApiResponse<{ items: Supplier[]; total: number; page: number; size: number; pages: number }>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
if (params.search_term) queryParams.append('search_term', params.search_term);
|
||||||
if (value !== undefined) {
|
if (params.supplier_type) queryParams.append('supplier_type', params.supplier_type);
|
||||||
queryParams.append(key, value.toString());
|
if (params.status) queryParams.append('status', params.status);
|
||||||
}
|
if (params.limit) queryParams.append('limit', params.limit.toString());
|
||||||
});
|
if (params.offset) queryParams.append('offset', params.offset.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = queryParams.toString()
|
const url = queryParams.toString()
|
||||||
? `${this.baseUrl}/suppliers?${queryParams.toString()}`
|
? `/suppliers?${queryParams.toString()}`
|
||||||
: `${this.baseUrl}/suppliers`;
|
: `/suppliers`;
|
||||||
|
|
||||||
return apiClient.get(url);
|
return apiClient.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSupplier(supplierId: string): Promise<ApiResponse<Supplier>> {
|
async getSupplier(supplierId: string): Promise<ApiResponse<SupplierResponse>> {
|
||||||
return apiClient.get(`${this.baseUrl}/suppliers/${supplierId}`);
|
return apiClient.get(`/suppliers/${supplierId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSupplier(supplierData: Omit<Supplier, 'id' | 'tenant_id' | 'performance_metrics' | 'created_at' | 'updated_at'>): Promise<ApiResponse<Supplier>> {
|
async createSupplier(supplierData: SupplierCreate): Promise<ApiResponse<SupplierResponse>> {
|
||||||
return apiClient.post(`${this.baseUrl}/suppliers`, supplierData);
|
return apiClient.post(`/suppliers`, supplierData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSupplier(supplierId: string, supplierData: Partial<Supplier>): Promise<ApiResponse<Supplier>> {
|
async updateSupplier(supplierId: string, supplierData: SupplierUpdate): Promise<ApiResponse<SupplierResponse>> {
|
||||||
return apiClient.put(`${this.baseUrl}/suppliers/${supplierId}`, supplierData);
|
return apiClient.put(`/suppliers/${supplierId}`, supplierData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSupplier(supplierId: string): Promise<ApiResponse<{ message: string }>> {
|
async deleteSupplier(supplierId: string): Promise<ApiResponse<{ message: string }>> {
|
||||||
return apiClient.delete(`${this.baseUrl}/suppliers/${supplierId}`);
|
return apiClient.delete(`/suppliers/${supplierId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSupplierPerformance(supplierId: string): Promise<ApiResponse<{
|
async approveSupplier(supplierId: string, approval: SupplierApproval): Promise<ApiResponse<SupplierResponse>> {
|
||||||
supplier: Supplier;
|
return apiClient.post(`/suppliers/${supplierId}/approve`, approval);
|
||||||
performance_history: Array<{
|
|
||||||
month: string;
|
|
||||||
on_time_delivery_rate: number;
|
|
||||||
quality_score: number;
|
|
||||||
order_count: number;
|
|
||||||
total_value: number;
|
|
||||||
}>;
|
|
||||||
recent_deliveries: DeliveryResponse[];
|
|
||||||
}>> {
|
|
||||||
return apiClient.get(`${this.baseUrl}/suppliers/${supplierId}/performance`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSupplierStatistics(): Promise<ApiResponse<SupplierStatistics>> {
|
||||||
|
return apiClient.get(`/suppliers/statistics`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveSuppliers(): Promise<ApiResponse<SupplierSummary[]>> {
|
||||||
|
return apiClient.get(`/suppliers/active`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopSuppliers(limit: number = 10): Promise<ApiResponse<SupplierSummary[]>> {
|
||||||
|
return apiClient.get(`/suppliers/top?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Delivery management
|
// Delivery management
|
||||||
async getDeliveries(params?: {
|
async getDeliveries(params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -246,131 +146,32 @@ class ProcurementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = queryParams.toString()
|
const url = queryParams.toString()
|
||||||
? `${this.baseUrl}/deliveries?${queryParams.toString()}`
|
? `/deliveries?${queryParams.toString()}`
|
||||||
: `${this.baseUrl}/deliveries`;
|
: `/deliveries`;
|
||||||
|
|
||||||
return apiClient.get(url);
|
return apiClient.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDelivery(deliveryId: string): Promise<ApiResponse<DeliveryResponse>> {
|
async getDelivery(deliveryId: string): Promise<ApiResponse<DeliveryResponse>> {
|
||||||
return apiClient.get(`${this.baseUrl}/deliveries/${deliveryId}`);
|
return apiClient.get(`/deliveries/${deliveryId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async receiveDelivery(deliveryId: string, deliveryData: {
|
async createDelivery(deliveryData: DeliveryCreate): Promise<ApiResponse<DeliveryResponse>> {
|
||||||
delivered_items: Array<{
|
return apiClient.post(`/deliveries`, deliveryData);
|
||||||
ingredient_id: string;
|
|
||||||
delivered_quantity: number;
|
|
||||||
batch_number?: string;
|
|
||||||
expiration_date?: string;
|
|
||||||
quality_notes?: string;
|
|
||||||
}>;
|
|
||||||
delivery_notes?: string;
|
|
||||||
quality_check_notes?: string;
|
|
||||||
}): Promise<ApiResponse<DeliveryResponse>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/deliveries/${deliveryId}/receive`, deliveryData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async reportDeliveryIssue(deliveryId: string, issue: {
|
async updateDelivery(deliveryId: string, deliveryData: Partial<DeliveryCreate>): Promise<ApiResponse<DeliveryResponse>> {
|
||||||
issue_type: 'late_delivery' | 'quality_issue' | 'quantity_mismatch' | 'damaged_goods' | 'other';
|
return apiClient.put(`/deliveries/${deliveryId}`, deliveryData);
|
||||||
description: string;
|
|
||||||
affected_items?: string[];
|
|
||||||
severity: 'low' | 'medium' | 'high';
|
|
||||||
}): Promise<ApiResponse<{ message: string; issue_id: string }>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/deliveries/${deliveryId}/report-issue`, issue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analytics and reporting
|
async updateDeliveryStatus(deliveryId: string, status: DeliveryStatus, notes?: string): Promise<ApiResponse<DeliveryResponse>> {
|
||||||
async getProcurementAnalytics(params?: {
|
return apiClient.put(`/deliveries/${deliveryId}/status`, { status, notes });
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
supplier_id?: string;
|
|
||||||
}): Promise<ApiResponse<{
|
|
||||||
total_purchase_value: number;
|
|
||||||
total_orders: number;
|
|
||||||
average_order_value: number;
|
|
||||||
on_time_delivery_rate: number;
|
|
||||||
quality_score: number;
|
|
||||||
cost_savings: number;
|
|
||||||
top_suppliers: Array<{
|
|
||||||
supplier_name: string;
|
|
||||||
total_value: number;
|
|
||||||
order_count: number;
|
|
||||||
performance_score: number;
|
|
||||||
}>;
|
|
||||||
spending_trends: Array<{
|
|
||||||
month: string;
|
|
||||||
total_spending: number;
|
|
||||||
order_count: number;
|
|
||||||
}>;
|
|
||||||
}>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined) {
|
|
||||||
queryParams.append(key, value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = queryParams.toString()
|
|
||||||
? `${this.baseUrl}/analytics?${queryParams.toString()}`
|
|
||||||
: `${this.baseUrl}/analytics`;
|
|
||||||
|
|
||||||
return apiClient.get(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSpendingByCategory(params?: {
|
async confirmDeliveryReceipt(deliveryId: string, confirmation: DeliveryReceiptConfirmation): Promise<ApiResponse<DeliveryResponse>> {
|
||||||
start_date?: string;
|
return apiClient.post(`/deliveries/${deliveryId}/confirm-receipt`, confirmation);
|
||||||
end_date?: string;
|
|
||||||
}): Promise<ApiResponse<Array<{
|
|
||||||
category: string;
|
|
||||||
total_spending: number;
|
|
||||||
percentage: number;
|
|
||||||
trend: 'up' | 'down' | 'stable';
|
|
||||||
}>>> {
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined) {
|
|
||||||
queryParams.append(key, value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = queryParams.toString()
|
|
||||||
? `${this.baseUrl}/spending/by-category?${queryParams.toString()}`
|
|
||||||
: `${this.baseUrl}/spending/by-category`;
|
|
||||||
|
|
||||||
return apiClient.get(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automated procurement
|
|
||||||
async getReorderSuggestions(): Promise<ApiResponse<Array<{
|
|
||||||
ingredient_id: string;
|
|
||||||
ingredient_name: string;
|
|
||||||
current_stock: number;
|
|
||||||
reorder_point: number;
|
|
||||||
suggested_quantity: number;
|
|
||||||
preferred_supplier: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
last_price: number;
|
|
||||||
lead_time_days: number;
|
|
||||||
};
|
|
||||||
urgency: 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
}>>> {
|
|
||||||
return apiClient.get(`${this.baseUrl}/reorder-suggestions`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createReorderFromSuggestions(suggestions: Array<{
|
|
||||||
ingredient_id: string;
|
|
||||||
supplier_id: string;
|
|
||||||
quantity: number;
|
|
||||||
}>): Promise<ApiResponse<PurchaseOrderResponse[]>> {
|
|
||||||
return apiClient.post(`${this.baseUrl}/auto-reorder`, { suggestions });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
getPurchaseOrderStatusOptions(): { value: PurchaseOrderStatus; label: string; color: string }[] {
|
getPurchaseOrderStatusOptions(): { value: PurchaseOrderStatus; label: string; color: string }[] {
|
||||||
@@ -395,14 +196,6 @@ class ProcurementService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
getPriorityOptions(): { value: string; label: string; color: string }[] {
|
|
||||||
return [
|
|
||||||
{ value: 'low', label: 'Low', color: 'gray' },
|
|
||||||
{ value: 'normal', label: 'Normal', color: 'blue' },
|
|
||||||
{ value: 'high', label: 'High', color: 'orange' },
|
|
||||||
{ value: 'urgent', label: 'Urgent', color: 'red' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const procurementService = new ProcurementService();
|
export const procurementService = new ProcurementService();
|
||||||
@@ -1,27 +1,21 @@
|
|||||||
import { apiClient, ApiResponse } from './client';
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
import {
|
||||||
|
ProductionBatchStatus,
|
||||||
|
QualityCheckStatus,
|
||||||
|
ProductionPriority,
|
||||||
|
ProductionBatch,
|
||||||
|
ProductionSchedule,
|
||||||
|
QualityCheck,
|
||||||
|
Recipe
|
||||||
|
} from '../../types/production.types';
|
||||||
|
|
||||||
// Enums
|
// Type aliases for service compatibility
|
||||||
export enum ProductionBatchStatus {
|
type ProductionBatchCreate = Omit<ProductionBatch, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>;
|
||||||
PLANNED = 'planned',
|
type ProductionBatchUpdate = Partial<ProductionBatchCreate>;
|
||||||
IN_PROGRESS = 'in_progress',
|
type ProductionBatchResponse = ProductionBatch;
|
||||||
COMPLETED = 'completed',
|
type ProductionScheduleEntry = ProductionSchedule;
|
||||||
CANCELLED = 'cancelled',
|
type QualityCheckCreate = Omit<QualityCheck, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>;
|
||||||
ON_HOLD = 'on_hold',
|
type QualityCheckResponse = QualityCheck;
|
||||||
}
|
|
||||||
|
|
||||||
export enum QualityCheckStatus {
|
|
||||||
PASSED = 'passed',
|
|
||||||
FAILED = 'failed',
|
|
||||||
PENDING = 'pending',
|
|
||||||
REQUIRES_REVIEW = 'requires_review',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ProductionPriority {
|
|
||||||
LOW = 'low',
|
|
||||||
NORMAL = 'normal',
|
|
||||||
HIGH = 'high',
|
|
||||||
URGENT = 'urgent',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request/Response Types
|
// Request/Response Types
|
||||||
export interface ProductionBatchCreate {
|
export interface ProductionBatchCreate {
|
||||||
|
|||||||
@@ -2,69 +2,76 @@
|
|||||||
* Training service for ML model training operations
|
* Training service for ML model training operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ApiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import { ApiResponse } from '../../types/api.types';
|
import { ApiResponse } from '../../types/api.types';
|
||||||
|
import {
|
||||||
|
TrainingJob,
|
||||||
|
TrainingJobCreate,
|
||||||
|
TrainingJobUpdate
|
||||||
|
} from '../../types/training.types';
|
||||||
|
|
||||||
export interface TrainingJob {
|
export class TrainingService {
|
||||||
id: string;
|
private getTenantId(): string {
|
||||||
model_id: string;
|
const tenantStorage = localStorage.getItem('tenant-storage');
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
if (tenantStorage) {
|
||||||
progress: number;
|
try {
|
||||||
started_at?: string;
|
const { state } = JSON.parse(tenantStorage);
|
||||||
completed_at?: string;
|
return state?.currentTenant?.id;
|
||||||
error_message?: string;
|
} catch {
|
||||||
parameters: Record<string, any>;
|
return '';
|
||||||
metrics?: Record<string, number>;
|
}
|
||||||
}
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrainingJobCreate {
|
private getBaseUrl(): string {
|
||||||
model_id: string;
|
const tenantId = this.getTenantId();
|
||||||
parameters?: Record<string, any>;
|
return `/tenants/${tenantId}/training`;
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrainingJobUpdate {
|
|
||||||
parameters?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TrainingService extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('/ml/training');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> {
|
async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> {
|
||||||
const params = modelId ? { model_id: modelId } : {};
|
const params = modelId ? { model_id: modelId } : {};
|
||||||
return this.get('/', params);
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params.model_id) {
|
||||||
|
queryParams.append('model_id', params.model_id);
|
||||||
|
}
|
||||||
|
const url = queryParams.toString()
|
||||||
|
? `${this.getBaseUrl()}/jobs?${queryParams.toString()}`
|
||||||
|
: `${this.getBaseUrl()}/jobs`;
|
||||||
|
return apiClient.get(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainingJob(id: string): Promise<ApiResponse<TrainingJob>> {
|
async getTrainingJob(id: string): Promise<ApiResponse<TrainingJob>> {
|
||||||
return this.get(`/${id}`);
|
return apiClient.get(`${this.getBaseUrl()}/jobs/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTrainingJob(data: TrainingJobCreate): Promise<ApiResponse<TrainingJob>> {
|
async createTrainingJob(data: TrainingJobCreate): Promise<ApiResponse<TrainingJob>> {
|
||||||
return this.post('/', data);
|
return apiClient.post(`${this.getBaseUrl()}/jobs`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTrainingJob(id: string, data: TrainingJobUpdate): Promise<ApiResponse<TrainingJob>> {
|
async updateTrainingJob(id: string, data: TrainingJobUpdate): Promise<ApiResponse<TrainingJob>> {
|
||||||
return this.put(`/${id}`, data);
|
return apiClient.put(`${this.getBaseUrl()}/jobs/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTrainingJob(id: string): Promise<ApiResponse<void>> {
|
async deleteTrainingJob(id: string): Promise<ApiResponse<void>> {
|
||||||
return this.delete(`/${id}`);
|
return apiClient.delete(`${this.getBaseUrl()}/jobs/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startTraining(id: string): Promise<ApiResponse<TrainingJob>> {
|
async startTraining(id: string): Promise<ApiResponse<TrainingJob>> {
|
||||||
return this.post(`/${id}/start`);
|
return apiClient.post(`${this.getBaseUrl()}/jobs/${id}/start`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopTraining(id: string): Promise<ApiResponse<TrainingJob>> {
|
async stopTraining(id: string): Promise<ApiResponse<TrainingJob>> {
|
||||||
return this.post(`/${id}/stop`);
|
return apiClient.post(`${this.getBaseUrl()}/jobs/${id}/stop`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainingLogs(id: string): Promise<ApiResponse<string[]>> {
|
async getTrainingLogs(id: string): Promise<ApiResponse<string[]>> {
|
||||||
return this.get(`/${id}/logs`);
|
return apiClient.get(`${this.getBaseUrl()}/jobs/${id}/logs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainingMetrics(id: string): Promise<ApiResponse<Record<string, number>>> {
|
async getTrainingMetrics(id: string): Promise<ApiResponse<Record<string, number>>> {
|
||||||
return this.get(`/${id}/metrics`);
|
return apiClient.get(`${this.getBaseUrl()}/jobs/${id}/metrics`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const trainingService = new TrainingService();
|
||||||
@@ -75,7 +75,8 @@ export const useTenantStore = create<TenantState>()(
|
|||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
// Get current user to determine user ID
|
// Get current user to determine user ID
|
||||||
const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user;
|
const authState = JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state;
|
||||||
|
const user = authState?.user;
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
throw new Error('User not authenticated');
|
throw new Error('User not authenticated');
|
||||||
@@ -130,7 +131,8 @@ export const useTenantStore = create<TenantState>()(
|
|||||||
if (!currentTenant) return false;
|
if (!currentTenant) return false;
|
||||||
|
|
||||||
// Get user to determine role within this tenant
|
// Get user to determine role within this tenant
|
||||||
const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user;
|
const authState = JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state;
|
||||||
|
const user = authState?.user;
|
||||||
|
|
||||||
// Admin role has all permissions
|
// Admin role has all permissions
|
||||||
if (user?.role === 'admin') return true;
|
if (user?.role === 'admin') return true;
|
||||||
|
|||||||
@@ -1,40 +1,45 @@
|
|||||||
// API and common response types
|
/**
|
||||||
|
* API Response Types - Matching actual backend implementation
|
||||||
|
*/
|
||||||
|
|
||||||
// Base API response structure
|
// Standard FastAPI response structure
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
data: T;
|
data?: T;
|
||||||
success: boolean;
|
success?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
detail?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
timestamp?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error response structure
|
// FastAPI error response structure
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
success: false;
|
detail: string | ValidationError[];
|
||||||
error: ErrorDetail;
|
type?: string;
|
||||||
timestamp: string;
|
|
||||||
request_id?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorDetail {
|
export interface ValidationError {
|
||||||
message: string;
|
loc: (string | number)[];
|
||||||
code?: string;
|
msg: string;
|
||||||
field?: string;
|
type: string;
|
||||||
details?: Record<string, any>;
|
ctx?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination types
|
// Pagination types (used by backend services)
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
items: T[];
|
items?: T[];
|
||||||
total: number;
|
records?: T[]; // Some endpoints use 'records'
|
||||||
page: number;
|
data?: T[]; // Some endpoints use 'data'
|
||||||
size: number;
|
total?: number;
|
||||||
pages: number;
|
page?: number;
|
||||||
has_next: boolean;
|
size?: number;
|
||||||
has_prev: boolean;
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
pages?: number;
|
||||||
|
has_next?: boolean;
|
||||||
|
has_prev?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Query parameters for API requests
|
||||||
export interface PaginationParams {
|
export interface PaginationParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -42,368 +47,33 @@ export interface PaginationParams {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorting and filtering
|
|
||||||
export interface SortParams {
|
export interface SortParams {
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_order?: SortOrder;
|
|
||||||
order_by?: string;
|
order_by?: string;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterParams {
|
export interface FilterParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
|
search_term?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
filters?: Record<string, any>;
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryParams extends PaginationParams, SortParams, FilterParams {}
|
export interface QueryParams extends PaginationParams, SortParams, FilterParams {}
|
||||||
|
|
||||||
// File upload types
|
// Task/Job status (used in ML training and other async operations)
|
||||||
export interface FileUploadResponse {
|
|
||||||
file_id: string;
|
|
||||||
filename: string;
|
|
||||||
size: number;
|
|
||||||
mime_type: string;
|
|
||||||
url?: string;
|
|
||||||
download_url?: string;
|
|
||||||
expires_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileUploadProgress {
|
|
||||||
loaded: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
speed?: number;
|
|
||||||
estimated_time?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkOperationResponse {
|
|
||||||
total: number;
|
|
||||||
processed: number;
|
|
||||||
successful: number;
|
|
||||||
failed: number;
|
|
||||||
errors: BulkOperationError[];
|
|
||||||
warnings?: BulkOperationWarning[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkOperationError {
|
|
||||||
index: number;
|
|
||||||
item_id?: string;
|
|
||||||
error_code: string;
|
|
||||||
message: string;
|
|
||||||
field?: string;
|
|
||||||
details?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkOperationWarning {
|
|
||||||
index: number;
|
|
||||||
item_id?: string;
|
|
||||||
warning_code: string;
|
|
||||||
message: string;
|
|
||||||
suggestion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task/Job status types
|
|
||||||
export interface TaskStatus {
|
export interface TaskStatus {
|
||||||
|
id: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
status: TaskStatusType;
|
status: TaskStatusType;
|
||||||
progress: number;
|
progress?: number;
|
||||||
message: string;
|
message?: string;
|
||||||
result?: any;
|
result?: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
started_at?: string;
|
started_at?: string;
|
||||||
completed_at?: string;
|
completed_at?: string;
|
||||||
estimated_completion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LongRunningTask {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: TaskType;
|
|
||||||
status: TaskStatusType;
|
|
||||||
progress: number;
|
|
||||||
total_steps?: number;
|
|
||||||
current_step?: number;
|
|
||||||
step_description?: string;
|
|
||||||
result_data?: any;
|
|
||||||
error_details?: TaskError;
|
|
||||||
created_by: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskError {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
stack_trace?: string;
|
|
||||||
context?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health check types
|
|
||||||
export interface HealthCheckResponse {
|
|
||||||
status: HealthStatus;
|
|
||||||
timestamp: string;
|
|
||||||
version: string;
|
|
||||||
uptime: number;
|
|
||||||
services: ServiceHealth[];
|
|
||||||
system_info?: SystemInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceHealth {
|
|
||||||
name: string;
|
|
||||||
status: HealthStatus;
|
|
||||||
response_time_ms?: number;
|
|
||||||
error_message?: string;
|
|
||||||
dependencies?: ServiceHealth[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemInfo {
|
|
||||||
memory_usage: MemoryUsage;
|
|
||||||
cpu_usage?: number;
|
|
||||||
disk_usage?: DiskUsage;
|
|
||||||
network_stats?: NetworkStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryUsage {
|
|
||||||
used: number;
|
|
||||||
available: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiskUsage {
|
|
||||||
used: number;
|
|
||||||
available: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetworkStats {
|
|
||||||
bytes_sent: number;
|
|
||||||
bytes_received: number;
|
|
||||||
packets_sent: number;
|
|
||||||
packets_received: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit and logging types
|
|
||||||
export interface AuditLog {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
user_email: string;
|
|
||||||
tenant_id: string;
|
|
||||||
action: AuditAction;
|
|
||||||
resource_type: ResourceType;
|
|
||||||
resource_id: string;
|
|
||||||
changes?: AuditChange[];
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
ip_address: string;
|
|
||||||
user_agent: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditChange {
|
|
||||||
field: string;
|
|
||||||
old_value?: any;
|
|
||||||
new_value?: any;
|
|
||||||
change_type: ChangeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache types
|
|
||||||
export interface CacheInfo {
|
|
||||||
key: string;
|
|
||||||
size: number;
|
|
||||||
hits: number;
|
|
||||||
misses: number;
|
|
||||||
hit_rate: number;
|
|
||||||
created_at: string;
|
|
||||||
expires_at?: string;
|
|
||||||
last_accessed: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CacheStats {
|
|
||||||
total_keys: number;
|
|
||||||
total_size: number;
|
|
||||||
hit_rate: number;
|
|
||||||
memory_usage: number;
|
|
||||||
cache_by_type: Record<string, CacheTypeStats>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CacheTypeStats {
|
|
||||||
count: number;
|
|
||||||
size: number;
|
|
||||||
hit_rate: number;
|
|
||||||
avg_ttl: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Webhook types
|
|
||||||
export interface WebhookConfig {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
events: WebhookEvent[];
|
|
||||||
secret?: string;
|
|
||||||
is_active: boolean;
|
|
||||||
retry_count: number;
|
|
||||||
timeout_seconds: number;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebhookDelivery {
|
|
||||||
id: string;
|
|
||||||
webhook_id: string;
|
|
||||||
event_type: WebhookEvent;
|
|
||||||
payload: Record<string, any>;
|
|
||||||
status: DeliveryStatus;
|
|
||||||
http_status?: number;
|
|
||||||
response_body?: string;
|
|
||||||
error_message?: string;
|
|
||||||
attempts: number;
|
|
||||||
delivered_at?: string;
|
|
||||||
created_at: string;
|
|
||||||
next_retry_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting types
|
|
||||||
export interface RateLimit {
|
|
||||||
requests_per_minute: number;
|
|
||||||
requests_per_hour: number;
|
|
||||||
requests_per_day: number;
|
|
||||||
current_usage: RateLimitUsage;
|
|
||||||
reset_time: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RateLimitUsage {
|
|
||||||
minute: number;
|
|
||||||
hour: number;
|
|
||||||
day: number;
|
|
||||||
percentage_used: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search types
|
|
||||||
export interface SearchRequest {
|
|
||||||
query: string;
|
|
||||||
filters?: SearchFilter[];
|
|
||||||
sort?: SearchSort[];
|
|
||||||
facets?: string[];
|
|
||||||
highlight?: SearchHighlight;
|
|
||||||
size?: number;
|
|
||||||
from?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchFilter {
|
|
||||||
field: string;
|
|
||||||
operator: FilterOperator;
|
|
||||||
value: any;
|
|
||||||
values?: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchSort {
|
|
||||||
field: string;
|
|
||||||
order: SortOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchHighlight {
|
|
||||||
fields: string[];
|
|
||||||
pre_tag?: string;
|
|
||||||
post_tag?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResponse<T> {
|
|
||||||
results: T[];
|
|
||||||
total: number;
|
|
||||||
facets?: SearchFacet[];
|
|
||||||
highlights?: Record<string, string[]>;
|
|
||||||
suggestions?: SearchSuggestion[];
|
|
||||||
took_ms: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchFacet {
|
|
||||||
field: string;
|
|
||||||
values: FacetValue[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FacetValue {
|
|
||||||
value: any;
|
|
||||||
count: number;
|
|
||||||
selected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchSuggestion {
|
|
||||||
text: string;
|
|
||||||
highlighted: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export/Import types
|
|
||||||
export interface ExportRequest {
|
|
||||||
format: ExportFormat;
|
|
||||||
filters?: Record<string, any>;
|
|
||||||
fields?: string[];
|
|
||||||
include_metadata?: boolean;
|
|
||||||
compression?: CompressionType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportResponse {
|
|
||||||
export_id: string;
|
|
||||||
status: ExportStatus;
|
|
||||||
download_url?: string;
|
|
||||||
file_size?: number;
|
|
||||||
expires_at?: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportRequest {
|
|
||||||
file_url?: string;
|
|
||||||
file_data?: string;
|
|
||||||
format: ImportFormat;
|
|
||||||
options?: ImportOptions;
|
|
||||||
mapping?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportOptions {
|
|
||||||
skip_validation?: boolean;
|
|
||||||
update_existing?: boolean;
|
|
||||||
batch_size?: number;
|
|
||||||
dry_run?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportResponse {
|
|
||||||
import_id: string;
|
|
||||||
status: ImportStatus;
|
|
||||||
total_records?: number;
|
|
||||||
processed_records?: number;
|
|
||||||
successful_records?: number;
|
|
||||||
failed_records?: number;
|
|
||||||
errors?: ImportError[];
|
|
||||||
warnings?: ImportWarning[];
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportError {
|
|
||||||
row: number;
|
|
||||||
field?: string;
|
|
||||||
value?: any;
|
|
||||||
error_code: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportWarning {
|
|
||||||
row: number;
|
|
||||||
field?: string;
|
|
||||||
value?: any;
|
|
||||||
warning_code: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enums
|
|
||||||
export enum SortOrder {
|
|
||||||
ASC = 'asc',
|
|
||||||
DESC = 'desc',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TaskStatusType {
|
export enum TaskStatusType {
|
||||||
@@ -411,153 +81,69 @@ export enum TaskStatusType {
|
|||||||
RUNNING = 'running',
|
RUNNING = 'running',
|
||||||
COMPLETED = 'completed',
|
COMPLETED = 'completed',
|
||||||
FAILED = 'failed',
|
FAILED = 'failed',
|
||||||
CANCELLED = 'cancelled',
|
CANCELLED = 'cancelled'
|
||||||
PAUSED = 'paused',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TaskType {
|
// Health check types (used by monitoring endpoints)
|
||||||
DATA_IMPORT = 'data_import',
|
export interface HealthCheckResponse {
|
||||||
DATA_EXPORT = 'data_export',
|
status: 'healthy' | 'unhealthy' | 'degraded';
|
||||||
REPORT_GENERATION = 'report_generation',
|
service: string;
|
||||||
MODEL_TRAINING = 'model_training',
|
version: string;
|
||||||
DATA_PROCESSING = 'data_processing',
|
timestamp: string;
|
||||||
BULK_OPERATION = 'bulk_operation',
|
dependencies?: ServiceHealth[];
|
||||||
SYNC_OPERATION = 'sync_operation',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum HealthStatus {
|
export interface ServiceHealth {
|
||||||
HEALTHY = 'healthy',
|
name: string;
|
||||||
DEGRADED = 'degraded',
|
status: 'healthy' | 'unhealthy' | 'degraded';
|
||||||
UNHEALTHY = 'unhealthy',
|
response_time?: number;
|
||||||
UNKNOWN = 'unknown',
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuditAction {
|
// File upload types
|
||||||
CREATE = 'create',
|
export interface FileUploadResponse {
|
||||||
READ = 'read',
|
file_id: string;
|
||||||
UPDATE = 'update',
|
filename: string;
|
||||||
DELETE = 'delete',
|
size: number;
|
||||||
LOGIN = 'login',
|
content_type: string;
|
||||||
LOGOUT = 'logout',
|
url?: string;
|
||||||
EXPORT = 'export',
|
|
||||||
IMPORT = 'import',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ResourceType {
|
// Bulk operation response
|
||||||
USER = 'user',
|
export interface BulkOperationResponse {
|
||||||
TENANT = 'tenant',
|
total: number;
|
||||||
INGREDIENT = 'ingredient',
|
processed: number;
|
||||||
STOCK = 'stock',
|
successful: number;
|
||||||
PRODUCTION_BATCH = 'production_batch',
|
failed: number;
|
||||||
SALES_RECORD = 'sales_record',
|
errors?: BulkOperationError[];
|
||||||
FORECAST = 'forecast',
|
|
||||||
ORDER = 'order',
|
|
||||||
SUPPLIER = 'supplier',
|
|
||||||
RECIPE = 'recipe',
|
|
||||||
NOTIFICATION = 'notification',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ChangeType {
|
export interface BulkOperationError {
|
||||||
CREATED = 'created',
|
index: number;
|
||||||
UPDATED = 'updated',
|
error: string;
|
||||||
DELETED = 'deleted',
|
details?: any;
|
||||||
ARCHIVED = 'archived',
|
|
||||||
RESTORED = 'restored',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WebhookEvent {
|
// Common enums
|
||||||
USER_CREATED = 'user.created',
|
export enum SortOrder {
|
||||||
USER_UPDATED = 'user.updated',
|
ASC = 'asc',
|
||||||
USER_DELETED = 'user.deleted',
|
DESC = 'desc'
|
||||||
INVENTORY_LOW_STOCK = 'inventory.low_stock',
|
|
||||||
PRODUCTION_COMPLETED = 'production.completed',
|
|
||||||
ORDER_CREATED = 'order.created',
|
|
||||||
ORDER_UPDATED = 'order.updated',
|
|
||||||
FORECAST_GENERATED = 'forecast.generated',
|
|
||||||
ALERT_TRIGGERED = 'alert.triggered',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DeliveryStatus {
|
// HTTP methods
|
||||||
PENDING = 'pending',
|
|
||||||
DELIVERED = 'delivered',
|
|
||||||
FAILED = 'failed',
|
|
||||||
RETRYING = 'retrying',
|
|
||||||
CANCELLED = 'cancelled',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum FilterOperator {
|
|
||||||
EQUALS = 'eq',
|
|
||||||
NOT_EQUALS = 'ne',
|
|
||||||
GREATER_THAN = 'gt',
|
|
||||||
GREATER_THAN_OR_EQUAL = 'gte',
|
|
||||||
LESS_THAN = 'lt',
|
|
||||||
LESS_THAN_OR_EQUAL = 'lte',
|
|
||||||
CONTAINS = 'contains',
|
|
||||||
STARTS_WITH = 'starts_with',
|
|
||||||
ENDS_WITH = 'ends_with',
|
|
||||||
IN = 'in',
|
|
||||||
NOT_IN = 'not_in',
|
|
||||||
IS_NULL = 'is_null',
|
|
||||||
IS_NOT_NULL = 'is_not_null',
|
|
||||||
BETWEEN = 'between',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ExportFormat {
|
|
||||||
CSV = 'csv',
|
|
||||||
EXCEL = 'excel',
|
|
||||||
JSON = 'json',
|
|
||||||
PDF = 'pdf',
|
|
||||||
XML = 'xml',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ImportFormat {
|
|
||||||
CSV = 'csv',
|
|
||||||
EXCEL = 'excel',
|
|
||||||
JSON = 'json',
|
|
||||||
XML = 'xml',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CompressionType {
|
|
||||||
NONE = 'none',
|
|
||||||
ZIP = 'zip',
|
|
||||||
GZIP = 'gzip',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ExportStatus {
|
|
||||||
PENDING = 'pending',
|
|
||||||
PROCESSING = 'processing',
|
|
||||||
COMPLETED = 'completed',
|
|
||||||
FAILED = 'failed',
|
|
||||||
EXPIRED = 'expired',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ImportStatus {
|
|
||||||
PENDING = 'pending',
|
|
||||||
PROCESSING = 'processing',
|
|
||||||
COMPLETED = 'completed',
|
|
||||||
FAILED = 'failed',
|
|
||||||
CANCELLED = 'cancelled',
|
|
||||||
PARTIALLY_COMPLETED = 'partially_completed',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility types for HTTP methods
|
|
||||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
|
||||||
|
|
||||||
// Generic type for API endpoints
|
|
||||||
export type ApiEndpoint<TRequest = any, TResponse = any> = {
|
|
||||||
method: HttpMethod;
|
|
||||||
path: string;
|
|
||||||
request?: TRequest;
|
|
||||||
response: TResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Type guards
|
// Type guards
|
||||||
export const isApiError = (obj: any): obj is ApiError => {
|
export const isApiError = (obj: any): obj is ApiError => {
|
||||||
return obj && obj.success === false && obj.error && typeof obj.error.message === 'string';
|
return obj && typeof obj.detail === 'string';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isPaginatedResponse = <T>(obj: any): obj is PaginatedResponse<T> => {
|
export const isPaginatedResponse = <T>(obj: any): obj is PaginatedResponse<T> => {
|
||||||
return obj && Array.isArray(obj.items) && typeof obj.total === 'number';
|
return obj && (
|
||||||
|
Array.isArray(obj.items) ||
|
||||||
|
Array.isArray(obj.records) ||
|
||||||
|
Array.isArray(obj.data)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isTaskStatus = (obj: any): obj is TaskStatus => {
|
export const isTaskStatus = (obj: any): obj is TaskStatus => {
|
||||||
|
|||||||
@@ -1,39 +1,23 @@
|
|||||||
// Authentication related types - Updated to match backend exactly
|
/**
|
||||||
export interface User {
|
* Authentication Types - Matching backend schemas exactly
|
||||||
id: string;
|
* Based on services/auth/app/schemas/auth.py
|
||||||
email: string;
|
*/
|
||||||
full_name: string; // Backend uses full_name, not name
|
|
||||||
is_active: boolean;
|
// ============================================================================
|
||||||
is_verified: boolean;
|
// REQUEST TYPES (Frontend -> Backend)
|
||||||
created_at: string; // ISO format datetime string
|
// ============================================================================
|
||||||
last_login?: string;
|
|
||||||
phone?: string;
|
|
||||||
language?: string;
|
|
||||||
timezone?: string;
|
|
||||||
tenant_id?: string;
|
|
||||||
role?: string; // Backend uses string, not enum
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserRegistration {
|
export interface UserRegistration {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
tenant_name?: string; // Optional in backend
|
tenant_name?: string;
|
||||||
role?: string; // Backend uses string, defaults to "user"
|
role?: 'user' | 'admin' | 'manager';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserLogin {
|
export interface UserLogin {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
remember_me?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
token_type: string; // defaults to "bearer"
|
|
||||||
expires_in: number; // seconds, defaults to 3600
|
|
||||||
user?: User;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshTokenRequest {
|
export interface RefreshTokenRequest {
|
||||||
@@ -54,6 +38,64 @@ export interface PasswordResetConfirm {
|
|||||||
new_password: string;
|
new_password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RESPONSE TYPES (Backend -> Frontend)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_verified: boolean;
|
||||||
|
created_at: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
token_type: string; // defaults to "bearer"
|
||||||
|
expires_in: number; // seconds, defaults to 3600
|
||||||
|
user?: UserData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JWT TOKEN CLAIMS (Internal)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TokenClaims {
|
||||||
|
sub: string; // user ID
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
user_id: string;
|
||||||
|
is_verified: boolean;
|
||||||
|
tenant_id?: string;
|
||||||
|
role?: string;
|
||||||
|
type: 'access' | 'refresh';
|
||||||
|
iat: number; // issued at
|
||||||
|
exp: number; // expires at
|
||||||
|
iss: string; // issuer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FRONTEND STATE TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: UserData | null;
|
||||||
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUTH SERVICE SPECIFIC TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export interface TokenVerification {
|
export interface TokenVerification {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
@@ -62,27 +104,27 @@ export interface TokenVerification {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserResponse extends UserData {
|
||||||
|
last_login?: string;
|
||||||
|
phone?: string;
|
||||||
|
language?: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserUpdate {
|
export interface UserUpdate {
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
avatar_url?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
// ============================================================================
|
||||||
isAuthenticated: boolean;
|
// FORM DATA TYPES (Frontend UI)
|
||||||
user: User | null;
|
// ============================================================================
|
||||||
token: string | null;
|
|
||||||
refreshToken: string | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginFormData {
|
export interface LoginFormData {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
remember_me: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterFormData {
|
export interface RegisterFormData {
|
||||||
@@ -90,17 +132,7 @@ export interface RegisterFormData {
|
|||||||
password: string;
|
password: string;
|
||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
tenant_name?: string; // Optional to match backend
|
tenant_name?: string;
|
||||||
phone?: string;
|
|
||||||
acceptTerms: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProfileFormData {
|
|
||||||
full_name: string;
|
|
||||||
email: string;
|
|
||||||
phone?: string;
|
|
||||||
language: string;
|
|
||||||
timezone: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordChangeFormData {
|
export interface PasswordChangeFormData {
|
||||||
@@ -109,142 +141,40 @@ export interface PasswordChangeFormData {
|
|||||||
confirm_password: string;
|
confirm_password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailVerificationRequest {
|
// ============================================================================
|
||||||
email: string;
|
// UTILITY TYPES
|
||||||
}
|
// ============================================================================
|
||||||
|
|
||||||
export interface EmailVerificationConfirm {
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserContext {
|
|
||||||
user_id: string;
|
|
||||||
email: string;
|
|
||||||
tenant_id?: string;
|
|
||||||
roles: string[];
|
|
||||||
is_verified: boolean;
|
|
||||||
permissions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenClaims {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string;
|
|
||||||
user_id: string;
|
|
||||||
is_verified: boolean;
|
|
||||||
tenant_id?: string;
|
|
||||||
iat: number;
|
|
||||||
exp: number;
|
|
||||||
iss: string;
|
|
||||||
roles?: string[];
|
|
||||||
permissions?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthError {
|
export interface AuthError {
|
||||||
code: string;
|
detail: string;
|
||||||
message: string;
|
type?: string;
|
||||||
field?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MFASetup {
|
// Keep User as alias for UserData for backward compatibility
|
||||||
enabled: boolean;
|
export interface User extends UserData {}
|
||||||
secret?: string;
|
|
||||||
backup_codes?: string[];
|
|
||||||
qr_code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MFAVerification {
|
// ============================================================================
|
||||||
token: string;
|
// ENUMS
|
||||||
backup_code?: string;
|
// ============================================================================
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionInfo {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
ip_address: string;
|
|
||||||
user_agent: string;
|
|
||||||
created_at: string;
|
|
||||||
last_activity: string;
|
|
||||||
is_current: boolean;
|
|
||||||
location?: string;
|
|
||||||
device_type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OAuthProvider {
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
icon: string;
|
|
||||||
color: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enums - Simplified to match backend
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
USER = 'user',
|
USER = 'user',
|
||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
MANAGER = 'manager',
|
MANAGER = 'manager'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuthProvider {
|
// ============================================================================
|
||||||
EMAIL = 'email',
|
// TYPE GUARDS
|
||||||
GOOGLE = 'google',
|
// ============================================================================
|
||||||
MICROSOFT = 'microsoft',
|
|
||||||
APPLE = 'apple',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Permission {
|
export const isUser = (obj: any): obj is UserData => {
|
||||||
// User management
|
|
||||||
USER_READ = 'user:read',
|
|
||||||
USER_WRITE = 'user:write',
|
|
||||||
USER_DELETE = 'user:delete',
|
|
||||||
|
|
||||||
// Inventory
|
|
||||||
INVENTORY_READ = 'inventory:read',
|
|
||||||
INVENTORY_WRITE = 'inventory:write',
|
|
||||||
INVENTORY_DELETE = 'inventory:delete',
|
|
||||||
|
|
||||||
// Production
|
|
||||||
PRODUCTION_READ = 'production:read',
|
|
||||||
PRODUCTION_WRITE = 'production:write',
|
|
||||||
PRODUCTION_DELETE = 'production:delete',
|
|
||||||
|
|
||||||
// Sales
|
|
||||||
SALES_READ = 'sales:read',
|
|
||||||
SALES_WRITE = 'sales:write',
|
|
||||||
SALES_DELETE = 'sales:delete',
|
|
||||||
|
|
||||||
// Forecasting
|
|
||||||
FORECASTING_READ = 'forecasting:read',
|
|
||||||
FORECASTING_WRITE = 'forecasting:write',
|
|
||||||
FORECASTING_DELETE = 'forecasting:delete',
|
|
||||||
|
|
||||||
// Orders
|
|
||||||
ORDERS_READ = 'orders:read',
|
|
||||||
ORDERS_WRITE = 'orders:write',
|
|
||||||
ORDERS_DELETE = 'orders:delete',
|
|
||||||
|
|
||||||
// Procurement
|
|
||||||
PROCUREMENT_READ = 'procurement:read',
|
|
||||||
PROCUREMENT_WRITE = 'procurement:write',
|
|
||||||
PROCUREMENT_DELETE = 'procurement:delete',
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
SETTINGS_READ = 'settings:read',
|
|
||||||
SETTINGS_WRITE = 'settings:write',
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
ANALYTICS_READ = 'analytics:read',
|
|
||||||
ANALYTICS_EXPORT = 'analytics:export',
|
|
||||||
|
|
||||||
// Admin
|
|
||||||
ADMIN_ALL = 'admin:all',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type guards
|
|
||||||
export const isUser = (obj: any): obj is User => {
|
|
||||||
return obj && typeof obj.id === 'string' && typeof obj.email === 'string';
|
return obj && typeof obj.id === 'string' && typeof obj.email === 'string';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isTokenResponse = (obj: any): obj is TokenResponse => {
|
export const isTokenResponse = (obj: any): obj is TokenResponse => {
|
||||||
return obj && typeof obj.access_token === 'string' && typeof obj.token_type === 'string';
|
return obj && typeof obj.access_token === 'string' && typeof obj.token_type === 'string';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAuthError = (obj: any): obj is AuthError => {
|
||||||
|
return obj && typeof obj.detail === 'string';
|
||||||
};
|
};
|
||||||
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
|
DataPreprocessing
|
||||||
} from './forecasting.types';
|
} from './forecasting.types';
|
||||||
|
|
||||||
|
// Suppliers types
|
||||||
|
export type {
|
||||||
|
SupplierCreate,
|
||||||
|
SupplierUpdate,
|
||||||
|
SupplierResponse,
|
||||||
|
SupplierSummary,
|
||||||
|
SupplierSearchParams,
|
||||||
|
SupplierApproval,
|
||||||
|
SupplierStatistics,
|
||||||
|
PurchaseOrderItemCreate,
|
||||||
|
PurchaseOrderItemResponse,
|
||||||
|
PurchaseOrderCreate,
|
||||||
|
PurchaseOrderUpdate,
|
||||||
|
PurchaseOrderResponse,
|
||||||
|
DeliveryItemCreate,
|
||||||
|
DeliveryItemResponse,
|
||||||
|
DeliveryCreate,
|
||||||
|
DeliveryResponse,
|
||||||
|
DeliveryReceiptConfirmation,
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
DeliveryStatus,
|
||||||
|
Supplier
|
||||||
|
} from './suppliers.types';
|
||||||
|
|
||||||
|
// Data types
|
||||||
|
export type {
|
||||||
|
WeatherData,
|
||||||
|
WeatherDataParams,
|
||||||
|
WeatherCondition,
|
||||||
|
TrafficData,
|
||||||
|
TrafficDataParams,
|
||||||
|
TrafficPattern,
|
||||||
|
TrafficPatternsParams,
|
||||||
|
EventData,
|
||||||
|
EventsParams,
|
||||||
|
EventType,
|
||||||
|
CustomEventCreate,
|
||||||
|
LocationConfig,
|
||||||
|
LocationCreate,
|
||||||
|
ExternalFactorsImpact,
|
||||||
|
ExternalFactorsParams,
|
||||||
|
DataQualityReport,
|
||||||
|
DataSettings,
|
||||||
|
DataSettingsUpdate,
|
||||||
|
RefreshDataResponse,
|
||||||
|
DeleteResponse,
|
||||||
|
RefreshInterval
|
||||||
|
} from './data.types';
|
||||||
|
|
||||||
// API and common types
|
// API and common types
|
||||||
export type {
|
export type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
ValidationError,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
SortParams,
|
||||||
|
FilterParams,
|
||||||
|
QueryParams,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
HealthCheckResponse
|
TaskStatusType,
|
||||||
|
HealthCheckResponse,
|
||||||
|
ServiceHealth,
|
||||||
|
FileUploadResponse,
|
||||||
|
BulkOperationResponse,
|
||||||
|
SortOrder,
|
||||||
|
HttpMethod
|
||||||
} from './api.types';
|
} from './api.types';
|
||||||
|
|
||||||
// Re-export commonly used types for convenience
|
// Re-export commonly used types for convenience
|
||||||
export type {
|
export type {
|
||||||
User,
|
User,
|
||||||
|
UserData,
|
||||||
UserLogin,
|
UserLogin,
|
||||||
UserRegistration,
|
UserRegistration,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
AuthState,
|
AuthState,
|
||||||
|
AuthError,
|
||||||
|
UserRole
|
||||||
} from './auth.types';
|
} from './auth.types';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -135,6 +199,16 @@ export type {
|
|||||||
BatchStatus,
|
BatchStatus,
|
||||||
} from './forecasting.types';
|
} from './forecasting.types';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
SupplierResponse,
|
||||||
|
SupplierSummary,
|
||||||
|
SupplierCreate,
|
||||||
|
PurchaseOrderResponse,
|
||||||
|
DeliveryResponse,
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
DeliveryStatus,
|
||||||
|
} from './suppliers.types';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
|||||||
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,
|
"errors": validation_result.errors,
|
||||||
"warnings": validation_result.warnings,
|
"warnings": validation_result.warnings,
|
||||||
"summary": validation_result.summary,
|
"summary": validation_result.summary,
|
||||||
|
"unique_products": validation_result.unique_products,
|
||||||
|
"product_list": validation_result.product_list,
|
||||||
"message": "Validation completed successfully" if validation_result.is_valid else "Validation found errors",
|
"message": "Validation completed successfully" if validation_result.is_valid else "Validation found errors",
|
||||||
"details": {
|
"details": {
|
||||||
"total_records": validation_result.total_records,
|
"total_records": validation_result.total_records,
|
||||||
|
|||||||
@@ -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
|
# Include routers - import router BEFORE sales router to avoid conflicts
|
||||||
from app.api.sales import router as sales_router
|
from app.api.sales import router as sales_router
|
||||||
from app.api.import_data import router as import_router
|
from app.api.import_data import router as import_router
|
||||||
from app.api.onboarding import router as onboarding_router
|
|
||||||
app.include_router(import_router, prefix="/api/v1", tags=["import"])
|
app.include_router(import_router, prefix="/api/v1", tags=["import"])
|
||||||
app.include_router(onboarding_router, prefix="/api/v1", tags=["onboarding"])
|
|
||||||
app.include_router(sales_router, prefix="/api/v1", tags=["sales"])
|
app.include_router(sales_router, prefix="/api/v1", tags=["sales"])
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
|
|||||||
@@ -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)
|
# Import result schemas (dataclass definitions)
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -38,6 +38,8 @@ class SalesValidationResult:
|
|||||||
errors: List[Dict[str, Any]]
|
errors: List[Dict[str, Any]]
|
||||||
warnings: List[Dict[str, Any]]
|
warnings: List[Dict[str, Any]]
|
||||||
summary: Dict[str, Any]
|
summary: Dict[str, Any]
|
||||||
|
unique_products: int = 0
|
||||||
|
product_list: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SalesImportResult:
|
class SalesImportResult:
|
||||||
@@ -99,7 +101,9 @@ class DataImportService:
|
|||||||
invalid_records=0,
|
invalid_records=0,
|
||||||
errors=[],
|
errors=[],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
summary={}
|
summary={},
|
||||||
|
unique_products=0,
|
||||||
|
product_list=[]
|
||||||
)
|
)
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
@@ -216,6 +220,22 @@ class DataImportService:
|
|||||||
"code": "MISSING_PRODUCT_COLUMN"
|
"code": "MISSING_PRODUCT_COLUMN"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Extract unique products for AI suggestions
|
||||||
|
if column_mapping.get('product') and not errors:
|
||||||
|
product_column = column_mapping['product']
|
||||||
|
unique_products_set = set()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
product_name = row.get(product_column, '').strip()
|
||||||
|
if product_name and len(product_name) > 0:
|
||||||
|
unique_products_set.add(product_name)
|
||||||
|
|
||||||
|
validation_result.product_list = list(unique_products_set)
|
||||||
|
validation_result.unique_products = len(unique_products_set)
|
||||||
|
|
||||||
|
logger.info(f"Extracted {validation_result.unique_products} unique products from CSV",
|
||||||
|
tenant_id=data.get("tenant_id"))
|
||||||
|
|
||||||
if not column_mapping.get('quantity'):
|
if not column_mapping.get('quantity'):
|
||||||
warnings.append({
|
warnings.append({
|
||||||
"type": "missing_column",
|
"type": "missing_column",
|
||||||
|
|||||||
Reference in New Issue
Block a user