Add Interactive Edges and Edge Configuration to Process Flow

- Introduced new components: ArrowEdge, CustomEdge, InteractiveArrowEdge, and EdgeConfiguration for enhanced edge management in the process flow.
- Implemented dynamic edge paths with customizable styles, labels, and animations, improving visual representation and user interaction.
- Enhanced ProcessFlowCanvas to support new edge types and configurations, allowing for more flexible process designs.
- Updated ProcessFlowNodes to include new edge components, ensuring seamless integration with existing node functionalities.
- Improved user experience by providing configuration options for edges, including animation and style settings, directly within the process builder.
This commit is contained in:
Md Afiq Iskandar 2025-07-11 14:13:42 +08:00
parent b4eb3265c2
commit 8919ac819c
15 changed files with 8509 additions and 362 deletions

View File

@ -0,0 +1,140 @@
<script setup>
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath } from '@vue-flow/core'
import { computed } from 'vue'
const props = defineProps({
sourceX: {
type: Number,
required: true,
},
sourceY: {
type: Number,
required: true,
},
targetX: {
type: Number,
required: true,
},
targetY: {
type: Number,
required: true,
},
sourcePosition: {
type: String,
required: true,
},
targetPosition: {
type: String,
required: true,
},
selected: {
type: Boolean,
default: false,
},
animated: {
type: Boolean,
default: false,
},
label: {
type: String,
default: '',
},
style: {
type: Object,
default: () => ({}),
},
markerEnd: {
type: String,
default: '',
},
data: {
type: Object,
default: () => ({}),
}
})
const path = computed(() => {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
borderRadius: 8,
offset: 20
})
return {
path: edgePath,
labelX,
labelY
}
})
const edgeStyle = computed(() => ({
stroke: props.selected ? '#ff6b6b' : '#555',
strokeWidth: props.selected ? 3 : 2,
strokeDasharray: props.animated ? '5,5' : 'none',
...props.style
}))
const markerEndUrl = computed(() => {
return props.selected ? 'url(#arrow-selected)' : 'url(#arrow)'
})
</script>
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<!-- Main edge path -->
<BaseEdge
:path="path.path"
:style="edgeStyle"
:marker-end="markerEndUrl"
:class="{
'animated': animated,
'selected': selected
}"
/>
<!-- Edge label if provided -->
<EdgeLabelRenderer v-if="label">
<div
:style="{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${path.labelX}px, ${path.labelY}px)`,
pointerEvents: 'all',
fontSize: '11px',
fontWeight: '600',
background: 'white',
padding: '3px 8px',
borderRadius: '12px',
border: '1.5px solid #3b82f6',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.2)',
color: '#1e40af',
whiteSpace: 'nowrap',
zIndex: 10
}"
class="edge-label nodrag nopan"
>
{{ label }}
</div>
</EdgeLabelRenderer>
</template>
<style scoped>
.animated {
animation: dash 2s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -10;
}
}
</style>

View File

