Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
edaae77744 | |||
99b2e43cfe | |||
0cb5ed10b5 | |||
e10a3745c0 | |||
1d1dd00066 | |||
6887a2b9bc | |||
fb81306a68 |
@ -339,16 +339,182 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Section Component -->
|
||||
<div v-else-if="component.type === 'form-section'" class="py-2">
|
||||
<div
|
||||
class="form-section-container"
|
||||
:class="{
|
||||
'has-border': component.props.showBorder,
|
||||
'border-dashed': component.props.borderStyle === 'dashed',
|
||||
'border-dotted': component.props.borderStyle === 'dotted',
|
||||
'spacing-compact': component.props.spacing === 'compact',
|
||||
'spacing-relaxed': component.props.spacing === 'relaxed'
|
||||
}"
|
||||
:style="{
|
||||
backgroundColor: component.props.backgroundColor || '#ffffff',
|
||||
borderColor: component.props.showBorder ? '#e5e7eb' : 'transparent'
|
||||
}"
|
||||
>
|
||||
<!-- Section Header -->
|
||||
<div
|
||||
v-if="component.props.showHeader"
|
||||
class="section-header"
|
||||
:class="{
|
||||
'header-small': component.props.headerSize === 'small',
|
||||
'header-large': component.props.headerSize === 'large',
|
||||
'collapsible': component.props.collapsible
|
||||
}"
|
||||
:style="{ backgroundColor: component.props.headerBackground || '#f9fafb' }"
|
||||
@click="component.props.collapsible && toggleSectionCollapse(component.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="section-title">
|
||||
{{ component.props.label || 'Section Title' }}
|
||||
</h3>
|
||||
<p v-if="component.props.description" class="section-description">
|
||||
{{ component.props.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="component.props.collapsible" class="collapse-icon">
|
||||
<Icon
|
||||
:name="component.props.collapsed ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
|
||||
class="w-5 h-5 text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Content -->
|
||||
<div
|
||||
v-if="!component.props.collapsed"
|
||||
class="section-content"
|
||||
:class="{
|
||||
'no-header': !component.props.showHeader
|
||||
}"
|
||||
>
|
||||
<!-- In preview mode, show the nested components or placeholder -->
|
||||
<div v-if="isPreview" class="section-fields">
|
||||
<!-- Render nested components if they exist -->
|
||||
<div v-if="component.props.children && component.props.children.length > 0" class="space-y-3">
|
||||
<template v-for="(childComponent, childIndex) in component.props.children" :key="childIndex">
|
||||
<component-preview :component="childComponent" :is-preview="true" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Show placeholder if no nested components -->
|
||||
<div v-else class="section-placeholder">
|
||||
<div class="placeholder-content">
|
||||
<Icon name="material-symbols:dashboard-customize-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p class="text-sm text-gray-500 text-center mb-2">Form Section Container</p>
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
This section will contain the form fields grouped here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In form builder mode, show drop zone -->
|
||||
<div v-else class="section-drop-zone">
|
||||
<!-- Nested components rendered in grid -->
|
||||
<div v-if="component.props.children && component.props.children.length > 0" class="nested-components">
|
||||
<draggable
|
||||
v-model="component.props.children"
|
||||
group="form-components"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
ghost-class="ghost"
|
||||
animation="300"
|
||||
class="grid grid-cols-12 gap-2"
|
||||
@end="onNestedDragEnd"
|
||||
@add="onNestedComponentAdd"
|
||||
>
|
||||
<template #item="{ element: childElement, index: childIndex }">
|
||||
<div
|
||||
class="form-component relative border rounded-md overflow-hidden transition-all duration-200 bg-white"
|
||||
:class="'border-gray-200 hover:border-blue-300 hover:shadow-md'"
|
||||
:style="{
|
||||
gridColumn: childElement.props.gridColumn || 'span 6'
|
||||
}"
|
||||
>
|
||||
<!-- Component actions -->
|
||||
<div class="component-actions absolute right-1 top-1 flex space-x-1 z-10">
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-blue-600 rounded"
|
||||
title="Component settings"
|
||||
@click.stop="openNestedComponentSettings(childElement)"
|
||||
>
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<span class="drag-handle cursor-move">
|
||||
<Icon name="material-symbols:drag-indicator" class="w-3 h-3" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-red-500 rounded"
|
||||
title="Remove from section"
|
||||
@click.stop="removeFromSection(component.id, childIndex)"
|
||||
>
|
||||
<Icon name="material-symbols:close" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<component-preview :component="childElement" :is-preview="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<!-- Drop zone placeholder -->
|
||||
<div
|
||||
class="section-drop-placeholder"
|
||||
:class="{
|
||||
'drop-active': sectionDropStates[component.id]?.isDraggingOver,
|
||||
'empty': !component.props.children || component.props.children.length === 0
|
||||
}"
|
||||
@dragover.prevent="handleSectionDragOver($event, component.id)"
|
||||
@dragleave="handleSectionDragLeave($event, component.id)"
|
||||
@drop="handleSectionDrop($event, component.id)"
|
||||
@dragenter.prevent="handleSectionDragEnter($event, component.id)"
|
||||
>
|
||||
<div class="placeholder-content">
|
||||
<Icon name="material-symbols:add-box-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p class="text-sm text-gray-500 text-center mb-1">Drop Components Here</p>
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
Drag form fields from the sidebar to add them to this section
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unknown Component Type Fallback -->
|
||||
<div v-else class="p-4 border border-dashed border-gray-300 rounded">
|
||||
<div class="text-gray-500">Unknown component type: {{ component.type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nested Component Settings Modal -->
|
||||
<FormBuilderFieldSettingsModal
|
||||
v-model="showNestedSettingsModal"
|
||||
:component="selectedNestedComponent"
|
||||
@close="closeNestedSettingsModal"
|
||||
@update-component="saveNestedComponentSettings"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useNuxtApp } from '#app';
|
||||
import { useFormBuilderStore } from '~/stores/formBuilder';
|
||||
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
@ -361,6 +527,8 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-nested-component']);
|
||||
|
||||
// Get access to the form builder store
|
||||
const formStore = useFormBuilderStore();
|
||||
const previewFormData = computed(() => formStore.previewFormData || {});
|
||||
@ -370,6 +538,13 @@ const searchQuery = ref({});
|
||||
const selectedItems = ref({});
|
||||
const validationErrors = ref({});
|
||||
|
||||
// New reactive state for form sections
|
||||
const sectionDropStates = ref({});
|
||||
|
||||
// State for nested component settings modal
|
||||
const showNestedSettingsModal = ref(false);
|
||||
const selectedNestedComponent = ref(null);
|
||||
|
||||
// Initialize data for components
|
||||
onMounted(() => {
|
||||
// Initialize search and selection data for dynamic lists
|
||||
@ -859,6 +1034,195 @@ const updateTableData = (newData) => {
|
||||
const updatedFormData = { ...formStore.previewFormData, [tableName]: newData };
|
||||
formStore.updatePreviewFormData(updatedFormData);
|
||||
};
|
||||
|
||||
// Form Section Component
|
||||
const toggleSectionCollapse = (sectionId) => {
|
||||
// Find the section component and toggle its collapsed state
|
||||
const section = formStore.formComponents.find(comp => comp.id === sectionId);
|
||||
if (section) {
|
||||
section.props.collapsed = !section.props.collapsed;
|
||||
formStore.updateComponent(section);
|
||||
}
|
||||
};
|
||||
|
||||
// New methods for form section component
|
||||
const onNestedDragEnd = (event) => {
|
||||
// Handle reordering within a section
|
||||
console.log('Nested drag end:', event);
|
||||
};
|
||||
|
||||
const onNestedComponentAdd = (event) => {
|
||||
// Handle adding a new component to a section
|
||||
console.log('Nested component add:', event);
|
||||
};
|
||||
|
||||
const removeFromSection = (sectionId, childIndex) => {
|
||||
// Actually delete the component from the section (don't move it back to main form)
|
||||
const section = formStore.formComponents.find(comp => comp.id === sectionId);
|
||||
if (section && section.props.children) {
|
||||
const removedComponent = section.props.children.splice(childIndex, 1)[0];
|
||||
if (removedComponent) {
|
||||
// Update the section (don't add the component back to main form)
|
||||
formStore.updateComponent(section);
|
||||
|
||||
// Record the deletion in history
|
||||
formStore.recordHistory('delete_component_from_section', {
|
||||
componentId: removedComponent.id,
|
||||
componentType: removedComponent.type,
|
||||
componentName: removedComponent.props.label || removedComponent.props.name,
|
||||
sectionId: sectionId,
|
||||
deletedComponent: removedComponent
|
||||
});
|
||||
|
||||
console.log('Component deleted from section:', removedComponent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionDragOver = (event, sectionId) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Initialize section drop state if it doesn't exist
|
||||
if (!sectionDropStates.value[sectionId]) {
|
||||
sectionDropStates.value[sectionId] = { isDraggingOver: false };
|
||||
}
|
||||
sectionDropStates.value[sectionId].isDraggingOver = true;
|
||||
};
|
||||
|
||||
const handleSectionDragLeave = (event, sectionId) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Only hide the drag over state if we're actually leaving the drop zone
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const isOutside = (
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right ||
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom
|
||||
);
|
||||
|
||||
if (isOutside && sectionDropStates.value[sectionId]) {
|
||||
sectionDropStates.value[sectionId].isDraggingOver = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionDrop = (event, sectionId) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Reset drag state
|
||||
if (sectionDropStates.value[sectionId]) {
|
||||
sectionDropStates.value[sectionId].isDraggingOver = false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the dropped component data
|
||||
let componentData = null;
|
||||
|
||||
try {
|
||||
componentData = JSON.parse(event.dataTransfer.getData('text/plain') || '{}');
|
||||
} catch (parseError) {
|
||||
// Fallback for Safari or other browsers
|
||||
componentData = window.__draggedComponentData || {};
|
||||
}
|
||||
|
||||
if (!componentData.type) {
|
||||
console.warn('No valid component data found in drop event');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new component instance with proper label setup
|
||||
const newComponent = {
|
||||
id: `${componentData.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: componentData.type,
|
||||
name: componentData.name,
|
||||
props: {
|
||||
...componentData.defaultProps,
|
||||
gridColumn: 'span 6', // Default to half width in sections
|
||||
width: '50%',
|
||||
// Ensure the component has a proper label
|
||||
label: componentData.defaultProps.label || componentData.name || `${componentData.type.charAt(0).toUpperCase() + componentData.type.slice(1)} Field`,
|
||||
name: componentData.defaultProps.name || `${componentData.type}_${Date.now()}`
|
||||
}
|
||||
};
|
||||
|
||||
// Find the target section
|
||||
const section = formStore.formComponents.find(comp => comp.id === sectionId);
|
||||
if (section) {
|
||||
// Initialize children array if it doesn't exist
|
||||
if (!section.props.children) {
|
||||
section.props.children = [];
|
||||
}
|
||||
|
||||
// Add the component to the section
|
||||
section.props.children.push(newComponent);
|
||||
|
||||
// Update the section in the form store
|
||||
formStore.updateComponent(section);
|
||||
|
||||
// Record the action in history
|
||||
formStore.recordHistory('add_component_to_section', {
|
||||
componentType: newComponent.type,
|
||||
componentName: newComponent.name,
|
||||
sectionId: sectionId
|
||||
});
|
||||
|
||||
console.log('Component added to section:', newComponent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error dropping component into section:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionDragEnter = (event, sectionId) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Initialize section drop state if it doesn't exist
|
||||
if (!sectionDropStates.value[sectionId]) {
|
||||
sectionDropStates.value[sectionId] = { isDraggingOver: false };
|
||||
}
|
||||
};
|
||||
|
||||
const openNestedComponentSettings = (component) => {
|
||||
// Set the selected nested component and open the modal
|
||||
selectedNestedComponent.value = { ...component };
|
||||
showNestedSettingsModal.value = true;
|
||||
console.log('Opening settings for component:', component);
|
||||
};
|
||||
|
||||
const closeNestedSettingsModal = () => {
|
||||
showNestedSettingsModal.value = false;
|
||||
selectedNestedComponent.value = null;
|
||||
};
|
||||
|
||||
const saveNestedComponentSettings = (updatedComponent) => {
|
||||
if (!updatedComponent || !selectedNestedComponent.value) return;
|
||||
|
||||
// Find the parent section and update the nested component
|
||||
const parentSection = formStore.formComponents.find(comp =>
|
||||
comp.type === 'form-section' &&
|
||||
comp.props.children &&
|
||||
comp.props.children.some(child => child.id === updatedComponent.id)
|
||||
);
|
||||
|
||||
if (parentSection) {
|
||||
const childIndex = parentSection.props.children.findIndex(child => child.id === updatedComponent.id);
|
||||
if (childIndex !== -1) {
|
||||
// Update the nested component
|
||||
parentSection.props.children[childIndex] = { ...updatedComponent };
|
||||
// Update the section to trigger reactivity
|
||||
formStore.updateComponent(parentSection);
|
||||
|
||||
console.log('Updated nested component:', updatedComponent);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
closeNestedSettingsModal();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -932,4 +1296,175 @@ const updateTableData = (newData) => {
|
||||
.repeating-table-wrapper{
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Form Section Component */
|
||||
.form-section-container {
|
||||
padding: 1rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
background-color: #f9fafb;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-placeholder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
border-width: 1px;
|
||||
border-style: dashed;
|
||||
border-color: #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-fields {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-drop-zone {
|
||||
padding: 1rem;
|
||||
min-height: 120px;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #ffffff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.nested-components {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-component {
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-component:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.component-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 0 0.375rem 0 0.375rem;
|
||||
padding: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.form-component:hover .component-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.component-actions:hover {
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.component-actions button {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.component-actions button:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-drop-placeholder {
|
||||
padding: 2rem;
|
||||
border-width: 1px;
|
||||
border-style: dashed;
|
||||
border-color: #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #ffffff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-drop-placeholder.empty {
|
||||
border-color: #e5e7eb;
|
||||
min-height: 120px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.section-drop-placeholder:not(.empty) {
|
||||
border-color: #e5e7eb;
|
||||
min-height: 80px;
|
||||
padding: 1.5rem;
|
||||
border-style: dashed;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.section-drop-placeholder.drop-active {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
border-width: 2px;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.section-drop-placeholder.empty:hover {
|
||||
border-color: #9ca3af;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.section-drop-placeholder:not(.empty):hover {
|
||||
border-color: #9ca3af;
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Ghost styles for dragging */
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background-color: #dbeafe !important;
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
</style>
|
@ -82,6 +82,7 @@
|
||||
<component-preview
|
||||
:component="element"
|
||||
:is-preview="false"
|
||||
@select-nested-component="handleNestedComponentSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -113,7 +114,7 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-component', 'move-component', 'delete-component', 'update-component', 'optimize-layout']);
|
||||
const emit = defineEmits(['select-component', 'move-component', 'delete-component', 'update-component', 'optimize-layout', 'select-nested-component']);
|
||||
|
||||
const selectedComponentId = ref(null);
|
||||
const resizeMode = ref(false);
|
||||
@ -162,6 +163,12 @@ const deleteComponent = (id) => {
|
||||
emit('delete-component', id);
|
||||
};
|
||||
|
||||
// Handle nested component selection from sections
|
||||
const handleNestedComponentSelect = (component) => {
|
||||
// Pass the nested component selection up to the main form builder
|
||||
emit('select-nested-component', component);
|
||||
};
|
||||
|
||||
// Toggle resize mode
|
||||
const toggleResizeMode = (component) => {
|
||||
resizeMode.value = !resizeMode.value;
|
||||
|
@ -603,6 +603,36 @@ const availableComponents = [
|
||||
},
|
||||
|
||||
// Layout
|
||||
{
|
||||
type: 'form-section',
|
||||
name: 'Form Section',
|
||||
category: 'Layout',
|
||||
icon: 'material-symbols:view-module-outline',
|
||||
description: 'Container to group related form fields into sections',
|
||||
defaultProps: {
|
||||
label: 'Section Title',
|
||||
description: 'Optional section description',
|
||||
collapsible: false,
|
||||
collapsed: false,
|
||||
showBorder: true,
|
||||
borderStyle: 'solid', // solid, dashed, dotted
|
||||
backgroundColor: '#ffffff',
|
||||
headerBackground: '#f9fafb',
|
||||
showHeader: true,
|
||||
headerSize: 'medium', // small, medium, large
|
||||
spacing: 'normal', // compact, normal, relaxed
|
||||
width: '100%',
|
||||
gridColumn: 'span 12',
|
||||
children: [], // Array to hold nested components
|
||||
// Conditional Logic Properties
|
||||
conditionalLogic: {
|
||||
enabled: false,
|
||||
conditions: [],
|
||||
action: 'show',
|
||||
operator: 'and'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'info-display',
|
||||
name: 'Info Display',
|
||||
|
@ -608,264 +608,202 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Repeating Table Configuration -->
|
||||
<template v-if="component.type === 'repeating-table'">
|
||||
<!-- Form Section Configuration -->
|
||||
<template v-if="component.type === 'form-section'">
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Table Settings -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Section Content -->
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Add Button Text"
|
||||
name="buttonText"
|
||||
v-model="configModel.buttonText"
|
||||
help="Text for the add record button"
|
||||
type="textarea"
|
||||
label="Section Description"
|
||||
name="description"
|
||||
v-model="configModel.description"
|
||||
help="Optional description text below the section title"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
placeholder="Add Record"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Edit Button Text"
|
||||
name="editText"
|
||||
v-model="configModel.editText"
|
||||
help="Text for the edit record button"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
placeholder="Edit"
|
||||
placeholder="Describe this section..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Record Limits -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="number"
|
||||
label="Minimum Records"
|
||||
name="minRecords"
|
||||
v-model="configModel.minRecords"
|
||||
help="Minimum number of records required"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
:min="0"
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="number"
|
||||
label="Maximum Records"
|
||||
name="maxRecords"
|
||||
v-model="configModel.maxRecords"
|
||||
help="Maximum number of records allowed"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
:min="1"
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Display Settings -->
|
||||
<!-- Section Header Settings -->
|
||||
<div class="space-y-4">
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Display Settings</h5>
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Header Settings</h5>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="switch"
|
||||
label="Show Row Numbers"
|
||||
name="showRowNumbers"
|
||||
v-model="configModel.showRowNumbers"
|
||||
help="Display row numbers in the table"
|
||||
label="Show Header"
|
||||
name="showHeader"
|
||||
v-model="configModel.showHeader"
|
||||
help="Display the section header"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Header Size"
|
||||
name="headerSize"
|
||||
v-model="configModel.headerSize"
|
||||
:options="[
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Medium', value: 'medium' },
|
||||
{ label: 'Large', value: 'large' }
|
||||
]"
|
||||
help="Size of the section header"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="switch"
|
||||
label="Enable Search"
|
||||
name="enableSearch"
|
||||
v-model="configModel.enableSearch"
|
||||
help="Add search functionality to the table"
|
||||
label="Collapsible"
|
||||
name="collapsible"
|
||||
v-model="configModel.collapsible"
|
||||
help="Allow users to collapse this section"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="switch"
|
||||
label="Allow Edit"
|
||||
name="allowEdit"
|
||||
v-model="configModel.allowEdit"
|
||||
help="Allow users to edit existing records"
|
||||
label="Start Collapsed"
|
||||
name="collapsed"
|
||||
v-model="configModel.collapsed"
|
||||
help="Start with this section collapsed"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
:disabled="!configModel.collapsible"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual Settings -->
|
||||
<div class="space-y-4">
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Visual Settings</h5>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="switch"
|
||||
label="Show Border"
|
||||
name="showBorder"
|
||||
v-model="configModel.showBorder"
|
||||
help="Display border around the section"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="switch"
|
||||
label="Allow Delete"
|
||||
name="allowDelete"
|
||||
v-model="configModel.allowDelete"
|
||||
help="Allow users to delete records"
|
||||
type="select"
|
||||
label="Border Style"
|
||||
name="borderStyle"
|
||||
v-model="configModel.borderStyle"
|
||||
:options="[
|
||||
{ label: 'Solid', value: 'solid' },
|
||||
{ label: 'Dashed', value: 'dashed' },
|
||||
{ label: 'Dotted', value: 'dotted' }
|
||||
]"
|
||||
help="Style of the section border"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
:disabled="!configModel.showBorder"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="switch"
|
||||
label="Confirm Delete"
|
||||
name="confirmDelete"
|
||||
v-model="configModel.confirmDelete"
|
||||
help="Require confirmation before deleting"
|
||||
type="select"
|
||||
label="Spacing"
|
||||
name="spacing"
|
||||
v-model="configModel.spacing"
|
||||
:options="[
|
||||
{ label: 'Compact', value: 'compact' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Relaxed', value: 'relaxed' }
|
||||
]"
|
||||
help="Internal padding of the section"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Columns Configuration -->
|
||||
<!-- Color Settings -->
|
||||
<div class="space-y-4">
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Table Columns</h5>
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Color Settings</h5>
|
||||
|
||||
<div v-if="configModel.columns && configModel.columns.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="(column, index) in configModel.columns"
|
||||
:key="index"
|
||||
class="border rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h6 class="text-sm font-medium text-gray-800">Column {{ index + 1 }}</h6>
|
||||
<button
|
||||
@click="removeTableColumn(index)"
|
||||
class="text-red-500 hover:text-red-700 text-sm"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="color"
|
||||
label="Background Color"
|
||||
name="backgroundColor"
|
||||
v-model="configModel.backgroundColor"
|
||||
help="Background color of the section"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
label="Header Background"
|
||||
name="headerBackground"
|
||||
v-model="configModel.headerBackground"
|
||||
help="Background color of the header"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
:disabled="!configModel.showHeader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nested Components Management -->
|
||||
<div class="space-y-4">
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Nested Components</h5>
|
||||
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">
|
||||
Components in this section: {{ (configModel.children || []).length }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Drag form components into the section to group them here
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Column Name"
|
||||
v-model="column.name"
|
||||
placeholder="field_name"
|
||||
help="Internal field name (no spaces)"
|
||||
:classes="{ outer: 'field-wrapper mb-0' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Display Label"
|
||||
v-model="column.label"
|
||||
placeholder="Display Name"
|
||||
help="What users will see"
|
||||
:classes="{ outer: 'field-wrapper mb-0' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Field Type"
|
||||
v-model="column.type"
|
||||
:options="[
|
||||
{ label: 'Text', value: 'text' },
|
||||
{ label: 'Number', value: 'number' },
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'Phone', value: 'tel' },
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Dropdown', value: 'select' },
|
||||
{ label: 'Text Area', value: 'textarea' }
|
||||
]"
|
||||
:classes="{ outer: 'field-wrapper mb-0' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Placeholder"
|
||||
v-model="column.placeholder"
|
||||
placeholder="Enter value..."
|
||||
help="Hint text for the input"
|
||||
:classes="{ outer: 'field-wrapper mb-0' }"
|
||||
/>
|
||||
<div v-if="(configModel.children || []).length > 0">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ (configModel.children || []).length }} {{ (configModel.children || []).length === 1 ? 'component' : 'components' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<FormKit
|
||||
type="switch"
|
||||
label="Required Field"
|
||||
v-model="column.required"
|
||||
help="Make this field mandatory"
|
||||
:classes="{ outer: 'field-wrapper mb-0' }"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Validation Rules"
|
||||
v-model="column.validation"
|
||||
placeholder="required|email"
|
||||
help="Validation rules (e.g., required, email)"
|
||||
:classes="{ outer: 'field-wrapper mb-0' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="column.type === 'select'" class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Options</label>
|
||||
<div v-if="column.options && column.options.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="(option, optionIndex) in column.options"
|
||||
:key="optionIndex"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="option.label"
|
||||
placeholder="Option label"
|
||||
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
v-model="option.value"
|
||||
placeholder="Option value"
|
||||
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
@click="removeColumnOption(index, optionIndex)"
|
||||
class="text-red-500 hover:text-red-700"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List of nested components -->
|
||||
<div v-if="(configModel.children || []).length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="(child, index) in configModel.children"
|
||||
:key="child.id"
|
||||
class="flex items-center justify-between bg-white border border-gray-200 rounded px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Icon :name="getComponentIcon(child.type)" class="w-4 h-4 text-gray-500" />
|
||||
<span class="text-sm text-gray-700">
|
||||
{{ child.props.label || getComponentTypeName(child.type) }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
({{ child.type }})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="addColumnOption(index)"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 flex items-center mt-2"
|
||||
type="button"
|
||||
<button
|
||||
@click="removeNestedComponent(index)"
|
||||
class="text-red-500 hover:text-red-700 text-xs"
|
||||
title="Remove from section"
|
||||
>
|
||||
<Icon name="material-symbols:add-circle-outline" class="w-4 h-4 mr-1" />
|
||||
Add Option
|
||||
<Icon name="material-symbols:close" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="text-center py-4">
|
||||
<Icon name="material-symbols:inbox-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p class="text-sm text-gray-500 mb-1">No components in this section</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Drag components from the sidebar into the section area
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8 text-gray-500 border-2 border-dashed border-gray-300 rounded-lg">
|
||||
<Icon name="heroicons:table-cells" class="w-8 h-8 mx-auto mb-2 text-gray-400" />
|
||||
<p class="text-sm mb-3">No columns defined yet</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="addTableColumn"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 flex items-center"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="material-symbols:add-circle-outline" class="w-4 h-4 mr-1" />
|
||||
Add Column
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content for Text Components -->
|
||||
<template v-if="showField('value')">
|
||||
<FormKit
|
||||
type="textarea"
|
||||
label="Text Content"
|
||||
name="value"
|
||||
v-model="configModel.value"
|
||||
help="The text that will be displayed"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
rows="3"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1433,6 +1371,7 @@ const getComponentIcon = (type) => {
|
||||
heading: 'heroicons:h1',
|
||||
paragraph: 'heroicons:document-text',
|
||||
divider: 'heroicons:minus',
|
||||
'form-section': 'material-symbols:view-module-outline',
|
||||
'info-display': 'heroicons:information-circle',
|
||||
'dynamic-list': 'heroicons:list-bullet',
|
||||
'repeating-table': 'heroicons:table-cells'
|
||||
@ -1467,6 +1406,7 @@ const getComponentTypeName = (type) => {
|
||||
heading: 'Heading Text',
|
||||
paragraph: 'Paragraph Text',
|
||||
divider: 'Divider Line',
|
||||
'form-section': 'Form Section',
|
||||
'info-display': 'Information Display',
|
||||
'dynamic-list': 'Dynamic List',
|
||||
'repeating-table': 'Data Table'
|
||||
@ -1501,6 +1441,7 @@ const getComponentDescription = (type) => {
|
||||
heading: 'Section heading to organize form content',
|
||||
paragraph: 'Text content for instructions and descriptions',
|
||||
divider: 'Visual separator to organize form sections',
|
||||
'form-section': 'Visual container to group related form fields into sections',
|
||||
'info-display': 'Read-only information display in organized format',
|
||||
'dynamic-list': 'Dynamic list for displaying and managing items',
|
||||
'repeating-table': 'Structured table for collecting multiple records with forms'
|
||||
@ -1528,14 +1469,14 @@ const showField = (fieldName) => {
|
||||
if (!props.component) return false
|
||||
|
||||
const fieldConfig = {
|
||||
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'dynamic-list', 'repeating-table'],
|
||||
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'dynamic-list', 'repeating-table'],
|
||||
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
|
||||
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
|
||||
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'dynamic-list'],
|
||||
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'dynamic-list', 'repeating-table'],
|
||||
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
|
||||
value: ['heading', 'paragraph', 'hidden'],
|
||||
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'info-display', 'dynamic-list', 'repeating-table'],
|
||||
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table'],
|
||||
options: ['select', 'checkbox', 'radio'],
|
||||
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'dynamic-list', 'repeating-table']
|
||||
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table']
|
||||
}
|
||||
|
||||
return fieldConfig[fieldName]?.includes(props.component.type) || false
|
||||
@ -1544,7 +1485,7 @@ const showField = (fieldName) => {
|
||||
const hasOptions = computed(() => showField('options'))
|
||||
const hasSpecificSettings = computed(() => {
|
||||
if (!props.component) return false
|
||||
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'heading', 'paragraph', 'button', 'dynamic-list', 'repeating-table']
|
||||
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'heading', 'paragraph', 'button', 'form-section', 'dynamic-list', 'repeating-table']
|
||||
return specificTypes.includes(props.component.type)
|
||||
})
|
||||
|
||||
@ -1704,7 +1645,16 @@ watch(() => props.component, (newComponent) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Methods
|
||||
// Methods - Define resetValidationState first before it's used above
|
||||
const resetValidationState = () => {
|
||||
hasLengthValidation.value = false
|
||||
hasNumberValidation.value = false
|
||||
minLength.value = null
|
||||
maxLength.value = null
|
||||
minNumber.value = null
|
||||
maxNumber.value = null
|
||||
}
|
||||
|
||||
const updateValidation = (rule, enabled) => {
|
||||
let current = configModel.value.validation || ''
|
||||
const rules = current.split('|').filter(r => r && r !== rule)
|
||||
@ -1716,15 +1666,6 @@ const updateValidation = (rule, enabled) => {
|
||||
configModel.value.validation = rules.join('|')
|
||||
}
|
||||
|
||||
const resetValidationState = () => {
|
||||
hasLengthValidation.value = false
|
||||
hasNumberValidation.value = false
|
||||
minLength.value = null
|
||||
maxLength.value = null
|
||||
minNumber.value = null
|
||||
maxNumber.value = null
|
||||
}
|
||||
|
||||
const setComponentWidth = (percentage, gridColumns) => {
|
||||
configModel.value.width = `${percentage}%`
|
||||
configModel.value.gridColumn = `span ${gridColumns}`
|
||||
@ -1921,6 +1862,12 @@ const removeColumnOption = (columnIndex, optionIndex) => {
|
||||
configModel.value.columns[columnIndex].options.splice(optionIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const removeNestedComponent = (index) => {
|
||||
if (configModel.value.children) {
|
||||
configModel.value.children.splice(index, 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -458,7 +458,6 @@
|
||||
{ label: 'Dynamic (from Variable)', value: 'variable' }
|
||||
]"
|
||||
placeholder="Select assignment type"
|
||||
@input="handleAssignmentTypeChange"
|
||||
:classes="{ outer: 'mb-0' }"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">How should this task be assigned</p>
|
||||
@ -472,15 +471,32 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- User Dropdown -->
|
||||
<div class="relative">
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="localNodeData.assignedUsers"
|
||||
:options="availableUsers"
|
||||
placeholder="Select users..."
|
||||
multiple
|
||||
v-model="selectedUserId"
|
||||
:options="filteredAvailableUsers"
|
||||
placeholder="Select a user to add..."
|
||||
:classes="{ outer: 'mb-0' }"
|
||||
@input="handleUserSelection"
|
||||
/>
|
||||
<p class="text-xs text-blue-700">Selected users will be able to complete this form task</p>
|
||||
<p class="mt-1 text-xs text-blue-700">Select users who will be able to complete this form task</p>
|
||||
</div>
|
||||
|
||||
<!-- Selected Users Pills -->
|
||||
<div v-if="localNodeData.assignedUsers && localNodeData.assignedUsers.length > 0" class="mt-3">
|
||||
<label class="block text-sm font-medium text-blue-700 mb-2">Selected Users</label>
|
||||
<div class="flex flex-wrap gap-2 p-2 bg-white border border-blue-100 rounded-md min-h-[40px]">
|
||||
<div v-for="(user, index) in localNodeData.assignedUsers" :key="'user-' + user.value"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800 border border-blue-200">
|
||||
<span class="mr-1">{{ user.label }}</span>
|
||||
<button @click="removeAssignedUser(index)" class="text-blue-600 hover:text-blue-800">
|
||||
<Icon name="material-symbols:close" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -492,15 +508,32 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Role Dropdown -->
|
||||
<div class="relative">
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="localNodeData.assignedRoles"
|
||||
:options="availableRoles"
|
||||
placeholder="Select roles..."
|
||||
multiple
|
||||
v-model="selectedRoleId"
|
||||
:options="filteredAvailableRoles"
|
||||
placeholder="Select a role to add..."
|
||||
:classes="{ outer: 'mb-0' }"
|
||||
@input="handleRoleSelection"
|
||||
/>
|
||||
<p class="text-xs text-purple-700">Any user with the selected roles will be able to complete this form task</p>
|
||||
<p class="mt-1 text-xs text-purple-700">Select roles that will be able to complete this form task</p>
|
||||
</div>
|
||||
|
||||
<!-- Selected Roles Pills -->
|
||||
<div v-if="localNodeData.assignedRoles && localNodeData.assignedRoles.length > 0" class="mt-3">
|
||||
<label class="block text-sm font-medium text-purple-700 mb-2">Selected Roles</label>
|
||||
<div class="flex flex-wrap gap-2 p-2 bg-white border border-purple-100 rounded-md min-h-[40px]">
|
||||
<div v-for="(role, index) in localNodeData.assignedRoles" :key="'role-' + role.value"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-purple-100 text-purple-800 border border-purple-200">
|
||||
<span class="mr-1">{{ role.label }}</span>
|
||||
<button @click="removeAssignedRole(index)" class="text-purple-600 hover:text-purple-800">
|
||||
<Icon name="material-symbols:close" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -623,6 +656,10 @@ const localNodeData = ref({
|
||||
assignmentVariableType: 'user_id'
|
||||
});
|
||||
|
||||
// New reactive state for selected user and role
|
||||
const selectedUserId = ref('');
|
||||
const selectedRoleId = ref('');
|
||||
|
||||
// Watch for changes from parent props
|
||||
watch(() => props.nodeData, async (newNodeData) => {
|
||||
if (newNodeData) {
|
||||
@ -859,21 +896,31 @@ const processVariableOptions = computed(() => {
|
||||
const users = ref([]);
|
||||
const roles = ref([]);
|
||||
|
||||
// Computed property for available users (for FormKit select)
|
||||
const availableUsers = computed(() => {
|
||||
return users.value.map(user => ({
|
||||
// Computed property for available users with filtering out already selected users
|
||||
const filteredAvailableUsers = computed(() => {
|
||||
// Convert all IDs to strings for consistent comparison
|
||||
const selectedUserIds = (localNodeData.value.assignedUsers || []).map(user => String(user.value));
|
||||
|
||||
return users.value
|
||||
.filter(user => !selectedUserIds.includes(String(user.userID)))
|
||||
.map(user => ({
|
||||
label: user.userFullName ? `${user.userFullName} (${user.userUsername})` : user.userUsername,
|
||||
value: user.userID,
|
||||
value: String(user.userID), // Ensure value is a string
|
||||
username: user.userUsername,
|
||||
email: user.userEmail
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed property for available roles (for FormKit select)
|
||||
const availableRoles = computed(() => {
|
||||
return roles.value.map(role => ({
|
||||
// Computed property for available roles with filtering out already selected roles
|
||||
const filteredAvailableRoles = computed(() => {
|
||||
// Convert all IDs to strings for consistent comparison
|
||||
const selectedRoleIds = (localNodeData.value.assignedRoles || []).map(role => String(role.value));
|
||||
|
||||
return roles.value
|
||||
.filter(role => !selectedRoleIds.includes(String(role.roleID)))
|
||||
.map(role => ({
|
||||
label: role.roleName,
|
||||
value: role.roleID,
|
||||
value: String(role.roleID), // Ensure value is a string
|
||||
description: role.roleDescription
|
||||
}));
|
||||
});
|
||||
@ -974,11 +1021,161 @@ function getStringValue(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle assignment type change
|
||||
function handleAssignmentTypeChange() {
|
||||
// Implementation of handleAssignmentTypeChange function
|
||||
// Handle user selection from dropdown
|
||||
function handleUserSelection(userId) {
|
||||
if (userId) {
|
||||
console.log('User selected:', userId, typeof userId);
|
||||
|
||||
// Convert userId to string to ensure consistent comparison
|
||||
const userIdStr = String(userId);
|
||||
|
||||
// Find the selected user from available users
|
||||
const selectedUser = filteredAvailableUsers.value.find(user => String(user.value) === userIdStr);
|
||||
|
||||
if (selectedUser) {
|
||||
console.log('Found user:', selectedUser);
|
||||
// Initialize the array if needed
|
||||
if (!localNodeData.value.assignedUsers) {
|
||||
localNodeData.value.assignedUsers = [];
|
||||
}
|
||||
|
||||
// Add the user to the assigned users
|
||||
localNodeData.value.assignedUsers.push({...selectedUser});
|
||||
|
||||
// Reset the selection
|
||||
selectedUserId.value = '';
|
||||
|
||||
// Save changes
|
||||
saveChanges();
|
||||
} else {
|
||||
console.warn('Selected user not found in filtered available users', userIdStr);
|
||||
|
||||
// Fallback: If we can't find the user in filtered list, try to find it in the original users list
|
||||
const userFromOriginal = users.value.find(user => String(user.userID) === userIdStr);
|
||||
|
||||
if (userFromOriginal) {
|
||||
console.log('Found user in original users list:', userFromOriginal);
|
||||
|
||||
// Add the user to the assigned users
|
||||
const userToAdd = {
|
||||
label: userFromOriginal.userFullName ? `${userFromOriginal.userFullName} (${userFromOriginal.userUsername})` : userFromOriginal.userUsername,
|
||||
value: String(userFromOriginal.userID),
|
||||
username: userFromOriginal.userUsername,
|
||||
email: userFromOriginal.userEmail
|
||||
};
|
||||
|
||||
if (!localNodeData.value.assignedUsers) {
|
||||
localNodeData.value.assignedUsers = [];
|
||||
}
|
||||
|
||||
localNodeData.value.assignedUsers.push(userToAdd);
|
||||
selectedUserId.value = '';
|
||||
saveChanges();
|
||||
} else {
|
||||
console.error('User not found in any list. Available users:', users.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle role selection from dropdown
|
||||
function handleRoleSelection(roleId) {
|
||||
if (roleId) {
|
||||
console.log('Role selected:', roleId, typeof roleId);
|
||||
|
||||
// Convert roleId to string to ensure consistent comparison
|
||||
const roleIdStr = String(roleId);
|
||||
|
||||
// Find the selected role from available roles
|
||||
const selectedRole = filteredAvailableRoles.value.find(role => String(role.value) === roleIdStr);
|
||||
|
||||
if (selectedRole) {
|
||||
console.log('Found role:', selectedRole);
|
||||
// Initialize the array if needed
|
||||
if (!localNodeData.value.assignedRoles) {
|
||||
localNodeData.value.assignedRoles = [];
|
||||
}
|
||||
|
||||
// Add the role to the assigned roles
|
||||
localNodeData.value.assignedRoles.push({...selectedRole});
|
||||
|
||||
// Reset the selection
|
||||
selectedRoleId.value = '';
|
||||
|
||||
// Save changes
|
||||
saveChanges();
|
||||
} else {
|
||||
console.warn('Selected role not found in filtered available roles', roleIdStr);
|
||||
|
||||
// Fallback: If we can't find the role in filtered list, try to find it in the original roles list
|
||||
const roleFromOriginal = roles.value.find(role => String(role.roleID) === roleIdStr);
|
||||
|
||||
if (roleFromOriginal) {
|
||||
console.log('Found role in original roles list:', roleFromOriginal);
|
||||
|
||||
// Add the role to the assigned roles
|
||||
const roleToAdd = {
|
||||
label: roleFromOriginal.roleName,
|
||||
value: String(roleFromOriginal.roleID),
|
||||
description: roleFromOriginal.roleDescription
|
||||
};
|
||||
|
||||
if (!localNodeData.value.assignedRoles) {
|
||||
localNodeData.value.assignedRoles = [];
|
||||
}
|
||||
|
||||
localNodeData.value.assignedRoles.push(roleToAdd);
|
||||
selectedRoleId.value = '';
|
||||
saveChanges();
|
||||
} else {
|
||||
console.error('Role not found in any list. Available roles:', roles.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to remove an assigned user
|
||||
function removeAssignedUser(index) {
|
||||
if (localNodeData.value.assignedUsers) {
|
||||
localNodeData.value.assignedUsers.splice(index, 1);
|
||||
saveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to remove an assigned role
|
||||
function removeAssignedRole(index) {
|
||||
if (localNodeData.value.assignedRoles) {
|
||||
localNodeData.value.assignedRoles.splice(index, 1);
|
||||
saveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes to assignment type
|
||||
watch(() => localNodeData.value.assignmentType, (newType, oldType) => {
|
||||
if (newType !== oldType) {
|
||||
// Reset selections when assignment type changes
|
||||
selectedUserId.value = '';
|
||||
selectedRoleId.value = '';
|
||||
|
||||
// If changing from users to another type, clear assigned users
|
||||
if (oldType === 'users' && newType !== 'users') {
|
||||
localNodeData.value.assignedUsers = [];
|
||||
}
|
||||
|
||||
// If changing from roles to another type, clear assigned roles
|
||||
if (oldType === 'roles' && newType !== 'roles') {
|
||||
localNodeData.value.assignedRoles = [];
|
||||
}
|
||||
|
||||
// If changing from variable to another type, clear assignment variable
|
||||
if (oldType === 'variable' && newType !== 'variable') {
|
||||
localNodeData.value.assignmentVariable = '';
|
||||
localNodeData.value.assignmentVariableType = 'user_id';
|
||||
}
|
||||
|
||||
saveChanges();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -54,9 +54,9 @@ const {
|
||||
deleteKeyCode: 'Delete',
|
||||
selectionKeyCode: 'Shift',
|
||||
multiSelectionKeyCode: 'Control',
|
||||
connectionMode: 'loose',
|
||||
connectionMode: 'strict',
|
||||
isValidConnection: (connection) => {
|
||||
// console.log('Validating connection:', connection);
|
||||
console.log('Validating connection:', connection);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
@ -76,7 +76,7 @@ const flowOptions = ref({
|
||||
snapToGrid: true,
|
||||
snapGrid: [15, 15],
|
||||
edgeUpdaterRadius: 10,
|
||||
connectionMode: 'loose',
|
||||
connectionMode: 'strict',
|
||||
connectionRadius: 25,
|
||||
elevateEdgesOnSelect: true,
|
||||
nodesDraggable: true,
|
||||
@ -282,7 +282,7 @@ watch(() => [props.initialEdges, nodes.value.length], async ([newEdges, nodeCoun
|
||||
if (edgesToAdd.length > 0) {
|
||||
// Ensure all edges have proper handle specifications
|
||||
const edgesWithHandles = edgesToAdd.map(edge => {
|
||||
// If edge already has sourceHandle and targetHandle, use them
|
||||
// IMPORTANT: If edge already has sourceHandle and targetHandle, preserve them exactly as they are
|
||||
if (edge.sourceHandle && edge.targetHandle) {
|
||||
return edge;
|
||||
}
|
||||
@ -325,23 +325,22 @@ watch(() => [props.initialEdges, nodes.value.length], async ([newEdges, nodeCoun
|
||||
// console.log('ProcessFlowCanvas: Successfully added edges with handles:', edgesWithHandles.length);
|
||||
}
|
||||
|
||||
// Update existing edges that have changed
|
||||
// Update existing edges that have changed - IMPORTANT: preserve handle positions
|
||||
newEdges.forEach(newEdge => {
|
||||
const existingEdge = edges.value.find(e => e.id === newEdge.id);
|
||||
if (existingEdge) {
|
||||
// Check if the edge has actually changed before updating
|
||||
const hasChanges = (
|
||||
existingEdge.label !== newEdge.label ||
|
||||
existingEdge.sourceHandle !== newEdge.sourceHandle ||
|
||||
existingEdge.targetHandle !== newEdge.targetHandle ||
|
||||
JSON.stringify(existingEdge.style) !== JSON.stringify(newEdge.style)
|
||||
);
|
||||
|
||||
if (hasChanges) {
|
||||
Object.assign(existingEdge, {
|
||||
label: newEdge.label,
|
||||
sourceHandle: newEdge.sourceHandle,
|
||||
targetHandle: newEdge.targetHandle,
|
||||
// Preserve existing handles if they exist
|
||||
sourceHandle: existingEdge.sourceHandle || newEdge.sourceHandle,
|
||||
targetHandle: existingEdge.targetHandle || newEdge.targetHandle,
|
||||
style: newEdge.style ? { ...newEdge.style } : undefined
|
||||
});
|
||||
}
|
||||
|
113
error.vue
113
error.vue
@ -1,68 +1,85 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
title: "Error Page",
|
||||
layout: "empty",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
error: Object,
|
||||
error: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// console.log("props", props);
|
||||
|
||||
const redirectClearError = () => {
|
||||
clearError({ redirect: "/" });
|
||||
const isDynamicRouteError = computed(() => {
|
||||
const path = window?.location?.pathname || '';
|
||||
return path.includes('/[') || path.includes(']');
|
||||
});
|
||||
|
||||
const handleError = () => {
|
||||
if (props.error.statusCode === 404 || isDynamicRouteError.value) {
|
||||
return navigateTo('/');
|
||||
}
|
||||
|
||||
clearError({ redirect: window.location.pathname });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex h-screen p-6 md:p-10"
|
||||
v-if="props.error.statusCode === 404"
|
||||
>
|
||||
<div class="m-auto">
|
||||
<div class="flex items-center flex-col md:flex-row gap-10">
|
||||
<img
|
||||
class="w-80 flex-shrink"
|
||||
src="@/assets/img/illustration/404-2.svg"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex-1 text-center md:text-left items-center">
|
||||
<span class="block mb-2 font-bold text-2xl md:text-3xl">Oops!</span>
|
||||
<p class="text-lg md:text-xl font-base">
|
||||
The page you are looking for does not exist.
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="redirectClearError"
|
||||
class="mt-5 w-fit rounded-lg flex justify-center items-center h-fit text-sm px-8 py-2.5 text-white bg-[#212E3B] hover:bg-[#212E3B]/90 disabled:bg-[#212E3B]/30 disabled:text-[#212E3B]/50 disabled:border-primary/5 disabled:cursor-default"
|
||||
>
|
||||
Back to Home
|
||||
</button>
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<img src="@/assets/img/logo/logo-word-black.svg" alt="Corrad Logo" class="h-12 mx-auto" />
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
{{ error.statusCode === 404 ? 'Page not found' : 'An error occurred' }}
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
{{ error.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||
<div v-if="isDynamicRouteError" class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Invalid URL Format</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
It looks like you're trying to access a page with a placeholder in the URL.
|
||||
Dynamic routes like <code class="bg-gray-100 px-1 py-0.5 rounded">[id]</code> need to be
|
||||
replaced with actual values.
|
||||
</p>
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="heroicons:information-circle" class="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
Instead of using <strong>/execution/form/[id]</strong>, you should use a specific ID, like
|
||||
<strong>/execution/form/123</strong> or navigate from the case list page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-screen p-6 md:p-10"
|
||||
v-else-if="props.error.statusCode === 500"
|
||||
>
|
||||
<div class="m-auto">
|
||||
<div class="flex items-center flex-col md:flex-row gap-10">
|
||||
<img
|
||||
class="w-80 flex-shrink"
|
||||
src="@/assets/img/illustration/500.svg"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex-1 text-center md:text-left items-center">
|
||||
<span class="block mb-2 font-bold text-2xl md:text-3xl"
|
||||
>Oops, something went wrong.
|
||||
</span>
|
||||
<p class="text-lg md:text-xl font-base">
|
||||
Please try again later or contact us if the problem persists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-4">
|
||||
<button
|
||||
@click="handleError"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
{{ error.statusCode === 404 ? 'Go to homepage' : 'Try again' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="error.statusCode !== 404"
|
||||
@click="navigateTo('/')"
|
||||
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Go to homepage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
360
pages/execution/form/[id].vue
Normal file
360
pages/execution/form/[id].vue
Normal file
@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<p class="text-lg font-bold mb-4">{{ caseInstance.caseName }}</p>
|
||||
|
||||
<div v-if="loading" class="text-blue-500">Loading...</div>
|
||||
<div v-if="error" class="text-red-500">{{ error }}</div>
|
||||
|
||||
<div v-if="forms.length > 0">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 mb-6">
|
||||
<nav class="flex -mb-px">
|
||||
<button
|
||||
v-for="(form, index) in forms"
|
||||
:key="index"
|
||||
@click="activeTabIndex = index"
|
||||
class="py-4 px-6 font-medium text-sm border-b-2 whitespace-nowrap"
|
||||
:class="[
|
||||
activeTabIndex === index
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center mr-2"
|
||||
:class="{ 'bg-primary text-white': activeTabIndex === index }">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
{{ form.formName || `Form ${index + 1}` }}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div v-for="(form, index) in forms" :key="`content-${index}`" v-show="activeTabIndex === index">
|
||||
<h2 class="text-xl font-semibold mb-4">{{ form.formName || `Form ${index + 1}` }}</h2>
|
||||
<p class="text-gray-600 mb-4">{{ form.description || 'Please complete this form step' }}</p>
|
||||
|
||||
<!-- Form content -->
|
||||
<FormKit
|
||||
type="form"
|
||||
:id="`form-${form.formID}`"
|
||||
v-model="formData[index]"
|
||||
@submit="handleSubmit(index)"
|
||||
:actions="false"
|
||||
:incomplete-message="false"
|
||||
validation-visibility="submit"
|
||||
>
|
||||
<div class="grid-preview-container">
|
||||
<template v-if="form.formComponents && form.formComponents.length > 0">
|
||||
<div
|
||||
v-for="(component, compIndex) in form.formComponents"
|
||||
:key="`component-${compIndex}`"
|
||||
:style="{
|
||||
gridColumn: component.props?.gridColumn || 'span 12'
|
||||
}"
|
||||
>
|
||||
<!-- Standard FormKit inputs -->
|
||||
<FormKit
|
||||
v-if="isStandardInput(component.type)"
|
||||
:type="component.type"
|
||||
:name="component.props?.name"
|
||||
:label="component.props?.label"
|
||||
:help="component.props?.help"
|
||||
:placeholder="component.props?.placeholder"
|
||||
:validation="component.props?.validation"
|
||||
:options="component.props?.options"
|
||||
:value="component.props?.value"
|
||||
:class="component.props?.width ? `w-${component.props.width}` : 'w-full'"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Heading -->
|
||||
<div v-else-if="component.type === 'heading'" class="py-2 mb-4">
|
||||
<component
|
||||
:is="`h${component.props?.level || 2}`"
|
||||
class="font-semibold"
|
||||
:class="{
|
||||
'text-2xl': component.props?.level === 2,
|
||||
'text-xl': component.props?.level === 3,
|
||||
'text-lg': component.props?.level === 4
|
||||
}"
|
||||
>
|
||||
{{ component.props?.value || 'Heading Text' }}
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<!-- Paragraph -->
|
||||
<div v-else-if="component.type === 'paragraph'" class="py-2 mb-4">
|
||||
<p class="text-gray-600">{{ component.props?.value || 'Paragraph text goes here' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div v-else-if="component.type === 'divider'" class="py-2 mb-4">
|
||||
<hr class="border-t border-gray-200">
|
||||
</div>
|
||||
|
||||
<!-- Info Display -->
|
||||
<div v-else-if="component.type === 'info-display'" class="mb-4">
|
||||
<div
|
||||
class="p-4 rounded-lg"
|
||||
:class="{ 'border': component.props?.showBorder }"
|
||||
:style="{ backgroundColor: component.props?.backgroundColor || '#f8fafc' }"
|
||||
>
|
||||
<h3 class="font-medium mb-2">{{ component.props?.title || 'Information' }}</h3>
|
||||
<div
|
||||
:class="{
|
||||
'grid grid-cols-2 gap-4': component.props?.layout === 'grid',
|
||||
'flex flex-col space-y-2': component.props?.layout === 'vertical' || !component.props?.layout,
|
||||
'flex flex-row flex-wrap gap-4': component.props?.layout === 'horizontal'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(field, fieldIndex) in component.props?.fields"
|
||||
:key="`field-${fieldIndex}`"
|
||||
class="text-sm"
|
||||
>
|
||||
<span class="text-gray-600">{{ field.label }}:</span>
|
||||
<span class="ml-2 font-medium">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button -->
|
||||
<div v-else-if="component.type === 'button'" class="py-2 mb-4">
|
||||
<RsButton
|
||||
:type="component.props?.buttonType || 'button'"
|
||||
:variant="component.props?.variant || 'primary'"
|
||||
:size="component.props?.size || 'md'"
|
||||
:disabled="component.props?.disabled || false"
|
||||
>
|
||||
{{ component.props?.label || 'Button' }}
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
No form components found.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit button if not already included in the form -->
|
||||
<!-- <FormKit
|
||||
v-if="!hasSubmitButton(form)"
|
||||
type="submit"
|
||||
label="Submit"
|
||||
:disabled="submitting"
|
||||
:classes="{
|
||||
input: submitting ? 'opacity-75 cursor-wait' : ''
|
||||
}"
|
||||
class="mt-6"
|
||||
/> -->
|
||||
<div v-if="submitting" class="text-center mt-2 text-sm text-blue-500">
|
||||
Submitting form...
|
||||
</div>
|
||||
</FormKit>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="flex justify-between mt-6">
|
||||
<button
|
||||
@click="prevStep"
|
||||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
:disabled="activeTabIndex === 0"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': activeTabIndex === 0 }"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextStep"
|
||||
class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark"
|
||||
:disabled="activeTabIndex === forms.length - 1"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': activeTabIndex === forms.length - 1 }"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading && !error">
|
||||
<p class="text-gray-500">No forms available.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import RsButton from '~/components/RsButton.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const route = useRoute()
|
||||
const forms = ref([])
|
||||
const caseInstance = ref([])
|
||||
const activeTabIndex = ref(0)
|
||||
const formData = ref([])
|
||||
const submitting = ref(false)
|
||||
|
||||
// Computed property for current form
|
||||
const currentForm = computed(() => {
|
||||
return forms.value[activeTabIndex.value] || null
|
||||
})
|
||||
|
||||
// Navigation methods
|
||||
const nextStep = () => {
|
||||
if (activeTabIndex.value < forms.value.length - 1) {
|
||||
activeTabIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (activeTabIndex.value > 0) {
|
||||
activeTabIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
// Check if component type is a standard FormKit input
|
||||
const isStandardInput = (type) => {
|
||||
const standardInputs = [
|
||||
'text', 'email', 'password', 'number', 'tel', 'url',
|
||||
'textarea', 'select', 'checkbox', 'radio', 'date',
|
||||
'time', 'datetime-local', 'file', 'color', 'range',
|
||||
'otp', 'mask', 'dropzone', 'switch'
|
||||
]
|
||||
return standardInputs.includes(type)
|
||||
}
|
||||
|
||||
// Check if form has a submit button component
|
||||
const hasSubmitButton = (form) => {
|
||||
if (!form.formComponents) return false
|
||||
return form.formComponents.some(comp =>
|
||||
comp.type === 'button' &&
|
||||
comp.props?.buttonType === 'submit'
|
||||
)
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (formIndex) => {
|
||||
try {
|
||||
submitting.value = true
|
||||
console.log(`Form ${formIndex + 1} submitted:`, formData.value[formIndex])
|
||||
|
||||
// Example submission logic - replace with your actual API endpoint
|
||||
const response = await $fetch(`/api/cases/${route.params.id}/forms/${forms.value[formIndex].formID}/submit`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
formData: formData.value[formIndex]
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
// Move to next form if available
|
||||
if (formIndex < forms.value.length - 1) {
|
||||
activeTabIndex.value = formIndex + 1
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.error || 'Form submission failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error submitting form:', err)
|
||||
error.value = err.message || 'Failed to submit form'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
const fetchCaseInstance = async (caseId) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
// Fetch case instance and all related forms using the API endpoint
|
||||
const response = await $fetch(`/api/cases/${caseId}/forms`);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to load case instance and forms');
|
||||
} else {
|
||||
caseInstance.value = response.caseInstance
|
||||
forms.value = response.forms
|
||||
console.log(response.forms)
|
||||
|
||||
// Initialize formData array with empty objects for each form
|
||||
formData.value = forms.value.map(() => ({}))
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching case instance and forms:', err);
|
||||
error.value = err.message || 'Failed to load forms';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
const caseId = route.params.id;
|
||||
if (caseId) {
|
||||
fetchCaseInstance(caseId);
|
||||
} else {
|
||||
error.value = 'No case ID provided';
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-preview-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid-preview-container > div {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
/* Apply width classes */
|
||||
.w-25 { width: 25%; }
|
||||
.w-33 { width: 33.333%; }
|
||||
.w-50 { width: 50%; }
|
||||
.w-66 { width: 66.666%; }
|
||||
.w-75 { width: 75%; }
|
||||
.w-100 { width: 100%; }
|
||||
|
||||
/* Match form-builder styling */
|
||||
:deep(.formkit-outer) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
:deep(.formkit-label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
:deep(.formkit-input) {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #fff;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
:deep(.formkit-input:focus) {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 1px #3b82f6;
|
||||
}
|
||||
|
||||
:deep(.formkit-help) {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
@ -26,14 +26,24 @@
|
||||
</div>
|
||||
</rs-card>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-12">
|
||||
<rs-progress-bar :indeterminate="true" class="w-64" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<rs-alert v-if="error" variant="danger" class="mb-6">
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
|
||||
<!-- Process Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-if="!loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<rs-card
|
||||
v-for="process in filteredProcesses"
|
||||
:key="process.id"
|
||||
class="overflow-hidden hover:shadow-md transition-shadow duration-300"
|
||||
>
|
||||
<div :class="`h-3 bg-${process.color}-500`"></div>
|
||||
<div :class="`h-3 bg-${getProcessColor(process.category)}-500`"></div>
|
||||
<template #body>
|
||||
<div class="p-5">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
@ -57,14 +67,14 @@
|
||||
class="text-base mr-1"
|
||||
name="material-symbols:schedule"
|
||||
></Icon>
|
||||
<span>Average duration: {{ process.duration }}</span>
|
||||
<span>Created: {{ formatDate(process.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Icon
|
||||
class="text-base mr-1"
|
||||
name="material-symbols:sync"
|
||||
></Icon>
|
||||
<span>{{ process.steps }} steps</span>
|
||||
<span>Status: {{ process.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
@ -79,11 +89,21 @@
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredProcesses.length === 0 && !loading" class="col-span-3 flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<Icon name="material-symbols:category-outline" class="w-16 h-16 mb-4 text-gray-300" />
|
||||
<p class="text-base font-medium">No processes found</p>
|
||||
<p class="text-sm mt-1">Try selecting a different category or search term</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
definePageMeta({
|
||||
title: "Start New Case",
|
||||
layout: "default",
|
||||
@ -91,61 +111,65 @@ definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
// Mock data for processes
|
||||
const processes = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: "Purchase Order Approval",
|
||||
description:
|
||||
"Process for approving purchase orders submitted by departments.",
|
||||
category: "Finance",
|
||||
duration: "3 days",
|
||||
steps: 10,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Leave Request",
|
||||
description:
|
||||
"Process for submitting and approving employee leave requests.",
|
||||
category: "HR",
|
||||
duration: "1 day",
|
||||
steps: 5,
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Budget Request",
|
||||
description:
|
||||
"Process for requesting and approving department budget allocations.",
|
||||
category: "Finance",
|
||||
duration: "7 days",
|
||||
steps: 12,
|
||||
color: "purple",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "IT Service Request",
|
||||
description:
|
||||
"Process for submitting and handling IT service and support requests.",
|
||||
category: "Operations",
|
||||
duration: "2 days",
|
||||
steps: 7,
|
||||
color: "yellow",
|
||||
},
|
||||
]);
|
||||
// Data loading states
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const processes = ref([]);
|
||||
|
||||
// Search and filter
|
||||
const searchQuery = ref("");
|
||||
const selectedCategory = ref("");
|
||||
const categoryOptions = ref([]);
|
||||
|
||||
// Categories for filter
|
||||
const categories = [
|
||||
{ value: "", label: "All Categories" },
|
||||
{ value: "HR", label: "Human Resources" },
|
||||
{ value: "Finance", label: "Finance" },
|
||||
{ value: "Operations", label: "Operations" },
|
||||
];
|
||||
// Fetch processes from database
|
||||
const fetchProcesses = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
// Only fetch published processes that are not templates
|
||||
const response = await $fetch('/api/process', {
|
||||
params: {
|
||||
status: 'published',
|
||||
isTemplate: false
|
||||
}
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
processes.value = response.data.processes.map(process => ({
|
||||
id: process.processUUID,
|
||||
name: process.processName,
|
||||
description: process.processDescription,
|
||||
category: process.processCategory || 'Uncategorized',
|
||||
status: process.processStatus,
|
||||
createdAt: process.processCreatedDate,
|
||||
updatedAt: process.processModifiedDate
|
||||
}));
|
||||
|
||||
// Extract unique categories from processes
|
||||
const uniqueCategories = [...new Set(processes.value.map(p => p.category))].sort();
|
||||
categoryOptions.value = [
|
||||
{ value: "", label: "All Categories" },
|
||||
...uniqueCategories.map(category => ({
|
||||
value: category,
|
||||
label: category
|
||||
}))
|
||||
];
|
||||
} else {
|
||||
error.value = response.error || 'Failed to fetch processes';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching processes:', err);
|
||||
error.value = 'An error occurred while fetching processes';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Computed categories for filter dropdown
|
||||
const categories = computed(() => {
|
||||
return categoryOptions.value;
|
||||
});
|
||||
|
||||
// Filtered processes
|
||||
const filteredProcesses = computed(() => {
|
||||
@ -165,9 +189,62 @@ const filteredProcesses = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Start a process
|
||||
const startProcess = (processId) => {
|
||||
// Logic to start a process would go here
|
||||
console.log("Starting process:", processId);
|
||||
// Get color based on category
|
||||
const getProcessColor = (category) => {
|
||||
const colorMap = {
|
||||
'HR': 'green',
|
||||
'Finance': 'blue',
|
||||
'Operations': 'yellow',
|
||||
'IT': 'purple',
|
||||
'Legal': 'red',
|
||||
'Marketing': 'indigo',
|
||||
'Sales': 'orange'
|
||||
};
|
||||
|
||||
return colorMap[category] || 'gray';
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Start a process
|
||||
const startProcess = async (processId) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
// Start the process
|
||||
const response = await $fetch(`/api/process/${processId}/start`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Navigate to the first task
|
||||
if (response.data.case) {
|
||||
router.push(`/execution/form/${response.data.case.id}`);
|
||||
} else {
|
||||
error.value = 'No tasks found in the process';
|
||||
}
|
||||
} else {
|
||||
console.error('Process start failed:', response);
|
||||
error.value = response.error || 'Failed to start process. Please try again.';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error starting process:', err);
|
||||
error.value = err.message || 'An error occurred while starting the process. Please try again.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch processes on component mount
|
||||
onMounted(() => {
|
||||
fetchProcesses();
|
||||
});
|
||||
</script>
|
||||
|
@ -183,6 +183,24 @@
|
||||
>
|
||||
<h2 class="text-sm font-medium text-gray-700">{{ isPreview ? 'Form Preview' : 'Form Canvas' }}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Device Preview Controls (only show in preview mode) -->
|
||||
<div v-if="isPreview" class="flex items-center bg-white border border-gray-200 rounded-lg p-1">
|
||||
<button
|
||||
v-for="device in deviceSizes"
|
||||
:key="device.name"
|
||||
@click="selectedDevice = device.name"
|
||||
class="px-3 py-1 text-xs font-medium rounded transition-colors flex items-center space-x-1"
|
||||
:class="{
|
||||
'bg-blue-500 text-white': selectedDevice === device.name,
|
||||
'text-gray-600 hover:text-gray-800 hover:bg-gray-100': selectedDevice !== device.name
|
||||
}"
|
||||
:title="`Preview on ${device.name} (${device.width}px)`"
|
||||
>
|
||||
<Icon :name="device.icon" class="w-3 h-3" />
|
||||
<span>{{ device.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ isPreview ? 'Preview mode - Form is interactive' : 'Drag components to build your form' }}
|
||||
</span>
|
||||
@ -204,48 +222,87 @@
|
||||
>
|
||||
<!-- Preview Mode -->
|
||||
<div v-if="isPreview" class="form-container">
|
||||
<FormScriptEngine
|
||||
ref="formScriptEngine"
|
||||
:form-data="previewFormData"
|
||||
:custom-script="formStore.formCustomScript"
|
||||
:custom-css="formStore.formCustomCSS"
|
||||
:form-events="formStore.formEvents"
|
||||
:script-mode="formStore.scriptMode"
|
||||
@field-change="handleScriptFieldChange"
|
||||
@field-validate="handleScriptFieldValidate"
|
||||
/>
|
||||
|
||||
<ConditionalLogicEngine
|
||||
ref="conditionalLogicEngine"
|
||||
:form-components="formStore.formComponents"
|
||||
:form-data="previewFormData"
|
||||
@script-generated="handleConditionalLogicGenerated"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
ref="previewForm"
|
||||
type="form"
|
||||
@submit="handlePreviewSubmit"
|
||||
:actions="false"
|
||||
v-model="previewFormData"
|
||||
<!-- Device Frame Container -->
|
||||
<div
|
||||
class="device-preview-container mx-auto"
|
||||
:class="[
|
||||
currentDeviceConfig.class,
|
||||
{
|
||||
'device-frame': selectedDevice !== 'Desktop'
|
||||
}
|
||||
]"
|
||||
:style="{
|
||||
maxWidth: selectedDevice === 'Desktop' ? '100%' : `${currentDeviceConfig.width}px`,
|
||||
height: selectedDevice === 'Desktop' ? 'auto' : `${currentDeviceConfig.height}px`
|
||||
}"
|
||||
>
|
||||
<div class="grid-preview-container">
|
||||
<template
|
||||
v-for="(component, index) in formStore.formComponents"
|
||||
:key="index"
|
||||
<!-- Device Screen -->
|
||||
<div
|
||||
class="device-screen"
|
||||
:class="{
|
||||
'overflow-y-auto': selectedDevice !== 'Desktop',
|
||||
'bg-white rounded-lg shadow-xl border': selectedDevice !== 'Desktop',
|
||||
'scrollable-device': selectedDevice !== 'Desktop'
|
||||
}"
|
||||
:style="{
|
||||
height: selectedDevice === 'Desktop' ? 'auto' : '100%',
|
||||
overflowY: selectedDevice === 'Desktop' ? 'visible' : 'auto'
|
||||
}"
|
||||
>
|
||||
<FormScriptEngine
|
||||
ref="formScriptEngine"
|
||||
:form-data="previewFormData"
|
||||
:custom-script="formStore.formCustomScript"
|
||||
:custom-css="formStore.formCustomCSS"
|
||||
:form-events="formStore.formEvents"
|
||||
:script-mode="formStore.scriptMode"
|
||||
@field-change="handleScriptFieldChange"
|
||||
@field-validate="handleScriptFieldValidate"
|
||||
/>
|
||||
|
||||
<ConditionalLogicEngine
|
||||
ref="conditionalLogicEngine"
|
||||
:form-components="formStore.formComponents"
|
||||
:form-data="previewFormData"
|
||||
@script-generated="handleConditionalLogicGenerated"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
ref="previewForm"
|
||||
type="form"
|
||||
@submit="handlePreviewSubmit"
|
||||
:actions="false"
|
||||
v-model="previewFormData"
|
||||
>
|
||||
<component-preview :component="component" :is-preview="false" />
|
||||
</template>
|
||||
<div
|
||||
class="grid-preview-container"
|
||||
:class="{
|
||||
'p-4': selectedDevice !== 'Desktop',
|
||||
'p-6': selectedDevice === 'Desktop'
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-for="(component, index) in formStore.formComponents"
|
||||
:key="index"
|
||||
>
|
||||
<component-preview :component="component" :is-preview="false" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Only show submit button if no submit button components exist in the form -->
|
||||
<FormKit
|
||||
v-if="!formStore.formComponents.some(comp => comp.type === 'button' && comp.props.buttonType === 'submit')"
|
||||
type="submit"
|
||||
label="Submit"
|
||||
class="form-submit mt-6"
|
||||
:class="{
|
||||
'mx-4 mb-4': selectedDevice !== 'Desktop',
|
||||
'mx-0 mb-0': selectedDevice === 'Desktop'
|
||||
}"
|
||||
/>
|
||||
</FormKit>
|
||||
</div>
|
||||
|
||||
<!-- Only show submit button if no submit button components exist in the form -->
|
||||
<FormKit
|
||||
v-if="!formStore.formComponents.some(comp => comp.type === 'button' && comp.props.buttonType === 'submit')"
|
||||
type="submit"
|
||||
label="Submit"
|
||||
class="form-submit mt-6"
|
||||
/>
|
||||
</FormKit>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
@ -253,6 +310,7 @@
|
||||
<FormBuilderCanvas
|
||||
:form-components="formStore.formComponents"
|
||||
@select-component="handleSelectComponent"
|
||||
@select-nested-component="handleSelectNestedComponent"
|
||||
@move-component="handleMoveComponent"
|
||||
@delete-component="handleDeleteComponent"
|
||||
@update-component="handleUpdateComponent"
|
||||
@ -916,20 +974,51 @@ try {
|
||||
};
|
||||
}
|
||||
|
||||
const showPreview = ref(false);
|
||||
// Reactive state
|
||||
const isPreview = ref(false);
|
||||
const showFormSettings = ref(false);
|
||||
const showTemplatesModal = ref(false);
|
||||
const showFormHistoryModal = ref(false);
|
||||
const showDropdown = ref(false);
|
||||
const leftSidebarTab = ref('components');
|
||||
const showFieldSettingsPanel = ref(true);
|
||||
const showFieldSettings = ref(false);
|
||||
const showUnsavedChangesModal = ref(false);
|
||||
const navigationConfirmed = ref(false);
|
||||
const pendingNavigation = ref(null);
|
||||
const navigationTarget = ref(null);
|
||||
const navigationConfirmed = ref(false);
|
||||
const leftSidebarTab = ref('components');
|
||||
const showFormSettings = ref(false);
|
||||
const activeSettingsTab = ref('info');
|
||||
const isPreview = ref(false);
|
||||
const showDropdown = ref(false);
|
||||
const showTemplatesModal = ref(false);
|
||||
const showFieldSettings = ref(false);
|
||||
const showFieldSettingsPanel = ref(false);
|
||||
const showFormHistoryModal = ref(false);
|
||||
|
||||
// Responsive device preview state
|
||||
const selectedDevice = ref('Desktop');
|
||||
const deviceSizes = ref([
|
||||
{
|
||||
name: 'Mobile',
|
||||
width: 375,
|
||||
height: 667,
|
||||
icon: 'material-symbols:smartphone',
|
||||
class: 'device-mobile'
|
||||
},
|
||||
{
|
||||
name: 'Tablet',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
icon: 'material-symbols:tablet',
|
||||
class: 'device-tablet'
|
||||
},
|
||||
{
|
||||
name: 'Desktop',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
icon: 'material-symbols:desktop-windows',
|
||||
class: 'device-desktop'
|
||||
}
|
||||
]);
|
||||
|
||||
const currentDeviceConfig = computed(() => {
|
||||
return deviceSizes.value.find(device => device.name === selectedDevice.value) || deviceSizes.value[2];
|
||||
});
|
||||
|
||||
const previewForm = ref(null);
|
||||
const formScriptEngine = ref(null);
|
||||
const conditionalLogicEngine = ref(null);
|
||||
@ -1326,43 +1415,43 @@ const handleJsonImport = (event) => {
|
||||
// Ensure all required info display properties
|
||||
processedProps = {
|
||||
title: processedProps.title || 'Information',
|
||||
name: processedProps.name || `info_display_${index + 1}`,
|
||||
help: processedProps.help || '',
|
||||
layout: processedProps.layout || 'vertical',
|
||||
showBorder: processedProps.showBorder !== undefined ? processedProps.showBorder : true,
|
||||
backgroundColor: processedProps.backgroundColor || '#f8fafc',
|
||||
fields: Array.isArray(processedProps.fields) ? processedProps.fields : [
|
||||
name: processedDefaultProps.name || `info_display_${index + 1}`,
|
||||
help: processedDefaultProps.help || '',
|
||||
layout: processedDefaultProps.layout || 'vertical',
|
||||
showBorder: processedDefaultProps.showBorder !== undefined ? processedDefaultProps.showBorder : true,
|
||||
backgroundColor: processedDefaultProps.backgroundColor || '#f8fafc',
|
||||
fields: Array.isArray(processedDefaultProps.fields) ? processedDefaultProps.fields : [
|
||||
{ label: 'Info Item', value: 'Value', key: 'item_1' }
|
||||
],
|
||||
...processedProps
|
||||
...processedDefaultProps
|
||||
};
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
// Ensure all required file upload properties
|
||||
processedProps = {
|
||||
label: processedProps.label || 'File Upload',
|
||||
name: processedProps.name || `file_upload_${index + 1}`,
|
||||
help: processedProps.help || 'Upload a file',
|
||||
accept: processedProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
|
||||
...processedProps
|
||||
label: processedDefaultProps.label || 'File Upload',
|
||||
name: processedDefaultProps.name || `file_upload_${index + 1}`,
|
||||
help: processedDefaultProps.help || 'Upload a file',
|
||||
accept: processedDefaultProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
|
||||
...processedDefaultProps
|
||||
};
|
||||
break;
|
||||
|
||||
case 'heading':
|
||||
// Ensure all required heading properties
|
||||
processedProps = {
|
||||
value: processedProps.value || 'Heading',
|
||||
level: processedProps.level || 2,
|
||||
...processedProps
|
||||
value: processedDefaultProps.value || 'Heading',
|
||||
level: processedDefaultProps.level || 2,
|
||||
...processedDefaultProps
|
||||
};
|
||||
break;
|
||||
|
||||
case 'paragraph':
|
||||
// Ensure all required paragraph properties
|
||||
processedProps = {
|
||||
value: processedProps.value || 'Paragraph text',
|
||||
...processedProps
|
||||
value: processedDefaultProps.value || 'Paragraph text',
|
||||
...processedDefaultProps
|
||||
};
|
||||
break;
|
||||
|
||||
@ -1370,43 +1459,42 @@ const handleJsonImport = (event) => {
|
||||
case 'radio':
|
||||
case 'checkbox':
|
||||
// Ensure options array exists
|
||||
if (!Array.isArray(processedProps.options) || processedProps.options.length === 0) {
|
||||
processedProps.options = [
|
||||
if (!Array.isArray(processedDefaultProps.options) || processedDefaultProps.options.length === 0) {
|
||||
processedDefaultProps.options = [
|
||||
{ label: 'Option 1', value: 'option_1' },
|
||||
{ label: 'Option 2', value: 'option_2' }
|
||||
];
|
||||
}
|
||||
break;
|
||||
// Fall through for other basic properties
|
||||
|
||||
default:
|
||||
// Basic properties for all other component types
|
||||
if (!processedProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||||
processedProps.label = component.type.charAt(0).toUpperCase() + component.type.slice(1) + ' ' + (index + 1);
|
||||
if (!processedDefaultProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||||
processedDefaultProps.label = component.name || component.type.charAt(0).toUpperCase() + component.type.slice(1);
|
||||
}
|
||||
|
||||
if (!processedProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||||
processedProps.name = `${component.type}_${index + 1}`;
|
||||
if (!processedDefaultProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
|
||||
processedDefaultProps.name = `${component.type}_${index + 1}`;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Create component in expected format
|
||||
const formattedComponent = {
|
||||
type: component.type,
|
||||
name: processedProps.label || component.type,
|
||||
category: getDefaultCategory(component.type),
|
||||
icon: getDefaultIcon(component.type),
|
||||
// Use the processed default props
|
||||
defaultProps: processedProps
|
||||
};
|
||||
|
||||
console.log('Formatted component:', formattedComponent);
|
||||
|
||||
// Add component to form
|
||||
formStore.addComponent(formattedComponent);
|
||||
} else {
|
||||
console.warn('Skipping invalid component:', component);
|
||||
}
|
||||
|
||||
// Create a component in the expected format for addComponent
|
||||
const formattedComponent = {
|
||||
type: component.type,
|
||||
name: component.name || component.type,
|
||||
category: getDefaultCategory(component.type),
|
||||
icon: getDefaultIcon(component.type),
|
||||
// Use the processed default props
|
||||
defaultProps: processedDefaultProps
|
||||
};
|
||||
|
||||
console.log('Formatted component:', formattedComponent);
|
||||
|
||||
// Add the component to the form
|
||||
formStore.addComponent(formattedComponent);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1567,9 +1655,48 @@ const handleSelectComponent = (component) => {
|
||||
formStore.selectComponent(component.id);
|
||||
};
|
||||
|
||||
const handleSelectNestedComponent = (nestedComponent) => {
|
||||
if (!nestedComponent || !nestedComponent.id) return;
|
||||
|
||||
// For nested components, we need to handle the selection differently
|
||||
// since they're not in the main formComponents array but inside section children
|
||||
formStore.selectedComponent = nestedComponent;
|
||||
formStore.selectedComponentId = nestedComponent.id;
|
||||
|
||||
// Ensure the settings panel is open
|
||||
if (!showFieldSettingsPanel.value) {
|
||||
showFieldSettingsPanel.value = true;
|
||||
}
|
||||
|
||||
console.log('Selected nested component:', nestedComponent);
|
||||
};
|
||||
|
||||
const handleUpdateComponent = (updatedComponent) => {
|
||||
if (!updatedComponent || !updatedComponent.id) return;
|
||||
formStore.updateComponent(updatedComponent);
|
||||
|
||||
// Check if this is a nested component inside a section
|
||||
let foundInSection = false;
|
||||
|
||||
// Look for the component in section children
|
||||
formStore.formComponents.forEach(component => {
|
||||
if (component.type === 'form-section' && component.props.children) {
|
||||
const nestedIndex = component.props.children.findIndex(child => child.id === updatedComponent.id);
|
||||
if (nestedIndex !== -1) {
|
||||
// Update the nested component
|
||||
component.props.children[nestedIndex] = updatedComponent;
|
||||
// Update the entire section to trigger reactivity
|
||||
formStore.updateComponent(component);
|
||||
foundInSection = true;
|
||||
console.log('Updated nested component in section:', updatedComponent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If not found in sections, update as a main component
|
||||
if (!foundInSection) {
|
||||
formStore.updateComponent(updatedComponent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveComponent = ({ oldIndex, newIndex }) => {
|
||||
@ -1578,7 +1705,37 @@ const handleMoveComponent = ({ oldIndex, newIndex }) => {
|
||||
|
||||
const handleDeleteComponent = (id) => {
|
||||
if (!id) return;
|
||||
formStore.deleteComponent(id);
|
||||
|
||||
// Check if this is a nested component inside a section
|
||||
let foundInSection = false;
|
||||
|
||||
// Look for the component in section children
|
||||
formStore.formComponents.forEach(component => {
|
||||
if (component.type === 'form-section' && component.props.children) {
|
||||
const nestedIndex = component.props.children.findIndex(child => child.id === id);
|
||||
if (nestedIndex !== -1) {
|
||||
// Remove the nested component
|
||||
const deletedComponent = component.props.children.splice(nestedIndex, 1)[0];
|
||||
// Update the entire section to trigger reactivity
|
||||
formStore.updateComponent(component);
|
||||
foundInSection = true;
|
||||
|
||||
// Clear selection if the deleted component was selected
|
||||
if (formStore.selectedComponentId === id) {
|
||||
formStore.selectedComponent = null;
|
||||
formStore.selectedComponentId = null;
|
||||
}
|
||||
|
||||
console.log('Deleted nested component from section:', deletedComponent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If not found in sections, delete as a main component
|
||||
if (!foundInSection) {
|
||||
formStore.deleteComponent(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
@ -2796,4 +2953,145 @@ const handleFormRestored = (restoredForm) => {
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Device Preview Styles */
|
||||
.device-preview-container {
|
||||
transition: all 0.3s ease-in-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-frame {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
background: #1a1a1a;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-frame.device-mobile {
|
||||
border-radius: 25px;
|
||||
padding: 15px;
|
||||
background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
|
||||
max-height: 80vh; /* Ensure it fits in viewport */
|
||||
}
|
||||
|
||||
.device-frame.device-tablet {
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
|
||||
max-height: 85vh; /* Ensure it fits in viewport */
|
||||
}
|
||||
|
||||
.device-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-screen.scrollable-device {
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch; /* iOS smooth scrolling */
|
||||
}
|
||||
|
||||
/* Custom scrollbar for device screens */
|
||||
.device-screen.scrollable-device::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.device-screen.scrollable-device::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.device-screen.scrollable-device::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.device-screen.scrollable-device::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.device-mobile .device-screen {
|
||||
border-radius: 20px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.device-tablet .device-screen {
|
||||
border-radius: 15px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Device-specific responsive adjustments */
|
||||
.device-mobile .grid-preview-container {
|
||||
font-size: 14px;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.device-mobile .grid-preview-container .formkit-wrapper {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.device-tablet .grid-preview-container {
|
||||
font-size: 15px;
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
|
||||
/* Form content adjustments for devices */
|
||||
.device-mobile .form-submit,
|
||||
.device-tablet .form-submit {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Scroll fade indicators */
|
||||
.device-screen.scrollable-device::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 20px;
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.9), rgba(255,255,255,0));
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.device-screen.scrollable-device::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 20px;
|
||||
background: linear-gradient(to top, rgba(255,255,255,0.9), rgba(255,255,255,0));
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Responsive grid adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.device-preview-container {
|
||||
transform: scale(0.8);
|
||||
transform-origin: top center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.device-preview-container {
|
||||
transform: scale(0.6);
|
||||
transform-origin: top center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
28
plugins/route-guard.js
Normal file
28
plugins/route-guard.js
Normal file
@ -0,0 +1,28 @@
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hook('app:created', () => {
|
||||
// Check if we're on the client side
|
||||
if (process.client) {
|
||||
const router = useRouter();
|
||||
|
||||
// Add global navigation guard
|
||||
router.beforeEach((to, from, next) => {
|
||||
// Check if the route path contains literal square brackets
|
||||
// This indicates a user is trying to navigate to a route with [param] directly
|
||||
if (to.fullPath.includes('/[') || to.fullPath.includes(']')) {
|
||||
console.warn('Invalid route detected with literal brackets:', to.fullPath);
|
||||
|
||||
// Extract the route pattern without the brackets
|
||||
const baseRoute = to.fullPath.split('/').slice(0, -1).join('/');
|
||||
|
||||
// Redirect to a more appropriate page
|
||||
return next({
|
||||
path: baseRoute || '/',
|
||||
query: { error: 'invalid_route_format' }
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
@ -63,6 +63,18 @@
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"caseInstance": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/caseInstance"
|
||||
}
|
||||
},
|
||||
"caseTimeline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/caseTimeline"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -87,11 +99,35 @@
|
||||
"$ref": "#/definitions/processHistory"
|
||||
}
|
||||
},
|
||||
"task": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/task"
|
||||
}
|
||||
},
|
||||
"userrole": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/userrole"
|
||||
}
|
||||
},
|
||||
"startedCases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/caseInstance"
|
||||
}
|
||||
},
|
||||
"assignedTasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/task"
|
||||
}
|
||||
},
|
||||
"caseTimelineEntries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/caseTimeline"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -246,6 +282,12 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/formHistory"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/task"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -449,6 +491,12 @@
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"caseInstance": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/caseInstance"
|
||||
}
|
||||
},
|
||||
"creator": {
|
||||
"anyOf": [
|
||||
{
|
||||
@ -464,6 +512,12 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/processHistory"
|
||||
}
|
||||
},
|
||||
"cases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/caseInstance"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -582,6 +636,203 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"caseInstance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"caseID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"caseUUID": {
|
||||
"type": "string"
|
||||
},
|
||||
"caseName": {
|
||||
"type": "string"
|
||||
},
|
||||
"caseStatus": {
|
||||
"type": "string",
|
||||
"default": "active"
|
||||
},
|
||||
"caseVariables": {
|
||||
"type": [
|
||||
"number",
|
||||
"string",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"caseSettings": {
|
||||
"type": [
|
||||
"number",
|
||||
"string",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"caseDefinition": {
|
||||
"type": [
|
||||
"number",
|
||||
"string",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"caseCreatedDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"caseModifiedDate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"caseCompletedDate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"process": {
|
||||
"$ref": "#/definitions/process"
|
||||
},
|
||||
"startedBy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/task"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/caseTimeline"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"task": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"taskUUID": {
|
||||
"type": "string"
|
||||
},
|
||||
"taskName": {
|
||||
"type": "string"
|
||||
},
|
||||
"taskType": {
|
||||
"type": "string"
|
||||
},
|
||||
"taskStatus": {
|
||||
"type": "string",
|
||||
"default": "pending"
|
||||
},
|
||||
"taskData": {
|
||||
"type": [
|
||||
"number",
|
||||
"string",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"taskCreatedDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"taskModifiedDate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"taskCompletedDate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"case": {
|
||||
"$ref": "#/definitions/caseInstance"
|
||||
},
|
||||
"assignedTo": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"form": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/form"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"caseTimeline": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timelineID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timelineType": {
|
||||
"type": "string"
|
||||
},
|
||||
"timelineDescription": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timelineDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"case": {
|
||||
"$ref": "#/definitions/caseInstance"
|
||||
},
|
||||
"createdBy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@ -606,6 +857,15 @@
|
||||
},
|
||||
"processHistory": {
|
||||
"$ref": "#/definitions/processHistory"
|
||||
},
|
||||
"caseInstance": {
|
||||
"$ref": "#/definitions/caseInstance"
|
||||
},
|
||||
"task": {
|
||||
"$ref": "#/definitions/task"
|
||||
},
|
||||
"caseTimeline": {
|
||||
"$ref": "#/definitions/caseTimeline"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `audit` (
|
||||
`auditID` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`auditIP` VARCHAR(255) NULL,
|
||||
`auditURL` VARCHAR(255) NULL,
|
||||
`auditURLMethod` VARCHAR(255) NULL,
|
||||
`auditURLPayload` VARCHAR(255) NULL,
|
||||
`auditCreatedDate` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`auditID`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user` (
|
||||
`userID` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userSecretKey` VARCHAR(255) NULL,
|
||||
`userUsername` VARCHAR(255) NULL,
|
||||
`userPassword` VARCHAR(255) NULL,
|
||||
`userFullName` VARCHAR(255) NULL,
|
||||
`userEmail` VARCHAR(255) NULL,
|
||||
`userPhone` VARCHAR(255) NULL,
|
||||
`userStatus` VARCHAR(255) NULL,
|
||||
`userCreatedDate` DATETIME(0) NULL,
|
||||
`userModifiedDate` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`userID`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `role` (
|
||||
`roleID` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`roleName` VARCHAR(255) NULL,
|
||||
`roleDescription` VARCHAR(255) NULL,
|
||||
`roleStatus` VARCHAR(255) NULL,
|
||||
`roleCreatedDate` DATETIME(0) NULL,
|
||||
`roleModifiedDate` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`roleID`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `lookup` (
|
||||
`lookupID` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`lookupOrder` INTEGER NULL,
|
||||
`lookupTitle` VARCHAR(255) NULL,
|
||||
`lookupRefCode` VARCHAR(255) NULL,
|
||||
`lookupValue` VARCHAR(255) NULL,
|
||||
`lookupType` VARCHAR(255) NULL,
|
||||
`lookupStatus` VARCHAR(255) NULL,
|
||||
`lookupCreatedDate` DATETIME(0) NULL,
|
||||
`lookupModifiedDate` DATETIME(0) NULL,
|
||||
|
||||
PRIMARY KEY (`lookupID`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `userrole` (
|
||||
`userRoleID` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userRoleUserID` INTEGER NOT NULL DEFAULT 0,
|
||||
`userRoleRoleID` INTEGER NOT NULL DEFAULT 0,
|
||||
`userRoleCreatedDate` DATETIME(0) NOT NULL,
|
||||
|
||||
INDEX `FK_userrole_role`(`userRoleRoleID`),
|
||||
INDEX `FK_userrole_user`(`userRoleUserID`),
|
||||
PRIMARY KEY (`userRoleID`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `userrole` ADD CONSTRAINT `FK_userrole_role` FOREIGN KEY (`userRoleRoleID`) REFERENCES `role`(`roleID`) ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `userrole` ADD CONSTRAINT `FK_userrole_user` FOREIGN KEY (`userRoleUserID`) REFERENCES `user`(`userID`) ON DELETE NO ACTION ON UPDATE NO ACTION;
|
@ -0,0 +1,56 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `caseInstance` (
|
||||
`caseID` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`caseUUID` VARCHAR(36) NOT NULL,
|
||||
`processID` INTEGER NOT NULL,
|
||||
`caseName` VARCHAR(255) NOT NULL,
|
||||
`caseStatus` VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
`caseStartedBy` INTEGER NULL,
|
||||
`caseVariables` JSON NULL,
|
||||
`caseSettings` JSON NULL,
|
||||
`caseDefinition` JSON NULL,
|
||||
`caseCreatedDate` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`caseModifiedDate` DATETIME(0) NULL,
|
||||
`caseCompletedDate` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `caseInstance_caseUUID_key`(`caseUUID`),
|
||||
INDEX `FK_case_process`(`processID`),
|
||||
INDEX `FK_case_startedBy`(`caseStartedBy`),
|
||||
INDEX `IDX_case_status`(`caseStatus`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `task` (
|
||||
`taskID` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`taskUUID` VARCHAR(36) NOT NULL,
|
||||
`caseID` INTEGER NOT NULL,
|
||||
`taskName` VARCHAR(255) NOT NULL,
|
||||
`taskType` VARCHAR(50) NOT NULL,
|
||||
`taskStatus` VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
`taskAssignedTo` INTEGER NULL,
|
||||
`taskFormID` INTEGER NULL,
|
||||
`taskData` JSON NULL,
|
||||
`taskCreatedDate` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`taskModifiedDate` DATETIME(0) NULL,
|
||||
`taskCompletedDate` DATETIME(0) NULL,
|
||||
|
||||
UNIQUE INDEX `task_taskUUID_key`(`taskUUID`),
|
||||
INDEX `FK_task_case`(`caseID`),
|
||||
INDEX `FK_task_assignedTo`(`taskAssignedTo`),
|
||||
INDEX `FK_task_form`(`taskFormID`),
|
||||
INDEX `IDX_task_status`(`taskStatus`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `caseTimeline` (
|
||||
`timelineID` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`caseID` INTEGER NOT NULL,
|
||||
`timelineType` VARCHAR(50) NOT NULL,
|
||||
`timelineDescription` TEXT NULL,
|
||||
`timelineDate` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`timelineCreatedBy` INTEGER NULL,
|
||||
|
||||
INDEX `FK_caseTimeline_case`(`caseID`),
|
||||
INDEX `FK_caseTimeline_createdBy`(`timelineCreatedBy`),
|
||||
INDEX `IDX_caseTimeline_date`(`timelineDate`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
@ -0,0 +1,4 @@
|
||||
# This is an empty migration.
|
||||
|
||||
migration_name = "20240321000001_add_case_instance_and_task_tables_v2"
|
||||
migration_hash = "20240321000001_add_case_instance_and_task_tables_v2"
|
@ -23,11 +23,17 @@ model user {
|
||||
userStatus String? @db.VarChar(255)
|
||||
userCreatedDate DateTime? @db.DateTime(0)
|
||||
userModifiedDate DateTime? @db.DateTime(0)
|
||||
caseInstance caseInstance[]
|
||||
caseTimeline caseTimeline[]
|
||||
forms form[] @relation("FormCreator")
|
||||
formHistoryEntries formHistory[]
|
||||
processes process[] @relation("ProcessCreator")
|
||||
processHistoryEntries processHistory[]
|
||||
task task[]
|
||||
userrole userrole[]
|
||||
startedCases caseInstance[] @relation("CaseStartedBy")
|
||||
assignedTasks task[] @relation("TaskAssignedTo")
|
||||
caseTimelineEntries caseTimeline[]
|
||||
}
|
||||
|
||||
model role {
|
||||
@ -68,6 +74,7 @@ model form {
|
||||
scriptMode String? @default("safe") @db.VarChar(20)
|
||||
creator user? @relation("FormCreator", fields: [formCreatedBy], references: [userID])
|
||||
history formHistory[] @relation("FormHistoryEntries")
|
||||
tasks task[]
|
||||
|
||||
@@index([formCreatedBy], map: "FK_form_creator")
|
||||
}
|
||||
@ -117,8 +124,10 @@ model process {
|
||||
processVariables Json?
|
||||
templateCategory String? @db.VarChar(100)
|
||||
processDeletedDate DateTime? @db.DateTime(0)
|
||||
caseInstance caseInstance[]
|
||||
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
|
||||
history processHistory[] @relation("ProcessHistoryEntries")
|
||||
cases caseInstance[]
|
||||
|
||||
@@index([processCreatedBy], map: "FK_process_creator")
|
||||
@@index([processStatus], map: "IDX_process_status")
|
||||
@ -154,3 +163,64 @@ model processHistory {
|
||||
@@index([processUUID], map: "IDX_processHistory_uuid")
|
||||
@@index([savedDate], map: "IDX_processHistory_date")
|
||||
}
|
||||
|
||||
model caseInstance {
|
||||
caseID Int @id @default(autoincrement())
|
||||
caseUUID String @unique @db.VarChar(36)
|
||||
processID Int
|
||||
caseName String @db.VarChar(255)
|
||||
caseStatus String @default("active") @db.VarChar(50)
|
||||
caseStartedBy Int?
|
||||
caseVariables Json?
|
||||
caseSettings Json?
|
||||
caseDefinition Json?
|
||||
caseCreatedDate DateTime @default(now()) @db.DateTime(0)
|
||||
caseModifiedDate DateTime? @updatedAt @db.DateTime(0)
|
||||
caseCompletedDate DateTime? @db.DateTime(0)
|
||||
process process @relation(fields: [processID], references: [processID])
|
||||
startedBy user? @relation("CaseStartedBy", fields: [caseStartedBy], references: [userID])
|
||||
tasks task[]
|
||||
timeline caseTimeline[]
|
||||
|
||||
@@index([processID], map: "FK_case_process")
|
||||
@@index([caseStartedBy], map: "FK_case_startedBy")
|
||||
@@index([caseStatus], map: "IDX_case_status")
|
||||
}
|
||||
|
||||
model task {
|
||||
taskID Int @id @default(autoincrement())
|
||||
taskUUID String @unique @db.VarChar(36)
|
||||
caseID Int
|
||||
taskName String @db.VarChar(255)
|
||||
taskType String @db.VarChar(50)
|
||||
taskStatus String @default("pending") @db.VarChar(50)
|
||||
taskAssignedTo Int?
|
||||
taskFormID Int?
|
||||
taskData Json?
|
||||
taskCreatedDate DateTime @default(now()) @db.DateTime(0)
|
||||
taskModifiedDate DateTime? @updatedAt @db.DateTime(0)
|
||||
taskCompletedDate DateTime? @db.DateTime(0)
|
||||
case caseInstance @relation(fields: [caseID], references: [caseID])
|
||||
assignedTo user? @relation("TaskAssignedTo", fields: [taskAssignedTo], references: [userID])
|
||||
form form? @relation(fields: [taskFormID], references: [formID])
|
||||
|
||||
@@index([caseID], map: "FK_task_case")
|
||||
@@index([taskAssignedTo], map: "FK_task_assignedTo")
|
||||
@@index([taskFormID], map: "FK_task_form")
|
||||
@@index([taskStatus], map: "IDX_task_status")
|
||||
}
|
||||
|
||||
model caseTimeline {
|
||||
timelineID Int @id @default(autoincrement())
|
||||
caseID Int
|
||||
timelineType String @db.VarChar(50)
|
||||
timelineDescription String? @db.Text
|
||||
timelineDate DateTime @default(now()) @db.DateTime(0)
|
||||
timelineCreatedBy Int?
|
||||
case caseInstance @relation(fields: [caseID], references: [caseID])
|
||||
createdBy user? @relation(fields: [timelineCreatedBy], references: [userID])
|
||||
|
||||
@@index([caseID], map: "FK_caseTimeline_case")
|
||||
@@index([timelineCreatedBy], map: "FK_caseTimeline_createdBy")
|
||||
@@index([timelineDate], map: "IDX_caseTimeline_date")
|
||||
}
|
||||
|
184
server/api/cases/[id]/forms.get.js
Normal file
184
server/api/cases/[id]/forms.get.js
Normal file
@ -0,0 +1,184 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Get the case ID from the route parameter
|
||||
const caseId = getRouterParam(event, 'id');
|
||||
|
||||
if (!caseId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Case ID is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the ID is a UUID or numeric ID
|
||||
const isUUID = caseId.length === 36 && caseId.includes('-');
|
||||
|
||||
// Find the case instance by UUID or ID
|
||||
const caseInstance = await prisma.caseInstance.findFirst({
|
||||
where: isUUID
|
||||
? { caseUUID: caseId }
|
||||
: { caseID: parseInt(caseId) },
|
||||
include: {
|
||||
process: {
|
||||
select: {
|
||||
processID: true,
|
||||
processUUID: true,
|
||||
processName: true,
|
||||
processDescription: true
|
||||
}
|
||||
},
|
||||
startedBy: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
include: {
|
||||
form: {
|
||||
select: {
|
||||
formID: true,
|
||||
formUUID: true,
|
||||
formName: true,
|
||||
formDescription: true,
|
||||
formComponents: true,
|
||||
formStatus: true,
|
||||
customCSS: true,
|
||||
customScript: true,
|
||||
formEvents: true,
|
||||
scriptMode: true
|
||||
}
|
||||
},
|
||||
assignedTo: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
timeline: {
|
||||
orderBy: {
|
||||
timelineDate: 'desc'
|
||||
},
|
||||
include: {
|
||||
createdBy: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!caseInstance) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Case instance not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Extract forms from tasks and remove duplicates
|
||||
const forms = [];
|
||||
const formIds = new Set();
|
||||
|
||||
for (const task of caseInstance.tasks) {
|
||||
if (task.form) {
|
||||
// Make sure formComponents is properly structured
|
||||
let formComponents = [];
|
||||
try {
|
||||
if (task.form.formComponents) {
|
||||
// Check if formComponents is already an array or needs to be extracted from a structure
|
||||
if (Array.isArray(task.form.formComponents)) {
|
||||
formComponents = task.form.formComponents;
|
||||
} else if (task.form.formComponents.components && Array.isArray(task.form.formComponents.components)) {
|
||||
formComponents = task.form.formComponents.components;
|
||||
} else {
|
||||
// Try to parse if it's a stringified JSON
|
||||
const parsedComponents = typeof task.form.formComponents === 'string'
|
||||
? JSON.parse(task.form.formComponents)
|
||||
: task.form.formComponents;
|
||||
|
||||
if (Array.isArray(parsedComponents)) {
|
||||
formComponents = parsedComponents;
|
||||
} else if (parsedComponents.components && Array.isArray(parsedComponents.components)) {
|
||||
formComponents = parsedComponents.components;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing form components:', err);
|
||||
}
|
||||
|
||||
// Extract form data from taskData if it exists
|
||||
let formData = null;
|
||||
if (task.taskData && task.taskData.formData) {
|
||||
formData = task.taskData.formData;
|
||||
}
|
||||
|
||||
forms.push({
|
||||
...task.form,
|
||||
formComponents: formComponents,
|
||||
taskId: task.taskID,
|
||||
taskUUID: task.taskUUID,
|
||||
taskStatus: task.taskStatus,
|
||||
taskName: task.taskName,
|
||||
taskData: task.taskData,
|
||||
formData: formData,
|
||||
submittedAt: task.taskData?.submittedAt || null,
|
||||
completedDate: task.taskCompletedDate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Format the response
|
||||
const response = {
|
||||
caseInstance: {
|
||||
caseID: caseInstance.caseID,
|
||||
caseUUID: caseInstance.caseUUID,
|
||||
caseName: caseInstance.caseName,
|
||||
caseStatus: caseInstance.caseStatus,
|
||||
caseCreatedDate: caseInstance.caseCreatedDate,
|
||||
caseModifiedDate: caseInstance.caseModifiedDate,
|
||||
caseCompletedDate: caseInstance.caseCompletedDate,
|
||||
caseVariables: caseInstance.caseVariables,
|
||||
process: {
|
||||
processID: caseInstance.process.processID,
|
||||
processUUID: caseInstance.process.processUUID,
|
||||
processName: caseInstance.process.processName,
|
||||
processDescription: caseInstance.process.processDescription
|
||||
},
|
||||
startedBy: caseInstance.startedBy ? {
|
||||
userID: caseInstance.startedBy.userID,
|
||||
userFullName: caseInstance.startedBy.userFullName,
|
||||
userUsername: caseInstance.startedBy.userUsername
|
||||
} : null
|
||||
},
|
||||
forms: forms,
|
||||
timeline: caseInstance.timeline
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...response
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching case instance and forms:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch case instance and forms',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
174
server/api/process/[id]/start.post.js
Normal file
174
server/api/process/[id]/start.post.js
Normal file
@ -0,0 +1,174 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Get the process ID from the route parameter
|
||||
const processId = getRouterParam(event, 'id');
|
||||
|
||||
if (!processId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Process ID is required'
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Starting process with ID:', processId);
|
||||
|
||||
// Check if the ID is a UUID or numeric ID
|
||||
const isUUID = processId.length === 36 && processId.includes('-');
|
||||
|
||||
// Find the process
|
||||
console.log('Finding process...');
|
||||
const process = await prisma.process.findFirst({
|
||||
where: isUUID
|
||||
? { processUUID: processId }
|
||||
: { processID: parseInt(processId) },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!process) {
|
||||
console.log('Process not found');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Process not found'
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Process found:', process.processName);
|
||||
|
||||
// Check if process is published
|
||||
if (process.processStatus !== 'published') {
|
||||
console.log('Process is not published:', process.processStatus);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot start an unpublished process'
|
||||
};
|
||||
}
|
||||
|
||||
// Get the current user (in a real app, this would come from the authenticated user)
|
||||
const currentUser = {
|
||||
userID: 1, // This would be the actual user ID in a real app
|
||||
userFullName: 'John Doe',
|
||||
userUsername: 'johndoe'
|
||||
};
|
||||
|
||||
console.log('Creating case instance...');
|
||||
// Create a new case instance
|
||||
const caseInstance = await prisma.caseInstance.create({
|
||||
data: {
|
||||
caseUUID: uuidv4(),
|
||||
processID: process.processID,
|
||||
caseName: `${process.processName} - ${new Date().toLocaleDateString()}`,
|
||||
caseStatus: 'active',
|
||||
caseStartedBy: currentUser.userID,
|
||||
caseVariables: process.processVariables || {},
|
||||
caseSettings: process.processSettings || {},
|
||||
caseDefinition: process.processDefinition || {},
|
||||
caseCreatedDate: new Date(),
|
||||
caseModifiedDate: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Case instance created:', caseInstance.caseUUID);
|
||||
|
||||
// Get the process definition
|
||||
const processDefinition = process.processDefinition || {};
|
||||
const nodes = processDefinition.nodes || [];
|
||||
const edges = processDefinition.edges || [];
|
||||
|
||||
// Find all form nodes
|
||||
const formNodes = nodes.filter(node => node.type === 'form');
|
||||
|
||||
if (formNodes.length === 0) {
|
||||
console.log('No form nodes found in process');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Process does not contain any forms'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Found ${formNodes.length} form nodes`);
|
||||
|
||||
// Create tasks for all forms
|
||||
const tasks = [];
|
||||
for (const formNode of formNodes) {
|
||||
console.log('Creating task for form:', formNode.data?.label);
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
taskUUID: uuidv4(),
|
||||
caseID: caseInstance.caseID,
|
||||
taskName: formNode.data?.label || 'Complete Form',
|
||||
taskType: 'form',
|
||||
taskStatus: 'pending',
|
||||
taskAssignedTo: currentUser.userID,
|
||||
taskFormID: formNode.data?.formId,
|
||||
taskCreatedDate: new Date(),
|
||||
taskModifiedDate: new Date()
|
||||
}
|
||||
});
|
||||
tasks.push(task);
|
||||
console.log('Task created:', task.taskUUID);
|
||||
}
|
||||
|
||||
// Add to case timeline
|
||||
console.log('Adding to case timeline...');
|
||||
await prisma.caseTimeline.create({
|
||||
data: {
|
||||
caseID: caseInstance.caseID,
|
||||
timelineType: 'start',
|
||||
timelineDescription: `Process started by ${currentUser.userFullName}`,
|
||||
timelineDate: new Date(),
|
||||
timelineCreatedBy: currentUser.userID
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Process started successfully');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
case: {
|
||||
id: caseInstance.caseUUID,
|
||||
name: caseInstance.caseName,
|
||||
status: caseInstance.caseStatus,
|
||||
startedAt: caseInstance.caseCreatedDate
|
||||
},
|
||||
tasks: tasks.map(task => ({
|
||||
id: task.taskUUID,
|
||||
name: task.taskName,
|
||||
type: task.taskType,
|
||||
formId: task.taskFormID,
|
||||
status: task.taskStatus
|
||||
}))
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error starting process:', error);
|
||||
console.error('Error details:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to start process',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
} finally {
|
||||
// Close the Prisma client connection
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
});
|
41
server/api/process/pending.get.js
Normal file
41
server/api/process/pending.get.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Get all published processes that haven't been started
|
||||
const processes = await prisma.process.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
cases: {
|
||||
none: {
|
||||
status: 'active'
|
||||
}
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: processes
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching pending processes:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch pending processes'
|
||||
};
|
||||
}
|
||||
});
|
122
server/api/tasks/[id].get.js
Normal file
122
server/api/tasks/[id].get.js
Normal file
@ -0,0 +1,122 @@
|
||||
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 },
|
||||
include: {
|
||||
case: {
|
||||
select: {
|
||||
caseID: true,
|
||||
caseUUID: true,
|
||||
caseName: true,
|
||||
caseStatus: true,
|
||||
process: {
|
||||
select: {
|
||||
processID: true,
|
||||
processUUID: true,
|
||||
processName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
assignedTo: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
},
|
||||
form: {
|
||||
select: {
|
||||
formID: true,
|
||||
formUUID: true,
|
||||
formName: true,
|
||||
formDescription: true,
|
||||
formComponents: true,
|
||||
formStatus: true,
|
||||
customCSS: true,
|
||||
customScript: true,
|
||||
formEvents: true,
|
||||
scriptMode: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (!isNaN(parseInt(taskId))) {
|
||||
// If it's a numeric ID
|
||||
task = await prisma.task.findUnique({
|
||||
where: { taskID: parseInt(taskId) },
|
||||
include: {
|
||||
case: {
|
||||
select: {
|
||||
caseID: true,
|
||||
caseUUID: true,
|
||||
caseName: true,
|
||||
caseStatus: true,
|
||||
process: {
|
||||
select: {
|
||||
processID: true,
|
||||
processUUID: true,
|
||||
processName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
assignedTo: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
},
|
||||
form: {
|
||||
select: {
|
||||
formID: true,
|
||||
formUUID: true,
|
||||
formName: true,
|
||||
formDescription: true,
|
||||
formComponents: true,
|
||||
formStatus: true,
|
||||
customCSS: true,
|
||||
customScript: true,
|
||||
formEvents: true,
|
||||
scriptMode: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task not found'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching task ${taskId}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch task',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
72
server/api/tasks/[id]/save-draft.post.js
Normal file
72
server/api/tasks/[id]/save-draft.post.js
Normal file
@ -0,0 +1,72 @@
|
||||
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.formData) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form data 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'
|
||||
};
|
||||
}
|
||||
|
||||
// Update the task with the draft form data
|
||||
const updatedTask = await prisma.task.update({
|
||||
where: {
|
||||
taskID: task.taskID
|
||||
},
|
||||
data: {
|
||||
taskData: {
|
||||
...task.taskData,
|
||||
formData: body.formData,
|
||||
lastSaved: new Date().toISOString(),
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task: updatedTask
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error saving draft for task ${taskId}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to save draft',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
122
server/api/tasks/[id]/submit.post.js
Normal file
122
server/api/tasks/[id]/submit.post.js
Normal file
@ -0,0 +1,122 @@
|
||||
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.formData) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Form data 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 },
|
||||
include: {
|
||||
case: true
|
||||
}
|
||||
});
|
||||
} else if (!isNaN(parseInt(taskId))) {
|
||||
// If it's a numeric ID
|
||||
task = await prisma.task.findUnique({
|
||||
where: { taskID: parseInt(taskId) },
|
||||
include: {
|
||||
case: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Update the task with the submitted form data
|
||||
const updatedTask = await prisma.task.update({
|
||||
where: {
|
||||
taskID: task.taskID
|
||||
},
|
||||
data: {
|
||||
taskStatus: 'completed',
|
||||
taskCompletedDate: new Date(),
|
||||
taskData: {
|
||||
...task.taskData,
|
||||
formData: body.formData,
|
||||
submittedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add an entry to the case timeline
|
||||
await prisma.caseTimeline.create({
|
||||
data: {
|
||||
caseID: task.caseID,
|
||||
timelineType: 'task_completed',
|
||||
timelineDescription: `Task "${task.taskName}" was completed`,
|
||||
timelineCreatedBy: task.taskAssignedTo
|
||||
}
|
||||
});
|
||||
|
||||
// Check if all tasks for the case are completed
|
||||
const remainingTasks = await prisma.task.count({
|
||||
where: {
|
||||
caseID: task.caseID,
|
||||
taskStatus: {
|
||||
not: 'completed'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If all tasks are completed, update the case status
|
||||
if (remainingTasks === 0) {
|
||||
await prisma.caseInstance.update({
|
||||
where: {
|
||||
caseID: task.caseID
|
||||
},
|
||||
data: {
|
||||
caseStatus: 'completed',
|
||||
caseCompletedDate: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Add an entry to the case timeline
|
||||
await prisma.caseTimeline.create({
|
||||
data: {
|
||||
caseID: task.caseID,
|
||||
timelineType: 'case_completed',
|
||||
timelineDescription: `Case "${task.case.caseName}" was completed`,
|
||||
timelineCreatedBy: task.taskAssignedTo
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task: updatedTask
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error submitting form for task ${taskId}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to submit form',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
96
server/api/tasks/index.get.js
Normal file
96
server/api/tasks/index.get.js
Normal file
@ -0,0 +1,96 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Get query parameters
|
||||
const query = getQuery(event);
|
||||
const caseId = query.caseId;
|
||||
const status = query.status;
|
||||
const assignedTo = query.assignedTo;
|
||||
const page = parseInt(query.page) || 1;
|
||||
const limit = parseInt(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Build where clause
|
||||
const where = {};
|
||||
|
||||
if (caseId) {
|
||||
where.caseID = parseInt(caseId);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.taskStatus = status;
|
||||
}
|
||||
|
||||
if (assignedTo) {
|
||||
where.taskAssignedTo = parseInt(assignedTo);
|
||||
}
|
||||
|
||||
// Fetch tasks
|
||||
const tasks = await prisma.task.findMany({
|
||||
where,
|
||||
include: {
|
||||
case: {
|
||||
select: {
|
||||
caseID: true,
|
||||
caseUUID: true,
|
||||
caseName: true,
|
||||
caseStatus: true,
|
||||
process: {
|
||||
select: {
|
||||
processID: true,
|
||||
processUUID: true,
|
||||
processName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
assignedTo: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
},
|
||||
form: {
|
||||
select: {
|
||||
formID: true,
|
||||
formUUID: true,
|
||||
formName: true,
|
||||
formDescription: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
taskCreatedDate: 'desc'
|
||||
},
|
||||
skip,
|
||||
take: limit
|
||||
});
|
||||
|
||||
// Count total tasks for pagination
|
||||
const totalTasks = await prisma.task.count({ where });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tasks,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalItems: totalTasks,
|
||||
totalPages: Math.ceil(totalTasks / limit)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch tasks',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
9
server/middleware/dynamic-routes.js
Normal file
9
server/middleware/dynamic-routes.js
Normal file
@ -0,0 +1,9 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const url = event.node.req.url;
|
||||
|
||||
// Check if the URL contains literal [id] which should be a parameter
|
||||
if (url && url.includes('/execution/form/[id]')) {
|
||||
// Redirect to a more appropriate error page or handle differently
|
||||
return sendRedirect(event, '/execution', 302);
|
||||
}
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user