Files
webapp/app/components/GanttTimeline.vue
2026-01-07 09:10:35 +07:00

273 lines
10 KiB
Vue

<template>
<div class="w-full">
<ClientOnly>
<Timeline
:groups="timelineGroups"
:items="timelineItems"
:viewportMin="viewportMin"
:viewportMax="viewportMax"
:minViewportDuration="1000 * 60 * 60 * 24 * 3"
:maxViewportDuration="1000 * 60 * 60 * 24 * 14"
:defaultViewportDuration="1000 * 60 * 60 * 24 * 7"
class="h-96"
@item-click="onTripClick"
@item-hover="onTripHover"
/>
<template #fallback>
<div class="h-96 w-full bg-base-200 rounded-lg flex items-center justify-center">
<p class="text-base-content/60">{{ t('ganttTimeline.states.loading') }}</p>
</div>
</template>
</ClientOnly>
<!-- Legend -->
<div class="mt-4 bg-base-200 rounded-box p-4">
<h4 class="font-medium text-sm text-base-content mb-3">{{ t('ganttTimeline.legend.title') }}</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div class="flex items-center space-x-2">
<div class="w-4 h-4 bg-primary rounded"></div>
<span>{{ t('ganttTimeline.legend.auto') }}</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-4 h-4 bg-purple-500 rounded"></div>
<span>{{ t('ganttTimeline.legend.sea') }}</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-4 h-4 bg-orange-400 rounded"></div>
<span>{{ t('ganttTimeline.legend.rail') }}</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-4 h-4 bg-success rounded"></div>
<span>{{ t('ganttTimeline.legend.services') }}</span>
</div>
</div>
</div>
<!-- Trip details modal -->
<div v-if="showTripModal" class="fixed inset-0 bg-base-content/50 flex items-center justify-center z-50" @click="closeTripModal">
<div class="bg-base-100 border border-base-300 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl" @click.stop>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">{{ t('ganttTimeline.modal.title') }}</h3>
<button @click="closeTripModal" class="text-base-content/60 hover:text-base-content">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div v-if="selectedTrip" class="space-y-3">
<div>
<h4 class="font-medium text-base">{{ selectedTrip.trip.name }}</h4>
<p class="text-sm text-base-content/70">{{ selectedTrip.stage.name }}</p>
</div>
<div v-if="selectedTrip.trip.company" class="card bg-base-200 border border-base-300 p-3">
<h5 class="font-medium text-sm mb-1">{{ t('ganttTimeline.modal.company.title') }}</h5>
<p class="text-sm">{{ selectedTrip.trip.company.name }}</p>
<p v-if="selectedTrip.trip.company.taxId" class="text-xs text-base-content/60">{{ t('ganttTimeline.modal.company.tax_id') }} {{ selectedTrip.trip.company.taxId }}</p>
</div>
<div class="card bg-primary/10 border border-base-300 p-3">
<h5 class="font-medium text-sm mb-2">{{ t('ganttTimeline.modal.dates.title') }}</h5>
<div class="space-y-1 text-sm">
<div v-if="selectedTrip.trip.plannedLoadingDate">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.planned_loading') }}</span>
{{ formatDisplayDate(selectedTrip.trip.plannedLoadingDate) }}
</div>
<div v-if="selectedTrip.trip.actualLoadingDate">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.actual_loading') }}</span>
<span class="text-success font-medium">{{ formatDisplayDate(selectedTrip.trip.actualLoadingDate) }}</span>
</div>
<div v-if="selectedTrip.trip.plannedUnloadingDate">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.planned_unloading') }}</span>
{{ formatDisplayDate(selectedTrip.trip.plannedUnloadingDate) }}
</div>
<div v-if="selectedTrip.trip.actualUnloadingDate">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.actual_unloading') }}</span>
<span class="text-success font-medium">{{ formatDisplayDate(selectedTrip.trip.actualUnloadingDate) }}</span>
</div>
</div>
</div>
<div v-if="selectedTrip.trip.plannedWeight || selectedTrip.trip.weightAtLoading || selectedTrip.trip.weightAtUnloading" class="card bg-warning/10 border border-base-300 p-3">
<h5 class="font-medium text-sm mb-2">{{ t('ganttTimeline.modal.weight.title') }}</h5>
<div class="space-y-1 text-sm">
<div v-if="selectedTrip.trip.plannedWeight">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.planned') }}</span> {{ selectedTrip.trip.plannedWeight }} {{ t('ganttTimeline.modal.weight.unit') }}
</div>
<div v-if="selectedTrip.trip.weightAtLoading">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.at_loading') }}</span> {{ selectedTrip.trip.weightAtLoading }} {{ t('ganttTimeline.modal.weight.unit') }}
</div>
<div v-if="selectedTrip.trip.weightAtUnloading">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.at_unloading') }}</span> {{ selectedTrip.trip.weightAtUnloading }} {{ t('ganttTimeline.modal.weight.unit') }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Timeline } from 'vue-timeline-chart'
import 'vue-timeline-chart/style.css'
const props = defineProps({
stages: {
type: Array,
default: () => []
},
showLoading: {
type: Boolean,
default: true
},
showUnloading: {
type: Boolean,
default: true
}
})
const { t } = useI18n()
// Stage groups for timeline (only stages with trips)
const timelineGroups = computed(() => {
return props.stages
.filter(stage => stage.trips && stage.trips.length > 0)
.map(stage => ({
id: stage.uuid,
label: stage.name
}))
})
// Timeline items (trips)
const timelineItems = computed(() => {
const items = []
props.stages.forEach(stage => {
stage.trips?.forEach(trip => {
// Date priority: actualLoadingDate > plannedLoadingDate > current date
const startDate = trip.actualLoadingDate || trip.plannedLoadingDate || new Date().toISOString()
// Date priority: actualUnloadingDate > plannedUnloadingDate > date + 1 day
const endDate = trip.actualUnloadingDate ||
trip.plannedUnloadingDate ||
new Date(new Date(startDate).getTime() + 24 * 60 * 60 * 1000).toISOString()
const startTime = new Date(startDate).getTime()
const endTime = new Date(endDate).getTime()
// Format dates for tooltip
const formatDate = (dateStr) => {
if (!dateStr) return 'Not specified'
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
items.push({
group: stage.uuid,
type: 'range',
start: startTime,
end: endTime,
label: trip.name,
tooltip: `
<div class="p-3">
<h4 class="font-semibold text-sm mb-2">${trip.name}</h4>
<div class="text-xs space-y-1">
<div><strong>Stage:</strong> ${stage.name}</div>
${trip.company?.name ? `<div><strong>Company:</strong> ${trip.company.name}</div>` : ''}
<div><strong>Planned load:</strong> ${formatDate(trip.plannedLoadingDate)}</div>
<div><strong>Planned unload:</strong> ${formatDate(trip.plannedUnloadingDate)}</div>
${trip.actualLoadingDate ? `<div><strong>Actual load:</strong> ${formatDate(trip.actualLoadingDate)}</div>` : ''}
${trip.actualUnloadingDate ? `<div><strong>Actual unload:</strong> ${formatDate(trip.actualUnloadingDate)}</div>` : ''}
${trip.plannedWeight ? `<div><strong>Planned weight:</strong> ${trip.plannedWeight} t</div>` : ''}
</div>
</div>
`,
data: {
trip: trip,
stage: stage
},
cssVariables: {
'--item-background': getStageColor(stage)
}
})
})
})
return items
})
// Dynamic viewport based on data
const viewportMin = computed(() => {
if (timelineItems.value.length === 0) {
return new Date().getTime() - (30 * 24 * 60 * 60 * 1000) // one month back
}
const minTime = Math.min(...timelineItems.value.map(item => item.start))
return minTime - (3 * 24 * 60 * 60 * 1000) // 3 days before earliest
})
const viewportMax = computed(() => {
if (timelineItems.value.length === 0) {
return new Date().getTime() + (60 * 24 * 60 * 60 * 1000) // two months ahead
}
const maxTime = Math.max(...timelineItems.value.map(item => item.end))
return maxTime + (3 * 24 * 60 * 60 * 1000) // 3 days after latest
})
// Modal reactive state
const selectedTrip = ref(null)
const showTripModal = ref(false)
// Timeline handlers
const onTripClick = (event) => {
if (event && event.data) {
selectedTrip.value = event.data
showTripModal.value = true
}
}
const onTripHover = (event) => {
// Tooltip handled automatically via tooltip field
console.log('Trip hovered:', event?.data?.trip?.name)
}
const closeTripModal = () => {
showTripModal.value = false
selectedTrip.value = null
}
// Format date for modal display
const formatDisplayDate = (dateStr) => {
if (!dateStr) return 'Not specified'
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getStageColor = (stage) => {
const colors = {
auto: '#3b82f6', // blue
sea: '#8b5cf6', // purple
rail: '#f97316', // orange
air: '#06b6d4', // cyan
service: '#10b981' // green
}
return colors[stage.transportType] || colors.service
}
</script>