erp-platform/ui/src/views/mrp/components/PlanningGantt.tsx
2025-09-17 12:02:03 +03:00

542 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useMemo } from 'react'
import {
FaUser,
FaChevronDown,
FaChevronRight,
FaChevronLeft,
FaChevronRight as FaArrowRight,
} from 'react-icons/fa'
import { PsGanttTask } from '../../../types/ps'
import { mockEmployees } from '../../../mocks/mockEmployees'
import { mockProductionOrders } from '../../../mocks/mockProductionOrders'
import { mockWorkOrders } from '../../../mocks/mockWorkOrders'
import { mockWorkCenters } from '../../../mocks/mockWorkCenters'
import { getFrequencyUnitText, getPriorityColor } from '../../../utils/erp'
import { Container } from '@/components/shared'
import { FrequencyUnitEnum } from '@/types/pm'
interface PlanningGanttProps {
workCenterId?: string
}
const PlanningGantt: React.FC<PlanningGanttProps> = ({ workCenterId }) => {
const getInitialExpandedItems = () => {
const expandedItems = new Set<string>()
const firstTwoOrders = mockProductionOrders.slice(0, 2)
firstTwoOrders.forEach((order) => {
expandedItems.add(`prod-${order.id}`)
const orderWorkOrders = mockWorkOrders.filter((wo) => wo.productionOrderId === order.id)
orderWorkOrders.forEach((wo) => {
expandedItems.add(`work-${wo.id}`)
})
})
return expandedItems
}
const [expandedItems, setExpandedItems] = useState<Set<string>>(getInitialExpandedItems())
const [selectedWorkCenter, setSelectedWorkCenter] = useState<string>(workCenterId || '')
const [viewMode, setViewMode] = useState<'day' | 'week' | 'month' | 'year'>('week')
const [currentDate, setCurrentDate] = useState<Date>(new Date())
const generateDateRange = () => {
const startDate = new Date(currentDate)
if (viewMode === 'day') {
const hours = []
for (let i = 0; i < 24; i++) {
const hour = new Date(startDate)
hour.setHours(i, 0, 0, 0)
hours.push(hour)
}
return hours
} else if (viewMode === 'week') {
const weekStart = new Date(startDate)
const dayOfWeek = weekStart.getDay()
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1
weekStart.setDate(weekStart.getDate() - daysToSubtract)
weekStart.setHours(0, 0, 0, 0)
const dates = []
for (let i = 0; i < 7; i++) {
const date = new Date(weekStart)
date.setDate(weekStart.getDate() + i)
dates.push(date)
}
return dates
} else if (viewMode === 'month') {
const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)
const daysInMonth = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
0,
).getDate()
const dates = []
for (let i = 0; i < daysInMonth; i++) {
const date = new Date(monthStart)
date.setDate(monthStart.getDate() + i)
dates.push(date)
}
return dates
} else {
const yearStart = new Date(currentDate.getFullYear(), 0, 1)
const months = []
for (let i = 0; i < 12; i++) {
const month = new Date(yearStart)
month.setMonth(i)
months.push(month)
}
return months
}
}
const dateRange = generateDateRange()
const filteredData = useMemo(() => {
const createGanttData = (): PsGanttTask[] => {
return mockProductionOrders.map((order) => {
const workOrders = mockWorkOrders
.filter((wo) => wo.productionOrderId === order.id)
.map((wo) => ({
id: `work-${wo.id}`,
name: wo.operation?.name || wo.workOrderNumber,
type: 'task' as const,
startDate: wo.plannedStartDate,
endDate: wo.plannedEndDate,
progress: Math.round((wo.confirmedQuantity / wo.plannedQuantity) * 100) || 0,
assignee: wo.assignedOperators[0]
? mockEmployees.find((e) => e.id === wo.assignedOperators[0])
: undefined,
status: wo.status,
priority: order.priority,
level: 1,
children: [],
estimatedHours: wo.processTime / 60,
hoursWorked: wo.actualProcessTime ? wo.actualProcessTime / 60 : 0,
}))
return {
id: `prod-${order.id}`,
name: `${order.orderNumber} - ${order.materials.map((m) => m.material?.name).join(', ')}`,
type: 'project' as const,
startDate: order.plannedStartDate,
endDate: order.plannedEndDate,
progress:
order.confirmedQuantity && order.plannedQuantity
? Math.round((order.confirmedQuantity / order.plannedQuantity) * 100)
: 0,
status: order.status,
priority: order.priority,
level: 0,
children: workOrders,
}
})
}
const filterByWorkCenter = (tasks: PsGanttTask[]): PsGanttTask[] => {
if (!selectedWorkCenter) return tasks
return tasks
.map((task) => {
if (task.type === 'task' && task.assignee?.id !== selectedWorkCenter) {
return null
}
if (task.children) {
const filteredChildren = filterByWorkCenter(task.children).filter(
Boolean,
) as PsGanttTask[]
if (filteredChildren.length > 0) {
return { ...task, children: filteredChildren }
} else if (task.type === 'task' && task.assignee?.id === selectedWorkCenter) {
return task
}
return null
}
return task
})
.filter(Boolean) as PsGanttTask[]
}
const allGanttTasks = createGanttData()
return filterByWorkCenter(allGanttTasks)
}, [selectedWorkCenter])
const toggleExpand = (id: string) => {
const newExpanded = new Set(expandedItems)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedItems(newExpanded)
}
const navigatePrevious = () => {
const newDate = new Date(currentDate)
if (viewMode === 'day') newDate.setDate(newDate.getDate() - 1)
else if (viewMode === 'week') newDate.setDate(newDate.getDate() - 7)
else if (viewMode === 'month') newDate.setMonth(newDate.getMonth() - 1)
else newDate.setFullYear(newDate.getFullYear() - 1)
setCurrentDate(newDate)
}
const navigateNext = () => {
const newDate = new Date(currentDate)
if (viewMode === 'day') newDate.setDate(newDate.getDate() + 1)
else if (viewMode === 'week') newDate.setDate(newDate.getDate() + 7)
else if (viewMode === 'month') newDate.setMonth(newDate.getMonth() + 1)
else newDate.setFullYear(newDate.getFullYear() + 1)
setCurrentDate(newDate)
}
const navigateToday = () => {
setCurrentDate(new Date())
}
const getCurrentDateInfo = () => {
if (viewMode === 'day') {
return currentDate.toLocaleDateString('tr-TR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
} else if (viewMode === 'week') {
const weekStart = new Date(currentDate)
const dayOfWeek = weekStart.getDay()
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1
weekStart.setDate(weekStart.getDate() - daysToSubtract)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 6)
return `${weekStart.toLocaleDateString('tr-TR', {
day: 'numeric',
month: 'short',
})} - ${weekEnd.toLocaleDateString('tr-TR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}`
} else if (viewMode === 'month') {
return currentDate.toLocaleDateString('tr-TR', {
year: 'numeric',
month: 'long',
})
} else {
return currentDate.toLocaleDateString('tr-TR', { year: 'numeric' })
}
}
const calculateTaskPosition = (startDate: Date, endDate: Date) => {
const chartStart = dateRange[0]
const chartEnd = dateRange[dateRange.length - 1]
const taskStart = new Date(startDate)
const taskEnd = new Date(endDate)
if (viewMode === 'day') {
const dayStart = new Date(chartStart)
dayStart.setHours(0, 0, 0, 0)
const dayEnd = new Date(chartStart)
dayEnd.setHours(23, 59, 59, 999)
if (taskEnd < dayStart || taskStart > dayEnd)
return { left: '100%', width: '0%', isVisible: false }
const startHour =
taskStart > dayStart ? taskStart.getHours() + taskStart.getMinutes() / 60 : 0
const endHour = taskEnd < dayEnd ? taskEnd.getHours() + taskEnd.getMinutes() / 60 : 24
const left = (startHour / 24) * 100
const width = Math.max(1, ((endHour - startHour) / 24) * 100)
return { left: `${left}%`, width: `${width}%`, isVisible: true }
} else if (viewMode === 'week') {
const weekStart = new Date(chartStart)
const weekEnd = new Date(chartEnd)
weekEnd.setHours(23, 59, 59, 999)
if (taskEnd < weekStart || taskStart > weekEnd)
return { left: '100%', width: '0%', isVisible: false }
const startDay =
taskStart > weekStart
? Math.floor((taskStart.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24))
: 0
const endDay =
taskEnd < weekEnd
? Math.ceil((taskEnd.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24))
: 7
const left = (startDay / 7) * 100
const width = Math.max(1, ((endDay - startDay) / 7) * 100)
return { left: `${left}%`, width: `${width}%`, isVisible: true }
} else if (viewMode === 'month') {
const monthStart = new Date(chartStart)
const monthEnd = new Date(chartEnd)
monthEnd.setHours(23, 59, 59, 999)
if (taskEnd < monthStart || taskStart > monthEnd)
return { left: '100%', width: '0%', isVisible: false }
const daysInMonth = dateRange.length
const startDay =
taskStart > monthStart
? Math.floor((taskStart.getTime() - monthStart.getTime()) / (1000 * 60 * 60 * 24))
: 0
const endDay =
taskEnd < monthEnd
? Math.ceil((taskEnd.getTime() - monthStart.getTime()) / (1000 * 60 * 60 * 24))
: daysInMonth
const left = (startDay / daysInMonth) * 100
const width = Math.max(1, ((endDay - startDay) / daysInMonth) * 100)
return { left: `${left}%`, width: `${width}%`, isVisible: true }
} else {
const yearStart = new Date(chartStart)
const yearEnd = new Date(chartEnd)
yearEnd.setMonth(11, 31)
yearEnd.setHours(23, 59, 59, 999)
if (taskEnd < yearStart || taskStart > yearEnd)
return { left: '100%', width: '0%', isVisible: false }
const startMonth = taskStart > yearStart ? taskStart.getMonth() : 0
const endMonth = taskEnd < yearEnd ? taskEnd.getMonth() + 1 : 12
const left = (startMonth / 12) * 100
const width = Math.max(1, ((endMonth - startMonth) / 12) * 100)
return { left: `${left}%`, width: `${width}%`, isVisible: true }
}
}
const renderTask = (task: PsGanttTask): React.ReactNode => {
const hasChildren = task.children && task.children.length > 0
const isExpanded = expandedItems.has(task.id)
const indent = task.level * 20
return (
<React.Fragment key={task.id}>
<div className="flex items-center border-b border-gray-100 hover:bg-gray-50 min-h-[36px]">
{/* Task Info */}
<div className="w-1/3 sm:w-1/4 p-1 border-r border-gray-200">
<div className="flex items-center" style={{ paddingLeft: `${Math.min(indent, 30)}px` }}>
{hasChildren && (
<button
onClick={() => toggleExpand(task.id)}
className="mr-1 p-0.5 hover:bg-gray-200 rounded"
>
{isExpanded ? (
<FaChevronDown className="h-2.5 w-2.5" />
) : (
<FaChevronRight className="h-2.5 w-2.5" />
)}
</button>
)}
{!hasChildren && <div className="w-3 sm:w-5"></div>}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 sm:gap-2">
<span
className={`font-medium text-xs truncate ${
task.type === 'project' ? 'text-blue-900 font-semibold' : 'text-gray-900'
}`}
title={task.name}
>
{task.name}
</span>
<span
className={`px-1 py-0 text-[10px] border rounded flex-shrink-0 hidden sm:inline ${getPriorityColor(
task.priority,
)}`}
>
{task.priority}
</span>
</div>
{task.type === 'task' && task.assignee && (
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-600">
<FaUser className="h-2 w-2 flex-shrink-0" />
<span className="truncate text-xs">
{task.assignee.firstName} {task.assignee.lastName}
</span>
{task.hoursWorked && task.estimatedHours && (
<span className="text-xs bg-gray-100 px-1 py-0.5 rounded flex-shrink-0 hidden md:inline">
{task.hoursWorked}h / {task.estimatedHours}h
</span>
)}
</div>
)}
<div className="mt-1 text-xs text-gray-500 truncate hidden sm:block">
{task.startDate.toLocaleDateString('tr-TR')} -{' '}
{task.endDate.toLocaleDateString('tr-TR')}
</div>
</div>
</div>
</div>
{/* Gantt Chart */}
<div className="w-2/3 sm:w-3/4 p-1 relative">
<div className="relative h-6 bg-gray-100 rounded">
{(() => {
const position = calculateTaskPosition(task.startDate, task.endDate)
if (task.type === 'task' && position.isVisible) {
return (
<div
className={`absolute h-5 mt-0.5 rounded shadow-sm group cursor-pointer border border-white border-opacity-20`}
style={{ left: position.left, width: position.width }}
title={`${task.name} - ${task.progress}% tamamlandı`}
>
<div
className="h-full bg-black bg-opacity-15 rounded-lg relative overflow-hidden"
style={{ width: `${task.progress}%` }}
>
<div className="absolute inset-0 bg-white bg-opacity-20"></div>
</div>
<div className="absolute inset-0 flex items-center justify-center text-white text-[10px] font-bold drop-shadow-sm z-10">
{task.progress}%
</div>
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap z-20 hidden sm:block">
<div className="font-semibold">{task.name}</div>
<div className="mt-1">İlerleme: {task.progress}% tamamlandı</div>
<div>Planlanan Başlangıç: {task.startDate.toLocaleDateString('tr-TR')}</div>
<div>Planlanan Bitiş: {task.endDate.toLocaleDateString('tr-TR')}</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-gray-800"></div>
</div>
</div>
)
}
if (task.type !== 'task' && position.isVisible) {
return (
<div
className="absolute h-2 mt-2 bg-gradient-to-r from-gray-400 to-gray-600 rounded-full group cursor-pointer shadow-sm border border-white border-opacity-30"
style={{ left: position.left, width: position.width }}
title={`${task.name}`}
>
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap z-20 hidden sm:block">
<div className="font-semibold">{task.name}</div>
<div>Planlanan Başlangıç: {task.startDate.toLocaleDateString('tr-TR')}</div>
<div>Planlanan Bitiş: {task.endDate.toLocaleDateString('tr-TR')}</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-gray-800"></div>
</div>
</div>
)
}
return null
})()}
</div>
</div>
</div>
{hasChildren && isExpanded && task.children?.map((child) => renderTask(child))}
</React.Fragment>
)
}
return (
<Container>
<div className="mb-3">
<div className="flex items-center gap-3">
<div>
<h2 className="text-2xl font-bold text-gray-900">Planlama Gantt Şeması</h2>
<p className="text-gray-600">Üretim ve emirlerinizi zaman çizelgesinde yönetin.</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border h-full flex flex-col">
<div className="p-2 border-b border-gray-200 flex-shrink-0">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center justify-center gap-2">
<button
onClick={navigatePrevious}
className="p-1.5 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<FaChevronLeft className="h-4 w-4" />
</button>
<div className="text-sm font-semibold text-gray-900 min-w-[180px] text-center">
{getCurrentDateInfo()}
</div>
<button
onClick={navigateNext}
className="p-1.5 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<FaArrowRight className="h-4 w-4" />
</button>
<button
onClick={navigateToday}
className="px-2 py-1 text-xs bg-blue-600 text-white hover:bg-blue-700 rounded-lg transition-colors"
>
Bugün
</button>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
<select
value={selectedWorkCenter}
onChange={(e) => setSelectedWorkCenter(e.target.value)}
className="px-2 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full sm:w-auto"
>
<option value="">Tüm İş Merkezleri</option>
{mockWorkCenters.map((wc) => (
<option key={wc.id} value={wc.id}>
{wc.name}
</option>
))}
</select>
<select
value={viewMode}
onChange={(e) => setViewMode(e.target.value as 'day' | 'week' | 'month' | 'year')}
className="px-2 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full sm:w-auto"
>
<option value={FrequencyUnitEnum.Days}>
{getFrequencyUnitText(FrequencyUnitEnum.Days)}
</option>
<option value={FrequencyUnitEnum.Weeks}>
{getFrequencyUnitText(FrequencyUnitEnum.Days)}
</option>
<option value={FrequencyUnitEnum.Months}>
{getFrequencyUnitText(FrequencyUnitEnum.Days)}
</option>
<option value={FrequencyUnitEnum.Years}>
{getFrequencyUnitText(FrequencyUnitEnum.Days)}
</option>
</select>
</div>
</div>
</div>
<div className="flex border-b border-gray-200 flex-shrink-0">
<div className="w-1/3 sm:w-1/4 p-2 border-r border-gray-200 bg-gray-50 font-semibold text-xs">
Üretim Emri / İş Emri
</div>
<div className="w-2/3 sm:w-3/4 p-2 bg-gray-50">
<div className="flex justify-between text-xs font-semibold">
{dateRange.map((date) => (
<div key={date.toISOString()} className="text-center min-w-0 flex-1">
<div className="truncate">
{viewMode === 'day'
? date.toLocaleTimeString('tr-TR', {
hour: '2-digit',
minute: '2-digit',
})
: viewMode === 'week'
? date.toLocaleDateString('tr-TR', { day: '2-digit' })
: viewMode === 'year'
? date.toLocaleDateString('tr-TR', { month: 'short' })
: date.toLocaleDateString('tr-TR', { day: '2-digit' })}
</div>
{viewMode !== 'day' && viewMode !== 'year' && (
<div className="text-xs text-gray-500 hidden sm:block">
{date.toLocaleDateString('tr-TR', { weekday: 'short' })}
</div>
)}
{viewMode === 'year' && (
<div className="text-xs text-gray-500 hidden sm:block">
{date.getFullYear()}
</div>
)}
</div>
))}
</div>
</div>
</div>
<div className="flex-1 overflow-auto">{filteredData.map((task) => renderTask(task))}</div>
</div>
</Container>
)
}
export default PlanningGantt