Add Form and Process Management Features
- Introduced new components for form selection and gateway condition management within the process builder. - Implemented a `FormSelector` component for selecting and managing forms, including search functionality and loading states. - Developed a `GatewayConditionManager` component to manage conditions for gateways, allowing users to define and edit conditions visually. - Created a `ProcessBuilderComponents` component to facilitate the addition of core components in the process builder. - Enhanced the `ProcessFlowCanvas` to support new features, including edge selection and improved node management. - Updated the backend API to handle CRUD operations for forms and processes, including error handling for associated tasks. - Integrated new database models for forms and processes in Prisma, ensuring proper relationships and data integrity. - Improved state management in the form builder store to accommodate new features and enhance user experience.
This commit is contained in:
parent
ac76e02825
commit
bb5e4c0637
263
components/process-flow/FormSelector.vue
Normal file
263
components/process-flow/FormSelector.vue
Normal file
@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="form-selector">
|
||||
<div class="form-selector-header mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-700">Form Selection</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-2 border-blue-500 border-t-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="forms.length === 0" class="text-center py-4 text-gray-500">
|
||||
<p>No forms available</p>
|
||||
<RsButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
@click="createNewForm"
|
||||
>
|
||||
Create New Form
|
||||
</RsButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-selector-content">
|
||||
<div class="form-search mb-2">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="formSearch"
|
||||
placeholder="Search forms..."
|
||||
v-model="searchQuery"
|
||||
:delay="200"
|
||||
:classes="{
|
||||
outer: 'mb-0',
|
||||
input: 'w-full'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-list max-h-60 overflow-y-auto border rounded-md">
|
||||
<div
|
||||
v-for="form in filteredForms"
|
||||
:key="form.formUUID"
|
||||
class="form-item p-2 hover:bg-gray-50 cursor-pointer border-b"
|
||||
:class="{'bg-blue-50': selectedFormId === form.formID}"
|
||||
@click="selectForm(form)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="font-medium text-sm">{{ form.formName }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ form.formDescription || 'No description' }}</div>
|
||||
</div>
|
||||
<div v-if="selectedFormId === form.formID" class="text-blue-500">
|
||||
<Icon name="material-symbols:check-circle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-selector-footer mt-3 flex justify-between">
|
||||
<RsButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="createNewForm"
|
||||
>
|
||||
Create New
|
||||
</RsButton>
|
||||
|
||||
<RsButton
|
||||
v-if="selectedForm"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="previewForm"
|
||||
>
|
||||
Preview Form
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedForm" class="selected-form-preview mt-4 p-3 border rounded-md bg-gray-50">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="text-sm font-medium">Selected Form</h4>
|
||||
<div @click="clearSelection" class="text-red-500 cursor-pointer text-sm">
|
||||
<Icon name="material-symbols:close" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">{{ selectedForm.formName }}</div>
|
||||
<div class="text-xs text-gray-500">{{ selectedForm.formDescription || 'No description' }}</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
<div class="flex justify-between">
|
||||
<span>Created:</span>
|
||||
<span>{{ formatDate(selectedForm.formCreatedDate) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Status:</span>
|
||||
<span class="capitalize">{{ selectedForm.formStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useFormBuilderStore } from '~/stores/formBuilder';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
formId: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
nodeData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'clear']);
|
||||
|
||||
const router = useRouter();
|
||||
const formStore = useFormBuilderStore();
|
||||
const forms = ref([]);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const selectedFormId = ref(props.formId);
|
||||
const selectedNodeData = ref(null);
|
||||
|
||||
// Fetch forms from the API
|
||||
const fetchForms = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Use the API endpoint we created
|
||||
const response = await fetch('/api/forms');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && Array.isArray(result.forms)) {
|
||||
forms.value = result.forms;
|
||||
} else {
|
||||
console.error('Error in API response:', result.error || 'Unknown error');
|
||||
forms.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching forms:', error);
|
||||
forms.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter forms based on search query
|
||||
const filteredForms = computed(() => {
|
||||
if (!searchQuery.value) return forms.value;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return forms.value.filter(form =>
|
||||
form.formName.toLowerCase().includes(query) ||
|
||||
(form.formDescription && form.formDescription.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
// Get the selected form
|
||||
const selectedForm = computed(() => {
|
||||
if (!selectedFormId.value) return null;
|
||||
return forms.value.find(form => form.formID === selectedFormId.value);
|
||||
});
|
||||
|
||||
// Select a form
|
||||
const selectForm = async (form) => {
|
||||
selectedFormId.value = form.formID;
|
||||
|
||||
try {
|
||||
// If we have a task ID in the node, update the task in the database
|
||||
if (selectedNodeData.value?.id) {
|
||||
const response = await fetch(`/api/tasks/${selectedNodeData.value.id}/form`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
formId: form.formID
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Error connecting form to task:', result.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the form selection event for parent components
|
||||
emit('select', form);
|
||||
} catch (error) {
|
||||
console.error('Error selecting form:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear the selection
|
||||
const clearSelection = async () => {
|
||||
selectedFormId.value = null;
|
||||
|
||||
try {
|
||||
// If we have a task ID in the node, remove the form from the task in the database
|
||||
if (selectedNodeData.value?.id) {
|
||||
const response = await fetch(`/api/tasks/${selectedNodeData.value.id}/form`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Error removing form from task:', result.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the clear selection event for parent components
|
||||
emit('clear');
|
||||
} catch (error) {
|
||||
console.error('Error clearing form selection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to create a new form
|
||||
const createNewForm = () => {
|
||||
router.push('/form-builder');
|
||||
};
|
||||
|
||||
// Preview the selected form
|
||||
const previewForm = () => {
|
||||
// This would open a preview modal or navigate to form preview
|
||||
// For now, we'll just navigate to the form builder with the form ID
|
||||
if (selectedForm.value) {
|
||||
router.push(`/form-builder?id=${selectedForm.value.formUUID}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Load forms when component mounts
|
||||
onMounted(() => {
|
||||
fetchForms();
|
||||
selectedNodeData.value = props.nodeData;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-selector {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.form-list {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.form-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
270
components/process-flow/GatewayConditionManager.vue
Normal file
270
components/process-flow/GatewayConditionManager.vue
Normal file
@ -0,0 +1,270 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
conditions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availableVariables: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ name: 'amount', label: 'Amount', type: 'number' },
|
||||
{ name: 'status', label: 'Status', type: 'string' },
|
||||
{ name: 'priority', label: 'Priority', type: 'string' },
|
||||
{ name: 'requestType', label: 'Request Type', type: 'string' },
|
||||
{ name: 'dueDate', label: 'Due Date', type: 'date' },
|
||||
{ name: 'isUrgent', label: 'Is Urgent', type: 'boolean' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:conditions', 'add-condition', 'remove-condition']);
|
||||
|
||||
// Local copy of conditions
|
||||
const localConditions = ref([...(props.conditions || [])]);
|
||||
|
||||
// Watch for external changes
|
||||
watch(() => props.conditions, (newConditions) => {
|
||||
localConditions.value = [...(newConditions || [])];
|
||||
}, { deep: true });
|
||||
|
||||
// Operators based on variable type
|
||||
const getOperatorsForType = (type) => {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return [
|
||||
{ value: 'eq', label: '= (Equal to)' },
|
||||
{ value: 'neq', label: '≠ (Not equal to)' },
|
||||
{ value: 'gt', label: '> (Greater than)' },
|
||||
{ value: 'gte', label: '≥ (Greater than or equal to)' },
|
||||
{ value: 'lt', label: '< (Less than)' },
|
||||
{ value: 'lte', label: '≤ (Less than or equal to)' }
|
||||
];
|
||||
case 'string':
|
||||
return [
|
||||
{ value: 'eq', label: '= (Equal to)' },
|
||||
{ value: 'neq', label: '≠ (Not equal to)' },
|
||||
{ value: 'contains', label: 'Contains' },
|
||||
{ value: 'startsWith', label: 'Starts with' },
|
||||
{ value: 'endsWith', label: 'Ends with' }
|
||||
];
|
||||
case 'boolean':
|
||||
return [
|
||||
{ value: 'eq', label: '= (Equal to)' },
|
||||
{ value: 'neq', label: '≠ (Not equal to)' }
|
||||
];
|
||||
case 'date':
|
||||
return [
|
||||
{ value: 'eq', label: '= (Equal to)' },
|
||||
{ value: 'neq', label: '≠ (Not equal to)' },
|
||||
{ value: 'gt', label: '> (After)' },
|
||||
{ value: 'gte', label: '≥ (On or after)' },
|
||||
{ value: 'lt', label: '< (Before)' },
|
||||
{ value: 'lte', label: '≤ (On or before)' }
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{ value: 'eq', label: '= (Equal to)' },
|
||||
{ value: 'neq', label: '≠ (Not equal to)' }
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
// Get value input type based on variable type
|
||||
const getInputTypeForVarType = (type) => {
|
||||
switch (type) {
|
||||
case 'number': return 'number';
|
||||
case 'date': return 'date';
|
||||
case 'boolean': return 'checkbox';
|
||||
default: return 'text';
|
||||
}
|
||||
};
|
||||
|
||||
// Add new condition
|
||||
const addCondition = () => {
|
||||
const defaultVar = props.availableVariables[0];
|
||||
const newCondition = {
|
||||
id: `condition-${Date.now()}`,
|
||||
variable: defaultVar.name,
|
||||
operator: getOperatorsForType(defaultVar.type)[0].value,
|
||||
value: '',
|
||||
valueType: defaultVar.type,
|
||||
output: '', // Output path label (e.g., "Yes" or "No")
|
||||
};
|
||||
|
||||
localConditions.value.push(newCondition);
|
||||
emit('update:conditions', [...localConditions.value]);
|
||||
emit('add-condition', newCondition);
|
||||
};
|
||||
|
||||
// Remove condition
|
||||
const removeCondition = (index) => {
|
||||
localConditions.value.splice(index, 1);
|
||||
emit('update:conditions', [...localConditions.value]);
|
||||
emit('remove-condition', index);
|
||||
};
|
||||
|
||||
// Update condition
|
||||
const updateCondition = (index, field, value) => {
|
||||
if (field === 'variable') {
|
||||
const selectedVar = props.availableVariables.find(v => v.name === value);
|
||||
localConditions.value[index].variable = value;
|
||||
localConditions.value[index].valueType = selectedVar.type;
|
||||
// Reset operator to a valid one for this type
|
||||
localConditions.value[index].operator = getOperatorsForType(selectedVar.type)[0].value;
|
||||
// Reset value
|
||||
localConditions.value[index].value = '';
|
||||
} else {
|
||||
localConditions.value[index][field] = value;
|
||||
}
|
||||
|
||||
emit('update:conditions', [...localConditions.value]);
|
||||
};
|
||||
|
||||
// Generate human-readable condition text
|
||||
const conditionText = (condition) => {
|
||||
if (!condition.variable || !condition.operator) return '';
|
||||
|
||||
const variable = props.availableVariables.find(v => v.name === condition.variable);
|
||||
const operator = getOperatorsForType(variable?.type || 'string').find(op => op.value === condition.operator);
|
||||
|
||||
return `${variable?.label || condition.variable} ${operator?.label.split(' ')[0] || condition.operator} ${condition.value}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gateway-condition-manager">
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-700">Gateway Conditions</h3>
|
||||
<button
|
||||
@click="addCondition"
|
||||
class="px-2 py-1 bg-orange-500 text-white text-xs rounded hover:bg-orange-600"
|
||||
>
|
||||
Add Condition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="localConditions.length === 0" class="text-gray-500 text-sm italic mb-2">
|
||||
No conditions defined. Default path will be taken.
|
||||
</div>
|
||||
|
||||
<div v-else class="conditions-list space-y-4 mb-4">
|
||||
<div
|
||||
v-for="(condition, index) in localConditions"
|
||||
:key="condition.id"
|
||||
class="condition-item p-3 border rounded-md bg-orange-50 relative"
|
||||
>
|
||||
<button
|
||||
@click="removeCondition(index)"
|
||||
class="absolute top-1 right-1 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<i class="material-icons text-sm">close</i>
|
||||
</button>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-xs font-medium mb-1">If</div>
|
||||
<div class="condition-grid grid grid-cols-3 gap-2">
|
||||
<!-- Variable -->
|
||||
<select
|
||||
:value="condition.variable"
|
||||
@change="updateCondition(index, 'variable', $event.target.value)"
|
||||
class="col-span-1 p-2 border rounded text-xs"
|
||||
>
|
||||
<option
|
||||
v-for="variable in availableVariables"
|
||||
:key="variable.name"
|
||||
:value="variable.name"
|
||||
>
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Operator -->
|
||||
<select
|
||||
:value="condition.operator"
|
||||
@change="updateCondition(index, 'operator', $event.target.value)"
|
||||
class="col-span-1 p-2 border rounded text-xs"
|
||||
>
|
||||
<option
|
||||
v-for="operator in getOperatorsForType(
|
||||
props.availableVariables.find(v => v.name === condition.variable)?.type || 'string'
|
||||
)"
|
||||
:key="operator.value"
|
||||
:value="operator.value"
|
||||
>
|
||||
{{ operator.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Value -->
|
||||
<input
|
||||
v-if="condition.valueType !== 'boolean'"
|
||||
:type="getInputTypeForVarType(condition.valueType)"
|
||||
:value="condition.value"
|
||||
@input="updateCondition(index, 'value', $event.target.value)"
|
||||
class="col-span-1 p-2 border rounded text-xs"
|
||||
:placeholder="'Value'"
|
||||
/>
|
||||
<div v-else class="col-span-1 p-2 border rounded flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="condition.value === true || condition.value === 'true'"
|
||||
@change="updateCondition(index, 'value', $event.target.checked)"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-xs">{{ condition.value === true || condition.value === 'true' ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output path -->
|
||||
<div class="mb-1">
|
||||
<div class="text-xs font-medium mb-1">Then follow path:</div>
|
||||
<input
|
||||
type="text"
|
||||
:value="condition.output"
|
||||
@input="updateCondition(index, 'output', $event.target.value)"
|
||||
class="w-full p-2 border rounded text-xs"
|
||||
placeholder="Path label (e.g. 'Yes', 'Approved')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Condition summary -->
|
||||
<div class="mt-2 p-2 bg-white rounded-md border border-orange-200">
|
||||
<div class="text-xs text-orange-800">
|
||||
<span class="font-medium">Condition:</span>
|
||||
{{ conditionText(condition) }} → {{ condition.output || 'Unlabeled path' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default path -->
|
||||
<div class="default-path p-3 border rounded-md bg-gray-50">
|
||||
<div class="text-xs font-medium mb-1">Default Path (when no conditions match):</div>
|
||||
<input
|
||||
type="text"
|
||||
value="Default"
|
||||
class="w-full p-2 border rounded text-xs"
|
||||
placeholder="Default path label (e.g. 'No', 'Rejected')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gateway-condition-manager {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.condition-item {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.condition-item:hover {
|
||||
border-color: #f97316;
|
||||
}
|
||||
</style>
|
180
components/process-flow/ProcessBuilderComponents.vue
Normal file
180
components/process-flow/ProcessBuilderComponents.vue
Normal file
@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="process-components">
|
||||
<!-- Search Bar -->
|
||||
<div class="search-container p-3 mb-2">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search..."
|
||||
class="w-full px-3 py-2 pl-9 bg-white border border-gray-300 rounded text-gray-700 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<Icon
|
||||
name="material-symbols:search"
|
||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Components -->
|
||||
<div class="component-category mb-6">
|
||||
<h3 class="text-gray-700 text-sm font-medium px-3 mb-2">Core Components</h3>
|
||||
<div class="grid grid-cols-2 gap-1 px-2">
|
||||
<div
|
||||
v-for="component in getComponentsByCategory('Core')"
|
||||
:key="component.type"
|
||||
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200"
|
||||
:class="{ 'hidden': !matchesSearch(component) }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, component)"
|
||||
@click="addComponent(component)"
|
||||
>
|
||||
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
|
||||
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['add-component']);
|
||||
const searchQuery = ref('');
|
||||
|
||||
// Define basic process components aligned with BPMN notation
|
||||
const availableComponents = [
|
||||
// Core components
|
||||
{
|
||||
type: 'start',
|
||||
name: 'Start Event',
|
||||
category: 'Core',
|
||||
icon: 'material-symbols:play-circle-outline',
|
||||
description: 'Initiates the process flow',
|
||||
defaultProps: {
|
||||
label: 'Start',
|
||||
data: {
|
||||
description: 'Process start point'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
name: 'End Event',
|
||||
category: 'Core',
|
||||
icon: 'material-symbols:stop-circle-outline',
|
||||
description: 'Terminates the process flow',
|
||||
defaultProps: {
|
||||
label: 'End',
|
||||
data: {
|
||||
description: 'Process end point'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'task',
|
||||
name: 'Task',
|
||||
category: 'Core',
|
||||
icon: 'material-symbols:assignment-outline',
|
||||
description: 'Manual or automated task',
|
||||
defaultProps: {
|
||||
label: 'Task',
|
||||
data: {
|
||||
description: 'A general task',
|
||||
assignee: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'form',
|
||||
name: 'Form Task',
|
||||
category: 'Core',
|
||||
icon: 'material-symbols:description-outline',
|
||||
description: 'Form to be filled out',
|
||||
defaultProps: {
|
||||
label: 'Form Task',
|
||||
data: {
|
||||
description: 'Form submission task',
|
||||
formId: null,
|
||||
formName: null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'gateway',
|
||||
name: 'Gateway',
|
||||
category: 'Core',
|
||||
icon: 'material-symbols:call-split',
|
||||
description: 'Decision point for flow control',
|
||||
defaultProps: {
|
||||
label: 'Gateway',
|
||||
data: {
|
||||
description: 'Decision gateway',
|
||||
conditions: [],
|
||||
defaultPath: 'Default'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Get components by category for rendering in sections
|
||||
const getComponentsByCategory = (category) => {
|
||||
return availableComponents.filter(component => component.category === category);
|
||||
};
|
||||
|
||||
// Check if component matches search query
|
||||
const matchesSearch = (component) => {
|
||||
if (!searchQuery.value) return true;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return (
|
||||
component.name.toLowerCase().includes(query) ||
|
||||
component.description.toLowerCase().includes(query) ||
|
||||
component.type.toLowerCase().includes(query)
|
||||
);
|
||||
};
|
||||
|
||||
// Handle drag start event
|
||||
const onDragStart = (event, component) => {
|
||||
// Set the component data in the format expected by ProcessFlowCanvas
|
||||
const componentData = {
|
||||
type: component.type,
|
||||
label: component.defaultProps.label,
|
||||
data: component.defaultProps.data
|
||||
};
|
||||
|
||||
// Set the drag data
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(componentData));
|
||||
};
|
||||
|
||||
// Add a component directly via click
|
||||
const addComponent = (component) => {
|
||||
// Use same format as drag operation for consistency
|
||||
const componentData = {
|
||||
type: component.type,
|
||||
label: component.defaultProps.label,
|
||||
data: component.defaultProps.data
|
||||
};
|
||||
|
||||
emit('add-component', componentData);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.process-components {
|
||||
@apply bg-white h-full;
|
||||
}
|
||||
|
||||
.component-item {
|
||||
@apply h-20;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.component-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.component-item:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
</style>
|
@ -27,7 +27,7 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['nodeClick', 'edgeClick', 'paneClick', 'nodesChange', 'edgesChange', 'nodeSelected']);
|
||||
const emit = defineEmits(['nodeClick', 'edgeClick', 'paneClick', 'nodesChange', 'edgesChange', 'nodeSelected', 'edgeSelected']);
|
||||
|
||||
// Get the flow instance
|
||||
const { flowInstance } = useVueFlow();
|
||||
@ -57,110 +57,10 @@ const {
|
||||
});
|
||||
|
||||
// Default nodes if empty
|
||||
const defaultNodes = [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
label: 'Start Process',
|
||||
position: { x: 100, y: 100 },
|
||||
data: {
|
||||
description: 'Process starts here'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'form1',
|
||||
type: 'form',
|
||||
label: 'Request Form',
|
||||
position: { x: 100, y: 250 },
|
||||
data: {
|
||||
description: 'User fills out request form',
|
||||
formName: 'Request Form'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'gateway',
|
||||
type: 'gateway',
|
||||
label: 'Approval Required?',
|
||||
position: { x: 100, y: 400 },
|
||||
data: {
|
||||
description: 'Check if approval is required',
|
||||
conditions: ['Amount > 1000', 'Special Request']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'task1',
|
||||
type: 'task',
|
||||
label: 'Manager Approval',
|
||||
position: { x: 250, y: 550 },
|
||||
data: {
|
||||
description: 'Manager reviews the request',
|
||||
assignee: 'Department Manager'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'script1',
|
||||
type: 'script',
|
||||
label: 'Process Request',
|
||||
position: { x: -50, y: 550 },
|
||||
data: {
|
||||
description: 'Automatically process the request',
|
||||
language: 'JavaScript'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
type: 'end',
|
||||
label: 'End Process',
|
||||
position: { x: 100, y: 700 },
|
||||
data: {
|
||||
description: 'Process completes here'
|
||||
}
|
||||
}
|
||||
];
|
||||
const defaultNodes = [];
|
||||
|
||||
// Default edges if empty
|
||||
const defaultEdges = [
|
||||
{
|
||||
id: 'start-form1',
|
||||
source: 'start',
|
||||
target: 'form1',
|
||||
animated: true
|
||||
},
|
||||
{
|
||||
id: 'form1-gateway',
|
||||
source: 'form1',
|
||||
target: 'gateway',
|
||||
animated: true
|
||||
},
|
||||
{
|
||||
id: 'gateway-task1',
|
||||
source: 'gateway',
|
||||
target: 'task1',
|
||||
animated: true,
|
||||
label: 'Yes',
|
||||
type: 'smoothstep'
|
||||
},
|
||||
{
|
||||
id: 'gateway-script1',
|
||||
source: 'gateway',
|
||||
target: 'script1',
|
||||
animated: true,
|
||||
label: 'No',
|
||||
type: 'smoothstep'
|
||||
},
|
||||
{
|
||||
id: 'task1-end',
|
||||
source: 'task1',
|
||||
target: 'end',
|
||||
animated: true
|
||||
},
|
||||
{
|
||||
id: 'script1-end',
|
||||
source: 'script1',
|
||||
target: 'end',
|
||||
animated: true
|
||||
}
|
||||
];
|
||||
const defaultEdges = [];
|
||||
|
||||
// Flow configuration
|
||||
const flowOptions = ref({
|
||||
@ -219,7 +119,18 @@ const onNodeClick = ({ node }) => {
|
||||
};
|
||||
|
||||
// Handle edge click
|
||||
const onEdgeClick = (_, edge) => {
|
||||
const onEdgeClick = (event, edge) => {
|
||||
// Create a simplified copy of the edge data
|
||||
const edgeData = {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.label || '',
|
||||
sourceNode: nodes.value.find(node => node.id === edge.source),
|
||||
targetNode: nodes.value.find(node => node.id === edge.target)
|
||||
};
|
||||
|
||||
emit('edgeSelected', edgeData);
|
||||
emit('edgeClick', edge);
|
||||
};
|
||||
|
||||
@ -274,13 +185,46 @@ onEdgesChange((changes) => {
|
||||
const handleConnect = (connection) => {
|
||||
if (!connection.source || !connection.target) return;
|
||||
|
||||
// Try to determine if this is coming from a gateway
|
||||
const sourceNode = nodes.value.find(node => node.id === connection.source);
|
||||
let label = '';
|
||||
|
||||
// If the source is a gateway, we should add a label based on conditions
|
||||
if (sourceNode && sourceNode.type === 'gateway') {
|
||||
// Check if there's a default path label
|
||||
if (sourceNode.data && sourceNode.data.defaultPath) {
|
||||
label = sourceNode.data.defaultPath;
|
||||
}
|
||||
|
||||
// For existing gateway connections, check if we should use a condition's output
|
||||
const existingEdges = edges.value.filter(edge => edge.source === connection.source);
|
||||
if (existingEdges.length === 0 && sourceNode.data.conditions && sourceNode.data.conditions.length > 0) {
|
||||
// If this is the first connection and we have conditions, use the first condition's output
|
||||
const firstCondition = sourceNode.data.conditions[0];
|
||||
if (firstCondition && firstCondition.output) {
|
||||
label = firstCondition.output;
|
||||
}
|
||||
} else if (sourceNode.data.conditions) {
|
||||
// If we already have connections, try to find an unused condition
|
||||
const usedOutputs = existingEdges.map(edge => edge.label);
|
||||
const unusedCondition = sourceNode.data.conditions.find(condition =>
|
||||
condition.output && !usedOutputs.includes(condition.output)
|
||||
);
|
||||
|
||||
if (unusedCondition) {
|
||||
label = unusedCondition.output;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newEdge = {
|
||||
id: `${connection.source}-${connection.target}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#555' }
|
||||
style: { stroke: '#555' },
|
||||
label: label
|
||||
};
|
||||
|
||||
addEdges([newEdge]);
|
||||
@ -288,15 +232,38 @@ const handleConnect = (connection) => {
|
||||
};
|
||||
|
||||
// Handle node removal
|
||||
const onNodeDelete = (nodes) => {
|
||||
removeNodes(nodes);
|
||||
emit('nodesChange', nodes.value);
|
||||
const onNodeDelete = (event) => {
|
||||
// Check if we have a node in the event
|
||||
if (event && event.node) {
|
||||
removeNodes([event.node]);
|
||||
emit('nodesChange', nodes.value);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edge removal
|
||||
const onEdgeDelete = (edges) => {
|
||||
removeEdges(edges);
|
||||
emit('edgesChange', edges.value);
|
||||
const onEdgeDelete = (event) => {
|
||||
// Check if we have an edge in the event
|
||||
if (event && event.edge) {
|
||||
removeEdges([event.edge]);
|
||||
emit('edgesChange', edges.value);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete key press to remove selected elements
|
||||
const onDeleteKeyPress = () => {
|
||||
const { getSelectedNodes, getSelectedEdges } = flowInstance.value;
|
||||
const selectedNodes = getSelectedNodes();
|
||||
const selectedEdges = getSelectedEdges();
|
||||
|
||||
if (selectedNodes.length > 0) {
|
||||
removeNodes(selectedNodes);
|
||||
emit('nodesChange', nodes.value);
|
||||
}
|
||||
|
||||
if (selectedEdges.length > 0) {
|
||||
removeEdges(selectedEdges);
|
||||
emit('edgesChange', edges.value);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drop event
|
||||
@ -359,6 +326,7 @@ const onDragOver = (event) => {
|
||||
@connect="handleConnect"
|
||||
@nodeDoubleClick="onNodeDelete"
|
||||
@edgeDoubleClick="onEdgeDelete"
|
||||
@keyup.delete="onDeleteKeyPress"
|
||||
>
|
||||
<Background pattern-color="#aaa" gap="20" />
|
||||
<Controls />
|
||||
@ -385,10 +353,10 @@ const onDragOver = (event) => {
|
||||
/* Node styles from ProcessFlowNodes.js are imported globally in a plugin */
|
||||
.process-flow-container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 216px); /* Adjust based on header/footer height */
|
||||
min-height: 600px;
|
||||
height: calc(100vh - 190px); /* Adjust based on new header/footer height */
|
||||
min-height: 500px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
@ -12,27 +12,37 @@ const CustomNode = markRaw({
|
||||
v-if="type !== 'start'"
|
||||
type="target"
|
||||
position="top"
|
||||
:style="{ background: '#555' }"
|
||||
:class="'handle-' + type + '-input'"
|
||||
/>
|
||||
|
||||
<div class="custom-node-header">
|
||||
<div class="custom-node-icon">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<div class="custom-node-title">{{ label }}</div>
|
||||
<div class="custom-node-badge" v-if="showBadge">
|
||||
<slot name="badge"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-node-content">
|
||||
<slot></slot>
|
||||
<template v-if="type === 'task' || type === 'form' || type === 'script'">
|
||||
<div class="flex items-center mb-1">
|
||||
<div class="custom-node-icon">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<div class="custom-node-title">{{ label }}</div>
|
||||
<div class="custom-node-badge" v-if="showBadge">
|
||||
<slot name="badge"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="custom-node-icon" v-if="type !== 'gateway'">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<div class="custom-node-title">{{ label }}</div>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
v-if="type !== 'end'"
|
||||
type="source"
|
||||
position="bottom"
|
||||
:style="{ background: '#555' }"
|
||||
:class="'handle-' + type + '-output'"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
@ -64,11 +74,11 @@ export const TaskNode = markRaw({
|
||||
data: this.data,
|
||||
onClick: () => this.$emit('node-click', this.id)
|
||||
}, {
|
||||
icon: () => h('i', { class: 'material-icons' }, 'assignment'),
|
||||
icon: () => h('i', { class: 'material-icons text-blue-500' }, 'assignment'),
|
||||
default: () => h('div', { class: 'node-details' }, [
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Task node'),
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'A general task'),
|
||||
h('div', { class: 'node-assignee' }, [
|
||||
h('span', { class: 'node-assignee-label' }, 'Assigned to: '),
|
||||
h('span', { class: 'node-assignee-label' }, 'Assigned to:'),
|
||||
h('span', { class: 'node-assignee-value' }, this.data?.assignee || 'Unassigned')
|
||||
])
|
||||
])
|
||||
@ -88,10 +98,8 @@ export const StartNode = markRaw({
|
||||
data: this.data,
|
||||
onClick: () => this.$emit('node-click', this.id)
|
||||
}, {
|
||||
icon: () => h('i', { class: 'material-icons' }, 'play_circle_filled'),
|
||||
default: () => h('div', { class: 'node-details' }, [
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Process starts here')
|
||||
])
|
||||
icon: () => h('i', { class: 'material-icons text-green-600' }, 'play_arrow'),
|
||||
default: () => null
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -108,10 +116,8 @@ export const EndNode = markRaw({
|
||||
data: this.data,
|
||||
onClick: () => this.$emit('node-click', this.id)
|
||||
}, {
|
||||
icon: () => h('i', { class: 'material-icons' }, 'stop_circle'),
|
||||
default: () => h('div', { class: 'node-details' }, [
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Process ends here')
|
||||
])
|
||||
icon: () => h('i', { class: 'material-icons text-red-600' }, 'stop'),
|
||||
default: () => null
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -123,18 +129,18 @@ export const GatewayNode = markRaw({
|
||||
return h(CustomNode, {
|
||||
id: this.id,
|
||||
type: 'gateway',
|
||||
label: this.label || 'Decision',
|
||||
label: this.label || 'Gateway',
|
||||
selected: this.selected,
|
||||
data: this.data,
|
||||
onClick: () => this.$emit('node-click', this.id)
|
||||
}, {
|
||||
icon: () => h('i', { class: 'material-icons' }, 'call_split'),
|
||||
default: () => h('div', { class: 'node-details' }, [
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Decision point'),
|
||||
h('div', { class: 'node-conditions' }, [
|
||||
h('span', { class: 'node-conditions-label' }, 'Conditions: '),
|
||||
h('span', { class: 'node-conditions-value' }, this.data?.conditions?.length || '0')
|
||||
])
|
||||
icon: () => h('i', { class: 'material-icons text-orange-500' }, 'call_split'),
|
||||
default: () => h('div', { class: 'gateway-details' }, [
|
||||
h('div', { class: 'node-conditions-value' },
|
||||
this.data?.conditions?.length
|
||||
? `${this.data.conditions.length} condition${this.data.conditions.length > 1 ? 's' : ''}`
|
||||
: ''
|
||||
)
|
||||
])
|
||||
});
|
||||
}
|
||||
@ -144,21 +150,31 @@ export const GatewayNode = markRaw({
|
||||
export const FormNode = markRaw({
|
||||
props: ['id', 'type', 'label', 'selected', 'data'],
|
||||
render() {
|
||||
// Check if we have a form selected
|
||||
const hasForm = this.data?.formId && this.data?.formName;
|
||||
|
||||
// Create badge content based on form selection status
|
||||
const badgeContent = hasForm ?
|
||||
h('span', { class: 'node-badge bg-purple-100 text-purple-600 px-1 text-xs rounded' }, 'Form') :
|
||||
null;
|
||||
|
||||
return h(CustomNode, {
|
||||
id: this.id,
|
||||
type: 'form',
|
||||
label: this.label || 'Form',
|
||||
label: this.label || 'Form Task',
|
||||
selected: this.selected,
|
||||
data: this.data,
|
||||
onClick: () => this.$emit('node-click', this.id)
|
||||
}, {
|
||||
icon: () => h('i', { class: 'material-icons' }, 'description'),
|
||||
badge: () => this.data?.formId ? h('span', { class: 'node-badge' }, 'F') : null,
|
||||
icon: () => h('i', { class: 'material-icons text-purple-500' }, 'description'),
|
||||
badge: () => badgeContent,
|
||||
default: () => h('div', { class: 'node-details' }, [
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Form submission'),
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Form submission task'),
|
||||
h('div', { class: 'node-form-info' }, [
|
||||
h('span', { class: 'node-form-label' }, 'Form: '),
|
||||
h('span', { class: 'node-form-value' }, this.data?.formName || 'None selected')
|
||||
h('span', { class: 'node-form-label' }, 'Form:'),
|
||||
h('span', {
|
||||
class: hasForm ? 'node-form-value text-purple-600 font-medium' : 'node-form-value text-gray-400 italic'
|
||||
}, hasForm ? this.data.formName : 'None selected')
|
||||
])
|
||||
])
|
||||
});
|
||||
@ -177,7 +193,7 @@ export const ScriptNode = markRaw({
|
||||
data: this.data,
|
||||
onClick: () => this.$emit('node-click', this.id)
|
||||
}, {
|
||||
icon: () => h('i', { class: 'material-icons' }, 'code'),
|
||||
icon: () => h('i', { class: 'material-icons script-icon' }, 'code'),
|
||||
default: () => h('div', { class: 'node-details' }, [
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Script execution'),
|
||||
h('div', { class: 'node-script-info' }, [
|
||||
@ -202,16 +218,11 @@ export const nodeTypes = markRaw({
|
||||
// Default CSS for the nodes to be imported where needed
|
||||
export const nodeStyles = `
|
||||
.custom-node {
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
color: #333;
|
||||
background: white;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
width: 200px;
|
||||
font-size: 12px;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.custom-node.selected {
|
||||
@ -219,79 +230,255 @@ export const nodeStyles = `
|
||||
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
|
||||
.custom-node-header {
|
||||
/* Base styles for different node types */
|
||||
.node-task, .node-form, .node-script {
|
||||
width: 180px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.node-gateway {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: white;
|
||||
transform: rotate(45deg);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #FF9800;
|
||||
}
|
||||
|
||||
.node-start, .node-end {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.node-start {
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #4CAF50;
|
||||
}
|
||||
|
||||
.node-end {
|
||||
background: #ffebee;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
|
||||
/* Content positioning */
|
||||
.custom-node-content {
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.node-gateway .custom-node-content {
|
||||
transform: rotate(-45deg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.node-start .custom-node-content,
|
||||
.node-end .custom-node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.custom-node-icon {
|
||||
margin-right: 8px;
|
||||
margin-right: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-start .custom-node-icon,
|
||||
.node-end .custom-node-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-node-icon .material-icons {
|
||||
font-size: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.custom-node-title {
|
||||
.node-start .material-icons,
|
||||
.node-end .material-icons {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-task .custom-node-title,
|
||||
.node-form .custom-node-title,
|
||||
.node-script .custom-node-title {
|
||||
font-weight: 500;
|
||||
flex-grow: 1;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.custom-node-content {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
.node-end .custom-node-title {
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
bottom: -29px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-start .custom-node-title {
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
bottom: 51px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-gateway .custom-node-title {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
bottom: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-details {
|
||||
margin-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.node-description {
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 2px;
|
||||
color: #666;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.node-assignee,
|
||||
.node-form-info,
|
||||
.node-script-info,
|
||||
.node-conditions {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.node-badge {
|
||||
background: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-assignee-label,
|
||||
.node-form-label,
|
||||
.node-script-label {
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Node type specific styles */
|
||||
.node-start .custom-node-icon .material-icons {
|
||||
color: #4CAF50;
|
||||
.node-form-value,
|
||||
.node-script-value,
|
||||
.node-assignee-value {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.node-end .custom-node-icon .material-icons {
|
||||
color: #f44336;
|
||||
.node-conditions-value {
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.node-task .custom-node-icon .material-icons {
|
||||
color: #2196F3;
|
||||
.node-form-id {
|
||||
font-size: 9px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.node-form .custom-node-icon .material-icons {
|
||||
color: #9C27B0;
|
||||
.gateway-details {
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-gateway .custom-node-icon .material-icons {
|
||||
color: #FF9800;
|
||||
.handle-task-input,
|
||||
.handle-form-input,
|
||||
.handle-script-input,
|
||||
.handle-gateway-input {
|
||||
top: -5px !important;
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.node-script .custom-node-icon .material-icons {
|
||||
color: #607D8B;
|
||||
.handle-task-output,
|
||||
.handle-form-output,
|
||||
.handle-script-output,
|
||||
.handle-gateway-output {
|
||||
bottom: -5px !important;
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.handle-start-output {
|
||||
bottom: -5px !important;
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.handle-end-input {
|
||||
top: -5px !important;
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
/* Position handles correctly for gateway node */
|
||||
.handle-gateway-input {
|
||||
transform: translateY(-14px) !important;
|
||||
}
|
||||
|
||||
.handle-gateway-output {
|
||||
transform: translateY(14px) !important;
|
||||
}
|
||||
|
||||
/* Badge style */
|
||||
.node-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
`;
|
@ -251,7 +251,20 @@ definePageMeta({
|
||||
|
||||
const router = useRouter();
|
||||
const formStore = useFormBuilderStore();
|
||||
const toast = useToast();
|
||||
let toast;
|
||||
|
||||
// Try to use the toast composable if available
|
||||
try {
|
||||
toast = useToast();
|
||||
} catch (error) {
|
||||
// Create a simple toast object if composable is not available
|
||||
toast = {
|
||||
success: (msg) => console.log('Success:', msg),
|
||||
error: (msg) => console.error('Error:', msg),
|
||||
info: (msg) => console.info('Info:', msg),
|
||||
warning: (msg) => console.warn('Warning:', msg)
|
||||
};
|
||||
}
|
||||
|
||||
const showPreview = ref(false);
|
||||
const showUnsavedChangesModal = ref(false);
|
||||
@ -271,8 +284,27 @@ const formName = computed({
|
||||
});
|
||||
|
||||
// Initialize the form builder
|
||||
onMounted(() => {
|
||||
formStore.loadSavedForms();
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await formStore.loadSavedForms();
|
||||
|
||||
// Check if there's a form ID in the URL query parameters
|
||||
const route = useRoute();
|
||||
const formId = route.query.id;
|
||||
|
||||
if (formId) {
|
||||
try {
|
||||
await formStore.loadForm(formId);
|
||||
toast.success(`Form '${formStore.formName}' loaded successfully`);
|
||||
} catch (error) {
|
||||
console.error('Error loading form from ID:', error);
|
||||
toast.error(`Failed to load form: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing form builder:', error);
|
||||
toast.error(`Initialization error: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Add the beforeunload event listener
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
@ -391,7 +423,7 @@ const handleDrop = (event) => {
|
||||
formStore.addComponent(componentData);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (!formStore.formName.trim()) {
|
||||
toast.error("Please enter a form name");
|
||||
return;
|
||||
@ -402,8 +434,17 @@ const handleSave = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
formStore.saveForm();
|
||||
toast.success("Form saved successfully");
|
||||
try {
|
||||
const savedForm = await formStore.saveForm();
|
||||
toast.success("Form saved successfully");
|
||||
|
||||
// Update URL to include the form ID without reloading the page
|
||||
const newPath = `/form-builder?id=${savedForm.formUUID}`;
|
||||
window.history.replaceState({}, '', newPath);
|
||||
} catch (error) {
|
||||
console.error("Error saving form:", error);
|
||||
toast.error(`Failed to save form: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
|
@ -160,13 +160,32 @@ definePageMeta({
|
||||
|
||||
const router = useRouter();
|
||||
const formStore = useFormBuilderStore();
|
||||
const toast = useToast();
|
||||
let toast;
|
||||
|
||||
// Try to use the toast composable if available
|
||||
try {
|
||||
toast = useToast();
|
||||
} catch (error) {
|
||||
// Create a simple toast object if composable is not available
|
||||
toast = {
|
||||
success: (msg) => console.log('Success:', msg),
|
||||
error: (msg) => console.error('Error:', msg),
|
||||
info: (msg) => console.info('Info:', msg),
|
||||
warning: (msg) => console.warn('Warning:', msg)
|
||||
};
|
||||
}
|
||||
|
||||
const searchQuery = ref("");
|
||||
const showUnsavedChangesModal = ref(false);
|
||||
|
||||
// Initialize and load forms
|
||||
onMounted(() => {
|
||||
formStore.loadSavedForms();
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await formStore.loadSavedForms();
|
||||
} catch (error) {
|
||||
console.error("Error loading forms:", error);
|
||||
toast.error("Failed to load forms: " + (error.message || "Unknown error"));
|
||||
}
|
||||
});
|
||||
|
||||
// Format date for display
|
||||
@ -213,18 +232,29 @@ const confirmNavigation = () => {
|
||||
router.push("/form-builder");
|
||||
};
|
||||
|
||||
const editForm = (formId) => {
|
||||
formStore.loadForm(formId);
|
||||
router.push("/form-builder");
|
||||
const editForm = async (formId) => {
|
||||
try {
|
||||
await formStore.loadForm(formId);
|
||||
router.push("/form-builder");
|
||||
} catch (error) {
|
||||
console.error("Error loading form:", error);
|
||||
toast.error("Failed to load form: " + (error.message || "Unknown error"));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteForm = (formId) => {
|
||||
const deleteForm = async (formId) => {
|
||||
if (confirm("Are you sure you want to delete this form?")) {
|
||||
const index = formStore.savedForms.findIndex((f) => f.id === formId);
|
||||
if (index !== -1) {
|
||||
formStore.savedForms.splice(index, 1);
|
||||
localStorage.setItem("savedForms", JSON.stringify(formStore.savedForms));
|
||||
try {
|
||||
// Call the API to delete the form
|
||||
await formStore.deleteForm(formId);
|
||||
|
||||
// Refresh the forms list
|
||||
await formStore.loadSavedForms();
|
||||
|
||||
toast.success("Form deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Error deleting form:", error);
|
||||
toast.error("Failed to delete form: " + (error.message || "Unknown error"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -1,8 +1,21 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, shallowRef } from 'vue';
|
||||
import { ref, onMounted, computed, shallowRef, onUnmounted } from 'vue';
|
||||
import { useProcessBuilderStore } from '~/stores/processBuilder';
|
||||
import { useRouter } from 'vue-router';
|
||||
import ProcessFlowCanvas from '~/components/process-flow/ProcessFlowCanvas.vue';
|
||||
import ProcessBuilderComponents from '~/components/process-flow/ProcessBuilderComponents.vue';
|
||||
import FormSelector from '~/components/process-flow/FormSelector.vue';
|
||||
import GatewayConditionManager from '~/components/process-flow/GatewayConditionManager.vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
|
||||
// Define page meta
|
||||
definePageMeta({
|
||||
title: "Process Builder",
|
||||
description: "Create business processes with drag and drop",
|
||||
layout: "empty",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
// Initialize the store and router
|
||||
const processStore = useProcessBuilderStore();
|
||||
@ -23,6 +36,15 @@ const selectedNodeData = ref(null);
|
||||
// Track drag data
|
||||
const draggedComponent = ref(null);
|
||||
|
||||
// Add a variable to track selected edge
|
||||
const selectedEdgeData = ref(null);
|
||||
|
||||
// Add variables for navigation handling
|
||||
const showUnsavedChangesModal = ref(false);
|
||||
const pendingNavigation = ref(null);
|
||||
const navigationTarget = ref(null);
|
||||
const navigationConfirmed = ref(false);
|
||||
|
||||
// Component definitions
|
||||
const components = [
|
||||
{
|
||||
@ -125,37 +147,142 @@ const nodeLanguage = computed({
|
||||
}
|
||||
});
|
||||
|
||||
// Add a computed property for gateway conditions
|
||||
const nodeConditions = computed({
|
||||
get: () => selectedNodeData.value?.data?.conditions || [],
|
||||
set: (value) => {
|
||||
if (selectedNodeData.value) {
|
||||
selectedNodeData.value.data.conditions = value;
|
||||
updateNodeInStore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add a computed property for gateway default path
|
||||
const nodeDefaultPath = computed({
|
||||
get: () => selectedNodeData.value?.data?.defaultPath || 'Default',
|
||||
set: (value) => {
|
||||
if (selectedNodeData.value) {
|
||||
selectedNodeData.value.data.defaultPath = value;
|
||||
updateNodeInStore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle node selection
|
||||
const onNodeSelected = (node) => {
|
||||
selectedNodeData.value = JSON.parse(JSON.stringify(node));
|
||||
selectedNode.value = node;
|
||||
selectedEdgeData.value = null;
|
||||
processStore.selectNode(node.id);
|
||||
};
|
||||
|
||||
// Handle edge selection
|
||||
const onEdgeSelected = (edge) => {
|
||||
selectedEdgeData.value = edge;
|
||||
selectedNode.value = null;
|
||||
selectedNodeData.value = null;
|
||||
processStore.selectEdge(edge.id);
|
||||
};
|
||||
|
||||
// Update edge label
|
||||
const updateEdgeLabel = (value) => {
|
||||
if (selectedEdgeData.value) {
|
||||
processStore.updateEdge(selectedEdgeData.value.id, { label: value });
|
||||
selectedEdgeData.value.label = value;
|
||||
}
|
||||
};
|
||||
|
||||
// Update the pane click handler to clear edge selection too
|
||||
const onPaneClick = () => {
|
||||
selectedNode.value = null;
|
||||
selectedNodeData.value = null;
|
||||
selectedEdgeData.value = null;
|
||||
processStore.clearSelection();
|
||||
};
|
||||
|
||||
// Update node in store
|
||||
const updateNodeInStore = () => {
|
||||
if (selectedNodeData.value) {
|
||||
processStore.updateNode(selectedNodeData.value);
|
||||
// Make sure we're passing the nodeId and updates correctly
|
||||
processStore.updateNode(selectedNodeData.value.id, {
|
||||
label: selectedNodeData.value.label,
|
||||
data: selectedNodeData.value.data
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle pane click (deselection)
|
||||
const onPaneClick = () => {
|
||||
selectedNode.value = null;
|
||||
selectedNodeData.value = null;
|
||||
processStore.clearSelection();
|
||||
// Handle condition update
|
||||
const handleConditionUpdate = (conditions) => {
|
||||
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
|
||||
selectedNodeData.value.data.conditions = conditions;
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle node changes
|
||||
const onNodesChange = (changes, nodes) => {
|
||||
// For now just log changes
|
||||
// console.log('Nodes changed:', changes);
|
||||
// Handle updates from the canvas when nodes change
|
||||
const onNodesChange = (changes, currentNodes) => {
|
||||
if (!changes || !currentNodes) return;
|
||||
|
||||
// Handle node removals
|
||||
const removedNodes = changes
|
||||
.filter(change => change.type === 'remove')
|
||||
.map(change => change.id);
|
||||
|
||||
if (removedNodes.length > 0) {
|
||||
removedNodes.forEach(nodeId => {
|
||||
processStore.deleteNode(nodeId);
|
||||
});
|
||||
|
||||
// Clear selection if the selected node was deleted
|
||||
if (selectedNodeData.value && removedNodes.includes(selectedNodeData.value.id)) {
|
||||
selectedNodeData.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle position changes
|
||||
const positionChanges = {};
|
||||
changes
|
||||
.filter(change => change.type === 'position' && change.position)
|
||||
.forEach(change => {
|
||||
positionChanges[change.id] = change.position;
|
||||
});
|
||||
|
||||
if (Object.keys(positionChanges).length > 0) {
|
||||
processStore.updateNodePositions(positionChanges);
|
||||
}
|
||||
|
||||
// Update node selection if needed
|
||||
if (selectedNodeData.value) {
|
||||
const updatedNode = currentNodes.find(node => node.id === selectedNodeData.value.id);
|
||||
if (updatedNode) {
|
||||
selectedNodeData.value = { ...updatedNode };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edge changes
|
||||
const onEdgesChange = (changes, edges) => {
|
||||
// For now just log changes
|
||||
// console.log('Edges changed:', changes);
|
||||
// Handle updates from the canvas when edges change
|
||||
const onEdgesChange = (changes, currentEdges) => {
|
||||
if (!changes || !currentEdges) return;
|
||||
|
||||
// Handle edge removals
|
||||
const removedEdges = changes
|
||||
.filter(change => change.type === 'remove')
|
||||
.map(change => change.id);
|
||||
|
||||
if (removedEdges.length > 0) {
|
||||
removedEdges.forEach(edgeId => {
|
||||
processStore.deleteEdge(edgeId);
|
||||
});
|
||||
|
||||
// Clear selection if the selected edge was deleted
|
||||
if (selectedEdgeData.value && removedEdges.includes(selectedEdgeData.value.id)) {
|
||||
selectedEdgeData.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync all edges
|
||||
processStore.currentProcess.edges = currentEdges;
|
||||
};
|
||||
|
||||
// Handle creating a new process
|
||||
@ -172,24 +299,91 @@ const createNewProcess = () => {
|
||||
newProcessDescription.value = '';
|
||||
};
|
||||
|
||||
// Go to process management
|
||||
const goToManage = () => {
|
||||
router.push('/process-builder/manage');
|
||||
};
|
||||
|
||||
// Mock demo process for testing if no process exists
|
||||
const createDemoProcess = () => {
|
||||
const process = processStore.createProcess('Demo Process', 'A demonstration process flow');
|
||||
processStore.setCurrentProcess(process.id);
|
||||
};
|
||||
|
||||
// Check if we have any processes, if not create a demo one
|
||||
onMounted(() => {
|
||||
if (!processStore.currentProcess && processStore.processes.length === 0) {
|
||||
createDemoProcess();
|
||||
// Add navigation guard
|
||||
onBeforeRouteLeave((to, from, next) => {
|
||||
// If navigation was already confirmed or there are no unsaved changes, proceed
|
||||
if (navigationConfirmed.value || !processStore.hasUnsavedChanges) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise show the confirmation modal
|
||||
showUnsavedChangesModal.value = true;
|
||||
pendingNavigation.value = () => {
|
||||
navigationConfirmed.value = true;
|
||||
next();
|
||||
};
|
||||
next(false);
|
||||
});
|
||||
|
||||
// Navigation handlers
|
||||
const cancelNavigation = () => {
|
||||
showUnsavedChangesModal.value = false;
|
||||
pendingNavigation.value = null;
|
||||
navigationTarget.value = null;
|
||||
navigationConfirmed.value = false;
|
||||
};
|
||||
|
||||
// Update the confirmNavigation function to handle targets
|
||||
const confirmNavigation = (target) => {
|
||||
// If already confirmed or no unsaved changes, navigate directly
|
||||
if (navigationConfirmed.value || !processStore.hasUnsavedChanges) {
|
||||
router.push(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise show confirmation modal
|
||||
showUnsavedChangesModal.value = true;
|
||||
navigationTarget.value = target;
|
||||
};
|
||||
|
||||
// Add proceeding with navigation
|
||||
const proceedWithNavigation = () => {
|
||||
showUnsavedChangesModal.value = false;
|
||||
|
||||
if (pendingNavigation.value) {
|
||||
pendingNavigation.value();
|
||||
} else if (navigationTarget.value) {
|
||||
navigationConfirmed.value = true; // Mark as confirmed before navigating
|
||||
router.push(navigationTarget.value);
|
||||
}
|
||||
};
|
||||
|
||||
// Update the goToManage function to use the navigation system
|
||||
const goToManage = () => {
|
||||
// If already confirmed or no unsaved changes, navigate directly
|
||||
if (navigationConfirmed.value || !processStore.hasUnsavedChanges) {
|
||||
router.push('/process-builder/manage');
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise show confirmation modal
|
||||
showUnsavedChangesModal.value = true;
|
||||
navigationTarget.value = "/process-builder/manage";
|
||||
};
|
||||
|
||||
// Add events for beforeunload
|
||||
onMounted(() => {
|
||||
// No automatic process creation - let the user create one explicitly
|
||||
|
||||
// Add the beforeunload event listener
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Remove event listeners
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
// Show warning if there are unsaved changes
|
||||
const handleBeforeUnload = (event) => {
|
||||
if (processStore.hasUnsavedChanges) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag start
|
||||
const onDragStart = (event, component) => {
|
||||
try {
|
||||
@ -219,225 +413,447 @@ const onDragEnd = (event) => {
|
||||
draggedComponent.value = null;
|
||||
};
|
||||
|
||||
// Generate unique node ID
|
||||
const generateNodeId = (type) => {
|
||||
return `${type}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
// Add these functions to handle form selection
|
||||
const handleFormSelection = (form) => {
|
||||
if (selectedNodeData.value && form) {
|
||||
selectedNodeData.value.data.formId = form.formID;
|
||||
selectedNodeData.value.data.formName = form.formName;
|
||||
selectedNodeData.value.data.formUuid = form.formUUID;
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
|
||||
const clearFormSelection = () => {
|
||||
if (selectedNodeData.value) {
|
||||
selectedNodeData.value.data.formId = null;
|
||||
selectedNodeData.value.data.formName = '';
|
||||
selectedNodeData.value.data.formUuid = null;
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
|
||||
// Delete current node
|
||||
const deleteNode = () => {
|
||||
if (selectedNodeData.value) {
|
||||
// Store the node ID before clearing the selection
|
||||
const nodeId = selectedNodeData.value.id;
|
||||
|
||||
// Clear selection first to avoid references to deleted node
|
||||
selectedNodeData.value = null;
|
||||
selectedNode.value = null;
|
||||
|
||||
// Delete the node
|
||||
processStore.deleteNode(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete current edge
|
||||
const deleteEdge = () => {
|
||||
if (selectedEdgeData.value) {
|
||||
processStore.deleteEdge(selectedEdgeData.value.id);
|
||||
selectedEdgeData.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Save current process
|
||||
const saveProcess = () => {
|
||||
processStore.saveProcess();
|
||||
};
|
||||
|
||||
// Add a component handler to add components from the component panel
|
||||
const onAddComponent = (component) => {
|
||||
// Create a new node from the component definition
|
||||
const newNode = {
|
||||
id: `${component.type}_${Date.now()}`,
|
||||
type: component.type,
|
||||
position: { x: 100, y: 100 }, // Default position
|
||||
label: component.label,
|
||||
data: component.data
|
||||
};
|
||||
|
||||
// Add the node to the process
|
||||
processStore.addNode(newNode);
|
||||
|
||||
// Select the newly added node
|
||||
onNodeSelected(newNode);
|
||||
};
|
||||
|
||||
// Fix references to functions
|
||||
const onFormSelected = (formData) => {
|
||||
if (selectedNodeData.value && selectedNodeData.value.type === 'form') {
|
||||
selectedNodeData.value.data.formId = formData.id;
|
||||
selectedNodeData.value.data.formName = formData.name;
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
|
||||
const onConditionsUpdated = (conditions) => {
|
||||
if (selectedNodeData.value && selectedNodeData.value.type === 'gateway') {
|
||||
selectedNodeData.value.data.conditions = conditions;
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="process-builder-container h-[calc(100vh-100px)] flex flex-col">
|
||||
<div class="process-builder-header mb-4 flex justify-between items-center p-4 bg-white shadow-sm">
|
||||
<h1 class="text-2xl font-bold">Process Builder</h1>
|
||||
|
||||
<div class="process-builder-actions flex gap-2">
|
||||
<button
|
||||
v-if="!isCreatingProcess"
|
||||
@click="isCreatingProcess = true"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
New Process
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasCurrentProcess"
|
||||
@click="processStore.saveProcess()"
|
||||
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
|
||||
:disabled="!processStore.hasUnsavedChanges"
|
||||
>
|
||||
Save Process
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="goToManage"
|
||||
class="px-4 py-2 border rounded hover:bg-gray-100"
|
||||
>
|
||||
Manage Processes
|
||||
</button>
|
||||
<div class="process-builder flex flex-col h-screen bg-white">
|
||||
<!-- Header Bar -->
|
||||
<header
|
||||
class="bg-gray-800 px-4 py-4 flex items-center justify-between text-white shadow-md"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
src="@/assets/img/logo/logo-word-white.svg"
|
||||
alt="Corrad Logo"
|
||||
class="h-7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New process form -->
|
||||
<div v-if="isCreatingProcess" class="mb-4 p-4 border rounded bg-gray-50 mx-4">
|
||||
<h2 class="text-lg font-bold mb-2">Create New Process</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Process Name</label>
|
||||
<input
|
||||
v-model="newProcessName"
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<FormKit
|
||||
v-if="hasCurrentProcess"
|
||||
v-model="processStore.currentProcess.name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
placeholder="Enter process name"
|
||||
:classes="{
|
||||
outer: 'w-64 mb-0',
|
||||
input: 'w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
v-model="newProcessDescription"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
placeholder="Enter process description"
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<RsButton @click="saveProcess" variant="primary" size="sm" :disabled="!hasCurrentProcess">
|
||||
<Icon name="material-symbols:save" class="mr-1" />
|
||||
Save Process
|
||||
</RsButton>
|
||||
<RsButton @click="confirmNavigation('/process-builder/manage')" variant="tertiary" size="sm">
|
||||
<Icon name="material-symbols:arrow-back" class="mr-1" />
|
||||
Back to Processes
|
||||
</RsButton>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="createNewProcess"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
:disabled="!newProcessName.trim()"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="isCreatingProcess = false"
|
||||
class="px-4 py-2 border rounded hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 flex overflow-hidden" v-if="hasCurrentProcess">
|
||||
<!-- Left Panel - Components -->
|
||||
<div class="w-64 border-r border-gray-200 flex flex-col overflow-hidden">
|
||||
<div class="bg-gray-100 p-3 flex items-center justify-between border-b border-gray-200">
|
||||
<h2 class="text-sm font-medium text-gray-700">Process Components</h2>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ProcessBuilderComponents @add-component="onAddComponent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasCurrentProcess" class="flex flex-1 gap-4 p-4 min-h-0 overflow-hidden">
|
||||
<!-- Left panel - Component palette -->
|
||||
<div class="process-builder-palette w-64 bg-white shadow-md rounded-md overflow-y-auto">
|
||||
<div class="p-4">
|
||||
<h2 class="text-lg font-bold mb-2">Components</h2>
|
||||
<div class="process-builder-component-list space-y-2">
|
||||
<div
|
||||
v-for="component in components"
|
||||
:key="component.type"
|
||||
class="process-component-item p-2 border rounded cursor-grab hover:bg-gray-50 transition-transform"
|
||||
draggable="true"
|
||||
@dragstart="(e) => onDragStart(e, component)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<i :class="['material-icons mr-2', component.iconColor]">{{ component.icon }}</i>
|
||||
<span>{{ component.label }}</span>
|
||||
|
||||
<!-- Center Panel - Process Canvas -->
|
||||
<div class="flex-1 relative">
|
||||
<ProcessFlowCanvas
|
||||
:initial-nodes="processStore.currentProcess.nodes"
|
||||
:initial-edges="processStore.currentProcess.edges"
|
||||
@node-selected="onNodeSelected"
|
||||
@edge-selected="onEdgeSelected"
|
||||
@pane-click="onPaneClick"
|
||||
@nodes-change="onNodesChange"
|
||||
@edges-change="onEdgesChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Properties -->
|
||||
<div class="w-72 border-l border-gray-200 flex flex-col overflow-hidden">
|
||||
<div class="bg-gray-100 p-3 flex items-center justify-between border-b border-gray-200">
|
||||
<h2 class="text-sm font-medium text-gray-700">Properties</h2>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4 bg-white">
|
||||
<!-- No selection state -->
|
||||
<div v-if="!selectedNodeData && !selectedEdgeData" class="text-gray-500 text-center py-8">
|
||||
<Icon name="material-symbols:touch-app" class="w-12 h-12 mx-auto mb-2" />
|
||||
<p>Select a node or connection to edit its properties</p>
|
||||
</div>
|
||||
|
||||
<!-- Node properties -->
|
||||
<div v-else-if="selectedNodeData" class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">{{ selectedNodeData.type.charAt(0).toUpperCase() + selectedNodeData.type.slice(1) }} Node Properties</h3>
|
||||
|
||||
<!-- Common properties for all nodes -->
|
||||
<div class="space-y-3">
|
||||
<FormKit
|
||||
v-model="nodeLabel"
|
||||
type="text"
|
||||
label="Label"
|
||||
placeholder="Node label"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
v-model="nodeDescription"
|
||||
type="textarea"
|
||||
label="Description"
|
||||
placeholder="Enter description"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Task specific properties -->
|
||||
<div v-if="selectedNodeData.type === 'task'" class="space-y-3">
|
||||
<FormKit
|
||||
v-model="nodeAssignee"
|
||||
type="text"
|
||||
label="Assignee"
|
||||
placeholder="Enter assignee"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Form specific properties -->
|
||||
<div v-if="selectedNodeData.type === 'form'" class="space-y-3">
|
||||
<FormSelector
|
||||
v-model="selectedNodeData.data.formId"
|
||||
@select="onFormSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Script specific properties -->
|
||||
<div v-if="selectedNodeData.type === 'script'" class="space-y-3">
|
||||
<FormKit
|
||||
v-model="nodeLanguage"
|
||||
type="select"
|
||||
label="Language"
|
||||
:options="['JavaScript', 'Python', 'PHP']"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
v-if="selectedNodeData.data.script !== undefined"
|
||||
v-model="selectedNodeData.data.script"
|
||||
type="textarea"
|
||||
label="Script"
|
||||
placeholder="Enter script code"
|
||||
:rows="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Gateway specific properties -->
|
||||
<div v-if="selectedNodeData.type === 'gateway'" class="space-y-3">
|
||||
<FormKit
|
||||
v-model="nodeDefaultPath"
|
||||
type="text"
|
||||
label="Default Path Label"
|
||||
placeholder="Default"
|
||||
/>
|
||||
|
||||
<GatewayConditionManager
|
||||
v-model="nodeConditions"
|
||||
:gateway-id="selectedNodeData.id"
|
||||
@update:modelValue="handleConditionUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<div class="pt-4 border-t border-gray-200 mt-4">
|
||||
<RsButton @click="deleteNode" variant="danger" size="sm" class="w-full">
|
||||
<Icon name="material-symbols:delete" class="mr-1" />
|
||||
Delete Node
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edge properties -->
|
||||
<div v-else-if="selectedEdgeData" class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">Connection Properties</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<FormKit
|
||||
:model-value="selectedEdgeData.label"
|
||||
@input="updateEdgeLabel"
|
||||
type="text"
|
||||
label="Label"
|
||||
placeholder="Connection label"
|
||||
/>
|
||||
|
||||
<div class="pt-4">
|
||||
<div class="mb-2 text-sm text-gray-500">Connection Details</div>
|
||||
<div class="p-3 bg-gray-50 rounded-md border border-gray-200 text-sm">
|
||||
<div class="mb-1">
|
||||
<span class="font-medium">From:</span>
|
||||
{{ selectedEdgeData.sourceNode?.label || selectedEdgeData.source }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">To:</span>
|
||||
{{ selectedEdgeData.targetNode?.label || selectedEdgeData.target }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<div class="pt-4 border-t border-gray-200 mt-4">
|
||||
<RsButton @click="deleteEdge" variant="danger" size="sm" class="w-full">
|
||||
<Icon name="material-symbols:delete" class="mr-1" />
|
||||
Delete Connection
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle panel - Canvas -->
|
||||
<div class="process-builder-canvas flex-1 min-w-0">
|
||||
<ProcessFlowCanvas
|
||||
@node-selected="onNodeSelected"
|
||||
@pane-click="onPaneClick"
|
||||
@nodes-change="onNodesChange"
|
||||
@edges-change="onEdgesChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right panel - Properties -->
|
||||
<div class="process-builder-properties w-72 bg-white shadow-md rounded-md overflow-y-auto">
|
||||
<div class="p-4">
|
||||
<h2 class="text-lg font-bold mb-2">Properties</h2>
|
||||
|
||||
<div v-if="selectedNodeData" class="process-properties-content space-y-4">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Node ID</label>
|
||||
<div class="px-3 py-2 border rounded bg-gray-50 text-sm">{{ selectedNodeData.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state - No process selected -->
|
||||
<div v-else class="flex-1 flex items-center justify-center bg-gray-50">
|
||||
<div class="text-center p-8 max-w-md">
|
||||
<Icon name="material-symbols:flowchart" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-2">Create a New Process</h2>
|
||||
<p class="text-gray-600 mb-6">Get started by creating a new process or navigate back to manage your existing processes.</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="mb-4">
|
||||
<FormKit
|
||||
v-model="newProcessName"
|
||||
type="text"
|
||||
label="Process Name"
|
||||
placeholder="Enter a name for your new process"
|
||||
validation="required"
|
||||
/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Node Type</label>
|
||||
<div class="px-3 py-2 border rounded bg-gray-50 text-sm">{{ selectedNodeData.type }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="nodeLabel"
|
||||
class="w-full px-3 py-2 border rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
v-model="nodeDescription"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border rounded text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Conditional fields based on node type -->
|
||||
<div v-if="selectedNodeData.type === 'task'" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="nodeAssignee"
|
||||
class="w-full px-3 py-2 border rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNodeData.type === 'form'" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Form Name</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="nodeFormName"
|
||||
class="w-full px-3 py-2 border rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNodeData.type === 'script'" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Language</label>
|
||||
<select
|
||||
v-model="nodeLanguage"
|
||||
class="w-full px-3 py-2 border rounded text-sm"
|
||||
>
|
||||
<option value="JavaScript">JavaScript</option>
|
||||
<option value="PHP">PHP</option>
|
||||
<option value="Python">Python</option>
|
||||
</select>
|
||||
</div>
|
||||
<FormKit
|
||||
v-model="newProcessDescription"
|
||||
type="textarea"
|
||||
label="Description (Optional)"
|
||||
placeholder="Enter a description"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-gray-500 text-sm italic">
|
||||
Select a node to view its properties
|
||||
<div class="flex justify-center gap-4">
|
||||
<RsButton @click="createNewProcess" variant="primary" :disabled="!newProcessName.trim()">
|
||||
<Icon name="material-symbols:add" class="mr-1" />
|
||||
Create Process
|
||||
</RsButton>
|
||||
|
||||
<RsButton @click="confirmNavigation('/process-builder/manage')" variant="tertiary">
|
||||
<Icon name="material-symbols:arrow-back" class="mr-1" />
|
||||
Back to Processes
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No process message -->
|
||||
<div v-if="!hasCurrentProcess && !isCreatingProcess" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500 mb-4">
|
||||
<i class="material-icons text-5xl">account_tree</i>
|
||||
<p class="mt-2 text-lg">No process is currently open</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<button
|
||||
@click="isCreatingProcess = true"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Create New Process
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="goToManage"
|
||||
class="px-4 py-2 border rounded hover:bg-gray-100"
|
||||
>
|
||||
Go to Process Management
|
||||
</button>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<RsModal v-model="showUnsavedChangesModal" title="Unsaved Changes" size="md" position="center">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center mb-4">
|
||||
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3" />
|
||||
<div>
|
||||
<p class="text-gray-600">You have unsaved changes that will be lost if you leave the page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<RsButton @click="cancelNavigation" variant="tertiary">
|
||||
Stay on this Page
|
||||
</RsButton>
|
||||
<RsButton @click="proceedWithNavigation" variant="danger">
|
||||
Discard Changes
|
||||
</RsButton>
|
||||
</div>
|
||||
</template>
|
||||
</RsModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.process-builder {
|
||||
--flow-node-selected-color: theme('colors.blue.500');
|
||||
--flow-background-color: theme('colors.gray.100');
|
||||
--flow-connection-path-color: theme('colors.gray.400');
|
||||
--flow-connection-path-hover-color: theme('colors.blue.400');
|
||||
}
|
||||
|
||||
:deep(.custom-node) {
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
:deep(.custom-node.selected) {
|
||||
border-color: var(--flow-node-selected-color);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
:deep(.custom-node-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.custom-node-title) {
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.node-gateway) {
|
||||
transform: rotate(45deg);
|
||||
background: white;
|
||||
border: 2px solid #FF9800;
|
||||
}
|
||||
|
||||
:deep(.node-gateway .custom-node-content) {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
:deep(.node-start), :deep(.node-end) {
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
:deep(.node-task), :deep(.node-form), :deep(.node-script) {
|
||||
min-width: 160px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
:deep(.node-details) {
|
||||
margin-top: 8px;
|
||||
font-size: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.node-description) {
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.process-builder-container {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.process-name-input :deep(.formkit-inner) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.process-name-input :deep(.formkit-inner:focus-within) {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.process-name-input :deep(input::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.process-name-input :deep(.formkit-message) {
|
||||
color: rgba(255, 200, 200, 0.9);
|
||||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.process-builder-palette,
|
||||
.process-builder-properties {
|
||||
height: 100%;
|
||||
|
@ -3,6 +3,15 @@ import { ref, computed, onMounted } from 'vue';
|
||||
import { useProcessBuilderStore } from '~/stores/processBuilder';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
// Define page meta
|
||||
definePageMeta({
|
||||
title: "Process Management",
|
||||
description: "Manage your business processes",
|
||||
layout: "empty",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
// Initialize the store and router
|
||||
const processStore = useProcessBuilderStore();
|
||||
const router = useRouter();
|
||||
@ -100,7 +109,7 @@ const createNewProcess = () => {
|
||||
|
||||
// Go back to builder
|
||||
const goToBuilder = () => {
|
||||
router.push('/process-builder');
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Check if we have processes, if not create a demo one
|
||||
@ -119,126 +128,137 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="process-management p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
@click="goToBuilder"
|
||||
class="mr-3 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<i class="material-icons">arrow_back</i>
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold">Process Management</h1>
|
||||
<div class="flex flex-col h-screen bg-gray-50">
|
||||
<!-- Header Bar -->
|
||||
<header
|
||||
class="bg-gray-800 px-4 py-4 flex items-center justify-between text-white shadow-md"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon
|
||||
@click="goToBuilder"
|
||||
name="ph:arrow-circle-left-duotone"
|
||||
class="cursor-pointer w-6 h-6"
|
||||
/>
|
||||
<img
|
||||
src="@/assets/img/logo/logo-word-white.svg"
|
||||
alt="Corrad Logo"
|
||||
class="h-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-xl font-semibold text-white">Process Management</h1>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="createNewProcess"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Create New Process
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search processes..."
|
||||
class="w-full px-4 py-2 pl-10 border rounded"
|
||||
/>
|
||||
<span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
||||
<i class="material-icons text-lg">search</i>
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<RsButton @click="createNewProcess" variant="primary" size="sm">
|
||||
<Icon name="material-symbols:add" class="mr-1" />
|
||||
Create New Process
|
||||
</RsButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<div class="mb-6">
|
||||
<div class="relative max-w-md">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search processes..."
|
||||
class="w-full px-4 py-2 pl-10 border rounded bg-white"
|
||||
/>
|
||||
<span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
||||
<Icon name="material-symbols:search" class="text-lg" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<table class="w-full table-auto">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-if="filteredProcesses.length === 0">
|
||||
<td colspan="5" class="px-6 py-4 text-center text-gray-500 italic">
|
||||
No processes found
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="process in filteredProcesses" :key="process.id" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ process.name }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-500 truncate max-w-xs">{{ process.description }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500">{{ formatDate(process.createdAt) }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500">{{ formatDate(process.updatedAt) }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex space-x-3 justify-end">
|
||||
<button
|
||||
@click="editProcess(process.id)"
|
||||
class="text-blue-600 hover:text-blue-900"
|
||||
title="Edit Process"
|
||||
>
|
||||
<Icon name="material-symbols:edit" class="text-lg" />
|
||||
</button>
|
||||
<button
|
||||
@click="duplicateProcess(process)"
|
||||
class="text-green-600 hover:text-green-900"
|
||||
title="Duplicate Process"
|
||||
>
|
||||
<Icon name="material-symbols:content-copy" class="text-lg" />
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete(process.id)"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
title="Delete Process"
|
||||
>
|
||||
<Icon name="material-symbols:delete" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<table class="w-full table-auto">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-if="filteredProcesses.length === 0">
|
||||
<td colspan="5" class="px-6 py-4 text-center text-gray-500 italic">
|
||||
No processes found
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="process in filteredProcesses" :key="process.id" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ process.name }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-500 truncate max-w-xs">{{ process.description }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500">{{ formatDate(process.createdAt) }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500">{{ formatDate(process.updatedAt) }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex space-x-2 justify-end">
|
||||
<button
|
||||
@click="editProcess(process.id)"
|
||||
class="text-blue-600 hover:text-blue-900"
|
||||
title="Edit Process"
|
||||
>
|
||||
<i class="material-icons">edit</i>
|
||||
</button>
|
||||
<button
|
||||
@click="duplicateProcess(process)"
|
||||
class="text-green-600 hover:text-green-900"
|
||||
title="Duplicate Process"
|
||||
>
|
||||
<i class="material-icons">content_copy</i>
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete(process.id)"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
title="Delete Process"
|
||||
>
|
||||
<i class="material-icons">delete</i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 w-96">
|
||||
<h3 class="text-lg font-bold mb-4">Confirm Delete</h3>
|
||||
<p class="mb-6">Are you sure you want to delete this process? This action cannot be undone.</p>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button
|
||||
@click="cancelDelete"
|
||||
class="px-4 py-2 border rounded hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="deleteProcess"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<RsModal v-model="showDeleteConfirm" title="Confirm Delete" size="md" position="center">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center mb-4">
|
||||
<Icon name="material-symbols:warning-outline" class="text-yellow-500 w-8 h-8 mr-3" />
|
||||
<div>
|
||||
<p class="text-gray-600">Are you sure you want to delete this process? This action cannot be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<RsButton @click="cancelDelete" variant="tertiary">
|
||||
Cancel
|
||||
</RsButton>
|
||||
<RsButton @click="deleteProcess" variant="danger">
|
||||
Delete
|
||||
</RsButton>
|
||||
</div>
|
||||
</template>
|
||||
</RsModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.process-management {
|
||||
min-height: calc(100vh - 80px);
|
||||
}
|
||||
/* No need for any special styles, using the flex layout */
|
||||
</style>
|
@ -141,6 +141,24 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/userrole"
|
||||
}
|
||||
},
|
||||
"processes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/process"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/form"
|
||||
}
|
||||
},
|
||||
"assignedTasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/task"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -265,6 +283,200 @@
|
||||
"$ref": "#/definitions/user"
|
||||
}
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"formID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"formUUID": {
|
||||
"type": "string"
|
||||
},
|
||||
"formName": {
|
||||
"type": "string"
|
||||
},
|
||||
"formDescription": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"formComponents": {
|
||||
"type": [
|
||||
"number",
|
||||
"string",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"formStatus": {
|
||||
"type": "string",
|
||||
"default": "active"
|
||||
},
|
||||
"formCreatedDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"formModifiedDate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"creator": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"formTasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/task"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"process": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"processID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"processUUID": {
|
||||
"type": "string"
|
||||
},
|
||||
"processName": {
|
||||
"type": "string"
|
||||
},
|
||||
"processDescription": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processDefinition": {
|
||||
"type": [
|
||||
"number",
|
||||
"string",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"processVersion": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
},
|
||||
"processStatus": {
|
||||
"type": "string",
|
||||
"default": "draft"
|
||||
},
|
||||
"processCreatedDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"processModifiedDate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"creator": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/task"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"task": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"taskUUID": {
|
||||
"type": "string"
|
||||
},
|
||||
"taskNodeId": {
|
||||
"type": "string"
|
||||
},
|
||||
"taskName": {
|
||||
"type": "string"
|
||||
},
|
||||
"taskType": {
|
||||
"type": "string"
|
||||
},
|
||||
"taskData": {
|
||||
"type": [
|
||||
"number",
|
||||
"string",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"taskStatus": {
|
||||
"type": "string",
|
||||
"default": "pending"
|
||||
},
|
||||
"taskCreatedDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"taskModifiedDate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"process": {
|
||||
"$ref": "#/definitions/process"
|
||||
},
|
||||
"form": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/form"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"assignee": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@ -283,6 +495,15 @@
|
||||
},
|
||||
"userrole": {
|
||||
"$ref": "#/definitions/userrole"
|
||||
},
|
||||
"form": {
|
||||
"$ref": "#/definitions/form"
|
||||
},
|
||||
"process": {
|
||||
"$ref": "#/definitions/process"
|
||||
},
|
||||
"task": {
|
||||
"$ref": "#/definitions/task"
|
||||
}
|
||||
}
|
||||
}
|
@ -41,6 +41,9 @@ model user {
|
||||
userModifiedDate DateTime? @db.DateTime(0)
|
||||
audit audit[]
|
||||
userrole userrole[]
|
||||
processes process[] @relation("ProcessCreator")
|
||||
forms form[] @relation("FormCreator")
|
||||
assignedTasks task[] @relation("TaskAssignee")
|
||||
}
|
||||
|
||||
model role {
|
||||
@ -76,3 +79,66 @@ model userrole {
|
||||
@@index([userRoleRoleID], map: "FK_userrole_role")
|
||||
@@index([userRoleUserID], map: "FK_userrole_user")
|
||||
}
|
||||
|
||||
// New models for Form Builder
|
||||
model form {
|
||||
formID Int @id @default(autoincrement())
|
||||
formUUID String @unique @db.VarChar(36)
|
||||
formName String @db.VarChar(255)
|
||||
formDescription String? @db.Text
|
||||
formComponents Json @db.Json
|
||||
formStatus String @default("active") @db.VarChar(50)
|
||||
formCreatedBy Int?
|
||||
formCreatedDate DateTime @default(now()) @db.DateTime(0)
|
||||
formModifiedDate DateTime? @updatedAt @db.DateTime(0)
|
||||
|
||||
// Relations
|
||||
creator user? @relation("FormCreator", fields: [formCreatedBy], references: [userID])
|
||||
formTasks task[] @relation("FormTask")
|
||||
|
||||
@@index([formCreatedBy], map: "FK_form_creator")
|
||||
}
|
||||
|
||||
// New models for Process Builder
|
||||
model process {
|
||||
processID Int @id @default(autoincrement())
|
||||
processUUID String @unique @db.VarChar(36)
|
||||
processName String @db.VarChar(255)
|
||||
processDescription String? @db.Text
|
||||
processDefinition Json @db.Json
|
||||
processVersion Int @default(1)
|
||||
processStatus String @default("draft") @db.VarChar(50)
|
||||
processCreatedBy Int?
|
||||
processCreatedDate DateTime @default(now()) @db.DateTime(0)
|
||||
processModifiedDate DateTime? @updatedAt @db.DateTime(0)
|
||||
|
||||
// Relations
|
||||
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
|
||||
tasks task[]
|
||||
|
||||
@@index([processCreatedBy], map: "FK_process_creator")
|
||||
}
|
||||
|
||||
model task {
|
||||
taskID Int @id @default(autoincrement())
|
||||
taskUUID String @unique @db.VarChar(36)
|
||||
taskNodeId String @db.VarChar(255)
|
||||
taskName String @db.VarChar(255)
|
||||
taskType String @db.VarChar(50)
|
||||
taskData Json? @db.Json
|
||||
taskProcessId Int
|
||||
taskFormId Int?
|
||||
taskAssigneeId Int?
|
||||
taskStatus String @default("pending") @db.VarChar(50)
|
||||
taskCreatedDate DateTime @default(now()) @db.DateTime(0)
|
||||
taskModifiedDate DateTime? @updatedAt @db.DateTime(0)
|
||||
|
||||
// Relations
|
||||
process process @relation(fields: [taskProcessId], references: [processID])
|
||||
form form? @relation("FormTask", fields: [taskFormId], references: [formID])
|
||||
assignee user? @relation("TaskAssignee", fields: [taskAssigneeId], references: [userID])
|
||||
|
||||
@@index([taskProcessId], map: "FK_task_process")
|
||||
@@index([taskFormId], map: "FK_task_form")
|
||||
@@index([taskAssigneeId], map: "FK_task_assignee")
|
||||
}
|
||||
|
57
server/api/forms/[id].delete.js
Normal file
57
server/api/forms/[id].delete.js
Normal file
@ -0,0 +1,57 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Get the form ID from the route params
|
||||
const id = event.context.params.id;
|
||||
|
||||
try {
|
||||
// Try to delete by UUID first
|
||||
let form;
|
||||
try {
|
||||
form = await prisma.form.delete({
|
||||
where: { formUUID: id }
|
||||
});
|
||||
} catch (e) {
|
||||
// If UUID not found, try numeric ID
|
||||
if (!isNaN(parseInt(id))) {
|
||||
form = await prisma.form.delete({
|
||||
where: { formID: parseInt(id) }
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Form deleted successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error deleting form ${id}:`, error);
|
||||
|
||||
// Handle specific errors
|
||||
if (error.code === 'P2025') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Handle cases where the form has associated tasks
|
||||
if (error.code === 'P2003') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot delete form because it is associated with one or more tasks'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to delete form',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
64
server/api/forms/[id].get.js
Normal file
64
server/api/forms/[id].get.js
Normal file
@ -0,0 +1,64 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Get the form ID from the route params
|
||||
const id = event.context.params.id;
|
||||
|
||||
try {
|
||||
// Try to find by UUID first
|
||||
let form = await prisma.form.findUnique({
|
||||
where: {
|
||||
formUUID: id
|
||||
},
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If not found, try to find by numeric ID
|
||||
if (!form && !isNaN(parseInt(id))) {
|
||||
form = await prisma.form.findUnique({
|
||||
where: {
|
||||
formID: parseInt(id)
|
||||
},
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If form not found, return 404
|
||||
if (!form) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form not found'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
form
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching form ${id}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch form',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
78
server/api/forms/[id].put.js
Normal file
78
server/api/forms/[id].put.js
Normal file
@ -0,0 +1,78 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Get the form ID from the route params
|
||||
const id = event.context.params.id;
|
||||
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await readBody(event);
|
||||
|
||||
// Validate required fields
|
||||
if (!body.formName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form name is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
formName: body.formName,
|
||||
formComponents: body.components || [],
|
||||
formModifiedDate: new Date()
|
||||
};
|
||||
|
||||
// Add optional fields if provided
|
||||
if (body.formDescription !== undefined) {
|
||||
updateData.formDescription = body.formDescription;
|
||||
}
|
||||
|
||||
if (body.status !== undefined) {
|
||||
updateData.formStatus = body.status;
|
||||
}
|
||||
|
||||
// Try to update by UUID first
|
||||
let form;
|
||||
try {
|
||||
form = await prisma.form.update({
|
||||
where: { formUUID: id },
|
||||
data: updateData
|
||||
});
|
||||
} catch (e) {
|
||||
// If UUID not found, try numeric ID
|
||||
if (!isNaN(parseInt(id))) {
|
||||
form = await prisma.form.update({
|
||||
where: { formID: parseInt(id) },
|
||||
data: updateData
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
form
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error updating form ${id}:`, error);
|
||||
|
||||
// Handle specific errors
|
||||
if (error.code === 'P2025') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form not found'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update form',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
45
server/api/forms/create.post.js
Normal file
45
server/api/forms/create.post.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await readBody(event);
|
||||
|
||||
// Validate required fields
|
||||
if (!body.formName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form name is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Create a new form in the database
|
||||
const form = await prisma.form.create({
|
||||
data: {
|
||||
formUUID: uuidv4(),
|
||||
formName: body.formName,
|
||||
formDescription: body.formDescription || null,
|
||||
formComponents: body.components || [],
|
||||
formStatus: body.status || 'active',
|
||||
formCreatedBy: body.createdBy || null // In a real app, this would come from the authenticated user
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
form
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating form:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to create form',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
47
server/api/forms/index.get.js
Normal file
47
server/api/forms/index.get.js
Normal file
@ -0,0 +1,47 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Get all active forms
|
||||
const forms = await prisma.form.findMany({
|
||||
where: {
|
||||
formStatus: 'active'
|
||||
},
|
||||
orderBy: {
|
||||
formCreatedDate: 'desc'
|
||||
},
|
||||
select: {
|
||||
formID: true,
|
||||
formUUID: true,
|
||||
formName: true,
|
||||
formDescription: true,
|
||||
formStatus: true,
|
||||
formCreatedDate: true,
|
||||
formModifiedDate: true,
|
||||
// Don't include the full components data to keep response size small
|
||||
creator: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
forms
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching forms:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch forms',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
85
server/api/processes/[id].put.js
Normal file
85
server/api/processes/[id].put.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Get the process ID from the route params
|
||||
const id = event.context.params.id;
|
||||
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await readBody(event);
|
||||
|
||||
// Validate required fields
|
||||
if (!body.processName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Process name is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
processName: body.processName,
|
||||
processModifiedDate: new Date()
|
||||
};
|
||||
|
||||
// Add optional fields if provided
|
||||
if (body.processDescription !== undefined) {
|
||||
updateData.processDescription = body.processDescription;
|
||||
}
|
||||
|
||||
if (body.definition !== undefined) {
|
||||
updateData.processDefinition = body.definition;
|
||||
}
|
||||
|
||||
if (body.processStatus !== undefined) {
|
||||
updateData.processStatus = body.processStatus;
|
||||
}
|
||||
|
||||
if (body.processVersion !== undefined) {
|
||||
updateData.processVersion = body.processVersion;
|
||||
}
|
||||
|
||||
// Try to update by UUID first
|
||||
let process;
|
||||
try {
|
||||
process = await prisma.process.update({
|
||||
where: { processUUID: id },
|
||||
data: updateData
|
||||
});
|
||||
} catch (e) {
|
||||
// If UUID not found, try numeric ID
|
||||
if (!isNaN(parseInt(id))) {
|
||||
process = await prisma.process.update({
|
||||
where: { processID: parseInt(id) },
|
||||
data: updateData
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
process
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error updating process ${id}:`, error);
|
||||
|
||||
// Handle specific errors
|
||||
if (error.code === 'P2025') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Process not found'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update process',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
71
server/api/processes/[id]/tasks/create.post.js
Normal file
71
server/api/processes/[id]/tasks/create.post.js
Normal file
@ -0,0 +1,71 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Get the process ID from the route params
|
||||
const processId = event.context.params.id;
|
||||
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await readBody(event);
|
||||
|
||||
// Validate required fields
|
||||
if (!body.taskName || !body.taskType || !body.taskNodeId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task name, type, and node ID are required'
|
||||
};
|
||||
}
|
||||
|
||||
// Find the process
|
||||
let process;
|
||||
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(processId)) {
|
||||
// If it looks like a UUID
|
||||
process = await prisma.process.findUnique({
|
||||
where: { processUUID: processId }
|
||||
});
|
||||
} else if (!isNaN(parseInt(processId))) {
|
||||
// If it's a numeric ID
|
||||
process = await prisma.process.findUnique({
|
||||
where: { processID: parseInt(processId) }
|
||||
});
|
||||
}
|
||||
|
||||
if (!process) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Process not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Create a new task
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
taskUUID: uuidv4(),
|
||||
taskNodeId: body.taskNodeId,
|
||||
taskName: body.taskName,
|
||||
taskType: body.taskType,
|
||||
taskData: body.taskData || {},
|
||||
taskProcessId: process.processID,
|
||||
taskAssigneeId: body.assigneeId || null
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error creating task for process ${processId}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to create task',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
46
server/api/processes/create.post.js
Normal file
46
server/api/processes/create.post.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await readBody(event);
|
||||
|
||||
// Validate required fields
|
||||
if (!body.processName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Process name is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Create a new process in the database
|
||||
const process = await prisma.process.create({
|
||||
data: {
|
||||
processUUID: uuidv4(),
|
||||
processName: body.processName,
|
||||
processDescription: body.processDescription || null,
|
||||
processDefinition: body.definition || { nodes: [], edges: [] },
|
||||
processVersion: 1,
|
||||
processStatus: body.status || 'draft',
|
||||
processCreatedBy: body.createdBy || null // In a real app, this would come from the authenticated user
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
process
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating process:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to create process',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
63
server/api/tasks/[id]/form.delete.js
Normal file
63
server/api/tasks/[id]/form.delete.js
Normal file
@ -0,0 +1,63 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Get the task ID from the route params
|
||||
const taskId = event.context.params.id;
|
||||
|
||||
try {
|
||||
// Find the task
|
||||
let task;
|
||||
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskId)) {
|
||||
// If it looks like a UUID
|
||||
task = await prisma.task.findUnique({
|
||||
where: { taskUUID: taskId }
|
||||
});
|
||||
} else if (!isNaN(parseInt(taskId))) {
|
||||
// If it's a numeric ID
|
||||
task = await prisma.task.findUnique({
|
||||
where: { taskID: parseInt(taskId) }
|
||||
});
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare task data without form information
|
||||
let taskData = task.taskData || {};
|
||||
delete taskData.formName;
|
||||
delete taskData.formId;
|
||||
delete taskData.formUuid;
|
||||
|
||||
// Unlink the form from the task
|
||||
const updatedTask = await prisma.task.update({
|
||||
where: {
|
||||
taskID: task.taskID
|
||||
},
|
||||
data: {
|
||||
taskFormId: null,
|
||||
taskData: taskData
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task: updatedTask
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error removing form from task ${taskId}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to remove form from task',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
99
server/api/tasks/[id]/form.post.js
Normal file
99
server/api/tasks/[id]/form.post.js
Normal file
@ -0,0 +1,99 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Get the task ID from the route params
|
||||
const taskId = event.context.params.id;
|
||||
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await readBody(event);
|
||||
|
||||
// Validate required fields
|
||||
if (!body.formId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form ID is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Find the task
|
||||
let task;
|
||||
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskId)) {
|
||||
// If it looks like a UUID
|
||||
task = await prisma.task.findUnique({
|
||||
where: { taskUUID: taskId }
|
||||
});
|
||||
} else if (!isNaN(parseInt(taskId))) {
|
||||
// If it's a numeric ID
|
||||
task = await prisma.task.findUnique({
|
||||
where: { taskID: parseInt(taskId) }
|
||||
});
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Find the form
|
||||
let form;
|
||||
const formId = body.formId;
|
||||
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(formId)) {
|
||||
// If it looks like a UUID
|
||||
form = await prisma.form.findUnique({
|
||||
where: { formUUID: formId }
|
||||
});
|
||||
} else if (!isNaN(parseInt(formId))) {
|
||||
// If it's a numeric ID
|
||||
form = await prisma.form.findUnique({
|
||||
where: { formID: parseInt(formId) }
|
||||
});
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Link the form to the task
|
||||
const updatedTask = await prisma.task.update({
|
||||
where: {
|
||||
taskID: task.taskID
|
||||
},
|
||||
data: {
|
||||
taskFormId: form.formID,
|
||||
taskData: {
|
||||
...task.taskData,
|
||||
formName: form.formName,
|
||||
formId: form.formID,
|
||||
formUuid: form.formUUID
|
||||
}
|
||||
},
|
||||
include: {
|
||||
form: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task: updatedTask
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error connecting form to task ${taskId}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to connect form to task',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
@ -12,7 +12,8 @@ export const useFormBuilderStore = defineStore('formBuilder', {
|
||||
hasUnsavedChanges: false,
|
||||
actionHistory: [],
|
||||
currentHistoryIndex: -1,
|
||||
maxHistoryLength: 30 // Maximum number of history entries to keep
|
||||
maxHistoryLength: 30, // Maximum number of history entries to keep
|
||||
currentFormId: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@ -414,32 +415,184 @@ export const useFormBuilderStore = defineStore('formBuilder', {
|
||||
this.hasUnsavedChanges = false;
|
||||
},
|
||||
|
||||
saveForm() {
|
||||
const formData = this.formConfig;
|
||||
|
||||
// Add to saved forms array
|
||||
const existingIndex = this.savedForms.findIndex(f => f.id === formData.id);
|
||||
if (existingIndex !== -1) {
|
||||
this.savedForms[existingIndex] = formData;
|
||||
} else {
|
||||
this.savedForms.push(formData);
|
||||
// Get forms from the backend
|
||||
async getForms() {
|
||||
try {
|
||||
// Use the API endpoint to fetch forms
|
||||
const response = await fetch('/api/forms');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && Array.isArray(result.forms)) {
|
||||
return result.forms;
|
||||
} else {
|
||||
console.error('Error in API response:', result.error || 'Unknown error');
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching forms:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('savedForms', JSON.stringify(this.savedForms));
|
||||
|
||||
this.hasUnsavedChanges = false;
|
||||
|
||||
// Record in history
|
||||
this.recordHistory('save_form', {
|
||||
formName: this.formName,
|
||||
formDescription: this.formDescription,
|
||||
componentCount: this.formComponents.length
|
||||
});
|
||||
|
||||
return formData;
|
||||
},
|
||||
|
||||
// Load saved forms from the API
|
||||
async loadSavedForms() {
|
||||
try {
|
||||
// Fetch forms from the API
|
||||
const forms = await this.getForms();
|
||||
|
||||
// Transform to the format expected by the UI
|
||||
this.savedForms = forms.map(form => ({
|
||||
id: form.formUUID,
|
||||
name: form.formName,
|
||||
description: form.formDescription || '',
|
||||
components: form.formComponents || [],
|
||||
createdAt: form.formCreatedDate,
|
||||
updatedAt: form.formModifiedDate
|
||||
}));
|
||||
|
||||
return this.savedForms;
|
||||
} catch (error) {
|
||||
console.error('Error loading saved forms:', error);
|
||||
this.savedForms = [];
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// Save form to the database
|
||||
async saveForm() {
|
||||
try {
|
||||
const formData = {
|
||||
formName: this.formName,
|
||||
formDescription: this.formDescription,
|
||||
components: this.formComponents.map(c => ({
|
||||
type: c.type,
|
||||
props: c.props
|
||||
}))
|
||||
};
|
||||
|
||||
// Determine if this is a new form or an update
|
||||
const isNewForm = !this.currentFormId;
|
||||
let response;
|
||||
|
||||
if (isNewForm) {
|
||||
// Create a new form
|
||||
response = await fetch('/api/forms/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
} else {
|
||||
// Update existing form
|
||||
response = await fetch(`/api/forms/${this.currentFormId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Update store state with the saved form
|
||||
this.currentFormId = result.form.formUUID;
|
||||
this.hasUnsavedChanges = false;
|
||||
|
||||
// Record in history
|
||||
this.recordHistory('save_form', {
|
||||
formName: this.formName,
|
||||
formDescription: this.formDescription,
|
||||
componentCount: this.formComponents.length
|
||||
});
|
||||
|
||||
return result.form;
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to save form');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving form:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Load a form from the database
|
||||
async loadForm(formId) {
|
||||
if (!formId) {
|
||||
throw new Error('Form ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/forms/${formId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.form) {
|
||||
// Clear existing data
|
||||
this.formComponents = [];
|
||||
this.selectedComponentId = null;
|
||||
|
||||
// Set form data
|
||||
this.formName = result.form.formName;
|
||||
this.formDescription = result.form.formDescription || '';
|
||||
this.currentFormId = result.form.formUUID;
|
||||
|
||||
// Transform components from DB format to store format
|
||||
if (Array.isArray(result.form.formComponents)) {
|
||||
this.formComponents = result.form.formComponents.map(c => ({
|
||||
...c,
|
||||
id: uuidv4() // Assign a new UUID for each component
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear and initialize history when loading a form
|
||||
this.actionHistory = [];
|
||||
this.currentHistoryIndex = -1;
|
||||
|
||||
// Record initial state in history
|
||||
this.recordHistory('load_form', {
|
||||
formName: result.form.formName,
|
||||
formId: formId
|
||||
});
|
||||
|
||||
return result.form;
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to load form');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading form ${formId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a form from the database
|
||||
async deleteForm(formId) {
|
||||
try {
|
||||
const response = await fetch(`/api/forms/${formId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete form');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error deleting form ${formId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear the current form
|
||||
clearForm() {
|
||||
// Capture the current state before clearing
|
||||
const oldComponents = [...this.formComponents];
|
||||
@ -451,6 +604,7 @@ export const useFormBuilderStore = defineStore('formBuilder', {
|
||||
this.selectedComponentId = null;
|
||||
this.formName = 'New Form';
|
||||
this.formDescription = '';
|
||||
this.currentFormId = null;
|
||||
this.hasUnsavedChanges = false;
|
||||
|
||||
// Clear history when starting a new form and add initial state
|
||||
@ -463,40 +617,6 @@ export const useFormBuilderStore = defineStore('formBuilder', {
|
||||
});
|
||||
},
|
||||
|
||||
loadForm(formId) {
|
||||
const savedForms = JSON.parse(localStorage.getItem('savedForms') || '[]');
|
||||
const form = savedForms.find(f => f.id === formId);
|
||||
|
||||
if (form) {
|
||||
// Clear existing data
|
||||
this.formComponents = [];
|
||||
this.selectedComponentId = null;
|
||||
|
||||
// Set form data
|
||||
this.formName = form.name;
|
||||
this.formDescription = form.description;
|
||||
this.formComponents = form.components.map(c => ({
|
||||
...c,
|
||||
id: uuidv4()
|
||||
}));
|
||||
|
||||
// Clear and initialize history when loading a form
|
||||
this.actionHistory = [];
|
||||
this.currentHistoryIndex = -1;
|
||||
|
||||
// Record initial state in history
|
||||
this.recordHistory('load_form', {
|
||||
formName: form.name,
|
||||
formId: formId
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadSavedForms() {
|
||||
const savedForms = JSON.parse(localStorage.getItem('savedForms') || '[]');
|
||||
this.savedForms = savedForms;
|
||||
},
|
||||
|
||||
// Optimize the grid layout by analyzing the current components
|
||||
// and adjusting their sizes to fill available spaces
|
||||
optimizeGridLayout() {
|
||||
|
@ -181,22 +181,36 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
|
||||
deleteNode(nodeId) {
|
||||
if (!this.currentProcess) return;
|
||||
|
||||
// Find the node index
|
||||
const index = this.currentProcess.nodes.findIndex(n => n.id === nodeId);
|
||||
if (index !== -1) {
|
||||
// Remove the node
|
||||
this.currentProcess.nodes.splice(index, 1);
|
||||
|
||||
// Remove any edges connected to this node
|
||||
this.currentProcess.edges = this.currentProcess.edges.filter(
|
||||
edge => edge.source !== nodeId && edge.target !== nodeId
|
||||
const edgesToRemove = this.currentProcess.edges.filter(
|
||||
edge => edge.source === nodeId || edge.target === nodeId
|
||||
);
|
||||
|
||||
edgesToRemove.forEach(edge => {
|
||||
const edgeIndex = this.currentProcess.edges.findIndex(e => e.id === edge.id);
|
||||
if (edgeIndex !== -1) {
|
||||
this.currentProcess.edges.splice(edgeIndex, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selection if the deleted node was selected
|
||||
if (this.selectedNodeId === nodeId) {
|
||||
this.selectedNodeId = null;
|
||||
}
|
||||
|
||||
this.saveToHistory('Delete node');
|
||||
this.unsavedChanges = true;
|
||||
|
||||
return true; // Return success
|
||||
}
|
||||
|
||||
return false; // Return failure
|
||||
},
|
||||
|
||||
/**
|
||||
@ -226,12 +240,35 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
|
||||
/**
|
||||
* Update an edge in the current process
|
||||
*/
|
||||
updateEdge(edgeId, updates) {
|
||||
updateEdge(edgeIdOrObject, updates) {
|
||||
if (!this.currentProcess) return;
|
||||
|
||||
// Handle different parameter formats
|
||||
let edgeId, edgeUpdates;
|
||||
|
||||
if (typeof edgeIdOrObject === 'string') {
|
||||
// Called with (id, updates)
|
||||
edgeId = edgeIdOrObject;
|
||||
edgeUpdates = updates || {};
|
||||
} else if (typeof edgeIdOrObject === 'object') {
|
||||
// Called with an edge object
|
||||
edgeId = edgeIdOrObject.id;
|
||||
|
||||
if (updates) {
|
||||
// Called with (edge, updates)
|
||||
edgeUpdates = updates;
|
||||
} else {
|
||||
// Called with just the edge object containing updates
|
||||
edgeUpdates = { ...edgeIdOrObject };
|
||||
delete edgeUpdates.id; // Don't update the ID
|
||||
}
|
||||
} else {
|
||||
return; // Invalid parameters
|
||||
}
|
||||
|
||||
const edge = this.currentProcess.edges.find(e => e.id === edgeId);
|
||||
if (edge) {
|
||||
Object.assign(edge, updates);
|
||||
Object.assign(edge, edgeUpdates);
|
||||
this.saveToHistory('Update edge');
|
||||
this.unsavedChanges = true;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user