@ -0,0 +1,285 @@
<template>
<g>
<!-- Main edge path -->
<BaseEdge
:id="id"
:path="edgePath"
:style="{
stroke: selected ? '#ff6b6b' : edgeStyle.stroke || '#555',
strokeWidth: selected ? 3 : edgeStyle.strokeWidth || 2,
...edgeStyle
}"
:marker-end="selected ? 'url(#arrow-selected)' : 'url(#arrow)'"
:class="{
'vue-flow__edge-path': true,
'custom-edge': true,
'selected': selected,
'animated': animated
}"
/>
<!-- Interactive control points for path adjustment -->
<g v-if="selected && showControlPoints" class="edge-control-points">
<!-- Mid-point control for path bending -->
<circle
:cx="labelX"
:cy="labelY"
r="4"
fill="#3b82f6"
stroke="white"
stroke-width="2"
class="edge-control-point"
style="cursor: move;"
@mousedown="onControlPointMouseDown"
@touchstart="onControlPointMouseDown"
/>
<!-- Visual indicator for draggable control -->
<circle
:cx="labelX"
:cy="labelY"
r="8"
fill="rgba(59, 130, 246, 0.2)"
class="edge-control-indicator"
style="pointer-events: none;"
/>
</g>
<!-- Edge label using EdgeLabelRenderer for HTML content -->
<EdgeLabelRenderer v-if="label">
<div
:style="{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
fontSize: '12px',
fontWeight: '500'
}"
class="edge-label-container nodrag nopan"
>
<div class="edge-label">
{{ label }}
</div>
</div>
</EdgeLabelRenderer>
</g>
</template>
<script setup>
import { computed, ref, inject } from 'vue'
import { BaseEdge, EdgeLabelRenderer, getBezierPath, getSmoothStepPath, getStraightPath } from '@vue-flow/core'
import { useEdge } from '@vue-flow/core'
const props = defineProps({
id: String,
sourceX: Number,
sourceY: Number,
targetX: Number,
targetY: Number,
sourcePosition: String,
targetPosition: String,
selected: Boolean,
animated: Boolean,
label: String,
style: Object,
type: {
type: String,
default: 'smoothstep'
},
data: Object,
markerEnd: String,
markerStart: String
})
// Get edge instance for updating
const { edge } = useEdge()
// Control state
const showControlPoints = ref(true)
const isDragging = ref(false)
const pathOffset = ref({ x: 0, y: 0 })
// Computed edge style
const edgeStyle = computed(() => ({
stroke: '#555',
strokeWidth: 2,
...props.style
}))
// Calculate edge path based on type with optional offset for custom positioning
const edgePath = computed(() => {
try {
const offsetX = pathOffset.value.x || 0
const offsetY = pathOffset.value.y || 0
// Apply offset to create custom path curvature
const centerX = (props.sourceX + props.targetX) / 2 + offsetX
const centerY = (props.sourceY + props.targetY) / 2 + offsetY
switch (props.type) {
case 'straight':
return getStraightPath(props.sourceX, props.sourceY, props.targetX, props.targetY)[0]
case 'smoothstep':
case 'custom':
return getSmoothStepPath(
props.sourceX,
props.sourceY,
props.sourcePosition,
props.targetX,
props.targetY,
props.targetPosition,
centerX,
centerY,
20, // offset
8 // border radius
)[0]
case 'bezier':
default:
return getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
curvature: 0.25
})[0]
}
} catch (error) {
console.error('Error calculating edge path:', error)
// Fallback to a simple straight line
return `M ${props.sourceX} ${props.sourceY} L ${props.targetX} ${props.targetY}`
}
})
// Calculate label position
const labelX = computed(() => {
return (props.sourceX + props.targetX) / 2 + pathOffset.value.x
})
const labelY = computed(() => {
return (props.sourceY + props.targetY) / 2 + pathOffset.value.y
})
// Control point drag handling
const onControlPointMouseDown = (event) => {
event.preventDefault()
event.stopPropagation()
isDragging.value = true
const startX = event.clientX || event.touches[0].clientX
const startY = event.clientY || event.touches[0].clientY
const startOffsetX = pathOffset.value.x
const startOffsetY = pathOffset.value.y
const onMouseMove = (moveEvent) => {
if (!isDragging.value) return
const currentX = moveEvent.clientX || moveEvent.touches[0].clientX
const currentY = moveEvent.clientY || moveEvent.touches[0].clientY
const deltaX = currentX - startX
const deltaY = currentY - startY
// Update path offset for custom positioning
pathOffset.value = {
x: startOffsetX + deltaX,
y: startOffsetY + deltaY
}
// Store the custom path in edge data for persistence
if (edge.value) {
edge.value.data = {
...edge.value.data,
customPath: pathOffset.value
}
}
}
const onMouseUp = () => {
isDragging.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchmove', onMouseMove)
document.removeEventListener('touchend', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchmove', onMouseMove)
document.addEventListener('touchend', onMouseUp)
}
// Initialize path offset from saved data
try {
if (edge.value && edge.value.data && edge.value.data.customPath) {
pathOffset.value = edge.value.data.customPath
}
} catch (error) {
console.log('Edge data not available yet, using default path')
}
</script>
<script>
export default {
name: 'CustomEdge',
inheritAttrs: false
}
</script>
<style scoped>
.edge-label-container {
z-index: 1000;
}
.edge-label {
background: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
white-space: nowrap;
pointer-events: none;
}
.edge-control-point {
cursor: move;
transition: r 0.2s ease;
}
.edge-control-point:hover {
r: 5;
fill: #2563eb;
}
.custom-edge {
transition: stroke-width 0.2s ease, stroke 0.2s ease;
}
.custom-edge.animated {
stroke-dasharray: 5;
animation: dash 1s linear infinite;
}
.custom-edge.selected {
filter: drop-shadow(0 0 4px rgba(255, 107, 107, 0.5));
}
@keyframes dash {
to {
stroke-dashoffset: -10;
}
}
/* Hide control points when not selected */
.edge-control-points {
opacity: 0;
transition: opacity 0.2s ease;
}
.selected .edge-control-points {
opacity: 1;
}
</style>

View File

@ -0,0 +1,386 @@
<template>
<div v-if="selectedEdge" class="edge-configuration">
<RsCard>
<template #header>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
<span class="font-medium">Edge Configuration</span>
</div>
</template>
<template #body>
<div class="space-y-4">
<!-- Edge Information -->
<div class="bg-gray-50 p-3 rounded-lg">
<div class="text-sm text-gray-600 mb-2">Connection:</div>
<div class="text-sm font-mono">
{{ selectedEdge.sourceNode?.data?.label || selectedEdge.source }}
{{ selectedEdge.targetNode?.data?.label || selectedEdge.target }}
</div>
</div>
<!-- Edge Label -->
<FormKit
type="text"
name="label"
label="Label"
v-model="edgeConfig.label"
placeholder="Enter edge label"
help="Text displayed on the edge"
@input="updateEdge"
/>
<!-- Edge Type -->
<FormKit
type="select"
name="type"
label="Edge Type"
v-model="edgeConfig.type"
:options="edgeTypeOptions"
help="Visual style of the edge path"
@input="updateEdge"
/>
<!-- Animation -->
<FormKit
type="checkbox"
name="animated"
label="Animated"
v-model="edgeConfig.animated"
help="Show flowing animation on the edge"
@input="updateEdge"
/>
<!-- Style Options -->
<div class="space-y-3">
<div class="text-sm font-medium text-gray-700">Style</div>
<!-- Stroke Color -->
<FormKit
type="color"
name="strokeColor"
label="Color"
v-model="edgeConfig.style.stroke"
@input="updateEdge"
/>
<!-- Stroke Width -->
<FormKit
type="range"
name="strokeWidth"
label="Width"
v-model="edgeConfig.style.strokeWidth"
:min="1"
:max="8"
:step="0.5"
help="Line thickness"
@input="updateEdge"
/>
<!-- Dash Pattern -->
<FormKit
type="select"
name="dashPattern"
label="Line Style"
v-model="edgeConfig.dashPattern"
:options="dashPatternOptions"
help="Solid or dashed line"
@input="updateEdge"
/>
</div>
<!-- Arrow Configuration -->
<div class="space-y-3">
<div class="text-sm font-medium text-gray-700">Arrows</div>
<FormKit
type="checkbox"
name="showStartArrow"
label="Start Arrow"
v-model="edgeConfig.showStartArrow"
help="Show arrow at the start of the edge"
@input="updateEdge"
/>
<FormKit
type="checkbox"
name="showEndArrow"
label="End Arrow"
v-model="edgeConfig.showEndArrow"
help="Show arrow at the end of the edge"
@input="updateEdge"
/>
</div>
<!-- Path Customization -->
<div class="space-y-3">
<div class="text-sm font-medium text-gray-700">Path Adjustment</div>
<div class="text-sm text-gray-600 mb-2">
Select the edge to see control points for custom positioning
</div>
<RsButton
v-if="edgeConfig.customPath"
@click="resetPath"
variant="secondary"
size="sm"
class="w-full"
>
Reset to Auto Path
</RsButton>
</div>
<!-- Advanced Options -->
<RsCollapse>
<RsCollapseItem title="Advanced Options">
<!-- Z-Index -->
<FormKit
type="range"
name="zIndex"
label="Layer (Z-Index)"
v-model="edgeConfig.zIndex"
:min="0"
:max="1000"
:step="1"
help="Higher values appear on top"
@input="updateEdge"
/>
<!-- Interaction Width -->
<FormKit
type="range"
name="interactionWidth"
label="Click Area Width"
v-model="edgeConfig.interactionWidth"
:min="5"
:max="30"
:step="1"
help="Invisible area around edge for easier clicking"
@input="updateEdge"
/>
<!-- Updatable -->
<FormKit
type="checkbox"
name="updatable"
label="Allow Reconnection"
v-model="edgeConfig.updatable"
help="Allow dragging edge endpoints to reconnect"
@input="updateEdge"
/>
</RsCollapseItem>
</RsCollapse>
</div>
</template>
<template #footer>
<div class="flex justify-between gap-2">
<RsButton @click="deleteEdge" variant="danger" size="sm">
Delete Edge
</RsButton>
<RsButton @click="closeConfiguration" variant="secondary" size="sm">
Close
</RsButton>
</div>
</template>
</RsCard>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import RsCard from '~/components/RsCard.vue'
import RsButton from '~/components/RsButton.vue'
import RsCollapse from '~/components/RsCollapse.vue'
import RsCollapseItem from '~/components/RsCollapseItem.vue'
const props = defineProps({
selectedEdge: {
type: Object,
default: null
}
})
const emit = defineEmits(['updateEdge', 'deleteEdge', 'close'])
// Edge configuration object
const edgeConfig = ref({
label: '',
type: 'custom',
animated: true,
style: {
stroke: '#555',
strokeWidth: 2,
strokeDasharray: ''
},
showStartArrow: false,
showEndArrow: true,
dashPattern: 'solid',
customPath: null,
zIndex: 0,
interactionWidth: 10,
updatable: true
})
// Edge type options
const edgeTypeOptions = [
{ label: 'Custom (Smooth Step)', value: 'custom' },
{ label: 'Bezier Curve', value: 'bezier' },
{ label: 'Straight Line', value: 'straight' },
{ label: 'Step', value: 'step' },
{ label: 'Smooth Step', value: 'smoothstep' }
]
// Dash pattern options
const dashPatternOptions = [
{ label: 'Solid', value: 'solid' },
{ label: 'Dashed', value: 'dashed' },
{ label: 'Dotted', value: 'dotted' },
{ label: 'Dash-Dot', value: 'dashdot' }
]
// Watch for selected edge changes
watch(() => props.selectedEdge, (newEdge) => {
if (newEdge) {
// Load edge configuration
edgeConfig.value = {
label: newEdge.label || '',
type: newEdge.type || 'custom',
animated: newEdge.animated !== undefined ? newEdge.animated : true,
style: {
stroke: newEdge.style?.stroke || '#555',
strokeWidth: newEdge.style?.strokeWidth || 2,
strokeDasharray: newEdge.style?.strokeDasharray || ''
},
showStartArrow: !!newEdge.markerStart,
showEndArrow: !!newEdge.markerEnd,
dashPattern: getDashPatternFromStyle(newEdge.style?.strokeDasharray),
customPath: newEdge.data?.customPath || null,
zIndex: newEdge.zIndex || 0,
interactionWidth: newEdge.interactionWidth || 10,
updatable: newEdge.updatable !== undefined ? newEdge.updatable : true
}
}
}, { immediate: true })
// Helper function to determine dash pattern from style
function getDashPatternFromStyle(dasharray) {
if (!dasharray) return 'solid'
if (dasharray === '5 5') return 'dashed'
if (dasharray === '2 2') return 'dotted'
if (dasharray === '5 2 2 2') return 'dashdot'
return 'solid'
}
// Helper function to get dash array from pattern
function getDashArrayFromPattern(pattern) {
switch (pattern) {
case 'dashed': return '5 5'
case 'dotted': return '2 2'
case 'dashdot': return '5 2 2 2'
default: return ''
}
}
// Update edge function
function updateEdge() {
if (!props.selectedEdge) return
const updates = {
label: edgeConfig.value.label,
type: edgeConfig.value.type,
animated: edgeConfig.value.animated,
style: {
...edgeConfig.value.style,
strokeDasharray: getDashArrayFromPattern(edgeConfig.value.dashPattern)
},
markerStart: edgeConfig.value.showStartArrow ? 'url(#arrow)' : undefined,
markerEnd: edgeConfig.value.showEndArrow ? 'url(#arrow)' : undefined,
zIndex: edgeConfig.value.zIndex,
interactionWidth: edgeConfig.value.interactionWidth,
updatable: edgeConfig.value.updatable
}
emit('updateEdge', props.selectedEdge.id, updates)
}
// Reset path to auto-generated
function resetPath() {
if (!props.selectedEdge) return
edgeConfig.value.customPath = null
emit('updateEdge', props.selectedEdge.id, {
data: {
...props.selectedEdge.data,
customPath: null
}
})
}
// Delete edge
function deleteEdge() {
if (!props.selectedEdge) return
emit('deleteEdge', props.selectedEdge.id)
}
// Close configuration
function closeConfiguration() {
emit('close')
}
</script>
<style scoped>
.edge-configuration {
width: 300px;
max-height: 80vh;
overflow-y: auto;
}
:deep(.formkit-outer) {
margin-bottom: 1rem;
}
:deep(.formkit-label) {
font-size: 0.875rem;
font-weight: 500;
color: rgb(55 65 81);
}
:deep(.formkit-help) {
font-size: 0.75rem;
color: rgb(107 114 128);
}
:deep(.formkit-input[type="color"]) {
width: 100%;
height: 2.5rem;
border-radius: 0.375rem;
border: 1px solid rgb(209 213 219);
}
:deep(.formkit-input[type="range"]) {
width: 100%;
}
/* Custom scrollbar */
.edge-configuration::-webkit-scrollbar {
width: 6px;
}
.edge-configuration::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.edge-configuration::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.edge-configuration::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View File

@ -0,0 +1,268 @@
<script setup>
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, useVueFlow } from '@vue-flow/core'
import { computed, ref, watch } from 'vue'
const props = defineProps({
id: String,
sourceX: Number,
sourceY: Number,
targetX: Number,
targetY: Number,
sourcePosition: String,
targetPosition: String,
selected: Boolean,
animated: Boolean,
label: String,
style: Object,
markerEnd: String,
data: Object
})
const { updateEdgeData } = useVueFlow()
// Custom path offset for manual positioning
const pathOffset = ref({ x: 0, y: 0 })
// Initialize from saved data
if (props.data?.pathOffset) {
pathOffset.value = props.data.pathOffset
}
// Calculate edge path with custom offset
const path = computed(() => {
try {
// Apply the offset to create a custom control point
const centerX = (props.sourceX + props.targetX) / 2 + pathOffset.value.x
const centerY = (props.sourceY + props.targetY) / 2 + pathOffset.value.y
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
borderRadius: 8,
offset: 20,
// Override center point with our custom position
centerX,
centerY
})
return {
path: edgePath,
labelX: labelX || centerX,
labelY: labelY || centerY,
controlX: centerX,
controlY: centerY
}
} catch (error) {
// Fallback to simple calculation
const centerX = (props.sourceX + props.targetX) / 2 + pathOffset.value.x
const centerY = (props.sourceY + props.targetY) / 2 + pathOffset.value.y
return {
path: `M ${props.sourceX} ${props.sourceY} Q ${centerX} ${centerY} ${props.targetX} ${props.targetY}`,
labelX: centerX,
labelY: centerY,
controlX: centerX,
controlY: centerY
}
}
})
const edgeStyle = computed(() => ({
stroke: props.selected ? '#ff6b6b' : '#555',
strokeWidth: props.selected ? 3 : 2,
strokeDasharray: props.animated ? '5,5' : 'none',
...props.style
}))
const markerEndUrl = computed(() => {
return props.selected ? 'url(#arrow-selected)' : 'url(#arrow)'
})
// Drag handling for control point
const isDragging = ref(false)
const onControlPointMouseDown = (event) => {
event.preventDefault()
event.stopPropagation()
isDragging.value = true
const startX = event.clientX
const startY = event.clientY
const startOffsetX = pathOffset.value.x
const startOffsetY = pathOffset.value.y
const onMouseMove = (moveEvent) => {
if (!isDragging.value) return
const deltaX = moveEvent.clientX - startX
const deltaY = moveEvent.clientY - startY
// Apply zoom factor (assuming 1.0 for now, can be enhanced)
pathOffset.value = {
x: startOffsetX + deltaX,
y: startOffsetY + deltaY
}
}
const onMouseUp = () => {
isDragging.value = false
// Save the new path offset to edge data
if (props.id && updateEdgeData) {
updateEdgeData(props.id, {
...props.data,
pathOffset: pathOffset.value
})
}
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// Reset path offset
const resetPath = () => {
pathOffset.value = { x: 0, y: 0 }
if (props.id && updateEdgeData) {
updateEdgeData(props.id, {
...props.data,
pathOffset: undefined
})
}
}
</script>
<script>
export default {
inheritAttrs: false,
}
</script>
<template>
<!-- Main edge path -->
<BaseEdge
:path="path.path"
:style="edgeStyle"
:marker-end="markerEndUrl"
:class="{
'animated': animated,
'selected': selected
}"
/>
<!-- Control point for edge repositioning (only when selected) -->
<g v-if="selected" class="edge-controls">
<!-- Control point circle -->
<circle
:cx="path.controlX"
:cy="path.controlY"
r="4"
fill="#3b82f6"
stroke="white"
stroke-width="2"
class="edge-control-point"
style="cursor: move;"
@mousedown="onControlPointMouseDown"
/>
<!-- Larger invisible area for easier clicking -->
<circle
:cx="path.controlX"
:cy="path.controlY"
r="12"
fill="transparent"
class="edge-control-area"
style="cursor: move;"
@mousedown="onControlPointMouseDown"
/>
<!-- Visual indicator for draggable control -->
<circle
:cx="path.controlX"
:cy="path.controlY"
r="8"
fill="rgba(59, 130, 246, 0.2)"
class="edge-control-indicator"
style="pointer-events: none;"
/>
</g>
<!-- Edge label if provided -->
<EdgeLabelRenderer v-if="label">
<div
:style="{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${path.labelX}px, ${path.labelY}px)`,
pointerEvents: 'all',
fontSize: '11px',
fontWeight: '600',
background: 'white',
padding: '3px 8px',
borderRadius: '12px',
border: '1.5px solid #3b82f6',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.2)',
color: '#1e40af',
whiteSpace: 'nowrap',
zIndex: 10
}"
class="edge-label nodrag nopan"
>
{{ label }}
</div>
</EdgeLabelRenderer>
<!-- Reset button for custom positioning -->
<EdgeLabelRenderer v-if="selected && (pathOffset.x !== 0 || pathOffset.y !== 0)">
<div
:style="{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${path.controlX + 20}px, ${path.controlY - 20}px)`,
pointerEvents: 'all'
}"
class="edge-reset-button nodrag nopan"
>
<button
@click="resetPath"
style="
background: #ef4444;
color: white;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
"
title="Reset edge path"
>
Reset
</button>
</div>
</EdgeLabelRenderer>
</template>
<style scoped>
.edge-control-point {
transition: r 0.2s ease;
}
.edge-control-point:hover {
r: 5;
}
.animated {
animation: dash 2s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -10;
}
}
</style>

