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
336
frontend/src/components/ModalExamples.vue
Normal file
336
frontend/src/components/ModalExamples.vue
Normal 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>
|
||||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
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>
|
||||
413
frontend/src/components/modals/CreatClientModal.vue
Normal file
413
frontend/src/components/modals/CreatClientModal.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue