corrad-bp/components/ProcessJourneyTimeline.vue
Md Afiq Iskandar 35a0bd412e Refactor Navigation and Enhance Form Builder UI
- Simplified navigation structure by consolidating child routes for Process and Form Designers, improving clarity and accessibility.
- Updated Form Builder and Process Builder header styles for a more modern look, including logo and title adjustments.
- Enhanced button styles and layout in the Form Builder and Process Management pages for better user interaction.
- Introduced new dashboard metrics and recent activity sections in Process Management, providing users with quick insights into their processes.
- Improved overall responsiveness and visual consistency across the application.
2025-07-11 14:50:42 +08:00

407 lines
14 KiB
Vue

<script setup>
import { ref, computed } from 'vue';
// Props
const props = defineProps({
journeyData: {
type: Array,
required: true,
default: () => []
},
showMetrics: {
type: Boolean,
default: true
},
showIssues: {
type: Boolean,
default: true
},
showBranches: {
type: Boolean,
default: true
},
interactive: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'medium', // 'small', 'medium', 'large'
validator: (value) => ['small', 'medium', 'large'].includes(value)
}
});
// Emits
const emit = defineEmits(['nodeClick', 'issueClick']);
// State
const selectedNodeId = ref(null);
const hoveredNodeId = ref(null);
// Computed properties
const sizeClasses = computed(() => {
switch (props.size) {
case 'small':
return {
container: 'text-sm',
icon: 'w-4 h-4',
iconContainer: 'p-2',
card: 'p-3',
spacing: 'space-y-4'
};
case 'large':
return {
container: 'text-base',
icon: 'w-8 h-8',
iconContainer: 'p-4',
card: 'p-6',
spacing: 'space-y-10'
};
default: // medium
return {
container: 'text-sm',
icon: 'w-6 h-6',
iconContainer: 'p-3',
card: 'p-4',
spacing: 'space-y-8'
};
}
});
// Helper functions
const getNodeIcon = (nodeType) => {
switch (nodeType) {
case 'start': return 'material-symbols:play-circle-outline';
case 'form': return 'material-symbols:description-outline';
case 'task': return 'material-symbols:task-outline';
case 'gateway': return 'material-symbols:alt-route';
case 'exclusive-gateway': return 'material-symbols:call-split';
case 'parallel-gateway': return 'material-symbols:merge';
case 'merge': return 'material-symbols:merge';
case 'api': return 'material-symbols:api';
case 'script': return 'material-symbols:code';
case 'timer': return 'material-symbols:timer-outline';
case 'message': return 'material-symbols:mail-outline';
case 'end': return 'material-symbols:check-circle-outline';
default: return 'material-symbols:circle-outline';
}
};
const getNodeColor = (nodeType, dropoffRate = 0) => {
// Color based on dropoff rate for critical assessment
if (dropoffRate > 15) return 'text-red-600 bg-red-50 border-red-200';
if (dropoffRate > 10) return 'text-orange-600 bg-orange-50 border-orange-200';
if (dropoffRate > 5) return 'text-yellow-600 bg-yellow-50 border-yellow-200';
// Default colors by node type
switch (nodeType) {
case 'start': return 'text-green-600 bg-green-50 border-green-200';
case 'form': return 'text-blue-600 bg-blue-50 border-blue-200';
case 'task': return 'text-indigo-600 bg-indigo-50 border-indigo-200';
case 'gateway': return 'text-purple-600 bg-purple-50 border-purple-200';
case 'exclusive-gateway': return 'text-purple-600 bg-purple-50 border-purple-200';
case 'parallel-gateway': return 'text-orange-600 bg-orange-50 border-orange-200';
case 'merge': return 'text-orange-600 bg-orange-50 border-orange-200';
case 'api': return 'text-cyan-600 bg-cyan-50 border-cyan-200';
case 'script': return 'text-slate-600 bg-slate-50 border-slate-200';
case 'timer': return 'text-amber-600 bg-amber-50 border-amber-200';
case 'message': return 'text-teal-600 bg-teal-50 border-teal-200';
case 'end': return 'text-green-600 bg-green-50 border-green-200';
default: return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
const getDropoffColor = (rate) => {
if (rate === 0) return 'text-green-600';
if (rate < 5) return 'text-yellow-600';
if (rate < 10) return 'text-orange-600';
return 'text-red-600';
};
const getCompletionRate = (node) => {
if (node.totalEntered === 0) return 0;
return ((node.totalCompleted / node.totalEntered) * 100).toFixed(1);
};
const formatDuration = (timeString) => {
if (!timeString) return 'N/A';
// Parse various time formats
if (timeString.includes('minute')) {
const minutes = parseInt(timeString);
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
if (timeString.includes('hour')) {
const hours = parseFloat(timeString);
return `${hours}h`;
}
if (timeString.includes('day')) {
const days = parseFloat(timeString);
if (days < 1) {
const hours = Math.round(days * 24);
return `${hours}h`;
}
return `${days}d`;
}
return timeString;
};
const getIssueIcon = (issue) => {
if (issue.toLowerCase().includes('validation')) return 'material-symbols:error-outline';
if (issue.toLowerCase().includes('timeout')) return 'material-symbols:timer-off';
if (issue.toLowerCase().includes('unavailable')) return 'material-symbols:person-off';
if (issue.toLowerCase().includes('notification')) return 'material-symbols:notifications-off';
if (issue.toLowerCase().includes('system')) return 'material-symbols:warning-outline';
if (issue.toLowerCase().includes('session')) return 'material-symbols:logout';
return 'material-symbols:info-outline';
};
const handleNodeClick = (node) => {
if (props.interactive) {
selectedNodeId.value = selectedNodeId.value === node.nodeId ? null : node.nodeId;
emit('nodeClick', node);
}
};
const handleIssueClick = (issue, node) => {
if (props.interactive) {
emit('issueClick', { issue, node });
}
};
</script>
<template>
<div :class="['process-journey-timeline', sizeClasses.container]">
<!-- Journey Flow -->
<div :class="['journey-flow', sizeClasses.spacing]">
<div
v-for="(node, index) in journeyData"
:key="node.nodeId"
class="relative"
@mouseover="hoveredNodeId = node.nodeId"
@mouseleave="hoveredNodeId = null"
>
<!-- Connection Line -->
<div
v-if="index < journeyData.length - 1"
class="absolute left-6 w-0.5 bg-gray-200 z-0"
:class="[
size === 'small' ? 'top-8 h-8' :
size === 'large' ? 'top-16 h-24' :
'top-12 h-16'
]"
></div>
<!-- Node Container -->
<div
class="relative z-10 flex items-start gap-4"
:class="[
interactive && 'cursor-pointer',
selectedNodeId === node.nodeId && 'ring-2 ring-blue-500 ring-offset-2 rounded-lg'
]"
@click="handleNodeClick(node)"
>
<!-- Node Icon -->
<div
:class="[
'rounded-lg border-2 border-white shadow-sm transition-all duration-200 flex-shrink-0',
sizeClasses.iconContainer,
getNodeColor(node.nodeType, node.dropoffRate),
hoveredNodeId === node.nodeId && 'scale-110 shadow-md'
]"
>
<Icon
:name="getNodeIcon(node.nodeType)"
:class="sizeClasses.icon"
/>
</div>
<!-- Node Content -->
<div class="flex-1 min-w-0">
<!-- Node Header -->
<div class="flex items-start justify-between mb-2">
<div class="min-w-0 flex-1">
<h4 class="font-semibold text-gray-900 truncate">{{ node.nodeName }}</h4>
<p class="text-xs text-gray-600 capitalize">{{ node.nodeType.replace('-', ' ') }} node</p>
</div>
<div v-if="showMetrics" class="text-right ml-4 flex-shrink-0">
<p class="text-xs font-medium text-gray-900">
{{ node.totalCompleted }}/{{ node.totalEntered }}
</p>
<p :class="['text-xs font-medium', getDropoffColor(node.dropoffRate)]">
{{ node.dropoffRate }}% loss
</p>
</div>
</div>
<!-- Progress Bar -->
<div v-if="showMetrics" class="mb-3">
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="[
node.dropoffRate > 10 ? 'bg-red-500' :
node.dropoffRate > 5 ? 'bg-orange-500' :
'bg-green-500'
]"
:style="`width: ${getCompletionRate(node)}%`"
></div>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>{{ getCompletionRate(node) }}% success</span>
<span>{{ formatDuration(node.averageTime) }} avg</span>
</div>
</div>
<!-- Node Metrics Grid -->
<div v-if="showMetrics" :class="['grid gap-2 mb-3', size === 'small' ? 'grid-cols-2' : 'grid-cols-4']">
<div class="bg-gray-50 rounded p-2">
<p class="text-xs text-gray-500">Entered</p>
<p class="font-medium text-gray-900">{{ node.totalEntered.toLocaleString() }}</p>
</div>
<div class="bg-gray-50 rounded p-2">
<p class="text-xs text-gray-500">Completed</p>
<p class="font-medium text-gray-900">{{ node.totalCompleted.toLocaleString() }}</p>
</div>
<div v-if="size !== 'small'" class="bg-gray-50 rounded p-2">
<p class="text-xs text-gray-500">Avg. Time</p>
<p class="font-medium text-gray-900">{{ formatDuration(node.averageTime) }}</p>
</div>
<div v-if="size !== 'small'" class="bg-gray-50 rounded p-2">
<p class="text-xs text-gray-500">Success Rate</p>
<p class="font-medium text-gray-900">{{ getCompletionRate(node) }}%</p>
</div>
</div>
<!-- Gateway Branches -->
<div v-if="showBranches && node.branches && node.branches.length > 0" class="mb-3">
<p class="text-xs text-gray-500 mb-2 font-medium">Decision Branches</p>
<div class="space-y-2">
<div
v-for="branch in node.branches"
:key="branch.condition"
class="flex items-center justify-between bg-blue-50 rounded-lg p-2 border border-blue-100"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<Icon name="material-symbols:arrow-forward" class="w-3 h-3 text-blue-600 flex-shrink-0" />
<span class="text-sm text-blue-900 truncate">{{ branch.condition }}</span>
</div>
<div class="flex items-center gap-2 ml-3 flex-shrink-0">
<span class="text-sm font-medium text-blue-900">{{ branch.taken }}</span>
<span class="text-xs text-blue-700 bg-blue-100 px-2 py-1 rounded">
{{ branch.percentage }}%
</span>
</div>
</div>
</div>
</div>
<!-- Issues Section -->
<div v-if="showIssues && node.issues && node.issues.length > 0" class="bg-red-50 border border-red-200 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<Icon name="material-symbols:warning" class="w-4 h-4 text-red-600" />
<p class="text-xs font-medium text-red-800">Issues Identified</p>
</div>
<ul class="space-y-1">
<li
v-for="(issue, issueIndex) in node.issues"
:key="issueIndex"
class="flex items-start gap-2 group"
:class="interactive && 'cursor-pointer hover:bg-red-100 rounded p-1 -m-1'"
@click="handleIssueClick(issue, node)"
>
<Icon
:name="getIssueIcon(issue)"
class="w-3 h-3 text-red-600 mt-0.5 flex-shrink-0"
/>
<span class="text-xs text-red-700 flex-1">{{ issue }}</span>
<Icon
v-if="interactive"
name="material-symbols:chevron-right"
class="w-3 h-3 text-red-500 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</li>
</ul>
</div>
<!-- Success Indicator for nodes with no issues -->
<div v-else-if="showIssues && node.dropoffRate === 0" class="bg-green-50 border border-green-200 rounded-lg p-2">
<div class="flex items-center gap-2">
<Icon name="material-symbols:check-circle" class="w-4 h-4 text-green-600" />
<p class="text-xs font-medium text-green-800">No issues detected</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Legend -->
<div v-if="size !== 'small'" class="mt-6 bg-gray-50 rounded-lg p-4">
<h5 class="text-xs font-medium text-gray-900 mb-3">Legend</h5>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 text-xs">
<div class="flex items-center gap-2">
<div class="w-3 h-3 bg-green-500 rounded"></div>
<span class="text-gray-700">Good (0-5% loss)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 bg-yellow-500 rounded"></div>
<span class="text-gray-700">Warning (5-10% loss)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 bg-orange-500 rounded"></div>
<span class="text-gray-700">Concern (10-15% loss)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 bg-red-500 rounded"></div>
<span class="text-gray-700">Critical (>15% loss)</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.process-journey-timeline {
position: relative;
}
.journey-flow {
position: relative;
}
/* Smooth transitions for all interactive elements */
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* Hover effects */
.group:hover .group-hover\:opacity-100 {
opacity: 1;
}
/* Custom scrollbar for overflow content */
.journey-flow::-webkit-scrollbar {
width: 6px;
}
.journey-flow::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.journey-flow::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.journey-flow::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>