Improve the frontend
This commit is contained in:
413
frontend/src/components/domain/dashboard/TodayProduction.tsx
Normal file
413
frontend/src/components/domain/dashboard/TodayProduction.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { StatusCard } from '../../ui/StatusCard/StatusCard';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useActiveBatches } from '../../../api/hooks/production';
|
||||
import {
|
||||
Factory,
|
||||
Clock,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Timer,
|
||||
ChefHat,
|
||||
Flame,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface TodayProductionProps {
|
||||
className?: string;
|
||||
maxBatches?: number;
|
||||
onStartBatch?: (batchId: string) => void;
|
||||
onPauseBatch?: (batchId: string) => void;
|
||||
onViewDetails?: (batchId: string) => void;
|
||||
onViewAllPlans?: () => void;
|
||||
}
|
||||
|
||||
const TodayProduction: React.FC<TodayProductionProps> = ({
|
||||
className,
|
||||
maxBatches = 5,
|
||||
onStartBatch,
|
||||
onPauseBatch,
|
||||
onViewDetails,
|
||||
onViewAllPlans
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// Get today's date
|
||||
const todayDate = useMemo(() => {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}, []);
|
||||
|
||||
// Fetch active production batches
|
||||
const { data: productionData, isLoading, error } = useActiveBatches(
|
||||
tenantId,
|
||||
{
|
||||
enabled: !!tenantId,
|
||||
}
|
||||
);
|
||||
|
||||
const getBatchStatusConfig = (batch: any) => {
|
||||
const baseConfig = {
|
||||
isCritical: batch.status === 'FAILED' || batch.priority === 'URGENT',
|
||||
isHighlight: batch.status === 'IN_PROGRESS' || batch.priority === 'HIGH',
|
||||
};
|
||||
|
||||
switch (batch.status) {
|
||||
case 'PENDING':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pendiente',
|
||||
icon: Clock
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-info)',
|
||||
text: 'En Proceso',
|
||||
icon: Flame
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-success)',
|
||||
text: 'Completado',
|
||||
icon: CheckCircle
|
||||
};
|
||||
case 'ON_HOLD':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pausado',
|
||||
icon: Pause
|
||||
};
|
||||
case 'FAILED':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-error)',
|
||||
text: 'Fallido',
|
||||
icon: AlertTriangle
|
||||
};
|
||||
case 'QUALITY_CHECK':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-info)',
|
||||
text: 'Control de Calidad',
|
||||
icon: CheckCircle
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pendiente',
|
||||
icon: Clock
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
// Process batches and sort by priority
|
||||
const displayBatches = useMemo(() => {
|
||||
if (!productionData?.batches || !Array.isArray(productionData.batches)) return [];
|
||||
|
||||
const batches = [...productionData.batches];
|
||||
|
||||
// Filter for today's batches only
|
||||
const todayBatches = batches.filter(batch => {
|
||||
const batchDate = new Date(batch.planned_start_time || batch.created_at);
|
||||
return batchDate.toISOString().split('T')[0] === todayDate;
|
||||
});
|
||||
|
||||
// Sort by priority and start time
|
||||
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
||||
todayBatches.sort((a, b) => {
|
||||
// First sort by status (pending/in_progress first)
|
||||
const statusOrder = { PENDING: 0, IN_PROGRESS: 1, QUALITY_CHECK: 2, ON_HOLD: 3, COMPLETED: 4, FAILED: 5, CANCELLED: 6 };
|
||||
const aStatus = statusOrder[a.status as keyof typeof statusOrder] ?? 7;
|
||||
const bStatus = statusOrder[b.status as keyof typeof statusOrder] ?? 7;
|
||||
|
||||
if (aStatus !== bStatus) return aStatus - bStatus;
|
||||
|
||||
// Then by priority
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 4;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 4;
|
||||
|
||||
if (aPriority !== bPriority) return aPriority - bPriority;
|
||||
|
||||
// Finally by start time
|
||||
const aTime = new Date(a.planned_start_time || a.created_at).getTime();
|
||||
const bTime = new Date(b.planned_start_time || b.created_at).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
return todayBatches.slice(0, maxBatches);
|
||||
}, [productionData, todayDate, maxBatches]);
|
||||
|
||||
const inProgressBatches = productionData?.batches?.filter(
|
||||
b => b.status === 'IN_PROGRESS'
|
||||
).length || 0;
|
||||
|
||||
const completedBatches = productionData?.batches?.filter(
|
||||
b => b.status === 'COMPLETED'
|
||||
).length || 0;
|
||||
|
||||
const delayedBatches = productionData?.batches?.filter(
|
||||
b => b.status === 'FAILED'
|
||||
).length || 0;
|
||||
|
||||
const pendingBatches = productionData?.batches?.filter(
|
||||
b => b.status === 'PENDING'
|
||||
).length || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.production_today', 'Producción de Hoy')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.production_today', 'Producción de Hoy')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
|
||||
<p className="text-[var(--color-error)] text-sm">
|
||||
{t('dashboard:messages.error_loading', 'Error al cargar los datos')}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center justify-between w-full flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.production_today', 'Producción de Hoy')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{delayedBatches > 0 && (
|
||||
<Badge variant="error" size="sm">
|
||||
{delayedBatches} retrasados
|
||||
</Badge>
|
||||
)}
|
||||
{inProgressBatches > 0 && (
|
||||
<Badge variant="info" size="sm">
|
||||
{inProgressBatches} activos
|
||||
</Badge>
|
||||
)}
|
||||
{completedBatches > 0 && (
|
||||
<Badge variant="success" size="sm">
|
||||
{completedBatches} completados
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{new Date(todayDate).toLocaleDateString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="none">
|
||||
{displayBatches.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--color-success)/20' }}
|
||||
>
|
||||
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium mb-2 text-[var(--text-primary)]">
|
||||
{t('dashboard:production.empty', 'Sin producción programada para hoy')}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
No hay lotes programados para iniciar hoy
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{displayBatches.map((batch) => {
|
||||
const statusConfig = getBatchStatusConfig(batch);
|
||||
|
||||
// Calculate progress based on status and time
|
||||
let progress = 0;
|
||||
if (batch.status === 'COMPLETED') {
|
||||
progress = 100;
|
||||
} else if (batch.status === 'IN_PROGRESS' && batch.actual_start_time && batch.planned_duration_minutes) {
|
||||
const elapsed = Date.now() - new Date(batch.actual_start_time).getTime();
|
||||
const elapsedMinutes = elapsed / (1000 * 60);
|
||||
progress = Math.min(Math.round((elapsedMinutes / batch.planned_duration_minutes) * 100), 99);
|
||||
} else if (batch.status === 'QUALITY_CHECK') {
|
||||
progress = 95;
|
||||
}
|
||||
|
||||
const startTime = batch.planned_start_time
|
||||
? new Date(batch.planned_start_time).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'No programado';
|
||||
|
||||
const assignedStaff = batch.staff_assigned && batch.staff_assigned.length > 0
|
||||
? batch.staff_assigned[0]
|
||||
: 'Sin asignar';
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={batch.id}
|
||||
id={batch.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={batch.product_name}
|
||||
subtitle={`Lote ${batch.batch_number} • ${batch.planned_quantity} unidades`}
|
||||
primaryValue={`${progress}%`}
|
||||
primaryValueLabel="PROGRESO"
|
||||
secondaryInfo={{
|
||||
label: 'Panadero asignado',
|
||||
value: assignedStaff
|
||||
}}
|
||||
progress={batch.status !== 'PENDING' ? {
|
||||
label: `Progreso de producción`,
|
||||
percentage: progress,
|
||||
color: progress === 100 ? 'var(--color-success)' :
|
||||
progress > 70 ? 'var(--color-info)' :
|
||||
progress > 30 ? 'var(--color-warning)' : 'var(--color-error)'
|
||||
} : undefined}
|
||||
metadata={[
|
||||
`⏰ Inicio: ${startTime}`,
|
||||
...(batch.planned_duration_minutes ? [`⏱️ Duración: ${formatDuration(batch.planned_duration_minutes)}`] : []),
|
||||
...(batch.station_id ? [`🏭 Estación: ${batch.station_id}`] : []),
|
||||
...(batch.priority === 'URGENT' ? [`⚠️ URGENTE`] : []),
|
||||
...(batch.production_notes ? [`📋 ${batch.production_notes}`] : [])
|
||||
]}
|
||||
actions={[
|
||||
...(batch.status === 'PENDING' ? [{
|
||||
label: 'Iniciar',
|
||||
icon: Play,
|
||||
variant: 'primary' as const,
|
||||
onClick: () => onStartBatch?.(batch.id),
|
||||
priority: 'primary' as const
|
||||
}] : []),
|
||||
...(batch.status === 'IN_PROGRESS' ? [{
|
||||
label: 'Pausar',
|
||||
icon: Pause,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => onPauseBatch?.(batch.id),
|
||||
priority: 'primary' as const,
|
||||
destructive: true
|
||||
}] : []),
|
||||
{
|
||||
label: 'Ver Detalles',
|
||||
icon: ChevronRight,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => onViewDetails?.(batch.id),
|
||||
priority: 'secondary' as const
|
||||
}
|
||||
]}
|
||||
compact={true}
|
||||
className="border-l-4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayBatches.length > 0 && (
|
||||
<div
|
||||
className="p-4 border-t"
|
||||
style={{
|
||||
borderColor: 'var(--border-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{pendingBatches} {t('dashboard:production.batches_pending', 'lotes pendientes')} de {productionData?.batches?.length || 0} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onViewAllPlans && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAllPlans}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Ver Todos los Planes
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayProduction;
|
||||
Reference in New Issue
Block a user