New enterprise feature
This commit is contained in:
372
frontend/src/pages/app/EnterpriseDashboardPage.tsx
Normal file
372
frontend/src/pages/app/EnterpriseDashboardPage.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
/*
|
||||
* Enterprise Dashboard Page
|
||||
* Main dashboard for enterprise parent tenants showing network-wide metrics
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import {
|
||||
useNetworkSummary,
|
||||
useChildrenPerformance,
|
||||
useDistributionOverview,
|
||||
useForecastSummary
|
||||
} from '../../api/hooks/enterprise';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import {
|
||||
Users,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
MapPin,
|
||||
Truck,
|
||||
Package,
|
||||
BarChart3,
|
||||
Network,
|
||||
Store,
|
||||
Activity,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
PackageCheck,
|
||||
Building2,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { apiClient } from '../../api/client/apiClient';
|
||||
|
||||
// Components for enterprise dashboard
|
||||
const NetworkSummaryCards = React.lazy(() => import('../../components/dashboard/NetworkSummaryCards'));
|
||||
const DistributionMap = React.lazy(() => import('../../components/maps/DistributionMap'));
|
||||
const PerformanceChart = React.lazy(() => import('../../components/charts/PerformanceChart'));
|
||||
|
||||
const EnterpriseDashboardPage = () => {
|
||||
const { tenantId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('dashboard');
|
||||
|
||||
const [selectedMetric, setSelectedMetric] = useState('sales');
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(30);
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
// Check if user has enterprise tier access
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<{ tenant_type: string }>(`/tenants/${tenantId}`);
|
||||
|
||||
if (response.tenant_type !== 'parent') {
|
||||
navigate('/unauthorized');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Access check failed:', error);
|
||||
navigate('/unauthorized');
|
||||
}
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
}, [tenantId, navigate]);
|
||||
|
||||
// Fetch network summary data
|
||||
const {
|
||||
data: networkSummary,
|
||||
isLoading: isNetworkSummaryLoading,
|
||||
error: networkSummaryError
|
||||
} = useNetworkSummary(tenantId!, {
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
});
|
||||
|
||||
// Fetch children performance data
|
||||
const {
|
||||
data: childrenPerformance,
|
||||
isLoading: isChildrenPerformanceLoading,
|
||||
error: childrenPerformanceError
|
||||
} = useChildrenPerformance(tenantId!, selectedMetric, selectedPeriod);
|
||||
|
||||
// Fetch distribution overview data
|
||||
const {
|
||||
data: distributionOverview,
|
||||
isLoading: isDistributionLoading,
|
||||
error: distributionError
|
||||
} = useDistributionOverview(tenantId!, selectedDate, {
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
});
|
||||
|
||||
// Fetch enterprise forecast summary
|
||||
const {
|
||||
data: forecastSummary,
|
||||
isLoading: isForecastLoading,
|
||||
error: forecastError
|
||||
} = useForecastSummary(tenantId!);
|
||||
|
||||
// Error boundary fallback
|
||||
const ErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) => (
|
||||
<div className="p-6 text-center">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Something went wrong</h3>
|
||||
<p className="text-gray-500 mb-4">{error.message}</p>
|
||||
<Button onClick={resetErrorBoundary}>Try again</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isNetworkSummaryLoading || isChildrenPerformanceLoading || isDistributionLoading || isForecastLoading) {
|
||||
return (
|
||||
<div className="p-6 min-h-screen">
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<LoadingSpinner text={t('enterprise.loading')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (networkSummaryError || childrenPerformanceError || distributionError || forecastError) {
|
||||
return (
|
||||
<div className="p-6 min-h-screen">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-red-800 mb-2">Error Loading Dashboard</h3>
|
||||
<p className="text-red-600">
|
||||
{networkSummaryError?.message ||
|
||||
childrenPerformanceError?.message ||
|
||||
distributionError?.message ||
|
||||
forecastError?.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<div className="p-6 min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Network className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{t('enterprise.network_dashboard')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
{t('enterprise.network_summary_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Network Summary Cards */}
|
||||
<div className="mb-8">
|
||||
<NetworkSummaryCards
|
||||
data={networkSummary}
|
||||
isLoading={isNetworkSummaryLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Distribution Map and Performance Chart Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Distribution Map */}
|
||||
<div>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="w-5 h-5 text-blue-600" />
|
||||
<CardTitle>{t('enterprise.distribution_map')}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{distributionOverview ? (
|
||||
<DistributionMap
|
||||
routes={distributionOverview.route_sequences}
|
||||
shipments={distributionOverview.status_counts}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-96 flex items-center justify-center text-gray-500">
|
||||
{t('enterprise.no_distribution_data')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Performance Chart */}
|
||||
<div>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-green-600" />
|
||||
<CardTitle>{t('enterprise.outlet_performance')}</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="sales">{t('enterprise.metrics.sales')}</option>
|
||||
<option value="inventory_value">{t('enterprise.metrics.inventory_value')}</option>
|
||||
<option value="order_frequency">{t('enterprise.metrics.order_frequency')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value={7}>{t('enterprise.last_7_days')}</option>
|
||||
<option value={30}>{t('enterprise.last_30_days')}</option>
|
||||
<option value={90}>{t('enterprise.last_90_days')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{childrenPerformance ? (
|
||||
<PerformanceChart
|
||||
data={childrenPerformance.rankings}
|
||||
metric={selectedMetric}
|
||||
period={selectedPeriod}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-96 flex items-center justify-center text-gray-500">
|
||||
{t('enterprise.no_performance_data')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Forecast Summary */}
|
||||
<div className="mb-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-purple-600" />
|
||||
<CardTitle>{t('enterprise.network_forecast')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{forecastSummary && forecastSummary.aggregated_forecasts ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Package className="w-4 h-4 text-blue-600" />
|
||||
<h3 className="font-semibold text-blue-800">{t('enterprise.total_demand')}</h3>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-900">
|
||||
{Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
|
||||
total + Object.values(day).reduce((dayTotal: number, product: any) =>
|
||||
dayTotal + (product.predicted_demand || 0), 0), 0
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar className="w-4 h-4 text-green-600" />
|
||||
<h3 className="font-semibold text-green-800">{t('enterprise.days_forecast')}</h3>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-900">
|
||||
{forecastSummary.days_forecast || 7}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Activity className="w-4 h-4 text-purple-600" />
|
||||
<h3 className="font-semibold text-purple-800">{t('enterprise.avg_daily_demand')}</h3>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-purple-900">
|
||||
{forecastSummary.aggregated_forecasts
|
||||
? Math.round(Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
|
||||
total + Object.values(day).reduce((dayTotal: number, product: any) =>
|
||||
dayTotal + (product.predicted_demand || 0), 0), 0) /
|
||||
Object.keys(forecastSummary.aggregated_forecasts).length
|
||||
).toLocaleString()
|
||||
: 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-yellow-600" />
|
||||
<h3 className="font-semibold text-yellow-800">{t('enterprise.last_updated')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-900">
|
||||
{forecastSummary.last_updated ?
|
||||
new Date(forecastSummary.last_updated).toLocaleTimeString() :
|
||||
'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 text-gray-500">
|
||||
{t('enterprise.no_forecast_data')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Building2 className="w-6 h-6 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold">Agregar Punto de Venta</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">Añadir un nuevo outlet a la red enterprise</p>
|
||||
<Button
|
||||
onClick={() => navigate(`/app/tenants/${tenantId}/settings/organization`)}
|
||||
className="w-full"
|
||||
>
|
||||
Crear Outlet
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<PackageCheck className="w-6 h-6 text-green-600" />
|
||||
<h3 className="text-lg font-semibold">Transferencias Internas</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">Gestionar pedidos entre obrador central y outlets</p>
|
||||
<Button
|
||||
onClick={() => navigate(`/app/tenants/${tenantId}/procurement/internal-transfers`)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Ver Transferencias
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<MapPin className="w-6 h-6 text-red-600" />
|
||||
<h3 className="text-lg font-semibold">Rutas de Distribución</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">Optimizar rutas de entrega entre ubicaciones</p>
|
||||
<Button
|
||||
onClick={() => navigate(`/app/tenants/${tenantId}/distribution/routes`)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Ver Rutas
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnterpriseDashboardPage;
|
||||
@@ -151,13 +151,17 @@ const DemoPage = () => {
|
||||
|
||||
const getLoadingMessage = (tier, progress) => {
|
||||
if (tier === 'enterprise') {
|
||||
if (progress < 25) return 'Creando obrador central...';
|
||||
if (progress < 50) return 'Configurando puntos de venta...';
|
||||
if (progress < 75) return 'Generando rutas de distribución...';
|
||||
if (progress < 15) return 'Preparando entorno enterprise...';
|
||||
if (progress < 35) return 'Creando obrador central en Madrid...';
|
||||
if (progress < 55) return 'Configurando outlets en Barcelona, Valencia y Bilbao...';
|
||||
if (progress < 75) return 'Generando rutas de distribución optimizadas...';
|
||||
if (progress < 90) return 'Configurando red de distribución...';
|
||||
return 'Finalizando configuración enterprise...';
|
||||
} else {
|
||||
if (progress < 50) return 'Configurando tu panadería...';
|
||||
return 'Cargando datos de demostración...';
|
||||
if (progress < 30) return 'Preparando tu panadería...';
|
||||
if (progress < 60) return 'Configurando inventario y recetas...';
|
||||
if (progress < 85) return 'Generando datos de ventas y producción...';
|
||||
return 'Finalizando configuración...';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,8 +384,13 @@ const DemoPage = () => {
|
||||
};
|
||||
|
||||
const updateProgressFromBackendStatus = (statusData, tier) => {
|
||||
// Calculate progress based on the actual status from backend
|
||||
if (statusData.progress) {
|
||||
// IMPORTANT: Backend only provides progress AFTER cloning completes
|
||||
// During cloning (status=PENDING), progress is empty {}
|
||||
// So we rely on estimated progress for visual feedback
|
||||
|
||||
const hasRealProgress = statusData.progress && Object.keys(statusData.progress).length > 0;
|
||||
|
||||
if (hasRealProgress) {
|
||||
if (tier === 'enterprise') {
|
||||
// Handle enterprise progress structure which may be different
|
||||
// Enterprise demos may have a different progress structure with parent, children, distribution
|
||||
@@ -391,12 +400,29 @@ const DemoPage = () => {
|
||||
handleIndividualProgress(statusData.progress);
|
||||
}
|
||||
} else {
|
||||
// If no detailed progress available, use estimated progress or increment gradually
|
||||
// No detailed progress available - backend is still cloning
|
||||
// Use estimated progress for smooth visual feedback
|
||||
// This is NORMAL during the cloning phase
|
||||
setCloneProgress(prev => {
|
||||
const newProgress = Math.max(
|
||||
estimatedProgress,
|
||||
Math.min(prev.overall + 2, 95) // Increment by 2% instead of 1%
|
||||
prev.overall // Never go backward
|
||||
);
|
||||
|
||||
// For enterprise, also update sub-components based on estimated progress
|
||||
if (tier === 'enterprise') {
|
||||
return {
|
||||
parent: Math.min(95, Math.round(estimatedProgress * 0.4)), // 40% weight
|
||||
children: [
|
||||
Math.min(95, Math.round(estimatedProgress * 0.35)),
|
||||
Math.min(95, Math.round(estimatedProgress * 0.35)),
|
||||
Math.min(95, Math.round(estimatedProgress * 0.35))
|
||||
],
|
||||
distribution: Math.min(95, Math.round(estimatedProgress * 0.25)), // 25% weight
|
||||
overall: newProgress
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
overall: newProgress
|
||||
|
||||
Reference in New Issue
Block a user