Compare commits
9 Commits
main
...
bp-process
Author | SHA1 | Date | |
---|---|---|---|
aa01c212ff | |||
b5ee79339a | |||
82646cf6a9 | |||
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
|
||||
});
|
||||
}
|
||||
|
223
docs/process-execution/ACCESS_CONTROL.md
Normal file
223
docs/process-execution/ACCESS_CONTROL.md
Normal file
@ -0,0 +1,223 @@
|
||||
# Form Access Control
|
||||
|
||||
## Overview
|
||||
|
||||
The form execution system now includes comprehensive access control that determines whether users can edit or only view forms based on their assignments and roles.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Access Validation
|
||||
|
||||
When a user accesses a form in a process execution, the system checks:
|
||||
|
||||
1. **Direct Task Assignment**: If the task is directly assigned to the current user
|
||||
2. **Process Definition Assignment**: If the form node in the process has specific user/role assignments
|
||||
3. **Default Access**: If no specific assignment is found, defaults to public access
|
||||
|
||||
### Assignment Types
|
||||
|
||||
The system supports the following assignment types for forms:
|
||||
|
||||
#### 1. Public Assignment (`assignmentType: 'public'`)
|
||||
- **Access**: Anyone can edit the form
|
||||
- **Behavior**: Full edit access for all users
|
||||
|
||||
#### 2. User Assignment (`assignmentType: 'users'`)
|
||||
- **Access**: Only specific users can edit the form
|
||||
- **Check**: Current user ID or email must be in `assignedUsers` array
|
||||
- **Example**:
|
||||
```json
|
||||
{
|
||||
"assignmentType": "users",
|
||||
"assignedUsers": [
|
||||
{
|
||||
"value": "123",
|
||||
"label": "John Doe (john.doe)",
|
||||
"username": "john.doe"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Role Assignment (`assignmentType: 'roles'`)
|
||||
- **Access**: Only users with specific roles can edit the form
|
||||
- **Check**: Current user's roles must match `assignedRoles` array
|
||||
- **Example**:
|
||||
```json
|
||||
{
|
||||
"assignmentType": "roles",
|
||||
"assignedRoles": [
|
||||
{
|
||||
"value": "2",
|
||||
"label": "Manager"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Variable Assignment (`assignmentType: 'variable'`)
|
||||
- **Access**: Dynamic assignment based on process variables
|
||||
- **Behavior**: Currently allows access (future enhancement needed)
|
||||
|
||||
## User Experience
|
||||
|
||||
### Edit Access
|
||||
Users with edit access see:
|
||||
- Green "Edit Access" badge
|
||||
- Fully functional form inputs
|
||||
- Submit buttons enabled
|
||||
- Conditional logic active
|
||||
|
||||
### Read-only Access
|
||||
Users without edit access see:
|
||||
- Yellow "Read-only Access" badge with warning icon
|
||||
- Disabled form inputs with gray styling
|
||||
- Submit buttons disabled
|
||||
- Clear explanation of why access is restricted
|
||||
- Form data is visible but not editable
|
||||
|
||||
### Visual Indicators
|
||||
|
||||
#### Tab Navigation
|
||||
- Warning icon next to form names for read-only forms
|
||||
- Visual distinction between editable and read-only forms
|
||||
|
||||
#### Form Header
|
||||
- Access status badges (Edit Access / Read-only Access)
|
||||
- Detailed explanation for read-only access
|
||||
- Color-coded indicators (green for edit, yellow for read-only)
|
||||
|
||||
#### Form Fields
|
||||
- Disabled styling for read-only inputs
|
||||
- Reduced opacity for entire form when disabled
|
||||
- Cursor changes to "not-allowed" for disabled fields
|
||||
|
||||
## API Changes
|
||||
|
||||
### Enhanced Response
|
||||
The `/api/cases/[id]/forms` endpoint now returns additional access control information:
|
||||
|
||||
```json
|
||||
{
|
||||
"forms": [
|
||||
{
|
||||
"formID": 123,
|
||||
"formName": "Example Form",
|
||||
"hasEditAccess": true,
|
||||
"accessReason": "user_assigned",
|
||||
"assignmentType": "users"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Access Control Fields
|
||||
- `hasEditAccess`: Boolean indicating if user can edit the form
|
||||
- `accessReason`: String explaining the access decision
|
||||
- `assignmentType`: The type of assignment configured for the form
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication Required
|
||||
- All form access requires valid authentication
|
||||
- User context is validated on every request
|
||||
|
||||
### Role-based Validation
|
||||
- User roles are fetched from database
|
||||
- Role assignments are validated against current user's roles
|
||||
|
||||
### Assignment Validation
|
||||
- Direct task assignments are checked first
|
||||
- Process definition assignments are validated
|
||||
- Fallback to public access if no assignment found
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Backend Changes
|
||||
- Enhanced `/api/cases/[id]/forms` endpoint with access validation
|
||||
- User role fetching and validation
|
||||
- Assignment type checking logic
|
||||
|
||||
### Frontend Changes
|
||||
- Readonly mode for forms without edit access
|
||||
- Visual indicators for access status
|
||||
- Disabled form submission for read-only forms
|
||||
- Conditional logic disabled for read-only forms
|
||||
|
||||
### Form Behavior
|
||||
- FormKit forms are disabled when user lacks edit access
|
||||
- All form inputs are set to readonly/disabled
|
||||
- Submit buttons are disabled
|
||||
- Conditional logic scripts are not executed
|
||||
|
||||
## Configuration
|
||||
|
||||
### Setting Up Form Assignments
|
||||
|
||||
1. **Open Process Builder** - Navigate to the process you want to configure
|
||||
2. **Select Form Node** - Click on the form node in your process
|
||||
3. **Configure Assignment** - In the form configuration modal:
|
||||
- Choose assignment type (Public, Users, Roles, or Variable)
|
||||
- Select specific users or roles as needed
|
||||
- Save the configuration
|
||||
|
||||
### Example Process Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "form-1",
|
||||
"type": "form",
|
||||
"data": {
|
||||
"label": "Manager Approval Form",
|
||||
"formId": "123",
|
||||
"assignmentType": "roles",
|
||||
"assignedRoles": [
|
||||
{
|
||||
"value": "2",
|
||||
"label": "Manager"
|
||||
},
|
||||
{
|
||||
"value": "3",
|
||||
"label": "Supervisor"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Access to Forms
|
||||
|
||||
If a user can't edit forms:
|
||||
|
||||
1. **Check User Roles** - Verify the user has the correct roles assigned
|
||||
2. **Check Form Assignment** - Ensure the form node has proper assignment configuration
|
||||
3. **Check Process Status** - Process must be published and not deleted
|
||||
4. **Check Assignment Type** - Verify the assignment type is configured correctly
|
||||
|
||||
### Debug Information
|
||||
|
||||
The API endpoint includes console logging for debugging:
|
||||
|
||||
```javascript
|
||||
// User information
|
||||
console.log('Current user ID:', currentUser.userID);
|
||||
console.log('User roles:', userRoleNames);
|
||||
|
||||
// Assignment checks
|
||||
console.log('Checking form access:', {...});
|
||||
console.log('Access result:', accessCheck);
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Variable-based assignment evaluation
|
||||
- Time-based access control
|
||||
- Conditional access based on form data
|
||||
- Audit logging for access attempts
|
||||
- Advanced permission inheritance
|
146
docs/process-execution/ASSIGNED_PROCESSES.md
Normal file
146
docs/process-execution/ASSIGNED_PROCESSES.md
Normal file
@ -0,0 +1,146 @@
|
||||
# Assigned Processes Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The "Start New Case" page now displays only processes where the current user is assigned to complete the first form task. This ensures users only see processes they have permission to start.
|
||||
|
||||
## How It Works
|
||||
|
||||
### API Endpoint
|
||||
|
||||
A new API endpoint `/api/process/assigned` has been created that:
|
||||
|
||||
1. **Authenticates the user** - Gets the current user from the request context
|
||||
2. **Fetches user roles** - Retrieves all roles assigned to the current user
|
||||
3. **Filters processes** - Only returns processes where the user is assigned to the first form
|
||||
|
||||
### Assignment Types
|
||||
|
||||
The system checks the assignment configuration of the first form node in each process:
|
||||
|
||||
#### 1. Public Assignment (`assignmentType: 'public'`)
|
||||
- **Access**: Anyone can start the process
|
||||
- **Behavior**: Process is included for all users
|
||||
|
||||
#### 2. User Assignment (`assignmentType: 'users'`)
|
||||
- **Access**: Only specific users can start the process
|
||||
- **Check**: Current user ID or email must be in `assignedUsers` array
|
||||
- **Example**:
|
||||
```json
|
||||
{
|
||||
"assignmentType": "users",
|
||||
"assignedUsers": [
|
||||
{
|
||||
"value": "123",
|
||||
"label": "John Doe (john.doe)",
|
||||
"username": "john.doe"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Role Assignment (`assignmentType: 'roles'`)
|
||||
- **Access**: Only users with specific roles can start the process
|
||||
- **Check**: Current user's roles must match `assignedRoles` array
|
||||
- **Example**:
|
||||
```json
|
||||
{
|
||||
"assignmentType": "roles",
|
||||
"assignedRoles": [
|
||||
{
|
||||
"value": "2",
|
||||
"label": "Manager"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Variable Assignment (`assignmentType: 'variable'`)
|
||||
- **Access**: Dynamic assignment based on process variables
|
||||
- **Behavior**: Currently includes all processes (future enhancement needed)
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
The `/execution/new-case` page has been updated to:
|
||||
|
||||
1. **Use the new API endpoint** - Calls `/api/process/assigned` instead of `/api/process`
|
||||
2. **Updated UI** - Shows "My Assigned Processes" header and assignment indicators
|
||||
3. **Better messaging** - Clear indication when no processes are assigned
|
||||
|
||||
## Configuration
|
||||
|
||||
### Setting Up Process Assignments
|
||||
|
||||
1. **Open Process Builder** - Navigate to the process you want to configure
|
||||
2. **Select First Form Node** - Click on the first form node in your process
|
||||
3. **Configure Assignment** - In the form configuration modal:
|
||||
- Choose assignment type (Public, Users, Roles, or Variable)
|
||||
- Select specific users or roles as needed
|
||||
- Save the configuration
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "form-1",
|
||||
"type": "form",
|
||||
"data": {
|
||||
"label": "Initial Request Form",
|
||||
"formId": "123",
|
||||
"assignmentType": "roles",
|
||||
"assignedRoles": [
|
||||
{
|
||||
"value": "2",
|
||||
"label": "Manager"
|
||||
},
|
||||
{
|
||||
"value": "3",
|
||||
"label": "Supervisor"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Authentication Required**: All requests must be authenticated
|
||||
- **Role-based Access**: Users can only see processes they're assigned to
|
||||
- **Audit Trail**: Process starts are logged with user information
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Processes Showing
|
||||
|
||||
If a user doesn't see any processes:
|
||||
|
||||
1. **Check User Roles** - Verify the user has the correct roles assigned
|
||||
2. **Check Process Assignment** - Ensure the first form node has proper assignment configuration
|
||||
3. **Check Process Status** - Process must be published and not deleted
|
||||
4. **Check Assignment Type** - Verify the assignment type is configured correctly
|
||||
|
||||
### Debug Information
|
||||
|
||||
The API endpoint includes console logging for debugging:
|
||||
|
||||
```javascript
|
||||
// User information
|
||||
console.log('Current user ID:', currentUser.userID);
|
||||
console.log('User roles:', userRoleNames);
|
||||
|
||||
// Assignment checks
|
||||
console.log('Checking user assignment:', {...});
|
||||
console.log('Checking role assignment:', {...});
|
||||
console.log('Process assignment result:', isAssigned);
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Variable Evaluation** - Implement proper variable-based assignment
|
||||
2. **Multiple Form Support** - Check assignments across multiple form nodes
|
||||
3. **Permission Inheritance** - Support for inherited permissions from parent processes
|
||||
4. **Bulk Assignment** - Tools for bulk assigning processes to users/roles
|
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>
|
||||
|
672
pages/execution/form/[id].vue
Normal file
672
pages/execution/form/[id].vue
Normal file
@ -0,0 +1,672 @@
|
||||
<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>
|
||||
<div class="flex items-center">
|
||||
{{ form.formName || `Form ${index + 1}` }}
|
||||
<!-- Access indicator -->
|
||||
<div v-if="!form.hasEditAccess" class="ml-2">
|
||||
<div class="w-4 h-4 rounded-full bg-yellow-100 flex items-center justify-center"
|
||||
title="Read-only access">
|
||||
<svg class="w-3 h-3 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<!-- Form Header with Access Status -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-xl font-semibold">{{ form.formName || `Form ${index + 1}` }}</h2>
|
||||
<!-- Access Status Badge -->
|
||||
<div v-if="!form.hasEditAccess" class="flex items-center">
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Read-only Access
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center">
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 border border-green-200">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Edit Access
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 mb-1">{{ form.description || 'Please complete this form step' }}</p>
|
||||
|
||||
<!-- Access Information -->
|
||||
<div v-if="!form.hasEditAccess" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-yellow-600 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800">Read-only Access</p>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
You can view this form but cannot make changes.
|
||||
<span v-if="form.accessReason === 'user_not_assigned'">This form is assigned to specific users.</span>
|
||||
<span v-else-if="form.accessReason === 'role_not_assigned'">This form is assigned to specific roles.</span>
|
||||
<span v-else>You don't have permission to edit this form.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conditional Logic Engine -->
|
||||
<ConditionalLogicEngine
|
||||
:form-components="form.formComponents"
|
||||
:form-data="formData[index]"
|
||||
@script-generated="handleScriptGenerated"
|
||||
/>
|
||||
|
||||
<!-- Form content -->
|
||||
<FormKit
|
||||
type="form"
|
||||
:id="`form-${form.formID}`"
|
||||
v-model="formData[index]"
|
||||
@submit="handleSubmit(index)"
|
||||
:actions="false"
|
||||
:incomplete-message="false"
|
||||
validation-visibility="submit"
|
||||
:disabled="!form.hasEditAccess"
|
||||
>
|
||||
<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"
|
||||
:disabled="!form.hasEditAccess"
|
||||
:readonly="!form.hasEditAccess"
|
||||
/>
|
||||
|
||||
<!-- 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 || !form.hasEditAccess"
|
||||
>
|
||||
{{ 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 || !form.hasEditAccess"
|
||||
: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, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import RsButton from '~/components/RsButton.vue'
|
||||
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.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'
|
||||
)
|
||||
}
|
||||
|
||||
// Utility functions for conditional logic
|
||||
const getField = (fieldName) => {
|
||||
const currentFormData = formData.value[activeTabIndex.value] || {}
|
||||
return currentFormData[fieldName]
|
||||
}
|
||||
|
||||
const showField = (fieldName) => {
|
||||
const currentForm = forms.value[activeTabIndex.value]
|
||||
if (!currentForm?.hasEditAccess) return // Don't modify fields if readonly
|
||||
|
||||
const field = document.querySelector(`[name="${fieldName}"]`)
|
||||
if (field) {
|
||||
const outerDiv = field.closest('.formkit-outer')
|
||||
if (outerDiv) {
|
||||
outerDiv.style.display = ''
|
||||
outerDiv.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hideField = (fieldName) => {
|
||||
const currentForm = forms.value[activeTabIndex.value]
|
||||
if (!currentForm?.hasEditAccess) return // Don't modify fields if readonly
|
||||
|
||||
const field = document.querySelector(`[name="${fieldName}"]`)
|
||||
if (field) {
|
||||
const outerDiv = field.closest('.formkit-outer')
|
||||
if (outerDiv) {
|
||||
outerDiv.style.display = 'none'
|
||||
outerDiv.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enableField = (fieldName) => {
|
||||
const currentForm = forms.value[activeTabIndex.value]
|
||||
if (!currentForm?.hasEditAccess) return // Don't modify fields if readonly
|
||||
|
||||
const field = document.querySelector(`[name="${fieldName}"]`)
|
||||
if (field) {
|
||||
field.removeAttribute('disabled')
|
||||
const outerDiv = field.closest('.formkit-outer')
|
||||
if (outerDiv) {
|
||||
outerDiv.classList.remove('opacity-50', 'pointer-events-none')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const disableField = (fieldName) => {
|
||||
const currentForm = forms.value[activeTabIndex.value]
|
||||
if (!currentForm?.hasEditAccess) return // Don't modify fields if readonly
|
||||
|
||||
const field = document.querySelector(`[name="${fieldName}"]`)
|
||||
if (field) {
|
||||
field.setAttribute('disabled', 'disabled')
|
||||
const outerDiv = field.closest('.formkit-outer')
|
||||
if (outerDiv) {
|
||||
outerDiv.classList.add('opacity-50', 'pointer-events-none')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onFieldChange = (fieldName, callback) => {
|
||||
const currentForm = forms.value[activeTabIndex.value]
|
||||
if (!currentForm?.hasEditAccess) return // Don't add listeners if readonly
|
||||
|
||||
const field = document.querySelector(`[name="${fieldName}"]`)
|
||||
if (field) {
|
||||
// Remove existing listeners first to prevent duplicates
|
||||
field.removeEventListener('change', callback)
|
||||
field.removeEventListener('input', callback)
|
||||
// Add new listeners
|
||||
field.addEventListener('change', callback)
|
||||
field.addEventListener('input', callback)
|
||||
}
|
||||
}
|
||||
|
||||
// Function to evaluate and apply conditional logic for all fields
|
||||
const evaluateAllFieldConditions = () => {
|
||||
const currentForm = forms.value[activeTabIndex.value]
|
||||
if (!currentForm?.formComponents) return
|
||||
|
||||
// Don't apply conditional logic if user doesn't have edit access
|
||||
if (!currentForm.hasEditAccess) return
|
||||
|
||||
currentForm.formComponents.forEach(component => {
|
||||
if (component.props?.conditionalLogic?.enabled) {
|
||||
const { conditions, action, operator = 'and' } = component.props.conditionalLogic
|
||||
const fieldName = component.props.name
|
||||
|
||||
// Evaluate all conditions
|
||||
const results = conditions.map(condition => {
|
||||
const fieldValue = getField(condition.field)
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return fieldValue === condition.value
|
||||
case 'not_equals':
|
||||
return fieldValue !== condition.value
|
||||
case 'contains':
|
||||
return String(fieldValue || '').includes(condition.value)
|
||||
case 'not_contains':
|
||||
return !String(fieldValue || '').includes(condition.value)
|
||||
case 'is_empty':
|
||||
return !fieldValue || fieldValue === ''
|
||||
case 'is_not_empty':
|
||||
return fieldValue && fieldValue !== ''
|
||||
case 'greater_than':
|
||||
return Number(fieldValue) > Number(condition.value)
|
||||
case 'less_than':
|
||||
return Number(fieldValue) < Number(condition.value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Check if conditions are met based on operator
|
||||
const conditionsMet = operator === 'and'
|
||||
? results.every(result => result)
|
||||
: results.some(result => result)
|
||||
|
||||
// Apply visibility/state based on conditions
|
||||
if (conditionsMet) {
|
||||
if (action === 'show') showField(fieldName)
|
||||
else if (action === 'hide') hideField(fieldName)
|
||||
else if (action === 'enable') enableField(fieldName)
|
||||
else if (action === 'disable') disableField(fieldName)
|
||||
} else {
|
||||
if (action === 'show') hideField(fieldName)
|
||||
else if (action === 'hide') showField(fieldName)
|
||||
else if (action === 'enable') disableField(fieldName)
|
||||
else if (action === 'disable') enableField(fieldName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for form data changes to re-evaluate conditional logic
|
||||
watch(() => formData.value[activeTabIndex.value], () => {
|
||||
nextTick(() => {
|
||||
evaluateAllFieldConditions()
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
// Watch for active tab changes to re-evaluate conditional logic
|
||||
watch(activeTabIndex, () => {
|
||||
nextTick(() => {
|
||||
evaluateAllFieldConditions()
|
||||
})
|
||||
})
|
||||
|
||||
// Watch for forms data to initialize conditional logic
|
||||
watch(forms, (newForms) => {
|
||||
if (newForms.length > 0) {
|
||||
nextTick(() => {
|
||||
evaluateAllFieldConditions()
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Handle script generated from ConditionalLogicEngine
|
||||
const handleScriptGenerated = (script) => {
|
||||
if (!script) return
|
||||
|
||||
const currentForm = forms.value[activeTabIndex.value]
|
||||
if (!currentForm?.hasEditAccess) return // Don't execute scripts if readonly
|
||||
|
||||
try {
|
||||
// Create a function with access to our utility functions
|
||||
const evalScript = new Function(
|
||||
'getField',
|
||||
'showField',
|
||||
'hideField',
|
||||
'enableField',
|
||||
'disableField',
|
||||
'onFieldChange',
|
||||
script
|
||||
)
|
||||
|
||||
// Execute the script with our utility functions
|
||||
evalScript(
|
||||
getField,
|
||||
showField,
|
||||
hideField,
|
||||
enableField,
|
||||
disableField,
|
||||
onFieldChange
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Error executing conditional logic script:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (formIndex) => {
|
||||
try {
|
||||
// Check if user has edit access to this form
|
||||
const currentForm = forms.value[formIndex];
|
||||
if (!currentForm.hasEditAccess) {
|
||||
error.value = 'You do not have permission to submit this form';
|
||||
return;
|
||||
}
|
||||
|
||||
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 default values for each form
|
||||
formData.value = forms.value.map(form => {
|
||||
const defaultData = {};
|
||||
|
||||
// Set default values for form fields
|
||||
form.formComponents?.forEach(component => {
|
||||
// For select fields with options, set first option as default
|
||||
if (component.type === 'select' && component.props.options?.length > 0) {
|
||||
defaultData[component.props.name] = component.props.options[0].value;
|
||||
}
|
||||
// Add more default value logic for other field types if needed
|
||||
});
|
||||
|
||||
return defaultData;
|
||||
});
|
||||
}
|
||||
|
||||
} 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: 0.5rem;
|
||||
}
|
||||
|
||||
.grid-preview-container > div {
|
||||
grid-column: span 9;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Readonly form styling */
|
||||
:deep(.formkit-form[disabled]) {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:deep(.formkit-form[disabled] .formkit-input) {
|
||||
background-color: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:deep(.formkit-form[disabled] .formkit-label) {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:deep(.formkit-form[disabled] .formkit-help) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Readonly input styling */
|
||||
:deep(.formkit-input[readonly]) {
|
||||
background-color: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:deep(.formkit-input[disabled]) {
|
||||
background-color: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
@ -2,6 +2,19 @@
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Header -->
|
||||
<rs-card class="mb-6">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center mb-4">
|
||||
<Icon name="material-symbols:assignment" class="text-blue-600 w-6 h-6 mr-3" />
|
||||
<h1 class="text-xl font-semibold text-gray-900">My Assigned Processes</h1>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Start a new case from processes where you are assigned to complete the first form task.
|
||||
</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<rs-card>
|
||||
<div class="p-5 flex flex-wrap gap-4">
|
||||
@ -9,7 +22,7 @@
|
||||
<FormKit
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search processes..."
|
||||
placeholder="Search assigned processes..."
|
||||
prefix-icon="search"
|
||||
v-model="searchQuery"
|
||||
/>
|
||||
@ -26,14 +39,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 +80,21 @@
|
||||
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">
|
||||
<div class="flex items-center mb-1">
|
||||
<Icon
|
||||
class="text-base mr-1"
|
||||
name="material-symbols:sync"
|
||||
></Icon>
|
||||
<span>{{ process.steps }} steps</span>
|
||||
<span>Status: {{ process.status }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Icon
|
||||
class="text-base mr-1"
|
||||
name="material-symbols:assignment"
|
||||
></Icon>
|
||||
<span class="text-blue-600 font-medium">Assigned to you</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
@ -79,73 +109,87 @@
|
||||
</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:assignment-outline" class="w-16 h-16 mb-4 text-gray-300" />
|
||||
<p class="text-base font-medium">No assigned processes found</p>
|
||||
<p class="text-sm mt-1">You don't have any processes assigned to you. Contact your administrator to get access to processes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
definePageMeta({
|
||||
title: "Start New Case",
|
||||
title: "Start New Case - Assigned Processes",
|
||||
layout: "default",
|
||||
middleware: ["auth"],
|
||||
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;
|
||||
|
||||
// Fetch processes that the current user is assigned to in the first form
|
||||
const response = await $fetch('/api/process/assigned', {
|
||||
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 +209,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,45 @@ try {
|
||||
};
|
||||
}
|
||||
|
||||
const showPreview = ref(false);
|
||||
const showUnsavedChangesModal = 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');
|
||||
// Reactive state
|
||||
const isPreview = ref(false);
|
||||
const showDropdown = ref(false);
|
||||
const showFormSettings = ref(false);
|
||||
const showTemplatesModal = ref(false);
|
||||
const showFieldSettings = ref(false);
|
||||
const showFieldSettingsPanel = ref(false);
|
||||
const showFormHistoryModal = ref(false);
|
||||
const showDropdown = ref(false);
|
||||
const leftSidebarTab = ref('components');
|
||||
const showFieldSettingsPanel = ref(true);
|
||||
|
||||
// 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 +1409,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 +1453,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 +1649,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 +1699,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 +2947,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")
|
||||
}
|
||||
|
286
server/api/cases/[id]/forms.get.js
Normal file
286
server/api/cases/[id]/forms.get.js
Normal file
@ -0,0 +1,286 @@
|
||||
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'
|
||||
};
|
||||
}
|
||||
|
||||
// Get the current authenticated user from the request context
|
||||
const currentUser = event.context.user;
|
||||
|
||||
if (!currentUser || !currentUser.userID) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Authentication 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,
|
||||
processDefinition: 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'
|
||||
};
|
||||
}
|
||||
|
||||
// Get current user's roles for access validation
|
||||
const userRoles = await prisma.userrole.findMany({
|
||||
where: {
|
||||
userRoleUserID: parseInt(currentUser.userID)
|
||||
},
|
||||
select: {
|
||||
role: {
|
||||
select: {
|
||||
roleID: true,
|
||||
roleName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const userRoleIds = userRoles.map(ur => ur.role.roleID);
|
||||
const userRoleNames = userRoles.map(ur => ur.role.roleName);
|
||||
|
||||
// Function to check if user has access to a form
|
||||
const checkFormAccess = (task, processDefinition) => {
|
||||
// If task is directly assigned to current user, they have access
|
||||
if (task.assignedTo && task.assignedTo.userID === parseInt(currentUser.userID)) {
|
||||
return { hasAccess: true, reason: 'directly_assigned' };
|
||||
}
|
||||
|
||||
// Check process definition for form node assignment
|
||||
const nodes = processDefinition?.nodes || [];
|
||||
const formNodes = nodes.filter(node => node.type === 'form');
|
||||
|
||||
// Find the form node that corresponds to this task
|
||||
const formNode = formNodes.find(node => {
|
||||
const nodeData = node.data || {};
|
||||
return nodeData.formId === task.form?.formID || nodeData.formUuid === task.form?.formUUID;
|
||||
});
|
||||
|
||||
if (!formNode) {
|
||||
// If no form node found, default to public access
|
||||
return { hasAccess: true, reason: 'public_default' };
|
||||
}
|
||||
|
||||
const formData = formNode.data || {};
|
||||
const assignmentType = formData.assignmentType || 'public';
|
||||
|
||||
// Check assignment type
|
||||
if (assignmentType === 'public') {
|
||||
return { hasAccess: true, reason: 'public_assignment' };
|
||||
} else if (assignmentType === 'users') {
|
||||
const assignedUsers = formData.assignedUsers || [];
|
||||
const currentUserIdStr = String(currentUser.userID);
|
||||
|
||||
const hasUserAccess = assignedUsers.some(user =>
|
||||
String(user.value) === currentUserIdStr ||
|
||||
user.username === currentUser.email
|
||||
);
|
||||
|
||||
return {
|
||||
hasAccess: hasUserAccess,
|
||||
reason: hasUserAccess ? 'user_assigned' : 'user_not_assigned'
|
||||
};
|
||||
} else if (assignmentType === 'roles') {
|
||||
const assignedRoles = formData.assignedRoles || [];
|
||||
|
||||
const hasRoleAccess = assignedRoles.some(role =>
|
||||
userRoleIds.includes(parseInt(role.value)) ||
|
||||
userRoleNames.includes(role.label)
|
||||
);
|
||||
|
||||
return {
|
||||
hasAccess: hasRoleAccess,
|
||||
reason: hasRoleAccess ? 'role_assigned' : 'role_not_assigned'
|
||||
};
|
||||
} else if (assignmentType === 'variable') {
|
||||
// For variable-based assignment, we'll allow access for now
|
||||
// In a real implementation, you might want to evaluate the variable
|
||||
return { hasAccess: true, reason: 'variable_assignment' };
|
||||
}
|
||||
|
||||
return { hasAccess: false, reason: 'no_access' };
|
||||
};
|
||||
|
||||
// Extract forms from tasks and remove duplicates
|
||||
const forms = [];
|
||||
const formIds = new Set();
|
||||
|
||||
for (const task of caseInstance.tasks) {
|
||||
if (task.form) {
|
||||
// Check if user has access to this form
|
||||
const accessCheck = checkFormAccess(task, caseInstance.process.processDefinition);
|
||||
|
||||
// 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,
|
||||
// Add access control information
|
||||
hasEditAccess: accessCheck.hasAccess,
|
||||
accessReason: accessCheck.reason,
|
||||
assignmentType: accessCheck.reason === 'public_assignment' ? 'public' :
|
||||
accessCheck.reason === 'user_assigned' ? 'users' :
|
||||
accessCheck.reason === 'role_assigned' ? 'roles' :
|
||||
accessCheck.reason === 'variable_assignment' ? 'variable' : 'none'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
console.log(`response.forms: ${JSON.stringify(response.forms)}`);
|
||||
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
|
||||
};
|
||||
}
|
||||
});
|
198
server/api/process/[id]/start.post.js
Normal file
198
server/api/process/[id]/start.post.js
Normal file
@ -0,0 +1,198 @@
|
||||
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 authenticated user from the request context
|
||||
const currentUser = event.context.user;
|
||||
|
||||
if (!currentUser || !currentUser.userID) {
|
||||
console.log('User not authenticated');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
};
|
||||
}
|
||||
|
||||
// Get full user details from database
|
||||
const userDetails = await prisma.user.findFirst({
|
||||
where: {
|
||||
userID: parseInt(currentUser.userID)
|
||||
},
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!userDetails) {
|
||||
console.log('User details not found');
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
};
|
||||
}
|
||||
|
||||
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: userDetails.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: userDetails.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 ${userDetails.userFullName}`,
|
||||
timelineDate: new Date(),
|
||||
timelineCreatedBy: userDetails.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();
|
||||
}
|
||||
});
|
228
server/api/process/assigned.get.js
Normal file
228
server/api/process/assigned.get.js
Normal file
@ -0,0 +1,228 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Get the current authenticated user from the request context
|
||||
const currentUser = event.context.user;
|
||||
|
||||
if (!currentUser || !currentUser.userID) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
};
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
const query = getQuery(event);
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
status,
|
||||
category,
|
||||
search,
|
||||
isTemplate,
|
||||
sortBy = 'processCreatedDate',
|
||||
sortOrder = 'desc'
|
||||
} = query;
|
||||
|
||||
// Build where clause
|
||||
const where = {};
|
||||
|
||||
// Exclude deleted processes by default unless explicitly requested
|
||||
if (query.includeDeleted !== 'true') {
|
||||
where.processStatus = { not: 'deleted' };
|
||||
}
|
||||
|
||||
if (status && status !== 'deleted') {
|
||||
// If status filter is provided and it's not 'deleted', filter by that status
|
||||
// and still exclude deleted processes
|
||||
where.processStatus = status;
|
||||
} else if (status === 'deleted') {
|
||||
// If specifically requesting deleted processes, only show those
|
||||
where.processStatus = 'deleted';
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.processCategory = category;
|
||||
}
|
||||
|
||||
if (isTemplate !== undefined) {
|
||||
where.isTemplate = isTemplate === 'true';
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ processName: { contains: search, mode: 'insensitive' } },
|
||||
{ processDescription: { contains: search, mode: 'insensitive' } }
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
const take = parseInt(limit);
|
||||
|
||||
// Build orderBy clause
|
||||
const orderBy = {};
|
||||
orderBy[sortBy] = sortOrder;
|
||||
|
||||
// Get all processes first
|
||||
const allProcesses = await prisma.process.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
processID: true,
|
||||
processUUID: true,
|
||||
processName: true,
|
||||
processDescription: true,
|
||||
processCategory: true,
|
||||
processPriority: true,
|
||||
processOwner: true,
|
||||
processVersion: true,
|
||||
processStatus: true,
|
||||
isTemplate: true,
|
||||
templateCategory: true,
|
||||
processCreatedDate: true,
|
||||
processModifiedDate: true,
|
||||
processDefinition: true,
|
||||
creator: {
|
||||
select: {
|
||||
userID: true,
|
||||
userFullName: true,
|
||||
userUsername: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user's roles
|
||||
const userRoles = await prisma.userrole.findMany({
|
||||
where: {
|
||||
userRoleUserID: parseInt(currentUser.userID)
|
||||
},
|
||||
select: {
|
||||
role: {
|
||||
select: {
|
||||
roleID: true,
|
||||
roleName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const userRoleIds = userRoles.map(ur => ur.role.roleID);
|
||||
const userRoleNames = userRoles.map(ur => ur.role.roleName);
|
||||
|
||||
// Filter processes based on user assignments in the first form
|
||||
const filteredProcesses = [];
|
||||
|
||||
for (const process of allProcesses) {
|
||||
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) {
|
||||
// If no form nodes, skip this process
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the first form node (assuming it's the starting form)
|
||||
const firstFormNode = formNodes[0];
|
||||
const firstFormData = firstFormNode.data || {};
|
||||
|
||||
// Check if the user is assigned to this form
|
||||
let isAssigned = false;
|
||||
|
||||
// Check assignment type
|
||||
const assignmentType = firstFormData.assignmentType || 'public';
|
||||
|
||||
if (assignmentType === 'public') {
|
||||
// Public assignment - anyone can access
|
||||
isAssigned = true;
|
||||
} else if (assignmentType === 'users') {
|
||||
// Check if current user is in assigned users
|
||||
const assignedUsers = firstFormData.assignedUsers || [];
|
||||
const currentUserIdStr = String(currentUser.userID);
|
||||
|
||||
|
||||
|
||||
isAssigned = assignedUsers.some(user =>
|
||||
String(user.value) === currentUserIdStr ||
|
||||
user.username === currentUser.email
|
||||
);
|
||||
} else if (assignmentType === 'roles') {
|
||||
// Check if current user's roles are in assigned roles
|
||||
const assignedRoles = firstFormData.assignedRoles || [];
|
||||
|
||||
|
||||
|
||||
isAssigned = assignedRoles.some(role =>
|
||||
userRoleIds.includes(parseInt(role.value)) ||
|
||||
userRoleNames.includes(role.label)
|
||||
);
|
||||
} else if (assignmentType === 'variable') {
|
||||
// For variable-based assignment, we'll include it for now
|
||||
// In a real implementation, you might want to evaluate the variable
|
||||
isAssigned = true;
|
||||
}
|
||||
|
||||
if (isAssigned) {
|
||||
filteredProcesses.push(process);
|
||||
}
|
||||
}
|
||||
|
||||
// Count total processes for pagination (we need to get all processes to filter)
|
||||
const totalProcesses = await prisma.process.count({ where });
|
||||
|
||||
// Calculate pagination info
|
||||
const totalPages = Math.ceil(totalProcesses / take);
|
||||
const hasNextPage = parseInt(page) < totalPages;
|
||||
const hasPrevPage = parseInt(page) > 1;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
processes: filteredProcesses.map(process => ({
|
||||
processID: process.processID,
|
||||
processUUID: process.processUUID,
|
||||
processName: process.processName,
|
||||
processDescription: process.processDescription,
|
||||
processCategory: process.processCategory,
|
||||
processPriority: process.processPriority,
|
||||
processOwner: process.processOwner,
|
||||
processVersion: process.processVersion,
|
||||
processStatus: process.processStatus,
|
||||
isTemplate: process.isTemplate,
|
||||
templateCategory: process.templateCategory,
|
||||
processCreatedDate: process.processCreatedDate,
|
||||
processModifiedDate: process.processModifiedDate,
|
||||
creator: process.creator
|
||||
})),
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages,
|
||||
totalCount: totalProcesses,
|
||||
filteredCount: filteredProcesses.length,
|
||||
limit: take,
|
||||
hasNextPage,
|
||||
hasPrevPage
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching assigned processes:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch assigned processes',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
};
|
||||
}
|
||||
});
|
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