273 lines
10 KiB
Vue
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>
|