579 lines
21 KiB
TypeScript
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; |