create form and modal components, update datatable persistant filtering

This commit is contained in:
Casey 2025-10-30 03:21:41 -05:00
parent b70e08026d
commit 8d9bb81fe2
23 changed files with 3502 additions and 74 deletions

View file

@ -0,0 +1,336 @@
<!-- Modal Usage Examples -->
<template>
<div class="modal-examples">
<h2>Modal Component Examples</h2>
<!-- Example buttons to trigger different modal types -->
<div class="example-buttons">
<v-btn @click="showBasicModal" color="primary">Basic Modal</v-btn>
<v-btn @click="showFormModal" color="secondary">Form Modal</v-btn>
<v-btn @click="showConfirmModal" color="warning">Confirmation Modal</v-btn>
<v-btn @click="showFullscreenModal" color="success">Fullscreen Modal</v-btn>
<v-btn @click="showCustomModal" color="info">Custom Styled Modal</v-btn>
</div>
<!-- Basic Modal -->
<Modal
v-model:visible="basicModalVisible"
:options="basicModalOptions"
@close="onBasicModalClose"
@confirm="onBasicModalConfirm"
>
<p>This is a basic modal with default settings.</p>
<p>You can put any content here!</p>
</Modal>
<!-- Form Modal -->
<Modal
v-model:visible="formModalVisible"
:options="formModalOptions"
@close="onFormModalClose"
@confirm="onFormModalConfirm"
>
<template #title>
<v-icon class="mr-2">mdi-account-plus</v-icon>
Add New User
</template>
<v-form ref="userForm" v-model="formValid">
<v-text-field
v-model="userForm.name"
label="Full Name"
:rules="[v => !!v || 'Name is required']"
required
/>
<v-text-field
v-model="userForm.email"
label="Email"
type="email"
:rules="emailRules"
required
/>
<v-select
v-model="userForm.role"
:items="roleOptions"
label="Role"
:rules="[v => !!v || 'Role is required']"
required
/>
</v-form>
</Modal>
<!-- Confirmation Modal -->
<Modal
v-model:visible="confirmModalVisible"
:options="confirmModalOptions"
@confirm="onDeleteConfirm"
@cancel="onDeleteCancel"
>
<div class="text-center">
<v-icon size="64" color="warning" class="mb-4">mdi-alert-circle</v-icon>
<h3 class="mb-2">Are you sure?</h3>
<p>This action cannot be undone. The item will be permanently deleted.</p>
</div>
</Modal>
<!-- Fullscreen Modal -->
<Modal
v-model:visible="fullscreenModalVisible"
:options="fullscreenModalOptions"
@close="onFullscreenModalClose"
>
<template #title>
Fullscreen Content
</template>
<div class="fullscreen-content">
<v-row>
<v-col cols="12" md="6">
<v-card>
<v-card-title>Left Panel</v-card-title>
<v-card-text>
<p>This is a fullscreen modal that can contain complex layouts.</p>
<v-list>
<v-list-item v-for="i in 10" :key="i">
<v-list-item-title>Item {{ i }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card>
<v-card-title>Right Panel</v-card-title>
<v-card-text>
<v-img
src="https://picsum.photos/400/200"
height="200"
class="mb-4"
/>
<p>You can include any Vue components here.</p>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</Modal>
<!-- Custom Styled Modal -->
<Modal
v-model:visible="customModalVisible"
:options="customModalOptions"
@close="onCustomModalClose"
>
<template #title>
<div class="custom-title">
<v-icon class="mr-2">mdi-palette</v-icon>
Custom Styled Modal
</div>
</template>
<div class="custom-content">
<v-card variant="outlined" class="mb-4">
<v-card-text>
<v-icon size="32" color="primary" class="mr-2">mdi-information</v-icon>
This modal demonstrates custom styling options.
</v-card-text>
</v-card>
<v-timeline density="compact">
<v-timeline-item
v-for="item in timelineItems"
:key="item.id"
:dot-color="item.color"
size="small"
>
<v-card>
<v-card-title>{{ item.title }}</v-card-title>
<v-card-subtitle>{{ item.time }}</v-card-subtitle>
</v-card>
</v-timeline-item>
</v-timeline>
</div>
<template #actions="{ close }">
<v-btn color="gradient" variant="elevated" @click="close">
<v-icon class="mr-1">mdi-check</v-icon>
Got it!
</v-btn>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import Modal from './common/Modal.vue'
// Basic Modal
const basicModalVisible = ref(false)
const basicModalOptions = {
title: 'Basic Modal',
maxWidth: '400px',
showActions: true
}
// Form Modal
const formModalVisible = ref(false)
const formValid = ref(false)
const userForm = reactive({
name: '',
email: '',
role: ''
})
const formModalOptions = {
maxWidth: '500px',
persistent: true,
confirmButtonText: 'Save User',
confirmButtonColor: 'success',
loading: false
}
const roleOptions = ['Admin', 'User', 'Manager', 'Viewer']
const emailRules = [
v => !!v || 'Email is required',
v => /.+@.+\..+/.test(v) || 'Email must be valid'
]
// Confirmation Modal
const confirmModalVisible = ref(false)
const confirmModalOptions = {
title: 'Confirm Deletion',
maxWidth: '400px',
persistent: false,
confirmButtonText: 'Delete',
confirmButtonColor: 'error',
cancelButtonText: 'Keep',
cardColor: 'surface-variant'
}
// Fullscreen Modal
const fullscreenModalVisible = ref(false)
const fullscreenModalOptions = {
fullscreen: true,
showActions: false,
scrollable: true
}
// Custom Modal
const customModalVisible = ref(false)
const customModalOptions = {
maxWidth: '600px',
cardColor: 'primary',
cardVariant: 'elevated',
elevation: 12,
headerClass: 'custom-header',
contentClass: 'custom-content-class',
showActions: false,
overlayOpacity: 0.8,
transition: 'scale-transition'
}
const timelineItems = [
{ id: 1, title: 'Project Started', time: '2 hours ago', color: 'primary' },
{ id: 2, title: 'First Milestone', time: '1 hour ago', color: 'success' },
{ id: 3, title: 'Review Phase', time: '30 minutes ago', color: 'warning' }
]
// Modal event handlers
const showBasicModal = () => {
basicModalVisible.value = true
}
const onBasicModalClose = () => {
console.log('Basic modal closed')
}
const onBasicModalConfirm = () => {
console.log('Basic modal confirmed')
}
const showFormModal = () => {
formModalVisible.value = true
}
const onFormModalClose = () => {
// Reset form
Object.assign(userForm, { name: '', email: '', role: '' })
}
const onFormModalConfirm = async () => {
if (formValid.value) {
formModalOptions.loading = true
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('User saved:', userForm)
formModalOptions.loading = false
}
}
const showConfirmModal = () => {
confirmModalVisible.value = true
}
const onDeleteConfirm = () => {
console.log('Item deleted')
}
const onDeleteCancel = () => {
console.log('Deletion cancelled')
}
const showFullscreenModal = () => {
fullscreenModalVisible.value = true
}
const onFullscreenModalClose = () => {
console.log('Fullscreen modal closed')
}
const showCustomModal = () => {
customModalVisible.value = true
}
const onCustomModalClose = () => {
console.log('Custom modal closed')
}
</script>
<style scoped>
.modal-examples {
padding: 20px;
}
.example-buttons {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.fullscreen-content {
height: 100%;
}
.custom-title {
display: flex;
align-items: center;
color: white;
}
.custom-content {
background: linear-gradient(45deg, #f3f4f6 0%, #ffffff 100%);
padding: 16px;
border-radius: 8px;
}
.custom-header {
background: linear-gradient(45deg, #1976d2 0%, #42a5f5 100%);
color: white;
}
.custom-content-class {
background-color: #fafafa;
}
</style>

View file

@ -1,6 +1,7 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useModalStore } from "@/stores/modal";
import {
Home,
Community,
@ -14,6 +15,7 @@ import {
import SpeedDial from "primevue/speeddial";
const router = useRouter();
const modalStore = useModalStore();
const categories = [
{ name: "Home", icon: Home, url: "/" },
{ name: "Calendar", icon: Calendar, url: "/calendar" },
@ -32,7 +34,7 @@ const createButtons = ref([
{
label: "Client",
command: () => {
frappe.new_doc("Customer");
modalStore.openCreateClient();
},
},
{

View file

@ -26,7 +26,7 @@
<InputText
v-model="filterModel.value"
type="text"
@input="filterCallback()"
@input="handleFilterInput(col.fieldName, filterModel.value, filterCallback)"
:placeholder="`Search ${col.label}...`"
/>
</template>
@ -48,7 +48,7 @@
</DataTable>
</template>
<script setup>
import { defineProps } from "vue";
import { defineProps, computed, onMounted, watch } from "vue";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Tag from "primevue/tag";
@ -56,6 +56,10 @@ 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,
@ -71,13 +75,76 @@ const props = defineProps({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
}),
},
tableName: {
type: String,
required: true,
},
});
const emit = defineEmits(["rowClick"]);
const filterRef = ref(props.filters);
// 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()) {
@ -93,14 +160,6 @@ const getBadgeColor = (status) => {
};
console.log("DEBUG: - DataTable props.columns", props.columns);
console.log("DEBUG: - DataTable props.data", props.data);
// const columnsList = props.columns.map((col) => col.label);
// const dataList = props.data.map((row) => props.columns.map((col) => row[col.fieldName] || ""));
// Pass the actual DOM element
// new frappe.DataTable(dataTableContainer.value, {
// columns: columnsList,
// data: dataList,
// });
</script>
<style lang=""></style>
<style lang="">
</style>

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

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

View file

@ -0,0 +1,413 @@
<template>
<Modal
:visible="isVisible"
:options="modalOptions"
@update:visible="handleVisibilityChange"
@close="handleClose"
>
<template #title>
Create New Client
</template>
<!-- Status Message -->
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`">
<v-icon
:icon="statusType === 'warning' ? 'mdi-alert' : statusType === 'error' ? 'mdi-alert-circle' : 'mdi-information'"
size="small"
class="mr-2"
/>
{{ statusMessage }}
</div>
<Form
:fields="formFields"
:form-data="formData"
:on-submit="handleSubmit"
:show-cancel-button="true"
:validate-on-change="false"
:validate-on-blur="true"
:validate-on-submit="true"
submit-button-text="Create Client"
cancel-button-text="Cancel"
@submit="handleSubmit"
@cancel="handleCancel"
@change="handleFieldChange"
@blur="handleFieldBlur"
/>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { useModalStore } from '@/stores/modal'
import Modal from '@/components/common/Modal.vue'
import Form from '@/components/common/Form.vue'
import Api from '@/api'
const modalStore = useModalStore()
// Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen('createClient'))
// Form data
const formData = reactive({
name: '',
address: '',
phone: '',
email: '',
zipcode: '',
city: '',
state: ''
})
// Available cities for the selected zipcode
const availableCities = ref([])
// Loading state for zipcode lookup
const isLoadingZipcode = ref(false)
// Status message for user feedback
const statusMessage = ref('')
const statusType = ref('info') // 'info', 'warning', 'error', 'success'
// US State abbreviations for validation
const US_STATES = [
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
'DC' // District of Columbia
]
// Modal configuration
const modalOptions = {
maxWidth: '600px',
persistent: false,
showActions: false,
title: 'Create New Client',
overlayColor: 'rgb(59, 130, 246)', // Blue background
overlayOpacity: 0.8,
cardClass: 'create-client-modal',
closeOnOutsideClick: true,
closeOnEscape: true
}
// Form field definitions
const formFields = computed(() => [
{
name: 'name',
label: 'Client Name',
type: 'text',
required: true,
placeholder: 'Enter client name',
cols: 12,
md: 12
},
{
name: 'address',
label: 'Address',
type: 'text',
required: true,
placeholder: 'Enter street address',
cols: 12,
md: 12
},
{
name: 'phone',
label: 'Phone Number',
type: 'text',
required: true,
placeholder: 'Enter phone number',
format: 'tel',
cols: 12,
md: 6,
validate: (value) => {
if (value && !/^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/.test(value)) {
return 'Please enter a valid phone number'
}
return null
}
},
{
name: 'email',
label: 'Email Address',
type: 'text',
required: true,
placeholder: 'Enter email address',
format: 'email',
cols: 12,
md: 6
},
{
name: 'zipcode',
label: 'Zip Code',
type: 'text',
required: true,
placeholder: 'Enter zip code',
cols: 12,
md: 4,
onChangeOverride: handleZipcodeChange,
validate: (value) => {
if (value && !/^\d{5}(-\d{4})?$/.test(value)) {
return 'Please enter a valid zip code'
}
return null
}
},
{
name: 'city',
label: 'City',
type: availableCities.value.length > 0 ? 'select' : 'text',
required: true,
disabled: false,
placeholder: availableCities.value.length > 0 ? 'Select city' : 'Enter city name',
options: availableCities.value.map(place => ({
label: place['place name'],
value: place['place name']
})),
cols: 12,
md: 4,
helpText: isLoadingZipcode.value
? 'Loading cities...'
: availableCities.value.length > 0
? 'Select from available cities'
: 'Enter city manually (auto-lookup unavailable)'
},
{
name: 'state',
label: 'State',
type: 'text',
required: true,
disabled: availableCities.value.length > 0,
placeholder: availableCities.value.length > 0 ? 'Auto-populated' : 'Enter state (e.g., CA, TX, NY)',
cols: 12,
md: 4,
helpText: availableCities.value.length > 0
? 'Auto-populated from zip code'
: 'Enter state abbreviation manually',
validate: (value) => {
// Only validate manually entered states (when API lookup failed)
if (availableCities.value.length === 0 && value) {
const upperValue = value.toUpperCase()
if (!US_STATES.includes(upperValue)) {
return 'Please enter a valid US state abbreviation (e.g., CA, TX, NY)'
}
// Auto-correct to uppercase
if (value !== upperValue) {
formData.state = upperValue
}
}
return null
}
}
])
// Handle zipcode change and API lookup
async function handleZipcodeChange(value, fieldName, formData) {
if (fieldName === 'zipcode' && value && value.length >= 5) {
// Only process if it's a valid zipcode format
const zipcode = value.replace(/\D/g, '').substring(0, 5)
if (zipcode.length === 5) {
isLoadingZipcode.value = true
try {
const places = await Api.getCityStateByZip(zipcode)
if (places && places.length > 0) {
availableCities.value = places
// Auto-populate state from first result
formData.state = places[0].state
// If only one city, auto-select it
if (places.length === 1) {
formData.city = places[0]['place name']
showStatusMessage(`Location found: ${places[0]['place name']}, ${places[0].state}`, 'success')
} else {
// Clear city selection if multiple cities
formData.city = ''
showStatusMessage(`Found ${places.length} cities for this zip code. Please select one.`, 'info')
}
} else {
// No results found - enable manual entry
handleApiFailure(formData, 'No location data found for this zip code')
}
} catch (error) {
console.error('Error fetching city/state data:', error)
// Check if it's a network/CORS error
if (error.code === 'ERR_NETWORK' || error.message.includes('Network Error')) {
handleApiFailure(formData, 'Unable to fetch location data. Please enter city and state manually.')
} else {
handleApiFailure(formData, 'Location lookup failed. Please enter city and state manually.')
}
} finally {
isLoadingZipcode.value = false
}
}
}
}
// Handle API failure by enabling manual entry
function handleApiFailure(formData, message) {
console.warn('Zipcode API failed:', message)
// Clear existing data
availableCities.value = []
formData.city = ''
formData.state = ''
// Show user-friendly message
showStatusMessage(message, 'warning')
}
// Show status message to user
function showStatusMessage(message, type = 'info') {
statusMessage.value = message
statusType.value = type
// Auto-clear message after 5 seconds
setTimeout(() => {
statusMessage.value = ''
}, 5000)
}
// Handle form field changes
function handleFieldChange(event) {
console.log('Field changed:', event)
}
// Handle form field blur
function handleFieldBlur(event) {
console.log('Field blurred:', event)
}
// Handle form submission
function handleSubmit(data) {
console.log('Form submitted with data:', data)
// TODO: Add API call to create client when ready
// For now, just log the data and close the modal
// Show success message (you can customize this)
alert('Client would be created with the following data:\n' + JSON.stringify(data, null, 2))
// Close the modal
handleClose()
}
// Handle cancel action
function handleCancel() {
handleClose()
}
// Handle modal close
function handleClose() {
modalStore.closeCreateClient()
resetForm()
}
// Handle visibility changes
function handleVisibilityChange(visible) {
if (!visible) {
handleClose()
}
}
// Reset form data
function resetForm() {
Object.keys(formData).forEach(key => {
formData[key] = ''
})
availableCities.value = []
isLoadingZipcode.value = false
statusMessage.value = ''
statusType.value = 'info'
}
// Initialize modal in store when component mounts
modalStore.initializeModal('createClient', {
closeOnEscape: true,
closeOnOutsideClick: true
})
</script>
<style scoped>
.create-client-modal {
border-radius: 12px;
}
/* Custom styling for the modal content */
:deep(.modal-header) {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
:deep(.modal-title) {
font-weight: 600;
font-size: 1.25rem;
}
:deep(.modal-close-btn) {
color: white !important;
}
:deep(.modal-content) {
padding: 24px;
}
/* Form styling adjustments */
:deep(.v-text-field) {
margin-bottom: 8px;
}
:deep(.v-select) {
margin-bottom: 8px;
}
:deep(.v-btn) {
text-transform: none;
font-weight: 500;
}
:deep(.v-btn.v-btn--variant-elevated) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Status message styling */
.status-message {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
display: flex;
align-items: center;
font-size: 0.9rem;
border-left: 4px solid;
}
.status-info {
background-color: #e3f2fd;
color: #1565c0;
border-left-color: #2196f3;
}
.status-warning {
background-color: #fff3e0;
color: #ef6c00;
border-left-color: #ff9800;
}
.status-error {
background-color: #ffebee;
color: #c62828;
border-left-color: #f44336;
}
.status-success {
background-color: #e8f5e8;
color: #2e7d32;
border-left-color: #4caf50;
}
</style>

View file

@ -6,12 +6,12 @@
Add
</button>
</div>
<DataTable :data="tableData" :columns="columns" :filters="filters" />
<DataTable :data="tableData" :columns="columns" :filters="filters" tableName="clients" />
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import DataTable from "../DataTable.vue";
import DataTable from "../common/DataTable.vue";
import Api from "../../api";
import { FilterMatchMode } from "@primevue/core";
@ -48,13 +48,6 @@ onMounted(async () => {
return;
}
let data = await Api.getClientDetails();
// data = data.map((item) => [
// item.customer["customer_name"] || "",
// item.address["appointment_scheduled"] || "",
// item.address["estimate_sent"] || "",
// item.address["payment_received"] || "",
// item.address["job_status"] || "",
// ]);
tableData.value = data;
});
</script>

View file

@ -1,19 +1,19 @@
<template>
<div>
<h2>Jobs</h2>
<DataTable :data="tableData" :columns="columns" />
<DataTable :data="tableData" :columns="columns" tableName="jobs" />
</div>
</template>
<script setup>
import DataTable from "../DataTable.vue";
import DataTable from "../common/DataTable.vue";
import { ref, onMounted } from "vue";
import Api from "../../api";
const tableData = ref([]);
const columns = [
{ label: "Job ID", fieldName: "jobId", type: "text", sortable: true },
{ label: "Job ID", fieldName: "jobId", type: "text", sortable: true, filterable: true },
{ label: "Address", fieldName: "address", type: "text", sortable: true },
{ label: "Customer", fieldName: "customer", type: "text", sortable: true },
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
{ label: "Overall Status", fieldName: "overAllStatus", type: "status", sortable: true },
{ label: "Progress", fieldName: "stepProgress", type: "text", sortable: true },
];

View file

@ -7,7 +7,7 @@
<!-- Routes Data Table -->
<div class="routes-table-container">
<DataTable :data="tableData" :columns="columns" @row-click="viewRouteDetails" />
<DataTable :data="tableData" :columns="columns" tableName="routes" @row-click="viewRouteDetails" />
</div>
<!-- Route Details Modal -->
@ -228,7 +228,7 @@
<script setup>
import { ref, onMounted } from "vue";
import DataTable from "../DataTable.vue";
import DataTable from "../common/DataTable.vue";
import Api from "../../api";
// Reactive data

View file

@ -127,6 +127,7 @@
:data="filteredTableData"
:columns="columns"
:filters="filters"
tableName="timesheets"
@row-click="viewTimesheetDetails"
/>
</div>
@ -368,7 +369,7 @@
<script setup>
import { ref, onMounted, computed } from "vue";
import DataTable from "../DataTable.vue";
import DataTable from "../common/DataTable.vue";
import Api from "../../api";
import { FilterMatchMode } from "@primevue/core";

View file

@ -6,13 +6,13 @@
Add New Warranty Claim
</button>
</div>
<DataTable :data="tableData" :columns="columns" :filters="filters" />
<DataTable :data="tableData" :columns="columns" :filters="filters" tableName="warranties" />
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import DataTable from "../DataTable.vue";
import DataTable from "../common/DataTable.vue";
import Api from "../../api";
import { FilterMatchMode } from "@primevue/core";