Compare commits

...

9 Commits

Author SHA1 Message Date
aa01c212ff add user validation in staing proccess and form 2025-07-15 10:56:46 +08:00
b5ee79339a Merge branch 'bp-process' of https://git.sena.my/corrad-software/corrad-bp into bp-process
updated form
2025-07-09 13:33:12 +08:00
82646cf6a9 updated form 2025-07-09 13:10:00 +08:00
99b2e43cfe Refactor FormNodeConfiguration and ProcessFlowCanvas for Improved Usability and Code Clarity
- Adjusted indentation and formatting in FormNodeConfiguration.vue for better readability.
- Updated FormKit component usage to ensure consistent styling and functionality in user and role selection dropdowns.
- Enhanced computed properties for filtered users and roles to maintain consistent formatting.
- Changed connection mode in ProcessFlowCanvas.vue from 'loose' to 'strict' to enforce stricter connection rules.
- Improved comments in ProcessFlowCanvas.vue to clarify edge handling logic and preserve existing handle positions during updates.
2025-07-03 12:51:59 +08:00
0cb5ed10b5 Merge branch 'afiq' into bp-process 2025-07-03 12:28:44 +08:00
e10a3745c0 Enhance FormNodeConfiguration with User and Role Management Features
- Refactored user and role assignment sections to improve user experience in the FormNodeConfiguration component.
- Introduced new reactive states for selected user and role, allowing for dynamic selection and assignment.
- Implemented filtering logic to prevent already assigned users and roles from being selectable, enhancing usability.
- Added functionality to remove assigned users and roles with intuitive UI elements for better interaction.
- Updated the handling of assignment type changes to reset selections appropriately, ensuring a smooth user experience.
- Enhanced documentation to reflect the new features and their usage within the form configuration context.
2025-07-03 12:27:36 +08:00
1d1dd00066 Update Error Page Logo for Consistency
- Replaced the logo image in the error.vue component from "logo-word.svg" to "logo-word-black.svg" to ensure visual consistency across the application.
2025-06-26 11:22:49 +08:00
6887a2b9bc Add Form Section Component to Enhance Form Builder Functionality
- Introduced a new Form Section component for grouping related form fields, allowing for better organization and layout within forms.
- Enhanced ComponentPreview to render the Form Section component with customizable properties such as header visibility, collapsibility, and border styles.
- Updated FormBuilderComponents to include the Form Section in the available components list with default properties.
- Implemented drag-and-drop functionality for nested components within the Form Section, improving user experience in form design.
- Enhanced FormBuilderFieldSettingsModal to support configuration options for the Form Section, including visual and spacing settings.
- Improved overall styling and responsiveness of the Form Section component to align with existing UI standards.
- Updated documentation to reflect the addition of the Form Section and its configuration options.
2025-06-24 12:31:21 +08:00
fb81306a68 process initial 2025-06-16 00:52:49 +08:00
27 changed files with 4215 additions and 533 deletions

View File

@ -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>

View File

@ -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;

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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
});
}

View 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

View 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
View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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
View 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();
});
}
});
});

View File

@ -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"
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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"

View File

@ -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")
}

View 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
};
}
});

View 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();
}
});

View 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
};
}
});

View 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'
};
}
});

View 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
};
}
});

View 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
};
}
});

View 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
};
}
});

View 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
};
}
});

View 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);
}
});