Initial commit from monorepo
This commit is contained in:
272
app/components/GanttTimeline.vue
Normal file
272
app/components/GanttTimeline.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user