Enhance ComponentPreview and FormBuilderFieldSettingsModal for Improved Field Management

- Updated the ComponentPreview component to utilize a new utility function, safeGetField, for safer field value retrieval from form data, enhancing data integrity and error handling.
- Added a new "Read Only" toggle option in the FormBuilderFieldSettingsModal, allowing users to set fields as non-editable, improving form configuration flexibility.
- Refactored various field access patterns to ensure consistent handling of undefined or null values across components.
- Improved the overall user experience by ensuring that field states are accurately reflected and managed during form previews.
This commit is contained in:
Md Afiq Iskandar 2025-07-25 13:15:14 +08:00
parent 8b44adf06e
commit 84e32e4dc7
9 changed files with 315 additions and 241 deletions

View File

@ -26,7 +26,8 @@
<FormKit v-else-if="isInputType" :id="`preview-${component.id}`" :type="component.type" :name="component.props.name" <FormKit v-else-if="isInputType" :id="`preview-${component.id}`" :type="component.type" :name="component.props.name"
:label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder" :label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder"
:validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'" :validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'"
:readonly="isPreview" :options="component.props.options || undefined" :value="component.props.value || undefined" :readonly="component.props.readonly || isPreview"
:disabled="isPreview" :options="component.props.options || undefined" :value="component.props.value || undefined"
:accept="component.props.accept || undefined" :max="component.props.max || undefined" :accept="component.props.accept || undefined" :max="component.props.max || undefined"
:mask="component.props.mask || undefined" :digits="component.props.digits || undefined" :mask="component.props.mask || undefined" :digits="component.props.digits || undefined"
:multiple="component.props.multiple || undefined" :maxSize="component.props.maxSize || undefined" :multiple="component.props.multiple || undefined" :maxSize="component.props.maxSize || undefined"
@ -162,11 +163,11 @@
<!-- Functional groups (in form view) --> <!-- Functional groups (in form view) -->
<div v-else class="repeating-groups space-y-4"> <div v-else class="repeating-groups space-y-4">
<div v-for="(group, groupIndex) in (previewFormData[component.props.name] || [])" :key="groupIndex" <div v-for="(group, groupIndex) in (safeGetField(component.props.name, previewFormData) || [])" :key="groupIndex"
class="group-item border border-gray-200 rounded-md p-3 bg-gray-50"> class="group-item border border-gray-200 rounded-md p-3 bg-gray-50">
<div class="flex justify-between items-center mb-3"> <div class="flex justify-between items-center mb-3">
<h4 class="text-sm font-medium text-gray-700">Item {{ groupIndex + 1 }}</h4> <h4 class="text-sm font-medium text-gray-700">Item {{ groupIndex + 1 }}</h4>
<button v-if="(previewFormData[component.props.name]?.length || 0) > (component.props.minItems || 1)" <button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) > (component.props.minItems || 1)"
type="button" class="text-red-500 hover:text-red-700 text-sm" @click="removeGroupItem(groupIndex)"> type="button" class="text-red-500 hover:text-red-700 text-sm" @click="removeGroupItem(groupIndex)">
{{ component.props.removeText || 'Remove' }} {{ component.props.removeText || 'Remove' }}
</button> </button>
@ -181,7 +182,7 @@
</div> </div>
</div> </div>
<button v-if="(previewFormData[component.props.name]?.length || 0) < (component.props.maxItems || 10)" <button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) < (component.props.maxItems || 10)"
type="button" type="button"
class="inline-flex items-center px-3 py-1.5 border border-blue-600 text-blue-600 bg-white hover:bg-blue-50 rounded text-sm" class="inline-flex items-center px-3 py-1.5 border border-blue-600 text-blue-600 bg-white hover:bg-blue-50 rounded text-sm"
@click="addGroupItem"> @click="addGroupItem">
@ -198,7 +199,7 @@
<label v-if="component.props.label" class="block text-sm font-medium text-gray-700 mb-1"> <label v-if="component.props.label" class="block text-sm font-medium text-gray-700 mb-1">
{{ component.props.label }} {{ component.props.label }}
<span v-if="component.props.showItemCounter" class="text-xs text-gray-500 ml-2"> <span v-if="component.props.showItemCounter" class="text-xs text-gray-500 ml-2">
({{ (previewFormData[component.props.name] || []).length }}/{{ component.props.maxItems || 20 }}) ({{ (safeGetField(component.props.name, previewFormData) || []).length }}/{{ component.props.maxItems || 20 }})
</span> </span>
</label> </label>
@ -217,7 +218,7 @@
</div> </div>
<!-- Bulk Operations Toolbar (if enabled) --> <!-- Bulk Operations Toolbar (if enabled) -->
<div v-if="component.props.bulkOperations && (previewFormData[component.props.name] || []).length > 0" <div v-if="component.props.bulkOperations && (safeGetField(component.props.name, previewFormData) || []).length > 0"
class="mb-3 flex items-center justify-between bg-gray-50 p-2 rounded"> class="mb-3 flex items-center justify-between bg-gray-50 p-2 rounded">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input type="checkbox" :checked="isAllSelected(component.props.name)" <input type="checkbox" :checked="isAllSelected(component.props.name)"
@ -270,7 +271,7 @@
:checked="isItemSelected(component.props.name, index)" :checked="isItemSelected(component.props.name, index)"
@change="toggleItemSelection(component.props.name, index)" @change="toggleItemSelection(component.props.name, index)"
class="mr-2 h-4 w-4 rounded border-gray-300" /> class="mr-2 h-4 w-4 rounded border-gray-300" />
<input :type="component.props.itemType || 'text'" v-model="previewFormData[component.props.name][index]" <input :type="component.props.itemType || 'text'" v-model="safeGetField(component.props.name, previewFormData)[index]"
:placeholder="component.props.placeholder" :placeholder="component.props.placeholder"
:class="getItemInputClasses(component.props.name, index, item)" :class="getItemInputClasses(component.props.name, index, item)"
@blur="validateItem(component.props.name, index, item)" @blur="validateItem(component.props.name, index, item)"
@ -279,7 +280,7 @@
class="ml-2 cursor-move text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity"> class="ml-2 cursor-move text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
<Icon name="material-symbols:drag-indicator" class="w-5 h-5" /> <Icon name="material-symbols:drag-indicator" class="w-5 h-5" />
</div> </div>
<button v-if="(previewFormData[component.props.name]?.length || 0) > (component.props.minItems || 0)" <button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) > (component.props.minItems || 0)"
type="button" class="ml-2 text-red-500 hover:text-red-700" @click="removeListItem(index)"> type="button" class="ml-2 text-red-500 hover:text-red-700" @click="removeListItem(index)">
<Icon name="material-symbols:delete-outline" class="w-5 h-5" /> <Icon name="material-symbols:delete-outline" class="w-5 h-5" />
</button> </button>
@ -290,7 +291,7 @@
{{ validationErrors[component.props.name] }} {{ validationErrors[component.props.name] }}
</div> </div>
<button v-if="(previewFormData[component.props.name]?.length || 0) < (component.props.maxItems || 20)" <button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) < (component.props.maxItems || 20)"
type="button" type="button"
class="inline-flex items-center px-3 py-1.5 border border-blue-600 text-blue-600 bg-white hover:bg-blue-50 rounded text-sm" class="inline-flex items-center px-3 py-1.5 border border-blue-600 text-blue-600 bg-white hover:bg-blue-50 rounded text-sm"
@click="addListItem"> @click="addListItem">
@ -299,7 +300,7 @@
</button> </button>
<!-- Export Button (if enabled) --> <!-- Export Button (if enabled) -->
<div v-if="component.props.exportFormat && (previewFormData[component.props.name] || []).length > 0" <div v-if="component.props.exportFormat && (safeGetField(component.props.name, previewFormData) || []).length > 0"
class="mt-2"> class="mt-2">
<button @click="exportItems(component.props.name, component.props.exportFormat)" <button @click="exportItems(component.props.name, component.props.exportFormat)"
class="text-xs text-green-600 hover:text-green-800 flex items-center"> class="text-xs text-green-600 hover:text-green-800 flex items-center">
@ -316,7 +317,7 @@
<div v-else-if="component.type === 'repeating-table'" class="repeating-table-wrapper"> <div v-else-if="component.type === 'repeating-table'" class="repeating-table-wrapper">
<RepeatingTable <RepeatingTable
:config="component.props" :config="component.props"
:model-value="previewFormData[component.props.name] || []" :model-value="safeGetField(component.props.name, previewFormData) || []"
:is-preview="isPreview" :is-preview="isPreview"
@update:model-value="updateTableData" @update:model-value="updateTableData"
/> />
@ -515,6 +516,8 @@
import { useNuxtApp } from '#app'; import { useNuxtApp } from '#app';
import { useFormBuilderStore } from '~/stores/formBuilder'; import { useFormBuilderStore } from '~/stores/formBuilder';
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue'; import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
import { safeGetField } from '~/composables/safeGetField';
import { onMounted, onUnmounted, watch, computed } from 'vue';
const props = defineProps({ const props = defineProps({
component: { component: {
@ -549,6 +552,27 @@ const sectionDropStates = ref({});
const showNestedSettingsModal = ref(false); const showNestedSettingsModal = ref(false);
const selectedNestedComponent = ref(null); const selectedNestedComponent = ref(null);
// Track timers and DOM elements for cleanup
let lightbox = null;
let importInput = null;
let timers = [];
onUnmounted(() => {
// Clean up any lightbox DOM element
if (lightbox && document.body.contains(lightbox)) {
document.body.removeChild(lightbox);
lightbox = null;
}
// Clean up import input
if (importInput && document.body.contains(importInput)) {
document.body.removeChild(importInput);
importInput = null;
}
// Clear all timers
timers.forEach(t => clearTimeout(t));
timers = [];
});
// Initialize data for components // Initialize data for components
onMounted(() => { onMounted(() => {
// Initialize search and selection data for dynamic lists // Initialize search and selection data for dynamic lists
@ -560,7 +584,7 @@ onMounted(() => {
validationErrors.value[listName] = ''; validationErrors.value[listName] = '';
// Initialize form data with default items if they exist and form data is empty // Initialize form data with default items if they exist and form data is empty
const currentFormData = formStore.previewFormData[listName]; const currentFormData = safeGetField(listName, formStore.previewFormData);
const defaultItems = props.component.props.defaultItems; const defaultItems = props.component.props.defaultItems;
if ((!currentFormData || currentFormData.length === 0) && defaultItems && defaultItems.length > 0) { if ((!currentFormData || currentFormData.length === 0) && defaultItems && defaultItems.length > 0) {
@ -593,7 +617,7 @@ const addGroupItem = () => {
if (!groupName) return; if (!groupName) return;
// Get current groups or initialize empty array // Get current groups or initialize empty array
const currentGroups = formStore.previewFormData[groupName] || []; const currentGroups = safeGetField(groupName, formStore.previewFormData) || [];
// Create a new empty group // Create a new empty group
const newGroup = {}; const newGroup = {};
@ -620,7 +644,7 @@ const removeGroupItem = (index) => {
if (!groupName) return; if (!groupName) return;
// Get current groups // Get current groups
const currentGroups = [...(formStore.previewFormData[groupName] || [])]; const currentGroups = [...(safeGetField(groupName, formStore.previewFormData) || [])];
// Remove the group at the specified index // Remove the group at the specified index
currentGroups.splice(index, 1); currentGroups.splice(index, 1);
@ -637,7 +661,7 @@ const addListItem = () => {
if (!listName) return; if (!listName) return;
// Get current items or initialize empty array // Get current items or initialize empty array
const currentItems = [...(formStore.previewFormData[listName] || [])]; const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
// Add an empty item // Add an empty item
currentItems.push(''); currentItems.push('');
@ -654,7 +678,7 @@ const removeListItem = (index) => {
if (!listName) return; if (!listName) return;
// Get current items // Get current items
const currentItems = [...(formStore.previewFormData[listName] || [])]; const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
// Remove the item at the specified index // Remove the item at the specified index
currentItems.splice(index, 1); currentItems.splice(index, 1);
@ -723,7 +747,7 @@ const validateItem = (listName, index, value) => {
const checkDuplicates = (listName, newValue, currentIndex) => { const checkDuplicates = (listName, newValue, currentIndex) => {
if (props.component.props.allowDuplicates) return true; if (props.component.props.allowDuplicates) return true;
const currentItems = formStore.previewFormData[listName] || []; const currentItems = safeGetField(listName, formStore.previewFormData) || [];
const duplicateIndex = currentItems.findIndex((item, index) => const duplicateIndex = currentItems.findIndex((item, index) =>
index !== currentIndex && item.toLowerCase() === newValue.toLowerCase() index !== currentIndex && item.toLowerCase() === newValue.toLowerCase()
); );
@ -762,7 +786,7 @@ const getItemInputClasses = (listName, index, value) => {
// Filter items based on search query // Filter items based on search query
const getFilteredItems = (listName) => { const getFilteredItems = (listName) => {
const items = formStore.previewFormData[listName] || []; const items = safeGetField(listName, formStore.previewFormData) || [];
const query = searchQuery.value[listName]; const query = searchQuery.value[listName];
if (!query || !props.component.props.enableSearch) { if (!query || !props.component.props.enableSearch) {
@ -795,13 +819,13 @@ const toggleItemSelection = (listName, index) => {
}; };
const isAllSelected = (listName) => { const isAllSelected = (listName) => {
const items = formStore.previewFormData[listName] || []; const items = safeGetField(listName, formStore.previewFormData) || [];
const selected = selectedItems.value[listName] || []; const selected = selectedItems.value[listName] || [];
return items.length > 0 && selected.length === items.length; return items.length > 0 && selected.length === items.length;
}; };
const toggleSelectAll = (listName) => { const toggleSelectAll = (listName) => {
const items = formStore.previewFormData[listName] || []; const items = safeGetField(listName, formStore.previewFormData) || [];
if (isAllSelected(listName)) { if (isAllSelected(listName)) {
selectedItems.value[listName] = []; selectedItems.value[listName] = [];
@ -812,7 +836,7 @@ const toggleSelectAll = (listName) => {
const deleteSelectedItems = (listName) => { const deleteSelectedItems = (listName) => {
if (!props.component.props.confirmDelete || confirm('Are you sure you want to delete the selected items?')) { if (!props.component.props.confirmDelete || confirm('Are you sure you want to delete the selected items?')) {
const currentItems = [...(formStore.previewFormData[listName] || [])]; const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
const selected = selectedItems.value[listName] || []; const selected = selectedItems.value[listName] || [];
// Sort indices in descending order to avoid index shifting issues // Sort indices in descending order to avoid index shifting issues
@ -834,7 +858,7 @@ const deleteSelectedItems = (listName) => {
// Import/Export functionality // Import/Export functionality
const exportItems = (listName, format) => { const exportItems = (listName, format) => {
const items = formStore.previewFormData[listName] || []; const items = safeGetField(listName, formStore.previewFormData) || [];
let content = ''; let content = '';
let filename = `${listName}_items`; let filename = `${listName}_items`;
let mimeType = 'text/plain'; let mimeType = 'text/plain';
@ -867,20 +891,17 @@ const exportItems = (listName, format) => {
}; };
const showImportModal = (listName) => { const showImportModal = (listName) => {
// This would open a modal for importing items importInput = document.createElement('input');
// For now, let's use a simple file input importInput.type = 'file';
const input = document.createElement('input'); importInput.accept = '.txt,.csv,.json';
input.type = 'file'; importInput.onchange = (event) => {
input.accept = '.txt,.csv,.json';
input.onchange = (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (file) { if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const content = e.target.result;
let importedItems = []; let importedItems = [];
try { try {
const content = e.target.result;
if (file.name.endsWith('.json')) { if (file.name.endsWith('.json')) {
importedItems = JSON.parse(content); importedItems = JSON.parse(content);
} else if (file.name.endsWith('.csv')) { } else if (file.name.endsWith('.csv')) {
@ -888,22 +909,15 @@ const showImportModal = (listName) => {
} else { } else {
importedItems = content.split('\n').filter(Boolean); importedItems = content.split('\n').filter(Boolean);
} }
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
// Add imported items to current list
const currentItems = [...(formStore.previewFormData[listName] || [])];
const newItems = [...currentItems, ...importedItems]; const newItems = [...currentItems, ...importedItems];
// Respect max items limit
const maxItems = props.component.props.maxItems || 20; const maxItems = props.component.props.maxItems || 20;
if (newItems.length > maxItems) { if (newItems.length > maxItems) {
newItems.splice(maxItems); newItems.splice(maxItems);
alert(`Import limited to ${maxItems} items total.`); alert(`Import limited to ${maxItems} items total.`);
} }
// Update form data
const updatedData = { ...formStore.previewFormData, [listName]: newItems }; const updatedData = { ...formStore.previewFormData, [listName]: newItems };
formStore.updatePreviewFormData(updatedData); formStore.updatePreviewFormData(updatedData);
} catch (error) { } catch (error) {
alert('Error importing file. Please check the file format.'); alert('Error importing file. Please check the file format.');
} }
@ -911,7 +925,7 @@ const showImportModal = (listName) => {
reader.readAsText(file); reader.readAsText(file);
} }
}; };
input.click(); importInput.click();
}; };
// Handle button click // Handle button click
@ -971,8 +985,11 @@ const handleImageClick = (event) => {
const imageUrl = props.component.props.imageUrl; const imageUrl = props.component.props.imageUrl;
if (!imageUrl) return; if (!imageUrl) return;
// Create a lightbox effect to view the image // Remove previous lightbox if exists
const lightbox = document.createElement('div'); if (lightbox && document.body.contains(lightbox)) {
document.body.removeChild(lightbox);
}
lightbox = document.createElement('div');
lightbox.style.position = 'fixed'; lightbox.style.position = 'fixed';
lightbox.style.top = '0'; lightbox.style.top = '0';
lightbox.style.left = '0'; lightbox.style.left = '0';
@ -994,7 +1011,10 @@ const handleImageClick = (event) => {
// Add close functionality // Add close functionality
lightbox.onclick = () => { lightbox.onclick = () => {
document.body.removeChild(lightbox); if (document.body.contains(lightbox)) {
document.body.removeChild(lightbox);
}
lightbox = null;
}; };
// Prevent image click from closing the lightbox // Prevent image click from closing the lightbox

View File

@ -217,6 +217,16 @@
placeholder="e.g., We'll use this to send you updates about your order" placeholder="e.g., We'll use this to send you updates about your order"
rows="2" rows="2"
/> />
<FormKit
v-if="showField('readonly')"
type="switch"
label="Read Only"
name="readonly"
v-model="configModel.readonly"
help="Users cannot edit this field value"
:classes="{ outer: 'field-wrapper' }"
/>
</template> </template>
</div> </div>
</div> </div>
@ -1735,7 +1745,8 @@ const showField = (fieldName) => {
value: ['heading', 'paragraph', 'hidden'], 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', 'form-section', 'info-display', 'dynamic-list', 'repeating-table', 'repeating-group'], 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', 'repeating-group'],
options: ['select', 'checkbox', 'radio'], options: ['select', 'checkbox', 'radio'],
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', 'repeating-group'] 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', 'repeating-group'],
readonly: ['text', 'number', 'email', 'textarea', 'mask', 'url', 'tel']
} }
return fieldConfig[fieldName]?.includes(props.component.type) || false return fieldConfig[fieldName]?.includes(props.component.type) || false
@ -1779,7 +1790,7 @@ const isTextBasedField = computed(() => {
// Type changing functionality // Type changing functionality
const compatibilityGroups = { const compatibilityGroups = {
// Text-based inputs (can switch between each other) // Text-based inputs (can switch between each other)
textInputs: ['text', 'textarea', 'email', 'password', 'url', 'tel', 'mask'], textInputs: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask'],
// Selection inputs (can switch between each other) // Selection inputs (can switch between each other)
selectionInputs: ['select', 'radio', 'checkbox'], selectionInputs: ['select', 'radio', 'checkbox'],

View File

@ -292,10 +292,13 @@ const handleFieldChange = (fieldName, newValue, oldValue) => {
}; };
// Watch for script changes and reinitialize // Watch for script changes and reinitialize
watch(() => props.customScript, (newScript, oldScript) => { watch(() => props.customScript, async (newScript, oldScript) => {
if (newScript !== oldScript) { if (newScript !== oldScript) {
isScriptInitialized.value = false; isScriptInitialized.value = false;
initializeScript(); await nextTick();
setTimeout(() => {
initializeScript();
}, 200);
} }
}); });
@ -325,10 +328,11 @@ watch(() => props.customCSS, () => {
}); });
// Lifecycle hooks // Lifecycle hooks
onMounted(() => { onMounted(async () => {
await nextTick();
setTimeout(() => { setTimeout(() => {
initializeScript(); initializeScript();
}, 500); // Delay to ensure parent DOM and initial props are settled }, 200); // Delay to ensure all FormKit fields are loaded
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@ -0,0 +1,11 @@
// Utility to safely get a field value from a form data object
export function safeGetField(field, formData) {
if (formData && Object.prototype.hasOwnProperty.call(formData, field)) {
return formData[field] !== undefined && formData[field] !== null ? formData[field] : '';
}
if (process.env.NODE_ENV !== 'production') {
// Only warn in development
console.warn(`Field '${field}' is missing or inaccessible.`);
}
return '';
}

View File

@ -1,232 +1,273 @@
// Manager Approval Form Custom Script Engine // Manager Approval Form Custom Script Engine
// This script provides dynamic behavior for the manager approval form // This script provides dynamic behavior for the manager approval form
console.log('Manager Approval Form Script Loaded'); console.log("Manager Approval Form Script Loaded");
// Auto-set approval date to today // Auto-set approval date to today
const setApprovalDate = () => { const setApprovalDate = () => {
const today = new Date(); const today = new Date();
const formattedDate = today.toISOString().split('T')[0]; // YYYY-MM-DD format const formattedDate = today.toISOString().split("T")[0]; // YYYY-MM-DD format
setField('approval_date', formattedDate); setField("approval_date", formattedDate);
}; };
// Calculate and display recommended approval amounts // Calculate and display recommended approval amounts
const calculateRecommendedAmounts = () => { const calculateRecommendedAmounts = () => {
const totalClaimed = parseFloat(getField('total_cost_display')) || 0; const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
const policyLimit = parseFloat(getField('policy_limit_display')) || 0; const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
const overBudget = parseFloat(getField('over_budget_amount_display')) || 0; const overBudget = parseFloat(getField("over_budget_amount_display")) || 0;
if (totalClaimed > 0 && policyLimit > 0) { if (totalClaimed > 0 && policyLimit > 0) {
const percentageOver = ((overBudget / policyLimit) * 100).toFixed(1); const percentageOver = ((overBudget / policyLimit) * 100).toFixed(1);
showInfo(`This claim is ${percentageOver}% over the policy limit. Policy limit: RM${policyLimit.toFixed(2)}, Claimed: RM${totalClaimed.toFixed(2)}`); showInfo(
`This claim is ${percentageOver}% over the policy limit. Policy limit: RM${policyLimit.toFixed(2)}, Claimed: RM${totalClaimed.toFixed(2)}`
);
} }
}; };
// Validate manager decision and provide guidance // Validate manager decision and provide guidance
const handleDecisionChange = (decision) => { const handleDecisionChange = (decision) => {
const totalClaimed = parseFloat(getField('total_cost_display')) || 0; const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
const policyLimit = parseFloat(getField('policy_limit_display')) || 0; const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
console.log('Manager decision changed to:', decision); console.log("Manager decision changed to:", decision);
switch(decision) { switch (decision) {
case 'approve_full': case "approve_full":
showField('custom_approved_amount'); showField("custom_approved_amount");
showInfo(`💰 Approving full amount: RM${totalClaimed.toFixed(2)}. You can enter a custom amount if needed.`); showInfo(
setField('custom_approved_amount', totalClaimed.toString()); `💰 Approving full amount: RM${totalClaimed.toFixed(2)}. You can enter a custom amount if needed.`
);
setField("custom_approved_amount", totalClaimed.toString());
break; break;
case 'approve_policy': case "approve_policy":
hideField('custom_approved_amount'); hideField("custom_approved_amount");
showInfo(`⚖️ Approving policy limit only: RM${policyLimit.toFixed(2)}. Employee will be notified of the reduced amount.`); showInfo(
`⚖️ Approving policy limit only: RM${policyLimit.toFixed(2)}. Employee will be notified of the reduced amount.`
);
break; break;
case 'reject': case "reject":
hideField('custom_approved_amount'); hideField("custom_approved_amount");
showError('❌ Claim will be rejected. Please provide detailed comments explaining the rejection reason.'); showError(
"❌ Claim will be rejected. Please provide detailed comments explaining the rejection reason."
);
break; break;
default: default:
hideField('custom_approved_amount'); hideField("custom_approved_amount");
} }
}; };
// Validate custom approved amount // Validate custom approved amount
const validateCustomAmount = (amount) => { const validateCustomAmount = (amount) => {
const numAmount = parseFloat(amount) || 0; const numAmount = parseFloat(amount) || 0;
const totalClaimed = parseFloat(getField('total_cost_display')) || 0; const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
const policyLimit = parseFloat(getField('policy_limit_display')) || 0; const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
if (numAmount < 0) { if (numAmount < 0) {
showError('Approved amount cannot be negative'); showError("Approved amount cannot be negative");
return false; return false;
} }
if (numAmount > totalClaimed) { if (numAmount > totalClaimed) {
showError(`Approved amount (RM${numAmount}) cannot exceed claimed amount (RM${totalClaimed})`); showError(
`Approved amount (RM${numAmount}) cannot exceed claimed amount (RM${totalClaimed})`
);
return false; return false;
} }
if (numAmount > 0 && numAmount < policyLimit) { if (numAmount > 0 && numAmount < policyLimit) {
showInfo(`Custom amount (RM${numAmount}) is less than policy limit (RM${policyLimit}). Consider approving policy limit instead.`); showInfo(
`Custom amount (RM${numAmount}) is less than policy limit (RM${policyLimit}). Consider approving policy limit instead.`
);
} }
return true; return true;
}; };
// Validate manager comments based on decision // Validate manager comments based on decision
const validateComments = (comments, decision) => { const validateComments = (comments, decision) => {
if (!comments || comments.trim().length < 10) { if (!comments || comments.trim().length < 10) {
showError('Please provide detailed comments (minimum 10 characters)'); showError("Please provide detailed comments (minimum 10 characters)");
return false; return false;
} }
if (decision === 'reject' && comments.length < 50) { if (decision === "reject" && comments.length < 50) {
showError('Rejection requires detailed explanation (minimum 50 characters)'); showError(
"Rejection requires detailed explanation (minimum 50 characters)"
);
return false; return false;
} }
if (decision === 'approve_full') { if (decision === "approve_full") {
const totalClaimed = parseFloat(getField('total_cost_display')) || 0; const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
const policyLimit = parseFloat(getField('policy_limit_display')) || 0; const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
const overBudget = totalClaimed - policyLimit; const overBudget = totalClaimed - policyLimit;
if (overBudget > 500 && !comments.toLowerCase().includes('business')) { if (overBudget > 500 && !comments.toLowerCase().includes("business")) {
showInfo('Consider mentioning business justification for approving over-budget amounts > RM500'); showInfo(
"Consider mentioning business justification for approving over-budget amounts > RM500"
);
} }
} }
return true; return true;
}; };
// Format manager name (title case) // Format manager name (title case)
const formatManagerName = (name) => { const formatManagerName = (name) => {
if (!name || typeof name !== 'string') return name; if (!name || typeof name !== "string") return name;
return name return name
.toLowerCase() .toLowerCase()
.split(' ') .split(" ")
.map(word => word.charAt(0).toUpperCase() + word.slice(1)) .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '); .join(" ");
}; };
// Form validation before submission // Form validation before submission
const validateApprovalForm = () => { const validateApprovalForm = () => {
let isValid = true; let isValid = true;
const errors = []; const errors = [];
// Check required fields // Check required fields
const requiredFields = { const requiredFields = {
'manager_decision': 'Approval Decision', manager_decision: "Approval Decision",
'manager_comments': 'Manager Comments', manager_comments: "Manager Comments",
'manager_name': 'Manager Name', manager_name: "Manager Name",
'approval_date': 'Approval Date' approval_date: "Approval Date",
}; };
Object.entries(requiredFields).forEach(([fieldName, displayName]) => { Object.entries(requiredFields).forEach(([fieldName, displayName]) => {
const value = getField(fieldName); const value = getField(fieldName);
if (!value || value.toString().trim() === '') { if (!value || value.toString().trim() === "") {
errors.push(`${displayName} is required`); errors.push(`${displayName} is required`);
isValid = false; isValid = false;
} }
}); });
// Validate decision-specific requirements // Validate decision-specific requirements
const decision = getField('manager_decision'); const decision = getField("manager_decision");
const comments = getField('manager_comments'); const comments = getField("manager_comments");
const customAmount = getField('custom_approved_amount'); const customAmount = getField("custom_approved_amount");
if (decision && comments) { if (decision && comments) {
if (!validateComments(comments, decision)) { if (!validateComments(comments, decision)) {
isValid = false; isValid = false;
} }
} }
if (decision === 'approve_full' && customAmount) { if (decision === "approve_full" && customAmount) {
if (!validateCustomAmount(customAmount)) { if (!validateCustomAmount(customAmount)) {
isValid = false; isValid = false;
} }
} }
// Validate approval date is not in the future // Validate approval date is not in the future
const approvalDate = getField('approval_date'); const approvalDate = getField("approval_date");
if (approvalDate) { if (approvalDate) {
const selectedDate = new Date(approvalDate); const selectedDate = new Date(approvalDate);
const today = new Date(); const today = new Date();
today.setHours(23, 59, 59, 999); // End of today today.setHours(23, 59, 59, 999); // End of today
if (selectedDate > today) { if (selectedDate > today) {
errors.push('Approval date cannot be in the future'); errors.push("Approval date cannot be in the future");
isValid = false; isValid = false;
} }
} }
// Show validation results // Show validation results
if (errors.length > 0) { if (errors.length > 0) {
showError(`Please fix the following errors:\n${errors.join('\n• ')}`); showError(`Please fix the following errors:\n${errors.join("\n• ")}`);
} else { } else {
showSuccess('Form validation passed! Ready to submit approval decision.'); showSuccess("Form validation passed! Ready to submit approval decision.");
} }
return isValid; return isValid;
}; };
// Set up field change handlers // Set up field change handlers
onFieldChange('manager_decision', (newValue) => { onFieldChange("manager_decision", (newValue) => {
if (newValue) { if (newValue) {
handleDecisionChange(newValue); handleDecisionChange(newValue);
} }
}); });
onFieldChange('custom_approved_amount', (newValue) => { onFieldChange("custom_approved_amount", (newValue) => {
if (newValue) { if (newValue) {
validateCustomAmount(newValue); validateCustomAmount(newValue);
} }
}); });
onFieldChange('manager_comments', (newValue) => { onFieldChange("manager_comments", (newValue) => {
const decision = getField('manager_decision'); const decision = getField("manager_decision");
if (newValue && decision) { if (newValue && decision) {
validateComments(newValue, decision); validateComments(newValue, decision);
} }
}); });
onFieldChange('manager_name', (newValue) => { onFieldChange("manager_name", (newValue) => {
if (newValue && typeof newValue === 'string') { if (newValue && typeof newValue === "string") {
const formatted = formatManagerName(newValue); const formatted = formatManagerName(newValue);
if (formatted !== newValue) { if (formatted !== newValue) {
setTimeout(() => { setTimeout(() => {
setField('manager_name', formatted); setField("manager_name", formatted);
}, 100); }, 100);
} }
} }
}); });
// Initialize form on load // Wait until all required fields are present before running initialization
setTimeout(() => { function waitForFields(fields, callback, maxAttempts = 20) {
setApprovalDate(); let attempts = 0;
calculateRecommendedAmounts(); function check() {
const allPresent = fields.every(f => getField(f) !== undefined);
// Make display fields read-only by adding visual styling if (allPresent) {
const displayFields = [ callback();
'claim_summary', 'employee_name_display', 'department_display', } else if (attempts < maxAttempts) {
'trip_purpose_display', 'destination_display', 'total_cost_display', attempts++;
'policy_limit_display', 'over_budget_amount_display' setTimeout(check, 100);
]; } else {
showError("Some required fields are missing. Please reload the page.");
displayFields.forEach(fieldName => {
const fieldElement = document.querySelector(`[data-name="${fieldName}"] input`);
if (fieldElement) {
fieldElement.style.backgroundColor = '#f3f4f6';
fieldElement.style.cursor = 'not-allowed';
fieldElement.readOnly = true;
} }
}); }
check();
showInfo('Review the claim details above and make your approval decision below.'); }
}, 1000);
waitForFields(
[
"total_cost_display",
"policy_limit_display",
"over_budget_amount_display",
// add any other required fields here
],
() => {
setApprovalDate();
calculateRecommendedAmounts();
showInfo("Review the claim details above and make your approval decision below.");
}
);
// Expose validation function for form submission // Expose validation function for form submission
window.validateManagerApprovalForm = validateApprovalForm; window.validateManagerApprovalForm = validateApprovalForm;
console.log('Manager Approval Form Script initialized successfully'); console.log("Manager Approval Form Script initialized successfully");
// Conditional logic for field: custom_approved_amount
onFieldChange("manager_decision", function () {
if (getField("manager_decision") === "approve_full") {
showField("custom_approved_amount");
} else {
hideField("custom_approved_amount");
}
});
// Initial evaluation for field: custom_approved_amount
(function () {
if (getField("manager_decision") === "approve_full") {
showField("custom_approved_amount");
} else {
hideField("custom_approved_amount");
}
})();

View File

@ -158,60 +158,42 @@
} }
}, },
{ {
"type": "text", "type": "number",
"props": { "props": {
"help": "Total amount claimed by employee", "help": "Total amount claimed by employee",
"name": "total_cost_display", "name": "total_cost_display",
"type": "text", "type": "number",
"label": "Total Claimed Amount (RM)", "label": "Total Claimed Amount (RM)",
"width": "33.33%", "width": "33.33%",
"gridColumn": "span 4", "gridColumn": "span 4",
"validation": "", "validation": "",
"placeholder": "0.00", "placeholder": "0.00"
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
} }
}, },
{ {
"type": "text", "type": "number",
"props": { "props": {
"help": "Maximum allowed per company policy", "help": "Maximum allowed per company policy",
"name": "policy_limit_display", "name": "policy_limit_display",
"type": "text", "type": "number",
"label": "Policy Limit (RM)", "label": "Policy Limit (RM)",
"width": "33.33%", "width": "33.33%",
"gridColumn": "span 4", "gridColumn": "span 4",
"validation": "", "validation": "",
"placeholder": "0.00", "placeholder": "0.00"
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
} }
}, },
{ {
"type": "text", "type": "number",
"props": { "props": {
"help": "Amount exceeding policy limits", "help": "Amount exceeding policy limits",
"name": "over_budget_amount_display", "name": "over_budget_amount_display",
"type": "text", "type": "number",
"label": "Over Budget Amount (RM)", "label": "Over Budget Amount (RM)",
"width": "33.33%", "width": "33.33%",
"gridColumn": "span 4", "gridColumn": "span 4",
"validation": "", "validation": "",
"placeholder": "0.00", "placeholder": "0.00"
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
} }
}, },
{ {
@ -254,9 +236,18 @@
"label": "Approval Decision", "label": "Approval Decision",
"width": "100%", "width": "100%",
"options": [ "options": [
{ "label": "✅ Approve Full Amount - Employee will receive the full claimed amount", "value": "approve_full" }, {
{ "label": "⚠️ Approve Policy Limit Only - Employee will receive only the policy-allowed amount", "value": "approve_policy" }, "label": "✅ Approve Full Amount - Employee will receive the full claimed amount",
{ "label": "❌ Reject Claim - Claim will be denied entirely", "value": "reject" } "value": "approve_full"
},
{
"label": "⚠️ Approve Policy Limit Only - Employee will receive only the policy-allowed amount",
"value": "approve_policy"
},
{
"label": "❌ Reject Claim - Claim will be denied entirely",
"value": "reject"
}
], ],
"gridColumn": "span 12", "gridColumn": "span 12",
"validation": "required", "validation": "required",
@ -286,8 +277,8 @@
"conditions": [ "conditions": [
{ {
"field": "manager_decision", "field": "manager_decision",
"operator": "eq", "value": "approve_full",
"value": "approve_full" "operator": "eq"
} }
] ]
} }
@ -337,10 +328,19 @@
"label": "Additional Requirements", "label": "Additional Requirements",
"width": "100%", "width": "100%",
"options": [ "options": [
{ "label": "Require additional documentation from employee", "value": "require_docs" }, {
{ "label": "Schedule follow-up meeting with employee", "value": "follow_up" }, "label": "Require additional documentation from employee",
"value": "require_docs"
},
{
"label": "Schedule follow-up meeting with employee",
"value": "follow_up"
},
{ "label": "Flag for finance team review", "value": "finance_review" }, { "label": "Flag for finance team review", "value": "finance_review" },
{ "label": "Add to employee's travel record for future reference", "value": "record_flag" } {
"label": "Add to employee's travel record for future reference",
"value": "record_flag"
}
], ],
"gridColumn": "span 12", "gridColumn": "span 12",
"validation": "", "validation": "",
@ -405,4 +405,4 @@
} }
} }
} }
] ]

View File

@ -234,7 +234,7 @@
} }
}, },
{ {
"type": "text", "type": "number",
"props": { "props": {
"help": "Enter transportation costs (flights, trains, taxis, etc.)", "help": "Enter transportation costs (flights, trains, taxis, etc.)",
"name": "transport_cost", "name": "transport_cost",
@ -243,17 +243,11 @@
"width": "50%", "width": "50%",
"gridColumn": "span 6", "gridColumn": "span 6",
"validation": "required|min:0", "validation": "required|min:0",
"placeholder": "0.00", "placeholder": "0.00"
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
} }
}, },
{ {
"type": "text", "type": "number",
"props": { "props": {
"help": "Enter accommodation costs (hotels, lodging, etc.)", "help": "Enter accommodation costs (hotels, lodging, etc.)",
"name": "accommodation_cost", "name": "accommodation_cost",
@ -262,17 +256,11 @@
"width": "50%", "width": "50%",
"gridColumn": "span 6", "gridColumn": "span 6",
"validation": "required|min:0", "validation": "required|min:0",
"placeholder": "0.00", "placeholder": "0.00"
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
} }
}, },
{ {
"type": "text", "type": "number",
"props": { "props": {
"help": "Enter meal and food expenses", "help": "Enter meal and food expenses",
"name": "meals_cost", "name": "meals_cost",
@ -281,17 +269,11 @@
"width": "50%", "width": "50%",
"gridColumn": "span 6", "gridColumn": "span 6",
"validation": "required|min:0", "validation": "required|min:0",
"placeholder": "0.00", "placeholder": "0.00"
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
} }
}, },
{ {
"type": "text", "type": "number",
"props": { "props": {
"help": "Enter other miscellaneous expenses", "help": "Enter other miscellaneous expenses",
"name": "other_cost", "name": "other_cost",
@ -300,33 +282,20 @@
"width": "50%", "width": "50%",
"gridColumn": "span 6", "gridColumn": "span 6",
"validation": "required|min:0", "validation": "required|min:0",
"placeholder": "0.00", "placeholder": "0.00"
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
} }
}, },
{ {
"type": "text", "type": "number",
"props": { "props": {
"help": "This will be automatically calculated from the above costs",
"name": "total_cost_display", "name": "total_cost_display",
"type": "text", "type": "number",
"label": "Total Claim Amount (RM)", "label": "Total Claim Amount (RM)",
"value": "0.00",
"width": "100%", "width": "100%",
"gridColumn": "span 12", "gridColumn": "span 12",
"help": "This will be automatically calculated from the above costs",
"placeholder": "0.00",
"validation": "", "validation": "",
"conditionalLogic": { "placeholder": "0.00"
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
} }
}, },
{ {
@ -381,4 +350,4 @@
} }
} }
} }
] ]

View File

@ -377,9 +377,9 @@
"assignedUsers": [], "assignedUsers": [],
"inputMappings": [ "inputMappings": [
{ "processVariable": "claimSummary", "formField": "claim_summary" }, { "processVariable": "claimSummary", "formField": "claim_summary" },
{ "processVariable": "totalCost", "formField": "total_cost" }, { "processVariable": "totalCost", "formField": "total_cost_display" },
{ "processVariable": "reimbursementAmount", "formField": "reimbursement_amount" }, { "processVariable": "overBudgetAmount", "formField": "over_budget_amount_display" },
{ "processVariable": "overBudgetAmount", "formField": "over_budget_amount" } { "processVariable": "totalAllowed", "formField": "policy_limit_display" }
], ],
"assignmentType": "roles", "assignmentType": "roles",
"outputMappings": [ "outputMappings": [

View File

@ -462,12 +462,24 @@ function substituteVariables(str, variables) {
function applyInputMappings(nodeData, processVars, formData) { function applyInputMappings(nodeData, processVars, formData) {
const { inputMappings = [] } = nodeData; const { inputMappings = [] } = nodeData;
console.log('[Workflow] Applying input mappings:', inputMappings); console.log('[Workflow] Applying input mappings:', inputMappings);
inputMappings.forEach(mapping => { inputMappings.forEach(mapping => {
const { processVariable, formField } = mapping; const { processVariable, formField } = mapping;
if (processVars[processVariable] !== undefined) { if (processVars[processVariable] !== undefined) {
formData[formField] = processVars[processVariable]; let value = processVars[processVariable];
console.log(`[Workflow] Mapped ${processVariable} -> ${formField}:`, processVars[processVariable]);
// Check if the form field is a date field
const fieldDef = nodeData.formComponents?.find(f => f.props?.name === formField);
if (fieldDef && fieldDef.type === 'date' && value) {
// Convert to YYYY-MM-DD
const d = new Date(value);
if (!isNaN(d)) {
value = d.toISOString().slice(0, 10);
}
}
formData[formField] = value;
console.log(`[Workflow] Mapped ${processVariable} -> ${formField}:`, value);
} }
}); });
} }
@ -915,6 +927,9 @@ watch(currentStep, async (newStep) => {
} }
// Update form store with form components and data for ComponentPreview // Update form store with form components and data for ComponentPreview
// Clear previous form state to prevent stale/leaked fields
formStore.formComponents = [];
formStore.updatePreviewFormData({});
if (currentForm.value?.formComponents) { if (currentForm.value?.formComponents) {
formStore.formComponents = currentForm.value.formComponents; formStore.formComponents = currentForm.value.formComponents;
formStore.updatePreviewFormData(formData.value); formStore.updatePreviewFormData(formData.value);
@ -1330,6 +1345,7 @@ function getConditionGroupResult(conditionGroup, variables) {
:form-components="currentForm.formComponents" :form-components="currentForm.formComponents"
:form-data="formData" :form-data="formData"
@script-generated="handleConditionalLogicGenerated" @script-generated="handleConditionalLogicGenerated"
:key="currentNode.value?.id"
/> />
<!-- Form Script Engine for conditional logic + custom script --> <!-- Form Script Engine for conditional logic + custom script -->
@ -1341,6 +1357,7 @@ function getConditionGroupResult(conditionGroup, variables) {
:form-events="currentForm.formEvents || { onLoad: true, onFieldChange: true }" :form-events="currentForm.formEvents || { onLoad: true, onFieldChange: true }"
:script-mode="currentForm.scriptMode || 'safe'" :script-mode="currentForm.scriptMode || 'safe'"
@field-change="handleScriptFieldChange" @field-change="handleScriptFieldChange"
:key="currentNode.value?.id"
/> />
<!-- FormKit form wrapper --> <!-- FormKit form wrapper -->
@ -1352,10 +1369,11 @@ function getConditionGroupResult(conditionGroup, variables) {
:actions="false" :actions="false"
:incomplete-message="false" :incomplete-message="false"
validation-visibility="submit" validation-visibility="submit"
:key="currentNode.value?.id"
> >
<div class="grid grid-cols-12 gap-2"> <div class="grid grid-cols-12 gap-2">
<template v-for="(component, index) in currentForm.formComponents" :key="index"> <template v-for="(component, index) in currentForm.formComponents" :key="index">
<ComponentPreview <ComponentPreview
:component="component" :component="component"
:is-preview="false" :is-preview="false"
:field-states="fieldStates" :field-states="fieldStates"