create form and modal components, update datatable persistant filtering
This commit is contained in:
parent
b70e08026d
commit
8d9bb81fe2
23 changed files with 3502 additions and 74 deletions
165
frontend/src/components/common/DataTable.vue
Normal file
165
frontend/src/components/common/DataTable.vue
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<template lang="html">
|
||||
<DataTable
|
||||
:value="data"
|
||||
:rowsPerPageOptions="[5, 10, 20, 50]"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
sortMode="multiple"
|
||||
removableSort
|
||||
filterDisplay="row"
|
||||
v-model:filters="filterRef"
|
||||
scrollable
|
||||
scrollHeight="70vh"
|
||||
v-model:selection="selectedRows"
|
||||
selectionMode="multiple"
|
||||
metaKeySelection="true"
|
||||
dataKey="id"
|
||||
>
|
||||
<Column
|
||||
v-for="col in columns"
|
||||
:key="col.fieldName"
|
||||
:field="col.fieldName"
|
||||
:header="col.label"
|
||||
:sortable="col.sortable"
|
||||
>
|
||||
<template v-if="col.filterable === true" #filter="{ filterModel, filterCallback }">
|
||||
<InputText
|
||||
v-model="filterModel.value"
|
||||
type="text"
|
||||
@input="handleFilterInput(col.fieldName, filterModel.value, filterCallback)"
|
||||
:placeholder="`Search ${col.label}...`"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="col.type === 'status'" #body="slotProps">
|
||||
<Tag
|
||||
:value="slotProps.data[col.fieldName]"
|
||||
:severity="getBadgeColor(slotProps.data[col.fieldName])"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="col.type === 'button'" #body="slotProps">
|
||||
<Button
|
||||
:label="slotProps.data[col.fieldName]"
|
||||
size="small"
|
||||
severity="info"
|
||||
@click="$emit('rowClick', slotProps)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
<script setup>
|
||||
import { defineProps, computed, onMounted, watch } from "vue";
|
||||
import DataTable from "primevue/datatable";
|
||||
import Column from "primevue/column";
|
||||
import Tag from "primevue/tag";
|
||||
import Button from "primevue/button";
|
||||
import InputText from "primevue/inputtext";
|
||||
import { ref } from "vue";
|
||||
import { FilterMatchMode } from "@primevue/core";
|
||||
import { useFiltersStore } from "../../stores/filters";
|
||||
|
||||
const filtersStore = useFiltersStore();
|
||||
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
}),
|
||||
},
|
||||
tableName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["rowClick"]);
|
||||
|
||||
// Initialize filters in store when component mounts
|
||||
onMounted(() => {
|
||||
filtersStore.initializeTableFilters(props.tableName, props.columns);
|
||||
});
|
||||
|
||||
// Get filters from store, with fallback to props.filters
|
||||
const filterRef = computed({
|
||||
get() {
|
||||
const storeFilters = filtersStore.getTableFilters(props.tableName);
|
||||
// Merge store filters with any additional filters from props
|
||||
return { ...props.filters, ...storeFilters };
|
||||
},
|
||||
set(newFilters) {
|
||||
// Update store when filters change
|
||||
Object.keys(newFilters).forEach(key => {
|
||||
if (key !== 'global' && newFilters[key]) {
|
||||
const filter = newFilters[key];
|
||||
filtersStore.updateTableFilter(
|
||||
props.tableName,
|
||||
key,
|
||||
filter.value,
|
||||
filter.matchMode
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for filter changes to sync match mode changes
|
||||
watch(filterRef, (newFilters) => {
|
||||
Object.keys(newFilters).forEach(key => {
|
||||
if (key !== 'global' && newFilters[key]) {
|
||||
const filter = newFilters[key];
|
||||
const storeFilter = filtersStore.getTableFilters(props.tableName)[key];
|
||||
|
||||
// Only update if the match mode has actually changed
|
||||
if (storeFilter && storeFilter.matchMode !== filter.matchMode) {
|
||||
filtersStore.updateTableFilter(
|
||||
props.tableName,
|
||||
key,
|
||||
filter.value,
|
||||
filter.matchMode
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { deep: true });
|
||||
|
||||
const selectedRows = ref();
|
||||
|
||||
// Handle filter input changes
|
||||
const handleFilterInput = (fieldName, value, filterCallback) => {
|
||||
// Get the current filter to preserve the match mode
|
||||
const currentFilter = filterRef.value[fieldName];
|
||||
const matchMode = currentFilter?.matchMode || FilterMatchMode.CONTAINS;
|
||||
|
||||
// Update the store with both value and match mode
|
||||
filtersStore.updateTableFilter(props.tableName, fieldName, value, matchMode);
|
||||
// Call the PrimeVue filter callback
|
||||
filterCallback();
|
||||
};
|
||||
|
||||
const getBadgeColor = (status) => {
|
||||
console.log("DEBUG: - getBadgeColor status", status);
|
||||
switch (status?.toLowerCase()) {
|
||||
case "completed":
|
||||
return "success"; // green
|
||||
case "in progress":
|
||||
return "warn";
|
||||
case "not started":
|
||||
return "danger"; // red
|
||||
default:
|
||||
return "info"; // blue fallback
|
||||
}
|
||||
};
|
||||
console.log("DEBUG: - DataTable props.columns", props.columns);
|
||||
console.log("DEBUG: - DataTable props.data", props.data);
|
||||
</script>
|
||||
<style lang="">
|
||||
</style>
|
||||
608
frontend/src/components/common/Form.vue
Normal file
608
frontend/src/components/common/Form.vue
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
<template>
|
||||
<v-form @submit.prevent="handleSubmit" class="dynamic-form">
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
:cols="field.cols || 12"
|
||||
:sm="field.sm || 12"
|
||||
:md="field.md || 6"
|
||||
:lg="field.lg || 6"
|
||||
>
|
||||
<!-- Text Input -->
|
||||
<v-text-field
|
||||
v-if="field.type === 'text'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
:type="field.format || 'text'"
|
||||
:placeholder="field.placeholder"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:readonly="field.readonly"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@input="handleFieldChange(field, $event.target ? $event.target.value : $event)"
|
||||
@blur="handleFieldBlur(field, $event.target ? $event.target.value : fieldValues[field.name])"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
>
|
||||
<template v-if="field.required" #append-inner>
|
||||
<span class="text-error">*</span>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<!-- Number Input -->
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
type="number"
|
||||
:placeholder="field.placeholder"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:readonly="field.readonly"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@input="handleFieldChange(field, parseFloat($event.target ? $event.target.value : $event) || $event)"
|
||||
@blur="handleFieldBlur(field, parseFloat($event.target ? $event.target.value : fieldValues[field.name]) || fieldValues[field.name])"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
>
|
||||
<template v-if="field.required" #append-inner>
|
||||
<span class="text-error">*</span>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<!-- Textarea -->
|
||||
<v-textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
:placeholder="field.placeholder"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:readonly="field.readonly"
|
||||
:rows="field.rows || 3"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@input="handleFieldChange(field, $event.target ? $event.target.value : $event)"
|
||||
@blur="handleFieldBlur(field, $event.target ? $event.target.value : fieldValues[field.name])"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
>
|
||||
<template v-if="field.required" #append-inner>
|
||||
<span class="text-error">*</span>
|
||||
</template>
|
||||
</v-textarea>
|
||||
|
||||
<!-- Select Dropdown -->
|
||||
<v-select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
:items="field.options"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:placeholder="field.placeholder"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@update:model-value="handleFieldChange(field, $event)"
|
||||
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
>
|
||||
<template v-if="field.required" #append-inner>
|
||||
<span class="text-error">*</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<v-checkbox
|
||||
v-else-if="field.type === 'checkbox'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@update:model-value="handleFieldChange(field, $event)"
|
||||
density="comfortable"
|
||||
/>
|
||||
|
||||
<!-- Radio Group -->
|
||||
<v-radio-group
|
||||
v-else-if="field.type === 'radio'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@update:model-value="handleFieldChange(field, $event)"
|
||||
density="comfortable"
|
||||
>
|
||||
<v-radio
|
||||
v-for="option in field.options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</v-radio-group>
|
||||
|
||||
<!-- Date Input -->
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
type="date"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:readonly="field.readonly"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@input="handleFieldChange(field, $event.target ? $event.target.value : $event)"
|
||||
@blur="handleFieldBlur(field, $event.target ? $event.target.value : fieldValues[field.name])"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
>
|
||||
<template v-if="field.required" #append-inner>
|
||||
<span class="text-error">*</span>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<!-- DateTime Input -->
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'datetime'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
type="datetime-local"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:readonly="field.readonly"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@input="handleFieldChange(field, $event.target ? $event.target.value : $event)"
|
||||
@blur="handleFieldBlur(field, $event.target ? $event.target.value : fieldValues[field.name])"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
>
|
||||
<template v-if="field.required" #append-inner>
|
||||
<span class="text-error">*</span>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<!-- File Input -->
|
||||
<v-file-input
|
||||
v-else-if="field.type === 'file'"
|
||||
v-model="fieldValues[field.name]"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
:disabled="field.disabled"
|
||||
:accept="field.accept"
|
||||
:multiple="field.multiple"
|
||||
:error-messages="getFieldError(field.name)"
|
||||
:hint="field.helpText"
|
||||
persistent-hint
|
||||
@update:model-value="handleFieldChange(field, $event)"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-icon="mdi-paperclip"
|
||||
>
|
||||
<template v-if="field.required" #append-inner>
|
||||
<span class="text-error">*</span>
|
||||
</template>
|
||||
</v-file-input>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Submit/Action Buttons -->
|
||||
<v-row v-if="showSubmitButton || showCancelButton" class="mt-4">
|
||||
<v-col cols="12">
|
||||
<div class="d-flex gap-2">
|
||||
<v-btn
|
||||
v-if="showSubmitButton"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isSubmitting"
|
||||
variant="elevated"
|
||||
>
|
||||
{{ submitButtonText }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="showCancelButton"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelButtonText }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, defineEmits, defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
formData: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
onChange: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
showSubmitButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showCancelButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
submitButtonText: {
|
||||
type: String,
|
||||
default: 'Submit'
|
||||
},
|
||||
cancelButtonText: {
|
||||
type: String,
|
||||
default: 'Cancel'
|
||||
},
|
||||
validateOnChange: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
validateOnBlur: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
validateOnSubmit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:formData', 'submit', 'cancel', 'change', 'blur']);
|
||||
|
||||
// Internal form state (used when no external formData is provided)
|
||||
const internalFormData = reactive({});
|
||||
const formErrors = reactive({});
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
// Computed property for v-model binding
|
||||
const fieldValues = computed({
|
||||
get() {
|
||||
return props.formData || internalFormData;
|
||||
},
|
||||
set(newValues) {
|
||||
if (props.formData) {
|
||||
Object.assign(props.formData, newValues);
|
||||
emit('update:formData', props.formData);
|
||||
} else {
|
||||
Object.assign(internalFormData, newValues);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize form data
|
||||
const initializeFormData = () => {
|
||||
props.fields.forEach(field => {
|
||||
if (props.formData) {
|
||||
// If external formData is provided, ensure all fields exist
|
||||
if (!(field.name in props.formData)) {
|
||||
props.formData[field.name] = getDefaultValue(field);
|
||||
}
|
||||
} else {
|
||||
// Use internal form data
|
||||
internalFormData[field.name] = getDefaultValue(field);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Get default value for a field based on its type
|
||||
const getDefaultValue = (field) => {
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
return field.defaultValue !== undefined ? field.defaultValue : false;
|
||||
case 'number':
|
||||
return field.defaultValue !== undefined ? field.defaultValue : '';
|
||||
case 'select':
|
||||
case 'radio':
|
||||
return field.defaultValue !== undefined ? field.defaultValue : '';
|
||||
case 'file':
|
||||
return null;
|
||||
default:
|
||||
return field.defaultValue !== undefined ? field.defaultValue : '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get the current value for a field
|
||||
const getFieldValue = (fieldName) => {
|
||||
return fieldValues.value[fieldName];
|
||||
};
|
||||
|
||||
// Get error for a field
|
||||
const getFieldError = (fieldName) => {
|
||||
return formErrors[fieldName];
|
||||
};
|
||||
|
||||
// Validate a single field
|
||||
const validateField = (field, value) => {
|
||||
const errors = [];
|
||||
|
||||
// Required validation (only check if field is required and value is empty)
|
||||
if (field.required && (value === '' || value === null || value === undefined)) {
|
||||
errors.push(`${field.label} is required`);
|
||||
return errors[0]; // Return early if required field is empty
|
||||
}
|
||||
|
||||
// Skip other validations if value is empty and field is not required
|
||||
if (!value && !field.required) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Email validation (more comprehensive)
|
||||
if (field.format === 'email' && value) {
|
||||
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
errors.push('Please enter a valid email address');
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation (for tel format)
|
||||
if (field.format === 'tel' && value) {
|
||||
// Allow various phone formats
|
||||
const phoneRegex = /^[\+]?[1-9][\d]{0,15}$|^\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}$|^[0-9]{10}$/;
|
||||
if (!phoneRegex.test(value.replace(/[\s\-\(\)\.]/g, ''))) {
|
||||
// Use custom validation if provided, otherwise use default
|
||||
if (!field.validate) {
|
||||
errors.push('Please enter a valid phone number');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL validation
|
||||
if (field.format === 'url' && value) {
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
errors.push('Please enter a valid URL');
|
||||
}
|
||||
}
|
||||
|
||||
// Min/Max length validation for text
|
||||
if ((field.type === 'text' || field.type === 'textarea') && value) {
|
||||
if (field.minLength && value.length < field.minLength) {
|
||||
errors.push(`${field.label} must be at least ${field.minLength} characters`);
|
||||
}
|
||||
if (field.maxLength && value.length > field.maxLength) {
|
||||
errors.push(`${field.label} must not exceed ${field.maxLength} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Min/Max for numbers
|
||||
if (field.type === 'number' && value !== '' && value !== null && value !== undefined) {
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
if (field.min !== undefined && numValue < field.min) {
|
||||
errors.push(`Value must be at least ${field.min}`);
|
||||
}
|
||||
if (field.max !== undefined && numValue > field.max) {
|
||||
errors.push(`Value must not exceed ${field.max}`);
|
||||
}
|
||||
} else {
|
||||
errors.push('Please enter a valid number');
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation (always runs last)
|
||||
if (field.validate && typeof field.validate === 'function') {
|
||||
const customError = field.validate(value);
|
||||
if (customError) {
|
||||
errors.push(customError);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length > 0 ? errors[0] : null;
|
||||
};
|
||||
|
||||
// Handle field value changes
|
||||
const handleFieldChange = (field, value) => {
|
||||
// Update form data
|
||||
if (props.formData) {
|
||||
props.formData[field.name] = value;
|
||||
emit('update:formData', props.formData);
|
||||
} else {
|
||||
internalFormData[field.name] = value;
|
||||
}
|
||||
|
||||
// Clear previous error for this field
|
||||
delete formErrors[field.name];
|
||||
|
||||
// Validate field if enabled
|
||||
if (props.validateOnChange) {
|
||||
const error = validateField(field, value);
|
||||
if (error) {
|
||||
formErrors[field.name] = error;
|
||||
}
|
||||
}
|
||||
|
||||
// Call custom onChange if provided on the field
|
||||
if (field.onChangeOverride && typeof field.onChangeOverride === 'function') {
|
||||
field.onChangeOverride(value, field.name, getCurrentFormData());
|
||||
}
|
||||
// Call global onChange if provided
|
||||
else if (props.onChange && typeof props.onChange === 'function') {
|
||||
props.onChange(field.name, value, getCurrentFormData());
|
||||
}
|
||||
|
||||
// Emit change event
|
||||
emit('change', {
|
||||
fieldName: field.name,
|
||||
value: value,
|
||||
formData: getCurrentFormData()
|
||||
});
|
||||
};
|
||||
|
||||
// Handle field blur events
|
||||
const handleFieldBlur = (field, value) => {
|
||||
// Validate field on blur if enabled
|
||||
if (props.validateOnBlur) {
|
||||
const error = validateField(field, value);
|
||||
if (error) {
|
||||
formErrors[field.name] = error;
|
||||
} else {
|
||||
// Clear error if validation passes
|
||||
delete formErrors[field.name];
|
||||
}
|
||||
}
|
||||
|
||||
// Emit blur event
|
||||
emit('blur', {
|
||||
fieldName: field.name,
|
||||
value: value,
|
||||
formData: getCurrentFormData()
|
||||
});
|
||||
};
|
||||
|
||||
// Get current form data (either external or internal)
|
||||
const getCurrentFormData = () => {
|
||||
return props.formData || internalFormData;
|
||||
};
|
||||
|
||||
// Validate entire form
|
||||
const validateForm = () => {
|
||||
const errors = {};
|
||||
let isValid = true;
|
||||
|
||||
props.fields.forEach(field => {
|
||||
const value = getFieldValue(field.name);
|
||||
const error = validateField(field, value);
|
||||
if (error) {
|
||||
errors[field.name] = error;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
Object.assign(formErrors, errors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async () => {
|
||||
// Always validate on submit if enabled
|
||||
if (props.validateOnSubmit && !validateForm()) {
|
||||
console.warn('Form validation failed on submit');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
try {
|
||||
const formData = getCurrentFormData();
|
||||
|
||||
if (props.onSubmit && typeof props.onSubmit === 'function') {
|
||||
await props.onSubmit(formData);
|
||||
}
|
||||
|
||||
emit('submit', formData);
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel action
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
// Initialize form data when component mounts
|
||||
initializeFormData();
|
||||
|
||||
// Watch for changes in fields to reinitialize
|
||||
watch(() => props.fields, () => {
|
||||
initializeFormData();
|
||||
}, { deep: true });
|
||||
|
||||
// Clear all form errors
|
||||
const clearAllErrors = () => {
|
||||
Object.keys(formErrors).forEach(key => delete formErrors[key]);
|
||||
};
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
validateForm,
|
||||
getCurrentFormData,
|
||||
resetForm: () => {
|
||||
initializeFormData();
|
||||
clearAllErrors();
|
||||
},
|
||||
setFieldError: (fieldName, error) => {
|
||||
formErrors[fieldName] = error;
|
||||
},
|
||||
clearFieldError: (fieldName) => {
|
||||
delete formErrors[fieldName];
|
||||
},
|
||||
clearAllErrors,
|
||||
// Manual validation trigger
|
||||
validateField: (fieldName) => {
|
||||
const field = props.fields.find(f => f.name === fieldName);
|
||||
if (field) {
|
||||
const value = getFieldValue(fieldName);
|
||||
const error = validateField(field, value);
|
||||
if (error) {
|
||||
formErrors[fieldName] = error;
|
||||
} else {
|
||||
delete formErrors[fieldName];
|
||||
}
|
||||
return !error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-form {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
286
frontend/src/components/common/Modal.vue
Normal file
286
frontend/src/components/common/Modal.vue
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-model="localVisible"
|
||||
:persistent="options.persistent || false"
|
||||
:fullscreen="options.fullscreen || false"
|
||||
:max-width="options.maxWidth || '500px'"
|
||||
:width="options.width"
|
||||
:height="options.height"
|
||||
:attach="options.attach"
|
||||
:transition="options.transition || 'dialog-transition'"
|
||||
:scrollable="options.scrollable || false"
|
||||
:retain-focus="options.retainFocus !== false"
|
||||
:close-on-back="options.closeOnBack !== false"
|
||||
:close-on-content-click="options.closeOnContentClick || false"
|
||||
:overlay-color="options.overlayColor"
|
||||
:overlay-opacity="options.overlayOpacity"
|
||||
:z-index="options.zIndex"
|
||||
:class="options.dialogClass"
|
||||
@click:outside="handleOutsideClick"
|
||||
@keydown.esc="handleEscapeKey"
|
||||
>
|
||||
<v-card
|
||||
:class="[
|
||||
'modal-card',
|
||||
options.cardClass,
|
||||
{
|
||||
'elevation-0': options.flat,
|
||||
'rounded-0': options.noRadius
|
||||
}
|
||||
]"
|
||||
:color="options.cardColor"
|
||||
:variant="options.cardVariant"
|
||||
:elevation="options.elevation"
|
||||
>
|
||||
<!-- Header Section -->
|
||||
<v-card-title
|
||||
v-if="options.showHeader !== false"
|
||||
:class="[
|
||||
'modal-header d-flex align-center justify-space-between',
|
||||
options.headerClass
|
||||
]"
|
||||
>
|
||||
<div class="modal-title">
|
||||
<slot name="title">
|
||||
{{ options.title }}
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<v-btn
|
||||
v-if="options.showCloseButton !== false && !options.persistent"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
:color="options.closeButtonColor || 'grey'"
|
||||
@click="closeModal"
|
||||
class="modal-close-btn"
|
||||
>
|
||||
<v-icon>{{ options.closeIcon || 'mdi-close' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider v-if="options.showHeaderDivider && options.showHeader !== false" />
|
||||
|
||||
<!-- Content Section -->
|
||||
<v-card-text
|
||||
:class="[
|
||||
'modal-content',
|
||||
options.contentClass,
|
||||
{
|
||||
'pa-0': options.noPadding,
|
||||
'overflow-y-auto': options.scrollable
|
||||
}
|
||||
]"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<slot>
|
||||
<!-- Default content if no slot provided -->
|
||||
<div v-if="options.message" v-html="options.message"></div>
|
||||
</slot>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<v-card-actions
|
||||
v-if="options.showActions !== false || $slots.actions"
|
||||
:class="[
|
||||
'modal-actions',
|
||||
options.actionsClass,
|
||||
{
|
||||
'justify-end': options.actionsAlign === 'right',
|
||||
'justify-center': options.actionsAlign === 'center',
|
||||
'justify-start': options.actionsAlign === 'left',
|
||||
'justify-space-between': options.actionsAlign === 'space-between'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<slot name="actions" :close="closeModal" :options="options">
|
||||
<!-- Default action buttons -->
|
||||
<v-btn
|
||||
v-if="options.showCancelButton !== false && !options.persistent"
|
||||
:color="options.cancelButtonColor || 'grey'"
|
||||
:variant="options.cancelButtonVariant || 'text'"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ options.cancelButtonText || 'Cancel' }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="options.showConfirmButton !== false"
|
||||
:color="options.confirmButtonColor || 'primary'"
|
||||
:variant="options.confirmButtonVariant || 'elevated'"
|
||||
:loading="options.loading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ options.confirmButtonText || 'Confirm' }}
|
||||
</v-btn>
|
||||
</slot>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
// Modal visibility state
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Options object for configuration
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits([
|
||||
'update:visible',
|
||||
'close',
|
||||
'confirm',
|
||||
'cancel',
|
||||
'outside-click',
|
||||
'escape-key'
|
||||
])
|
||||
|
||||
// Local visibility state that syncs with parent
|
||||
const localVisible = computed({
|
||||
get() {
|
||||
return props.visible
|
||||
},
|
||||
set(value) {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
})
|
||||
|
||||
// Computed styles for content area
|
||||
const contentStyle = computed(() => {
|
||||
const styles = {}
|
||||
|
||||
if (props.options.contentHeight) {
|
||||
styles.height = props.options.contentHeight
|
||||
}
|
||||
|
||||
if (props.options.contentMaxHeight) {
|
||||
styles.maxHeight = props.options.contentMaxHeight
|
||||
}
|
||||
|
||||
if (props.options.contentMinHeight) {
|
||||
styles.minHeight = props.options.contentMinHeight
|
||||
}
|
||||
|
||||
return styles
|
||||
})
|
||||
|
||||
// Methods
|
||||
const closeModal = () => {
|
||||
localVisible.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm')
|
||||
|
||||
// Auto-close unless specified not to
|
||||
if (props.options.autoCloseOnConfirm !== false) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
|
||||
// Auto-close unless specified not to
|
||||
if (props.options.autoCloseOnCancel !== false) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOutsideClick = () => {
|
||||
emit('outside-click')
|
||||
|
||||
// Close on outside click unless persistent or disabled
|
||||
if (!props.options.persistent && props.options.closeOnOutsideClick !== false) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscapeKey = () => {
|
||||
emit('escape-key')
|
||||
|
||||
// Close on escape key unless persistent or disabled
|
||||
if (!props.options.persistent && props.options.closeOnEscape !== false) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for external visibility changes
|
||||
watch(() => props.visible, (newValue) => {
|
||||
if (newValue && props.options.onOpen) {
|
||||
props.options.onOpen()
|
||||
} else if (!newValue && props.options.onClose) {
|
||||
props.options.onClose()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background-color: var(--v-theme-surface-variant);
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 16px 24px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.modal-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom transitions */
|
||||
.v-dialog--fullscreen .modal-card {
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.modal-card.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue