New enterprise feature
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user