create form and modal components, update datatable persistant filtering
This commit is contained in:
parent
b70e08026d
commit
8d9bb81fe2
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
|
||||
332
frontend/documentation/components/DataTable.md
Normal file
332
frontend/documentation/components/DataTable.md
Normal file
@ -0,0 +1,332 @@
|
||||
# DataTable Component Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
A feature-rich data table component built with PrimeVue's DataTable. This component provides advanced functionality including pagination, sorting, filtering, row selection, and customizable column types with persistent filter state management.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:data="tableData"
|
||||
table-name="my-table"
|
||||
@row-click="handleRowClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import DataTable from './components/common/DataTable.vue'
|
||||
|
||||
const tableColumns = ref([
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: 'Name',
|
||||
sortable: true,
|
||||
filterable: true
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: 'Status',
|
||||
type: 'status',
|
||||
sortable: true,
|
||||
filterable: true
|
||||
}
|
||||
])
|
||||
|
||||
const tableData = ref([
|
||||
{ id: 1, name: 'John Doe', status: 'completed' },
|
||||
{ id: 2, name: 'Jane Smith', status: 'in progress' }
|
||||
])
|
||||
|
||||
const handleRowClick = (event) => {
|
||||
console.log('Row clicked:', event.data)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### `columns` (Array) - Required
|
||||
- **Description:** Array of column configuration objects that define the table structure
|
||||
- **Type:** `Array<Object>`
|
||||
- **Required:** `true`
|
||||
|
||||
### `data` (Array) - Required
|
||||
- **Description:** Array of data objects to display in the table
|
||||
- **Type:** `Array<Object>`
|
||||
- **Required:** `true`
|
||||
|
||||
### `tableName` (String) - Required
|
||||
- **Description:** Unique identifier for the table, used for persistent filter state management
|
||||
- **Type:** `String`
|
||||
- **Required:** `true`
|
||||
|
||||
### `filters` (Object)
|
||||
- **Description:** Initial filter configuration object
|
||||
- **Type:** `Object`
|
||||
- **Default:** `{ global: { value: null, matchMode: FilterMatchMode.CONTAINS } }`
|
||||
|
||||
## Column Configuration
|
||||
|
||||
Each column object in the `columns` array supports the following properties:
|
||||
|
||||
### Basic Properties
|
||||
- **`fieldName`** (String, required) - The field name in the data object
|
||||
- **`label`** (String, required) - Display label for the column header
|
||||
- **`sortable`** (Boolean, default: `false`) - Enables sorting for this column
|
||||
- **`filterable`** (Boolean, default: `false`) - Enables row-level filtering for this column
|
||||
|
||||
### Column Types
|
||||
- **`type`** (String) - Defines special rendering behavior for the column
|
||||
|
||||
#### Available Types:
|
||||
|
||||
##### `'status'` Type
|
||||
Renders values as colored tags/badges:
|
||||
```javascript
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: 'Status',
|
||||
type: 'status',
|
||||
sortable: true,
|
||||
filterable: true
|
||||
}
|
||||
```
|
||||
|
||||
**Status Colors:**
|
||||
- `'completed'` → Success (green)
|
||||
- `'in progress'` → Warning (yellow/orange)
|
||||
- `'not started'` → Danger (red)
|
||||
- Other values → Info (blue)
|
||||
|
||||
##### `'button'` Type
|
||||
Renders values as clickable buttons:
|
||||
```javascript
|
||||
{
|
||||
fieldName: 'action',
|
||||
label: 'Action',
|
||||
type: 'button'
|
||||
}
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
### `rowClick`
|
||||
- **Description:** Emitted when a button-type column is clicked
|
||||
- **Payload:** PrimeVue slot properties object containing row data
|
||||
- **Usage:** `@row-click="handleRowClick"`
|
||||
|
||||
```javascript
|
||||
const handleRowClick = (slotProps) => {
|
||||
console.log('Clicked row data:', slotProps.data)
|
||||
console.log('Row index:', slotProps.index)
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Pagination
|
||||
- **Rows per page options:** 5, 10, 20, 50
|
||||
- **Default rows per page:** 10
|
||||
- **Built-in pagination controls**
|
||||
|
||||
### Sorting
|
||||
- **Multiple column sorting** support
|
||||
- **Removable sort** - click to remove sort from a column
|
||||
- **Sort indicators** in column headers
|
||||
|
||||
### Filtering
|
||||
- **Row-level filtering** for filterable columns
|
||||
- **Text-based search** with real-time filtering
|
||||
- **Persistent filter state** across component re-renders
|
||||
- **Global search capability**
|
||||
|
||||
### Selection
|
||||
- **Multiple row selection** with checkboxes
|
||||
- **Meta key selection** (Ctrl/Cmd + click for individual selection)
|
||||
- **Unique row identification** using `dataKey="id"`
|
||||
|
||||
### Scrolling
|
||||
- **Vertical scrolling** with fixed height (70vh)
|
||||
- **Horizontal scrolling** for wide tables
|
||||
- **Fixed headers** during scroll
|
||||
|
||||
### State Management
|
||||
- **Persistent filters** using Pinia store (`useFiltersStore`)
|
||||
- **Automatic filter initialization** on component mount
|
||||
- **Cross-component filter synchronization**
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Table
|
||||
```vue
|
||||
<script setup>
|
||||
const columns = [
|
||||
{ fieldName: 'id', label: 'ID', sortable: true },
|
||||
{ fieldName: 'name', label: 'Name', sortable: true, filterable: true },
|
||||
{ fieldName: 'email', label: 'Email', filterable: true }
|
||||
]
|
||||
|
||||
const data = [
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com' },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
table-name="users-table"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Status Table
|
||||
```vue
|
||||
<script setup>
|
||||
const columns = [
|
||||
{ fieldName: 'task', label: 'Task', sortable: true, filterable: true },
|
||||
{ fieldName: 'status', label: 'Status', type: 'status', sortable: true, filterable: true },
|
||||
{ fieldName: 'assignee', label: 'Assignee', filterable: true }
|
||||
]
|
||||
|
||||
const data = [
|
||||
{ id: 1, task: 'Setup project', status: 'completed', assignee: 'John' },
|
||||
{ id: 2, task: 'Write tests', status: 'in progress', assignee: 'Jane' },
|
||||
{ id: 3, task: 'Deploy app', status: 'not started', assignee: 'Bob' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
table-name="tasks-table"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Interactive Table with Buttons
|
||||
```vue
|
||||
<script setup>
|
||||
const columns = [
|
||||
{ fieldName: 'name', label: 'Name', sortable: true, filterable: true },
|
||||
{ fieldName: 'status', label: 'Status', type: 'status', sortable: true },
|
||||
{ fieldName: 'action', label: 'Action', type: 'button' }
|
||||
]
|
||||
|
||||
const data = [
|
||||
{ id: 1, name: 'Project A', status: 'completed', action: 'View Details' },
|
||||
{ id: 2, name: 'Project B', status: 'in progress', action: 'Edit' }
|
||||
]
|
||||
|
||||
const handleRowClick = (slotProps) => {
|
||||
const { data, index } = slotProps
|
||||
console.log(`Action clicked for ${data.name} at row ${index}`)
|
||||
// Handle the action (navigate, open modal, etc.)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
table-name="projects-table"
|
||||
@row-click="handleRowClick"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Custom Filters
|
||||
```vue
|
||||
<script setup>
|
||||
import { FilterMatchMode } from '@primevue/core'
|
||||
|
||||
const customFilters = {
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
name: { value: 'John', matchMode: FilterMatchMode.STARTS_WITH }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:filters="customFilters"
|
||||
table-name="filtered-table"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Store Integration
|
||||
|
||||
The component integrates with a Pinia store (`useFiltersStore`) for persistent filter state:
|
||||
|
||||
### Store Methods Used
|
||||
- `initializeTableFilters(tableName, columns)` - Initialize filters for a table
|
||||
- `getTableFilters(tableName)` - Get current filters for a table
|
||||
- `updateTableFilter(tableName, fieldName, value, matchMode)` - Update a specific filter
|
||||
|
||||
### Filter Persistence
|
||||
- Filters are automatically saved when changed
|
||||
- Filters persist across component re-mounts
|
||||
- Each table maintains separate filter state based on `tableName`
|
||||
|
||||
## Styling
|
||||
|
||||
The component uses PrimeVue's default DataTable styling with:
|
||||
- **Scrollable layout** with fixed 70vh height
|
||||
- **Responsive design** that adapts to container width
|
||||
- **Consistent spacing** and typography
|
||||
- **Accessible color schemes** for status badges
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Large Datasets
|
||||
- **Virtual scrolling** is not implemented - consider for datasets > 1000 rows
|
||||
- **Client-side pagination** may impact performance with very large datasets
|
||||
- **Debounced filtering** helps with real-time search performance
|
||||
|
||||
### Memory Management
|
||||
- **Filter state persistence** may accumulate over time
|
||||
- Consider implementing filter cleanup for unused tables
|
||||
- **Component re-rendering** is optimized through computed properties
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use unique `tableName`** for each table instance to avoid filter conflicts
|
||||
2. **Define clear column labels** for better user experience
|
||||
3. **Enable sorting and filtering** on searchable/comparable columns
|
||||
4. **Use appropriate column types** (`status`, `button`) for better UX
|
||||
5. **Handle `rowClick` events** for interactive functionality
|
||||
6. **Consider data structure** - ensure `id` field exists for selection
|
||||
7. **Test with various data sizes** to ensure performance
|
||||
8. **Use consistent status values** for proper badge coloring
|
||||
|
||||
## Accessibility
|
||||
|
||||
The component includes:
|
||||
- **Keyboard navigation** support via PrimeVue
|
||||
- **Screen reader compatibility** with proper ARIA labels
|
||||
- **High contrast** status badges for visibility
|
||||
- **Focus management** for interactive elements
|
||||
- **Semantic HTML structure** for assistive technologies
|
||||
|
||||
## Browser Support
|
||||
|
||||
Compatible with all modern browsers that support:
|
||||
- Vue 3 Composition API
|
||||
- ES6+ features
|
||||
- CSS Grid and Flexbox
|
||||
- PrimeVue components
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Vue 3** with Composition API
|
||||
- **PrimeVue** DataTable, Column, Tag, Button, InputText components
|
||||
- **@primevue/core** for FilterMatchMode
|
||||
- **Pinia** store for state management (`useFiltersStore`)
|
||||
763
frontend/documentation/components/Form.md
Normal file
763
frontend/documentation/components/Form.md
Normal file
@ -0,0 +1,763 @@
|
||||
# Form Component Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
A highly flexible and configurable dynamic form component built with Vuetify. This component generates forms based on field configuration objects and supports various input types, validation, responsive layouts, and both controlled and uncontrolled form state management.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Form
|
||||
:fields="formFields"
|
||||
:form-data="formData"
|
||||
@submit="handleSubmit"
|
||||
@change="handleFieldChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Form from './components/common/Form.vue'
|
||||
|
||||
const formData = ref({})
|
||||
|
||||
const formFields = [
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'First Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
cols: 12,
|
||||
md: 6
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'text',
|
||||
format: 'email',
|
||||
required: true,
|
||||
cols: 12,
|
||||
md: 6
|
||||
},
|
||||
{
|
||||
name: 'bio',
|
||||
label: 'Biography',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
cols: 12
|
||||
}
|
||||
]
|
||||
|
||||
const handleSubmit = (data) => {
|
||||
console.log('Form submitted:', data)
|
||||
}
|
||||
|
||||
const handleFieldChange = (event) => {
|
||||
console.log('Field changed:', event.fieldName, event.value)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### `fields` (Array) - Required
|
||||
- **Description:** Array of field configuration objects that define the form structure
|
||||
- **Type:** `Array<Object>`
|
||||
- **Required:** `true`
|
||||
|
||||
### `formData` (Object)
|
||||
- **Description:** External form data object for controlled form state
|
||||
- **Type:** `Object`
|
||||
- **Default:** `null`
|
||||
- **Note:** When provided, the form operates in controlled mode. When null, uses internal state.
|
||||
|
||||
### `onChange` (Function)
|
||||
- **Description:** Global change handler function called when any field changes
|
||||
- **Type:** `Function`
|
||||
- **Signature:** `(fieldName: string, value: any, formData: Object) => void`
|
||||
|
||||
### `onSubmit` (Function)
|
||||
- **Description:** Submit handler function called when form is submitted
|
||||
- **Type:** `Function`
|
||||
- **Signature:** `(formData: Object) => Promise<void> | void`
|
||||
|
||||
### `showSubmitButton` (Boolean)
|
||||
- **Description:** Controls visibility of the submit button
|
||||
- **Type:** `Boolean`
|
||||
- **Default:** `true`
|
||||
|
||||
### `showCancelButton` (Boolean)
|
||||
- **Description:** Controls visibility of the cancel button
|
||||
- **Type:** `Boolean`
|
||||
- **Default:** `false`
|
||||
|
||||
### `submitButtonText` (String)
|
||||
- **Description:** Text displayed on the submit button
|
||||
- **Type:** `String`
|
||||
- **Default:** `'Submit'`
|
||||
|
||||
### `cancelButtonText` (String)
|
||||
- **Description:** Text displayed on the cancel button
|
||||
- **Type:** `String`
|
||||
- **Default:** `'Cancel'`
|
||||
|
||||
### `validateOnChange` (Boolean)
|
||||
- **Description:** Enables real-time validation as fields change
|
||||
- **Type:** `Boolean`
|
||||
- **Default:** `true`
|
||||
|
||||
## Field Configuration
|
||||
|
||||
Each field object in the `fields` array supports the following properties:
|
||||
|
||||
### Basic Properties
|
||||
- **`name`** (String, required) - Unique identifier for the field
|
||||
- **`label`** (String, required) - Display label for the field
|
||||
- **`type`** (String, required) - Field input type
|
||||
- **`required`** (Boolean, default: `false`) - Makes the field mandatory
|
||||
- **`disabled`** (Boolean, default: `false`) - Disables the field
|
||||
- **`readonly`** (Boolean, default: `false`) - Makes the field read-only
|
||||
- **`placeholder`** (String) - Placeholder text for input fields
|
||||
- **`helpText`** (String) - Help text displayed below the field
|
||||
- **`defaultValue`** (Any) - Initial value for the field
|
||||
|
||||
### Layout Properties
|
||||
- **`cols`** (Number, default: `12`) - Column width on extra small screens
|
||||
- **`sm`** (Number, default: `12`) - Column width on small screens
|
||||
- **`md`** (Number, default: `6`) - Column width on medium screens
|
||||
- **`lg`** (Number, default: `6`) - Column width on large screens
|
||||
|
||||
### Validation Properties
|
||||
- **`validate`** (Function) - Custom validation function
|
||||
- **Signature:** `(value: any) => string | null`
|
||||
- **Returns:** Error message string or null if valid
|
||||
|
||||
### Field-Specific Properties
|
||||
- **`onChangeOverride`** (Function) - Field-specific change handler that overrides global onChange
|
||||
- **Signature:** `(value: any, fieldName: string, formData: Object) => void`
|
||||
|
||||
## Field Types
|
||||
|
||||
### Text Input (`type: 'text'`)
|
||||
Standard text input field with optional format validation.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Enter your username'
|
||||
}
|
||||
```
|
||||
|
||||
**Additional Properties:**
|
||||
- **`format`** (String) - Input format validation (`'email'` for email validation)
|
||||
|
||||
### Number Input (`type: 'number'`)
|
||||
Numeric input field with optional min/max constraints.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'age',
|
||||
label: 'Age',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 120,
|
||||
step: 1
|
||||
}
|
||||
```
|
||||
|
||||
**Additional Properties:**
|
||||
- **`min`** (Number) - Minimum allowed value
|
||||
- **`max`** (Number) - Maximum allowed value
|
||||
- **`step`** (Number) - Step increment for the input
|
||||
|
||||
### Textarea (`type: 'textarea'`)
|
||||
Multi-line text input for longer content.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
placeholder: 'Enter description...'
|
||||
}
|
||||
```
|
||||
|
||||
**Additional Properties:**
|
||||
- **`rows`** (Number, default: `3`) - Number of visible text lines
|
||||
|
||||
### Select Dropdown (`type: 'select'`)
|
||||
Dropdown selection field with predefined options.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'country',
|
||||
label: 'Country',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'United States', value: 'us' },
|
||||
{ label: 'Canada', value: 'ca' },
|
||||
{ label: 'Mexico', value: 'mx' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Additional Properties:**
|
||||
- **`options`** (Array, required) - Array of option objects with `label` and `value` properties
|
||||
|
||||
### Checkbox (`type: 'checkbox'`)
|
||||
Boolean checkbox input.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'subscribe',
|
||||
label: 'Subscribe to newsletter',
|
||||
type: 'checkbox',
|
||||
defaultValue: false
|
||||
}
|
||||
```
|
||||
|
||||
### Radio Group (`type: 'radio'`)
|
||||
Radio button group for single selection from multiple options.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'gender',
|
||||
label: 'Gender',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Male', value: 'male' },
|
||||
{ label: 'Female', value: 'female' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Additional Properties:**
|
||||
- **`options`** (Array, required) - Array of option objects with `label` and `value` properties
|
||||
|
||||
### Date Input (`type: 'date'`)
|
||||
Date picker input field.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'birthDate',
|
||||
label: 'Birth Date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
min: '1900-01-01',
|
||||
max: '2025-12-31'
|
||||
}
|
||||
```
|
||||
|
||||
**Additional Properties:**
|
||||
- **`min`** (String) - Minimum allowed date (YYYY-MM-DD format)
|
||||
- **`max`** (String) - Maximum allowed date (YYYY-MM-DD format)
|
||||
|
||||
### DateTime Input (`type: 'datetime'`)
|
||||
Date and time picker input field.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'appointmentTime',
|
||||
label: 'Appointment Time',
|
||||
type: 'datetime',
|
||||
required: true
|
||||
}
|
||||
```
|
||||
|
||||
**Additional Properties:**
|
||||
- **`min`** (String) - Minimum allowed datetime
|
||||
- **`max`** (String) - Maximum allowed datetime
|
||||
|
||||
### File Input (`type: 'file'`)
|
||||
File upload input field.
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'resume',
|
||||
label: 'Resume',
|
||||
type: 'file',
|
||||
accept: '.pdf,.doc,.docx',
|
||||
multiple: false
|
||||
}
|
||||
```
|
||||
|
||||
**Additional Properties:**
|
||||
- **`accept`** (String) - File types to accept (MIME types or file extensions)
|
||||
- **`multiple`** (Boolean, default: `false`) - Allow multiple file selection
|
||||
|
||||
## Events
|
||||
|
||||
### `update:formData`
|
||||
- **Description:** Emitted when form data changes (controlled mode only)
|
||||
- **Payload:** Updated form data object
|
||||
- **Usage:** `@update:formData="handleFormDataUpdate"`
|
||||
|
||||
### `submit`
|
||||
- **Description:** Emitted when form is successfully submitted
|
||||
- **Payload:** Form data object
|
||||
- **Usage:** `@submit="handleSubmit"`
|
||||
|
||||
### `cancel`
|
||||
- **Description:** Emitted when cancel button is clicked
|
||||
- **Usage:** `@cancel="handleCancel"`
|
||||
|
||||
### `change`
|
||||
- **Description:** Emitted when any field value changes
|
||||
- **Payload:** Object with `fieldName`, `value`, and `formData` properties
|
||||
- **Usage:** `@change="handleFieldChange"`
|
||||
|
||||
## Exposed Methods
|
||||
|
||||
The component exposes several methods that can be accessed via template refs:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Form ref="formRef" :fields="fields" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
// Access exposed methods
|
||||
const validateForm = () => formRef.value.validateForm()
|
||||
const resetForm = () => formRef.value.resetForm()
|
||||
</script>
|
||||
```
|
||||
|
||||
### `validateForm()`
|
||||
- **Description:** Validates the entire form and returns validation status
|
||||
- **Returns:** `Boolean` - `true` if valid, `false` if invalid
|
||||
- **Side Effect:** Updates form error state
|
||||
|
||||
### `getCurrentFormData()`
|
||||
- **Description:** Gets the current form data object
|
||||
- **Returns:** `Object` - Current form data
|
||||
|
||||
### `resetForm()`
|
||||
- **Description:** Resets form to initial state and clears all errors
|
||||
- **Returns:** `void`
|
||||
|
||||
### `setFieldError(fieldName, error)`
|
||||
- **Description:** Sets an error message for a specific field
|
||||
- **Parameters:**
|
||||
- `fieldName` (String) - The field name
|
||||
- `error` (String) - Error message to display
|
||||
|
||||
### `clearFieldError(fieldName)`
|
||||
- **Description:** Clears the error for a specific field
|
||||
- **Parameters:**
|
||||
- `fieldName` (String) - The field name to clear
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Contact Form
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const formData = ref({})
|
||||
|
||||
const contactFields = [
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'First Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
cols: 12,
|
||||
md: 6
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Last Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
cols: 12,
|
||||
md: 6
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'text',
|
||||
format: 'email',
|
||||
required: true,
|
||||
cols: 12
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
label: 'Message',
|
||||
type: 'textarea',
|
||||
rows: 5,
|
||||
required: true,
|
||||
cols: 12
|
||||
}
|
||||
]
|
||||
|
||||
const handleSubmit = async (data) => {
|
||||
try {
|
||||
await sendContactForm(data)
|
||||
alert('Form submitted successfully!')
|
||||
} catch (error) {
|
||||
console.error('Submission failed:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
:fields="contactFields"
|
||||
v-model:form-data="formData"
|
||||
@submit="handleSubmit"
|
||||
submit-button-text="Send Message"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### User Registration Form
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const registrationData = ref({})
|
||||
|
||||
const registrationFields = [
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (value && value.length < 3) {
|
||||
return 'Username must be at least 3 characters'
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'text',
|
||||
format: 'email',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
label: 'Age',
|
||||
type: 'number',
|
||||
min: 18,
|
||||
max: 100,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
label: 'Country',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'United States', value: 'us' },
|
||||
{ label: 'Canada', value: 'ca' },
|
||||
{ label: 'United Kingdom', value: 'uk' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'terms',
|
||||
label: 'I agree to the terms and conditions',
|
||||
type: 'checkbox',
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (!value) {
|
||||
return 'You must agree to the terms and conditions'
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
:fields="registrationFields"
|
||||
v-model:form-data="registrationData"
|
||||
@submit="handleRegistration"
|
||||
submit-button-text="Register"
|
||||
show-cancel-button
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Survey Form with Custom Validation
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const surveyData = ref({})
|
||||
|
||||
const surveyFields = [
|
||||
{
|
||||
name: 'satisfaction',
|
||||
label: 'How satisfied are you with our service?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Very Satisfied', value: '5' },
|
||||
{ label: 'Satisfied', value: '4' },
|
||||
{ label: 'Neutral', value: '3' },
|
||||
{ label: 'Dissatisfied', value: '2' },
|
||||
{ label: 'Very Dissatisfied', value: '1' }
|
||||
],
|
||||
cols: 12
|
||||
},
|
||||
{
|
||||
name: 'feedback',
|
||||
label: 'Additional Feedback',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
placeholder: 'Please share your thoughts...',
|
||||
cols: 12,
|
||||
onChangeOverride: (value, fieldName, formData) => {
|
||||
// Custom logic for this field only
|
||||
console.log(`Feedback length: ${value?.length || 0} characters`)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'recommend',
|
||||
label: 'Would you recommend us to others?',
|
||||
type: 'checkbox',
|
||||
cols: 12
|
||||
}
|
||||
]
|
||||
|
||||
const handleSurveySubmit = (data) => {
|
||||
console.log('Survey submitted:', data)
|
||||
}
|
||||
|
||||
const handleFieldChange = (event) => {
|
||||
console.log('Global change handler:', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
:fields="surveyFields"
|
||||
v-model:form-data="surveyData"
|
||||
@submit="handleSurveySubmit"
|
||||
@change="handleFieldChange"
|
||||
submit-button-text="Submit Survey"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### File Upload Form
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const uploadData = ref({})
|
||||
|
||||
const uploadFields = [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Document Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
cols: 12
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Reports', value: 'reports' },
|
||||
{ label: 'Presentations', value: 'presentations' },
|
||||
{ label: 'Documents', value: 'documents' }
|
||||
],
|
||||
cols: 12,
|
||||
md: 6
|
||||
},
|
||||
{
|
||||
name: 'uploadDate',
|
||||
label: 'Upload Date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
cols: 12,
|
||||
md: 6
|
||||
},
|
||||
{
|
||||
name: 'files',
|
||||
label: 'Select Files',
|
||||
type: 'file',
|
||||
accept: '.pdf,.doc,.docx,.ppt,.pptx',
|
||||
multiple: true,
|
||||
required: true,
|
||||
cols: 12
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
type: 'textarea',
|
||||
rows: 3,
|
||||
helpText: 'Optional description of the uploaded files',
|
||||
cols: 12
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
:fields="uploadFields"
|
||||
v-model:form-data="uploadData"
|
||||
@submit="handleFileUpload"
|
||||
submit-button-text="Upload Files"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Form State Management
|
||||
|
||||
### Controlled Mode (External Form Data)
|
||||
When you provide a `formData` prop, the component operates in controlled mode:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// External form state
|
||||
const formData = ref({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
:fields="fields"
|
||||
v-model:form-data="formData"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Uncontrolled Mode (Internal Form Data)
|
||||
When no `formData` is provided, the component manages its own internal state:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Form
|
||||
:fields="fields"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const handleSubmit = (data) => {
|
||||
// Data is passed to the submit handler
|
||||
console.log('Form data:', data)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### Built-in Validation
|
||||
- **Required fields** - Validates that required fields are not empty
|
||||
- **Email format** - Validates email format when `format: 'email'` is used
|
||||
- **Number ranges** - Validates min/max values for number fields
|
||||
|
||||
### Custom Validation
|
||||
Each field can have a custom validation function:
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (value && value.length < 8) {
|
||||
return 'Password must be at least 8 characters long'
|
||||
}
|
||||
if (value && !/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
|
||||
return 'Password must contain at least one lowercase letter, one uppercase letter, and one number'
|
||||
}
|
||||
return null // Valid
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Timing
|
||||
- **On change** - When `validateOnChange` is `true` (default)
|
||||
- **On submit** - Always validates on form submission
|
||||
- **Manual** - Using the exposed `validateForm()` method
|
||||
|
||||
## Responsive Layout
|
||||
|
||||
The component uses Vuetify's grid system for responsive layouts:
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: 'field',
|
||||
label: 'Field',
|
||||
type: 'text',
|
||||
cols: 12, // Full width on extra small screens
|
||||
sm: 12, // Full width on small screens
|
||||
md: 6, // Half width on medium screens
|
||||
lg: 4 // One-third width on large screens
|
||||
}
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
The component uses Vuetify's design system with:
|
||||
- **Outlined variants** for consistent appearance
|
||||
- **Comfortable density** for optimal spacing
|
||||
- **Error state styling** for validation feedback
|
||||
- **Required field indicators** with red asterisks
|
||||
- **Responsive design** that adapts to screen size
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use meaningful field names** that reflect the data structure
|
||||
2. **Provide clear labels and help text** for better user experience
|
||||
3. **Implement proper validation** for data integrity
|
||||
4. **Consider responsive layout** for different screen sizes
|
||||
5. **Handle form submission errors** gracefully
|
||||
6. **Use controlled mode** when form data needs to be managed externally
|
||||
7. **Leverage custom validation** for business-specific rules
|
||||
8. **Test with various field combinations** to ensure proper behavior
|
||||
9. **Use appropriate field types** for better user experience
|
||||
10. **Provide meaningful default values** when appropriate
|
||||
|
||||
## Accessibility
|
||||
|
||||
The component includes:
|
||||
- **Proper form semantics** with native HTML form elements
|
||||
- **Label associations** for screen readers
|
||||
- **Error message announcements** for validation feedback
|
||||
- **Keyboard navigation** support throughout the form
|
||||
- **Focus management** for better usability
|
||||
- **Required field indicators** for clarity
|
||||
|
||||
## Browser Support
|
||||
|
||||
Compatible with all modern browsers that support:
|
||||
- Vue 3 Composition API
|
||||
- Vuetify 3 components
|
||||
- ES6+ features
|
||||
- CSS Grid and Flexbox
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Vue 3** with Composition API
|
||||
- **Vuetify 3** components (v-form, v-text-field, v-textarea, v-select, v-checkbox, v-radio-group, v-file-input, v-btn)
|
||||
- **Modern JavaScript** features (ES6+)
|
||||
268
frontend/documentation/components/Modal.md
Normal file
268
frontend/documentation/components/Modal.md
Normal file
@ -0,0 +1,268 @@
|
||||
# Dynamic Modal Component Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
A flexible and customizable modal component built with Vuetify's v-dialog. This component provides extensive configuration options and supports slot-based content rendering.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Modal
|
||||
v-model:visible="isModalVisible"
|
||||
:options="modalOptions"
|
||||
@close="handleClose"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<p>Your modal content goes here</p>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Modal from './components/Modal.vue'
|
||||
|
||||
const isModalVisible = ref(false)
|
||||
const modalOptions = {
|
||||
title: 'My Modal',
|
||||
maxWidth: '500px'
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
console.log('Modal confirmed')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### `visible` (Boolean)
|
||||
- **Default:** `false`
|
||||
- **Description:** Controls the visibility state of the modal
|
||||
- **Usage:** Use with `v-model:visible` for two-way binding
|
||||
|
||||
### `options` (Object)
|
||||
- **Default:** `{}`
|
||||
- **Description:** Configuration object for customizing modal behavior and appearance
|
||||
|
||||
## Options Object Properties
|
||||
|
||||
### Dialog Configuration
|
||||
- **`persistent`** (Boolean, default: `false`) - Prevents closing when clicking outside or pressing escape
|
||||
- **`fullscreen`** (Boolean, default: `false`) - Makes the modal fullscreen
|
||||
- **`maxWidth`** (String, default: `'500px'`) - Maximum width of the modal
|
||||
- **`width`** (String) - Fixed width of the modal
|
||||
- **`height`** (String) - Fixed height of the modal
|
||||
- **`attach`** (String) - Element to attach the modal to
|
||||
- **`transition`** (String, default: `'dialog-transition'`) - CSS transition name
|
||||
- **`scrollable`** (Boolean, default: `false`) - Makes the modal content scrollable
|
||||
- **`retainFocus`** (Boolean, default: `true`) - Retains focus within the modal
|
||||
- **`closeOnBack`** (Boolean, default: `true`) - Closes modal on browser back button
|
||||
- **`closeOnContentClick`** (Boolean, default: `false`) - Closes modal when clicking content
|
||||
- **`closeOnOutsideClick`** (Boolean, default: `true`) - Closes modal when clicking outside
|
||||
- **`closeOnEscape`** (Boolean, default: `true`) - Closes modal when pressing escape key
|
||||
|
||||
### Styling Options
|
||||
- **`overlayColor`** (String) - Color of the backdrop overlay
|
||||
- **`overlayOpacity`** (Number) - Opacity of the backdrop overlay
|
||||
- **`zIndex`** (Number) - Z-index of the modal
|
||||
- **`dialogClass`** (String) - Additional CSS classes for the dialog
|
||||
- **`cardClass`** (String) - Additional CSS classes for the card
|
||||
- **`cardColor`** (String) - Background color of the card
|
||||
- **`cardVariant`** (String) - Vuetify card variant
|
||||
- **`elevation`** (Number) - Shadow elevation of the card
|
||||
- **`flat`** (Boolean) - Removes elevation
|
||||
- **`noRadius`** (Boolean) - Removes border radius
|
||||
|
||||
### Header Configuration
|
||||
- **`title`** (String) - Modal title text
|
||||
- **`showHeader`** (Boolean, default: `true`) - Shows/hides the header
|
||||
- **`showHeaderDivider`** (Boolean) - Shows divider below header
|
||||
- **`headerClass`** (String) - Additional CSS classes for header
|
||||
- **`showCloseButton`** (Boolean, default: `true`) - Shows/hides close button
|
||||
- **`closeButtonColor`** (String, default: `'grey'`) - Color of close button
|
||||
- **`closeIcon`** (String, default: `'mdi-close'`) - Icon for close button
|
||||
|
||||
### Content Configuration
|
||||
- **`message`** (String) - Default message content (HTML supported)
|
||||
- **`contentClass`** (String) - Additional CSS classes for content area
|
||||
- **`contentHeight`** (String) - Fixed height of content area
|
||||
- **`contentMaxHeight`** (String) - Maximum height of content area
|
||||
- **`contentMinHeight`** (String) - Minimum height of content area
|
||||
- **`noPadding`** (Boolean) - Removes padding from content area
|
||||
|
||||
### Actions Configuration
|
||||
- **`showActions`** (Boolean, default: `true`) - Shows/hides action buttons
|
||||
- **`actionsClass`** (String) - Additional CSS classes for actions area
|
||||
- **`actionsAlign`** (String) - Alignment of action buttons (`'left'`, `'center'`, `'right'`, `'space-between'`)
|
||||
|
||||
### Button Configuration
|
||||
- **`showConfirmButton`** (Boolean, default: `true`) - Shows/hides confirm button
|
||||
- **`confirmButtonText`** (String, default: `'Confirm'`) - Text for confirm button
|
||||
- **`confirmButtonColor`** (String, default: `'primary'`) - Color of confirm button
|
||||
- **`confirmButtonVariant`** (String, default: `'elevated'`) - Variant of confirm button
|
||||
- **`showCancelButton`** (Boolean, default: `true`) - Shows/hides cancel button
|
||||
- **`cancelButtonText`** (String, default: `'Cancel'`) - Text for cancel button
|
||||
- **`cancelButtonColor`** (String, default: `'grey'`) - Color of cancel button
|
||||
- **`cancelButtonVariant`** (String, default: `'text'`) - Variant of cancel button
|
||||
- **`loading`** (Boolean) - Shows loading state on confirm button
|
||||
|
||||
### Behavior Configuration
|
||||
- **`autoCloseOnConfirm`** (Boolean, default: `true`) - Auto-closes modal after confirm
|
||||
- **`autoCloseOnCancel`** (Boolean, default: `true`) - Auto-closes modal after cancel
|
||||
- **`onOpen`** (Function) - Callback function when modal opens
|
||||
- **`onClose`** (Function) - Callback function when modal closes
|
||||
|
||||
## Events
|
||||
|
||||
- **`update:visible`** - Emitted when visibility state changes
|
||||
- **`close`** - Emitted when modal is closed
|
||||
- **`confirm`** - Emitted when confirm button is clicked
|
||||
- **`cancel`** - Emitted when cancel button is clicked
|
||||
- **`outside-click`** - Emitted when clicking outside the modal
|
||||
- **`escape-key`** - Emitted when escape key is pressed
|
||||
|
||||
## Slots
|
||||
|
||||
### Default Slot
|
||||
```vue
|
||||
<Modal>
|
||||
<p>Your content here</p>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
### Title Slot
|
||||
```vue
|
||||
<Modal>
|
||||
<template #title>
|
||||
<v-icon class="mr-2">mdi-account</v-icon>
|
||||
Custom Title
|
||||
</template>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
### Actions Slot
|
||||
```vue
|
||||
<Modal>
|
||||
<template #actions="{ close, options }">
|
||||
<v-btn @click="customAction">Custom Action</v-btn>
|
||||
<v-btn @click="close">Close</v-btn>
|
||||
</template>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Modal
|
||||
```vue
|
||||
const basicOptions = {
|
||||
title: 'Information',
|
||||
maxWidth: '400px'
|
||||
}
|
||||
```
|
||||
|
||||
### Confirmation Modal
|
||||
```vue
|
||||
const confirmOptions = {
|
||||
title: 'Confirm Action',
|
||||
persistent: false,
|
||||
confirmButtonText: 'Delete',
|
||||
confirmButtonColor: 'error',
|
||||
cancelButtonText: 'Keep'
|
||||
}
|
||||
```
|
||||
|
||||
### Form Modal
|
||||
```vue
|
||||
const formOptions = {
|
||||
title: 'Add New Item',
|
||||
maxWidth: '600px',
|
||||
persistent: true,
|
||||
confirmButtonText: 'Save',
|
||||
loading: isLoading.value
|
||||
}
|
||||
```
|
||||
|
||||
### Fullscreen Modal
|
||||
```vue
|
||||
const fullscreenOptions = {
|
||||
fullscreen: true,
|
||||
showActions: false,
|
||||
scrollable: true
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Styled Modal
|
||||
```vue
|
||||
const customOptions = {
|
||||
maxWidth: '500px',
|
||||
cardColor: 'primary',
|
||||
elevation: 12,
|
||||
overlayOpacity: 0.8,
|
||||
transition: 'scale-transition'
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### With Reactive Options
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const formValid = ref(false)
|
||||
|
||||
const modalOptions = computed(() => ({
|
||||
title: 'Dynamic Title',
|
||||
loading: loading.value,
|
||||
confirmButtonText: formValid.value ? 'Save' : 'Validate First',
|
||||
persistent: !formValid.value
|
||||
}))
|
||||
</script>
|
||||
```
|
||||
|
||||
### Multiple Modals
|
||||
```vue
|
||||
<template>
|
||||
<!-- Each modal can have different configurations -->
|
||||
<Modal v-model:visible="modal1" :options="options1">
|
||||
Content 1
|
||||
</Modal>
|
||||
|
||||
<Modal v-model:visible="modal2" :options="options2">
|
||||
Content 2
|
||||
</Modal>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use persistent modals for forms** to prevent accidental data loss
|
||||
2. **Set appropriate maxWidth** for different screen sizes
|
||||
3. **Use loading states** for async operations
|
||||
4. **Provide clear button labels** that describe the action
|
||||
5. **Use slots for complex content** instead of the message option
|
||||
6. **Handle all events** to provide good user feedback
|
||||
7. **Test keyboard navigation** and accessibility features
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
The modal automatically adjusts for mobile devices:
|
||||
- Reduced padding on smaller screens
|
||||
- Appropriate font sizes
|
||||
- Touch-friendly button sizes
|
||||
- Proper viewport handling
|
||||
|
||||
## Accessibility
|
||||
|
||||
The component includes:
|
||||
- Proper ARIA attributes
|
||||
- Keyboard navigation support
|
||||
- Focus management
|
||||
- Screen reader compatibility
|
||||
- High contrast support
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { IconoirProvider } from "@iconoir/vue";
|
||||
import SideBar from "./components/SideBar.vue";
|
||||
import CreateClientModal from "./components/modals/CreatClientModal.vue";
|
||||
import ScrollPanel from "primevue/scrollpanel";
|
||||
</script>
|
||||
|
||||
@ -21,6 +22,9 @@ import ScrollPanel from "primevue/scrollpanel";
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Modals -->
|
||||
<CreateClientModal />
|
||||
</IconoirProvider>
|
||||
</template>
|
||||
|
||||
|
||||
@ -1,6 +1,32 @@
|
||||
import DataUtils from "./utils";
|
||||
import axios from "axios";
|
||||
|
||||
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
||||
|
||||
class Api {
|
||||
|
||||
static async request(url, method = "get", data = {}) {
|
||||
try {
|
||||
const response = await axios({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
withCredentials: false,
|
||||
timeout: 10000, // 10 second timeout
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
console.log("DEBUG: API - Request Response: ", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("DEBUG: API - Request Error: ", error);
|
||||
// Re-throw the error so calling code can handle it
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getClientDetails() {
|
||||
// const data = [];
|
||||
// const addresses = await this.getDocsList("Address");
|
||||
@ -51,17 +77,65 @@ class Api {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of documents from a specific doctype.
|
||||
*
|
||||
* @param {String} doctype
|
||||
* @param {string[]} fields
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
static async getDocsList(doctype, fields = []) {
|
||||
const docs = await frappe.db.get_list(doctype, { fields });
|
||||
console.log(`DEBUG: API - Fetched ${doctype} list: `, docs);
|
||||
return docs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a detailed document by doctype and name.
|
||||
*
|
||||
* @param {String} doctype
|
||||
* @param {String} name
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
static async getDetailedDoc(doctype, name) {
|
||||
const doc = await frappe.db.get_doc(doctype, name);
|
||||
console.log(`DEBUG: API - Fetched Detailed ${doctype}: `, doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of places (city/state) by zipcode using Zippopotamus API.
|
||||
*
|
||||
* @param {String} zipcode
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
static async getCityStateByZip(zipcode) {
|
||||
const url = `${ZIPPOPOTAMUS_BASE_URL}/${zipcode}`;
|
||||
try {
|
||||
const response = await this.request(url);
|
||||
const { places } = response || {};
|
||||
|
||||
if (!places || places.length === 0) {
|
||||
throw new Error(`No location data found for zip code ${zipcode}`);
|
||||
}
|
||||
|
||||
return places;
|
||||
} catch (error) {
|
||||
console.error("DEBUG: API - getCityStateByZip Error: ", error);
|
||||
|
||||
// Provide more specific error information
|
||||
if (error.code === 'ERR_NETWORK') {
|
||||
throw new Error('Network error: Unable to connect to location service. This may be due to CORS restrictions or network connectivity issues.');
|
||||
} else if (error.response?.status === 404) {
|
||||
throw new Error(`Zip code ${zipcode} not found in database.`);
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
throw new Error('Request timeout: Location service is taking too long to respond.');
|
||||
}
|
||||
|
||||
// Re-throw the original error if we can't categorize it
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Api;
|
||||
|
||||
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";
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import PrimeVue from "primevue/config";
|
||||
import { globalSettings } from "./globalSettings";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
// Vuetify
|
||||
import "vuetify/styles";
|
||||
@ -24,8 +25,11 @@ const vuetify = createVuetify({
|
||||
},
|
||||
});
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(pinia)
|
||||
.use(vuetify)
|
||||
.use(PrimeVue, {
|
||||
theme: {
|
||||
|
||||
90
frontend/src/stores/filters.js
Normal file
90
frontend/src/stores/filters.js
Normal file
@ -0,0 +1,90 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { FilterMatchMode } from "@primevue/core";
|
||||
|
||||
export const useFiltersStore = defineStore("filters", {
|
||||
state: () => ({
|
||||
// Store filters by table/component name
|
||||
tableFilters: {
|
||||
clients: {
|
||||
fullName: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
},
|
||||
jobs: {
|
||||
customer: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
jobId: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
},
|
||||
timesheets: {
|
||||
employee: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
customer: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
},
|
||||
warranties: {
|
||||
customer: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
warrantyId: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
address: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
assignedTechnician: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
},
|
||||
routes: {
|
||||
technician: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
routeId: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
}
|
||||
}
|
||||
}),
|
||||
actions: {
|
||||
// Generic method to get filters for a specific table
|
||||
getTableFilters(tableName) {
|
||||
return this.tableFilters[tableName] || {};
|
||||
},
|
||||
|
||||
// Generic method to update a specific filter
|
||||
updateTableFilter(tableName, fieldName, value, matchMode = null) {
|
||||
if (!this.tableFilters[tableName]) {
|
||||
this.tableFilters[tableName] = {};
|
||||
}
|
||||
if (!this.tableFilters[tableName][fieldName]) {
|
||||
this.tableFilters[tableName][fieldName] = {
|
||||
value: null,
|
||||
matchMode: FilterMatchMode.CONTAINS
|
||||
};
|
||||
}
|
||||
this.tableFilters[tableName][fieldName].value = value;
|
||||
// Update match mode if provided
|
||||
if (matchMode) {
|
||||
this.tableFilters[tableName][fieldName].matchMode = matchMode;
|
||||
}
|
||||
},
|
||||
|
||||
// Method to clear all filters for a table
|
||||
clearTableFilters(tableName) {
|
||||
if (this.tableFilters[tableName]) {
|
||||
Object.keys(this.tableFilters[tableName]).forEach(key => {
|
||||
this.tableFilters[tableName][key].value = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Method to initialize filters for a table if they don't exist
|
||||
initializeTableFilters(tableName, columns) {
|
||||
if (!this.tableFilters[tableName]) {
|
||||
this.tableFilters[tableName] = {};
|
||||
}
|
||||
|
||||
columns.forEach(column => {
|
||||
if (column.filterable && !this.tableFilters[tableName][column.fieldName]) {
|
||||
this.tableFilters[tableName][column.fieldName] = {
|
||||
value: null,
|
||||
matchMode: FilterMatchMode.CONTAINS
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Legacy method for backward compatibility
|
||||
setClientNameFilter(filterValue) {
|
||||
this.updateTableFilter('clients', 'fullName', filterValue);
|
||||
},
|
||||
|
||||
// Getter for legacy compatibility
|
||||
get clientNameFilter() {
|
||||
return this.tableFilters?.clients?.fullName?.value || "";
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -1,36 +1,232 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useModalsStore = defineStore("modals", {
|
||||
export const useModalStore = defineStore("modal", {
|
||||
state: () => ({
|
||||
isCreateClientOpen: false,
|
||||
isCreateInvoiceOpen: false,
|
||||
isCreateJobOpen: false,
|
||||
isCreateEstimateOpen: false,
|
||||
// Dynamic modal registry - can handle any number of modals
|
||||
modals: {},
|
||||
// Stack for modal layering (optional)
|
||||
modalStack: [],
|
||||
// Component registry for dynamic modals
|
||||
registeredComponents: {},
|
||||
// Global modal configuration
|
||||
globalConfig: {
|
||||
closeOnEscape: true,
|
||||
closeOnOutsideClick: true,
|
||||
preventBodyScroll: true
|
||||
}
|
||||
}),
|
||||
actions: {
|
||||
openCreateClient() {
|
||||
this.isCreateClientOpen = true;
|
||||
|
||||
getters: {
|
||||
// Check if any modal is open
|
||||
hasOpenModal: (state) => {
|
||||
return Object.values(state.modals).some(modal => modal.isOpen);
|
||||
},
|
||||
closeCreateClient() {
|
||||
this.isCreateClientOpen = false;
|
||||
|
||||
// Get modal by ID
|
||||
getModal: (state) => (modalId) => {
|
||||
return state.modals[modalId] || null;
|
||||
},
|
||||
openCreateInvoice() {
|
||||
this.isCreateInvoiceOpen = true;
|
||||
|
||||
// Check if specific modal is open
|
||||
isModalOpen: (state) => (modalId) => {
|
||||
return state.modals[modalId]?.isOpen || false;
|
||||
},
|
||||
closeCreateInvoice() {
|
||||
this.isCreateInvoiceOpen = false;
|
||||
},
|
||||
openCreateJob() {
|
||||
this.isCreateJobOpen = true;
|
||||
},
|
||||
closeCreateJob() {
|
||||
this.isCreateJobOpen = false;
|
||||
},
|
||||
openCreateEstimate() {
|
||||
this.isCreateEstimateOpen = true;
|
||||
},
|
||||
closeCreateEstimate() {
|
||||
this.isCreateEstimateOpen = false;
|
||||
|
||||
// Get modal data
|
||||
getModalData: (state) => (modalId) => {
|
||||
return state.modals[modalId]?.data || null;
|
||||
},
|
||||
|
||||
// Get the topmost modal in stack
|
||||
getTopModal: (state) => {
|
||||
if (state.modalStack.length === 0) return null;
|
||||
const topModalId = state.modalStack[state.modalStack.length - 1];
|
||||
return state.modals[topModalId];
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// Register a modal component for dynamic loading
|
||||
registerModalComponent(modalId, component) {
|
||||
this.registeredComponents[modalId] = component;
|
||||
},
|
||||
|
||||
// Initialize a modal (register it in the store)
|
||||
initializeModal(modalId, config = {}) {
|
||||
if (!this.modals[modalId]) {
|
||||
this.modals[modalId] = {
|
||||
id: modalId,
|
||||
isOpen: false,
|
||||
data: null,
|
||||
config: {
|
||||
...this.globalConfig,
|
||||
...config
|
||||
},
|
||||
history: []
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Open a modal with optional data
|
||||
openModal(modalId, data = null, config = {}) {
|
||||
// Initialize modal if it doesn't exist
|
||||
this.initializeModal(modalId, config);
|
||||
|
||||
// Close other modals if exclusive mode (default behavior)
|
||||
if (config.exclusive !== false) {
|
||||
this.closeAllModals();
|
||||
}
|
||||
|
||||
// Set modal state
|
||||
this.modals[modalId].isOpen = true;
|
||||
this.modals[modalId].data = data;
|
||||
this.modals[modalId].config = {
|
||||
...this.modals[modalId].config,
|
||||
...config
|
||||
};
|
||||
|
||||
// Add to stack
|
||||
if (!this.modalStack.includes(modalId)) {
|
||||
this.modalStack.push(modalId);
|
||||
}
|
||||
|
||||
// Track opening in history
|
||||
this.modals[modalId].history.push({
|
||||
action: 'opened',
|
||||
timestamp: new Date(),
|
||||
data: data
|
||||
});
|
||||
},
|
||||
|
||||
// Close a specific modal
|
||||
closeModal(modalId) {
|
||||
if (this.modals[modalId]) {
|
||||
this.modals[modalId].isOpen = false;
|
||||
this.modals[modalId].data = null;
|
||||
|
||||
// Remove from stack
|
||||
const index = this.modalStack.indexOf(modalId);
|
||||
if (index > -1) {
|
||||
this.modalStack.splice(index, 1);
|
||||
}
|
||||
|
||||
// Track closing in history
|
||||
this.modals[modalId].history.push({
|
||||
action: 'closed',
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle a modal
|
||||
toggleModal(modalId, data = null, config = {}) {
|
||||
if (this.isModalOpen(modalId)) {
|
||||
this.closeModal(modalId);
|
||||
} else {
|
||||
this.openModal(modalId, data, config);
|
||||
}
|
||||
},
|
||||
|
||||
// Close all modals
|
||||
closeAllModals() {
|
||||
Object.keys(this.modals).forEach(modalId => {
|
||||
if (this.modals[modalId].isOpen) {
|
||||
this.closeModal(modalId);
|
||||
}
|
||||
});
|
||||
this.modalStack = [];
|
||||
},
|
||||
|
||||
// Close the topmost modal
|
||||
closeTopModal() {
|
||||
if (this.modalStack.length > 0) {
|
||||
const topModalId = this.modalStack[this.modalStack.length - 1];
|
||||
this.closeModal(topModalId);
|
||||
}
|
||||
},
|
||||
|
||||
// Update modal data without opening/closing
|
||||
updateModalData(modalId, data) {
|
||||
if (this.modals[modalId]) {
|
||||
this.modals[modalId].data = data;
|
||||
}
|
||||
},
|
||||
|
||||
// Update modal configuration
|
||||
updateModalConfig(modalId, config) {
|
||||
if (this.modals[modalId]) {
|
||||
this.modals[modalId].config = {
|
||||
...this.modals[modalId].config,
|
||||
...config
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Remove a modal from the store (cleanup)
|
||||
removeModal(modalId) {
|
||||
if (this.modals[modalId]) {
|
||||
this.closeModal(modalId);
|
||||
delete this.modals[modalId];
|
||||
}
|
||||
},
|
||||
|
||||
// Convenience methods for common modals
|
||||
// Add your specific modal methods here
|
||||
|
||||
// Example: Edit User Modal
|
||||
openEditUser(userData = null) {
|
||||
this.openModal('editUser', userData, {
|
||||
closeOnEscape: true,
|
||||
closeOnOutsideClick: false
|
||||
});
|
||||
},
|
||||
|
||||
closeEditUser() {
|
||||
this.closeModal('editUser');
|
||||
},
|
||||
|
||||
// Example: Confirmation Modal
|
||||
openConfirmation(message, onConfirm, onCancel = null) {
|
||||
this.openModal('confirmation', {
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}, {
|
||||
closeOnEscape: false,
|
||||
closeOnOutsideClick: false
|
||||
});
|
||||
},
|
||||
|
||||
closeConfirmation() {
|
||||
this.closeModal('confirmation');
|
||||
},
|
||||
|
||||
// Example: Image Gallery Modal
|
||||
openImageGallery(images, currentIndex = 0) {
|
||||
this.openModal('imageGallery', {
|
||||
images,
|
||||
currentIndex
|
||||
}, {
|
||||
closeOnEscape: true,
|
||||
exclusive: true
|
||||
});
|
||||
},
|
||||
|
||||
closeImageGallery() {
|
||||
this.closeModal('imageGallery');
|
||||
},
|
||||
|
||||
// Create Client Modal
|
||||
openCreateClient(clientData = null) {
|
||||
this.openModal('createClient', clientData, {
|
||||
closeOnEscape: true,
|
||||
closeOnOutsideClick: true,
|
||||
exclusive: true
|
||||
});
|
||||
},
|
||||
|
||||
closeCreateClient() {
|
||||
this.closeModal('createClient');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useUserStore = defineStore("user", {
|
||||
state: () => ({
|
||||
username: "",
|
||||
roles: [],
|
||||
}),
|
||||
actions: {
|
||||
check_permission(role) {
|
||||
if (this.roles.includes(role)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -8,6 +8,12 @@ export default defineConfig(({ command }) => {
|
||||
return {
|
||||
plugins: [vue()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
|
||||
base: isDev ? "/" : "/assets/custom_ui/dist/",
|
||||
|
||||
build: {
|
||||
@ -22,6 +28,13 @@ export default defineConfig(({ command }) => {
|
||||
},
|
||||
|
||||
server: {
|
||||
proxy: {
|
||||
'/zippopotam': {
|
||||
target: 'https://api.zippopotam.us',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/zippopotam/, ''),
|
||||
},
|
||||
},
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user