Add frontend pages imporvements

This commit is contained in:
Urtzi Alfaro
2025-09-21 22:56:55 +02:00
parent f08667150d
commit ecfc6a1997
14 changed files with 1538 additions and 1093 deletions

View File

@@ -1,15 +1,17 @@
import React, { useState, useMemo } from 'react';
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer } from 'lucide-react';
import { Button, Card, Badge, Select, Table, StatsGrid } from '../../../../components/ui';
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer, Package, Activity, Clock } from 'lucide-react';
import { Button, Card, Badge, Table, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import type { TableColumn } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { LoadingSpinner } from '../../../../components/shared';
import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting';
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useModels } from '../../../../api/hooks/training';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useTenantId } from '../../../../hooks/useTenantId';
import { ForecastResponse } from '../../../../api/types/forecasting';
import { forecastingService } from '../../../../api/services/forecasting';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
const ForecastingPage: React.FC = () => {
const [selectedProduct, setSelectedProduct] = useState('');
@@ -20,8 +22,7 @@ const ForecastingPage: React.FC = () => {
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
// Get tenant ID from tenant store
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const tenantId = useTenantId();
// Calculate date range based on selected period
const endDate = new Date();
@@ -145,42 +146,6 @@ const ForecastingPage: React.FC = () => {
};
// Extract weather data from all forecasts for 7-day view
const getWeatherImpact = (forecasts: ForecastResponse[]) => {
if (!forecasts || forecasts.length === 0) return null;
// Calculate average temperature across all forecast days
const avgTemp = forecasts.reduce((sum, f) => sum + (f.weather_temperature || 0), 0) / forecasts.length;
const tempRange = {
min: Math.min(...forecasts.map(f => f.weather_temperature || 0)),
max: Math.max(...forecasts.map(f => f.weather_temperature || 0))
};
// Aggregate weather descriptions
const weatherTypes = forecasts
.map(f => f.weather_description)
.filter(Boolean)
.reduce((acc, desc) => {
acc[desc] = (acc[desc] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const dominantWeather = Object.entries(weatherTypes)
.sort(([,a], [,b]) => b - a)[0]?.[0] || 'N/A';
return {
avgTemperature: Math.round(avgTemp),
tempRange,
dominantWeather,
forecastDays: forecasts.length,
dailyForecasts: forecasts.map(f => ({
date: f.forecast_date,
temperature: f.weather_temperature,
description: f.weather_description,
predicted_demand: f.predicted_demand
}))
};
};
const forecastColumns: TableColumn[] = [
@@ -224,7 +189,6 @@ const ForecastingPage: React.FC = () => {
// Use either current forecast data or fetched data
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
const transformedForecasts = transformForecastsForTable(forecasts);
const weatherImpact = getWeatherImpact(forecasts);
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
const hasError = forecastsError || ingredientsError || modelsError;
@@ -234,495 +198,370 @@ const ForecastingPage: React.FC = () => {
? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100)
: 0;
// Get forecast insights from the latest forecast - only real backend data
const getForecastInsights = (forecast: ForecastResponse) => {
// Get simplified forecast insights
const getForecastInsights = (forecasts: ForecastResponse[]) => {
if (!forecasts || forecasts.length === 0) return [];
const insights = [];
const avgConfidence = forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length;
// Weather data (only factual)
if (forecast.weather_temperature) {
insights.push({
type: 'weather',
icon: Thermometer,
title: 'Temperatura',
description: `${forecast.weather_temperature}°C`,
impact: 'info'
});
}
if (forecast.weather_description) {
insights.push({
type: 'weather',
icon: CloudRain,
title: 'Condición Climática',
description: forecast.weather_description,
impact: 'info'
});
}
// Temporal factors (only factual)
if (forecast.is_weekend) {
insights.push({
type: 'temporal',
icon: Calendar,
title: 'Fin de Semana',
description: 'Día de fin de semana',
impact: 'info'
});
}
if (forecast.is_holiday) {
insights.push({
type: 'temporal',
icon: Calendar,
title: 'Día Festivo',
description: 'Día festivo',
impact: 'info'
});
}
// Model confidence (factual)
// Model confidence
insights.push({
type: 'model',
icon: Target,
title: 'Confianza del Modelo',
description: `${Math.round(forecast.confidence_level * 100)}%`,
impact: forecast.confidence_level > 0.8 ? 'positive' : forecast.confidence_level > 0.6 ? 'moderate' : 'high'
description: `${Math.round(avgConfidence * 100)}%`,
impact: avgConfidence > 0.8 ? 'positive' : avgConfidence > 0.6 ? 'moderate' : 'high'
});
// Algorithm used
if (forecasts[0]?.algorithm) {
insights.push({
type: 'algorithm',
icon: Brain,
title: 'Algoritmo',
description: forecasts[0].algorithm,
impact: 'info'
});
}
// Forecast period
insights.push({
type: 'period',
icon: Calendar,
title: 'Período',
description: `${forecasts.length} días predichos`,
impact: 'info'
});
return insights;
};
const currentInsights = forecasts.length > 0 ? getForecastInsights(forecasts[0]) : [];
const currentInsights = getForecastInsights(forecasts);
// Loading and error states - using project patterns
if (isLoading || !tenantId) {
return (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando datos de predicción..." />
</div>
);
}
if (hasError) {
return (
<div className="text-center py-12">
<TrendingUp className="mx-auto h-12 w-12 text-[var(--color-error)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar datos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{forecastsError?.message || ingredientsError?.message || modelsError?.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="space-y-6">
<PageHeader
title="Predicción de Demanda"
description="Predicciones inteligentes basadas en IA para optimizar tu producción"
action={
<div className="flex space-x-2">
<Button variant="outline">
<Settings className="w-4 h-4 mr-2" />
Configurar
</Button>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
}
description="Sistema inteligente de predicción de demanda basado en IA"
/>
{isLoading && (
<Card className="p-6 flex items-center justify-center">
<Loader className="h-6 w-6 animate-spin mr-2" />
<span>
{isGenerating ? 'Generando nuevas predicciones...' : 'Cargando predicciones...'}
</span>
</Card>
)}
{/* Stats Grid - Similar to POSPage */}
<StatsGrid
stats={[
{
title: 'Ingredientes con Modelos',
value: products.length,
variant: 'default' as const,
icon: Brain,
},
{
title: 'Predicciones Generadas',
value: forecasts.length,
variant: 'info' as const,
icon: TrendingUp,
},
{
title: 'Confianza Promedio',
value: `${averageConfidence}%`,
variant: 'success' as const,
icon: Target,
},
{
title: 'Demanda Total',
value: formatters.number(Math.round(totalDemand)),
variant: 'warning' as const,
icon: BarChart3,
},
]}
columns={4}
/>
{hasError && (
<Card className="p-6 bg-[var(--color-error-50)] border-[var(--color-error-200)]">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-[var(--color-error-600)] mr-2" />
<span className="text-[var(--color-error-800)]">Error al cargar las predicciones. Por favor, inténtalo de nuevo.</span>
</div>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Ingredient Selection Section */}
<div className="lg:col-span-2 space-y-6">
{/* Ingredients Grid - Similar to POSPage products */}
<Card className="p-4">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Brain className="w-5 h-5 mr-2" />
Ingredientes Disponibles ({products.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{products.map(product => {
const isSelected = selectedProduct === product.id;
{!isLoading && !hasError && (
<>
const getStatusConfig = () => {
if (isSelected) {
return {
color: getStatusColor('completed'),
text: 'Seleccionado',
icon: Target,
isCritical: false,
isHighlight: true
};
} else {
return {
color: getStatusColor('pending'),
text: 'Disponible',
icon: Brain,
isCritical: false,
isHighlight: false
};
}
};
</>
)}
return (
<StatusCard
key={product.id}
id={product.id}
statusIndicator={getStatusConfig()}
title={product.name}
subtitle={product.category}
primaryValue="Modelo IA"
primaryValueLabel="entrenado"
actions={[
{
label: isSelected ? 'Seleccionado' : 'Seleccionar',
icon: isSelected ? Target : Zap,
variant: isSelected ? 'success' : 'primary',
priority: 'primary',
disabled: isGenerating,
onClick: () => setSelectedProduct(product.id)
}
]}
/>
);
})}
</div>
{/* Forecast Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configurar Predicción</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Step 1: Select Ingredient */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<span className="flex items-center">
<span className="bg-[var(--color-info-600)] text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">1</span>
Seleccionar Ingrediente
</span>
</label>
<select
value={selectedProduct}
onChange={(e) => setSelectedProduct(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
disabled={isGenerating}
>
<option value="">Selecciona un ingrediente...</option>
{products.map(product => (
<option key={product.id} value={product.id}>
🤖 {product.name}
</option>
))}
</select>
{/* Empty State */}
{products.length === 0 && (
<p className="text-xs text-[var(--text-tertiary)] mt-1">
No hay ingredientes con modelos entrenados
</p>
<div className="text-center py-12">
<Brain className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay modelos entrenados
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Para generar predicciones, necesitas modelos IA entrenados para tus ingredientes
</p>
<Button
onClick={() => window.location.href = '/app/database/models'}
variant="primary"
>
<Settings className="w-4 h-4 mr-2" />
Entrenar Modelos
</Button>
</div>
)}
</div>
</Card>
</div>
{/* Step 2: Select Period */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<span className="flex items-center">
<span className={`rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2 ${
selectedProduct ? 'bg-[var(--color-info-600)] text-white' : 'bg-gray-300 text-gray-600'
}`}>2</span>
Período de Predicción
</span>
</label>
<select
value={forecastPeriod}
onChange={(e) => setForecastPeriod(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
disabled={!selectedProduct || isGenerating}
>
{periods.map(period => (
<option key={period.value} value={period.value}>{period.label}</option>
))}
</select>
</div>
{/* Configuration and Generation Section */}
<div className="space-y-6">
{/* Period Selection */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Calendar className="w-5 h-5 mr-2" />
Configuración
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Período de Predicción
</label>
<select
value={forecastPeriod}
onChange={(e) => setForecastPeriod(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
disabled={isGenerating}
>
{periods.map(period => (
<option key={period.value} value={period.value}>{period.label}</option>
))}
</select>
</div>
{/* Step 3: Generate */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<span className="flex items-center">
<span className={`rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2 ${
selectedProduct && forecastPeriod ? 'bg-[var(--color-info-600)] text-white' : 'bg-gray-300 text-gray-600'
}`}>3</span>
Generar Predicción
</span>
</label>
{selectedProduct && (
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-sm font-medium text-[var(--text-primary)]">
{products.find(p => p.id === selectedProduct)?.name}
</p>
<p className="text-xs text-[var(--text-secondary)]">
Predicción para {forecastPeriod} días
</p>
</div>
)}
</div>
</Card>
{/* Generate Forecast */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Zap className="w-5 h-5 mr-2" />
Generar Predicción
</h3>
<Button
onClick={handleGenerateForecast}
disabled={!selectedProduct || !forecastPeriod || isGenerating}
className="w-full bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary)]/90"
className="w-full"
size="lg"
>
{isGenerating ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin" />
Generando...
<Loader className="w-5 h-5 mr-2 animate-spin" />
Generando Predicción...
</>
) : (
<>
<Zap className="w-4 h-4 mr-2" />
Generar Predicción
<Zap className="w-5 h-5 mr-2" />
Generar Predicción IA
</>
)}
</Button>
</div>
{!selectedProduct && (
<p className="text-xs text-[var(--text-tertiary)] mt-2 text-center">
Selecciona un ingrediente para continuar
</p>
)}
</Card>
</div>
</div>
{selectedProduct && (
<div className="mt-4 p-3 bg-[var(--color-info-50)] border border-[var(--color-info-200)] rounded-lg">
<p className="text-sm text-[var(--color-info-800)]">
<strong>Ingrediente seleccionado:</strong> {products.find(p => p.id === selectedProduct)?.name}
</p>
<p className="text-xs text-[var(--color-info-600)] mt-1">
Se generará una predicción de demanda para los próximos {forecastPeriod} días usando IA
</p>
</div>
)}
</Card>
{/* Results Section - Only show after generating forecast */}
{/* Results Section */}
{hasGeneratedForecast && forecasts.length > 0 && (
<>
{/* Enhanced Layout Structure */}
<div className="space-y-8">
{/* Key Metrics Row - Using StatsGrid */}
<StatsGrid
columns={4}
gap="md"
stats={[
{
title: "Confianza del Modelo",
value: `${averageConfidence}%`,
icon: Target,
variant: "success",
size: "sm"
},
{
title: "Demanda Total",
value: Math.round(totalDemand),
icon: TrendingUp,
variant: "info",
size: "sm",
subtitle: `próximos ${forecastPeriod} días`
},
{
title: "Días Predichos",
value: forecasts.length,
icon: Calendar,
variant: "default",
size: "sm"
},
{
title: "Variabilidad",
value: (Math.max(...forecasts.map(f => f.predicted_demand)) - Math.min(...forecasts.map(f => f.predicted_demand))).toFixed(1),
icon: BarChart3,
variant: "warning",
size: "sm"
}
]}
/>
{/* Main Content Grid */}
<div className="grid grid-cols-12 gap-6">
{/* Chart Section - Takes most space */}
<div className="col-span-12 lg:col-span-8">
<Card className="h-full">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-bold text-gray-900">Predicción de Demanda</h3>
<p className="text-sm text-gray-600 mt-1">
{products.find(p => p.id === selectedProduct)?.name} {forecastPeriod} días {forecasts.length} puntos
</p>
</div>
<div className="flex items-center space-x-3">
<div className="flex rounded-lg border border-gray-300 overflow-hidden">
<button
onClick={() => setViewMode('chart')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
viewMode === 'chart'
? 'bg-[var(--color-info-600)] text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
📊 Gráfico
</button>
<button
onClick={() => setViewMode('table')}
className={`px-4 py-2 text-sm font-medium transition-colors border-l ${
viewMode === 'table'
? 'bg-[var(--color-info-600)] text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
📋 Tabla
</button>
</div>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</div>
</div>
<div className="p-6">
{viewMode === 'chart' ? (
<DemandChart
data={forecasts}
product={selectedProduct}
period={forecastPeriod}
loading={isLoading}
error={hasError ? 'Error al cargar las predicciones' : null}
height={450}
title=""
/>
) : (
<ForecastTable forecasts={transformedForecasts} />
)}
</div>
</Card>
{/* Results Header */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-[var(--text-primary)]">Resultados de Predicción</h3>
<p className="text-[var(--text-secondary)]">
{products.find(p => p.id === selectedProduct)?.name} {forecastPeriod} días
</p>
</div>
{/* Right Sidebar - Insights */}
<div className="col-span-12 lg:col-span-4 space-y-6">
{/* Weather & External Factors */}
<div className="space-y-6">
{/* Forecast Insights */}
{currentInsights.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Factores que Afectan la Predicción</h3>
<div className="space-y-3">
{currentInsights.map((insight, index) => {
const IconComponent = insight.icon;
return (
<div key={index} className="flex items-start space-x-3 p-3 bg-[var(--bg-secondary)] rounded-lg">
<div className={`p-2 rounded-lg ${
insight.impact === 'positive' ? 'bg-[var(--color-success-100)] text-[var(--color-success-600)]' :
insight.impact === 'high' ? 'bg-[var(--color-error-100)] text-[var(--color-error-600)]' :
insight.impact === 'moderate' ? 'bg-[var(--color-warning-100)] text-[var(--color-warning-600)]' :
'bg-[var(--color-info-100)] text-[var(--color-info-600)]'
}`}>
<IconComponent className="w-4 h-4" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-[var(--text-primary)]">{insight.title}</p>
<p className="text-xs text-[var(--text-secondary)]">{insight.description}</p>
</div>
</div>
);
})}
</div>
</Card>
)}
{/* Weather Impact */}
{weatherImpact && (
<Card className="overflow-hidden">
<div className="bg-gradient-to-r from-[var(--color-warning-500)] to-[var(--color-error-500)] p-4">
<h3 className="text-lg font-bold text-white flex items-center">
<Sun className="w-5 h-5 mr-2" />
Clima ({weatherImpact.forecastDays} días)
</h3>
<p className="text-[var(--color-warning-100)] text-sm">Impacto meteorológico en la demanda</p>
</div>
<div className="p-4 space-y-4">
{/* Temperature Overview */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-[var(--color-warning-50)] p-3 rounded-lg border border-[var(--color-warning-200)]">
<div className="flex items-center justify-between">
<Thermometer className="w-4 h-4 text-[var(--color-warning-600)]" />
<span className="text-lg font-bold text-[var(--color-warning-800)]">{weatherImpact.avgTemperature}°C</span>
</div>
<p className="text-xs text-[var(--color-warning-600)] mt-1">Promedio</p>
</div>
<div className="bg-[var(--color-info-50)] p-3 rounded-lg border border-[var(--color-info-200)]">
<div className="text-center">
<p className="text-sm font-medium text-[var(--color-info-800)]">{weatherImpact.dominantWeather}</p>
<p className="text-xs text-[var(--color-info-600)] mt-1">Condición</p>
</div>
</div>
</div>
{/* Daily forecast - compact */}
<div className="space-y-1">
<p className="text-xs font-medium text-gray-700">Pronóstico detallado:</p>
<div className="max-h-24 overflow-y-auto space-y-1">
{weatherImpact.dailyForecasts.slice(0, 5).map((day, index) => (
<div key={index} className="flex items-center justify-between text-xs p-2 bg-gray-50 rounded">
<span className="text-gray-600 font-medium">
{new Date(day.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })}
</span>
<div className="flex items-center space-x-2">
<span className="text-[var(--color-warning-600)]">{day.temperature}°C</span>
<span className="text-[var(--color-info-600)] font-bold">{day.predicted_demand?.toFixed(0)}</span>
</div>
</div>
))}
</div>
</div>
</div>
</Card>
)}
{/* Model Information */}
{forecasts.length > 0 && (
<Card className="overflow-hidden">
<div className="bg-gradient-to-r from-[var(--chart-quinary)] to-[var(--color-info-700)] p-4">
<h3 className="text-lg font-bold text-white flex items-center">
<Brain className="w-5 h-5 mr-2" />
Modelo IA
</h3>
<p className="text-purple-100 text-sm">Información técnica del algoritmo</p>
</div>
<div className="p-4 space-y-3">
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center justify-between p-2 bg-[var(--color-info-50)] rounded border border-[var(--color-info-200)]">
<span className="text-sm font-medium text-[var(--color-info-800)]">Algoritmo</span>
<Badge variant="purple">{forecasts[0]?.algorithm || 'N/A'}</Badge>
</div>
<div className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-xs text-gray-600">Versión</span>
<span className="text-xs font-mono text-gray-800">{forecasts[0]?.model_version || 'N/A'}</span>
</div>
<div className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-xs text-gray-600">Tiempo</span>
<span className="text-xs font-mono text-gray-800">{forecasts[0]?.processing_time_ms || 'N/A'}ms</span>
</div>
</div>
</div>
</Card>
)}
<div className="flex items-center space-x-3">
<div className="flex rounded-lg border border-[var(--border-secondary)] overflow-hidden">
<Button
variant={viewMode === 'chart' ? 'primary' : 'outline'}
onClick={() => setViewMode('chart')}
size="sm"
>
<BarChart3 className="w-4 h-4 mr-1" />
Gráfico
</Button>
<Button
variant={viewMode === 'table' ? 'primary' : 'outline'}
onClick={() => setViewMode('table')}
size="sm"
>
<Package className="w-4 h-4 mr-1" />
Tabla
</Button>
</div>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</div>
</div>
</div>
{/* Chart or Table */}
<div className="min-h-96">
{viewMode === 'chart' ? (
<DemandChart
data={forecasts}
product={selectedProduct}
period={forecastPeriod}
loading={isLoading}
error={hasError ? 'Error al cargar las predicciones' : null}
height={400}
title=""
/>
) : (
<ForecastTable forecasts={transformedForecasts} />
)}
</div>
</Card>
{/* Insights */}
{currentInsights.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
<Activity className="w-5 h-5 mr-2" />
Factores de Influencia
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{currentInsights.map((insight, index) => {
const IconComponent = insight.icon;
return (
<div key={index} className="flex items-start space-x-3 p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className={`p-2 rounded-lg ${
insight.impact === 'positive' ? 'bg-[var(--color-success-100)] text-[var(--color-success-600)]' :
insight.impact === 'high' ? 'bg-[var(--color-error-100)] text-[var(--color-error-600)]' :
insight.impact === 'moderate' ? 'bg-[var(--color-warning-100)] text-[var(--color-warning-600)]' :
'bg-[var(--color-info-100)] text-[var(--color-info-600)]'
}`}>
<IconComponent className="w-4 h-4" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-[var(--text-primary)]">{insight.title}</p>
<p className="text-xs text-[var(--text-secondary)]">{insight.description}</p>
</div>
</div>
);
})}
</div>
</Card>
)}
</>
)}
{/* Detailed Forecasts Table */}
{!isLoading && !hasError && transformedForecasts.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
<Table
columns={forecastColumns}
data={transformedForecasts}
rowKey="id"
hover={true}
variant="default"
size="md"
/>
</Card>
)}
{/* Empty States */}
{/* Help Section - Only when no models available */}
{!isLoading && !hasError && products.length === 0 && (
<Card className="p-6 text-center">
<div className="py-8">
<TrendingUp className="h-12 w-12 text-[var(--color-warning-400)] mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-600 mb-2">No hay ingredientes con modelos entrenados</h3>
<p className="text-gray-500 mb-6">
Para generar predicciones, primero necesitas entrenar modelos de IA para tus ingredientes.
Ve a la página de Modelos IA para entrenar modelos para tus ingredientes.
<Brain className="h-12 w-12 text-[var(--color-info)] mx-auto mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay modelos entrenados
</h3>
<p className="text-[var(--text-secondary)] mb-6">
Para generar predicciones, necesitas entrenar modelos de IA para tus ingredientes.
</p>
<div className="flex justify-center space-x-4">
<Button
variant="primary"
onClick={() => {
window.location.href = '/app/database/models';
}}
>
<Settings className="w-4 h-4 mr-2" />
Configurar Modelos IA
</Button>
</div>
</div>
</Card>
)}
{!isLoading && !hasError && products.length > 0 && !hasGeneratedForecast && (
<Card className="p-6 text-center">
<div className="py-8">
<BarChart3 className="h-12 w-12 text-[var(--color-info-400)] mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-600 mb-2">Listo para Generar Predicciones</h3>
<p className="text-gray-500 mb-6">
Tienes {products.length} ingrediente{products.length > 1 ? 's' : ''} con modelos entrenados disponibles.
Selecciona un ingrediente y período para comenzar.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-2xl mx-auto">
<div className="text-center p-4 border rounded-lg">
<div className="bg-[var(--color-info-600)] text-white rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">1</div>
<p className="text-sm font-medium text-gray-600">Selecciona Ingrediente</p>
<p className="text-xs text-gray-500">Elige un ingrediente con modelo IA</p>
</div>
<div className="text-center p-4 border rounded-lg">
<div className="bg-gray-300 text-gray-600 rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">2</div>
<p className="text-sm font-medium text-gray-600">Define Período</p>
<p className="text-xs text-gray-500">Establece días a predecir</p>
</div>
<div className="text-center p-4 border rounded-lg">
<div className="bg-gray-300 text-gray-600 rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">3</div>
<p className="text-sm font-medium text-gray-600">Generar Predicción</p>
<p className="text-xs text-gray-500">Obtén insights de IA</p>
</div>
</div>
<Button
onClick={() => window.location.href = '/app/database/models'}
variant="primary"
>
<Settings className="w-4 h-4 mr-2" />
Entrenar Modelos IA
</Button>
</div>
</Card>
)}

View File

@@ -15,7 +15,7 @@ import {
// Import AddStockModal separately since we need it for adding batches
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useTenantId } from '../../../../hooks/useTenantId';
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
const InventoryPage: React.FC = () => {
@@ -30,8 +30,7 @@ const InventoryPage: React.FC = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAddBatch, setShowAddBatch] = useState(false);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const tenantId = useTenantId();
// Mutations
const createIngredientMutation = useCreateIngredient();

View File

@@ -1,16 +1,24 @@
import React, { useState } from 'react';
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt } from 'lucide-react';
import { Button, Input, Card, Badge } from '../../../../components/ui';
import React, { useState, useMemo } from 'react';
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock } from 'lucide-react';
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { LoadingSpinner } from '../../../../components/shared';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { ProductType, ProductCategory, IngredientResponse } from '../../../../api/types/inventory';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
category: string;
stock: number;
}
const POSPage: React.FC = () => {
const [cart, setCart] = useState<Array<{
id: string;
name: string;
price: number;
quantity: number;
category: string;
}>>([]);
const [cart, setCart] = useState<CartItem[]>([]);
const [selectedCategory, setSelectedCategory] = useState('all');
const [customerInfo, setCustomerInfo] = useState({
name: '',
@@ -20,73 +28,65 @@ const POSPage: React.FC = () => {
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
const [cashReceived, setCashReceived] = useState('');
const products = [
{
id: '1',
name: 'Pan de Molde Integral',
price: 4.50,
category: 'bread',
stock: 25,
image: '/api/placeholder/100/100',
},
{
id: '2',
name: 'Croissants de Mantequilla',
price: 1.50,
category: 'pastry',
stock: 32,
image: '/api/placeholder/100/100',
},
{
id: '3',
name: 'Baguette Francesa',
price: 2.80,
category: 'bread',
stock: 18,
image: '/api/placeholder/100/100',
},
{
id: '4',
name: 'Tarta de Chocolate',
price: 25.00,
category: 'cake',
stock: 8,
image: '/api/placeholder/100/100',
},
{
id: '5',
name: 'Magdalenas',
price: 0.75,
category: 'pastry',
stock: 48,
image: '/api/placeholder/100/100',
},
{
id: '6',
name: 'Empanadas',
price: 2.50,
category: 'other',
stock: 24,
image: '/api/placeholder/100/100',
},
];
const tenantId = useTenantId();
const categories = [
{ id: 'all', name: 'Todos' },
{ id: 'bread', name: 'Panes' },
{ id: 'pastry', name: 'Bollería' },
{ id: 'cake', name: 'Tartas' },
{ id: 'other', name: 'Otros' },
];
// Fetch finished products from API
const {
data: ingredientsData,
isLoading: productsLoading,
error: productsError
} = useIngredients(tenantId, {
// Filter for finished products only
category: undefined, // We'll filter client-side for now
search: undefined
});
const filteredProducts = products.filter(product =>
selectedCategory === 'all' || product.category === selectedCategory
);
// Filter for finished products and convert to POS format
const products = useMemo(() => {
if (!ingredientsData) return [];
return ingredientsData
.filter(ingredient => ingredient.product_type === ProductType.FINISHED_PRODUCT)
.map(ingredient => ({
id: ingredient.id,
name: ingredient.name,
price: Number(ingredient.average_cost) || 0,
category: ingredient.category.toLowerCase(),
stock: Number(ingredient.current_stock) || 0,
ingredient: ingredient
}))
.filter(product => product.stock > 0); // Only show products in stock
}, [ingredientsData]);
// Generate categories from actual product data
const categories = useMemo(() => {
const categoryMap = new Map();
categoryMap.set('all', { id: 'all', name: 'Todos' });
products.forEach(product => {
if (!categoryMap.has(product.category)) {
const categoryName = product.category.charAt(0).toUpperCase() + product.category.slice(1);
categoryMap.set(product.category, { id: product.category, name: categoryName });
}
});
return Array.from(categoryMap.values());
}, [products]);
const filteredProducts = useMemo(() => {
return products.filter(product =>
selectedCategory === 'all' || product.category === selectedCategory
);
}, [products, selectedCategory]);
const addToCart = (product: typeof products[0]) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
if (existingItem) {
// Check if we have enough stock
if (existingItem.quantity >= product.stock) {
return prevCart; // Don't add if no stock available
}
return prevCart.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
@@ -99,6 +99,7 @@ const POSPage: React.FC = () => {
price: product.price,
quantity: 1,
category: product.category,
stock: product.stock
}];
}
});
@@ -109,9 +110,14 @@ const POSPage: React.FC = () => {
setCart(prevCart => prevCart.filter(item => item.id !== id));
} else {
setCart(prevCart =>
prevCart.map(item =>
item.id === id ? { ...item, quantity } : item
)
prevCart.map(item => {
if (item.id === id) {
// Don't allow quantity to exceed stock
const maxQuantity = Math.min(quantity, item.stock);
return { ...item, quantity: maxQuantity };
}
return item;
})
);
}
};
@@ -128,8 +134,8 @@ const POSPage: React.FC = () => {
const processPayment = () => {
if (cart.length === 0) return;
// Process payment logic here
// TODO: Integrate with real POS API endpoint
console.log('Processing payment:', {
cart,
customerInfo,
@@ -143,53 +149,205 @@ const POSPage: React.FC = () => {
setCart([]);
setCustomerInfo({ name: '', email: '', phone: '' });
setCashReceived('');
alert('Venta procesada exitosamente');
};
// Calculate stats for the POS dashboard
const posStats = useMemo(() => {
const totalProducts = products.length;
const totalStock = products.reduce((sum, product) => sum + product.stock, 0);
const cartValue = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const cartItems = cart.reduce((sum, item) => sum + item.quantity, 0);
const lowStockProducts = products.filter(product => product.stock <= 5).length;
const avgProductPrice = totalProducts > 0 ? products.reduce((sum, product) => sum + product.price, 0) / totalProducts : 0;
return {
totalProducts,
totalStock,
cartValue,
cartItems,
lowStockProducts,
avgProductPrice
};
}, [products, cart]);
const stats = [
{
title: 'Productos Disponibles',
value: posStats.totalProducts,
variant: 'default' as const,
icon: Package,
},
{
title: 'Stock Total',
value: posStats.totalStock,
variant: 'info' as const,
icon: Package,
},
{
title: 'Artículos en Carrito',
value: posStats.cartItems,
variant: 'success' as const,
icon: ShoppingCart,
},
{
title: 'Valor del Carrito',
value: formatters.currency(posStats.cartValue),
variant: 'success' as const,
icon: Euro,
},
{
title: 'Stock Bajo',
value: posStats.lowStockProducts,
variant: 'warning' as const,
icon: Clock,
},
{
title: 'Precio Promedio',
value: formatters.currency(posStats.avgProductPrice),
variant: 'info' as const,
icon: TrendingUp,
},
];
// Loading and error states
if (productsLoading || !tenantId) {
return (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando productos..." />
</div>
);
}
if (productsError) {
return (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar productos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{productsError.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
return (
<div className="p-6 h-screen flex flex-col">
<div className="space-y-6">
<PageHeader
title="Punto de Venta"
description="Sistema de ventas integrado"
description="Sistema de ventas para productos terminados"
/>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Products Section */}
<div className="lg:col-span-2 space-y-6">
{/* Categories */}
<div className="flex space-x-2 overflow-x-auto">
{categories.map(category => (
<Button
key={category.id}
variant={selectedCategory === category.id ? 'default' : 'outline'}
onClick={() => setSelectedCategory(category.id)}
className="whitespace-nowrap"
>
{category.name}
</Button>
))}
</div>
<Card className="p-4">
<div className="flex space-x-2 overflow-x-auto">
{categories.map(category => (
<Button
key={category.id}
variant={selectedCategory === category.id ? 'default' : 'outline'}
onClick={() => setSelectedCategory(category.id)}
className="whitespace-nowrap"
>
{category.name}
</Button>
))}
</div>
</Card>
{/* Products Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{filteredProducts.map(product => (
<Card
key={product.id}
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => addToCart(product)}
>
<img
src={product.image}
alt={product.name}
className="w-full h-20 object-cover rounded mb-3"
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredProducts.map(product => {
const cartItem = cart.find(item => item.id === product.id);
const inCart = !!cartItem;
const cartQuantity = cartItem?.quantity || 0;
const remainingStock = product.stock - cartQuantity;
const getStockStatusConfig = () => {
if (remainingStock <= 0) {
return {
color: getStatusColor('cancelled'),
text: 'Sin Stock',
icon: Package,
isCritical: true,
isHighlight: false
};
} else if (remainingStock <= 5) {
return {
color: getStatusColor('pending'),
text: `${remainingStock} disponibles`,
icon: Package,
isCritical: false,
isHighlight: true
};
} else {
return {
color: getStatusColor('completed'),
text: `${remainingStock} disponibles`,
icon: Package,
isCritical: false,
isHighlight: false
};
}
};
return (
<StatusCard
key={product.id}
id={product.id}
statusIndicator={getStockStatusConfig()}
title={product.name}
subtitle={product.category.charAt(0).toUpperCase() + product.category.slice(1)}
primaryValue={formatters.currency(product.price)}
primaryValueLabel="precio"
secondaryInfo={inCart ? {
label: 'En carrito',
value: cartQuantity.toString()
} : undefined}
actions={[
{
label: 'Agregar al Carrito',
icon: Plus,
variant: 'primary',
priority: 'primary',
disabled: remainingStock <= 0,
onClick: () => addToCart(product)
}
]}
/>
<h3 className="font-medium text-sm mb-1 line-clamp-2">{product.name}</h3>
<p className="text-lg font-bold text-[var(--color-success)]">{product.price.toFixed(2)}</p>
<p className="text-xs text-[var(--text-tertiary)]">Stock: {product.stock}</p>
</Card>
))}
);
})}
</div>
{/* Empty State */}
{filteredProducts.length === 0 && (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay productos disponibles
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{selectedCategory === 'all'
? 'No hay productos en stock en este momento'
: `No hay productos en la categoría "${categories.find(c => c.id === selectedCategory)?.name}"`
}
</p>
</div>
)}
</div>
{/* Cart and Checkout Section */}
@@ -212,40 +370,47 @@ const POSPage: React.FC = () => {
{cart.length === 0 ? (
<p className="text-[var(--text-tertiary)] text-center py-8">Carrito vacío</p>
) : (
cart.map(item => (
<div key={item.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
<div className="flex-1">
<h4 className="text-sm font-medium">{item.name}</h4>
<p className="text-xs text-[var(--text-tertiary)]">{item.price.toFixed(2)} c/u</p>
cart.map(item => {
const product = products.find(p => p.id === item.id);
const maxQuantity = product?.stock || item.stock;
return (
<div key={item.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
<div className="flex-1">
<h4 className="text-sm font-medium">{item.name}</h4>
<p className="text-xs text-[var(--text-tertiary)]">{item.price.toFixed(2)} c/u</p>
<p className="text-xs text-[var(--text-tertiary)]">Stock: {maxQuantity}</p>
</div>
<div className="flex items-center space-x-2">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
updateQuantity(item.id, item.quantity - 1);
}}
>
<Minus className="w-3 h-3" />
</Button>
<span className="w-8 text-center text-sm">{item.quantity}</span>
<Button
size="sm"
variant="outline"
disabled={item.quantity >= maxQuantity}
onClick={(e) => {
e.stopPropagation();
updateQuantity(item.id, item.quantity + 1);
}}
>
<Plus className="w-3 h-3" />
</Button>
</div>
<div className="ml-4 text-right">
<p className="text-sm font-medium">{(item.price * item.quantity).toFixed(2)}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
updateQuantity(item.id, item.quantity - 1);
}}
>
<Minus className="w-3 h-3" />
</Button>
<span className="w-8 text-center text-sm">{item.quantity}</span>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
updateQuantity(item.id, item.quantity + 1);
}}
>
<Plus className="w-3 h-3" />
</Button>
</div>
<div className="ml-4 text-right">
<p className="text-sm font-medium">{(item.price * item.quantity).toFixed(2)}</p>
</div>
</div>
))
);
})
)}
</div>
@@ -305,7 +470,7 @@ const POSPage: React.FC = () => {
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2">
<Button
variant={paymentMethod === 'cash' ? 'default' : 'outline'}
variant={paymentMethod === 'cash' ? 'primary' : 'outline'}
onClick={() => setPaymentMethod('cash')}
className="flex items-center justify-center"
>
@@ -313,7 +478,7 @@ const POSPage: React.FC = () => {
Efectivo
</Button>
<Button
variant={paymentMethod === 'card' ? 'default' : 'outline'}
variant={paymentMethod === 'card' ? 'primary' : 'outline'}
onClick={() => setPaymentMethod('card')}
className="flex items-center justify-center"
>
@@ -321,7 +486,7 @@ const POSPage: React.FC = () => {
Tarjeta
</Button>
<Button
variant={paymentMethod === 'transfer' ? 'default' : 'outline'}
variant={paymentMethod === 'transfer' ? 'primary' : 'outline'}
onClick={() => setPaymentMethod('transfer')}
className="flex items-center justify-center"
>

View File

@@ -40,7 +40,7 @@ interface BusinessHours {
const BakeryConfigPage: React.FC = () => {
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const { loadUserTenants } = useTenantActions();
const { loadUserTenants, setCurrentTenant } = useTenantActions();
const tenantId = currentTenant?.id || '';
// Use the current tenant from the store instead of making additional API calls
@@ -83,12 +83,10 @@ const BakeryConfigPage: React.FC = () => {
language: 'es'
});
// Load user tenants on component mount if no current tenant
// Load user tenants on component mount to ensure fresh data
React.useEffect(() => {
if (!currentTenant) {
loadUserTenants();
}
}, [currentTenant, loadUserTenants]);
loadUserTenants();
}, [loadUserTenants]);
// Update config when tenant data is loaded
@@ -97,8 +95,8 @@ const BakeryConfigPage: React.FC = () => {
setConfig({
name: tenant.name || '',
description: tenant.description || '',
email: tenant.email || '', // Fixed: use email instead of contact_email
phone: tenant.phone || '', // Fixed: use phone instead of contact_phone
email: tenant.email || '',
phone: tenant.phone || '',
website: tenant.website || '',
address: tenant.address || '',
city: tenant.city || '',
@@ -106,9 +104,10 @@ const BakeryConfigPage: React.FC = () => {
country: tenant.country || '',
taxId: '', // Not supported by backend yet
currency: 'EUR', // Default value
timezone: 'Europe/Madrid', // Default value
timezone: 'Europe/Madrid', // Default value
language: 'es' // Default value
});
setHasUnsavedChanges(false); // Reset unsaved changes when loading fresh data
}
}, [tenant]);
@@ -249,30 +248,48 @@ const BakeryConfigPage: React.FC = () => {
const handleSaveConfig = async () => {
if (!validateConfig() || !tenantId) return;
setIsLoading(true);
try {
await updateTenantMutation.mutateAsync({
const updateData = {
name: config.name,
description: config.description,
email: config.email,
phone: config.phone,
website: config.website,
address: config.address,
city: config.city,
postal_code: config.postalCode,
country: config.country
};
const updatedTenant = await updateTenantMutation.mutateAsync({
tenantId,
updateData: {
name: config.name,
description: config.description,
email: config.email, // Fixed: use email instead of contact_email
phone: config.phone, // Fixed: use phone instead of contact_phone
website: config.website,
address: config.address,
city: config.city,
postal_code: config.postalCode,
country: config.country
// Note: tax_id, currency, timezone, language might not be supported by backend
}
updateData
});
// Update the tenant store with the new data
if (updatedTenant) {
setCurrentTenant(updatedTenant);
// Force reload tenant list to ensure cache consistency
await loadUserTenants();
// Update localStorage to persist the changes
const tenantStorage = localStorage.getItem('tenant-storage');
if (tenantStorage) {
const parsedStorage = JSON.parse(tenantStorage);
if (parsedStorage.state && parsedStorage.state.currentTenant) {
parsedStorage.state.currentTenant = updatedTenant;
localStorage.setItem('tenant-storage', JSON.stringify(parsedStorage));
}
}
}
setHasUnsavedChanges(false);
addToast('Configuración actualizada correctamente', { type: 'success' });
} catch (error) {
addToast('No se pudo actualizar la configuración', { type: 'error' });
addToast(`Error al actualizar: ${error instanceof Error ? error.message : 'Error desconocido'}`, { type: 'error' });
} finally {
setIsLoading(false);
}

View File

@@ -1,9 +1,9 @@
import React, { useState, useMemo } from 'react';
import { Users, Plus, Search, Mail, Phone, Shield, Trash2, Crown, X, UserCheck } from 'lucide-react';
import { Button, Card, Badge, Input, StatusCard, getStatusColor, StatsGrid } from '../../../../components/ui';
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-react';
import { Button, Card, Input, StatusCard, getStatusColor, StatsGrid } from '../../../../components/ui';
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
import { PageHeader } from '../../../../components/layout';
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole } from '../../../../api/hooks/tenant';
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
import { useAllUsers } from '../../../../api/hooks/user';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
@@ -12,12 +12,23 @@ import { TENANT_ROLES } from '../../../../types/roles';
const TeamPage: React.FC = () => {
const { addToast } = useToast();
const currentUser = useAuthUser();
const currentTenant = useCurrentTenant();
const currentTenantAccess = useCurrentTenantAccess();
const tenantId = currentTenant?.id || '';
// Try to get tenant access directly via hook as fallback
const { data: directTenantAccess } = useTenantAccess(
tenantId,
currentUser?.id || '',
{ enabled: !!tenantId && !!currentUser?.id && !currentTenantAccess }
);
const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
const { data: allUsers = [] } = useAllUsers();
const { data: allUsers = [], error: allUsersError, isLoading: allUsersLoading } = useAllUsers({
retry: false, // Don't retry on permission errors
staleTime: 0 // Always fresh check for permissions
});
// Mutations
const addMemberMutation = useAddTeamMember();
@@ -72,9 +83,15 @@ const TeamPage: React.FC = () => {
{ value: TENANT_ROLES.VIEWER, label: 'Observador', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.VIEWER).length }
];
// Use direct tenant access as fallback
const effectiveTenantAccess = currentTenantAccess || directTenantAccess;
// Check if current user is the tenant owner (fallback when access endpoint fails)
const isCurrentUserOwner = currentUser?.id === currentTenant?.owner_id;
// Permission checks
const isOwner = currentTenantAccess?.role === TENANT_ROLES.OWNER;
const canManageTeam = isOwner || currentTenantAccess?.role === TENANT_ROLES.ADMIN;
const isOwner = effectiveTenantAccess?.role === TENANT_ROLES.OWNER || isCurrentUserOwner;
const canManageTeam = isOwner || effectiveTenantAccess?.role === TENANT_ROLES.ADMIN;
const teamStats = {
total: enhancedTeamMembers.length,
@@ -197,29 +214,38 @@ const TeamPage: React.FC = () => {
});
// Available users for adding (exclude current members)
const availableUsers = allUsers.filter(u =>
const availableUsers = allUsers.filter(u =>
!enhancedTeamMembers.some(m => m.user_id === u.id)
);
// Member action handlers
const handleAddMember = async () => {
if (!selectedUserToAdd || !selectedRoleToAdd || !tenantId) return;
try {
await addMemberMutation.mutateAsync({
tenantId,
userId: selectedUserToAdd,
role: selectedRoleToAdd,
});
addToast('Miembro agregado exitosamente', { type: 'success' });
setShowAddForm(false);
setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
} catch (error) {
addToast('Error al agregar miembro', { type: 'error' });
// Force reload tenant access if missing
React.useEffect(() => {
if (currentTenant?.id && !currentTenantAccess) {
console.log('Forcing tenant access reload for tenant:', currentTenant.id);
// You can trigger a manual reload here if needed
}
};
}, [currentTenant?.id, currentTenantAccess]);
// Debug logging
console.log('TeamPage Debug:', {
canManageTeam,
isOwner,
isCurrentUserOwner,
currentUser: currentUser?.id,
currentTenant: currentTenant?.id,
tenantOwner: currentTenant?.owner_id,
currentTenantAccess,
directTenantAccess,
effectiveTenantAccess,
tenantAccess: effectiveTenantAccess?.role,
allUsers: allUsers.length,
allUsersError,
allUsersLoading,
availableUsers: availableUsers.length,
enhancedTeamMembers: enhancedTeamMembers.length
});
// Member action handlers - removed unused handleAddMember since modal handles it directly
const handleRemoveMember = async (memberUserId: string) => {
if (!tenantId) return;
@@ -276,7 +302,7 @@ const TeamPage: React.FC = () => {
title="Gestión de Equipo"
description="Administra los miembros del equipo, roles y permisos"
actions={
canManageTeam && availableUsers.length > 0 ? [{
canManageTeam ? [{
id: 'add-member',
label: 'Agregar Miembro',
icon: Plus,
@@ -351,7 +377,7 @@ const TeamPage: React.FC = () => {
</Card>
{/* Add Member Button */}
{canManageTeam && availableUsers.length > 0 && filteredMembers.length > 0 && (
{canManageTeam && filteredMembers.length > 0 && (
<div className="flex justify-end">
<Button
onClick={() => setShowAddForm(true)}
@@ -406,7 +432,7 @@ const TeamPage: React.FC = () => {
: "Este tenant aún no tiene miembros del equipo"
}
</p>
{canManageTeam && availableUsers.length > 0 && (
{canManageTeam && (
<Button
onClick={() => setShowAddForm(true)}
variant="primary"