New enterprise feature
This commit is contained in:
@@ -102,6 +102,9 @@ class ApiClient {
|
||||
// Only add auth token for non-public endpoints
|
||||
if (this.authToken && !isPublicEndpoint) {
|
||||
config.headers.Authorization = `Bearer ${this.authToken}`;
|
||||
console.log('🔑 [API Client] Adding Authorization header for:', config.url);
|
||||
} else if (!isPublicEndpoint) {
|
||||
console.warn('⚠️ [API Client] No auth token available for:', config.url, 'authToken:', this.authToken ? 'exists' : 'missing');
|
||||
}
|
||||
|
||||
// Add tenant ID only for endpoints that require it
|
||||
@@ -343,7 +346,9 @@ class ApiClient {
|
||||
|
||||
// Configuration methods
|
||||
setAuthToken(token: string | null) {
|
||||
console.log('🔧 [API Client] setAuthToken called:', token ? `${token.substring(0, 20)}...` : 'null');
|
||||
this.authToken = token;
|
||||
console.log('✅ [API Client] authToken is now:', this.authToken ? 'set' : 'null');
|
||||
}
|
||||
|
||||
setRefreshToken(token: string | null) {
|
||||
|
||||
89
frontend/src/api/hooks/enterprise.ts
Normal file
89
frontend/src/api/hooks/enterprise.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { enterpriseService, NetworkSummary, ChildPerformance, DistributionOverview, ForecastSummary, NetworkPerformance } from '../services/enterprise';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const enterpriseKeys = {
|
||||
all: ['enterprise'] as const,
|
||||
networkSummary: (tenantId: string) => [...enterpriseKeys.all, 'network-summary', tenantId] as const,
|
||||
childrenPerformance: (tenantId: string, metric: string, period: number) =>
|
||||
[...enterpriseKeys.all, 'children-performance', tenantId, metric, period] as const,
|
||||
distributionOverview: (tenantId: string, date?: string) =>
|
||||
[...enterpriseKeys.all, 'distribution-overview', tenantId, date] as const,
|
||||
forecastSummary: (tenantId: string, days: number) =>
|
||||
[...enterpriseKeys.all, 'forecast-summary', tenantId, days] as const,
|
||||
networkPerformance: (tenantId: string, startDate?: string, endDate?: string) =>
|
||||
[...enterpriseKeys.all, 'network-performance', tenantId, startDate, endDate] as const,
|
||||
} as const;
|
||||
|
||||
// Hooks
|
||||
|
||||
export const useNetworkSummary = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<NetworkSummary, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<NetworkSummary, ApiError>({
|
||||
queryKey: enterpriseKeys.networkSummary(tenantId),
|
||||
queryFn: () => enterpriseService.getNetworkSummary(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useChildrenPerformance = (
|
||||
tenantId: string,
|
||||
metric: string,
|
||||
period: number,
|
||||
options?: Omit<UseQueryOptions<ChildPerformance, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ChildPerformance, ApiError>({
|
||||
queryKey: enterpriseKeys.childrenPerformance(tenantId, metric, period),
|
||||
queryFn: () => enterpriseService.getChildrenPerformance(tenantId, metric, period),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 60000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDistributionOverview = (
|
||||
tenantId: string,
|
||||
targetDate?: string,
|
||||
options?: Omit<UseQueryOptions<DistributionOverview, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<DistributionOverview, ApiError>({
|
||||
queryKey: enterpriseKeys.distributionOverview(tenantId, targetDate),
|
||||
queryFn: () => enterpriseService.getDistributionOverview(tenantId, targetDate),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useForecastSummary = (
|
||||
tenantId: string,
|
||||
daysAhead: number = 7,
|
||||
options?: Omit<UseQueryOptions<ForecastSummary, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ForecastSummary, ApiError>({
|
||||
queryKey: enterpriseKeys.forecastSummary(tenantId, daysAhead),
|
||||
queryFn: () => enterpriseService.getForecastSummary(tenantId, daysAhead),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 120000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useNetworkPerformance = (
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<NetworkPerformance, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<NetworkPerformance, ApiError>({
|
||||
queryKey: enterpriseKeys.networkPerformance(tenantId, startDate, endDate),
|
||||
queryFn: () => enterpriseService.getNetworkPerformance(tenantId, startDate, endDate),
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
* Subscription hook for checking plan features and limits
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { subscriptionService } from '../services/subscription';
|
||||
import {
|
||||
SUBSCRIPTION_TIERS,
|
||||
@@ -41,7 +41,7 @@ export const useSubscription = () => {
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
const { notifySubscriptionChanged, subscriptionVersion } = useSubscriptionEvents();
|
||||
@@ -72,7 +72,7 @@ export const useSubscription = () => {
|
||||
error: 'Failed to load subscription data'
|
||||
}));
|
||||
}
|
||||
}, [tenantId]); // Removed notifySubscriptionChanged - it's now stable from context
|
||||
}, [tenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscriptionData();
|
||||
@@ -99,7 +99,7 @@ export const useSubscription = () => {
|
||||
|
||||
// Check analytics access level
|
||||
const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => {
|
||||
const { plan } = subscriptionInfo;
|
||||
const plan = subscriptionInfo.plan;
|
||||
|
||||
// Convert plan string to typed SubscriptionTier
|
||||
let tierKey: SubscriptionTier | undefined;
|
||||
|
||||
104
frontend/src/api/services/enterprise.ts
Normal file
104
frontend/src/api/services/enterprise.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export interface NetworkSummary {
|
||||
parent_tenant_id: string;
|
||||
total_tenants: number;
|
||||
child_tenant_count: number;
|
||||
total_revenue: number;
|
||||
network_sales_30d: number;
|
||||
active_alerts: number;
|
||||
efficiency_score: number;
|
||||
growth_rate: number;
|
||||
production_volume_30d: number;
|
||||
pending_internal_transfers_count: number;
|
||||
active_shipments_count: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface ChildPerformance {
|
||||
rankings: Array<{
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
anonymized_name: string;
|
||||
metric_value: number;
|
||||
rank: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DistributionOverview {
|
||||
route_sequences: any[];
|
||||
status_counts: {
|
||||
pending: number;
|
||||
in_transit: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
[key: string]: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ForecastSummary {
|
||||
aggregated_forecasts: Record<string, any>;
|
||||
days_forecast: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface NetworkPerformance {
|
||||
metrics: Record<string, any>;
|
||||
}
|
||||
|
||||
export class EnterpriseService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
async getNetworkSummary(tenantId: string): Promise<NetworkSummary> {
|
||||
return apiClient.get<NetworkSummary>(`${this.baseUrl}/${tenantId}/enterprise/network-summary`);
|
||||
}
|
||||
|
||||
async getChildrenPerformance(
|
||||
tenantId: string,
|
||||
metric: string = 'sales',
|
||||
periodDays: number = 30
|
||||
): Promise<ChildPerformance> {
|
||||
const queryParams = new URLSearchParams({
|
||||
metric,
|
||||
period_days: periodDays.toString()
|
||||
});
|
||||
return apiClient.get<ChildPerformance>(
|
||||
`${this.baseUrl}/${tenantId}/enterprise/children-performance?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getDistributionOverview(tenantId: string, targetDate?: string): Promise<DistributionOverview> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (targetDate) {
|
||||
queryParams.append('target_date', targetDate);
|
||||
}
|
||||
return apiClient.get<DistributionOverview>(
|
||||
`${this.baseUrl}/${tenantId}/enterprise/distribution-overview?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getForecastSummary(tenantId: string, daysAhead: number = 7): Promise<ForecastSummary> {
|
||||
const queryParams = new URLSearchParams({
|
||||
days_ahead: daysAhead.toString()
|
||||
});
|
||||
return apiClient.get<ForecastSummary>(
|
||||
`${this.baseUrl}/${tenantId}/enterprise/forecast-summary?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getNetworkPerformance(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<NetworkPerformance> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
return apiClient.get<NetworkPerformance>(
|
||||
`${this.baseUrl}/${tenantId}/enterprise/network-performance?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const enterpriseService = new EnterpriseService();
|
||||
@@ -23,10 +23,11 @@ import {
|
||||
} from '../types/subscription';
|
||||
|
||||
// Map plan tiers to analytics levels based on backend data
|
||||
const TIER_TO_ANALYTICS_LEVEL: Record<SubscriptionTier, AnalyticsLevel> = {
|
||||
const TIER_TO_ANALYTICS_LEVEL: Record<SubscriptionTier | string, AnalyticsLevel> = {
|
||||
[SUBSCRIPTION_TIERS.STARTER]: ANALYTICS_LEVELS.BASIC,
|
||||
[SUBSCRIPTION_TIERS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
|
||||
[SUBSCRIPTION_TIERS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE
|
||||
[SUBSCRIPTION_TIERS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE,
|
||||
'demo': ANALYTICS_LEVELS.ADVANCED, // Treat demo tier same as professional for analytics access
|
||||
};
|
||||
|
||||
// Cache for available plans
|
||||
|
||||
148
frontend/src/components/charts/PerformanceChart.tsx
Normal file
148
frontend/src/components/charts/PerformanceChart.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Performance Chart Component for Enterprise Dashboard
|
||||
* Shows anonymized performance ranking of child outlets
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { BarChart3, TrendingUp, TrendingDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PerformanceDataPoint {
|
||||
rank: number;
|
||||
tenant_id: string;
|
||||
anonymized_name: string; // "Outlet 1", "Outlet 2", etc.
|
||||
metric_value: number;
|
||||
original_name?: string; // Only for internal use, not displayed
|
||||
}
|
||||
|
||||
interface PerformanceChartProps {
|
||||
data: PerformanceDataPoint[];
|
||||
metric: string;
|
||||
period: number;
|
||||
}
|
||||
|
||||
const PerformanceChart: React.FC<PerformanceChartProps> = ({
|
||||
data = [],
|
||||
metric,
|
||||
period
|
||||
}) => {
|
||||
const { t } = useTranslation('dashboard');
|
||||
|
||||
// Get metric info
|
||||
const getMetricInfo = () => {
|
||||
switch (metric) {
|
||||
case 'sales':
|
||||
return {
|
||||
icon: <TrendingUp className="w-4 h-4" />,
|
||||
label: t('enterprise.metrics.sales'),
|
||||
unit: '€',
|
||||
format: (val: number) => val.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
};
|
||||
case 'inventory_value':
|
||||
return {
|
||||
icon: <Package className="w-4 h-4" />,
|
||||
label: t('enterprise.metrics.inventory_value'),
|
||||
unit: '€',
|
||||
format: (val: number) => val.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
};
|
||||
case 'order_frequency':
|
||||
return {
|
||||
icon: <ShoppingCart className="w-4 h-4" />,
|
||||
label: t('enterprise.metrics.order_frequency'),
|
||||
unit: '',
|
||||
format: (val: number) => Math.round(val).toString()
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <BarChart3 className="w-4 h-4" />,
|
||||
label: metric,
|
||||
unit: '',
|
||||
format: (val: number) => val.toString()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const metricInfo = getMetricInfo();
|
||||
|
||||
// Calculate max value for bar scaling
|
||||
const maxValue = data.length > 0 ? Math.max(...data.map(item => item.metric_value), 1) : 1;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-blue-600" />
|
||||
<CardTitle>{t('enterprise.outlet_performance')}</CardTitle>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('enterprise.performance_based_on_period', {
|
||||
metric: t(`enterprise.metrics.${metric}`) || metric,
|
||||
period
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{data.map((item, index) => {
|
||||
const percentage = (item.metric_value / maxValue) * 100;
|
||||
const isTopPerformer = index === 0;
|
||||
|
||||
return (
|
||||
<div key={item.tenant_id} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
isTopPerformer
|
||||
? 'bg-yellow-100 text-yellow-800 border border-yellow-300'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{item.rank}
|
||||
</div>
|
||||
<span className="font-medium">{item.anonymized_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">
|
||||
{metricInfo.unit}{metricInfo.format(item.metric_value)}
|
||||
</span>
|
||||
{isTopPerformer && (
|
||||
<Badge variant="secondary" className="bg-yellow-50 text-yellow-800">
|
||||
{t('enterprise.top_performer')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
isTopPerformer
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-500'
|
||||
: 'bg-blue-400'
|
||||
}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<BarChart3 className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('enterprise.no_performance_data')}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t('enterprise.performance_based_on_period', {
|
||||
metric: t(`enterprise.metrics.${metric}`) || metric,
|
||||
period
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceChart;
|
||||
158
frontend/src/components/dashboard/DeliveryRoutesMap.tsx
Normal file
158
frontend/src/components/dashboard/DeliveryRoutesMap.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Delivery Routes Map Component
|
||||
* Visualizes delivery routes and shipment status
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '../ui/Card';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Route {
|
||||
route_id: string;
|
||||
route_number: string;
|
||||
status: string;
|
||||
total_distance_km: number;
|
||||
stops: any[]; // Simplified for now
|
||||
estimated_duration_minutes: number;
|
||||
}
|
||||
|
||||
interface DeliveryRoutesMapProps {
|
||||
routes?: Route[];
|
||||
shipments?: Record<string, number>;
|
||||
}
|
||||
|
||||
export const DeliveryRoutesMap: React.FC<DeliveryRoutesMapProps> = ({ routes, shipments }) => {
|
||||
const { t } = useTranslation('dashboard');
|
||||
|
||||
// Calculate summary stats for display
|
||||
const totalRoutes = routes?.length || 0;
|
||||
const totalDistance = routes?.reduce((sum, route) => sum + (route.total_distance_km || 0), 0) || 0;
|
||||
|
||||
// Calculate shipment status counts
|
||||
const pendingShipments = shipments?.pending || 0;
|
||||
const inTransitShipments = shipments?.in_transit || 0;
|
||||
const deliveredShipments = shipments?.delivered || 0;
|
||||
const totalShipments = pendingShipments + inTransitShipments + deliveredShipments;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Route Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-blue-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-blue-600">{t('enterprise.total_routes')}</p>
|
||||
<p className="text-xl font-bold text-blue-900">{totalRoutes}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-green-600">{t('enterprise.total_distance')}</p>
|
||||
<p className="text-xl font-bold text-green-900">{totalDistance.toFixed(1)} km</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-yellow-600">{t('enterprise.total_shipments')}</p>
|
||||
<p className="text-xl font-bold text-yellow-900">{totalShipments}</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-purple-600">{t('enterprise.active_routes')}</p>
|
||||
<p className="text-xl font-bold text-purple-900">
|
||||
{routes?.filter(r => r.status === 'in_progress').length || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route Status Legend */}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-gray-300 rounded-full mr-2"></div>
|
||||
<span className="text-xs">{t('enterprise.planned')}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full mr-2"></div>
|
||||
<span className="text-xs">{t('enterprise.pending')}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full mr-2"></div>
|
||||
<span className="text-xs">{t('enterprise.in_transit')}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
|
||||
<span className="text-xs">{t('enterprise.delivered')}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
|
||||
<span className="text-xs">{t('enterprise.failed')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simplified Map Visualization */}
|
||||
<div className="border rounded-lg p-4 bg-gray-50 min-h-[400px] relative">
|
||||
<h3 className="text-lg font-semibold mb-4">{t('enterprise.distribution_routes')}</h3>
|
||||
|
||||
{routes && routes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{/* For each route, show a simplified representation */}
|
||||
{routes.map((route, index) => {
|
||||
let statusColor = 'bg-gray-300'; // planned
|
||||
if (route.status === 'in_progress') statusColor = 'bg-yellow-500';
|
||||
else if (route.status === 'completed') statusColor = 'bg-green-500';
|
||||
else if (route.status === 'cancelled') statusColor = 'bg-red-500';
|
||||
|
||||
return (
|
||||
<div key={route.route_id} className="border rounded p-3 bg-white">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="font-medium">{t('enterprise.route')} {route.route_number}</h4>
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${statusColor} text-white`}>
|
||||
{t(`enterprise.route_status.${route.status}`) || route.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">{t('enterprise.distance')}:</span>
|
||||
<span className="ml-1">{route.total_distance_km?.toFixed(1)} km</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">{t('enterprise.duration')}:</span>
|
||||
<span className="ml-1">{Math.round(route.estimated_duration_minutes || 0)} min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">{t('enterprise.stops')}:</span>
|
||||
<span className="ml-1">{route.stops?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route stops visualization */}
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
{route.stops && route.stops.length > 0 ? (
|
||||
route.stops.map((stop, stopIndex) => (
|
||||
<React.Fragment key={stopIndex}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs">
|
||||
{stopIndex + 1}
|
||||
</div>
|
||||
<span className="text-xs mt-1 text-center max-w-[60px] truncate">
|
||||
{stop.location?.name || `${t('enterprise.stop')} ${stopIndex + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
{stopIndex < route.stops.length - 1 && (
|
||||
<div className="flex-1 h-0.5 bg-gray-300"></div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-500 text-sm">{t('enterprise.no_stops')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
{t('enterprise.no_routes_available')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
160
frontend/src/components/dashboard/NetworkSummaryCards.tsx
Normal file
160
frontend/src/components/dashboard/NetworkSummaryCards.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Network Summary Cards Component for Enterprise Dashboard
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import {
|
||||
Store as StoreIcon,
|
||||
DollarSign,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Truck,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatCurrency } from '../../utils/format';
|
||||
|
||||
interface NetworkSummaryData {
|
||||
parent_tenant_id: string;
|
||||
child_tenant_count: number;
|
||||
network_sales_30d: number;
|
||||
production_volume_30d: number;
|
||||
pending_internal_transfers_count: number;
|
||||
active_shipments_count: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
interface NetworkSummaryCardsProps {
|
||||
data?: NetworkSummaryData;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const NetworkSummaryCards: React.FC<NetworkSummaryCardsProps> = ({
|
||||
data,
|
||||
isLoading
|
||||
}) => {
|
||||
const { t } = useTranslation('dashboard');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Card key={index} className="animate-pulse">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-500 h-4 bg-gray-200 rounded w-3/4"></CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{t('enterprise.no_network_data')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
{/* Network Outlets Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">
|
||||
{t('enterprise.network_outlets')}
|
||||
</CardTitle>
|
||||
<StoreIcon className="w-4 h-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{data.child_tenant_count}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('enterprise.outlets_in_network')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Sales Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">
|
||||
{t('enterprise.network_sales')}
|
||||
</CardTitle>
|
||||
<DollarSign className="w-4 h-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(data.network_sales_30d, 'EUR')}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('enterprise.last_30_days')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Production Volume Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">
|
||||
{t('enterprise.production_volume')}
|
||||
</CardTitle>
|
||||
<Package className="w-4 h-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{new Intl.NumberFormat('es-ES').format(data.production_volume_30d)} kg
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('enterprise.last_30_days')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pending Internal Transfers Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">
|
||||
{t('enterprise.pending_orders')}
|
||||
</CardTitle>
|
||||
<ShoppingCart className="w-4 h-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{data.pending_internal_transfers_count}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('enterprise.internal_transfers')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Shipments Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">
|
||||
{t('enterprise.active_shipments')}
|
||||
</CardTitle>
|
||||
<Truck className="w-4 h-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{data.active_shipments_count}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('enterprise.today')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkSummaryCards;
|
||||
155
frontend/src/components/dashboard/PerformanceChart.tsx
Normal file
155
frontend/src/components/dashboard/PerformanceChart.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Performance Chart Component
|
||||
* Shows anonymized ranking of outlets based on selected metric
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Card, CardContent } from '../ui/Card';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
interface PerformanceData {
|
||||
rank: number;
|
||||
tenant_id: string;
|
||||
anonymized_name: string;
|
||||
metric_value: number;
|
||||
}
|
||||
|
||||
interface PerformanceChartProps {
|
||||
data?: PerformanceData[];
|
||||
metric: string;
|
||||
period: number;
|
||||
}
|
||||
|
||||
export const PerformanceChart: React.FC<PerformanceChartProps> = ({ data, metric, period }) => {
|
||||
const { t } = useTranslation('dashboard');
|
||||
|
||||
// Prepare chart data
|
||||
const chartData = {
|
||||
labels: data?.map(item => item.anonymized_name) || [],
|
||||
datasets: [
|
||||
{
|
||||
label: t(`enterprise.metric_labels.${metric}`) || metric,
|
||||
data: data?.map(item => item.metric_value) || [],
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.6)',
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('enterprise.outlet_performance_chart_title'),
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
if (metric === 'sales') {
|
||||
label += `€${context.parsed.y.toFixed(2)}`;
|
||||
} else {
|
||||
label += context.parsed.y;
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t('enterprise.outlet'),
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: t(`enterprise.metric_labels.${metric}`) || metric,
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{t('enterprise.performance_based_on', {
|
||||
metric: t(`enterprise.metrics.${metric}`) || metric,
|
||||
period
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data && data.length > 0 ? (
|
||||
<div className="h-80">
|
||||
<Bar data={chartData} options={options} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||
{t('enterprise.no_performance_data')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance ranking table */}
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">{t('enterprise.ranking')}</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">{t('enterprise.rank')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('enterprise.outlet')}</th>
|
||||
<th className="px-3 py-2 text-right">
|
||||
{t(`enterprise.metric_labels.${metric}`) || metric}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{data?.map((item, index) => (
|
||||
<tr key={item.tenant_id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-3 py-2">{item.rank}</td>
|
||||
<td className="px-3 py-2 font-medium">{item.anonymized_name}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{metric === 'sales' ? `€${item.metric_value.toFixed(2)}` : item.metric_value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -255,6 +255,23 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
const allUserRoles = [...globalUserRoles, ...tenantRoles];
|
||||
const tenantPermissions = currentTenantAccess?.permissions || [];
|
||||
|
||||
// Debug logging for analytics route
|
||||
if (item.path === '/app/analytics') {
|
||||
console.log('🔍 [Sidebar] Checking analytics menu item:', {
|
||||
path: item.path,
|
||||
requiredRoles: item.requiredRoles,
|
||||
requiredPermissions: item.requiredPermissions,
|
||||
globalUserRoles,
|
||||
tenantRoles,
|
||||
allUserRoles,
|
||||
tenantPermissions,
|
||||
isAuthenticated,
|
||||
hasAccess,
|
||||
user,
|
||||
currentTenantAccess
|
||||
});
|
||||
}
|
||||
|
||||
// If no specific permissions/roles required, allow access
|
||||
if (!item.requiredPermissions && !item.requiredRoles) {
|
||||
return true;
|
||||
@@ -272,6 +289,10 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
tenantPermissions
|
||||
);
|
||||
|
||||
if (item.path === '/app/analytics') {
|
||||
console.log('🔍 [Sidebar] Analytics canAccessRoute result:', canAccessItem);
|
||||
}
|
||||
|
||||
return canAccessItem;
|
||||
});
|
||||
};
|
||||
|
||||
336
frontend/src/components/maps/DistributionMap.tsx
Normal file
336
frontend/src/components/maps/DistributionMap.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* Distribution Map Component for Enterprise Dashboard
|
||||
* Shows delivery routes and shipment status across the network
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { Button } from '../ui/Button';
|
||||
import {
|
||||
MapPin,
|
||||
Truck,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Package,
|
||||
Eye,
|
||||
Info,
|
||||
Route,
|
||||
Navigation,
|
||||
Map as MapIcon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface RoutePoint {
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
status: 'pending' | 'in_transit' | 'delivered' | 'failed';
|
||||
estimated_arrival?: string;
|
||||
actual_arrival?: string;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
interface RouteData {
|
||||
id: string;
|
||||
route_number: string;
|
||||
total_distance_km: number;
|
||||
estimated_duration_minutes: number;
|
||||
status: 'planned' | 'in_progress' | 'completed' | 'cancelled';
|
||||
route_points: RoutePoint[];
|
||||
}
|
||||
|
||||
interface ShipmentStatusData {
|
||||
pending: number;
|
||||
in_transit: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface DistributionMapProps {
|
||||
routes?: RouteData[];
|
||||
shipments?: ShipmentStatusData;
|
||||
}
|
||||
|
||||
const DistributionMap: React.FC<DistributionMapProps> = ({
|
||||
routes = [],
|
||||
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
|
||||
}) => {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
|
||||
const [showAllRoutes, setShowAllRoutes] = useState(true);
|
||||
|
||||
const renderMapVisualization = () => {
|
||||
if (!routes || routes.length === 0) {
|
||||
return (
|
||||
<div className="h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-lg font-medium text-gray-900">{t('enterprise.no_active_routes')}</p>
|
||||
<p className="text-gray-500">{t('enterprise.no_shipments_today')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Find active routes (in_progress or planned for today)
|
||||
const activeRoutes = routes.filter(route =>
|
||||
route.status === 'in_progress' || route.status === 'planned'
|
||||
);
|
||||
|
||||
if (activeRoutes.length === 0) {
|
||||
return (
|
||||
<div className="h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-green-400" />
|
||||
<p className="text-lg font-medium text-gray-900">{t('enterprise.all_routes_completed')}</p>
|
||||
<p className="text-gray-500">{t('enterprise.no_active_deliveries')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// This would normally render an interactive map, but we'll create a visual representation
|
||||
return (
|
||||
<div className="h-96 bg-gradient-to-b from-blue-50 to-indigo-50 rounded-lg border border-gray-200 relative">
|
||||
{/* Map visualization placeholder with route indicators */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapIcon className="w-16 h-16 mx-auto text-blue-400 mb-2" />
|
||||
<div className="text-lg font-medium text-gray-700">{t('enterprise.distribution_map')}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{activeRoutes.length} {t('enterprise.active_routes')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route visualization elements */}
|
||||
{activeRoutes.map((route, index) => (
|
||||
<div key={route.id} className="absolute top-4 left-4 bg-white p-3 rounded-lg shadow-md text-sm max-w-xs">
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
||||
<Route className="w-4 h-4 text-blue-600" />
|
||||
{t('enterprise.route')} {route.route_number}
|
||||
</div>
|
||||
<div className="text-gray-600 mt-1">{route.status.replace('_', ' ')}</div>
|
||||
<div className="text-gray-500">{route.total_distance_km.toFixed(1)} km • {Math.ceil(route.estimated_duration_minutes / 60)}h</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Shipment status indicators */}
|
||||
<div className="absolute bottom-4 right-4 bg-white p-3 rounded-lg shadow-md space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
|
||||
<span className="text-sm">{t('enterprise.pending')}: {shipments.pending}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-400"></div>
|
||||
<span className="text-sm">{t('enterprise.in_transit')}: {shipments.in_transit}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-400"></div>
|
||||
<span className="text-sm">{t('enterprise.delivered')}: {shipments.delivered}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400"></div>
|
||||
<span className="text-sm">{t('enterprise.failed')}: {shipments.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'in_transit':
|
||||
return <Truck className="w-4 h-4 text-blue-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
case 'failed':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'in_transit':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Shipment Status Summary */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-yellow-600" />
|
||||
<span className="text-sm font-medium text-yellow-800">{t('enterprise.pending')}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-900">{shipments?.pending || 0}</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="w-5 h-5 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-800">{t('enterprise.in_transit')}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-900">{shipments?.in_transit || 0}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">{t('enterprise.delivered')}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-900">{shipments?.delivered || 0}</p>
|
||||
</div>
|
||||
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">{t('enterprise.failed')}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-red-900">{shipments?.failed || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Visualization */}
|
||||
{renderMapVisualization()}
|
||||
|
||||
{/* Route Details Panel */}
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">{t('enterprise.active_routes')}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAllRoutes(!showAllRoutes)}
|
||||
>
|
||||
{showAllRoutes ? t('enterprise.hide_routes') : t('enterprise.show_routes')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAllRoutes && routes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{routes
|
||||
.filter(route => route.status === 'in_progress' || route.status === 'planned')
|
||||
.map(route => (
|
||||
<Card key={route.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-sm">
|
||||
{t('enterprise.route')} {route.route_number}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{route.total_distance_km.toFixed(1)} km • {Math.ceil(route.estimated_duration_minutes / 60)}h
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={getStatusColor(route.status)}>
|
||||
{getStatusIcon(route.status)}
|
||||
<span className="ml-1 capitalize">
|
||||
{t(`enterprise.route_status.${route.status}`) || route.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{route.route_points.map((point, index) => (
|
||||
<div key={index} className="flex items-center gap-3 text-sm">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
point.status === 'delivered' ? 'bg-green-500 text-white' :
|
||||
point.status === 'in_transit' ? 'bg-blue-500 text-white' :
|
||||
point.status === 'failed' ? 'bg-red-500 text-white' :
|
||||
'bg-yellow-500 text-white'
|
||||
}`}>
|
||||
{point.sequence}
|
||||
</div>
|
||||
<span className="flex-1 truncate">{point.name}</span>
|
||||
<Badge variant="outline" className={getStatusColor(point.status)}>
|
||||
{getStatusIcon(point.status)}
|
||||
<span className="ml-1 text-xs">
|
||||
{t(`enterprise.stop_status.${point.status}`) || point.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
{routes.length === 0 ?
|
||||
t('enterprise.no_routes_planned') :
|
||||
t('enterprise.no_active_routes')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Route Detail Panel (would be modal in real implementation) */}
|
||||
{selectedRoute && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">{t('enterprise.route_details')}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedRoute(null)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('enterprise.route_number')}</span>
|
||||
<span className="font-medium">{selectedRoute.route_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('enterprise.total_distance')}</span>
|
||||
<span>{selectedRoute.total_distance_km.toFixed(1)} km</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('enterprise.estimated_duration')}</span>
|
||||
<span>{Math.ceil(selectedRoute.estimated_duration_minutes / 60)}h {selectedRoute.estimated_duration_minutes % 60}m</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('enterprise.status')}</span>
|
||||
<Badge className={getStatusColor(selectedRoute.status)}>
|
||||
{getStatusIcon(selectedRoute.status)}
|
||||
<span className="ml-1 capitalize">
|
||||
{t(`enterprise.route_status.${selectedRoute.status}`) || selectedRoute.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-4"
|
||||
onClick={() => setSelectedRoute(null)}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DistributionMap;
|
||||
@@ -329,5 +329,73 @@
|
||||
"celebration": "Great news! AI prevented {count} issue(s) before they became problems.",
|
||||
"ai_insight": "AI Insight:",
|
||||
"orchestration_title": "Latest Orchestration Run"
|
||||
},
|
||||
"enterprise": {
|
||||
"network_dashboard": "Enterprise Network Dashboard",
|
||||
"network_summary_description": "Overview of your bakery network performance",
|
||||
"loading": "Loading network data...",
|
||||
"network_summary": "Network Summary",
|
||||
"outlets_count": "Network Outlets",
|
||||
"network_outlets": "outlets in network",
|
||||
"network_sales": "Network Sales",
|
||||
"last_30_days": "last 30 days",
|
||||
"production_volume": "Production Volume",
|
||||
"pending_orders": "Pending Orders",
|
||||
"internal_transfers": "internal transfers",
|
||||
"active_shipments": "Active Shipments",
|
||||
"today": "today",
|
||||
"distribution_map": "Distribution Routes",
|
||||
"outlet_performance": "Outlet Performance",
|
||||
"sales": "Sales",
|
||||
"inventory_value": "Inventory Value",
|
||||
"order_frequency": "Order Frequency",
|
||||
"last_7_days": "Last 7 days",
|
||||
"last_30_days": "Last 30 days",
|
||||
"last_90_days": "Last 90 days",
|
||||
"network_forecast": "Network Forecast",
|
||||
"total_demand": "Total Demand",
|
||||
"days_forecast": "Days Forecast",
|
||||
"avg_daily_demand": "Avg Daily Demand",
|
||||
"last_updated": "Last Updated",
|
||||
"no_forecast_data": "No forecast data available",
|
||||
"no_performance_data": "No performance data available",
|
||||
"no_distribution_data": "No distribution data available",
|
||||
"performance_based_on": "Performance based on {{metric}} over {{period}} days",
|
||||
"ranking": "Ranking",
|
||||
"rank": "Rank",
|
||||
"outlet": "Outlet",
|
||||
"metric_labels": {
|
||||
"sales": "Sales (€)",
|
||||
"inventory_value": "Inventory Value (€)",
|
||||
"order_frequency": "Order Frequency"
|
||||
},
|
||||
"metrics": {
|
||||
"sales": "sales",
|
||||
"inventory_value": "inventory value",
|
||||
"order_frequency": "order frequency"
|
||||
},
|
||||
"route": "Route",
|
||||
"total_routes": "Total Routes",
|
||||
"total_distance": "Total Distance",
|
||||
"total_shipments": "Total Shipments",
|
||||
"active_routes": "Active Routes",
|
||||
"distance": "Distance",
|
||||
"duration": "Duration",
|
||||
"stops": "Stops",
|
||||
"no_stops": "No stops",
|
||||
"stop": "Stop",
|
||||
"no_routes_available": "No routes available",
|
||||
"route_status": {
|
||||
"planned": "Planned",
|
||||
"in_progress": "In Progress",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"planned": "Planned",
|
||||
"pending": "Pending",
|
||||
"in_transit": "In Transit",
|
||||
"delivered": "Delivered",
|
||||
"failed": "Failed",
|
||||
"distribution_routes": "Distribution Routes"
|
||||
}
|
||||
}
|
||||
@@ -378,5 +378,73 @@
|
||||
"celebration": "¡Buenas noticias! La IA evitó {count} incidencia(s) antes de que se convirtieran en problemas.",
|
||||
"ai_insight": "Análisis de IA:",
|
||||
"orchestration_title": "Última Ejecución de Orquestación"
|
||||
},
|
||||
"enterprise": {
|
||||
"network_dashboard": "Panel de Red Empresarial",
|
||||
"network_summary_description": "Resumen del rendimiento de tu red de panaderías",
|
||||
"loading": "Cargando datos de red...",
|
||||
"network_summary": "Resumen de Red",
|
||||
"outlets_count": "Tiendas en Red",
|
||||
"network_outlets": "tiendas en red",
|
||||
"network_sales": "Ventas de Red",
|
||||
"last_30_days": "últimos 30 días",
|
||||
"production_volume": "Volumen de Producción",
|
||||
"pending_orders": "Órdenes Pendientes",
|
||||
"internal_transfers": "transferencias internas",
|
||||
"active_shipments": "Envíos Activos",
|
||||
"today": "hoy",
|
||||
"distribution_map": "Rutas de Distribución",
|
||||
"outlet_performance": "Rendimiento de Tiendas",
|
||||
"sales": "Ventas",
|
||||
"inventory_value": "Valor de Inventario",
|
||||
"order_frequency": "Frecuencia de Pedidos",
|
||||
"last_7_days": "Últimos 7 días",
|
||||
"last_30_days": "Últimos 30 días",
|
||||
"last_90_days": "Últimos 90 días",
|
||||
"network_forecast": "Pronóstico de Red",
|
||||
"total_demand": "Demanda Total",
|
||||
"days_forecast": "Días de Pronóstico",
|
||||
"avg_daily_demand": "Demanda Diaria Promedio",
|
||||
"last_updated": "Última Actualización",
|
||||
"no_forecast_data": "No hay datos de pronóstico disponibles",
|
||||
"no_performance_data": "No hay datos de rendimiento disponibles",
|
||||
"no_distribution_data": "No hay datos de distribución disponibles",
|
||||
"performance_based_on": "Rendimiento basado en {{metric}} durante {{period}} días",
|
||||
"ranking": "Clasificación",
|
||||
"rank": "Posición",
|
||||
"outlet": "Tienda",
|
||||
"metric_labels": {
|
||||
"sales": "Ventas (€)",
|
||||
"inventory_value": "Valor de Inventario (€)",
|
||||
"order_frequency": "Frecuencia de Pedidos"
|
||||
},
|
||||
"metrics": {
|
||||
"sales": "ventas",
|
||||
"inventory_value": "valor de inventario",
|
||||
"order_frequency": "frecuencia de pedidos"
|
||||
},
|
||||
"route": "Ruta",
|
||||
"total_routes": "Rutas Totales",
|
||||
"total_distance": "Distancia Total",
|
||||
"total_shipments": "Envíos Totales",
|
||||
"active_routes": "Rutas Activas",
|
||||
"distance": "Distancia",
|
||||
"duration": "Duración",
|
||||
"stops": "Paradas",
|
||||
"no_stops": "Sin paradas",
|
||||
"stop": "Parada",
|
||||
"no_routes_available": "No hay rutas disponibles",
|
||||
"route_status": {
|
||||
"planned": "Planificada",
|
||||
"in_progress": "En Progreso",
|
||||
"completed": "Completada",
|
||||
"cancelled": "Cancelada"
|
||||
},
|
||||
"planned": "Planificada",
|
||||
"pending": "Pendiente",
|
||||
"in_transit": "En Tránsito",
|
||||
"delivered": "Entregada",
|
||||
"failed": "Fallida",
|
||||
"distribution_routes": "Rutas de Distribución"
|
||||
}
|
||||
}
|
||||
@@ -327,5 +327,73 @@
|
||||
"celebration": "Albiste onak! IAk {count} arazo saihestatu ditu arazo bihurtu aurretik.",
|
||||
"ai_insight": "IAren Analisia:",
|
||||
"orchestration_title": "Azken Orkestraketa-Exekuzioa"
|
||||
},
|
||||
"enterprise": {
|
||||
"network_dashboard": "Enpresa-sarearen Aginte-panela",
|
||||
"network_summary_description": "Zure okindegi-sarearen errendimenduaren laburpena",
|
||||
"loading": "Sare-datuak kargatzen...",
|
||||
"network_summary": "Sarearen Laburpena",
|
||||
"outlets_count": "Sarea Dendak",
|
||||
"network_outlets": "sarea dendak",
|
||||
"network_sales": "Sarea Salmentak",
|
||||
"last_30_days": "azken 30 egunetan",
|
||||
"production_volume": "Ekoizpen Bolumena",
|
||||
"pending_orders": "Aginduak Zain",
|
||||
"internal_transfers": "transferentzia barneak",
|
||||
"active_shipments": "Bidalketa Aktiboak",
|
||||
"today": "gaur",
|
||||
"distribution_map": "Banaketa Ibilbideak",
|
||||
"outlet_performance": "Denda Errendimendua",
|
||||
"sales": "Salmentak",
|
||||
"inventory_value": "Inbentario Balorea",
|
||||
"order_frequency": "Agindu Maiztasuna",
|
||||
"last_7_days": "Azken 7 egun",
|
||||
"last_30_days": "Azken 30 egun",
|
||||
"last_90_days": "Azken 90 egun",
|
||||
"network_forecast": "Sarea Iragarpena",
|
||||
"total_demand": "Eskari Osoa",
|
||||
"days_forecast": "Iragarpen Egunak",
|
||||
"avg_daily_demand": "Eguneko Eskari Batezbestekoa",
|
||||
"last_updated": "Azken Eguneraketa",
|
||||
"no_forecast_data": "Ez dago iragarpen daturik erabilgarri",
|
||||
"no_performance_data": "Ez dago errendimendu daturik erabilgarri",
|
||||
"no_distribution_data": "Ez dago banaketa daturik erabilgarri",
|
||||
"performance_based_on": "Errendimendua {{metric}}-n oinarrituta {{period}} egunetan",
|
||||
"ranking": "Sailkapena",
|
||||
"rank": "Postua",
|
||||
"outlet": "Denda",
|
||||
"metric_labels": {
|
||||
"sales": "Salmentak (€)",
|
||||
"inventory_value": "Inbentario Balorea (€)",
|
||||
"order_frequency": "Agindu Maiztasuna"
|
||||
},
|
||||
"metrics": {
|
||||
"sales": "salmentak",
|
||||
"inventory_value": "inbentario balorea",
|
||||
"order_frequency": "agindu maiztasuna"
|
||||
},
|
||||
"route": "Ibilbidea",
|
||||
"total_routes": "Ibilbide Guztiak",
|
||||
"total_distance": "Distantzia Guztira",
|
||||
"total_shipments": "Bidalketa Guztiak",
|
||||
"active_routes": "Ibilbide Aktiboak",
|
||||
"distance": "Distantzia",
|
||||
"duration": "Iraupena",
|
||||
"stops": "Geralekuak",
|
||||
"no_stops": "Geralekurik ez",
|
||||
"stop": "Geldo",
|
||||
"no_routes_available": "Ez dago ibilbirik erabilgarri",
|
||||
"route_status": {
|
||||
"planned": "Planifikatua",
|
||||
"in_progress": "Abian",
|
||||
"completed": "Osatua",
|
||||
"cancelled": "Ezeztatua"
|
||||
},
|
||||
"planned": "Planifikatua",
|
||||
"pending": "Zain",
|
||||
"in_transit": "Bidaiatzen",
|
||||
"delivered": "Entregatua",
|
||||
"failed": "Huts egin du",
|
||||
"distribution_routes": "Banaketa Ibilbideak"
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -43,6 +43,8 @@ const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insigh
|
||||
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
|
||||
const EventRegistryPage = React.lazy(() => import('../pages/app/analytics/events/EventRegistryPage'));
|
||||
|
||||
// Enterprise Dashboard Page
|
||||
const EnterpriseDashboardPage = React.lazy(() => import('../pages/app/EnterpriseDashboardPage'));
|
||||
|
||||
// Settings pages - Unified
|
||||
const BakerySettingsPage = React.lazy(() => import('../pages/app/settings/bakery/BakerySettingsPage'));
|
||||
@@ -340,6 +342,17 @@ export const AppRouter: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Enterprise Dashboard Route - Only for enterprise tier */}
|
||||
<Route
|
||||
path="/app/tenants/:tenantId/enterprise"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<EnterpriseDashboardPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Settings Routes */}
|
||||
{/* NEW: Unified Profile Settings Route */}
|
||||
|
||||
@@ -395,6 +395,17 @@ export const routesConfig: RouteConfig[] = [
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
path: '/app/tenants/:tenantId/enterprise',
|
||||
name: 'EnterpriseDashboard',
|
||||
component: 'EnterpriseDashboardPage',
|
||||
title: 'Enterprise Dashboard',
|
||||
icon: 'analytics',
|
||||
requiresAuth: true,
|
||||
requiredSubscriptionFeature: 'multi_location_dashboard',
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ export interface AuthState {
|
||||
updateUser: (updates: Partial<User>) => void;
|
||||
clearError: () => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
|
||||
setDemoAuth: (token: string, demoUser: Partial<User>) => void;
|
||||
|
||||
// Permission helpers
|
||||
hasPermission: (permission: string) => boolean;
|
||||
hasRole: (role: string) => boolean;
|
||||
@@ -234,6 +235,24 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ isLoading: loading });
|
||||
},
|
||||
|
||||
setDemoAuth: (token: string, demoUser: Partial<User>) => {
|
||||
console.log('🔧 [Auth Store] setDemoAuth called - demo sessions use X-Demo-Session-Id header, not JWT');
|
||||
// DO NOT set API client token for demo sessions!
|
||||
// Demo authentication works via X-Demo-Session-Id header, not JWT
|
||||
// The demo middleware handles authentication server-side
|
||||
|
||||
// Update store state so user is marked as authenticated
|
||||
set({
|
||||
token: null, // No JWT token for demo sessions
|
||||
refreshToken: null,
|
||||
user: demoUser as User,
|
||||
isAuthenticated: true, // User is authenticated via demo session
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
console.log('✅ [Auth Store] Demo auth state updated (no JWT token)');
|
||||
},
|
||||
|
||||
// Permission helpers - Global user permissions only
|
||||
hasPermission: (_permission: string): boolean => {
|
||||
const { user } = get();
|
||||
|
||||
Reference in New Issue
Block a user