- 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.
407 lines
14 KiB
Vue
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> |