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