custom_ui/frontend/src/components/modals/BidMeetingNoteForm.vue
2026-01-25 05:33:55 -06:00

978 lines
26 KiB
Vue

<template>
<Modal
:visible="showModal"
@update:visible="showModal = $event"
:options="modalOptions"
@confirm="handleSubmit"
@cancel="handleCancel"
>
<template #title>
<div class="modal-header">
<i class="pi pi-file-edit" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
{{ formTitle }}
</div>
</template>
<div v-if="isLoading" class="loading-container">
<ProgressSpinner style="width: 50px; height: 50px" strokeWidth="4" />
<p>Loading form...</p>
</div>
<div v-else-if="formConfig" class="form-container">
<Button @click="debugLog" label="Debug" severity="secondary" size="small" class="debug-button" />
<template v-for="row in groupedFields" :key="`row-${row.rowIndex}`">
<div class="form-row">
<div
v-for="field in row.fields"
:key="field.name"
:class="`form-column-${Math.min(Math.max(field.columns || 12, 1), 12)}`"
>
<div class="form-field">
<!-- Field Label -->
<label :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required-indicator">*</span>
</label>
<!-- Help Text -->
<small v-if="field.helpText" class="field-help-text">
{{ field.helpText }}
</small>
<!-- Data/Text Field -->
<template v-if="field.type === 'Data' || field.type === 'Text'">
<InputText
v-if="field.type === 'Data'"
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="field.label"
class="w-full"
/>
<Textarea
v-else
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="field.label"
rows="3"
class="w-full"
/>
</template>
<!-- Check Field -->
<template v-else-if="field.type === 'Check'">
<div class="checkbox-container">
<Checkbox
:id="field.name"
v-model="formData[field.name].value"
:binary="true"
:disabled="field.readOnly || !isFieldVisible(field)"
/>
<label :for="field.name" class="checkbox-label">
{{ formData[field.name].value ? 'Yes' : 'No' }}
</label>
</div>
</template>
<!-- Date Field -->
<template v-else-if="field.type === 'Date'">
<DatePicker
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
dateFormat="yy-mm-dd"
class="w-full"
/>
</template>
<!-- Datetime Field -->
<template v-else-if="field.type === 'Datetime'">
<DatePicker
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
showTime
hourFormat="12"
dateFormat="yy-mm-dd"
class="w-full"
/>
</template>
<!-- Time Field -->
<template v-else-if="field.type === 'Time'">
<DatePicker
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
timeOnly
hourFormat="12"
class="w-full"
/>
</template>
<!-- Number Field -->
<template v-else-if="field.type === 'Number'">
<InputNumber
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
class="w-full"
/>
</template>
<!-- Select Field -->
<template v-else-if="field.type === 'Select'">
<div @click="console.log('Select wrapper clicked:', field.name, 'disabled:', field.readOnly || !isFieldVisible(field), 'options:', optionsForFields[field.name])">
<Select
:id="field.name"
v-model="formData[field.name].value"
:options="optionsForFields[field.name]"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="`Select ${field.label}`"
:optionLabel="'label'"
:optionValue="'value'"
:editable="false"
:showClear="true"
:baseZIndex="10000"
@click.native="console.log('Select native click:', field.name)"
class="w-full"
/>
</div>
</template>
<!-- Multi-Select Field -->
<template v-else-if="field.type === 'Multi-Select'">
<MultiSelect
:id="field.name"
v-model="formData[field.name].value"
:options="optionsForFields[field.name]"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="`Select ${field.label}`"
:key="`${field.name}-${field.readOnly || !isFieldVisible(field)}`"
:optionLabel="'label'"
:optionValue="'value'"
:showClear="true"
:baseZIndex="9999"
display="chip"
class="w-full"
/>
</template>
<!-- Multi-Select w/ Quantity Field -->
<template v-else-if="field.type === 'Multi-Select w/ Quantity'">
<div class="multi-select-quantity-container">
<!-- Item Selector -->
<div class="item-selector">
<Select
v-model="currentItemSelection[field.name]"
:options="optionsForFields[field.name]"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="`Add ${field.label}`"
:key="`${field.name}-${field.readOnly || !isFieldVisible(field)}`"
:optionLabel="'label'"
:showClear="true"
:baseZIndex="9999"
class="w-full"
@change="addItemToQuantityList(field)"
/>
</div>
<!-- Selected Items with Quantities -->
<div v-if="formData[field.name].value && formData[field.name].value.length > 0" class="selected-items-list">
<div
v-for="(item, index) in formData[field.name].value"
:key="index"
class="quantity-item"
>
<div class="item-name">{{ getOptionLabel(field, item) }}</div>
<div class="quantity-controls">
<InputNumber
v-model="item.quantity"
:min="1"
:disabled="field.readOnly || !isFieldVisible(field)"
showButtons
buttonLayout="horizontal"
:step="1"
decrementButtonClass="p-button-secondary"
incrementButtonClass="p-button-secondary"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
@click="removeItemFromQuantityList(field, index)"
:disabled="field.readOnly || !isFieldVisible(field)"
/>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
<div v-else class="error-container">
<i class="pi pi-exclamation-triangle" style="font-size: 2rem; color: var(--red-500);"></i>
<p>Failed to load form configuration</p>
</div>
</Modal>
</template>
<script setup>
import { ref, computed, watch, onMounted, reactive } from "vue";
import Modal from "../common/Modal.vue";
import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import Checkbox from "primevue/checkbox";
import DatePicker from "primevue/datepicker";
import InputNumber from "primevue/inputnumber";
import Select from "primevue/select";
import MultiSelect from "primevue/multiselect";
import Button from "primevue/button";
import ProgressSpinner from "primevue/progressspinner";
import Api from "../../api";
import { useLoadingStore } from "../../stores/loading";
import { useNotificationStore } from "../../stores/notifications-primevue";
const docsForSelectFields = ref({});
const props = defineProps({
visible: {
type: Boolean,
required: true,
},
bidMeetingName: {
type: String,
required: true,
},
projectTemplate: {
type: String,
required: true,
},
});
const emit = defineEmits(["update:visible", "submit", "cancel"]);
const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const showModal = computed({
get: () => props.visible,
set: (value) => emit("update:visible", value),
});
const isLoading = ref(false);
const formConfig = ref(null);
const formData = ref({}); // Will store fieldName: {fieldConfig, value}
const currentItemSelection = ref({}); // For tracking current selection in Multi-Select w/ Quantity
const doctypeOptions = ref({}); // Cache for doctype options
const formTitle = computed(() => {
return formConfig.value?.title || "Bid Meeting Notes";
});
// Include all fields from config plus a general notes field
const allFields = computed(() => {
if (!formConfig.value) return [];
const fields = [...(formConfig.value.fields || [])];
// Always add a general notes field at the end
const generalNotesField = {
name: 'general_notes',
label: 'General Notes',
type: 'Text',
required: 0,
readOnly: 0,
helpText: 'Any additional notes or observations from the meeting',
row: Math.max(...fields.map(f => f.row || 1), 0) + 1,
columns: 12,
};
fields.push(generalNotesField);
return fields;
});
// Group fields by row for grid layout
const groupedFields = computed(() => {
const groups = {};
allFields.value.forEach(field => {
const rowNum = field.row || 1;
if (!groups[rowNum]) {
groups[rowNum] = { rowIndex: rowNum, fields: [] };
}
groups[rowNum].fields.push(field);
});
// Sort fields by column and set columns span
Object.values(groups).forEach(group => {
group.fields.sort((a, b) => (a.column || 0) - (b.column || 0));
const numFields = group.fields.length;
const span = Math.floor(12 / numFields);
group.fields.forEach(field => {
field.columns = span;
});
});
return Object.values(groups).sort((a, b) => a.rowIndex - b.rowIndex);
});
// Update field value in reactive form data
const updateFieldValue = (fieldName, value) => {
if (formData.value[fieldName]) {
formData.value[fieldName].value = value;
}
};
// Get CSS class for field column span
const getFieldColumnClass = (field) => {
const columns = field.columns || 12; // Default to full width if not specified
return `form-column-${Math.min(Math.max(columns, 1), 12)}`; // Ensure between 1-12
};
const modalOptions = computed(() => ({
maxWidth: "800px",
showCancelButton: true,
confirmButtonText: "Submit",
cancelButtonText: "Cancel",
confirmButtonColor: "primary",
zIndex: 1000, // Lower than select baseZIndex
}));
// Helper to find field name by label
const findFieldNameByLabel = (label) => {
if (!formConfig.value || !formConfig.value.fields) return null;
const field = formConfig.value.fields.find(f => f.label === label);
return field ? field.name : null;
};
const fetchDocsForSelectField = async (doctype, fieldName) => {
const docs = await Api.getDocsList(doctype);
docsForSelectFields[fieldName] = docs;
}
// Check if a field should be visible based on conditional logic
const isFieldVisible = (field) => {
if (!field.conditionalOnField) {
return true;
}
// Find the actual field name from the label (conditionalOnField contains the label)
const dependentFieldName = findFieldNameByLabel(field.conditionalOnField);
if (!dependentFieldName) {
console.warn(`Could not find field with label: ${field.conditionalOnField}`);
return true; // Show field if we can't find the dependency
}
const dependentFieldValue = formData.value[dependentFieldName]?.value;
console.log(`Checking visibility for ${field.label}:`, {
conditionalOnField: field.conditionalOnField,
dependentFieldName,
dependentFieldValue,
conditionalOnValue: field.conditionalOnValue,
});
// If the dependent field is a checkbox, it should be true
if (typeof dependentFieldValue === "boolean") {
return dependentFieldValue === true;
}
// If conditional_on_value is specified, check for exact match
if (field.conditionalOnValue !== null && field.conditionalOnValue !== undefined) {
return dependentFieldValue === field.conditionalOnValue;
}
// Otherwise, just check if the dependent field has any truthy value
return !!dependentFieldValue;
};
// Get options for select/multi-select fields
const getFieldOptions = (field) => {
// Access reactive data to ensure reactivity
const optionsData = docsForSelectFields.value[field.name];
console.log(`getFieldOptions called for ${field.label}:`, {
type: field.type,
options: field.options,
optionsType: typeof field.options,
doctypeForSelect: field.doctypeForSelect,
doctypeLabelField: field.doctypeLabelField,
hasDoctypeOptions: !!optionsData,
});
// If options should be fetched from a doctype
if (field.doctypeForSelect && optionsData) {
console.log(`Using doctype options for ${field.label}:`, optionsData);
return [...optionsData]; // Return a copy to ensure reactivity
}
// If options are provided as a string (comma-separated), parse them
if (field.options && typeof field.options === "string" && field.options.trim() !== "") {
const optionStrings = field.options.split(",").map((opt) => opt.trim()).filter(opt => opt !== "");
// Convert to objects for consistency with PrimeVue MultiSelect
const options = optionStrings.map((opt) => ({
label: opt,
value: opt,
}));
console.log(`Parsed options for ${field.label}:`, options);
return options;
}
console.warn(`No options found for ${field.label}`);
return [];
};
// Get options for select/multi-select fields
const optionsForFields = computed(() => {
// Ensure reactivity by accessing docsForSelectFields
const docsData = docsForSelectFields.value;
const opts = {};
allFields.value.forEach(field => {
const options = getFieldOptions(field);
opts[field.name] = options;
console.log(`Computed options for ${field.name}:`, options);
});
console.log('optionsForFields computed:', opts);
return opts;
});
// Add item to quantity list for Multi-Select w/ Quantity fields
const addItemToQuantityList = (field) => {
const selectedItem = currentItemSelection.value[field.name];
if (!selectedItem) return;
// selectedItem is now an object with { label, value }
const itemValue = selectedItem.value || selectedItem;
const itemLabel = selectedItem.label || selectedItem;
// Initialize array if it doesn't exist
const fieldData = formData.value[field.name];
if (!fieldData.value) {
fieldData.value = [];
}
// Check if item already exists (compare by value)
const existingItem = fieldData.value.find((item) => item.item === itemValue);
if (existingItem) {
// Increment quantity if item already exists
existingItem.quantity += 1;
} else {
// Add new item with quantity 1
fieldData.value.push({
item: itemValue,
label: itemLabel,
quantity: 1,
});
}
// Clear selection
currentItemSelection.value[field.name] = null;
};
// Remove item from quantity list
const removeItemFromQuantityList = (field, index) => {
const fieldData = formData.value[field.name];
if (fieldData && fieldData.value) {
fieldData.value.splice(index, 1);
}
};
// Get option label for display
const getOptionLabel = (field, item) => {
const options = optionsForFields.value[field.name];
const option = options.find(o => o.value === item.item);
return option ? option.label : item.label || item.item;
};
// Initialize form data with default values
const initializeFormData = () => {
if (!formConfig.value) return;
const data = {};
allFields.value.forEach((field) => {
// Create reactive object with field config and value
data[field.name] = {
...field, // Include all field configuration
value: null, // Initialize value
};
// Set default value if provided
if (field.defaultValue !== null && field.defaultValue !== undefined) {
data[field.name].value = field.defaultValue;
} else {
// Initialize based on field type
switch (field.type) {
case "Check":
data[field.name].value = false;
break;
case "Multi-Select":
data[field.name].value = [];
break;
case "Multi-Select w/ Quantity":
data[field.name].value = [];
break;
case "Number":
data[field.name].value = null;
break;
case "Select":
data[field.name].value = null;
break;
default:
data[field.name].value = "";
}
}
});
formData.value = data;
};
// Load form configuration
const loadFormConfig = async () => {
if (!props.projectTemplate) return;
isLoading.value = true;
try {
const config = await Api.getBidMeetingNoteForm(props.projectTemplate);
formConfig.value = config;
console.log("Loaded form config:", config);
// Load doctype options for fields that need them
await loadDoctypeOptions();
// Initialize form data
initializeFormData();
} catch (error) {
console.error("Error loading form config:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to load form configuration",
duration: 5000,
});
} finally {
isLoading.value = false;
}
};
// Load options for fields that reference doctypes
const loadDoctypeOptions = async () => {
if (!formConfig.value || !formConfig.value.fields) return;
const fieldsWithDoctype = formConfig.value.fields.filter(
(field) => field.doctypeForSelect && field.doctypeForSelect !== ""
);
for (const field of fieldsWithDoctype) {
try {
// Use the new API method for fetching docs
let docs = await Api.getEstimateItems();
// Deduplicate by value field
const valueField = field.doctypeValueField || 'name';
const seen = new Set();
docs = docs.filter(doc => {
const val = doc[valueField] || doc.name || doc;
if (seen.has(val)) return false;
seen.add(val);
return true;
});
// Transform docs into options format
// Use doctypeLabelField if specified, otherwise default to 'name'
const labelField = field.doctypeLabelField || 'name';
const options = docs.map((doc) => ({
label: doc[labelField] || doc.name || doc,
value: doc[valueField] || doc.name || doc,
}));
docsForSelectFields.value[field.name] = options;
console.log(`Loaded ${options.length} options for ${field.label} from ${field.doctypeForSelect}`);
} catch (error) {
console.error(`Error loading options for ${field.doctypeForSelect}:`, error);
}
}
};
// Validate form
const validateForm = () => {
const errors = [];
if (!formConfig.value) return errors;
allFields.value.forEach((field) => {
// Only validate if field is visible
if (!isFieldVisible(field)) return;
// Skip required validation for checkboxes (they always have a value: true or false)
if (field.type === 'Check') return;
if (field.required) {
const value = formData.value[field.name]?.value;
if (value === null || value === undefined || value === "") {
errors.push(`${field.label} is required`);
} else if (Array.isArray(value) && value.length === 0) {
errors.push(`${field.label} is required`);
}
}
});
return errors;
};
// Format field data for submission
const formatFieldData = (field) => {
const value = formData.value[field.name]?.value;
// Include the entire field configuration
const fieldData = {
...field, // Include all field properties
value: value, // Override with current value
};
// Handle options: include unless fetched from doctype
if (field.doctypeForSelect) {
// Remove options if they were fetched from doctype
delete fieldData.options;
}
// For fields with include_options flag, include the selected options
if (field.includeOptions) {
if (field.type === "Multi-Select") {
fieldData.selectedOptions = value || [];
} else if (field.type === "Multi-Select w/ Quantity") {
fieldData.items = value || [];
} else if (field.type === "Select") {
fieldData.selectedOption = value;
}
}
// Format dates as strings
if (field.type === "Date" || field.type === "Datetime" || field.type === "Time") {
if (value instanceof Date) {
if (field.type === "Date") {
fieldData.value = value.toISOString().split("T")[0];
} else if (field.type === "Datetime") {
fieldData.value = value.toISOString();
} else if (field.type === "Time") {
fieldData.value = value.toTimeString().split(" ")[0];
}
}
}
return fieldData;
};
// Handle form submission
const handleSubmit = async () => {
// Validate form
const errors = validateForm();
if (errors.length > 0) {
notificationStore.addNotification({
type: "error",
title: "Validation Error",
message: errors.join(", "),
duration: 5000,
});
return;
}
try {
loadingStore.setLoading(true);
// Format data for submission
const submissionData = {
bidMeeting: props.bidMeetingName,
projectTemplate: props.projectTemplate,
formName: formConfig.value?.name || formConfig.value?.title,
formTemplate: formConfig.value?.name || formConfig.value?.title,
fields: allFields.value
.filter((field) => isFieldVisible(field))
.map((field) => formatFieldData(field)),
};
console.log("Submitting form data:", submissionData);
// Submit to API
await Api.submitBidMeetingNoteForm(submissionData);
notificationStore.addNotification({
type: "success",
title: "Success",
message: "Bid meeting notes submitted successfully",
duration: 5000,
});
emit("submit", submissionData);
showModal.value = false;
} catch (error) {
console.error("Error submitting form:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to submit form. Please try again.",
duration: 5000,
});
} finally {
loadingStore.setLoading(false);
}
};
// Handle cancel
const handleCancel = () => {
emit("cancel");
showModal.value = false;
};
// Debug function to log current form data and select options
const debugLog = () => {
console.log("=== FORM DEBUG ===");
const debugData = {};
allFields.value.forEach(field => {
const fieldValue = formData.value[field.name]?.value;
const fieldOptions = optionsForFields.value[field.name];
const isVisible = isFieldVisible(field);
const isDisabled = field.readOnly || !isVisible;
debugData[field.name] = {
label: field.label,
type: field.type,
value: fieldValue,
isVisible,
isDisabled,
...(field.type.includes('Select') ? {
options: fieldOptions,
optionsCount: fieldOptions?.length || 0
} : {}),
};
});
console.log("Current Form Data:", debugData);
console.log("==================");
};
// Watch for modal visibility changes
watch(
() => props.visible,
(newVal) => {
if (newVal) {
loadFormConfig();
}
}
);
// Load form config on mount if modal is visible
onMounted(() => {
if (props.visible) {
loadFormConfig();
}
});
</script>
<style scoped>
.modal-header {
display: flex;
align-items: center;
font-size: 1.25rem;
font-weight: 600;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
color: var(--text-color-secondary);
}
.debug-button {
margin-bottom: 1rem;
}
/* Grid Layout Styles */
.form-row {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1rem;
width: 100%;
margin-bottom: 1rem;
}
.form-column-1 { grid-column: span 1; }
.form-column-2 { grid-column: span 2; }
.form-column-3 { grid-column: span 3; }
.form-column-4 { grid-column: span 4; }
.form-column-5 { grid-column: span 5; }
.form-column-6 { grid-column: span 6; }
.form-column-7 { grid-column: span 7; }
.form-column-8 { grid-column: span 8; }
.form-column-9 { grid-column: span 9; }
.form-column-10 { grid-column: span 10; }
.form-column-11 { grid-column: span 11; }
.form-column-12 { grid-column: span 12; }
.form-field-wrapper {
width: 100%;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: auto !important;
}
.form-field :deep(.p-select) {
pointer-events: auto !important;
}
.form-field :deep(.p-multiselect) {
pointer-events: auto !important;
}
.field-label {
font-weight: 600;
color: var(--text-color);
font-size: 0.95rem;
}
.required-indicator {
color: var(--red-500);
margin-left: 0.25rem;
}
.field-help-text {
color: var(--text-color-secondary);
font-size: 0.85rem;
margin-top: -0.25rem;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
}
.checkbox-container :deep(.p-checkbox) {
width: 1.25rem !important;
height: 1.25rem !important;
position: relative !important;
}
.checkbox-container :deep(.p-checkbox .p-checkbox-box) {
width: 1.25rem !important;
height: 1.25rem !important;
border: 1px solid var(--surface-border) !important;
border-radius: 3px !important;
position: relative !important;
}
.checkbox-container :deep(.p-checkbox .p-checkbox-input) {
width: 100% !important;
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
opacity: 0 !important;
cursor: pointer !important;
z-index: 1 !important;
}
.checkbox-container :deep(.p-checkbox .p-checkbox-box .p-checkbox-icon) {
font-size: 0.875rem !important;
color: var(--primary-color) !important;
}
.checkbox-label {
font-size: 0.9rem;
color: var(--text-color-secondary);
margin: 0;
font-weight: normal;
cursor: pointer;
user-select: none;
}
.multi-select-quantity-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.item-selector {
width: 100%;
}
.selected-items-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background-color: var(--surface-50);
border-radius: 6px;
border: 1px solid var(--surface-border);
}
.quantity-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background-color: var(--surface-0);
border-radius: 4px;
border: 1px solid var(--surface-border);
}
.item-name {
flex: 1;
font-weight: 500;
color: var(--text-color);
}
.quantity-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.form-container {
max-height: 60vh;
}
.quantity-item {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.quantity-controls {
width: 100%;
justify-content: space-between;
}
}
</style>