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"
:label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder"
: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"
:mask="component.props.mask || undefined" :digits="component.props.digits || undefined"
:multiple="component.props.multiple || undefined" :maxSize="component.props.maxSize || undefined"
@ -162,11 +163,11 @@
<!-- Functional groups (in form view) -->
<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">
<div class="flex justify-between items-center mb-3">
<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)">
{{ component.props.removeText || 'Remove' }}
</button>
@ -181,7 +182,7 @@
</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"
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">
@ -198,7 +199,7 @@
<label v-if="component.props.label" class="block text-sm font-medium text-gray-700 mb-1">
{{ component.props.label }}
<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>
</label>
@ -217,7 +218,7 @@
</div>
<!-- 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">
<div class="flex items-center space-x-2">
<input type="checkbox" :checked="isAllSelected(component.props.name)"
@ -270,7 +271,7 @@
:checked="isItemSelected(component.props.name, index)"
@change="toggleItemSelection(component.props.name, index)"
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"
:class="getItemInputClasses(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">
<Icon name="material-symbols:drag-indicator" class="w-5 h-5" />
</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)">
<Icon name="material-symbols:delete-outline" class="w-5 h-5" />
</button>
@ -290,7 +291,7 @@
{{ validationErrors[component.props.name] }}
</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"
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">
@ -299,7 +300,7 @@
</button>
<!-- 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">
<button @click="exportItems(component.props.name, component.props.exportFormat)"
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">
<RepeatingTable
:config="component.props"
:model-value="previewFormData[component.props.name] || []"
:model-value="safeGetField(component.props.name, previewFormData) || []"
:is-preview="isPreview"
@update:model-value="updateTableData"
/>
@ -515,6 +516,8 @@
import { useNuxtApp } from '#app';
import { useFormBuilderStore } from '~/stores/formBuilder';
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
import { safeGetField } from '~/composables/safeGetField';
import { onMounted, onUnmounted, watch, computed } from 'vue';
const props = defineProps({
component: {
@ -549,6 +552,27 @@ const sectionDropStates = ref({});
const showNestedSettingsModal = ref(false);
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
onMounted(() => {
// Initialize search and selection data for dynamic lists
@ -560,7 +584,7 @@ onMounted(() => {
validationErrors.value[listName] = '';
// 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;
if ((!currentFormData || currentFormData.length === 0) && defaultItems && defaultItems.length > 0) {
@ -593,7 +617,7 @@ const addGroupItem = () => {
if (!groupName) return;
// Get current groups or initialize empty array
const currentGroups = formStore.previewFormData[groupName] || [];
const currentGroups = safeGetField(groupName, formStore.previewFormData) || [];
// Create a new empty group
const newGroup = {};
@ -620,7 +644,7 @@ const removeGroupItem = (index) => {
if (!groupName) return;
// Get current groups
const currentGroups = [...(formStore.previewFormData[groupName] || [])];
const currentGroups = [...(safeGetField(groupName, formStore.previewFormData) || [])];
// Remove the group at the specified index
currentGroups.splice(index, 1);
@ -637,7 +661,7 @@ const addListItem = () => {
if (!listName) return;
// Get current items or initialize empty array
const currentItems = [...(formStore.previewFormData[listName] || [])];
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
// Add an empty item
currentItems.push('');
@ -654,7 +678,7 @@ const removeListItem = (index) => {
if (!listName) return;
// Get current items
const currentItems = [...(formStore.previewFormData[listName] || [])];
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
// Remove the item at the specified index
currentItems.splice(index, 1);
@ -723,7 +747,7 @@ const validateItem = (listName, index, value) => {
const checkDuplicates = (listName, newValue, currentIndex) => {
if (props.component.props.allowDuplicates) return true;
const currentItems = formStore.previewFormData[listName] || [];
const currentItems = safeGetField(listName, formStore.previewFormData) || [];
const duplicateIndex = currentItems.findIndex((item, index) =>
index !== currentIndex && item.toLowerCase() === newValue.toLowerCase()
);
@ -762,7 +786,7 @@ const getItemInputClasses = (listName, index, value) => {
// Filter items based on search query
const getFilteredItems = (listName) => {
const items = formStore.previewFormData[listName] || [];
const items = safeGetField(listName, formStore.previewFormData) || [];
const query = searchQuery.value[listName];
if (!query || !props.component.props.enableSearch) {
@ -795,13 +819,13 @@ const toggleItemSelection = (listName, index) => {
};
const isAllSelected = (listName) => {
const items = formStore.previewFormData[listName] || [];
const items = safeGetField(listName, formStore.previewFormData) || [];
const selected = selectedItems.value[listName] || [];
return items.length > 0 && selected.length === items.length;
};
const toggleSelectAll = (listName) => {
const items = formStore.previewFormData[listName] || [];
const items = safeGetField(listName, formStore.previewFormData) || [];
if (isAllSelected(listName)) {
selectedItems.value[listName] = [];
@ -812,7 +836,7 @@ const toggleSelectAll = (listName) => {
const deleteSelectedItems = (listName) => {
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] || [];
// Sort indices in descending order to avoid index shifting issues
@ -834,7 +858,7 @@ const deleteSelectedItems = (listName) => {
// Import/Export functionality
const exportItems = (listName, format) => {
const items = formStore.previewFormData[listName] || [];
const items = safeGetField(listName, formStore.previewFormData) || [];
let content = '';
let filename = `${listName}_items`;
let mimeType = 'text/plain';
@ -867,20 +891,17 @@ const exportItems = (listName, format) => {
};
const showImportModal = (listName) => {
// This would open a modal for importing items
// For now, let's use a simple file input
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt,.csv,.json';
input.onchange = (event) => {
importInput = document.createElement('input');
importInput.type = 'file';
importInput.accept = '.txt,.csv,.json';
importInput.onchange = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
let importedItems = [];
try {
const content = e.target.result;
if (file.name.endsWith('.json')) {
importedItems = JSON.parse(content);
} else if (file.name.endsWith('.csv')) {
@ -888,22 +909,15 @@ const showImportModal = (listName) => {
} else {
importedItems = content.split('\n').filter(Boolean);
}
// Add imported items to current list
const currentItems = [...(formStore.previewFormData[listName] || [])];
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
const newItems = [...currentItems, ...importedItems];
// Respect max items limit
const maxItems = props.component.props.maxItems || 20;
if (newItems.length > maxItems) {
newItems.splice(maxItems);
alert(`Import limited to ${maxItems} items total.`);
}
// Update form data
const updatedData = { ...formStore.previewFormData, [listName]: newItems };
formStore.updatePreviewFormData(updatedData);
} catch (error) {
alert('Error importing file. Please check the file format.');
}
@ -911,7 +925,7 @@ const showImportModal = (listName) => {
reader.readAsText(file);
}
};
input.click();
importInput.click();
};
// Handle button click
@ -971,8 +985,11 @@ const handleImageClick = (event) => {
const imageUrl = props.component.props.imageUrl;
if (!imageUrl) return;
// Create a lightbox effect to view the image
const lightbox = document.createElement('div');
// Remove previous lightbox if exists
if (lightbox && document.body.contains(lightbox)) {
document.body.removeChild(lightbox);
}
lightbox = document.createElement('div');
lightbox.style.position = 'fixed';
lightbox.style.top = '0';
lightbox.style.left = '0';
@ -994,7 +1011,10 @@ const handleImageClick = (event) => {
// Add close functionality
lightbox.onclick = () => {
document.body.removeChild(lightbox);
if (document.body.contains(lightbox)) {
document.body.removeChild(lightbox);
}
lightbox = null;
};
// 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"
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>
</div>
</div>
@ -1735,7 +1745,8 @@ const showField = (fieldName) => {
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'],
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
@ -1779,7 +1790,7 @@ const isTextBasedField = computed(() => {
// Type changing functionality
const compatibilityGroups = {
// 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)
selectionInputs: ['select', 'radio', 'checkbox'],

View File

@ -292,10 +292,13 @@ const handleFieldChange = (fieldName, newValue, oldValue) => {
};
// Watch for script changes and reinitialize
watch(() => props.customScript, (newScript, oldScript) => {
watch(() => props.customScript, async (newScript, oldScript) => {
if (newScript !== oldScript) {
isScriptInitialized.value = false;
initializeScript();
await nextTick();
setTimeout(() => {
initializeScript();
}, 200);
}
});
@ -325,10 +328,11 @@ watch(() => props.customCSS, () => {
});
// Lifecycle hooks
onMounted(() => {
onMounted(async () => {
await nextTick();
setTimeout(() => {
initializeScript();
}, 500); // Delay to ensure parent DOM and initial props are settled
}, 200); // Delay to ensure all FormKit fields are loaded
});
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
// 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
const setApprovalDate = () => {
const today = new Date();
const formattedDate = today.toISOString().split('T')[0]; // YYYY-MM-DD format
setField('approval_date', formattedDate);
const formattedDate = today.toISOString().split("T")[0]; // YYYY-MM-DD format
setField("approval_date", formattedDate);
};
// Calculate and display recommended approval amounts
const calculateRecommendedAmounts = () => {
const totalClaimed = parseFloat(getField('total_cost_display')) || 0;
const policyLimit = parseFloat(getField('policy_limit_display')) || 0;
const overBudget = parseFloat(getField('over_budget_amount_display')) || 0;
const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
const overBudget = parseFloat(getField("over_budget_amount_display")) || 0;
if (totalClaimed > 0 && policyLimit > 0) {
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
const handleDecisionChange = (decision) => {
const totalClaimed = parseFloat(getField('total_cost_display')) || 0;
const policyLimit = parseFloat(getField('policy_limit_display')) || 0;
console.log('Manager decision changed to:', decision);
switch(decision) {
case 'approve_full':
showField('custom_approved_amount');
showInfo(`💰 Approving full amount: RM${totalClaimed.toFixed(2)}. You can enter a custom amount if needed.`);
setField('custom_approved_amount', totalClaimed.toString());
const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
console.log("Manager decision changed to:", decision);
switch (decision) {
case "approve_full":
showField("custom_approved_amount");
showInfo(
`💰 Approving full amount: RM${totalClaimed.toFixed(2)}. You can enter a custom amount if needed.`
);
setField("custom_approved_amount", totalClaimed.toString());
break;
case 'approve_policy':
hideField('custom_approved_amount');
showInfo(`⚖️ Approving policy limit only: RM${policyLimit.toFixed(2)}. Employee will be notified of the reduced amount.`);
case "approve_policy":
hideField("custom_approved_amount");
showInfo(
`⚖️ Approving policy limit only: RM${policyLimit.toFixed(2)}. Employee will be notified of the reduced amount.`
);
break;
case 'reject':
hideField('custom_approved_amount');
showError('❌ Claim will be rejected. Please provide detailed comments explaining the rejection reason.');
case "reject":
hideField("custom_approved_amount");
showError(
"❌ Claim will be rejected. Please provide detailed comments explaining the rejection reason."
);
break;
default:
hideField('custom_approved_amount');
hideField("custom_approved_amount");
}
};
// Validate custom approved amount
const validateCustomAmount = (amount) => {
const numAmount = parseFloat(amount) || 0;
const totalClaimed = parseFloat(getField('total_cost_display')) || 0;
const policyLimit = parseFloat(getField('policy_limit_display')) || 0;
const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
if (numAmount < 0) {
showError('Approved amount cannot be negative');
showError("Approved amount cannot be negative");
return false;
}
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;
}
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;
};
// Validate manager comments based on decision
const validateComments = (comments, decision) => {
if (!comments || comments.trim().length < 10) {
showError('Please provide detailed comments (minimum 10 characters)');
showError("Please provide detailed comments (minimum 10 characters)");
return false;
}
if (decision === 'reject' && comments.length < 50) {
showError('Rejection requires detailed explanation (minimum 50 characters)');
if (decision === "reject" && comments.length < 50) {
showError(
"Rejection requires detailed explanation (minimum 50 characters)"
);
return false;
}
if (decision === 'approve_full') {
const totalClaimed = parseFloat(getField('total_cost_display')) || 0;
const policyLimit = parseFloat(getField('policy_limit_display')) || 0;
if (decision === "approve_full") {
const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
const overBudget = totalClaimed - policyLimit;
if (overBudget > 500 && !comments.toLowerCase().includes('business')) {
showInfo('Consider mentioning business justification for approving over-budget amounts > RM500');
if (overBudget > 500 && !comments.toLowerCase().includes("business")) {
showInfo(
"Consider mentioning business justification for approving over-budget amounts > RM500"
);
}
}
return true;
};
// Format manager name (title case)
const formatManagerName = (name) => {
if (!name || typeof name !== 'string') return name;
if (!name || typeof name !== "string") return name;
return name
.toLowerCase()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
// Form validation before submission
const validateApprovalForm = () => {
let isValid = true;
const errors = [];
// Check required fields
const requiredFields = {
'manager_decision': 'Approval Decision',
'manager_comments': 'Manager Comments',
'manager_name': 'Manager Name',
'approval_date': 'Approval Date'
manager_decision: "Approval Decision",
manager_comments: "Manager Comments",
manager_name: "Manager Name",
approval_date: "Approval Date",
};
Object.entries(requiredFields).forEach(([fieldName, displayName]) => {
const value = getField(fieldName);
if (!value || value.toString().trim() === '') {
if (!value || value.toString().trim() === "") {
errors.push(`${displayName} is required`);
isValid = false;
}
});
// Validate decision-specific requirements
const decision = getField('manager_decision');
const comments = getField('manager_comments');
const customAmount = getField('custom_approved_amount');
const decision = getField("manager_decision");
const comments = getField("manager_comments");
const customAmount = getField("custom_approved_amount");
if (decision && comments) {
if (!validateComments(comments, decision)) {
isValid = false;
}
}
if (decision === 'approve_full' && customAmount) {
if (decision === "approve_full" && customAmount) {
if (!validateCustomAmount(customAmount)) {
isValid = false;
}
}
// Validate approval date is not in the future
const approvalDate = getField('approval_date');
const approvalDate = getField("approval_date");
if (approvalDate) {
const selectedDate = new Date(approvalDate);
const today = new Date();
today.setHours(23, 59, 59, 999); // End of today
if (selectedDate > today) {
errors.push('Approval date cannot be in the future');
errors.push("Approval date cannot be in the future");
isValid = false;
}
}
// Show validation results
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 {
showSuccess('Form validation passed! Ready to submit approval decision.');
showSuccess("Form validation passed! Ready to submit approval decision.");
}
return isValid;
};
// Set up field change handlers
onFieldChange('manager_decision', (newValue) => {
onFieldChange("manager_decision", (newValue) => {
if (newValue) {
handleDecisionChange(newValue);
}
});
onFieldChange('custom_approved_amount', (newValue) => {
onFieldChange("custom_approved_amount", (newValue) => {
if (newValue) {
validateCustomAmount(newValue);
}
});
onFieldChange('manager_comments', (newValue) => {
const decision = getField('manager_decision');
onFieldChange("manager_comments", (newValue) => {
const decision = getField("manager_decision");
if (newValue && decision) {
validateComments(newValue, decision);
}
});
onFieldChange('manager_name', (newValue) => {
if (newValue && typeof newValue === 'string') {
onFieldChange("manager_name", (newValue) => {
if (newValue && typeof newValue === "string") {
const formatted = formatManagerName(newValue);
if (formatted !== newValue) {
setTimeout(() => {
setField('manager_name', formatted);
setField("manager_name", formatted);
}, 100);
}
}
});
// Initialize form on load
setTimeout(() => {
setApprovalDate();
calculateRecommendedAmounts();
// Make display fields read-only by adding visual styling
const displayFields = [
'claim_summary', 'employee_name_display', 'department_display',
'trip_purpose_display', 'destination_display', 'total_cost_display',
'policy_limit_display', 'over_budget_amount_display'
];
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;
// Wait until all required fields are present before running initialization
function waitForFields(fields, callback, maxAttempts = 20) {
let attempts = 0;
function check() {
const allPresent = fields.every(f => getField(f) !== undefined);
if (allPresent) {
callback();
} else if (attempts < maxAttempts) {
attempts++;
setTimeout(check, 100);
} else {
showError("Some required fields are missing. Please reload the page.");
}
});
showInfo('Review the claim details above and make your approval decision below.');
}, 1000);
}
check();
}
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
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": {
"help": "Total amount claimed by employee",
"name": "total_cost_display",
"type": "text",
"type": "number",
"label": "Total Claimed Amount (RM)",
"width": "33.33%",
"gridColumn": "span 4",
"validation": "",
"placeholder": "0.00",
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
"placeholder": "0.00"
}
},
{
"type": "text",
"type": "number",
"props": {
"help": "Maximum allowed per company policy",
"name": "policy_limit_display",
"type": "text",
"type": "number",
"label": "Policy Limit (RM)",
"width": "33.33%",
"gridColumn": "span 4",
"validation": "",
"placeholder": "0.00",
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
"placeholder": "0.00"
}
},
{
"type": "text",
"type": "number",
"props": {
"help": "Amount exceeding policy limits",
"name": "over_budget_amount_display",
"type": "text",
"type": "number",
"label": "Over Budget Amount (RM)",
"width": "33.33%",
"gridColumn": "span 4",
"validation": "",
"placeholder": "0.00",
"conditionalLogic": {
"action": "show",
"enabled": false,
"operator": "and",
"conditions": []
}
"placeholder": "0.00"
}
},
{
@ -254,9 +236,18 @@
"label": "Approval Decision",
"width": "100%",
"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": "❌ Reject Claim - Claim will be denied entirely", "value": "reject" }
{
"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": "❌ Reject Claim - Claim will be denied entirely",
"value": "reject"
}
],
"gridColumn": "span 12",
"validation": "required",
@ -286,8 +277,8 @@
"conditions": [
{
"field": "manager_decision",
"operator": "eq",
"value": "approve_full"
"value": "approve_full",
"operator": "eq"
}
]
}
@ -337,10 +328,19 @@
"label": "Additional Requirements",
"width": "100%",
"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": "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",
"validation": "",
@ -405,4 +405,4 @@
}
}
}
]
]

View File

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

View File

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

View File

@ -462,12 +462,24 @@ function substituteVariables(str, variables) {
function applyInputMappings(nodeData, processVars, formData) {
const { inputMappings = [] } = nodeData;
console.log('[Workflow] Applying input mappings:', inputMappings);
inputMappings.forEach(mapping => {
const { processVariable, formField } = mapping;
if (processVars[processVariable] !== undefined) {
formData[formField] = processVars[processVariable];
console.log(`[Workflow] Mapped ${processVariable} -> ${formField}:`, processVars[processVariable]);
let value = 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
// Clear previous form state to prevent stale/leaked fields
formStore.formComponents = [];
formStore.updatePreviewFormData({});
if (currentForm.value?.formComponents) {
formStore.formComponents = currentForm.value.formComponents;
formStore.updatePreviewFormData(formData.value);
@ -1330,6 +1345,7 @@ function getConditionGroupResult(conditionGroup, variables) {
:form-components="currentForm.formComponents"
:form-data="formData"
@script-generated="handleConditionalLogicGenerated"
:key="currentNode.value?.id"
/>
<!-- Form Script Engine for conditional logic + custom script -->
@ -1341,6 +1357,7 @@ function getConditionGroupResult(conditionGroup, variables) {
:form-events="currentForm.formEvents || { onLoad: true, onFieldChange: true }"
:script-mode="currentForm.scriptMode || 'safe'"
@field-change="handleScriptFieldChange"
:key="currentNode.value?.id"
/>
<!-- FormKit form wrapper -->
@ -1352,10 +1369,11 @@ function getConditionGroupResult(conditionGroup, variables) {
:actions="false"
:incomplete-message="false"
validation-visibility="submit"
:key="currentNode.value?.id"
>
<div class="grid grid-cols-12 gap-2">
<template v-for="(component, index) in currentForm.formComponents" :key="index">
<ComponentPreview
<ComponentPreview
:component="component"
:is-preview="false"
:field-states="fieldStates"