- Updated VariableManager.vue to change variable type labels for clarity, replacing 'Int' and 'Decimal' with 'Number'. - Modified manager-approval-customScript.js to correct field references and enhance conditional logic for displaying the custom approved amount. - Enhanced manager-approval-form.json by adding 'readonly' attributes to several fields, ensuring they are non-editable during the approval process. - Revised travel-workflow-process.json to improve node connections and labels for better workflow clarity. - Updated travel-workflow-variables.json to refine variable descriptions and ensure consistency across the workflow. - Adjusted [id].vue to improve form data handling and loading states, enhancing user experience during workflow execution.
1683 lines
66 KiB
Vue
1683 lines
66 KiB
Vue
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import FormScriptEngine from '~/components/FormScriptEngine.vue';
|
|
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue';
|
|
import ComponentPreview from '~/components/ComponentPreview.vue';
|
|
import { useFormBuilderStore } from '~/stores/formBuilder';
|
|
import { FormKit } from '@formkit/vue';
|
|
|
|
// Define page meta
|
|
definePageMeta({
|
|
title: "Process Execution",
|
|
description: "Execute and run through a business process workflow",
|
|
layout: "empty",
|
|
middleware: ["auth"],
|
|
requiresAuth: true,
|
|
});
|
|
|
|
// Get route and router
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
|
|
// Get form builder store for ComponentPreview data sharing
|
|
const formStore = useFormBuilderStore();
|
|
|
|
// State
|
|
const loading = ref(true);
|
|
const process = ref(null);
|
|
const currentStep = ref(0);
|
|
const caseInstance = ref(null);
|
|
const formData = ref({});
|
|
const processVariables = ref({});
|
|
const error = ref(null);
|
|
const stepLoading = ref(false);
|
|
const currentForm = ref(null);
|
|
const tasks = ref([]);
|
|
const conditionalLogicScript = ref('');
|
|
const combinedScript = ref('');
|
|
const fieldStates = ref({});
|
|
|
|
// Get process ID from route
|
|
const processId = computed(() => route.params.id);
|
|
|
|
// Get current workflow definition
|
|
const workflowData = computed(() => {
|
|
if (!process.value?.processDefinition) return null;
|
|
return process.value.processDefinition;
|
|
});
|
|
|
|
// Get current step node
|
|
const currentNode = computed(() => {
|
|
if (!workflowData.value?.nodes || currentStep.value >= workflowData.value.nodes.length) {
|
|
return null;
|
|
}
|
|
return workflowData.value.nodes[currentStep.value];
|
|
});
|
|
|
|
// Get next step
|
|
const nextNode = computed(() => {
|
|
if (!workflowData.value?.nodes || currentStep.value + 1 >= workflowData.value.nodes.length) {
|
|
return null;
|
|
}
|
|
return workflowData.value.nodes[currentStep.value + 1];
|
|
});
|
|
|
|
// Check if process is complete
|
|
const isProcessComplete = computed(() => {
|
|
return currentNode.value?.type === 'end' || currentStep.value >= (workflowData.value?.nodes?.length || 0);
|
|
});
|
|
|
|
// Load process data
|
|
const loadProcess = async () => {
|
|
try {
|
|
loading.value = true;
|
|
error.value = null;
|
|
|
|
console.log('[Workflow] Loading process definition...');
|
|
const response = await $fetch(`/api/process/${processId.value}`);
|
|
|
|
if (response.success) {
|
|
process.value = response.process; // includes processDefinition
|
|
console.log('[Workflow] Process loaded:', process.value.processName, process.value.processDefinition);
|
|
|
|
// Check if process is published
|
|
const status = process.value.processStatus || process.value.status || 'draft';
|
|
if (status !== 'published') {
|
|
error.value = 'Process must be published before execution';
|
|
return;
|
|
}
|
|
|
|
// Initialize process variables from DB (process.processVariables)
|
|
processVariables.value = process.value.processVariables ? { ...process.value.processVariables } : {};
|
|
|
|
// Start the process execution (case instance)
|
|
await startProcessExecution();
|
|
|
|
} else {
|
|
error.value = response.message || 'Failed to load process';
|
|
}
|
|
} catch (err) {
|
|
console.error('[Workflow] Error loading process:', err);
|
|
error.value = 'Failed to load process data';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Start process execution (create case instance)
|
|
const startProcessExecution = async () => {
|
|
try {
|
|
console.log('[Workflow] Starting process execution (creating case instance)...');
|
|
const response = await $fetch(`/api/process/${processId.value}/start`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
if (response.success) {
|
|
caseInstance.value = response.data.case;
|
|
tasks.value = response.data.tasks;
|
|
console.log('[Workflow] Case instance created:', caseInstance.value);
|
|
// Find the start node
|
|
const startNodeIndex = workflowData.value.nodes.findIndex(node => node.type === 'start');
|
|
currentStep.value = startNodeIndex >= 0 ? startNodeIndex : 0;
|
|
console.log('[Workflow] Starting at node index:', currentStep.value, workflowData.value.nodes[currentStep.value]);
|
|
moveToNextStep();
|
|
} else {
|
|
throw new Error(response.error || 'Failed to start process');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Workflow] Error starting process execution:', err);
|
|
error.value = 'Failed to start process execution';
|
|
}
|
|
};
|
|
|
|
// Helper: Get all outgoing edges for a node
|
|
function getOutgoingEdges(currentNodeId) {
|
|
return workflowData.value.edges.filter(e => e.source === currentNodeId);
|
|
}
|
|
|
|
// Helper: Get next node ID by following edges (for single-path nodes)
|
|
function getNextNodeId(currentNodeId) {
|
|
const edges = getOutgoingEdges(currentNodeId);
|
|
return edges.length > 0 ? edges[0].target : null;
|
|
}
|
|
|
|
// Helper: Check if a node can auto-progress (has single outgoing edge)
|
|
function canAutoProgress(node) {
|
|
const outgoingEdges = getOutgoingEdges(node.id);
|
|
return outgoingEdges.length === 1;
|
|
}
|
|
|
|
// Helper: Get next node ID for decision/gateway nodes based on conditions
|
|
function getNextNodeIdForDecision(currentNodeId) {
|
|
const currentNodeObj = workflowData.value.nodes.find(n => n.id === currentNodeId);
|
|
const outgoingEdges = getOutgoingEdges(currentNodeId);
|
|
|
|
if (!currentNodeObj || !outgoingEdges.length) return null;
|
|
|
|
const { conditions = [] } = currentNodeObj.data || {};
|
|
|
|
// Evaluate condition groups (each group represents a path)
|
|
for (const conditionGroup of conditions) {
|
|
if (evaluateConditionGroup(conditionGroup, processVariables.value)) {
|
|
// Find the edge that matches this condition group's output label
|
|
const edge = outgoingEdges.find(e => e.label === conditionGroup.output || e.data?.condition === conditionGroup.output);
|
|
if (edge) return edge.target;
|
|
}
|
|
}
|
|
|
|
// If no conditions match, look for default path
|
|
const defaultEdge = outgoingEdges.find(e => e.data?.isDefault || e.label?.toLowerCase().includes('default'));
|
|
if (defaultEdge) return defaultEdge.target;
|
|
|
|
// Fallback to first edge
|
|
return outgoingEdges[0]?.target || null;
|
|
}
|
|
|
|
// Helper: Evaluate a condition group (multiple conditions with AND/OR logic)
|
|
function evaluateConditionGroup(conditionGroup, variables) {
|
|
if (!conditionGroup.conditions || conditionGroup.conditions.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// If only one condition, evaluate it directly
|
|
if (conditionGroup.conditions.length === 1) {
|
|
const singleResult = evaluateCondition(conditionGroup.conditions[0], variables);
|
|
console.log(`[Gateway Debug] Group '${conditionGroup.output}': single condition result=`, singleResult);
|
|
return singleResult;
|
|
}
|
|
|
|
// For multiple conditions, evaluate based on logical operators
|
|
let result = evaluateCondition(conditionGroup.conditions[0], variables);
|
|
console.log(`[Gateway Debug] Group '${conditionGroup.output}': condition 0 result=`, result);
|
|
|
|
for (let i = 1; i < conditionGroup.conditions.length; i++) {
|
|
const condition = conditionGroup.conditions[i];
|
|
const conditionResult = evaluateCondition(condition, variables);
|
|
const operator = condition.logicalOperator || 'and';
|
|
if (operator === 'and') {
|
|
result = result && conditionResult;
|
|
} else if (operator === 'or') {
|
|
result = result || conditionResult;
|
|
}
|
|
console.log(`[Gateway Debug] Group '${conditionGroup.output}': after condition ${i}, operator='${operator}', conditionResult=`, conditionResult, ', groupResult=', result);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Helper: Evaluate a single condition
|
|
function evaluateCondition(condition, variables) {
|
|
const { variable, operator, value, valueType, minValue, maxValue } = condition;
|
|
const variableValue = variables[variable];
|
|
|
|
// Handle boolean type conversions
|
|
let compareValue = value;
|
|
if (valueType === 'boolean') {
|
|
if (typeof value === 'string') {
|
|
compareValue = value.toLowerCase() === 'true';
|
|
} else {
|
|
compareValue = Boolean(value);
|
|
}
|
|
}
|
|
|
|
let result;
|
|
switch (operator) {
|
|
case 'eq':
|
|
case 'equals':
|
|
case '==':
|
|
result = variableValue == compareValue;
|
|
break;
|
|
case 'neq':
|
|
case 'not_equals':
|
|
case '!=':
|
|
result = variableValue != compareValue;
|
|
break;
|
|
case 'gt':
|
|
case 'greater_than':
|
|
case '>':
|
|
result = Number(variableValue) > Number(compareValue);
|
|
break;
|
|
case 'lt':
|
|
case 'less_than':
|
|
case '<':
|
|
result = Number(variableValue) < Number(compareValue);
|
|
break;
|
|
case 'gte':
|
|
case 'greater_than_or_equal':
|
|
case '>=':
|
|
result = Number(variableValue) >= Number(compareValue);
|
|
break;
|
|
case 'lte':
|
|
case 'less_than_or_equal':
|
|
case '<=':
|
|
result = Number(variableValue) <= Number(compareValue);
|
|
break;
|
|
case 'between':
|
|
result = (
|
|
Number(variableValue) >= Number(minValue) &&
|
|
Number(variableValue) <= Number(maxValue)
|
|
);
|
|
break;
|
|
case 'not_between':
|
|
result = (
|
|
Number(variableValue) < Number(minValue) ||
|
|
Number(variableValue) > Number(maxValue)
|
|
);
|
|
break;
|
|
case 'contains':
|
|
result = String(variableValue).toLowerCase().includes(String(compareValue).toLowerCase());
|
|
break;
|
|
case 'not_contains':
|
|
result = !String(variableValue).toLowerCase().includes(String(compareValue).toLowerCase());
|
|
break;
|
|
case 'starts_with':
|
|
result = String(variableValue).toLowerCase().startsWith(String(compareValue).toLowerCase());
|
|
break;
|
|
case 'ends_with':
|
|
result = String(variableValue).toLowerCase().endsWith(String(compareValue).toLowerCase());
|
|
break;
|
|
case 'regex':
|
|
try {
|
|
result = new RegExp(compareValue).test(variableValue);
|
|
} catch (e) {
|
|
result = false;
|
|
}
|
|
break;
|
|
case 'has_property':
|
|
result = variableValue && typeof variableValue === 'object' && value in variableValue;
|
|
break;
|
|
case 'property_equals': {
|
|
if (!variableValue || typeof variableValue !== 'object' || typeof compareValue !== 'string') { result = false; break; }
|
|
const [prop, val] = compareValue.split(':');
|
|
result = variableValue[prop] == val;
|
|
break;
|
|
}
|
|
case 'empty':
|
|
case 'is_empty':
|
|
result = !variableValue || variableValue === '' || variableValue === null || variableValue === undefined;
|
|
break;
|
|
case 'not_empty':
|
|
case 'is_not_empty':
|
|
result = variableValue && variableValue !== '' && variableValue !== null && variableValue !== undefined;
|
|
break;
|
|
case 'is_true':
|
|
result = Boolean(variableValue) === true;
|
|
break;
|
|
case 'is_false':
|
|
result = Boolean(variableValue) === false;
|
|
break;
|
|
// Date operators
|
|
case 'today': {
|
|
if (!variableValue) { result = false; break; }
|
|
const date = new Date(variableValue);
|
|
const now = new Date();
|
|
result = (
|
|
date.getFullYear() === now.getFullYear() &&
|
|
date.getMonth() === now.getMonth() &&
|
|
date.getDate() === now.getDate()
|
|
);
|
|
break;
|
|
}
|
|
case 'this_week': {
|
|
if (!variableValue) { result = false; break; }
|
|
const date = new Date(variableValue);
|
|
const now = new Date();
|
|
const startOfWeek = new Date(now);
|
|
startOfWeek.setDate(now.getDate() - now.getDay());
|
|
const endOfWeek = new Date(startOfWeek);
|
|
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
|
result = date >= startOfWeek && date <= endOfWeek;
|
|
break;
|
|
}
|
|
case 'this_month': {
|
|
if (!variableValue) { result = false; break; }
|
|
const date = new Date(variableValue);
|
|
const now = new Date();
|
|
result = (
|
|
date.getFullYear() === now.getFullYear() &&
|
|
date.getMonth() === now.getMonth()
|
|
);
|
|
break;
|
|
}
|
|
case 'this_year': {
|
|
if (!variableValue) { result = false; break; }
|
|
const date = new Date(variableValue);
|
|
const now = new Date();
|
|
result = date.getFullYear() === now.getFullYear();
|
|
break;
|
|
}
|
|
default:
|
|
console.warn('[Workflow] Unknown condition operator:', operator);
|
|
result = false;
|
|
}
|
|
console.log(`[Gateway Debug] Condition: variable='${variable}', operator='${operator}', value=`, value, ', minValue=', minValue, ', maxValue=', maxValue, '| variableValue=', variableValue, '| result=', result);
|
|
return result;
|
|
}
|
|
|
|
// Move to a specific node by its ID
|
|
const moveToSpecificNode = (targetNodeId) => {
|
|
const nextIndex = workflowData.value.nodes.findIndex(n => n.id === targetNodeId);
|
|
if (nextIndex !== -1) {
|
|
currentStep.value = nextIndex;
|
|
const node = workflowData.value.nodes[currentStep.value];
|
|
console.log(`[Workflow] Moved to node: ${node.type} - ${node.data?.label || node.label}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Move to next step in workflow (only for single-path auto-progressable nodes)
|
|
const moveToNextStep = () => {
|
|
const currentNode = workflowData.value.nodes[currentStep.value];
|
|
if (!currentNode) return;
|
|
|
|
// Only auto-progress if there's exactly one outgoing edge
|
|
if (!canAutoProgress(currentNode)) {
|
|
console.log(`[Workflow] Node ${currentNode.id} has multiple paths, waiting for user choice`);
|
|
return;
|
|
}
|
|
|
|
const nextNodeId = getNextNodeId(currentNode.id);
|
|
if (nextNodeId) {
|
|
moveToSpecificNode(nextNodeId);
|
|
}
|
|
};
|
|
|
|
// Handle form submission
|
|
const handleFormSubmit = async () => {
|
|
try {
|
|
stepLoading.value = true;
|
|
console.log('[Workflow] Form submitted. Data:', formData.value);
|
|
|
|
// Apply output mappings if configured
|
|
if (currentNode.value.data?.outputMappings?.length > 0) {
|
|
applyOutputMappings(
|
|
currentNode.value.data,
|
|
formData.value,
|
|
processVariables.value
|
|
);
|
|
} else {
|
|
// Fallback to current flat assignment behavior
|
|
Object.assign(processVariables.value, formData.value);
|
|
}
|
|
|
|
// Handle assignment variables
|
|
if (currentNode.value.data?.assignmentVariable) {
|
|
processVariables.value[currentNode.value.data.assignmentVariable] = {
|
|
completedBy: 'currentUser', // TODO: Get from auth context
|
|
completedAt: new Date().toISOString(),
|
|
formData: { ...formData.value }
|
|
};
|
|
}
|
|
|
|
moveToNextStep();
|
|
console.log('[Workflow] After form submit, current node:', currentNode.value);
|
|
if (currentNode.value && ['api', 'script'].includes(currentNode.value.type)) {
|
|
await executeCurrentStep();
|
|
}
|
|
} catch (err) {
|
|
console.error('[Workflow] Error submitting form:', err);
|
|
error.value = 'Failed to submit form';
|
|
} finally {
|
|
stepLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// --- Utility: Substitute variables in a string ---
|
|
function substituteVariables(str, variables) {
|
|
if (typeof str !== 'string') return str;
|
|
// Replace {{variable}} first
|
|
str = str.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, varName) => {
|
|
const value = variables[varName];
|
|
if (
|
|
value === undefined ||
|
|
value === null ||
|
|
(typeof value === 'object' && value.name && value.type)
|
|
) return '';
|
|
if (typeof value === 'object') {
|
|
return JSON.stringify(value);
|
|
}
|
|
return String(value);
|
|
});
|
|
// Then replace {variable}
|
|
str = str.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, varName) => {
|
|
const value = variables[varName];
|
|
if (
|
|
value === undefined ||
|
|
value === null ||
|
|
(typeof value === 'object' && value.name && value.type)
|
|
) return '';
|
|
if (typeof value === 'object') {
|
|
return JSON.stringify(value);
|
|
}
|
|
return String(value);
|
|
});
|
|
return str;
|
|
}
|
|
|
|
// --- Variable Mapping Functions ---
|
|
|
|
// Apply input mappings to pre-fill form with process variables
|
|
function applyInputMappings(nodeData, processVars, formData) {
|
|
const { inputMappings = [] } = nodeData;
|
|
console.log('[Workflow] Applying input mappings:', inputMappings);
|
|
|
|
inputMappings.forEach(mapping => {
|
|
const { processVariable, formField } = mapping;
|
|
if (processVars[processVariable] !== undefined) {
|
|
let value = processVars[processVariable];
|
|
|
|
// Check if the form field is a date field
|
|
const fieldDef = nodeData.formComponents?.find(f => f.props?.name === formField);
|
|
if (fieldDef && fieldDef.type === 'date' && value) {
|
|
// Convert to YYYY-MM-DD
|
|
const d = new Date(value);
|
|
if (!isNaN(d)) {
|
|
value = d.toISOString().slice(0, 10);
|
|
}
|
|
}
|
|
|
|
formData[formField] = value;
|
|
console.log(`[Workflow] Mapped ${processVariable} -> ${formField}:`, value);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Apply output mappings to save form data to process variables
|
|
function applyOutputMappings(nodeData, formData, processVars) {
|
|
const { outputMappings = [] } = nodeData;
|
|
console.log('[Workflow] Applying output mappings:', outputMappings);
|
|
|
|
outputMappings.forEach(mapping => {
|
|
const { formField, processVariable } = mapping;
|
|
if (formData[formField] !== undefined) {
|
|
processVars[processVariable] = formData[formField];
|
|
console.log(`[Workflow] Mapped ${formField} -> ${processVariable}:`, formData[formField]);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Apply field conditions for dynamic form behavior
|
|
function applyFieldConditions(nodeData, processVars) {
|
|
const { fieldConditions = [] } = nodeData;
|
|
const fieldStates = {};
|
|
|
|
fieldConditions.forEach(condition => {
|
|
const { processVariable, operator, value, targetField, action } = condition;
|
|
|
|
if (evaluateCondition({ variable: processVariable, operator, value }, processVars)) {
|
|
fieldStates[targetField] = {
|
|
...fieldStates[targetField],
|
|
[action]: true
|
|
};
|
|
console.log(`[Workflow] Field condition applied: ${targetField} -> ${action}`);
|
|
}
|
|
});
|
|
|
|
return fieldStates;
|
|
}
|
|
|
|
// --- Utility: Build API request from node data ---
|
|
function buildApiRequest(nodeData, variables) {
|
|
// 1. URL (with param substitution)
|
|
let url = substituteVariables(nodeData.apiUrl, variables);
|
|
|
|
// 2. Params (for GET, DELETE, etc.)
|
|
let params = Array.isArray(nodeData.params) ? nodeData.params : [];
|
|
if (params.length) {
|
|
const query = params
|
|
.filter(p => p.key)
|
|
.map(p => `${encodeURIComponent(substituteVariables(p.key, variables))}=${encodeURIComponent(substituteVariables(p.value, variables))}`)
|
|
.join('&');
|
|
if (query) {
|
|
url += (url.includes('?') ? '&' : '?') + query;
|
|
}
|
|
}
|
|
|
|
// 3. Headers
|
|
let headers = {};
|
|
if (Array.isArray(nodeData.headers)) {
|
|
nodeData.headers.forEach(h => {
|
|
if (h.key) headers[substituteVariables(h.key, variables)] = substituteVariables(h.value, variables);
|
|
});
|
|
} else if (typeof nodeData.headers === 'object') {
|
|
headers = { ...nodeData.headers };
|
|
}
|
|
|
|
// 4. Authorization
|
|
if (nodeData.authorization && nodeData.authorization.type && nodeData.authorization.type !== 'none') {
|
|
const auth = nodeData.authorization;
|
|
if (auth.type === 'bearer' && auth.token) {
|
|
headers['Authorization'] = `Bearer ${substituteVariables(auth.token, variables)}`;
|
|
} else if (auth.type === 'basic' && auth.username && auth.password) {
|
|
const token = btoa(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`);
|
|
headers['Authorization'] = `Basic ${token}`;
|
|
} else if (auth.type === 'apiKey' && auth.key && auth.value) {
|
|
if (auth.in === 'header') {
|
|
headers[substituteVariables(auth.key, variables)] = substituteVariables(auth.value, variables);
|
|
} else if (auth.in === 'query') {
|
|
url += (url.includes('?') ? '&' : '?') + `${encodeURIComponent(substituteVariables(auth.key, variables))}=${encodeURIComponent(substituteVariables(auth.value, variables))}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Body
|
|
let body;
|
|
if (nodeData.body && nodeData.body.type && nodeData.body.type !== 'none') {
|
|
if (['form-data', 'x-www-form-urlencoded'].includes(nodeData.body.type)) {
|
|
const dataArr = Array.isArray(nodeData.body.data) ? nodeData.body.data : [];
|
|
if (nodeData.body.type === 'form-data') {
|
|
body = new FormData();
|
|
dataArr.forEach(item => {
|
|
if (item.key) body.append(substituteVariables(item.key, variables), substituteVariables(item.value, variables));
|
|
});
|
|
// Remove content-type header for FormData (browser will set it)
|
|
if (headers['Content-Type']) delete headers['Content-Type'];
|
|
} else {
|
|
// x-www-form-urlencoded
|
|
body = dataArr
|
|
.filter(item => item.key)
|
|
.map(item => `${encodeURIComponent(substituteVariables(item.key, variables))}=${encodeURIComponent(substituteVariables(item.value, variables))}`)
|
|
.join('&');
|
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
}
|
|
} else if (nodeData.body.type === 'raw') {
|
|
body = substituteVariables(nodeData.body.data, variables);
|
|
// Try to detect JSON
|
|
if (body && body.trim().startsWith('{')) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
} else if (nodeData.body.type === 'binary') {
|
|
// Not supported in workflow execution context
|
|
body = undefined;
|
|
}
|
|
}
|
|
|
|
return { url, headers, body };
|
|
}
|
|
|
|
// Update executeCurrentStep for API node
|
|
const executeCurrentStep = async () => {
|
|
try {
|
|
stepLoading.value = true;
|
|
console.log('[Workflow] Executing current step:', currentNode.value);
|
|
if (currentNode.value?.type === 'api') {
|
|
console.log(`[Workflow] Executing API node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
|
const nodeData = currentNode.value.data || {};
|
|
// Use new structure if present
|
|
if (nodeData.body || nodeData.headers || nodeData.params || nodeData.authorization) {
|
|
const { url, headers, body } = buildApiRequest(nodeData, processVariables.value);
|
|
const apiMethod = nodeData.apiMethod || 'GET';
|
|
const outputVariable = nodeData.outputVariable || 'apiResponse';
|
|
const errorVariable = nodeData.errorVariable || 'apiError';
|
|
const continueOnError = nodeData.continueOnError || false;
|
|
try {
|
|
const response = await $fetch(url, {
|
|
method: apiMethod,
|
|
headers,
|
|
body: ['GET', 'HEAD'].includes(apiMethod) ? undefined : body,
|
|
});
|
|
processVariables.value[outputVariable] = response;
|
|
processVariables.value[errorVariable] = null;
|
|
console.log('[Workflow] API call success. Output variable set:', outputVariable, response);
|
|
if (canAutoProgress(currentNode.value)) {
|
|
moveToNextStep();
|
|
} else {
|
|
console.log('[Workflow] API completed, multiple paths available - waiting for user choice');
|
|
}
|
|
} catch (err) {
|
|
processVariables.value[errorVariable] = err;
|
|
console.error('[Workflow] API call failed:', err);
|
|
if (continueOnError) {
|
|
if (canAutoProgress(currentNode.value)) {
|
|
moveToNextStep();
|
|
} else {
|
|
console.log('[Workflow] API failed but continuing, multiple paths available - waiting for user choice');
|
|
}
|
|
} else {
|
|
error.value = 'API call failed: ' + (err.message || err);
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback: old structure
|
|
const {
|
|
apiUrl,
|
|
apiMethod = 'GET',
|
|
headers = '{}',
|
|
requestBody = '',
|
|
outputVariable = 'apiResponse',
|
|
errorVariable = 'apiError',
|
|
continueOnError = false
|
|
} = nodeData;
|
|
try {
|
|
const response = await $fetch(apiUrl, {
|
|
method: apiMethod,
|
|
headers: headers ? JSON.parse(headers) : {},
|
|
body: requestBody ? JSON.parse(requestBody) : undefined,
|
|
});
|
|
processVariables.value[outputVariable] = response;
|
|
processVariables.value[errorVariable] = null;
|
|
console.log('[Workflow] API call success. Output variable set:', outputVariable, response);
|
|
if (canAutoProgress(currentNode.value)) {
|
|
moveToNextStep();
|
|
} else {
|
|
console.log('[Workflow] API completed, multiple paths available - waiting for user choice');
|
|
}
|
|
} catch (err) {
|
|
processVariables.value[errorVariable] = err;
|
|
console.error('[Workflow] API call failed:', err);
|
|
if (continueOnError) {
|
|
if (canAutoProgress(currentNode.value)) {
|
|
moveToNextStep();
|
|
} else {
|
|
console.log('[Workflow] API failed but continuing, multiple paths available - waiting for user choice');
|
|
}
|
|
} else {
|
|
error.value = 'API call failed: ' + (err.message || err);
|
|
}
|
|
}
|
|
}
|
|
} else if (currentNode.value?.type === 'script') {
|
|
const scriptCode = currentNode.value.data?.scriptCode;
|
|
if (scriptCode) {
|
|
try {
|
|
// Expose processVariables to the script
|
|
window.processVariables = processVariables.value;
|
|
// Run the script code with processVariables in scope
|
|
// eslint-disable-next-line no-new-func
|
|
new Function('processVariables', scriptCode)(processVariables.value);
|
|
} catch (err) {
|
|
console.error('[Workflow] Error executing script node:', err);
|
|
error.value = 'Script execution failed: ' + (err.message || err);
|
|
}
|
|
}
|
|
// Only auto-progress if there's a single outgoing edge
|
|
if (canAutoProgress(currentNode.value)) {
|
|
moveToNextStep();
|
|
} else {
|
|
console.log('[Workflow] Script completed, multiple paths available - waiting for user choice');
|
|
}
|
|
} else if (currentNode.value?.type === 'html') {
|
|
console.log(`[Workflow] Processing HTML node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
|
// HTML nodes are user-interactive, so we don't auto-advance
|
|
// The user will click a button to continue
|
|
return;
|
|
} else if (currentNode.value?.type === 'notification') {
|
|
console.log(`[Workflow] Sending notification: ${currentNode.value.data?.label || currentNode.value.label}`);
|
|
|
|
// Extract notification configuration
|
|
const {
|
|
notificationType = 'info',
|
|
recipientType = 'email',
|
|
recipientUser = '',
|
|
recipientRole = '',
|
|
recipientVariable = '',
|
|
recipientEmail = '',
|
|
subject = '',
|
|
message = '',
|
|
messageFormat = 'text',
|
|
htmlMessage = '',
|
|
richTextMessage = '',
|
|
deliveryOptions = { inApp: true, email: false, sms: false },
|
|
priority = 'medium',
|
|
continueOnError = true
|
|
} = currentNode.value.data || {};
|
|
|
|
try {
|
|
// Only send email if email delivery is enabled
|
|
if (deliveryOptions.email && (subject || message)) {
|
|
console.log('[Workflow] Sending email notification...');
|
|
|
|
// Prepare notification payload
|
|
const notificationPayload = {
|
|
notificationType,
|
|
recipientType,
|
|
recipientData: {
|
|
recipientUser,
|
|
recipientRole,
|
|
recipientVariable,
|
|
recipientEmail
|
|
},
|
|
subject: substituteVariables(subject, processVariables.value),
|
|
message: substituteVariables(message, processVariables.value),
|
|
htmlMessage: htmlMessage ? substituteVariables(htmlMessage, processVariables.value) : '',
|
|
messageFormat,
|
|
processVariables: processVariables.value,
|
|
processName: process.value?.processName || 'Process Workflow',
|
|
deliveryOptions,
|
|
priority
|
|
};
|
|
|
|
// Send notification via API
|
|
const response = await $fetch('/api/notifications/send', {
|
|
method: 'POST',
|
|
body: notificationPayload
|
|
});
|
|
|
|
if (response.success) {
|
|
console.log('[Workflow] Email notification sent successfully:', response);
|
|
// You could update process variables with notification results if needed
|
|
processVariables.value.lastNotificationResult = {
|
|
success: true,
|
|
sent: response.sent,
|
|
recipients: response.recipients,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
} else {
|
|
throw new Error(response.message || 'Failed to send notification');
|
|
}
|
|
} else {
|
|
console.log('[Workflow] Email delivery disabled or no content - skipping email notification');
|
|
|
|
// Handle in-app notifications if enabled
|
|
if (deliveryOptions.inApp) {
|
|
console.log('[Workflow] In-app notification would be displayed here');
|
|
// In a real implementation, you would add the notification to a queue
|
|
// or trigger an in-app notification system
|
|
}
|
|
|
|
// Set notification result in process variables
|
|
processVariables.value.lastNotificationResult = {
|
|
success: true,
|
|
sent: 0,
|
|
recipients: 0,
|
|
timestamp: new Date().toISOString(),
|
|
note: 'Email delivery disabled'
|
|
};
|
|
}
|
|
|
|
// Only auto-progress if there's a single outgoing edge
|
|
if (canAutoProgress(currentNode.value)) {
|
|
moveToNextStep();
|
|
} else {
|
|
console.log('[Workflow] Notification processed, multiple paths available - waiting for user choice');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Workflow] Notification failed:', err);
|
|
|
|
// Set error in process variables
|
|
processVariables.value.lastNotificationError = {
|
|
error: err.message || String(err),
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
if (continueOnError) {
|
|
console.log('[Workflow] Continuing despite notification error');
|
|
if (canAutoProgress(currentNode.value)) {
|
|
moveToNextStep();
|
|
} else {
|
|
console.log('[Workflow] Notification failed but continuing, multiple paths available - waiting for user choice');
|
|
}
|
|
} else {
|
|
error.value = 'Notification failed: ' + (err.message || err);
|
|
}
|
|
}
|
|
} else if (['decision', 'gateway'].includes(currentNode.value?.type)) {
|
|
console.log(`[Workflow] Processing decision node: ${currentNode.value.data?.label || currentNode.value.label}`);
|
|
// Decision nodes require user interaction or automatic condition evaluation
|
|
// We'll handle this in the template section
|
|
return;
|
|
}
|
|
// Add more node types as needed
|
|
} catch (err) {
|
|
console.error('[Workflow] Error executing step:', err);
|
|
error.value = 'Failed to execute step';
|
|
} finally {
|
|
stepLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// Get step type display info
|
|
const getStepInfo = (node) => {
|
|
const stepTypes = {
|
|
'start': { label: 'Process Started', icon: 'material-symbols:play-circle', color: 'green' },
|
|
'form': { label: 'User Form', icon: 'material-symbols:description', color: 'blue' },
|
|
'api': { label: 'API Call', icon: 'material-symbols:api', color: 'purple' },
|
|
'script': { label: 'Script Execution', icon: 'material-symbols:code', color: 'orange' },
|
|
'decision': { label: 'Decision Point', icon: 'material-symbols:alt-route', color: 'yellow' },
|
|
'gateway': { label: 'Decision Gateway', icon: 'material-symbols:fork-right', color: 'yellow' },
|
|
'html': { label: 'HTML Content', icon: 'material-symbols:code-blocks', color: 'indigo' },
|
|
'notification': { label: 'Notification', icon: 'material-symbols:notifications', color: 'pink' },
|
|
'end': { label: 'Process Complete', icon: 'material-symbols:check-circle', color: 'green' }
|
|
};
|
|
|
|
return stepTypes[node?.type] || { label: 'Unknown Step', icon: 'material-symbols:help', color: 'gray' };
|
|
};
|
|
|
|
// Load form data from database
|
|
const loadFormData = async (formId) => {
|
|
try {
|
|
if (!formId) return null;
|
|
|
|
const response = await $fetch(`/api/forms/${formId}`);
|
|
if (response.success) {
|
|
return response.form;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Error loading form:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Note: isInputType function removed as ComponentPreview handles all component types
|
|
|
|
// Get truncated custom script for display
|
|
const customScriptPreview = computed(() => {
|
|
if (!currentForm.value?.customScript) return '';
|
|
return currentForm.value.customScript.substring(0, 200) + '...';
|
|
});
|
|
|
|
// Handle script-driven field changes
|
|
const handleScriptFieldChange = ({ fieldName, value }) => {
|
|
console.log('[WorkflowExecution] Script field change:', fieldName, '=', value);
|
|
// Update form data with script changes
|
|
formData.value[fieldName] = value;
|
|
// Also update form store for ComponentPreview
|
|
formStore.updatePreviewFormData(formData.value);
|
|
};
|
|
|
|
// Handle conditional logic script generation
|
|
const handleConditionalLogicGenerated = (generatedScript) => {
|
|
console.log('[WorkflowExecution] Conditional logic script generated');
|
|
conditionalLogicScript.value = generatedScript;
|
|
|
|
// Combine conditional logic with custom script
|
|
const customScript = currentForm.value?.customScript || '';
|
|
combinedScript.value = [conditionalLogicScript.value, customScript].filter(Boolean).join('\n\n');
|
|
};
|
|
|
|
// Navigation functions
|
|
const goHome = () => {
|
|
router.push('/');
|
|
};
|
|
|
|
// Load process on mount
|
|
onMounted(() => {
|
|
loadProcess();
|
|
});
|
|
|
|
// Watch for step changes to auto-execute non-form steps or load form data
|
|
watch(currentStep, async (newStep) => {
|
|
if (currentNode.value) {
|
|
stepLoading.value = true; // Start loading for any node
|
|
try {
|
|
if (["api", "script", "notification"].includes(currentNode.value.type)) {
|
|
await executeCurrentStep();
|
|
} else if (currentNode.value.type === "form") {
|
|
// Reset all form-related state to prevent flush errors
|
|
formData.value = {};
|
|
fieldStates.value = {};
|
|
formStore.updatePreviewFormData({});
|
|
formStore.formComponents = [];
|
|
// Load form data for form nodes
|
|
const formId = currentNode.value.data?.formId;
|
|
if (formId) {
|
|
currentForm.value = await loadFormData(formId);
|
|
// Apply input mappings to pre-fill form
|
|
if (currentNode.value.data?.inputMappings) {
|
|
applyInputMappings(
|
|
currentNode.value.data,
|
|
processVariables.value,
|
|
formData.value
|
|
);
|
|
}
|
|
// Apply field conditions
|
|
if (currentNode.value.data?.fieldConditions) {
|
|
fieldStates.value = applyFieldConditions(
|
|
currentNode.value.data,
|
|
processVariables.value
|
|
);
|
|
}
|
|
// Update form store with form components and data for ComponentPreview
|
|
if (currentForm.value?.formComponents) {
|
|
formStore.formComponents = currentForm.value.formComponents;
|
|
formStore.updatePreviewFormData(formData.value);
|
|
}
|
|
}
|
|
} else if (["decision", "gateway"].includes(currentNode.value.type)) {
|
|
await executeDecisionNode();
|
|
}
|
|
// html nodes are handled in template - no auto-execution needed
|
|
} finally {
|
|
stepLoading.value = false; // End loading after all async work
|
|
}
|
|
}
|
|
});
|
|
|
|
// Watch formData changes and sync with form store
|
|
watch(formData, (newData) => {
|
|
formStore.updatePreviewFormData(newData);
|
|
}, { deep: true });
|
|
|
|
// Watch for process variable changes and re-evaluate field conditions
|
|
watch(processVariables, (newVars) => {
|
|
if (currentNode.value?.type === 'form' && currentNode.value.data?.fieldConditions) {
|
|
fieldStates.value = applyFieldConditions(currentNode.value.data, newVars);
|
|
}
|
|
}, { deep: true });
|
|
|
|
// Add FormKit form ref
|
|
const formRef = ref(null);
|
|
|
|
// New: handle FormKit form submit
|
|
const onFormKitSubmit = () => {
|
|
handleFormSubmit();
|
|
};
|
|
|
|
// New: validate and submit handler for button
|
|
const validateAndSubmit = () => {
|
|
if (formRef.value && formRef.value.node && typeof formRef.value.node.submit === 'function') {
|
|
formRef.value.node.submit();
|
|
}
|
|
};
|
|
|
|
// Handle decision node execution (automatic or manual)
|
|
const executeDecisionNode = async () => {
|
|
try {
|
|
stepLoading.value = true;
|
|
const currentNodeObj = workflowData.value.nodes[currentStep.value];
|
|
const { executionType = 'automatic' } = currentNodeObj.data || {};
|
|
|
|
// Debug: Log process variables before evaluating gateway
|
|
console.log('[Gateway Debug] Current processVariables:', JSON.stringify(processVariables.value, null, 2));
|
|
|
|
if (executionType === 'automatic') {
|
|
// Automatic decision based on conditions
|
|
const nextNodeId = getNextNodeIdForDecision(currentNodeObj.id);
|
|
if (nextNodeId) {
|
|
if (moveToSpecificNode(nextNodeId)) {
|
|
const node = workflowData.value.nodes[currentStep.value];
|
|
console.log(`[Workflow] Decision made automatically, moving to: ${node.data?.label || node.label}`);
|
|
}
|
|
}
|
|
}
|
|
// Manual decisions are handled in the template with buttons
|
|
} catch (err) {
|
|
console.error('[Workflow] Error executing decision:', err);
|
|
error.value = 'Failed to execute decision';
|
|
} finally {
|
|
stepLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// Handle manual decision choice
|
|
const makeDecision = (targetNodeId) => {
|
|
if (moveToSpecificNode(targetNodeId)) {
|
|
const node = workflowData.value.nodes[currentStep.value];
|
|
console.log(`[Workflow] Manual decision made, moving to: ${node.data?.label || node.label}`);
|
|
}
|
|
};
|
|
|
|
// Helper: Interpolate process variables in HTML content
|
|
const interpolateHtmlContent = (htmlContent) => {
|
|
if (!htmlContent || typeof htmlContent !== 'string') return htmlContent;
|
|
// Replace {{ processVariables.variableName }} with actual values
|
|
return htmlContent.replace(/\{\{\s*processVariables\.(\w+)\s*\}\}/g, (match, variableName) => {
|
|
const value = processVariables.value[variableName];
|
|
if (value === undefined || value === null) return '';
|
|
if (typeof value === 'object') {
|
|
// Pretty print object as JSON, wrapped in <pre> if not already inside <pre>
|
|
return `<pre style='display:inline;white-space:pre-wrap;'>${JSON.stringify(value, null, 2)}</pre>`;
|
|
}
|
|
return String(value);
|
|
});
|
|
};
|
|
|
|
// Compute the workflow path by following edges from start to end
|
|
function computeWorkflowPath() {
|
|
const path = [];
|
|
const nodes = workflowData.value?.nodes || [];
|
|
const edges = workflowData.value?.edges || [];
|
|
if (!nodes.length) return path;
|
|
let node = nodes.find(n => n.type === 'start');
|
|
while (node) {
|
|
path.push(node.id);
|
|
const outgoingEdges = edges.filter(e => e.source === node.id);
|
|
if (outgoingEdges.length > 1) {
|
|
console.warn('[Workflow] Multiple outgoing edges found for node', node.id, outgoingEdges);
|
|
// For progress, just follow the first edge for now
|
|
}
|
|
const edge = outgoingEdges[0];
|
|
node = edge ? nodes.find(n => n.id === edge.target) : null;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
const workflowPath = ref([]);
|
|
|
|
watch(
|
|
() => workflowData.value,
|
|
() => {
|
|
workflowPath.value = computeWorkflowPath();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
const currentStepIndex = computed(() => {
|
|
return workflowPath.value.indexOf(currentNode.value?.id) + 1;
|
|
});
|
|
const totalSteps = computed(() => workflowPath.value.length);
|
|
|
|
// Computed: Interpolated HTML content for HTML nodes
|
|
const interpolatedHtmlContent = computed(() => {
|
|
if (currentNode.value?.type !== 'html') return '';
|
|
const htmlContent = currentNode.value?.data?.htmlCode || currentNode.value?.data?.htmlContent || '';
|
|
// Interpolate variables in HTML
|
|
const interpolated = substituteVariables(htmlContent, processVariables.value);
|
|
return interpolateHtmlContent(interpolated);
|
|
});
|
|
|
|
// Computed: CSS styles for HTML nodes
|
|
const htmlNodeStyles = computed(() => {
|
|
if (currentNode.value?.type !== 'html') return '';
|
|
return currentNode.value?.data?.cssCode || '';
|
|
});
|
|
|
|
// Computed: Condition evaluation results for gateway nodes
|
|
const conditionEvaluationResults = computed(() => {
|
|
if (!['decision', 'gateway'].includes(currentNode.value?.type)) return [];
|
|
if (!currentNode.value?.data?.conditions) return [];
|
|
|
|
return currentNode.value.data.conditions.map(conditionGroup =>
|
|
getConditionGroupResult(conditionGroup, processVariables.value)
|
|
);
|
|
});
|
|
|
|
// CSS injection for HTML nodes
|
|
let currentStyleElement = null;
|
|
// JS injection for HTML nodes
|
|
let currentHtmlScriptElement = null;
|
|
|
|
// Function to inject CSS
|
|
const injectHtmlNodeCSS = (cssCode) => {
|
|
// Remove previous style element
|
|
if (currentStyleElement) {
|
|
currentStyleElement.remove();
|
|
currentStyleElement = null;
|
|
}
|
|
// Interpolate variables in CSS
|
|
const processedCss = substituteVariables(cssCode, processVariables.value);
|
|
// Add new styles if available
|
|
if (processedCss && processedCss.trim()) {
|
|
currentStyleElement = document.createElement('style');
|
|
currentStyleElement.textContent = processedCss;
|
|
currentStyleElement.setAttribute('data-workflow-html-node', 'true');
|
|
document.head.appendChild(currentStyleElement);
|
|
}
|
|
};
|
|
|
|
// Function to inject JS
|
|
const injectHtmlNodeJS = (jsCode) => {
|
|
// Remove previous script if exists
|
|
if (currentHtmlScriptElement) {
|
|
currentHtmlScriptElement.remove();
|
|
currentHtmlScriptElement = null;
|
|
}
|
|
// Expose processVariables to global scope
|
|
window.processVariables = processVariables.value;
|
|
// Interpolate variables in JS code
|
|
const processedJsCode = substituteVariables(jsCode, processVariables.value);
|
|
if (processedJsCode && processedJsCode.trim()) {
|
|
const script = document.createElement('script');
|
|
script.type = 'text/javascript';
|
|
script.textContent = processedJsCode;
|
|
script.setAttribute('data-workflow-html-node', 'true');
|
|
document.body.appendChild(script);
|
|
currentHtmlScriptElement = script;
|
|
}
|
|
};
|
|
|
|
// Watch for HTML node CSS changes and inject styles
|
|
watch(htmlNodeStyles, (newStyles) => {
|
|
injectHtmlNodeCSS(newStyles);
|
|
}, { immediate: true });
|
|
|
|
// Watch for HTML node JS changes and inject script
|
|
watch(
|
|
() => currentNode.value?.type === 'html' ? currentNode.value?.data?.jsCode : '',
|
|
(newJsCode) => {
|
|
nextTick(() => {
|
|
injectHtmlNodeJS(newJsCode);
|
|
});
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Cleanup styles and JS on unmount
|
|
onUnmounted(() => {
|
|
if (currentStyleElement) {
|
|
currentStyleElement.remove();
|
|
currentStyleElement = null;
|
|
}
|
|
if (currentHtmlScriptElement) {
|
|
currentHtmlScriptElement.remove();
|
|
currentHtmlScriptElement = null;
|
|
}
|
|
});
|
|
|
|
// Helper: Get next node object for single-path nodes
|
|
function getNextNodeObject(currentNode) {
|
|
if (!currentNode || !workflowData.value?.nodes) return null;
|
|
const edges = getOutgoingEdges(currentNode.id);
|
|
if (edges.length !== 1) return null;
|
|
const nextNodeId = edges[0].target;
|
|
return workflowData.value.nodes.find(n => n.id === nextNodeId) || null;
|
|
}
|
|
|
|
// Helper: Get next node label by node ID
|
|
function getNodeLabel(nodeId) {
|
|
const node = workflowData.value.nodes.find(n => n.id === nodeId);
|
|
return node?.data?.label || node?.label || 'Next Step';
|
|
}
|
|
|
|
// Helper: Get human-readable condition description
|
|
function getConditionDescription(condition, variables) {
|
|
const { variable, operator, value, valueType } = condition;
|
|
const variableValue = variables[variable];
|
|
|
|
let operatorText = operator;
|
|
switch (operator) {
|
|
case 'eq': operatorText = '='; break;
|
|
case 'neq': operatorText = '≠'; break;
|
|
case 'gt': operatorText = '>'; break;
|
|
case 'lt': operatorText = '<'; break;
|
|
case 'gte': operatorText = '≥'; break;
|
|
case 'lte': operatorText = '≤'; break;
|
|
case 'contains': operatorText = 'contains'; break;
|
|
case 'not_contains': operatorText = 'does not contain'; break;
|
|
case 'starts_with': operatorText = 'starts with'; break;
|
|
case 'ends_with': operatorText = 'ends with'; break;
|
|
case 'empty': operatorText = 'is empty'; break;
|
|
case 'not_empty': operatorText = 'is not empty'; break;
|
|
case 'is_true': operatorText = 'is true'; break;
|
|
case 'is_false': operatorText = 'is false'; break;
|
|
}
|
|
|
|
if (['empty', 'not_empty', 'is_true', 'is_false'].includes(operator)) {
|
|
return `${variable} ${operatorText}`;
|
|
}
|
|
|
|
return `${variable} ${operatorText} ${value}`;
|
|
}
|
|
|
|
// Helper: Get condition group evaluation result
|
|
function getConditionGroupResult(conditionGroup, variables) {
|
|
const results = conditionGroup.conditions.map(condition => {
|
|
const result = evaluateCondition(condition, variables);
|
|
const description = getConditionDescription(condition, variables);
|
|
return { result, description };
|
|
});
|
|
|
|
return {
|
|
group: conditionGroup,
|
|
results,
|
|
finalResult: evaluateConditionGroup(conditionGroup, variables)
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-gray-50">
|
|
<!-- Header -->
|
|
<header class="bg-white border-b border-gray-200 px-6 py-4 shadow-sm">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<Icon
|
|
@click="goHome"
|
|
name="ph:arrow-circle-left-duotone"
|
|
class="cursor-pointer w-6 h-6 hover:text-gray-600 text-gray-500"
|
|
/>
|
|
<div class="flex items-center gap-3">
|
|
<img
|
|
src="@/assets/img/logo/logo-word-black.svg"
|
|
alt="Corrad Logo"
|
|
class="h-8"
|
|
/>
|
|
<div class="border-l border-gray-300 pl-3">
|
|
<h1 class="text-xl font-semibold text-gray-900">Process Execution</h1>
|
|
<p class="text-sm text-gray-500">
|
|
{{ process?.processName || 'Loading...' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress indicator -->
|
|
<div v-if="!loading && !error" class="flex items-center gap-2 text-sm text-gray-600">
|
|
<span>Step {{ currentStepIndex }} of {{ totalSteps }}</span>
|
|
<div class="w-32 bg-gray-200 rounded-full h-2">
|
|
<div
|
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
:style="{ width: `${((currentStepIndex) / (totalSteps || 1)) * 100}%` }"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Content -->
|
|
<div class="container mx-auto px-6 py-8 max-w-[1200px]">
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="flex justify-center items-center py-12">
|
|
<div class="text-center">
|
|
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-blue-500 mx-auto mb-2" />
|
|
<p class="text-gray-500">Loading process...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
|
|
<Icon name="material-symbols:error-outline" class="w-16 h-16 text-red-400 mx-auto mb-4" />
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Error</h3>
|
|
<p class="text-gray-600 mb-6">{{ error }}</p>
|
|
<RsButton @click="goHome" variant="primary">
|
|
<Icon name="material-symbols:home" class="mr-2" />
|
|
Go Home
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Process Complete -->
|
|
<div v-else-if="isProcessComplete" class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
|
|
<Icon name="material-symbols:check-circle" class="w-16 h-16 text-green-500 mx-auto mb-4" />
|
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">Process Complete!</h2>
|
|
<p class="text-gray-600 mb-6">
|
|
The workflow "{{ process.processName }}" has been completed successfully.
|
|
</p>
|
|
<div class="flex justify-center gap-3">
|
|
<RsButton @click="loadProcess" variant="secondary">
|
|
<Icon name="material-symbols:refresh" class="mr-2" />
|
|
Run Again
|
|
</RsButton>
|
|
<RsButton @click="goHome" variant="primary">
|
|
<Icon name="material-symbols:home" class="mr-2" />
|
|
Go Home
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Current Step -->
|
|
<div v-else-if="currentNode" class="space-y-6">
|
|
<!-- Step Info -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div class="flex items-center gap-4 mb-4">
|
|
<div :class="[
|
|
'p-3 rounded-lg',
|
|
getStepInfo(currentNode).color === 'blue' ? 'bg-blue-100 text-blue-600' :
|
|
getStepInfo(currentNode).color === 'green' ? 'bg-green-100 text-green-600' :
|
|
getStepInfo(currentNode).color === 'purple' ? 'bg-purple-100 text-purple-600' :
|
|
getStepInfo(currentNode).color === 'orange' ? 'bg-orange-100 text-orange-600' :
|
|
getStepInfo(currentNode).color === 'yellow' ? 'bg-yellow-100 text-yellow-600' :
|
|
getStepInfo(currentNode).color === 'indigo' ? 'bg-indigo-100 text-indigo-600' :
|
|
getStepInfo(currentNode).color === 'pink' ? 'bg-pink-100 text-pink-600' :
|
|
'bg-gray-100 text-gray-600'
|
|
]">
|
|
<Icon :name="getStepInfo(currentNode).icon" class="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-gray-900">{{ getStepInfo(currentNode).label }}</h2>
|
|
<p class="text-gray-600">{{ currentNode.data?.label || currentNode.data?.name || `Step ${currentStepIndex}` }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Form Step -->
|
|
<div v-if="currentNode.type === 'form'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div class="mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
|
{{ currentNode.data?.formName || currentNode.data?.label || 'Please fill out the form' }}
|
|
</h3>
|
|
<p v-if="currentForm?.formDescription" class="text-gray-600 text-sm">
|
|
{{ currentForm.formDescription }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Loading form data -->
|
|
<div v-if="!currentForm && currentNode.data?.formId" class="flex justify-center py-8">
|
|
<div class="text-center">
|
|
<Icon name="material-symbols:progress-activity" class="w-6 h-6 animate-spin text-blue-500 mx-auto mb-2" />
|
|
<p class="text-gray-500">Loading form...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Form rendering -->
|
|
<div v-else-if="currentForm && currentForm.formComponents" class="form-container">
|
|
<!-- Conditional Logic Engine for FormKit conditional logic -->
|
|
<ConditionalLogicEngine
|
|
:form-components="currentForm.formComponents"
|
|
:form-data="formData"
|
|
@script-generated="handleConditionalLogicGenerated"
|
|
:key="currentNode.value?.id"
|
|
/>
|
|
|
|
<!-- Form Script Engine for conditional logic + custom script -->
|
|
<FormScriptEngine
|
|
v-if="combinedScript"
|
|
:form-data="formData"
|
|
:custom-script="combinedScript"
|
|
:custom-css="currentForm.customCSS"
|
|
:form-events="currentForm.formEvents || { onLoad: true, onFieldChange: true }"
|
|
:script-mode="currentForm.scriptMode || 'safe'"
|
|
@field-change="handleScriptFieldChange"
|
|
:key="currentNode.value?.id"
|
|
/>
|
|
|
|
<!-- FormKit form wrapper -->
|
|
<FormKit
|
|
type="form"
|
|
v-model="formData"
|
|
ref="formRef"
|
|
@submit="onFormKitSubmit"
|
|
:actions="false"
|
|
:incomplete-message="false"
|
|
validation-visibility="submit"
|
|
:key="currentForm?.formId || currentNode.value?.id"
|
|
>
|
|
<div class="grid grid-cols-12 gap-2">
|
|
<template v-for="component in currentForm.formComponents" :key="component.id || component.props.name">
|
|
<ComponentPreview
|
|
:component="component"
|
|
:is-preview="false"
|
|
:field-states="fieldStates"
|
|
/>
|
|
</template>
|
|
<div class="flex justify-end pt-6 border-t border-gray-200">
|
|
<RsButton
|
|
@click="validateAndSubmit"
|
|
:disabled="stepLoading"
|
|
variant="primary"
|
|
>
|
|
<Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" />
|
|
{{ stepLoading ? 'Processing...' : 'Submit & Continue' }}
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
</FormKit>
|
|
|
|
<!-- Script Info (for debugging) -->
|
|
<div v-if="combinedScript" class="mt-4 p-3 bg-gray-50 rounded-lg border">
|
|
<details>
|
|
<summary class="text-sm font-medium text-gray-700 cursor-pointer">Form Logic Available (Development)</summary>
|
|
<div class="mt-2 space-y-2">
|
|
<div v-if="conditionalLogicScript">
|
|
<p class="text-xs font-medium text-gray-600">Conditional Logic:</p>
|
|
<pre class="text-xs text-gray-600 bg-white p-2 rounded border overflow-auto max-h-32">{{ conditionalLogicScript.substring(0, 200) }}...</pre>
|
|
</div>
|
|
<div v-if="currentForm?.customScript">
|
|
<p class="text-xs font-medium text-gray-600">Custom Script:</p>
|
|
<pre class="text-xs text-gray-600 bg-white p-2 rounded border overflow-auto max-h-32">{{ customScriptPreview }}</pre>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fallback if no form data -->
|
|
<div v-else class="text-center py-8">
|
|
<Icon name="material-symbols:description-outline" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
|
<h4 class="text-lg font-medium text-gray-900 mb-2">Form Not Found</h4>
|
|
<p class="text-gray-600 mb-4">The form associated with this step could not be loaded.</p>
|
|
<RsButton @click="moveToNextStep" variant="primary">
|
|
<Icon name="material-symbols:skip-next" class="mr-2" />
|
|
Skip to Next Step
|
|
</RsButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- API/Script/Notification Step -->
|
|
<div v-else-if="['api', 'script', 'notification'].includes(currentNode.type)" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<!-- Processing State -->
|
|
<div v-if="stepLoading" class="text-center py-8">
|
|
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-blue-500 mx-auto mb-4" />
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
|
{{
|
|
currentNode.type === 'api' ? 'Calling API...' :
|
|
currentNode.type === 'script' ? 'Executing Script...' :
|
|
'Sending Notification...'
|
|
}}
|
|
</h3>
|
|
<p class="text-gray-600">Please wait while we process this step</p>
|
|
</div>
|
|
|
|
<!-- Completed State with Multiple Paths -->
|
|
<div v-else-if="!canAutoProgress(currentNode)" class="space-y-4">
|
|
<div class="text-center">
|
|
<Icon name="material-symbols:check-circle" class="w-8 h-8 text-green-500 mx-auto mb-4" />
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
|
{{
|
|
currentNode.type === 'api' ? 'API Call Complete' :
|
|
currentNode.type === 'script' ? 'Script Executed' :
|
|
'Notification Sent'
|
|
}}
|
|
</h3>
|
|
<p class="text-gray-600 mb-6">{{ currentNode.data?.label || currentNode.label }}</p>
|
|
</div>
|
|
|
|
<!-- Multiple Path Options -->
|
|
<div class="border-t border-gray-200 pt-4">
|
|
<h4 class="font-medium text-gray-700 mb-3">Choose your next step:</h4>
|
|
<div class="grid gap-2">
|
|
<template v-for="edge in getOutgoingEdges(currentNode.id)" :key="edge.id">
|
|
<RsButton
|
|
@click="makeDecision(edge.target)"
|
|
variant="outline"
|
|
class="justify-between p-3"
|
|
>
|
|
<span>Continue to: {{ getNodeLabel(edge.target) }}</span>
|
|
<Icon name="material-symbols:arrow-right-alt" />
|
|
</RsButton>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- HTML Content Step -->
|
|
<div v-else-if="currentNode.type === 'html'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div class="mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
|
{{ currentNode.data?.title || currentNode.data?.label || 'Content Display' }}
|
|
</h3>
|
|
<p v-if="currentNode.data?.description" class="text-gray-600 text-sm">
|
|
{{ currentNode.data.description }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- HTML Content -->
|
|
<div class="html-content-container border border-gray-200 rounded-lg p-4 mb-6">
|
|
<div
|
|
v-if="interpolatedHtmlContent"
|
|
v-html="interpolatedHtmlContent"
|
|
class="prose max-w-none"
|
|
></div>
|
|
<div v-else class="text-center text-gray-500 py-8">
|
|
<Icon name="material-symbols:code-blocks" class="w-12 h-12 mx-auto mb-2" />
|
|
<p>No HTML content provided</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Continue Options -->
|
|
<div class="pt-4 border-t border-gray-200">
|
|
<!-- Single Path - Simple Continue Button -->
|
|
<div v-if="canAutoProgress(currentNode)" class="flex justify-end">
|
|
<RsButton
|
|
@click="moveToNextStep"
|
|
:disabled="stepLoading"
|
|
variant="primary"
|
|
>
|
|
<Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" />
|
|
<template v-if="!stepLoading">
|
|
Continue to: {{ getNextNodeObject(currentNode)?.data?.label || getNextNodeObject(currentNode)?.label || 'Next Step' }}
|
|
</template>
|
|
<template v-else>
|
|
Processing...
|
|
</template>
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Multiple Paths - Show Options -->
|
|
<div v-else class="space-y-3">
|
|
<h4 class="font-medium text-gray-700">Choose your next step:</h4>
|
|
<div class="grid gap-2">
|
|
<template v-for="edge in getOutgoingEdges(currentNode.id)" :key="edge.id">
|
|
<RsButton
|
|
@click="makeDecision(edge.target)"
|
|
variant="outline"
|
|
class="justify-between p-3"
|
|
>
|
|
<span>Continue to: {{ getNodeLabel(edge.target) }}</span>
|
|
<Icon name="material-symbols:arrow-right-alt" />
|
|
</RsButton>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Decision/Gateway Step -->
|
|
<div v-else-if="['decision', 'gateway'].includes(currentNode.type)" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<div class="mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
|
{{ currentNode.data?.title || currentNode.data?.label || 'Make a Decision' }}
|
|
</h3>
|
|
<p v-if="currentNode.data?.description" class="text-gray-600 text-sm">
|
|
{{ currentNode.data.description }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Manual Decision Options -->
|
|
<div v-if="currentNode.data?.executionType === 'manual'" class="space-y-4">
|
|
<p class="text-gray-700 mb-4">Please choose one of the following options:</p>
|
|
<div class="grid gap-3">
|
|
<template v-for="edge in workflowData.edges.filter(e => e.source === currentNode.id)" :key="edge.id">
|
|
<RsButton
|
|
@click="makeDecision(edge.target)"
|
|
variant="outline-primary"
|
|
class="justify-start p-4 h-auto"
|
|
>
|
|
<div class="text-left">
|
|
<div class="font-medium">{{ edge.label || 'Option' }}</div>
|
|
<div v-if="edge.data?.description" class="text-sm text-gray-500 mt-1">
|
|
{{ edge.data.description }}
|
|
</div>
|
|
</div>
|
|
<Icon name="material-symbols:arrow-right-alt" class="ml-auto" />
|
|
</RsButton>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Automatic Decision Processing -->
|
|
<div v-else class="text-center py-8">
|
|
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-yellow-500 mx-auto mb-4" />
|
|
<h4 class="text-lg font-medium text-gray-900 mb-2">Evaluating Conditions</h4>
|
|
<p class="text-gray-600">Please wait while we determine the next step...</p>
|
|
|
|
<!-- Show conditions being evaluated -->
|
|
<div v-if="currentNode.data?.conditions?.length" class="mt-6 text-left">
|
|
<h5 class="font-medium text-gray-700 mb-3">Decision Paths:</h5>
|
|
<div class="space-y-3">
|
|
<div v-for="(evaluationResult, groupIndex) in conditionEvaluationResults" :key="groupIndex"
|
|
class="border rounded-lg p-3"
|
|
:class="evaluationResult.finalResult ? 'border-green-300 bg-green-50' : 'border-gray-200 bg-gray-50'">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<Icon name="material-symbols:alt-route" class="w-4 h-4"
|
|
:class="evaluationResult.finalResult ? 'text-green-500' : 'text-orange-500'" />
|
|
<span class="font-medium text-sm">{{ evaluationResult.group.output || `Path ${groupIndex + 1}` }}</span>
|
|
<span v-if="evaluationResult.finalResult"
|
|
class="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">
|
|
MATCH
|
|
</span>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div v-for="(conditionResult, conditionIndex) in evaluationResult.results" :key="conditionIndex"
|
|
class="flex items-center gap-2 text-xs">
|
|
<Icon name="material-symbols:rule" class="w-3 h-3"
|
|
:class="conditionResult.result ? 'text-green-500' : 'text-red-500'" />
|
|
<span v-if="conditionIndex > 0" class="text-orange-600 font-medium">
|
|
{{ evaluationResult.group.conditions[conditionIndex].logicalOperator?.toUpperCase() || 'AND' }}
|
|
</span>
|
|
<span :class="conditionResult.result ? 'text-green-700' : 'text-red-700'">
|
|
{{ conditionResult.description }}
|
|
</span>
|
|
<span class="text-gray-500">
|
|
({{ conditionResult.result ? '✓' : '✗' }})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Other Step Types -->
|
|
<div v-else class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
|
<Icon name="material-symbols:info" class="w-8 h-8 text-blue-500 mx-auto mb-4" />
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ getStepInfo(currentNode).label }}</h3>
|
|
<p class="text-gray-600 mb-6">This step type is not yet implemented</p>
|
|
<RsButton @click="moveToNextStep" variant="primary">
|
|
<Icon name="material-symbols:skip-next" class="mr-2" />
|
|
Skip to Next Step
|
|
</RsButton>
|
|
</div>
|
|
|
|
<!-- Variable Mapping Debug (always visible for any node) -->
|
|
<div v-if="currentNode" class="bg-gray-100 rounded-lg p-4">
|
|
<details>
|
|
<summary class="font-medium text-gray-700 cursor-pointer mb-2">Variable Mapping Debug</summary>
|
|
<div class="space-y-3 text-xs">
|
|
<!-- Input Mappings -->
|
|
<div v-if="currentNode.data?.inputMappings?.length">
|
|
<p class="font-medium text-gray-700">Input Mappings (Process → Form):</p>
|
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.inputMappings, null, 2) }}</pre>
|
|
</div>
|
|
<!-- Output Mappings -->
|
|
<div v-if="currentNode.data?.outputMappings?.length">
|
|
<p class="font-medium text-gray-700">Output Mappings (Form → Process):</p>
|
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.outputMappings, null, 2) }}</pre>
|
|
</div>
|
|
<!-- Field Conditions -->
|
|
<div v-if="currentNode.data?.fieldConditions?.length">
|
|
<p class="font-medium text-gray-700">Field Conditions:</p>
|
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.fieldConditions, null, 2) }}</pre>
|
|
</div>
|
|
<!-- Current Field States -->
|
|
<div v-if="Object.keys(fieldStates || {}).length">
|
|
<p class="font-medium text-gray-700">Active Field States:</p>
|
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(fieldStates, null, 2) }}</pre>
|
|
</div>
|
|
<!-- Form Data -->
|
|
<div v-if="Object.keys(formData || {}).length">
|
|
<p class="font-medium text-gray-700">Current Form Data:</p>
|
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(formData, null, 2) }}</pre>
|
|
</div>
|
|
<!-- Node Data (for non-form nodes) -->
|
|
<div v-if="currentNode.data && !currentNode.data.inputMappings && !currentNode.data.outputMappings && !currentNode.data.fieldConditions">
|
|
<p class="font-medium text-gray-700">Node Data:</p>
|
|
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data, null, 2) }}</pre>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<!-- Process Variables Debug (only in development) -->
|
|
<div v-if="Object.keys(processVariables || {}).length > 0" class="bg-gray-100 rounded-lg p-4 mt-4">
|
|
<details>
|
|
<summary class="font-medium text-gray-700 cursor-pointer mb-2">Process Variables (Debug)</summary>
|
|
<pre class="text-xs text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(processVariables, null, 2) }}</pre>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.transition-all {
|
|
transition-property: all;
|
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
transition-duration: 300ms;
|
|
}
|
|
</style> |