491 lines
19 KiB
TypeScript
491 lines
19 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
|
import { StatsGrid } from '../../ui/Stats';
|
|
import { Badge } from '../../ui/Badge';
|
|
import { Button } from '../../ui/Button';
|
|
import { Select } from '../../ui/Select';
|
|
import {
|
|
Euro,
|
|
TrendingUp,
|
|
AlertTriangle,
|
|
Target,
|
|
Clock,
|
|
Activity,
|
|
Zap,
|
|
Download
|
|
} from 'lucide-react';
|
|
import { useCurrentTenant } from '../../../stores/tenant.store';
|
|
import { useProductionDashboard } from '../../../api';
|
|
|
|
export interface ProductionCostData {
|
|
totalCost: number;
|
|
laborCost: number;
|
|
materialCost: number;
|
|
overheadCost: number;
|
|
energyCost: number;
|
|
costPerUnit: number;
|
|
budget: number;
|
|
variance: number;
|
|
trend: {
|
|
direction: 'up' | 'down' | 'stable';
|
|
percentage: number;
|
|
};
|
|
costHistory: Array<{
|
|
date: string;
|
|
totalCost: number;
|
|
laborCost: number;
|
|
materialCost: number;
|
|
overheadCost: number;
|
|
energyCost: number;
|
|
}>;
|
|
}
|
|
|
|
export interface ProductionCostAnalyticsProps {
|
|
className?: string;
|
|
data?: ProductionCostData;
|
|
timeRange?: 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
onTimeRangeChange?: (range: 'day' | 'week' | 'month' | 'quarter' | 'year') => void;
|
|
onViewDetails?: () => void;
|
|
onOptimize?: () => void;
|
|
onExport?: () => void;
|
|
}
|
|
|
|
const ProductionCostAnalytics: React.FC<ProductionCostAnalyticsProps> = ({
|
|
className,
|
|
data,
|
|
timeRange = 'month',
|
|
onTimeRangeChange,
|
|
onViewDetails,
|
|
onOptimize,
|
|
onExport
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const currentTenant = useCurrentTenant();
|
|
const tenantId = currentTenant?.id || '';
|
|
|
|
const {
|
|
data: dashboardData,
|
|
isLoading,
|
|
error
|
|
} = useProductionDashboard(tenantId);
|
|
|
|
const [selectedTimeRange, setSelectedTimeRange] = useState<'day' | 'week' | 'month' | 'quarter' | 'year'>(timeRange);
|
|
|
|
const costData = useMemo((): ProductionCostData => {
|
|
if (data) return data;
|
|
|
|
// Calculate from dashboard data if available
|
|
if (dashboardData) {
|
|
const mockData: ProductionCostData = {
|
|
totalCost: 2840.50,
|
|
laborCost: 1200.00,
|
|
materialCost: 1450.00,
|
|
overheadCost: 190.50,
|
|
energyCost: 95.25,
|
|
costPerUnit: 14.20,
|
|
budget: 3000.00,
|
|
variance: -159.50,
|
|
trend: {
|
|
direction: 'down',
|
|
percentage: 5.3
|
|
},
|
|
costHistory: [
|
|
{ date: '2024-01-16', totalCost: 2750.20, laborCost: 1150.00, materialCost: 1420.00, overheadCost: 180.20, energyCost: 90.00 },
|
|
{ date: '2024-01-17', totalCost: 2780.80, laborCost: 1180.00, materialCost: 1430.00, overheadCost: 180.80, energyCost: 90.00 },
|
|
{ date: '2024-01-18', totalCost: 2720.10, laborCost: 1120.00, materialCost: 1410.00, overheadCost: 190.10, energyCost: 100.00 },
|
|
{ date: '2024-01-19', totalCost: 2810.60, laborCost: 1220.00, materialCost: 1440.00, overheadCost: 190.60, energyCost: 95.00 },
|
|
{ date: '2024-01-20', totalCost: 2800.40, laborCost: 1210.00, materialCost: 1440.00, overheadCost: 180.40, energyCost: 90.00 },
|
|
{ date: '2024-01-21', totalCost: 2790.30, laborCost: 1190.00, materialCost: 1430.00, overheadCost: 180.30, energyCost: 90.00 },
|
|
{ date: '2024-01-22', totalCost: 2840.50, laborCost: 1200.00, materialCost: 1450.00, overheadCost: 190.50, energyCost: 95.25 }
|
|
]
|
|
};
|
|
return mockData;
|
|
}
|
|
|
|
// Default mock data
|
|
return {
|
|
totalCost: 2840.50,
|
|
laborCost: 1200.00,
|
|
materialCost: 1450.00,
|
|
overheadCost: 190.50,
|
|
energyCost: 95.25,
|
|
costPerUnit: 14.20,
|
|
budget: 3000.00,
|
|
variance: -159.50,
|
|
trend: {
|
|
direction: 'down',
|
|
percentage: 5.3
|
|
},
|
|
costHistory: [
|
|
{ date: '2024-01-16', totalCost: 2750.20, laborCost: 1150.00, materialCost: 1420.00, overheadCost: 180.20, energyCost: 90.00 },
|
|
{ date: '2024-01-17', totalCost: 2780.80, laborCost: 1180.00, materialCost: 1430.00, overheadCost: 180.80, energyCost: 90.00 },
|
|
{ date: '2024-01-18', totalCost: 2720.10, laborCost: 1120.00, materialCost: 1410.00, overheadCost: 190.10, energyCost: 100.00 },
|
|
{ date: '2024-01-19', totalCost: 2810.60, laborCost: 1220.00, materialCost: 1440.00, overheadCost: 190.60, energyCost: 95.00 },
|
|
{ date: '2024-01-20', totalCost: 2800.40, laborCost: 1210.00, materialCost: 1440.00, overheadCost: 180.40, energyCost: 90.00 },
|
|
{ date: '2024-01-21', totalCost: 2790.30, laborCost: 1190.00, materialCost: 1430.00, overheadCost: 180.30, energyCost: 90.00 },
|
|
{ date: '2024-01-22', totalCost: 2840.50, laborCost: 1200.00, materialCost: 1450.00, overheadCost: 190.50, energyCost: 95.25 }
|
|
]
|
|
};
|
|
}, [data, dashboardData]);
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('es-ES', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
minimumFractionDigits: 2
|
|
}).format(amount);
|
|
};
|
|
|
|
const getVarianceColor = (variance: number) => {
|
|
if (variance > 0) return 'text-red-600';
|
|
if (variance < -50) return 'text-green-600';
|
|
return 'text-yellow-600';
|
|
};
|
|
|
|
const getBudgetStatus = () => {
|
|
const percentage = (costData.totalCost / costData.budget) * 100;
|
|
if (percentage > 100) return { color: 'error', label: t('production.cost.over_budget', 'Over Budget') };
|
|
if (percentage > 90) return { color: 'warning', label: t('production.cost.near_budget', 'Near Budget') };
|
|
return { color: 'success', label: t('production.cost.on_budget', 'On Budget') };
|
|
};
|
|
|
|
const budgetStatus = getBudgetStatus();
|
|
|
|
const handleTimeRangeChange = (range: string) => {
|
|
const newRange = range as 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
setSelectedTimeRange(newRange);
|
|
onTimeRangeChange?.(newRange);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card className={className}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
|
{t('production.cost.analytics.title', 'Cost Analytics')}
|
|
</h3>
|
|
<div className="flex items-center space-x-2">
|
|
<Select
|
|
value={selectedTimeRange}
|
|
onChange={handleTimeRangeChange}
|
|
options={[
|
|
{ value: 'day', label: t('common.time_range.day', 'Today') },
|
|
{ value: 'week', label: t('common.time_range.week', 'This Week') },
|
|
{ value: 'month', label: t('common.time_range.month', 'This Month') },
|
|
{ value: 'quarter', label: t('common.time_range.quarter', 'This Quarter') },
|
|
{ value: 'year', label: t('common.time_range.year', 'This Year') }
|
|
]}
|
|
/>
|
|
<Button variant="outline" size="sm">
|
|
<Download className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<div className="animate-pulse space-y-4">
|
|
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
|
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Card className={className}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
|
{t('production.cost.analytics.title', 'Cost Analytics')}
|
|
</h3>
|
|
<div className="flex items-center space-x-2">
|
|
<Select
|
|
value={selectedTimeRange}
|
|
onChange={handleTimeRangeChange}
|
|
options={[
|
|
{ value: 'day', label: t('common.time_range.day', 'Today') },
|
|
{ value: 'week', label: t('common.time_range.week', 'This Week') },
|
|
{ value: 'month', label: t('common.time_range.month', 'This Month') },
|
|
{ value: 'quarter', label: t('common.time_range.quarter', 'This Quarter') },
|
|
{ value: 'year', label: t('common.time_range.year', 'This Year') }
|
|
]}
|
|
/>
|
|
<Button variant="outline" size="sm">
|
|
<Download className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardBody className="text-center py-8">
|
|
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
|
<p className="text-[var(--text-secondary)]">
|
|
{t('production.cost.error', 'Error loading cost data')}
|
|
</p>
|
|
</CardBody>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const costStats = [
|
|
{
|
|
title: t('production.cost.total_cost', 'Total Cost'),
|
|
value: formatCurrency(costData.totalCost),
|
|
icon: Euro,
|
|
variant: budgetStatus.color as 'success' | 'warning' | 'error',
|
|
trend: {
|
|
value: costData.trend.percentage,
|
|
direction: costData.trend.direction === 'up' ? 'up' as const : 'down' as const,
|
|
label: t('production.cost.vs_last_period', 'vs last period')
|
|
},
|
|
subtitle: `${formatCurrency(Math.abs(costData.variance))} ${costData.variance > 0 ? t('common.over', 'over') : t('common.under', 'under')} ${t('common.budget', 'budget')}`
|
|
},
|
|
{
|
|
title: t('production.cost.cost_per_unit', 'Cost per Unit'),
|
|
value: formatCurrency(costData.costPerUnit),
|
|
icon: Target,
|
|
variant: 'info' as const,
|
|
subtitle: t('production.cost.average', 'Average for selected period')
|
|
},
|
|
{
|
|
title: t('production.cost.labor_cost', 'Labor Cost'),
|
|
value: formatCurrency(costData.laborCost),
|
|
icon: Clock,
|
|
variant: 'default' as const,
|
|
subtitle: `${((costData.laborCost / costData.totalCost) * 100).toFixed(1)}% of total`
|
|
},
|
|
{
|
|
title: t('production.cost.material_cost', 'Material Cost'),
|
|
value: formatCurrency(costData.materialCost),
|
|
icon: Activity,
|
|
variant: 'default' as const,
|
|
subtitle: `${((costData.materialCost / costData.totalCost) * 100).toFixed(1)}% of total`
|
|
}
|
|
];
|
|
|
|
const costBreakdown = [
|
|
{
|
|
label: t('production.cost.labor', 'Labor'),
|
|
amount: costData.laborCost,
|
|
percentage: (costData.laborCost / costData.totalCost) * 100,
|
|
icon: Clock,
|
|
color: 'bg-blue-500'
|
|
},
|
|
{
|
|
label: t('production.cost.materials', 'Materials'),
|
|
amount: costData.materialCost,
|
|
percentage: (costData.materialCost / costData.totalCost) * 100,
|
|
icon: Activity,
|
|
color: 'bg-green-500'
|
|
},
|
|
{
|
|
label: t('production.cost.overhead', 'Overhead'),
|
|
amount: costData.overheadCost,
|
|
percentage: (costData.overheadCost / costData.totalCost) * 100,
|
|
icon: Euro,
|
|
color: 'bg-orange-500'
|
|
},
|
|
{
|
|
label: t('production.cost.energy', 'Energy'),
|
|
amount: costData.energyCost,
|
|
percentage: (costData.energyCost / costData.totalCost) * 100,
|
|
icon: Zap,
|
|
color: 'bg-yellow-500'
|
|
}
|
|
];
|
|
|
|
// Create a simple bar chart visualization for cost history
|
|
const renderCostChart = () => {
|
|
if (!costData.costHistory || costData.costHistory.length === 0) return null;
|
|
|
|
// Find the maximum value for scaling
|
|
const maxValue = Math.max(...costData.costHistory.map(item => item.totalCost));
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-[var(--text-secondary)]">{formatCurrency(0)}</span>
|
|
<span className="text-[var(--text-secondary)]">{formatCurrency(maxValue)}</span>
|
|
</div>
|
|
<div className="flex items-end h-32 gap-1">
|
|
{costData.costHistory.map((item, index) => {
|
|
const height = (item.totalCost / maxValue) * 100;
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="flex-1 flex flex-col items-center group relative"
|
|
style={{ height: '100%' }}
|
|
>
|
|
<div
|
|
className="w-full bg-gradient-to-t from-[var(--color-primary)] to-[var(--color-primary)]/70 rounded-t hover:opacity-90 transition-all duration-200"
|
|
style={{ height: `${height}%` }}
|
|
></div>
|
|
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-[var(--bg-tertiary)] px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
|
|
{new Date(item.date).toLocaleDateString('es-ES')}<br/>{formatCurrency(item.totalCost)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Card className={className}>
|
|
<CardHeader>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
|
{t('production.cost.analytics.title', 'Cost Analytics')}
|
|
</h3>
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
{t('production.cost.analytics.subtitle', 'Detailed cost breakdown and trends')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Select
|
|
value={selectedTimeRange}
|
|
onChange={handleTimeRangeChange}
|
|
options={[
|
|
{ value: 'day', label: t('common.time_range.day', 'Today') },
|
|
{ value: 'week', label: t('common.time_range.week', 'This Week') },
|
|
{ value: 'month', label: t('common.time_range.month', 'This Month') },
|
|
{ value: 'quarter', label: t('common.time_range.quarter', 'This Quarter') },
|
|
{ value: 'year', label: t('common.time_range.year', 'This Year') }
|
|
]}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onExport}
|
|
disabled={!onExport}
|
|
>
|
|
<Download className="w-4 h-4 mr-1" />
|
|
{t('common.export', 'Export')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--border-primary)]">
|
|
<div className="flex items-center space-x-3">
|
|
<Badge variant={budgetStatus.color as 'success' | 'warning' | 'error' | 'default' | 'info'}>
|
|
{budgetStatus.label}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{t('production.cost.last_updated', 'Updated today')}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardBody className="space-y-8">
|
|
{/* Key Metrics */}
|
|
<StatsGrid
|
|
stats={costStats}
|
|
columns={2}
|
|
gap="md"
|
|
/>
|
|
|
|
{/* Cost Trend Chart */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-4 flex items-center">
|
|
<TrendingUp className="w-4 h-4 mr-2" />
|
|
{t('production.cost.trend', 'Cost Trend')}
|
|
</h4>
|
|
{renderCostChart()}
|
|
<div className="mt-2 text-xs text-[var(--text-tertiary)] text-center">
|
|
{t('production.cost.trend_description', 'Total cost trend over time')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cost Breakdown */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-4 flex items-center">
|
|
<Euro className="w-4 h-4 mr-2" />
|
|
{t('production.cost.breakdown', 'Cost Breakdown')}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{costBreakdown.map((item, index) => {
|
|
const Icon = item.icon;
|
|
return (
|
|
<div key={index} className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<div className={`w-3 h-3 rounded-full ${item.color}`}></div>
|
|
<Icon className="w-4 h-4 text-[var(--text-tertiary)]" />
|
|
<span className="text-sm text-[var(--text-primary)]">
|
|
{item.label}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
|
{formatCurrency(item.amount)}
|
|
</span>
|
|
<span className="text-xs text-[var(--text-tertiary)] w-10 text-right">
|
|
{item.percentage.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Budget Progress */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
|
{t('production.cost.budget_usage', 'Budget Usage')}
|
|
</span>
|
|
<span className="text-sm text-[var(--text-secondary)]">
|
|
{formatCurrency(costData.totalCost)} / {formatCurrency(costData.budget)}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2.5">
|
|
<div
|
|
className={`h-2.5 rounded-full transition-all duration-300 ${costData.totalCost > costData.budget ? 'bg-red-500' : costData.totalCost > costData.budget * 0.9 ? 'bg-yellow-500' : 'bg-green-500'}`}
|
|
style={{
|
|
width: `${Math.min(100, (costData.totalCost / costData.budget) * 100)}%`
|
|
}}
|
|
></div>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<span className={`text-xs font-medium ${getVarianceColor(costData.variance)}`}>
|
|
{costData.variance > 0 ? '+' : ''}{formatCurrency(costData.variance)}
|
|
</span>
|
|
<span className="text-xs text-[var(--text-tertiary)]">
|
|
{((costData.totalCost / costData.budget) * 100).toFixed(1)}% {t('common.used', 'used')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3 pt-4 border-t border-[var(--border-primary)]">
|
|
<Button
|
|
variant="outline"
|
|
onClick={onViewDetails}
|
|
className="flex-1"
|
|
>
|
|
{t('production.cost.view_details', 'View Detailed Report')}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={onOptimize}
|
|
className="flex-1"
|
|
>
|
|
{t('production.cost.optimize', 'Optimize Costs')}
|
|
</Button>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default ProductionCostAnalytics; |