View File

@ -1,10 +1,11 @@
<script setup>
import { ref, onMounted, computed, shallowRef, watch, defineExpose, nextTick } from 'vue';
import { ref, onMounted, computed, shallowRef, watch, defineExpose, nextTick, markRaw } from 'vue';
import { VueFlow, useVueFlow, Panel } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import { nodeTypes as customNodeTypes, nodeStyles } from './ProcessFlowNodes';
import InteractiveArrowEdge from './InteractiveArrowEdge.vue';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
@ -55,17 +56,30 @@ const {
} = useVueFlow({
defaultEdgeOptions: {
animated: true,
type: 'smoothstep'
type: 'custom',
style: {
stroke: '#555',
strokeWidth: 2
},
markerEnd: 'url(#arrow)'
},
deleteKeyCode: 'Delete',
selectionKeyCode: 'Shift',
multiSelectionKeyCode: 'Control',
connectionMode: 'strict',
edgeUpdaterRadius: 12,
edgesUpdatable: true,
defaultMarkerColor: '#555',
isValidConnection: (connection) => {
return true;
}
});
// Define custom edge types - use markRaw to prevent reactivity issues
const edgeTypes = {
custom: markRaw(InteractiveArrowEdge)
};
// Default nodes if empty
const defaultNodes = [];
@ -586,9 +600,13 @@ const handleConnect = (connection) => {
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
type: 'smoothstep',
type: 'custom',
animated: true,
style: { stroke: '#555' },
style: {
stroke: '#555',
strokeWidth: 2
},
markerEnd: 'url(#arrow)',
label: label
};
@ -733,6 +751,25 @@ function updateNode(nodeId, newData) {
nodeToUpdate.position = { ...newData.position };
}
// Update style if provided
if (newData.style !== undefined) {
nodeToUpdate.style = {
...(nodeToUpdate.style || {}),
...newData.style
};
// Also apply style directly to DOM element for immediate visual feedback
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${nodeId}"]`);
if (nodeElement) {
// Apply styles directly to the DOM element
Object.entries(newData.style).forEach(([key, value]) => {
nodeElement.style[key] = value;
});
}
});
}
// Force re-render by updating node internals
updateNodeInternals([nodeId]);
@ -1053,7 +1090,7 @@ function fromObject(flowObject) {
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
label: edge.label || '',
type: edge.type || 'smoothstep',
type: edge.type || 'custom',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data || {}
// Only include essential properties
@ -1102,6 +1139,7 @@ function fromObject(flowObject) {
v-bind="flowOptions"
class="bg-slate-50 process-flow"
:nodeTypes="customNodeTypes"
:edgeTypes="edgeTypes"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
@ -1110,21 +1148,72 @@ function fromObject(flowObject) {
@edgeDoubleClick="onEdgeDelete"
@keyup.delete="onDeleteKeyPress"
>
<!-- Global SVG definitions for arrows -->
<svg style="position: absolute; top: 0; left: 0; width: 0; height: 0">
<defs>
<marker
id="arrow"
viewBox="0 0 10 10"
refX="9"
refY="3"
markerWidth="6"
markerHeight="6"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="#555" />
</marker>
<marker
id="arrow-selected"
viewBox="0 0 10 10"
refX="9"
refY="3"
markerWidth="6"
markerHeight="6"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="#ff6b6b" />
</marker>
<marker
id="arrow-animated"
viewBox="0 0 10 10"
refX="9"
refY="3"
markerWidth="6"
markerHeight="6"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="#3b82f6" />
</marker>
</defs>
</svg>
<Background pattern-color="#aaa" gap="20" />
<Controls />
<MiniMap />
<!-- Custom edge template -->
<template #edge-custom="edgeProps">
<InteractiveArrowEdge v-bind="edgeProps" />
</template>
<template #edge-label="{ label }">
<div class="edge-label">{{ label }}</div>
</template>
<Panel position="top-right" class="node-controls">
<div class="p-2 bg-white rounded shadow-sm text-sm">
<div class="mb-1">Controls:</div>
<div class="mb-1 font-medium">Controls:</div>
<div> Delete: Remove selected</div>
<div> Shift: Select nodes</div>
<div> Drag between nodes to connect</div>
<div> Double-click to remove</div>
<div class="mt-2 mb-1 font-medium">Edge Features:</div>
<div> Arrows show flow direction</div>
<div> Select edge for controls</div>
<div> Blue dot = drag to reposition</div>
<div> Reset button restores path</div>
</div>
</Panel>
</VueFlow>
@ -1181,21 +1270,28 @@ function fromObject(flowObject) {
.edge-label {
background-color: white;
padding: 2px 4px;
border-radius: 4px;
font-size: 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
border: 1.5px solid #3b82f6;
color: #1e40af;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
white-space: nowrap;
z-index: 10;
}
/* Vue Flow custom styles */
:deep(.vue-flow__edge-path) {
stroke: #555;
stroke-width: 2px;
transition: stroke-width 0.2s ease, stroke 0.2s ease;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #ff6b6b;
stroke-width: 3px;
filter: drop-shadow(0 0 4px rgba(255, 107, 107, 0.3));
}
:deep(.vue-flow__edge.animated .vue-flow__edge-path) {
@ -1203,6 +1299,49 @@ function fromObject(flowObject) {
animation: flowEdgeAnimation 2s linear infinite;
}
:deep(.vue-flow__edge:hover .vue-flow__edge-path) {
stroke-width: 3px;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.2));
}
/* Custom edge styles */
:deep(.custom-edge) {
cursor: pointer;
}
:deep(.custom-edge.selected) {
filter: drop-shadow(0 0 4px rgba(255, 107, 107, 0.5));
}
/* Edge updater styles */
:deep(.vue-flow__edge-updater) {
stroke: #3b82f6;
fill: #3b82f6;
stroke-width: 2;
cursor: crosshair;
}
:deep(.vue-flow__edge-updater:hover) {
stroke: #2563eb;
fill: #2563eb;
}
/* Edge control point styles for better visibility */
:deep(.edge-control-point) {
cursor: move !important;
opacity: 0;
transition: opacity 0.2s ease, r 0.2s ease;
}
:deep(.vue-flow__edge.selected .edge-control-point) {
opacity: 1;
}
:deep(.edge-control-point:hover) {
r: 6 !important;
fill: #2563eb !important;
}
/* Add custom node icon styles */
:deep(.custom-node) {
border-radius: 6px;

View File

@ -1346,14 +1346,20 @@ export const nodeStyles = `
.node-gateway .custom-node-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
transform: rotate(-45deg);
width: 120%;
height: 120%;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
padding: 0;
text-align: center;
z-index: 10;
}
.node-start .custom-node-content,
@ -1427,12 +1433,12 @@ export const nodeStyles = `
}
.node-gateway .custom-node-title {
font-size: 12px;
font-weight: 500;
color: #333;
margin: 0;
font-size: 11px;
font-weight: 700;
color: #c2410c;
margin: 0 0 4px 0;
text-align: center;
width: 100%;
width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -1501,9 +1507,13 @@ export const nodeStyles = `
}
.gateway-details {
width: 100%;
width: 80%;
text-align: center;
margin-top: 4px;
margin-top: 2px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
@ -1584,9 +1594,11 @@ export const nodeStyles = `
.node-gateway .node-description {
text-align: center;
margin-bottom: 4px;
font-size: 10px;
margin-bottom: 2px;
font-size: 8px;
line-height: 1.1;
overflow: hidden;
max-width: 80px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@ -1595,12 +1607,12 @@ export const nodeStyles = `
.node-gateway .node-rule-detail {
display: flex;
font-size: 10px;
font-size: 8px;
color: #666;
align-items: center;
justify-content: center;
width: 100%;
margin-bottom: 3px;
width: 80%;
margin-bottom: 2px;
}
.node-gateway .node-rule-detail-label {
@ -1612,9 +1624,11 @@ export const nodeStyles = `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 10px;
max-width: 60px;
font-size: 8px;
max-width: 50px;
text-align: center;
font-weight: 600;
color: #c2410c;
}
.node-gateway .material-icons {
@ -1714,13 +1728,13 @@ export const nodeStyles = `
background: white;
cursor: move;
transition: all 0.2s ease;
z-index: -10 !important; /* Lower z-index so shapes stay in background */
/* z-index is now controlled dynamically through node data */
}
.shape-node.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
z-index: -8 !important; /* Still behind process nodes when selected */
/* z-index is now controlled dynamically through node data */
}
.shape-node:hover {

View File

@ -118,13 +118,34 @@
v-model="newVariableType"
class="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:border-purple-500 focus:ring focus:ring-purple-200"
>
<option value="string">String (Text)</option>
<option value="int">Integer (Whole Number)</option>
<option value="decimal">Decimal (Number with decimals)</option>
<option value="boolean">Boolean (True/False)</option>
<option value="date">Date</option>
<option value="datetime">DateTime</option>
<option value="object">Object (JSON)</option>
<optgroup label="Common Types">
<option value="string">String (Text)</option>
<option value="int">Integer (Whole Number)</option>
<option value="decimal">Decimal (Number with decimals)</option>
<option value="boolean">Boolean (True/False)</option>
</optgroup>
<optgroup label="Date & Time Types">
<option value="date">Date</option>
<option value="datetime">DateTime</option>
<option value="time">Time</option>
<option value="timestamp">Timestamp</option>
</optgroup>
<optgroup label="Complex Types">
<option value="object">Object (JSON)</option>
<option value="array">Array (List)</option>
<option value="map">Map (Key-Value Pairs)</option>
</optgroup>
<optgroup label="Special Types">
<option value="currency">Currency</option>
<option value="percentage">Percentage</option>
<option value="email">Email</option>
<option value="url">URL</option>
<option value="phone">Phone Number</option>
<option value="file">File</option>
<option value="image">Image</option>
<option value="binary">Binary Data</option>
<option value="uuid">UUID</option>
</optgroup>
</select>
</div>
@ -240,6 +261,7 @@ const errorMessage = computed(() => {
});
const groupedVariables = computed(() => {
// Initialize with common types but allow for dynamic additions
const types = {
string: { label: 'Text Variables', variables: [] },
int: { label: 'Integer Variables', variables: [] },
@ -248,6 +270,7 @@ const groupedVariables = computed(() => {
date: { label: 'Date Variables', variables: [] },
datetime: { label: 'DateTime Variables', variables: [] },
object: { label: 'Object Variables', variables: [] }
// Other types will be added dynamically
};
let variables = props.availableVariables;
@ -259,9 +282,16 @@ const groupedVariables = computed(() => {
variables.forEach(variable => {
const type = variable.type || 'string';
if (types[type]) {
types[type].variables.push(variable);
// If type doesn't exist in our types object, add it dynamically
if (!types[type]) {
// Create a friendly label from the type name
const label = type.charAt(0).toUpperCase() + type.slice(1) + ' Variables';
types[type] = { label, variables: [] };
}
// Add the variable to its type group
types[type].variables.push(variable);
});
return Object.values(types).filter(group => group.variables.length > 0);
@ -344,10 +374,26 @@ const getTypeColorClass = (type) => {
string: 'bg-blue-100 text-blue-800',
int: 'bg-purple-100 text-purple-800',
decimal: 'bg-purple-100 text-purple-800',
number: 'bg-purple-100 text-purple-800',
boolean: 'bg-indigo-100 text-indigo-800',
date: 'bg-amber-100 text-amber-800',
datetime: 'bg-amber-100 text-amber-800',
object: 'bg-emerald-100 text-emerald-800'
object: 'bg-emerald-100 text-emerald-800',
// Add colors for any additional types you know about
array: 'bg-emerald-100 text-emerald-800',
map: 'bg-teal-100 text-teal-800',
set: 'bg-teal-100 text-teal-800',
binary: 'bg-slate-100 text-slate-800',
time: 'bg-amber-100 text-amber-800',
timestamp: 'bg-amber-100 text-amber-800',
uuid: 'bg-violet-100 text-violet-800',
email: 'bg-blue-100 text-blue-800',
url: 'bg-blue-100 text-blue-800',
phone: 'bg-blue-100 text-blue-800',
currency: 'bg-green-100 text-green-800',
percentage: 'bg-purple-100 text-purple-800',
file: 'bg-orange-100 text-orange-800',
image: 'bg-orange-100 text-orange-800'
};
return classes[type] || 'bg-gray-100 text-gray-800';
};
@ -361,13 +407,47 @@ const formatPreviewValue = (value, type) => {
case 'boolean':
return value ? 'true' : 'false';
case 'object':
case 'array':
case 'map':
case 'set':
try {
const str = typeof value === 'string' ? value : JSON.stringify(value);
return str.length > 30 ? `${str.substring(0, 30)}...` : str;
} catch {
return 'Invalid JSON';
}
case 'date':
case 'datetime':
case 'time':
case 'timestamp':
// Try to format dates nicely if possible
try {
if (value instanceof Date) {
return value.toLocaleString();
} else if (typeof value === 'string' && (value.includes('T') || value.includes('-'))) {
return new Date(value).toLocaleString();
}
} catch {}
return String(value);
case 'int':
case 'decimal':
case 'percentage':
case 'currency':
// For numeric types
if (typeof value === 'number') {
return value.toString();
}
return String(value);
case 'file':
case 'image':
case 'binary':
// For file-like types
if (typeof value === 'string') {
return value.length > 20 ? `${value.substring(0, 20)}...` : value;
}
return '[Binary Data]';
default:
// For any other type, just convert to string
return String(value);
}
};

111
docs/BR/business-rules.md Normal file
View File

@ -0,0 +1,111 @@
Definisi Aktiviti BF-NAS-PRF-AS-QS-03 Analisa Data (Had Kifayah)
DEFINISI AKTIVITI FUNGSI BISNES
Rujukan Fungsi BF-NAS-PRF-AS_QS
Nama Fungsi Pendaftaran Pantas Perseorangan
Rujukan Aktiviti BF-NAS-PRF-AS-QS-03
Nama Aktiviti Analisa Data (Had Kifayah)
Keterangan Aktiviti Proses pengiraan had kifayah asnaf menggunakan maklumat konfigurasi had kifayah dan juga maklumat yang telah diisi oleh pemohon/pendaftar
Aktor NAS
Aktiviti Sebelum -
Aktiviti Selepas/ Aktiviti Lain BF-NAS-PRF-AS-QS-04
Syor Status Keluarga Asnaf
Kaedah/Operasi (Bagaimana):
1. Sebagai contoh, proses pengiraan had kifayah menggunakan maklumat-maklumat didalam konfigurasi had kifayah. Untuk kes pendaftaran pantas perseorangan, hanya minimum maklumat yang dimasukkan. Oleh itu proses pengiraan had kifayah akan menggunakan maklumat-maklumat yang dimasukkan oleh pemohon.
• No Pengenalan
• Pendapatan
• Bilangan tanggungan
2. Sistem akan membuat pengiraan had kifayah menggunakan maklumat yang dimasukkan satu persatu mengikut formula had kifayah yang telah dikonfigurasi.
3. Pengesyoran profil akan menggunakan maklumat %had kifayah, contohnya:
• 0%-49.9% - Fakir
• 50 100% - Miskin
• >100% - Non-FM
4. Pengiraan had kifayah ini adalah dinamik. Sistem akan membuat pengiraan mengikut formula terkini.
5. Data had kifayah keluarga akan disimpan ke dalam pangkalan data
6. Sistem akan menjana ID rekod dan masa tindakan.
7. Rekod Log
o Tindakan Admin direkodkan ke dalam Audit Trail, termasuk:
- Nama pengguna
- Tarikh dan Masa
- Jenis perubahan (Tambah/Kemaskini)
- Butiran data
8. Pemberitahuan Sistem (Automatik) Pengiraan had kifayah selesai
Penggunaan Maklumat:
1. No Pengenalan Ketua Keluarga(CRU)
2. No Pengenalan Tanggungan (CRU)
3. Jumlah had kifayah mengikut formula (CRU)
4. Kira Jumlah Had Kifayah Keluarga (CRU)
5. Kira %Had Kifayah (CRU)
6. Kategori Keluarga (CRU)
Polisi dan Dasar Berkaitan
1. Dasar Keselamatan ICT LZS
• Polisi Pengurusan Data ICT Kuatkuasa 1 Jan 2020 (3)
• Polisi Perlindungan Data Peribadi LZS
2. Garis Panduan dan Syarat Kelayakan Agihan Zakat
• Menetapkan prinsip asas mengenai pemberian zakat termasuk had keperluan asas dan mekanisme kelayakan penerima.
• Kelulusan kadar kifayah mesti selari dengan keutamaan dan strategi agihan semasa.
Kaedah Alternatif
Tiada
Ciri-ciri Kualiti (Keperluan Bukan Fungsian)
1. Audit Trail
Catatan Tambahan:
Aktiviti akan dipanggil setiap kali membuat pendaftaran pantas asnaf.
Jadual 28: Definisi Aktiviti BF-NAS-PRF-AS-QS-03 Analisa Data (Had Kifayah)
Definisi Aktiviti BF-NAS-PRF-AS-QS-04 Syor Status Keluarga Asnaf
DEFINISI AKTIVITI FUNGSI BISNES
Rujukan Fungsi BF-NAS-PRF-AS-QS
Nama Fungsi Pendaftaran Pantas Perseorangan
Rujukan Aktiviti BF-NAS-PRF-AS-QS-04
Nama Aktiviti Syor Status Keluarga Asnaf
Keterangan Aktiviti Sistem akan mengesyorkan status keluarga asnaf hasil dari pengiraan had kifayah.
Aktor NAS
Aktiviti Sebelum -
Aktiviti Selepas/ Aktiviti Lain BF-NAS-PRF-AS-QS-05
Pengesahan
Kaedah/Operasi (Bagaimana):
1. Pengesyoran profil akan menggunakan maklumat %had kifayah, contohnya:
a. 0%-49.9% - Fakir
b. 50 100% - Miskin
c. >100% - Non-FM
2. Pengiraan had kifayah ini adalah dinamik. Sistem akan membuat pengiraan mengikut formula terkini.
3. Notifikasi berkenaan maklumat ID ketua keluarga akan dihantar kepada pegawai yang akan membuat pengesahan.
4. Pengesahan akan dilakukan oleh pegawai yang telah dikonfigurasi untuk mengesahkan profil ini.
5. Jika tarikh masuk islam diisi, status individu tersebut akan menjadi Muallaf dengan syarat tempoh tarikh masuk islam dengan tarikh semasa kurang dari 5 tahun.
6. Status keluarga akan ditentukan oleh proses pengiraan had kifayah.
7. Status individu tanggungan akan menggunakan tetapan status keluarga tersebut. (Fakir, Miskin, Muallaf, Non-FM)
8. Simpan maklumat
a. %Had kifayah kepada %had kifayah syor
b. status keluarga kepada kategori keluarga asnaf syor
c. status asnaf = status keluarga kepada kategori asnaf syor
9. Ringkasan maklumat asnaf yang digenerate oleh AI perlu disimpan di dalam pangkalan data (syor pengesahan) berserta tarikh janaan laporan (tarikh pengesyoran) dan akan dipaparkan kemudian pada proses pengesahan.
Penggunaan Maklumat:
Maklumat Had Kifayah
1. No Pengenalan Ketua Keluarga(R)
2. Nama (R)
3. Alamat (R)
4. %Had Kifayah syor (C)
5. Kategori Keluarga Asnaf syor (C)
6. Kategori Asnaf syor (C)
7. Kategori Tanggungan syor (C)
8. Syor Pengesahan (C)
9. Tarikh Pengesyoran (C)
Polisi dan Dasar Berkaitan
1. Dasar Keselamatan ICT LZS
• Polisi Pengurusan Data ICT Kuatkuasa 1 Jan 2020 (3)
• Polisi Perlindungan Data Peribadi LZS
2. Garis Panduan dan Syarat Kelayakan Agihan Zakat
• Menetapkan prinsip asas mengenai pemberian zakat termasuk had keperluan asas dan mekanisme kelayakan penerima.
• Kelulusan kadar kifayah mesti selari dengan keutamaan dan strategi agihan semasa.
Kaedah Alternatif
Tiada
Ciri-ciri Kualiti (Keperluan Bukan Fungsian)
Tiada
Catatan Tambahan:
Aktiviti akan dipanggil setiap kali membuat pendaftaran pantas asnaf.

View File

@ -1,41 +1,889 @@
// Conditional logic for showing 'Nyatakan keperluan lain' field
onFieldChange("keperluan_mendesak", (value) => {
if (Array.isArray(value) && value.includes("lain_lain")) {
showField("keperluan_lain_nyatakan");
} else {
hideField("keperluan_lain_nyatakan");
setField("keperluan_lain_nyatakan", "");
this.hideField("form_jeniskp_1");
this.hideField("form_jeniskp_2");
this.hideField("form_jeniskp_3");
this.onFieldChange("select_1", (value) => {
this.hideField("form_jeniskp_1");
this.hideField("form_jeniskp_2");
this.hideField("form_jeniskp_3");
if (value && value.trim()) {
if (value == "jeniskp_1") this.showField("form_jeniskp_1");
if (value == "jeniskp_2") this.showField("form_jeniskp_2");
if (value == "jeniskp_3") this.showField("form_jeniskp_3");
}
});
// Show success message on form load
showInfo(
"Sila lengkapkan semua maklumat yang diperlukan untuk penilaian awal."
);
// Conditional Logic Script
// Conditional logic for field: keperluan_lain_nyatakan
onFieldChange("keperluan_mendesak", function () {
if (
String(getField("keperluan_mendesak") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
showField("keperluan_lain_nyatakan");
// Conditional Logic Script
// Conditional Logic Script
// Conditional Logic Script
// Conditional Logic Script
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
hideField("keperluan_lain_nyatakan");
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: keperluan_lain_nyatakan
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional logic for field: text_14
onFieldChange("radio_pendidikan", function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
});
// Initial evaluation for field: text_14
(function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
})();
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional logic for field: text_14
onFieldChange("radio_pendidikan", function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
});
// Initial evaluation for field: text_14
(function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
})();
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional logic for field: text_14
onFieldChange("radio_pendidikan", function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
});
// Initial evaluation for field: text_14
(function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
})();
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional logic for field: text_14
onFieldChange("radio_pendidikan", function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
});
// Initial evaluation for field: text_14
(function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
})();
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional logic for field: text_14
onFieldChange("radio_pendidikan", function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
});
// Initial evaluation for field: text_14
(function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
})();
// Hide "Nyatakan Hubungan Lain-lain" initially
this.hideField("hubungan_lain_nyatakan");
// Show/hide relationship specification field
this.onFieldChange("hubungan_keluarga", (value) => {
if (value && value.includes("lain_lain")) {
this.showField("hubungan_lain_nyatakan");
} else {
this.hideField("hubungan_lain_nyatakan");
}
});
// Hide "Sebab Pembayaran Tunai" initially
this.hideField("sebab_tunai");
// Show/hide cash payment reason field
this.onFieldChange("cara_pembayaran", (value) => {
if (value && value.includes("tunai")) {
this.showField("sebab_tunai");
} else {
this.hideField("sebab_tunai");
}
});
// Hide education specification field initially
this.hideField("pendidikan_lain_tanggungan");
// Show/hide education specification field
this.onFieldChange("pendidikan_tertinggi_tanggungan", (value) => {
if (value && value.includes("lain_lain")) {
this.showField("pendidikan_lain_tanggungan");
} else {
this.hideField("pendidikan_lain_tanggungan");
}
});
// Hide school information initially
this.hideField("maklumat_sekolah");
// Show/hide school information based on schooling status
this.onFieldChange("bersekolah_tanggungan", (value) => {
if (value === "ya") {
this.showField("maklumat_sekolah");
} else {
this.hideField("maklumat_sekolah");
}
});
// Handle repeating group conditional logic for each dependent
this.onFieldChange("tanggungan_maklumat", (value) => {
if (value && Array.isArray(value)) {
value.forEach((item, index) => {
// Handle race specification for each dependent
if (item.bangsa_tanggungan !== "lain_lain") {
// Hide the specification field for this item
const fieldName = `tanggungan_maklumat[${index}].bangsa_lain_tanggungan`;
// Note: Repeating group field hiding requires specific handling
}
});
}
});
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional logic for field: text_14
onFieldChange("radio_pendidikan", function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
});
// Initial evaluation for field: text_14
(function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
})();
// Conditional logic for field: hubungan_lain_nyatakan
onFieldChange("hubungan_keluarga", function () {
if (
String(getField("keperluan_mendesak") || "")
!String(getField("hubungan_keluarga") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
showField("keperluan_lain_nyatakan");
hideField("hubungan_lain_nyatakan");
} else {
hideField("keperluan_lain_nyatakan");
showField("hubungan_lain_nyatakan");
}
});
// Initial evaluation for field: hubungan_lain_nyatakan
(function () {
if (
!String(getField("hubungan_keluarga") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("hubungan_lain_nyatakan");
} else {
showField("hubungan_lain_nyatakan");
}
})();
// Conditional logic for field: sebab_tunai
onFieldChange("cara_pembayaran", function () {
if (
!String(getField("cara_pembayaran") || "")
.toLowerCase()
.includes("tunai".toLowerCase())
) {
hideField("sebab_tunai");
} else {
showField("sebab_tunai");
}
});
// Initial evaluation for field: sebab_tunai
(function () {
if (
!String(getField("cara_pembayaran") || "")
.toLowerCase()
.includes("tunai".toLowerCase())
) {
hideField("sebab_tunai");
} else {
showField("sebab_tunai");
}
})();
// Conditional logic for field: pendidikan_lain_tanggungan
onFieldChange("pendidikan_tertinggi_tanggungan", function () {
if (
!String(getField("pendidikan_tertinggi_tanggungan") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("pendidikan_lain_tanggungan");
} else {
showField("pendidikan_lain_tanggungan");
}
});
// Initial evaluation for field: pendidikan_lain_tanggungan
(function () {
if (
!String(getField("pendidikan_tertinggi_tanggungan") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("pendidikan_lain_tanggungan");
} else {
showField("pendidikan_lain_tanggungan");
}
})();
// Conditional logic for field: maklumat_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("maklumat_sekolah");
} else {
showField("maklumat_sekolah");
}
});
// Initial evaluation for field: maklumat_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("maklumat_sekolah");
} else {
showField("maklumat_sekolah");
}
})();
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional logic for field: text_14
onFieldChange("radio_pendidikan", function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
});
// Initial evaluation for field: text_14
(function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
})();
// Conditional logic for field: hubungan_lain_nyatakan
onFieldChange("hubungan_keluarga", function () {
if (
!String(getField("hubungan_keluarga") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("hubungan_lain_nyatakan");
} else {
showField("hubungan_lain_nyatakan");
}
});
// Initial evaluation for field: hubungan_lain_nyatakan
(function () {
if (
!String(getField("hubungan_keluarga") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("hubungan_lain_nyatakan");
} else {
showField("hubungan_lain_nyatakan");
}
})();
// Conditional logic for field: bangsa_lain_tanggungan
onFieldChange("bangsa_tanggungan", function () {
if (getField("bangsa_tanggungan") !== "lain_lain") {
hideField("bangsa_lain_tanggungan");
} else {
showField("bangsa_lain_tanggungan");
}
});
// Initial evaluation for field: bangsa_lain_tanggungan
(function () {
if (getField("bangsa_tanggungan") !== "lain_lain") {
hideField("bangsa_lain_tanggungan");
} else {
showField("bangsa_lain_tanggungan");
}
})();
// Conditional logic for field: sebab_tunai
onFieldChange("cara_pembayaran", function () {
if (
!String(getField("cara_pembayaran") || "")
.toLowerCase()
.includes("tunai".toLowerCase())
) {
hideField("sebab_tunai");
} else {
showField("sebab_tunai");
}
});
// Initial evaluation for field: sebab_tunai
(function () {
if (
!String(getField("cara_pembayaran") || "")
.toLowerCase()
.includes("tunai".toLowerCase())
) {
hideField("sebab_tunai");
} else {
showField("sebab_tunai");
}
})();
// Conditional logic for field: pendidikan_lain_tanggungan
onFieldChange("pendidikan_tertinggi_tanggungan", function () {
if (
!String(getField("pendidikan_tertinggi_tanggungan") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("pendidikan_lain_tanggungan");
} else {
showField("pendidikan_lain_tanggungan");
}
});
// Initial evaluation for field: pendidikan_lain_tanggungan
(function () {
if (
!String(getField("pendidikan_tertinggi_tanggungan") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("pendidikan_lain_tanggungan");
} else {
showField("pendidikan_lain_tanggungan");
}
})();
// Conditional logic for field: nama_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("nama_sekolah");
} else {
showField("nama_sekolah");
}
});
// Initial evaluation for field: nama_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("nama_sekolah");
} else {
showField("nama_sekolah");
}
})();
// Conditional logic for field: alamat_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("alamat_sekolah");
} else {
showField("alamat_sekolah");
}
});
// Initial evaluation for field: alamat_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("alamat_sekolah");
} else {
showField("alamat_sekolah");
}
})();
// Conditional logic for field: daerah_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("daerah_sekolah");
} else {
showField("daerah_sekolah");
}
});
// Initial evaluation for field: daerah_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("daerah_sekolah");
} else {
showField("daerah_sekolah");
}
})();
// Conditional logic for field: negeri_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("negeri_sekolah");
} else {
showField("negeri_sekolah");
}
});
// Initial evaluation for field: negeri_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("negeri_sekolah");
} else {
showField("negeri_sekolah");
}
})();
// Conditional logic for field: poskod_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("poskod_sekolah");
} else {
showField("poskod_sekolah");
}
});
// Initial evaluation for field: poskod_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("poskod_sekolah");
} else {
showField("poskod_sekolah");
}
})();
// Conditional Logic Script
// Conditional logic for field: nyatakan_lain2
onFieldChange("radio_bangsa", function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
});
// Initial evaluation for field: nyatakan_lain2
(function () {
if (getField("radio_bangsa") !== "lain") {
hideField("nyatakan_lain2");
} else {
showField("nyatakan_lain2");
}
})();
// Conditional logic for field: text_14
onFieldChange("radio_pendidikan", function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
});
// Initial evaluation for field: text_14
(function () {
if (getField("radio_pendidikan") !== "lain") {
hideField("text_14");
} else {
showField("text_14");
}
})();
// Conditional logic for field: hubungan_lain_nyatakan
onFieldChange("hubungan_keluarga", function () {
if (
!String(getField("hubungan_keluarga") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("hubungan_lain_nyatakan");
} else {
showField("hubungan_lain_nyatakan");
}
});
// Initial evaluation for field: hubungan_lain_nyatakan
(function () {
if (
!String(getField("hubungan_keluarga") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("hubungan_lain_nyatakan");
} else {
showField("hubungan_lain_nyatakan");
}
})();
// Conditional logic for field: bangsa_lain_tanggungan
onFieldChange("bangsa_tanggungan", function () {
if (getField("bangsa_tanggungan") !== "lain_lain") {
hideField("bangsa_lain_tanggungan");
} else {
showField("bangsa_lain_tanggungan");
}
});
// Initial evaluation for field: bangsa_lain_tanggungan
(function () {
if (getField("bangsa_tanggungan") !== "lain_lain") {
hideField("bangsa_lain_tanggungan");
} else {
showField("bangsa_lain_tanggungan");
}
})();
// Conditional logic for field: sebab_tunai
onFieldChange("cara_pembayaran", function () {
if (
!String(getField("cara_pembayaran") || "")
.toLowerCase()
.includes("tunai".toLowerCase())
) {
hideField("sebab_tunai");
} else {
showField("sebab_tunai");
}
});
// Initial evaluation for field: sebab_tunai
(function () {
if (
!String(getField("cara_pembayaran") || "")
.toLowerCase()
.includes("tunai".toLowerCase())
) {
hideField("sebab_tunai");
} else {
showField("sebab_tunai");
}
})();
// Conditional logic for field: pendidikan_lain_tanggungan
onFieldChange("pendidikan_tertinggi_tanggungan", function () {
if (
!String(getField("pendidikan_tertinggi_tanggungan") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("pendidikan_lain_tanggungan");
} else {
showField("pendidikan_lain_tanggungan");
}
});
// Initial evaluation for field: pendidikan_lain_tanggungan
(function () {
if (
!String(getField("pendidikan_tertinggi_tanggungan") || "")
.toLowerCase()
.includes("lain_lain".toLowerCase())
) {
hideField("pendidikan_lain_tanggungan");
} else {
showField("pendidikan_lain_tanggungan");
}
})();
// Conditional logic for field: nama_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("nama_sekolah");
} else {
showField("nama_sekolah");
}
});
// Initial evaluation for field: nama_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("nama_sekolah");
} else {
showField("nama_sekolah");
}
})();
// Conditional logic for field: alamat_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("alamat_sekolah");
} else {
showField("alamat_sekolah");
}
});
// Initial evaluation for field: alamat_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("alamat_sekolah");
} else {
showField("alamat_sekolah");
}
})();
// Conditional logic for field: daerah_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("daerah_sekolah");
} else {
showField("daerah_sekolah");
}
});
// Initial evaluation for field: daerah_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("daerah_sekolah");
} else {
showField("daerah_sekolah");
}
})();
// Conditional logic for field: negeri_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("negeri_sekolah");
} else {
showField("negeri_sekolah");
}
});
// Initial evaluation for field: negeri_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("negeri_sekolah");
} else {
showField("negeri_sekolah");
}
})();
// Conditional logic for field: poskod_sekolah
onFieldChange("bersekolah_tanggungan", function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("poskod_sekolah");
} else {
showField("poskod_sekolah");
}
});
// Initial evaluation for field: poskod_sekolah
(function () {
if (getField("bersekolah_tanggungan") !== "ya") {
hideField("poskod_sekolah");
} else {
showField("poskod_sekolah");
}
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -66,28 +66,6 @@
"sourceHandle": "api-1751871528249-right",
"targetHandle": "script-1751871635000-left"
},
{
"id": "script-1751871635000-api-1752114771983-1752114797295",
"data": {},
"type": "smoothstep",
"label": "",
"source": "script-1751871635000",
"target": "api-1752114771983",
"animated": true,
"sourceHandle": "script-1751871635000-right",
"targetHandle": "api-1752114771983-left"
},
{
"id": "api-1752114771983-html-1752109761532-1752114821740",
"data": {},
"type": "smoothstep",
"label": "",
"source": "api-1752114771983",
"target": "html-1752109761532",
"animated": true,
"sourceHandle": "api-1752114771983-right",
"targetHandle": "html-1752109761532-left"
},
{
"id": "start-1751870920411-form-1753000000000-1752115210580",
"data": {},
@ -120,15 +98,299 @@
"animated": true,
"sourceHandle": "api-1753000000001-right",
"targetHandle": "script-1753000000002-left"
},
{
"id": "script-1753000000002-gateway-1753100000000-1752202680793",
"data": {},
"type": "smoothstep",
"label": "",
"source": "script-1753000000002",
"target": "gateway-1753100000000",
"animated": true,
"sourceHandle": "script-1753000000002-right",
"targetHandle": "gateway-1753100000000-left"
},
{
"id": "gateway-1753100000000-form-1751870928350-1752202820616",
"data": {},
"type": "smoothstep",
"label": "Tidak",
"source": "gateway-1753100000000",
"target": "form-1751870928350",
"animated": true,
"sourceHandle": "gateway-1753100000000-right",
"targetHandle": "form-1751870928350-left"
},
{
"id": "form-1753100000001-gateway-1753100000002-1752202841767",
"data": {},
"type": "smoothstep",
"label": "",
"source": "form-1753100000001",
"target": "gateway-1753100000002",
"animated": true,
"sourceHandle": "form-1753100000001-right",
"targetHandle": "gateway-1753100000002-left"
},
{
"id": "gateway-1753100000002-subprocess-1753100000003-1752202973206",
"data": {},
"type": "smoothstep",
"label": "Ya",
"source": "gateway-1753100000002",
"target": "subprocess-1753100000003",
"animated": true,
"sourceHandle": "gateway-1753100000002-right",
"targetHandle": "subprocess-1753100000003-left"
},
{
"id": "gateway-1753100000000-form-1753100000001-1752203198190",
"data": {},
"type": "smoothstep",
"label": "Ya",
"source": "gateway-1753100000000",
"target": "form-1753100000001",
"animated": true,
"sourceHandle": "gateway-1753100000000-right",
"targetHandle": "form-1753100000001-top"
},
{
"id": "script-1751871635000-form-family-tree-choice-1752204006162",
"data": {},
"type": "smoothstep",
"label": "",
"source": "script-1751871635000",
"target": "form-family-tree-choice",
"animated": true,
"sourceHandle": "script-1751871635000-right",
"targetHandle": "form-family-tree-choice-left"
},
{
"id": "form-family-tree-choice-gateway-family-tree-choice-1752204014461",
"data": {},
"type": "smoothstep",
"label": "",
"source": "form-family-tree-choice",
"target": "gateway-family-tree-choice",
"animated": true,
"sourceHandle": "form-family-tree-choice-bottom",
"targetHandle": "gateway-family-tree-choice-left"
},
{
"id": "gateway-family-tree-choice-api-1752114771983-1752204050925",
"data": {},
"type": "smoothstep",
"label": "Ya",
"source": "gateway-family-tree-choice",
"target": "api-1752114771983",
"animated": true,
"sourceHandle": "gateway-family-tree-choice-right",
"targetHandle": "api-1752114771983-left"
},
{
"id": "api-1752114771983-html-1752109761532-1752204167660",
"data": {},
"type": "smoothstep",
"label": "",
"source": "api-1752114771983",
"target": "html-1752109761532",
"animated": true,
"sourceHandle": "api-1752114771983-bottom",
"targetHandle": "html-1752109761532-left"
},
{
"id": "gateway-family-tree-choice-form-kategori-asnaf-1752204312974",
"data": {},
"type": "smoothstep",
"label": "Tidak",
"source": "gateway-family-tree-choice",
"target": "form-kategori-asnaf",
"animated": true,
"sourceHandle": "gateway-family-tree-choice-right",
"targetHandle": "form-kategori-asnaf-top"
},
{
"id": "form-kategori-asnaf-gateway-fakir-miskin-choice-1752204332412",
"data": {},
"type": "smoothstep",
"label": "",
"source": "form-kategori-asnaf",
"target": "gateway-fakir-miskin-choice",
"animated": true,
"sourceHandle": "form-kategori-asnaf-bottom",
"targetHandle": "gateway-fakir-miskin-choice-left"
},
{
"id": "gateway-1753100000002-subprocess-1752202751327-1752204374213",
"data": {},
"type": "smoothstep",
"label": "Tidak",
"source": "gateway-1753100000002",
"target": "subprocess-1752202751327",
"animated": true,
"sourceHandle": "gateway-1753100000002-right",
"targetHandle": "subprocess-1752202751327-left"
},
{
"id": "gateway-fakir-miskin-choice-business-rule-1751871900000-1752204422657",
"data": {},
"type": "smoothstep",
"label": "Ya",
"source": "gateway-fakir-miskin-choice",
"target": "business-rule-1751871900000",
"animated": true,
"sourceHandle": "gateway-fakir-miskin-choice-right",
"targetHandle": "business-rule-1751871900000-left"
}
],
"nodes": [
{
"id": "gateway-family-tree-choice",
"data": {
"label": "Lihat Family Tree?",
"shape": "diamond",
"textColor": "#374151",
"conditions": [
{
"id": "condition-group-ya-path",
"output": "Ya",
"conditions": [
{
"id": "condition-1",
"value": "ya",
"operator": "eq",
"variable": "lihatFamilyTree",
"valueType": "string",
"logicalOperator": "and"
}
]
},
{
"id": "condition-group-tidak-path",
"output": "Tidak",
"conditions": [
{
"id": "condition-2",
"value": "tidak",
"operator": "eq",
"variable": "lihatFamilyTree",
"valueType": "string",
"logicalOperator": "and"
}
]
}
],
"borderColor": "#6b7280",
"defaultPath": "gateway-fakir-miskin-choice",
"description": "Choose whether to view family tree",
"gatewayType": "exclusive",
"backgroundColor": "#f9fafb"
},
"type": "gateway",
"label": "Lihat Family Tree?",
"position": { "x": 2070, "y": -345 }
},
{
"id": "form-family-tree-choice",
"data": {
"label": "Pilihan Family Tree",
"formId": 6,
"formName": "Pilihan Family Tree",
"formUuid": "6e07fc8f-a160-478a-85fd-fa3364401547",
"description": "Form untuk memilih sama ada mahu lihat family tree atau tidak",
"assignedRoles": [],
"assignedUsers": [],
"inputMappings": [
{ "formField": "nama_pemohon", "processVariable": "applicantName" }
],
"assignmentType": "public",
"outputMappings": [
{
"formField": "lihat_family_tree",
"processVariable": "lihatFamilyTree"
}
],
"fieldConditions": []
},
"type": "form",
"label": "Pilihan Family Tree",
"position": { "x": 2040, "y": -540 }
},
{
"id": "gateway-fakir-miskin-choice",
"data": {
"label": "Fakir Miskin?",
"shape": "diamond",
"textColor": "#333333",
"conditions": [
{
"id": "condition-group-ya-path",
"output": "Ya",
"conditions": [
{
"id": "condition-1",
"value": "fakir_miskin",
"operator": "eq",
"variable": "kategoriAsnaf",
"valueType": "string",
"logicalOperator": "and"
}
]
},
{
"id": "condition-group-tidak-path",
"output": "Tidak",
"conditions": [
{
"id": "condition-2",
"value": "bukan_fakir_miskin",
"operator": "eq",
"variable": "kategoriAsnaf",
"valueType": "string",
"logicalOperator": "and"
}
]
}
],
"borderColor": "#6b7280",
"defaultPath": "business-rule-1751871900000",
"description": "Determine if applicant is Fakir Miskin",
"gatewayType": "exclusive",
"backgroundColor": "#f9fafb"
},
"type": "gateway",
"label": "Fakir Miskin?",
"position": { "x": 2085, "y": 210 }
},
{
"id": "form-kategori-asnaf",
"data": {
"label": "Pilihan Kategori Asnaf",
"formId": 7,
"formName": "Pilihan Kategori Asnaf",
"formUuid": "7e07fc8f-a160-478a-85fd-fa3364401548",
"description": "Form untuk memilih kategori asnaf",
"assignedRoles": [],
"assignedUsers": [],
"inputMappings": [
{ "formField": "nama_pemohon", "processVariable": "applicantName" }
],
"assignmentType": "public",
"outputMappings": [
{ "formField": "kategori_asnaf", "processVariable": "kategoriAsnaf" }
],
"fieldConditions": []
},
"type": "form",
"label": "Pilihan Kategori Asnaf",
"position": { "x": 2055, "y": -30 }
},
{
"id": "start-1751870920411",
"data": { "label": "Start", "description": "Process start point" },
"type": "start",
"label": "Start",
"position": { "x": 120, "y": -495 }
"position": { "x": -75, "y": -510 }
},
{
"id": "form-1752471000000",
@ -170,7 +432,7 @@
},
"type": "form",
"label": "Penilaian Awal",
"position": { "x": 450, "y": -525 }
"position": { "x": 435, "y": -540 }
},
{
"id": "api-1752471000010",
@ -187,7 +449,7 @@
},
"type": "api",
"label": "Submit Penilaian Awal API",
"position": { "x": 450, "y": -345 }
"position": { "x": 435, "y": -345 }
},
{
"id": "script-1752471000020",
@ -434,7 +696,7 @@
},
"type": "form",
"label": "Borang Maklumat Peribadi",
"position": { "x": 1275, "y": -525 }
"position": { "x": 1260, "y": -540 }
},
{
"id": "api-1751871528249",
@ -451,7 +713,7 @@
},
"type": "api",
"label": "Submit Profile API",
"position": { "x": 1275, "y": -345 }
"position": { "x": 1260, "y": -345 }
},
{
"id": "script-1751871635000",
@ -572,7 +834,7 @@
},
"type": "form",
"label": "Borang Semak Dokumen",
"position": { "x": 885, "y": 675 }
"position": { "x": 825, "y": 945 }
},
{
"id": "api-1751871750000",
@ -589,7 +851,7 @@
},
"type": "api",
"label": "Submit Document Verification API",
"position": { "x": 1050, "y": 510 }
"position": { "x": 1170, "y": 810 }
},
{
"id": "script-1751871770000",
@ -667,12 +929,14 @@
},
"type": "script",
"label": "Process Verification Response",
"position": { "x": 630, "y": 525 }
"position": { "x": 540, "y": 780 }
},
{
"id": "gateway-1751871800000",
"data": {
"label": "Lengkap?",
"shape": "diamond",
"textColor": "#333333",
"conditions": [
{
"id": "condition-group-ya-path",
@ -703,18 +967,22 @@
]
}
],
"borderColor": "#6b7280",
"defaultPath": "notification-1751872000000",
"description": "Check if documents are complete",
"gatewayType": "exclusive"
"gatewayType": "exclusive",
"backgroundColor": "#f9fafb"
},
"type": "gateway",
"label": "Lengkap?",
"position": { "x": 1350, "y": 315 }
"position": { "x": 1935, "y": 510 }
},
{
"id": "business-rule-1751871900000",
"data": {
"label": "Analisis Had Kifayah",
"shape": "rectangle",
"textColor": "#1e40af",
"ruleGroups": [
{
"id": "group-1",
@ -828,13 +1096,15 @@
]
}
],
"borderColor": "#3b82f6",
"description": "Calculate Had Kifayah based on household composition and income",
"errorVariable": "kifayahCalculationError",
"outputVariable": "kifayahCalculationResult"
"outputVariable": "kifayahCalculationResult",
"backgroundColor": "#eff6ff"
},
"type": "business-rule",
"label": "Analisis Had Kifayah",
"position": { "x": 1665, "y": 120 }
"position": { "x": 2445, "y": -45 }
},
{
"id": "notification-1751871950000",
@ -851,7 +1121,7 @@
},
"type": "notification",
"label": "Hantar Keputusan Had Kifayah",
"position": { "x": 1890, "y": 30 }
"position": { "x": 2730, "y": 690 }
},
{
"id": "notification-1751872000000",
@ -868,7 +1138,7 @@
},
"type": "notification",
"label": "Terima Notifikasi",
"position": { "x": 1485, "y": 360 }
"position": { "x": 1455, "y": 915 }
},
{
"id": "end-1751872100000",
@ -878,7 +1148,7 @@
},
"type": "end",
"label": "End",
"position": { "x": 1935, "y": 390 }
"position": { "x": 1710, "y": 930 }
},
{
"id": "html-1752109761532",
@ -899,7 +1169,7 @@
},
"type": "html",
"label": "Family Tree",
"position": { "x": 2385, "y": -360 }
"position": { "x": 2535, "y": -435 }
},
{
"id": "rectangle-shape-1752110224921",
@ -978,7 +1248,7 @@
"data": {
"label": "",
"shape": "rectangle",
"width": 650,
"width": 750,
"height": 400,
"isShape": true,
"shapeType": "rectangle",
@ -1010,7 +1280,7 @@
},
"type": "api",
"label": "Called Family Tree",
"position": { "x": 2070, "y": -345 }
"position": { "x": 2295, "y": -525 }
},
{
"id": "text-annotation-1752114833800",
@ -1028,7 +1298,7 @@
},
"type": "text-annotation",
"label": "BF-NAS-PRF-AS-FM",
"position": { "x": 2430, "y": -555 }
"position": { "x": 2520, "y": -555 }
},
{
"id": "form-1753000000000",
@ -1148,11 +1418,200 @@
"type": "text-annotation",
"label": "BF-NAS-PRF-AS-QS-01",
"position": { "x": 810, "y": -30 }
},
{
"id": "gateway-1753100000000",
"data": {
"label": "Profil Wujud?",
"shape": "diamond",
"textColor": "#374151",
"conditions": [
{
"id": "condition-group-ya-path",
"output": "Ya",
"conditions": [
{
"id": "condition-1",
"value": true,
"operator": "eq",
"variable": "profileFound",
"valueType": "boolean",
"logicalOperator": "and"
}
]
},
{
"id": "condition-group-tidak-path",
"output": "Tidak",
"conditions": [
{
"id": "condition-2",
"value": false,
"operator": "eq",
"variable": "profileFound",
"valueType": "boolean",
"logicalOperator": "and"
}
]
}
],
"borderColor": "#6b7280",
"defaultPath": "form-1752471000000",
"description": "Check if profile exists in system",
"gatewayType": "exclusive",
"backgroundColor": "#f9fafb"
},
"type": "gateway",
"label": "Profil Wujud?",
"position": { "x": 1230, "y": 0 }
},
{
"id": "form-1753100000001",
"data": {
"label": "Pengesahan Kemaskini Profil",
"formId": 5,
"formName": "Pengesahan Kemaskini Profil",
"formUuid": "5e07fc8f-a160-478a-85fd-fa3364401546",
"description": "Borang pengesahan untuk kemaskini profil sedia ada",
"assignedRoles": [],
"assignedUsers": [],
"inputMappings": [
{
"formField": "nama_pemohon",
"processVariable": "profileData.nama"
},
{
"formField": "no_kad_pengenalan",
"processVariable": "profileData.noKadPengenalan"
},
{
"formField": "tarikh_lahir",
"processVariable": "profileData.tarikhLahir"
},
{ "formField": "alamat", "processVariable": "profileData.alamat" }
],
"assignmentType": "public",
"outputMappings": [
{
"formField": "kemaskini_profil",
"processVariable": "kemaskiniProfil"
},
{
"formField": "sebab_kemaskini",
"processVariable": "sebabKemaskini"
}
],
"fieldConditions": [],
"assignmentVariable": "",
"assignmentVariableType": "user_id"
},
"type": "form",
"label": "Pengesahan Kemaskini Profil",
"position": { "x": 1200, "y": 195 }
},
{
"id": "gateway-1753100000002",
"data": {
"label": "Perubahan Profil?",
"shape": "diamond",
"textColor": "#374151",
"conditions": [
{
"id": "condition-group-ya-path",
"output": "Ya",
"conditions": [
{
"id": "condition-1",
"value": "ya",
"operator": "eq",
"variable": "kemaskiniProfil",
"valueType": "string",
"logicalOperator": "and"
}
]
},
{
"id": "condition-group-tidak-path",
"output": "Tidak",
"conditions": [
{
"id": "condition-2",
"value": "tidak",
"operator": "eq",
"variable": "kemaskiniProfil",
"valueType": "string",
"logicalOperator": "and"
}
]
}
],
"borderColor": "#6b7280",
"defaultPath": "form-1752471000000",
"description": "Check if user wants to update profile",
"gatewayType": "exclusive",
"backgroundColor": "#f9fafb"
},
"type": "gateway",
"label": "Perubahan Profil?",
"position": { "x": 1440, "y": 60 }
},
{
"id": "subprocess-1753100000003",
"data": {
"label": "BF-NAS-PRF-AS-UP-02",
"shape": "rectangle",
"processId": "kemaskini-profil-process",
"textColor": "#134e4a",
"borderColor": "#14b8a6",
"description": "Sub Process: Kemaskini Profil",
"subprocessId": 3,
"inputMappings": [
{
"processVariable": "profileData",
"subprocessVariable": "profileData"
},
{
"processVariable": "sebabKemaskini",
"subprocessVariable": "sebabKemaskini"
}
],
"outputMappings": [
{
"processVariable": "updatedProfileData",
"subprocessVariable": "profileData"
},
{
"processVariable": "profileUpdateStatus",
"subprocessVariable": "updateStatus"
}
],
"subprocessName": "BF-NAS-PRF-AS-UP-02",
"backgroundColor": "#f0fdfa"
},
"type": "subprocess",
"label": "BF-NAS-PRF-AS-UP-02",
"position": { "x": 1635, "y": -30 }
},
{
"id": "subprocess-1752202751327",
"data": {
"label": "BF-NAS-BTN-PB",
"shape": "rectangle",
"textColor": "#134e4a",
"borderColor": "#14b8a6",
"description": "Sub Process: Bantuan",
"subprocessId": 4,
"subprocessName": "BF-NAS-BTN-PB",
"backgroundColor": "#f0fdfa"
},
"type": "subprocess",
"label": "BF-NAS-BTN-PB",
"position": { "x": 1635, "y": 180 }
}
],
"viewport": {
"x": -104.1414298310864,
"y": 273.7689874210555,
"zoom": 0.402665859661672
"x": -818.4067095426772,
"y": 210.4283305655609,
"zoom": 0.5930425475009586
}
}

File diff suppressed because it is too large Load Diff

View File

@ -319,12 +319,18 @@ const hasCurrentProcess = computed(() => {
const canvasNodes = computed(() => {
if (!processStore.currentProcess) return [];
// Return a deep copy to ensure proper reactivity for nested objects like data
return processStore.currentProcess.nodes.map(node => ({
...node,
data: { ...node.data },
// Ensure labels are synchronized both ways
label: node.label || node.data?.label || node.type
}));
return processStore.currentProcess.nodes.map(node => {
const isShape = node.data?.isShape || false;
const style = isShape ? { zIndex: node.data.zIndex ?? 0 } : {};
return {
...node,
style: { ...(node.style || {}), ...style },
data: { ...node.data },
// Ensure labels are synchronized both ways
label: node.label || node.data?.label || node.type
};
});
});
const canvasEdges = computed(() => {
@ -716,9 +722,14 @@ const updateNodeInStore = async () => {
// Force canvas update directly for immediate feedback
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
// Include z-index in style if this is a shape
const isShape = newData?.isShape || false;
const style = isShape ? { zIndex: newData.zIndex ?? 0 } : {};
processFlowCanvas.value.updateNode(nodeId, {
label: newLabel,
data: newData
data: newData,
style: style
});
}
@ -750,6 +761,9 @@ const refreshSelectedNodeFromStore = () => {
// Special handler for shape size updates to force reactivity
const updateShapeSize = async () => {
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
// Get the z-index before updating
const zIndex = selectedNodeData.value.data.zIndex || 0;
// Force update the node in store with new size
updateNodeInStore();
@ -757,8 +771,21 @@ const updateShapeSize = async () => {
await nextTick();
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
data: { ...selectedNodeData.value.data }
data: { ...selectedNodeData.value.data },
style: { zIndex }
});
// Also directly manipulate DOM for immediate feedback
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
if (nodeElement) {
nodeElement.style.zIndex = zIndex;
// Also update custom-node if it exists
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.style.zIndex = zIndex;
}
}
}
}
};
@ -927,7 +954,7 @@ const onEdgesChange = (changes, currentEdges) => {
source: edge.source,
target: edge.target,
label: edge.label || '',
type: edge.type || 'smoothstep',
type: edge.type || 'custom',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data || {},
sourceHandle: edge.sourceHandle,
@ -1292,6 +1319,42 @@ const onAddComponent = async (component) => {
}
};
// If this is a shape node, set an initial z-index
if (component.data?.isShape) {
// Find the highest z-index among existing shapes
let highestZ = 0;
if (processStore.currentProcess && processStore.currentProcess.nodes) {
processStore.currentProcess.nodes.forEach(node => {
if (node.data?.isShape) {
const nodeZ = parseInt(node.data.zIndex || 0, 10);
highestZ = Math.max(highestZ, nodeZ);
}
});
}
// Set new shape's z-index to be on top of existing shapes
// Add 10 to ensure it's clearly above other shapes
const newZ = highestZ + 10;
newNode.data.zIndex = newZ;
// Add style with z-index
newNode.style = { zIndex: newZ };
// After the node is added, directly manipulate the DOM to ensure z-index is applied
setTimeout(() => {
const nodeElement = document.querySelector(`[data-id="${newNode.id}"]`);
if (nodeElement) {
nodeElement.style.zIndex = newZ;
// Also update custom-node if it exists
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.style.zIndex = newZ;
}
}
}, 100);
}
// Add the node to the process store
await processStore.addNode(newNode);
@ -1430,7 +1493,7 @@ const applyProcessTemplate = async (template) => {
const newEdge = {
...edge,
id: edge.id, // Keep original ID
type: edge.type || 'smoothstep',
type: edge.type || 'custom',
animated: edge.animated !== undefined ? edge.animated : true
};
@ -1825,6 +1888,199 @@ const canShowNodeActions = computed(() => {
selectedNodeData.value.type !== 'end'
);
});
const nodeZIndex = computed({
get: () => {
return selectedNodeData.value?.data?.isShape ? selectedNodeData.value.data.zIndex || 0 : 0;
},
set: (value) => {
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
const z = parseInt(value, 10);
const newZ = isNaN(z) ? 0 : z;
selectedNodeData.value.data.zIndex = newZ;
updateNodeInStore();
// Force update the node style in the canvas
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
style: { zIndex: newZ }
});
// Also directly manipulate DOM for immediate feedback
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
if (nodeElement) {
nodeElement.style.zIndex = newZ;
// Also update custom-node if it exists
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.style.zIndex = newZ;
}
}
});
}
}
}
});
// Add functions to bring forward and send backward shapes
const bringForward = () => {
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
// Increment z-index by 1
const currentZ = parseInt(selectedNodeData.value.data.zIndex || 0, 10);
const newZ = currentZ + 1;
selectedNodeData.value.data.zIndex = newZ;
// Update in store
updateNodeInStore();
// Force update the node style in the canvas
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
style: { zIndex: newZ }
});
// Also directly manipulate DOM for immediate feedback
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
if (nodeElement) {
nodeElement.style.zIndex = newZ;
// Also update custom-node if it exists
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.style.zIndex = newZ;
}
}
});
}
}
};
const sendBackward = () => {
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
// Decrement z-index by 1, but not below 0
const currentZ = parseInt(selectedNodeData.value.data.zIndex || 0, 10);
const newZ = Math.max(0, currentZ - 1);
selectedNodeData.value.data.zIndex = newZ;
// Update in store
updateNodeInStore();
// Force update the node style in the canvas
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
style: { zIndex: newZ }
});
// Also directly manipulate DOM for immediate feedback
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
if (nodeElement) {
nodeElement.style.zIndex = newZ;
// Also update custom-node if it exists
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.style.zIndex = newZ;
}
}
});
}
}
};
const bringToFront = () => {
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
// Find the highest z-index in the current shapes
let highestZ = 0;
if (processStore.currentProcess && processStore.currentProcess.nodes) {
processStore.currentProcess.nodes.forEach(node => {
if (node.data?.isShape) {
const nodeZ = parseInt(node.data.zIndex || 0, 10);
highestZ = Math.max(highestZ, nodeZ);
}
});
}
// Set to higher than the highest found (add 10 to ensure it's clearly on top)
const newZ = highestZ + 10;
selectedNodeData.value.data.zIndex = newZ;
// Update in store
updateNodeInStore();
// Force update the node style in the canvas
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
style: { zIndex: newZ }
});
// Also directly manipulate DOM for immediate feedback
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
if (nodeElement) {
nodeElement.style.zIndex = newZ;
// Also update custom-node if it exists
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.style.zIndex = newZ;
}
}
});
}
}
};
const sendToBack = () => {
if (selectedNodeData.value && selectedNodeData.value.data?.isShape) {
// Find the lowest z-index in the current shapes
let lowestZ = Number.MAX_SAFE_INTEGER;
if (processStore.currentProcess && processStore.currentProcess.nodes) {
processStore.currentProcess.nodes.forEach(node => {
if (node.data?.isShape && node.id !== selectedNodeData.value.id) {
const nodeZ = parseInt(node.data.zIndex || 0, 10);
lowestZ = Math.min(lowestZ, nodeZ);
}
});
}
// If no other shapes were found, set to 0
if (lowestZ === Number.MAX_SAFE_INTEGER) {
lowestZ = 0;
}
// Set to lower than the lowest found (subtract 10 to ensure it's clearly below)
const newZ = Math.max(0, lowestZ - 10);
selectedNodeData.value.data.zIndex = newZ;
// Update in store
updateNodeInStore();
// Force update the node style in the canvas
if (processFlowCanvas.value && processFlowCanvas.value.updateNode) {
processFlowCanvas.value.updateNode(selectedNodeData.value.id, {
style: { zIndex: newZ }
});
// Also directly manipulate DOM for immediate feedback
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${selectedNodeData.value.id}"]`);
if (nodeElement) {
nodeElement.style.zIndex = newZ;
// Also update custom-node if it exists
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.style.zIndex = newZ;
}
}
});
}
}
};
</script>
<template>
@ -2130,33 +2386,92 @@ const canShowNodeActions = computed(() => {
</div>
</div>
<!-- Size Controls for Shapes -->
<div v-if="selectedNodeData.data?.isShape" class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Width</label>
<input
v-model.number="selectedNodeData.data.width"
type="number"
min="50"
max="2000"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@input="updateShapeSize"
/>
<!-- Size Controls for Shapes -->
<div v-if="selectedNodeData.data?.isShape" class="space-y-4">
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center">
<Icon name="material-symbols:straighten" class="w-4 h-4 mr-2" />
Size
</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Width</label>
<input
v-model.number="selectedNodeData.data.width"
type="number"
min="50"
max="2000"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@input="updateShapeSize"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Height</label>
<input
v-model.number="selectedNodeData.data.height"
type="number"
min="50"
max="2000"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@input="updateShapeSize"
/>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Height</label>
<input
v-model.number="selectedNodeData.data.height"
type="number"
min="50"
max="2000"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@input="updateShapeSize"
/>
<!-- Z-Index control with heading -->
<div class="mt-6 pt-4 border-t border-gray-100">
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center">
<Icon name="material-symbols:layers" class="w-4 h-4 mr-2" />
Layer Position
</h4>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">Stack Order (Z-Index)</label>
<input
v-model.number="nodeZIndex"
type="number"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0"
/>
<div class="flex justify-center space-x-2 mt-2">
<button
@click="sendToBack"
title="Send to Back"
class="p-2 bg-gray-100 hover:bg-gray-200 rounded border border-gray-300"
>
<Icon name="material-symbols:vertical-align-bottom" class="w-4 h-4 text-gray-600" />
</button>
<button
@click="sendBackward"
title="Send Backward"
class="p-2 bg-gray-100 hover:bg-gray-200 rounded border border-gray-300"
>
<Icon name="material-symbols:arrow-downward" class="w-4 h-4 text-gray-600" />
</button>
<button
@click="bringForward"
title="Bring Forward"
class="p-2 bg-gray-100 hover:bg-gray-200 rounded border border-gray-300"
>
<Icon name="material-symbols:arrow-upward" class="w-4 h-4 text-gray-600" />
</button>
<button
@click="bringToFront"
title="Bring to Front"
class="p-2 bg-gray-100 hover:bg-gray-200 rounded border border-gray-300"
>
<Icon name="material-symbols:vertical-align-top" class="w-4 h-4 text-gray-600" />
</button>
</div>
</div>
<div class="bg-blue-50 p-2 rounded-md">
<p class="text-xs text-blue-700">
<strong>Tip:</strong> Use these controls to arrange the stacking order of shapes. Higher values appear on top of other shapes.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
@ -2274,7 +2589,7 @@ const canShowNodeActions = computed(() => {
<div v-if="selectedNodeData.type === 'gateway'">
<p class="text-xs text-gray-600 mb-3">Define decision conditions and workflow paths.</p>
<RsButton @click="showGatewayConfigModal = true" variant="primary" class="w-full">
<Icon name="material-symbols:call_split" class="w-4 h-4 mr-2" />
<Icon name="material-symbols:call-split" class="w-4 h-4 mr-2" />
Configure Decision Paths
</RsButton>
</div>
@ -3258,12 +3573,10 @@ const canShowNodeActions = computed(() => {
justify-content: center;
text-align: center;
user-select: none;
z-index: -10 !important; /* Always behind process nodes */
}
:deep(.shape-node.selected) {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.6);
z-index: -8 !important; /* Higher z-index when selected, but still behind process nodes */
}
/* Ensure process nodes always stay above shapes, even during drag operations */
@ -3320,11 +3633,9 @@ const canShowNodeActions = computed(() => {
/* Ensure custom nodes (process nodes) maintain proper layering */
:deep(.custom-node:not(.shape-node)) {
position: relative;
z-index: 10 !important;
}
:deep(.custom-node:not(.shape-node).selected) {
z-index: 15 !important;
}
/* Ensure edges/connection lines stay above shapes */