Files
bakery-ia/frontend/src/components/domain/production/ProductionSchedule.tsx
2025-09-05 17:49:48 +02:00

579 lines
21 KiB
TypeScript

import React, { useState, useCallback, useMemo } from 'react';
import { Card, Button, Badge, Select, DatePicker, Input, Modal, Table } from '../../ui';
import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../api/services/production.service';
import type { ProductionSchedule as ProductionScheduleType, ProductionBatch, EquipmentReservation, StaffAssignment } from '../../../types/production.types';
interface ProductionScheduleProps {
className?: string;
onBatchSelected?: (batch: ProductionBatch) => void;
onScheduleUpdate?: (schedule: ProductionScheduleEntry) => void;
}
interface TimeSlot {
time: string;
capacity: number;
utilized: number;
available: number;
batches: ProductionScheduleEntry[];
}
interface CapacityResource {
id: string;
name: string;
type: 'oven' | 'mixer' | 'staff';
capacity: number;
slots: TimeSlot[];
}
const PRODUCT_TYPE_COLORS = {
pan: 'bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20 text-[var(--color-warning)]',
bolleria: 'bg-[var(--color-primary)]/10 border-[var(--color-primary)]/20 text-[var(--color-primary)]',
reposteria: 'bg-[var(--color-error)]/10 border-[var(--color-error)]/20 text-[var(--color-error)]',
especial: 'bg-[var(--color-info)]/10 border-[var(--color-info)]/20 text-[var(--color-info)]',
};
const STATUS_COLORS = {
[ProductionBatchStatus.PLANNED]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
[ProductionBatchStatus.IN_PROGRESS]: 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]',
[ProductionBatchStatus.COMPLETED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)]',
[ProductionBatchStatus.CANCELLED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
[ProductionBatchStatus.ON_HOLD]: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
};
export const ProductionSchedule: React.FC<ProductionScheduleProps> = ({
className = '',
onBatchSelected,
onScheduleUpdate,
}) => {
const [scheduleEntries, setScheduleEntries] = useState<ProductionScheduleEntry[]>([]);
const [loading, setLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewMode, setViewMode] = useState<'calendar' | 'timeline' | 'capacity'>('timeline');
const [filterStatus, setFilterStatus] = useState<ProductionBatchStatus | 'all'>('all');
const [filterProduct, setFilterProduct] = useState<string>('all');
const [draggedBatch, setDraggedBatch] = useState<ProductionScheduleEntry | null>(null);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [selectedBatchForScheduling, setSelectedBatchForScheduling] = useState<ProductionBatch | null>(null);
// Mock data for demonstration
const mockCapacityResources: CapacityResource[] = [
{
id: 'horno-1',
name: 'Horno Principal',
type: 'oven',
capacity: 4,
slots: generateTimeSlots('06:00', '20:00', 2),
},
{
id: 'horno-2',
name: 'Horno Secundario',
type: 'oven',
capacity: 2,
slots: generateTimeSlots('06:00', '18:00', 2),
},
{
id: 'amasadora-1',
name: 'Amasadora Industrial',
type: 'mixer',
capacity: 6,
slots: generateTimeSlots('05:00', '21:00', 1),
},
{
id: 'panadero-1',
name: 'Equipo Panadería',
type: 'staff',
capacity: 8,
slots: generateTimeSlots('06:00', '14:00', 1),
},
];
function generateTimeSlots(startTime: string, endTime: string, intervalHours: number): TimeSlot[] {
const slots: TimeSlot[] = [];
const start = new Date(`2024-01-01 ${startTime}`);
const end = new Date(`2024-01-01 ${endTime}`);
while (start < end) {
const timeStr = start.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
slots.push({
time: timeStr,
capacity: 100,
utilized: Math.floor(Math.random() * 80),
available: 100 - Math.floor(Math.random() * 80),
batches: [],
});
start.setHours(start.getHours() + intervalHours);
}
return slots;
}
const loadSchedule = useCallback(async () => {
setLoading(true);
try {
const response = await productionService.getProductionSchedule({
start_date: selectedDate.toISOString().split('T')[0],
end_date: selectedDate.toISOString().split('T')[0],
});
if (response.success) {
setScheduleEntries(response.data || []);
}
} catch (error) {
console.error('Error loading schedule:', error);
} finally {
setLoading(false);
}
}, [selectedDate]);
const filteredEntries = useMemo(() => {
return scheduleEntries.filter(entry => {
if (filterStatus !== 'all' && entry.batch?.status !== filterStatus) {
return false;
}
if (filterProduct !== 'all' && entry.batch?.recipe?.category !== filterProduct) {
return false;
}
return true;
});
}, [scheduleEntries, filterStatus, filterProduct]);
const handleDragStart = (e: React.DragEvent, entry: ProductionScheduleEntry) => {
setDraggedBatch(entry);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = async (e: React.DragEvent, newTime: string) => {
e.preventDefault();
if (!draggedBatch) return;
try {
const response = await productionService.updateScheduleEntry(draggedBatch.id, {
scheduled_start_time: newTime,
scheduled_end_time: calculateEndTime(newTime, draggedBatch.estimated_duration_minutes),
});
if (response.success && onScheduleUpdate) {
onScheduleUpdate(response.data);
await loadSchedule();
}
} catch (error) {
console.error('Error updating schedule:', error);
} finally {
setDraggedBatch(null);
}
};
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
const start = new Date(`2024-01-01 ${startTime}`);
start.setMinutes(start.getMinutes() + durationMinutes);
return start.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
};
const handleScheduleBatch = async (scheduleData: {
batch_id: string;
scheduled_date: string;
scheduled_start_time: string;
scheduled_end_time: string;
equipment_reservations?: string[];
staff_assignments?: string[];
}) => {
try {
const response = await productionService.scheduleProductionBatch(scheduleData);
if (response.success && onScheduleUpdate) {
onScheduleUpdate(response.data);
await loadSchedule();
setIsScheduleModalOpen(false);
setSelectedBatchForScheduling(null);
}
} catch (error) {
console.error('Error scheduling batch:', error);
}
};
const getProductTypeColor = (category?: string) => {
return PRODUCT_TYPE_COLORS[category as keyof typeof PRODUCT_TYPE_COLORS] || 'bg-[var(--bg-tertiary)] border-[var(--border-secondary)] text-[var(--text-secondary)]';
};
const getStatusColor = (status?: ProductionBatchStatus) => {
return STATUS_COLORS[status as keyof typeof STATUS_COLORS] || 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]';
};
const renderTimelineView = () => (
<div className="space-y-4">
<div className="flex gap-4 items-center mb-6">
<DatePicker
selected={selectedDate}
onChange={(date) => setSelectedDate(date || new Date())}
dateFormat="dd/MM/yyyy"
className="w-40"
/>
<Select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as ProductionBatchStatus | 'all')}
className="w-40"
>
<option value="all">Todos los estados</option>
<option value={ProductionBatchStatus.PLANNED}>Planificado</option>
<option value={ProductionBatchStatus.IN_PROGRESS}>En progreso</option>
<option value={ProductionBatchStatus.COMPLETED}>Completado</option>
</Select>
<Select
value={filterProduct}
onChange={(e) => setFilterProduct(e.target.value)}
className="w-40"
>
<option value="all">Todos los productos</option>
<option value="pan">Pan</option>
<option value="bolleria">Bollería</option>
<option value="reposteria">Repostería</option>
</Select>
</div>
<div className="bg-white rounded-lg border">
<div className="grid grid-cols-25 gap-1 p-4 bg-[var(--bg-secondary)] text-xs font-medium text-[var(--text-tertiary)] border-b">
<div className="col-span-3">Producto</div>
{Array.from({ length: 22 }, (_, i) => (
<div key={i} className="text-center">
{String(i + 3).padStart(2, '0')}:00
</div>
))}
</div>
<div className="divide-y">
{filteredEntries.map((entry) => (
<div
key={entry.id}
className="grid grid-cols-25 gap-1 p-2 hover:bg-[var(--bg-secondary)]"
>
<div className="col-span-3 flex items-center space-x-2">
<div className="flex flex-col">
<span className="font-medium text-sm">{entry.batch?.recipe?.name}</span>
<Badge className={`text-xs ${getStatusColor(entry.batch?.status)}`}>
{entry.batch?.status === ProductionBatchStatus.PLANNED && 'Planificado'}
{entry.batch?.status === ProductionBatchStatus.IN_PROGRESS && 'En progreso'}
{entry.batch?.status === ProductionBatchStatus.COMPLETED && 'Completado'}
</Badge>
</div>
</div>
{Array.from({ length: 22 }, (_, hourIndex) => {
const hour = hourIndex + 3;
const hourStr = `${String(hour).padStart(2, '0')}:00`;
const startHour = parseInt(entry.scheduled_start_time.split(':')[0]);
const endHour = parseInt(entry.scheduled_end_time.split(':')[0]);
const isInRange = hour >= startHour && hour < endHour;
const isStart = hour === startHour;
return (
<div
key={hourIndex}
className={`h-12 border border-[var(--border-primary)] ${isInRange ? getProductTypeColor(entry.batch?.recipe?.category) : ''}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, hourStr)}
>
{isStart && (
<div
className={`h-full w-full rounded px-1 py-1 cursor-move ${getProductTypeColor(entry.batch?.recipe?.category)} border-l-4 border-blue-500`}
draggable
onDragStart={(e) => handleDragStart(e, entry)}
onClick={() => onBatchSelected?.(entry.batch!)}
>
<div className="text-xs font-medium truncate">
{entry.batch?.recipe?.name}
</div>
<div className="text-xs text-[var(--text-secondary)]">
{entry.batch?.planned_quantity} uds
</div>
</div>
)}
</div>
);
})}
</div>
))}
</div>
</div>
</div>
);
const renderCapacityView = () => (
<div className="space-y-6">
<div className="flex gap-4 items-center mb-6">
<DatePicker
selected={selectedDate}
onChange={(date) => setSelectedDate(date || new Date())}
dateFormat="dd/MM/yyyy"
className="w-40"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{mockCapacityResources.map((resource) => (
<Card key={resource.id} className="p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-lg">{resource.name}</h3>
<p className="text-sm text-[var(--text-secondary)]">
{resource.type === 'oven' && 'Horno'}
{resource.type === 'mixer' && 'Amasadora'}
{resource.type === 'staff' && 'Personal'}
- Capacidad: {resource.capacity}
</p>
</div>
<Badge className="bg-[var(--color-info)]/10 text-[var(--color-info)]">
{Math.round(resource.slots.reduce((acc, slot) => acc + slot.utilized, 0) / resource.slots.length)}% utilizado
</Badge>
</div>
<div className="space-y-2">
{resource.slots.slice(0, 8).map((slot, index) => (
<div key={index} className="flex items-center gap-3">
<span className="text-sm font-mono w-16">{slot.time}</span>
<div className="flex-1 h-6 bg-[var(--bg-quaternary)] rounded-full overflow-hidden">
<div
className={`h-full ${
slot.utilized > 80
? 'bg-[var(--color-error)]'
: slot.utilized > 60
? 'bg-yellow-500'
: 'bg-[var(--color-success)]'
}`}
style={{ width: `${slot.utilized}%` }}
/>
</div>
<span className="text-sm text-[var(--text-secondary)] w-12">
{slot.utilized}%
</span>
</div>
))}
</div>
<Button
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={() => {
// Open detailed capacity view
console.log('Open detailed view for', resource.name);
}}
>
Ver detalles completos
</Button>
</Card>
))}
</div>
</div>
);
const renderCalendarView = () => (
<div className="bg-white rounded-lg border p-4">
<div className="grid grid-cols-7 gap-4 mb-4">
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map((day) => (
<div key={day} className="text-center font-semibold text-[var(--text-secondary)] py-2">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-4">
{Array.from({ length: 35 }, (_, i) => {
const date = new Date(selectedDate);
date.setDate(date.getDate() - date.getDay() + i);
const dayEntries = filteredEntries.filter(entry =>
new Date(entry.scheduled_date).toDateString() === date.toDateString()
);
return (
<div
key={i}
className={`min-h-24 p-2 border rounded-lg ${
date.toDateString() === selectedDate.toDateString()
? 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20'
: 'bg-[var(--bg-secondary)] border-[var(--border-primary)]'
}`}
>
<div className="text-sm font-medium mb-1">{date.getDate()}</div>
<div className="space-y-1">
{dayEntries.slice(0, 3).map((entry) => (
<div
key={entry.id}
className={`text-xs p-1 rounded cursor-pointer ${getProductTypeColor(entry.batch?.recipe?.category)}`}
onClick={() => onBatchSelected?.(entry.batch!)}
>
{entry.batch?.recipe?.name}
</div>
))}
{dayEntries.length > 3 && (
<div className="text-xs text-[var(--text-tertiary)]">
+{dayEntries.length - 3} más
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
return (
<div className={`space-y-6 ${className}`}>
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-[var(--text-primary)]">Programación de Producción</h2>
<p className="text-[var(--text-secondary)]">Gestiona y programa la producción diaria de la panadería</p>
</div>
<div className="flex gap-2">
<div className="flex bg-[var(--bg-tertiary)] rounded-lg p-1">
<Button
variant={viewMode === 'calendar' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setViewMode('calendar')}
className="px-3"
>
Calendario
</Button>
<Button
variant={viewMode === 'timeline' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setViewMode('timeline')}
className="px-3"
>
Línea de tiempo
</Button>
<Button
variant={viewMode === 'capacity' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setViewMode('capacity')}
className="px-3"
>
Capacidad
</Button>
</div>
<Button
variant="primary"
onClick={() => setIsScheduleModalOpen(true)}
>
Nueva programación
</Button>
</div>
</div>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<>
{viewMode === 'calendar' && renderCalendarView()}
{viewMode === 'timeline' && renderTimelineView()}
{viewMode === 'capacity' && renderCapacityView()}
</>
)}
{/* Schedule Creation Modal */}
<Modal
isOpen={isScheduleModalOpen}
onClose={() => {
setIsScheduleModalOpen(false);
setSelectedBatchForScheduling(null);
}}
title="Programar Lote de Producción"
>
<div className="space-y-4">
<Select className="w-full">
<option value="">Seleccionar lote...</option>
<option value="batch-1">Lote #001 - Pan de Molde (100 uds)</option>
<option value="batch-2">Lote #002 - Croissants (50 uds)</option>
<option value="batch-3">Lote #003 - Tarta de Chocolate (5 uds)</option>
</Select>
<div className="grid grid-cols-2 gap-4">
<DatePicker
selected={selectedDate}
onChange={(date) => setSelectedDate(date || new Date())}
dateFormat="dd/MM/yyyy"
/>
<Input
type="time"
placeholder="Hora de inicio"
defaultValue="06:00"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
type="number"
placeholder="Duración (horas)"
defaultValue="4"
/>
<Select>
<option value="">Asignar horno...</option>
<option value="horno-1">Horno Principal</option>
<option value="horno-2">Horno Secundario</option>
</Select>
</div>
<Select>
<option value="">Asignar personal...</option>
<option value="panadero-1">Equipo Panadería - Turno Mañana</option>
<option value="panadero-2">Equipo Panadería - Turno Tarde</option>
</Select>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setIsScheduleModalOpen(false)}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={() => {
// Handle schedule creation
setIsScheduleModalOpen(false);
}}
>
Programar lote
</Button>
</div>
</div>
</Modal>
{/* Legend */}
<Card className="p-4">
<h3 className="font-semibold mb-3">Leyenda de productos</h3>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/20 rounded"></div>
<span className="text-sm">Pan</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-[var(--color-primary)]/10 border border-[var(--color-primary)]/20 rounded"></div>
<span className="text-sm">Bollería</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-pink-100 border border-pink-300 rounded"></div>
<span className="text-sm">Repostería</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-purple-100 border border-purple-300 rounded"></div>
<span className="text-sm">Especial</span>
</div>
</div>
</Card>
</div>
);
};
export default ProductionSchedule;