Add improved production UI 4

This commit is contained in:
Urtzi Alfaro
2025-09-23 22:11:34 +02:00
parent 7892c5a739
commit 87310ced5f
17 changed files with 1658 additions and 296 deletions

View File

@@ -0,0 +1,491 @@
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;

View File

@@ -63,7 +63,7 @@ export const ReportsTable: React.FC<ReportsTableProps> = ({
const [reportToShare, setReportToShare] = useState<AnalyticsReport | null>(null);
const filteredAndSortedReports = useMemo(() => {
let filtered = reports.filter(report => {
const filtered = reports.filter(report => {
const matchesSearch = !searchTerm ||
report.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
report.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||

View File

@@ -4,6 +4,7 @@ export { default as ChartWidget } from './ChartWidget';
export { default as ReportsTable } from './ReportsTable';
export { default as FilterPanel } from './FilterPanel';
export { default as ExportOptions } from './ExportOptions';
export { default as ProductionCostAnalytics } from './ProductionCostAnalytics';
// Types
export * from './types';