add global loading state, update to use real data for clients table
This commit is contained in:
parent
2cfe7ed8e6
commit
464c62d1e5
49
custom_ui/api/db.py
Normal file
49
custom_ui/api/db.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import frappe, json
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def upsert_client(data):
|
||||||
|
data = json.loads(data)
|
||||||
|
"""
|
||||||
|
Upsert a document in the database.
|
||||||
|
If a document with the same name exists, it will be updated.
|
||||||
|
Otherwise, a new document will be created.
|
||||||
|
"""
|
||||||
|
customer = frappe.db.exists("Customer", {"customer_name": data.get("customer_name")})
|
||||||
|
if not customer:
|
||||||
|
customer_doc = frappe.get_doc({
|
||||||
|
"doctype": "Customer",
|
||||||
|
"customer_name": data.get("customer_name"),
|
||||||
|
"customer_type": data.get("customer_type")
|
||||||
|
}).insert(ignore_permissions=True)
|
||||||
|
customer = customer_doc.name
|
||||||
|
else:
|
||||||
|
customer_doc = frappe.get_doc("Customer", customer)
|
||||||
|
filters = {
|
||||||
|
"address_line1": data.get("address_line1"),
|
||||||
|
"city": data.get("city"),
|
||||||
|
"state": data.get("state"),
|
||||||
|
"country": "US",
|
||||||
|
"pincode": data.get("pincode")
|
||||||
|
}
|
||||||
|
existing_address = frappe.db.exists("Address", filters)
|
||||||
|
if existing_address:
|
||||||
|
frappe.throw(f"Address already exists for customer {data.get('customer_name')}.", frappe.ValidationError)
|
||||||
|
address_doc = frappe.get_doc({
|
||||||
|
"doctype": "Address",
|
||||||
|
"address_line1": data.get("address_line1"),
|
||||||
|
"city": data.get("city"),
|
||||||
|
"state": data.get("state"),
|
||||||
|
"country": "US",
|
||||||
|
"pincode": data.get("pincode"),
|
||||||
|
}).insert(ignore_permissions=True)
|
||||||
|
link = {
|
||||||
|
"link_doctype": "Customer",
|
||||||
|
"link_name": customer
|
||||||
|
}
|
||||||
|
address_doc.append("links", link)
|
||||||
|
address_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"customer": customer.name,
|
||||||
|
"address": address_doc.name
|
||||||
|
}
|
||||||
194
frontend/documentation/LOADING_USAGE.md
Normal file
194
frontend/documentation/LOADING_USAGE.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# Global Loading State Usage Guide
|
||||||
|
|
||||||
|
This document explains how to use the global loading state system in your Vue app.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The loading system provides multiple ways to handle loading states:
|
||||||
|
|
||||||
|
1. **Global Loading Overlay** - Shows over the entire app
|
||||||
|
2. **Component-specific Loading** - For individual components like DataTable and Form
|
||||||
|
3. **Operation-specific Loading** - For tracking specific async operations
|
||||||
|
|
||||||
|
## Loading Store
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
|
||||||
|
// Set global loading
|
||||||
|
loadingStore.setLoading(true, "Processing...");
|
||||||
|
|
||||||
|
// Set component-specific loading
|
||||||
|
loadingStore.setComponentLoading("dataTable", true, "Loading data...");
|
||||||
|
|
||||||
|
// Use async wrapper
|
||||||
|
const data = await loadingStore.withLoading(
|
||||||
|
"fetchUsers",
|
||||||
|
() => Api.getUsers(),
|
||||||
|
"Fetching user data...",
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Methods
|
||||||
|
|
||||||
|
- `setLoading(isLoading, message?)` - Global loading state
|
||||||
|
- `setComponentLoading(componentName, isLoading, message?)` - Component loading
|
||||||
|
- `startOperation(operationKey, message?)` - Start tracked operation
|
||||||
|
- `stopOperation(operationKey)` - Stop tracked operation
|
||||||
|
- `withLoading(operationKey, asyncFn, message?)` - Async wrapper
|
||||||
|
- `withComponentLoading(componentName, asyncFn, message?)` - Component async wrapper
|
||||||
|
|
||||||
|
### Convenience Methods
|
||||||
|
|
||||||
|
- `startApiCall(apiName?)` - Quick API loading
|
||||||
|
- `stopApiCall()` - Stop API loading
|
||||||
|
- `startDataTableLoading(message?)` - DataTable loading
|
||||||
|
- `stopDataTableLoading()` - Stop DataTable loading
|
||||||
|
- `startFormLoading(message?)` - Form loading
|
||||||
|
- `stopFormLoading()` - Stop Form loading
|
||||||
|
|
||||||
|
## DataTable Component
|
||||||
|
|
||||||
|
The DataTable component automatically integrates with the loading store:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<DataTable
|
||||||
|
:data="tableData"
|
||||||
|
:columns="columns"
|
||||||
|
tableName="clients"
|
||||||
|
:loading="customLoading"
|
||||||
|
loadingMessage="Custom loading message..."
|
||||||
|
emptyMessage="No clients found"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// DataTable will automatically show loading when:
|
||||||
|
// 1. props.loading is true
|
||||||
|
// 2. Global loading store has loading for 'dataTable'
|
||||||
|
// 3. Global loading store has loading for props.tableName
|
||||||
|
// 4. Any global loading (if useGlobalLoading is true)
|
||||||
|
|
||||||
|
// You can also control it directly:
|
||||||
|
const tableRef = ref();
|
||||||
|
tableRef.value?.startLoading("Custom loading...");
|
||||||
|
tableRef.value?.stopLoading();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Component
|
||||||
|
|
||||||
|
The Form component also integrates with loading:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Form
|
||||||
|
:fields="formFields"
|
||||||
|
formName="userForm"
|
||||||
|
:loading="customLoading"
|
||||||
|
loadingMessage="Saving user..."
|
||||||
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// Form will disable all inputs and show loading buttons when:
|
||||||
|
// 1. props.loading is true
|
||||||
|
// 2. Global loading store has loading for 'form'
|
||||||
|
// 3. Global loading store has loading for props.formName
|
||||||
|
// 4. Internal isSubmitting is true
|
||||||
|
|
||||||
|
// Control directly:
|
||||||
|
const formRef = ref();
|
||||||
|
formRef.value?.startLoading("Processing...");
|
||||||
|
formRef.value?.stopLoading();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Integration Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In your page component
|
||||||
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
|
||||||
|
// Method 1: Manual control
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
loadingStore.startDataTableLoading("Loading clients...");
|
||||||
|
const data = await Api.getClients();
|
||||||
|
tableData.value = data;
|
||||||
|
} finally {
|
||||||
|
loadingStore.stopDataTableLoading();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method 2: Using wrapper (recommended)
|
||||||
|
const loadData = async () => {
|
||||||
|
const data = await loadingStore.withComponentLoading(
|
||||||
|
"clients",
|
||||||
|
() => Api.getClients(),
|
||||||
|
"Loading clients...",
|
||||||
|
);
|
||||||
|
tableData.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method 3: For global overlay
|
||||||
|
const performGlobalAction = async () => {
|
||||||
|
const result = await loadingStore.withLoading(
|
||||||
|
"globalOperation",
|
||||||
|
() => Api.performHeavyOperation(),
|
||||||
|
"Processing your request...",
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global Loading Overlay
|
||||||
|
|
||||||
|
The `GlobalLoadingOverlay` component shows automatically when global loading is active:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Already added to App.vue -->
|
||||||
|
<GlobalLoadingOverlay />
|
||||||
|
|
||||||
|
<!-- Customizable props -->
|
||||||
|
<GlobalLoadingOverlay
|
||||||
|
:globalOnly="false" <!-- Show for any loading, not just global -->
|
||||||
|
:minDisplayTime="500" <!-- Minimum display time in ms -->
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use component-specific loading** for individual components
|
||||||
|
2. **Use global loading** for app-wide operations (login, navigation, etc.)
|
||||||
|
3. **Use operation tracking** for multiple concurrent operations
|
||||||
|
4. **Always use try/finally** when manually controlling loading
|
||||||
|
5. **Prefer async wrappers** over manual start/stop calls
|
||||||
|
6. **Provide meaningful loading messages** to users
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const data = await loadingStore.withComponentLoading(
|
||||||
|
"clients",
|
||||||
|
() => Api.getClients(),
|
||||||
|
"Loading clients...",
|
||||||
|
);
|
||||||
|
tableData.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load clients:", error);
|
||||||
|
// Show error message to user
|
||||||
|
// Loading state is automatically cleared by the wrapper
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
@ -2,6 +2,7 @@
|
|||||||
import { IconoirProvider } from "@iconoir/vue";
|
import { IconoirProvider } from "@iconoir/vue";
|
||||||
import SideBar from "./components/SideBar.vue";
|
import SideBar from "./components/SideBar.vue";
|
||||||
import CreateClientModal from "./components/modals/CreatClientModal.vue";
|
import CreateClientModal from "./components/modals/CreatClientModal.vue";
|
||||||
|
import GlobalLoadingOverlay from "./components/common/GlobalLoadingOverlay.vue";
|
||||||
import ScrollPanel from "primevue/scrollpanel";
|
import ScrollPanel from "primevue/scrollpanel";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -22,9 +23,12 @@ import ScrollPanel from "primevue/scrollpanel";
|
|||||||
</ScrollPanel>
|
</ScrollPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global Modals -->
|
<!-- Global Modals -->
|
||||||
<CreateClientModal />
|
<CreateClientModal />
|
||||||
|
|
||||||
|
<!-- Global Loading Overlay -->
|
||||||
|
<GlobalLoadingOverlay />
|
||||||
</IconoirProvider>
|
</IconoirProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
|
import { da } from "vuetify/locale";
|
||||||
import DataUtils from "./utils";
|
import DataUtils from "./utils";
|
||||||
|
|
||||||
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
||||||
|
const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request";
|
||||||
|
const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.upsert_client";
|
||||||
|
|
||||||
class Api {
|
class Api {
|
||||||
static async request(url, method = "GET", data = {}) {
|
static async request(frappeMethod, args = {}) {
|
||||||
try {
|
try {
|
||||||
const response = await frappe.call({
|
const response = await frappe.call({
|
||||||
method: "custom_ui.api.proxy.request",
|
method: frappeMethod,
|
||||||
args: {
|
args: {
|
||||||
url,
|
...args,
|
||||||
method,
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log("DEBUG: API - Request Response: ", response);
|
console.log("DEBUG: API - Request Response: ", response);
|
||||||
@ -22,20 +23,72 @@ class Api {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getClientDetails() {
|
static async getClientDetails(forTable = true) {
|
||||||
// const data = [];
|
const data = [];
|
||||||
// const addresses = await this.getDocsList("Address");
|
const addresses = await this.getDocsList("Address", ["*"]);
|
||||||
// for (const addr of addresses) {
|
for (const addr of addresses) {
|
||||||
// const clientDetail = {};
|
const clientDetail = {};
|
||||||
// const fullAddress = await this.getDetailedDoc("Address", addr["name"] || addr["Name"]);
|
|
||||||
// const customer = await this.getDetailedCustomer(fullAddress["links"][0]["link_name"]);
|
const customer = await this.getDetailedDoc(
|
||||||
// clientDetail.customer = customer;
|
"Customer",
|
||||||
// clientDetail.address = fullAddress;
|
addr["custom_customer_to_bill"],
|
||||||
// data.push(clientDetail);
|
);
|
||||||
// }
|
|
||||||
// console.log("DEBUG: API - Fetched Client Details: ", data);
|
const quotations = await this.getDocsList("Quotation", [], {
|
||||||
const data = DataUtils.dummyClientData;
|
custom_installation_address: addr["name"],
|
||||||
console.log("DEBUG: API - getClientDetails result: ", data);
|
});
|
||||||
|
const quoteDetails =
|
||||||
|
quotations.length > 0
|
||||||
|
? await this.getDetailedDoc("Quotation", quotations[0]["name"])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const jobs = await this.getDocsList("Project", [], {
|
||||||
|
project_template: "SNW Install",
|
||||||
|
custom_installation_address: addr["name"],
|
||||||
|
});
|
||||||
|
const jobDetails =
|
||||||
|
jobs.length > 0 ? await this.getDetailedDoc("Project", jobs[0]["name"]) : null;
|
||||||
|
|
||||||
|
clientDetail.customer = customer;
|
||||||
|
clientDetail.address = addr;
|
||||||
|
clientDetail.estimate = quoteDetails;
|
||||||
|
clientDetail.job = jobDetails;
|
||||||
|
|
||||||
|
const totalPaid = quoteDetails
|
||||||
|
? quoteDetails.payment_schedule
|
||||||
|
? quoteDetails.payment_schedule.reduce(
|
||||||
|
(sum, payment) => sum + (payment.paid_amount || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
const tableRow = {
|
||||||
|
fullName: `${customer.customer_name} - ${addr.address_line1}, ${addr.city} ${addr.state}`,
|
||||||
|
appointmentStatus: "not started",
|
||||||
|
estimateStatus: quoteDetails
|
||||||
|
? quoteDetails.custom_response == "Accepted"
|
||||||
|
? "completed"
|
||||||
|
: "in progress"
|
||||||
|
: "not started",
|
||||||
|
paymentStatus: quoteDetails
|
||||||
|
? totalPaid < quoteDetails.grand_total
|
||||||
|
? "in progress"
|
||||||
|
: "completed"
|
||||||
|
: "not started",
|
||||||
|
jobStatus: jobDetails
|
||||||
|
? jobDetails.status === "Completed"
|
||||||
|
? "completed"
|
||||||
|
: "in progress"
|
||||||
|
: "not started",
|
||||||
|
};
|
||||||
|
if (forTable) {
|
||||||
|
data.push(tableRow);
|
||||||
|
} else {
|
||||||
|
data.push(clientDetail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// const data = DataUtils.dummyClientData;
|
||||||
|
console.log("DEBUG: API - Fetched Client Details: ", data);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,10 +130,16 @@ class Api {
|
|||||||
*
|
*
|
||||||
* @param {String} doctype
|
* @param {String} doctype
|
||||||
* @param {string[]} fields
|
* @param {string[]} fields
|
||||||
|
* @param {Object} filters
|
||||||
* @returns {Promise<Object[]>}
|
* @returns {Promise<Object[]>}
|
||||||
*/
|
*/
|
||||||
static async getDocsList(doctype, fields = []) {
|
static async getDocsList(doctype, fields = [], filters = {}, page = 0, pageLength = 600) {
|
||||||
const docs = await frappe.db.get_list(doctype, { fields });
|
const docs = await frappe.db.get_list(doctype, {
|
||||||
|
fields,
|
||||||
|
filters,
|
||||||
|
start: page * pageLength,
|
||||||
|
limit: pageLength,
|
||||||
|
});
|
||||||
console.log(`DEBUG: API - Fetched ${doctype} list: `, docs);
|
console.log(`DEBUG: API - Fetched ${doctype} list: `, docs);
|
||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
@ -90,14 +149,48 @@ class Api {
|
|||||||
*
|
*
|
||||||
* @param {String} doctype
|
* @param {String} doctype
|
||||||
* @param {String} name
|
* @param {String} name
|
||||||
|
* @param {Object} filters
|
||||||
* @returns {Promise<Object>}
|
* @returns {Promise<Object>}
|
||||||
*/
|
*/
|
||||||
static async getDetailedDoc(doctype, name) {
|
static async getDetailedDoc(doctype, name, filters = {}) {
|
||||||
const doc = await frappe.db.get_doc(doctype, name);
|
const doc = await frappe.db.get_doc(doctype, name, filters);
|
||||||
console.log(`DEBUG: API - Fetched Detailed ${doctype}: `, doc);
|
console.log(`DEBUG: API - Fetched Detailed ${doctype}: `, doc);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getDocCount(doctype, filters = {}) {
|
||||||
|
const count = await frappe.db.count(doctype, filters);
|
||||||
|
console.log(`DEBUG: API - Counted ${doctype}: `, count);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createDoc(doctype, data) {
|
||||||
|
const doc = await frappe.db.insert({
|
||||||
|
...data,
|
||||||
|
doctype,
|
||||||
|
});
|
||||||
|
console.log(`DEBUG: API - Created ${doctype}: `, doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getCustomerNames() {
|
||||||
|
const customers = await this.getDocsList("Customer", ["name"]);
|
||||||
|
const customerNames = customers.map((customer) => customer.name);
|
||||||
|
console.log("DEBUG: API - Fetched Customer Names: ", customerNames);
|
||||||
|
return customerNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create methods
|
||||||
|
|
||||||
|
static async createClient(clientData) {
|
||||||
|
const payload = DataUtils.toSnakeCaseObject(clientData);
|
||||||
|
const result = await this.request(FRAPPE_UPSERT_CLIENT_METHOD, { data: payload });
|
||||||
|
console.log("DEBUG: API - Created/Updated Client: ", result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// External API calls
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a list of places (city/state) by zipcode using Zippopotamus API.
|
* Fetch a list of places (city/state) by zipcode using Zippopotamus API.
|
||||||
*
|
*
|
||||||
@ -106,22 +199,13 @@ class Api {
|
|||||||
*/
|
*/
|
||||||
static async getCityStateByZip(zipcode) {
|
static async getCityStateByZip(zipcode) {
|
||||||
const url = `${ZIPPOPOTAMUS_BASE_URL}/${zipcode}`;
|
const url = `${ZIPPOPOTAMUS_BASE_URL}/${zipcode}`;
|
||||||
const response = await this.request(url);
|
const response = await this.request(FRAPPE_PROXY_METHOD, { url, method: "GET" });
|
||||||
const { places } = response || {};
|
const { places } = response || {};
|
||||||
if (!places || places.length === 0) {
|
if (!places || places.length === 0) {
|
||||||
throw new Error(`No location data found for zip code ${zipcode}`);
|
throw new Error(`No location data found for zip code ${zipcode}`);
|
||||||
}
|
}
|
||||||
return places;
|
return places;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a list of Customer names.
|
|
||||||
* @returns {Promise<String[]>}
|
|
||||||
*/
|
|
||||||
static async getCustomerNames() {
|
|
||||||
const customers = await this.getDocsList("Customer", ["name"]);
|
|
||||||
return customers.map((customer) => customer.name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Api;
|
export default Api;
|
||||||
|
|||||||
@ -14,7 +14,21 @@
|
|||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
metaKeySelection="true"
|
metaKeySelection="true"
|
||||||
dataKey="id"
|
dataKey="id"
|
||||||
|
:loading="loading"
|
||||||
|
:loadingIcon="loadingIcon"
|
||||||
>
|
>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center py-6">
|
||||||
|
<i class="pi pi-info-circle text-4xl text-gray-400 mb-2"></i>
|
||||||
|
<p class="text-gray-500">{{ emptyMessage || "No data available" }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #loading>
|
||||||
|
<div class="text-center py-6">
|
||||||
|
<i class="pi pi-spin pi-spinner text-4xl text-blue-500 mb-2"></i>
|
||||||
|
<p class="text-gray-600">{{ loadingMessage || "Loading data. Please wait..." }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<Column
|
<Column
|
||||||
v-for="col in columns"
|
v-for="col in columns"
|
||||||
:key="col.fieldName"
|
:key="col.fieldName"
|
||||||
@ -28,6 +42,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
@input="handleFilterInput(col.fieldName, filterModel.value, filterCallback)"
|
@input="handleFilterInput(col.fieldName, filterModel.value, filterCallback)"
|
||||||
:placeholder="`Search ${col.label}...`"
|
:placeholder="`Search ${col.label}...`"
|
||||||
|
:disabled="loading"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="col.type === 'status'" #body="slotProps">
|
<template v-if="col.type === 'status'" #body="slotProps">
|
||||||
@ -57,8 +72,10 @@ import InputText from "primevue/inputtext";
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { FilterMatchMode } from "@primevue/core";
|
import { FilterMatchMode } from "@primevue/core";
|
||||||
import { useFiltersStore } from "../../stores/filters";
|
import { useFiltersStore } from "../../stores/filters";
|
||||||
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
|
||||||
const filtersStore = useFiltersStore();
|
const filtersStore = useFiltersStore();
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
columns: {
|
columns: {
|
||||||
@ -79,10 +96,44 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loadingMessage: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
emptyMessage: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
loadingIcon: {
|
||||||
|
type: String,
|
||||||
|
default: "pi pi-spinner pi-spin",
|
||||||
|
},
|
||||||
|
// Auto-connect to global loading store
|
||||||
|
useGlobalLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["rowClick"]);
|
const emit = defineEmits(["rowClick"]);
|
||||||
|
|
||||||
|
// Computed loading state that considers both prop and global store
|
||||||
|
const loading = computed(() => {
|
||||||
|
if (props.useGlobalLoading) {
|
||||||
|
return (
|
||||||
|
props.loading ||
|
||||||
|
loadingStore.getComponentLoading("dataTable") ||
|
||||||
|
loadingStore.getComponentLoading(props.tableName) ||
|
||||||
|
loadingStore.isAnyLoading
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return props.loading;
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize filters in store when component mounts
|
// Initialize filters in store when component mounts
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
filtersStore.initializeTableFilters(props.tableName, props.columns);
|
filtersStore.initializeTableFilters(props.tableName, props.columns);
|
||||||
@ -97,39 +148,43 @@ const filterRef = computed({
|
|||||||
},
|
},
|
||||||
set(newFilters) {
|
set(newFilters) {
|
||||||
// Update store when filters change
|
// Update store when filters change
|
||||||
Object.keys(newFilters).forEach(key => {
|
Object.keys(newFilters).forEach((key) => {
|
||||||
if (key !== 'global' && newFilters[key]) {
|
if (key !== "global" && newFilters[key]) {
|
||||||
const filter = newFilters[key];
|
const filter = newFilters[key];
|
||||||
filtersStore.updateTableFilter(
|
filtersStore.updateTableFilter(
|
||||||
props.tableName,
|
props.tableName,
|
||||||
key,
|
key,
|
||||||
filter.value,
|
filter.value,
|
||||||
filter.matchMode
|
filter.matchMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for filter changes to sync match mode changes
|
// Watch for filter changes to sync match mode changes
|
||||||
watch(filterRef, (newFilters) => {
|
watch(
|
||||||
Object.keys(newFilters).forEach(key => {
|
filterRef,
|
||||||
if (key !== 'global' && newFilters[key]) {
|
(newFilters) => {
|
||||||
const filter = newFilters[key];
|
Object.keys(newFilters).forEach((key) => {
|
||||||
const storeFilter = filtersStore.getTableFilters(props.tableName)[key];
|
if (key !== "global" && newFilters[key]) {
|
||||||
|
const filter = newFilters[key];
|
||||||
// Only update if the match mode has actually changed
|
const storeFilter = filtersStore.getTableFilters(props.tableName)[key];
|
||||||
if (storeFilter && storeFilter.matchMode !== filter.matchMode) {
|
|
||||||
filtersStore.updateTableFilter(
|
// Only update if the match mode has actually changed
|
||||||
props.tableName,
|
if (storeFilter && storeFilter.matchMode !== filter.matchMode) {
|
||||||
key,
|
filtersStore.updateTableFilter(
|
||||||
filter.value,
|
props.tableName,
|
||||||
filter.matchMode
|
key,
|
||||||
);
|
filter.value,
|
||||||
|
filter.matchMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
},
|
||||||
}, { deep: true });
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
const selectedRows = ref();
|
const selectedRows = ref();
|
||||||
|
|
||||||
@ -138,7 +193,7 @@ const handleFilterInput = (fieldName, value, filterCallback) => {
|
|||||||
// Get the current filter to preserve the match mode
|
// Get the current filter to preserve the match mode
|
||||||
const currentFilter = filterRef.value[fieldName];
|
const currentFilter = filterRef.value[fieldName];
|
||||||
const matchMode = currentFilter?.matchMode || FilterMatchMode.CONTAINS;
|
const matchMode = currentFilter?.matchMode || FilterMatchMode.CONTAINS;
|
||||||
|
|
||||||
// Update the store with both value and match mode
|
// Update the store with both value and match mode
|
||||||
filtersStore.updateTableFilter(props.tableName, fieldName, value, matchMode);
|
filtersStore.updateTableFilter(props.tableName, fieldName, value, matchMode);
|
||||||
// Call the PrimeVue filter callback
|
// Call the PrimeVue filter callback
|
||||||
@ -160,6 +215,12 @@ const getBadgeColor = (status) => {
|
|||||||
};
|
};
|
||||||
console.log("DEBUG: - DataTable props.columns", props.columns);
|
console.log("DEBUG: - DataTable props.columns", props.columns);
|
||||||
console.log("DEBUG: - DataTable props.data", props.data);
|
console.log("DEBUG: - DataTable props.data", props.data);
|
||||||
|
|
||||||
|
// Expose loading control methods for parent components
|
||||||
|
defineExpose({
|
||||||
|
startLoading: (message) => loadingStore.setComponentLoading(props.tableName, true, message),
|
||||||
|
stopLoading: () => loadingStore.setComponentLoading(props.tableName, false),
|
||||||
|
isLoading: () => loading.value,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="">
|
<style lang=""></style>
|
||||||
</style>
|
|
||||||
|
|||||||
@ -19,16 +19,15 @@
|
|||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:type="field.format || 'text'"
|
:type="field.format || 'text'"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:readonly="field.readonly"
|
:readonly="field.readonly"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
:fluid="true"
|
fluid
|
||||||
@input="
|
:maxlength="field.maxLength"
|
||||||
handleFieldChange(
|
:inputmode="field.inputMode"
|
||||||
field,
|
:pattern="field.pattern"
|
||||||
$event.target ? $event.target.value : $event,
|
@keydown="handleKeyDown(field, $event)"
|
||||||
)
|
@input="handleTextInput(field, $event)"
|
||||||
"
|
|
||||||
@blur="
|
@blur="
|
||||||
handleFieldBlur(
|
handleFieldBlur(
|
||||||
field,
|
field,
|
||||||
@ -59,13 +58,13 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:readonly="field.readonly"
|
:readonly="field.readonly"
|
||||||
:min="field.min"
|
:min="field.min"
|
||||||
:max="field.max"
|
:max="field.max"
|
||||||
:step="field.step"
|
:step="field.step"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
:fluid="true"
|
fluid
|
||||||
@input="handleFieldChange(field, $event.value)"
|
@input="handleFieldChange(field, $event.value)"
|
||||||
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
||||||
/>
|
/>
|
||||||
@ -92,11 +91,11 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:readonly="field.readonly"
|
:readonly="field.readonly"
|
||||||
:rows="field.rows || 3"
|
:rows="field.rows || 3"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
:fluid="true"
|
fluid
|
||||||
:autoResize="field.autoResize !== false"
|
:autoResize="field.autoResize !== false"
|
||||||
@input="
|
@input="
|
||||||
handleFieldChange(
|
handleFieldChange(
|
||||||
@ -134,14 +133,13 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
:optionLabel="field.optionLabel || 'label'"
|
optionLabel="label"
|
||||||
:optionValue="field.optionValue || 'value'"
|
optionValue="value"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
:fluid="true"
|
fluid
|
||||||
:filter="field.filter !== false"
|
appendTo="body"
|
||||||
:showClear="field.showClear !== false"
|
|
||||||
@update:model-value="handleFieldChange(field, $event)"
|
@update:model-value="handleFieldChange(field, $event)"
|
||||||
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
||||||
/>
|
/>
|
||||||
@ -168,15 +166,13 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:suggestions="field.filteredOptions || field.options || []"
|
:suggestions="field.filteredOptions || field.options || []"
|
||||||
:option-label="field.optionLabel"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:disabled="field.disabled"
|
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
:fluid="true"
|
fluid
|
||||||
:dropdown="field.dropdown !== false"
|
:dropdown="field.dropdown"
|
||||||
:multiple="field.multiple === true"
|
:forceSelection="field.forceSelection"
|
||||||
:force-selection="field.forceSelection === true"
|
appendTo="body"
|
||||||
dropdown-mode="blank"
|
|
||||||
@complete="handleAutocompleteSearch(field, $event)"
|
@complete="handleAutocompleteSearch(field, $event)"
|
||||||
@update:model-value="handleFieldChange(field, $event)"
|
@update:model-value="handleFieldChange(field, $event)"
|
||||||
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
@blur="handleFieldBlur(field, fieldValues[field.name])"
|
||||||
@ -201,7 +197,7 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:binary="true"
|
:binary="true"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
@update:model-value="handleFieldChange(field, $event)"
|
@update:model-value="handleFieldChange(field, $event)"
|
||||||
/>
|
/>
|
||||||
@ -240,7 +236,7 @@
|
|||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:name="field.name"
|
:name="field.name"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
@update:model-value="handleFieldChange(field, $event)"
|
@update:model-value="handleFieldChange(field, $event)"
|
||||||
/>
|
/>
|
||||||
@ -272,13 +268,13 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:readonly="field.readonly"
|
:readonly="field.readonly"
|
||||||
:minDate="field.minDate"
|
:minDate="field.minDate"
|
||||||
:maxDate="field.maxDate"
|
:maxDate="field.maxDate"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
:fluid="true"
|
fluid
|
||||||
:showIcon="true"
|
showIcon
|
||||||
iconDisplay="input"
|
iconDisplay="input"
|
||||||
:dateFormat="field.dateFormat || 'dd/mm/yy'"
|
:dateFormat="field.dateFormat || 'dd/mm/yy'"
|
||||||
@update:model-value="handleFieldChange(field, $event)"
|
@update:model-value="handleFieldChange(field, $event)"
|
||||||
@ -307,15 +303,15 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:readonly="field.readonly"
|
:readonly="field.readonly"
|
||||||
:minDate="field.minDate"
|
:minDate="field.minDate"
|
||||||
:maxDate="field.maxDate"
|
:maxDate="field.maxDate"
|
||||||
:invalid="!!getFieldError(field.name)"
|
:invalid="!!getFieldError(field.name)"
|
||||||
:fluid="true"
|
fluid
|
||||||
:showIcon="true"
|
showIcon
|
||||||
iconDisplay="input"
|
iconDisplay="input"
|
||||||
:showTime="true"
|
showTime
|
||||||
:hourFormat="field.hourFormat || '24'"
|
:hourFormat="field.hourFormat || '24'"
|
||||||
:dateFormat="field.dateFormat || 'dd/mm/yy'"
|
:dateFormat="field.dateFormat || 'dd/mm/yy'"
|
||||||
@update:model-value="handleFieldChange(field, $event)"
|
@update:model-value="handleFieldChange(field, $event)"
|
||||||
@ -344,7 +340,7 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="fieldValues[field.name]"
|
v-model="fieldValues[field.name]"
|
||||||
mode="basic"
|
mode="basic"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isFormDisabled"
|
||||||
:accept="field.accept"
|
:accept="field.accept"
|
||||||
:multiple="field.multiple"
|
:multiple="field.multiple"
|
||||||
:invalidFileTypeMessage="`Invalid file type. Accepted types: ${field.accept || 'any'}`"
|
:invalidFileTypeMessage="`Invalid file type. Accepted types: ${field.accept || 'any'}`"
|
||||||
@ -378,13 +374,15 @@
|
|||||||
v-if="showSubmitButton"
|
v-if="showSubmitButton"
|
||||||
type="submit"
|
type="submit"
|
||||||
:label="submitButtonText"
|
:label="submitButtonText"
|
||||||
:loading="isSubmitting"
|
:loading="isLoading"
|
||||||
|
:disabled="isFormDisabled"
|
||||||
severity="primary"
|
severity="primary"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
v-if="showCancelButton"
|
v-if="showCancelButton"
|
||||||
type="button"
|
type="button"
|
||||||
:label="cancelButtonText"
|
:label="cancelButtonText"
|
||||||
|
:disabled="isLoading"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
@ -408,6 +406,9 @@ import DatePicker from "primevue/datepicker";
|
|||||||
import FileUpload from "primevue/fileupload";
|
import FileUpload from "primevue/fileupload";
|
||||||
import Button from "primevue/button";
|
import Button from "primevue/button";
|
||||||
import Message from "primevue/message";
|
import Message from "primevue/message";
|
||||||
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
fields: {
|
fields: {
|
||||||
@ -457,6 +458,27 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loadingMessage: {
|
||||||
|
type: String,
|
||||||
|
default: "Processing...",
|
||||||
|
},
|
||||||
|
disableOnLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
// Auto-connect to global loading store
|
||||||
|
useGlobalLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
formName: {
|
||||||
|
type: String,
|
||||||
|
default: "form",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:formData", "submit", "cancel", "change", "blur"]);
|
const emit = defineEmits(["update:formData", "submit", "cancel", "change", "blur"]);
|
||||||
@ -466,6 +488,23 @@ const internalFormData = reactive({});
|
|||||||
const formErrors = reactive({});
|
const formErrors = reactive({});
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
// Computed loading and disabled states
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
if (props.useGlobalLoading) {
|
||||||
|
return (
|
||||||
|
props.loading ||
|
||||||
|
loadingStore.getComponentLoading("form") ||
|
||||||
|
loadingStore.getComponentLoading(props.formName) ||
|
||||||
|
isSubmitting.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return props.loading || isSubmitting.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFormDisabled = computed(() => {
|
||||||
|
return props.disableOnLoading && isLoading.value;
|
||||||
|
});
|
||||||
|
|
||||||
// Computed property for v-model binding
|
// Computed property for v-model binding
|
||||||
const fieldValues = computed({
|
const fieldValues = computed({
|
||||||
get() {
|
get() {
|
||||||
@ -605,6 +644,71 @@ const validateField = (field, value) => {
|
|||||||
return errors.length > 0 ? errors[0] : null;
|
return errors.length > 0 ? errors[0] : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle keydown events for input restrictions
|
||||||
|
const handleKeyDown = (field, event) => {
|
||||||
|
// Check if field has numeric-only restriction
|
||||||
|
if (field.inputMode === "numeric" || field.pattern === "[0-9]*") {
|
||||||
|
const key = event.key;
|
||||||
|
|
||||||
|
// Allow control keys (backspace, delete, tab, escape, enter, arrows, etc.)
|
||||||
|
const allowedKeys = [
|
||||||
|
"Backspace",
|
||||||
|
"Delete",
|
||||||
|
"Tab",
|
||||||
|
"Escape",
|
||||||
|
"Enter",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X, Ctrl+Z
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow allowed control keys
|
||||||
|
if (allowedKeys.includes(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow numeric keys (0-9)
|
||||||
|
if (!/^[0-9]$/.test(key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max length if specified
|
||||||
|
if (field.maxLength && event.target.value.length >= field.maxLength) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle text input with custom formatting
|
||||||
|
const handleTextInput = (field, event) => {
|
||||||
|
let value = event.target ? event.target.value : event;
|
||||||
|
|
||||||
|
// Apply custom input formatting if provided
|
||||||
|
if (field.onInput && typeof field.onInput === "function") {
|
||||||
|
value = field.onInput(value);
|
||||||
|
|
||||||
|
// Update the input value immediately to reflect formatting
|
||||||
|
if (event.target) {
|
||||||
|
event.target.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the standard field change handler
|
||||||
|
handleFieldChange(field, value);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle field value changes
|
// Handle field value changes
|
||||||
const handleFieldChange = (field, value) => {
|
const handleFieldChange = (field, value) => {
|
||||||
// Update form data
|
// Update form data
|
||||||
@ -693,19 +797,24 @@ const handleAutocompleteSearch = (field, event) => {
|
|||||||
const getFieldColumnClasses = (field) => {
|
const getFieldColumnClasses = (field) => {
|
||||||
const classes = [];
|
const classes = [];
|
||||||
|
|
||||||
// Default column sizes based on field.cols or defaults
|
// Base column size (mobile-first)
|
||||||
const cols = field.cols || 12;
|
const cols = field.cols || 12;
|
||||||
const sm = field.sm || 12;
|
classes.push(`col-${cols}`);
|
||||||
const md = field.md || 6;
|
|
||||||
const lg = field.lg || 6;
|
|
||||||
|
|
||||||
// Convert to CSS Grid or Flexbox classes
|
// Small breakpoint (sm)
|
||||||
// This is a basic implementation - you might want to use a CSS framework
|
if (field.sm && field.sm !== cols) {
|
||||||
if (cols === 12) classes.push("col-full");
|
classes.push(`sm-${field.sm}`);
|
||||||
else if (cols === 6) classes.push("col-half");
|
}
|
||||||
else if (cols === 4) classes.push("col-third");
|
|
||||||
else if (cols === 3) classes.push("col-quarter");
|
// Medium breakpoint (md)
|
||||||
else classes.push(`col-${cols}`);
|
if (field.md && field.md !== cols) {
|
||||||
|
classes.push(`md-${field.md}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large breakpoint (lg)
|
||||||
|
if (field.lg && field.lg !== cols) {
|
||||||
|
classes.push(`lg-${field.lg}`);
|
||||||
|
}
|
||||||
|
|
||||||
return classes.join(" ");
|
return classes.join(" ");
|
||||||
};
|
};
|
||||||
@ -805,6 +914,11 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
// Loading control methods
|
||||||
|
startLoading: (message) =>
|
||||||
|
loadingStore.setComponentLoading(props.formName, true, message || props.loadingMessage),
|
||||||
|
stopLoading: () => loadingStore.setComponentLoading(props.formName, false),
|
||||||
|
isLoading: () => isLoading.value,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -831,19 +945,7 @@ defineExpose({
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive column classes */
|
/* Base column classes (mobile-first) */
|
||||||
.col-full {
|
|
||||||
grid-column: span 12;
|
|
||||||
}
|
|
||||||
.col-half {
|
|
||||||
grid-column: span 6;
|
|
||||||
}
|
|
||||||
.col-third {
|
|
||||||
grid-column: span 4;
|
|
||||||
}
|
|
||||||
.col-quarter {
|
|
||||||
grid-column: span 3;
|
|
||||||
}
|
|
||||||
.col-1 {
|
.col-1 {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
@ -881,14 +983,130 @@ defineExpose({
|
|||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsive */
|
/* Small breakpoint (576px and up) */
|
||||||
@media (max-width: 768px) {
|
@media (min-width: 576px) {
|
||||||
.form-row {
|
.sm-1 {
|
||||||
grid-template-columns: 1fr;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
.sm-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.sm-3 {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
.sm-4 {
|
||||||
|
grid-column: span 4;
|
||||||
|
}
|
||||||
|
.sm-5 {
|
||||||
|
grid-column: span 5;
|
||||||
|
}
|
||||||
|
.sm-6 {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
.sm-7 {
|
||||||
|
grid-column: span 7;
|
||||||
|
}
|
||||||
|
.sm-8 {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
.sm-9 {
|
||||||
|
grid-column: span 9;
|
||||||
|
}
|
||||||
|
.sm-10 {
|
||||||
|
grid-column: span 10;
|
||||||
|
}
|
||||||
|
.sm-11 {
|
||||||
|
grid-column: span 11;
|
||||||
|
}
|
||||||
|
.sm-12 {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medium breakpoint (768px and up) */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.md-1 {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
.md-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.md-3 {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
.md-4 {
|
||||||
|
grid-column: span 4;
|
||||||
|
}
|
||||||
|
.md-5 {
|
||||||
|
grid-column: span 5;
|
||||||
|
}
|
||||||
|
.md-6 {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
.md-7 {
|
||||||
|
grid-column: span 7;
|
||||||
|
}
|
||||||
|
.md-8 {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
.md-9 {
|
||||||
|
grid-column: span 9;
|
||||||
|
}
|
||||||
|
.md-10 {
|
||||||
|
grid-column: span 10;
|
||||||
|
}
|
||||||
|
.md-11 {
|
||||||
|
grid-column: span 11;
|
||||||
|
}
|
||||||
|
.md-12 {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large breakpoint (992px and up) */
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.lg-1 {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
.lg-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.lg-3 {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
.lg-4 {
|
||||||
|
grid-column: span 4;
|
||||||
|
}
|
||||||
|
.lg-5 {
|
||||||
|
grid-column: span 5;
|
||||||
|
}
|
||||||
|
.lg-6 {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
.lg-7 {
|
||||||
|
grid-column: span 7;
|
||||||
|
}
|
||||||
|
.lg-8 {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
.lg-9 {
|
||||||
|
grid-column: span 9;
|
||||||
|
}
|
||||||
|
.lg-10 {
|
||||||
|
grid-column: span 10;
|
||||||
|
}
|
||||||
|
.lg-11 {
|
||||||
|
grid-column: span 11;
|
||||||
|
}
|
||||||
|
.lg-12 {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive - stack all fields on very small screens */
|
||||||
|
@media (max-width: 575px) {
|
||||||
.form-field {
|
.form-field {
|
||||||
grid-column: span 1 !important;
|
grid-column: span 12 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -959,12 +1177,11 @@ defineExpose({
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet responsive */
|
/* Tablet responsive - let the responsive classes handle the layout */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 767px) {
|
||||||
.col-half,
|
/* Fields without md specified should span full width on tablets */
|
||||||
.col-third,
|
.form-field:not([class*="md-"]) {
|
||||||
.col-quarter {
|
grid-column: span 12;
|
||||||
grid-column: span 6;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
frontend/src/components/common/GlobalLoadingOverlay.vue
Normal file
60
frontend/src/components/common/GlobalLoadingOverlay.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="showOverlay"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-[9999] transition-opacity duration-200"
|
||||||
|
:class="{ 'opacity-100': showOverlay, 'opacity-0 pointer-events-none': !showOverlay }"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full mx-4 text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="pi pi-spin pi-spinner text-4xl text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-2">Loading</h3>
|
||||||
|
<p class="text-gray-600">{{ loadingMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Show overlay only for global loading, not component-specific
|
||||||
|
globalOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
// Minimum display time to prevent flashing
|
||||||
|
minDisplayTime: {
|
||||||
|
type: Number,
|
||||||
|
default: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
|
||||||
|
const showOverlay = computed(() => {
|
||||||
|
if (props.globalOnly) {
|
||||||
|
return loadingStore.isLoading;
|
||||||
|
}
|
||||||
|
return loadingStore.isAnyLoading;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingMessage = computed(() => {
|
||||||
|
return loadingStore.loadingMessage;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Additional styling for better visual appearance */
|
||||||
|
.bg-opacity-30 {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Backdrop blur effect for modern browsers */
|
||||||
|
@supports (backdrop-filter: blur(4px)) {
|
||||||
|
.fixed.inset-0 {
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -29,7 +29,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, watch, watchEffect } from "vue";
|
import { ref, reactive, computed, watch } from "vue";
|
||||||
import { useModalStore } from "@/stores/modal";
|
import { useModalStore } from "@/stores/modal";
|
||||||
import Modal from "@/components/common/Modal.vue";
|
import Modal from "@/components/common/Modal.vue";
|
||||||
import Form from "@/components/common/Form.vue";
|
import Form from "@/components/common/Form.vue";
|
||||||
@ -43,11 +43,12 @@ const isVisible = computed(() => modalStore.isModalOpen("createClient"));
|
|||||||
const customerNames = ref([]);
|
const customerNames = ref([]);
|
||||||
// Form data
|
// Form data
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
name: "",
|
customertype: "",
|
||||||
address: "",
|
customerName: "",
|
||||||
|
addressLine1: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
email: "",
|
email: "",
|
||||||
zipcode: "",
|
pincode: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
});
|
});
|
||||||
@ -78,12 +79,27 @@ const modalOptions = {
|
|||||||
// Form field definitions
|
// Form field definitions
|
||||||
const formFields = computed(() => [
|
const formFields = computed(() => [
|
||||||
{
|
{
|
||||||
name: "name",
|
name: "customertype",
|
||||||
|
label: "Client Type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Select client type",
|
||||||
|
cols: 12,
|
||||||
|
md: 6,
|
||||||
|
options: [
|
||||||
|
{ label: "Individual", value: "Individual" },
|
||||||
|
{ label: "Company", value: "Company" },
|
||||||
|
],
|
||||||
|
helpText: "Select whether this is an individual or company client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "customerName",
|
||||||
label: "Client Name",
|
label: "Client Name",
|
||||||
type: "autocomplete", // Changed from 'select' to 'autocomplete'
|
type: "autocomplete", // Changed from 'select' to 'autocomplete'
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: "Type or select client name",
|
placeholder: "Type or select client name",
|
||||||
cols: 12,
|
cols: 12,
|
||||||
|
md: 6,
|
||||||
options: customerNames.value, // Direct array of strings
|
options: customerNames.value, // Direct array of strings
|
||||||
forceSelection: false, // Allow custom entries not in the list
|
forceSelection: false, // Allow custom entries not in the list
|
||||||
dropdown: true,
|
dropdown: true,
|
||||||
@ -92,7 +108,7 @@ const formFields = computed(() => [
|
|||||||
// Let the Form component handle filtering automatically
|
// Let the Form component handle filtering automatically
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "address",
|
name: "addressLine1",
|
||||||
label: "Address",
|
label: "Address",
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
@ -127,17 +143,24 @@ const formFields = computed(() => [
|
|||||||
md: 6,
|
md: 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "zipcode",
|
name: "pincode",
|
||||||
label: "Zip Code",
|
label: "Zip Code",
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: "Enter zip code",
|
placeholder: "Enter 5-digit zip code",
|
||||||
cols: 12,
|
cols: 12,
|
||||||
md: 4,
|
md: 4,
|
||||||
|
maxLength: 5,
|
||||||
|
inputMode: "numeric",
|
||||||
|
pattern: "[0-9]*",
|
||||||
onChangeOverride: handleZipcodeChange,
|
onChangeOverride: handleZipcodeChange,
|
||||||
|
onInput: (value) => {
|
||||||
|
// Only allow numbers and limit to 5 digits
|
||||||
|
return value.replace(/\D/g, "").substring(0, 5);
|
||||||
|
},
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
if (value && !/^\d{5}(-\d{4})?$/.test(value)) {
|
if (value && !/^\d{5}$/.test(value)) {
|
||||||
return "Please enter a valid zip code";
|
return "Please enter a valid 5-digit zip code";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@ -148,6 +171,7 @@ const formFields = computed(() => [
|
|||||||
type: availableCities.value.length > 0 ? "select" : "text",
|
type: availableCities.value.length > 0 ? "select" : "text",
|
||||||
required: true,
|
required: true,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
showClear: availableCities.value.length > 1,
|
||||||
placeholder: availableCities.value.length > 0 ? "Select city" : "Enter city name",
|
placeholder: availableCities.value.length > 0 ? "Select city" : "Enter city name",
|
||||||
options: availableCities.value.map((place) => ({
|
options: availableCities.value.map((place) => ({
|
||||||
label: place["place name"],
|
label: place["place name"],
|
||||||
@ -194,7 +218,10 @@ const formFields = computed(() => [
|
|||||||
|
|
||||||
// Handle zipcode change and API lookup
|
// Handle zipcode change and API lookup
|
||||||
async function handleZipcodeChange(value, fieldName, currentFormData) {
|
async function handleZipcodeChange(value, fieldName, currentFormData) {
|
||||||
if (fieldName === "zipcode" && value && value.length >= 5) {
|
if (value.length < 5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fieldName === "pincode" && value && value.length >= 5) {
|
||||||
// Only process if it's a valid zipcode format
|
// Only process if it's a valid zipcode format
|
||||||
const zipcode = value.replace(/\D/g, "").substring(0, 5);
|
const zipcode = value.replace(/\D/g, "").substring(0, 5);
|
||||||
|
|
||||||
@ -292,23 +319,14 @@ function getStatusIcon(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
async function handleSubmit(submittedFormData) {
|
async function handleSubmit() {
|
||||||
try {
|
try {
|
||||||
showStatusMessage("Creating client...", "info");
|
showStatusMessage("Creating client...", "info");
|
||||||
|
|
||||||
// Convert form data to the expected format
|
// Convert form data to the expected format
|
||||||
const clientData = {
|
|
||||||
name: submittedFormData.name,
|
|
||||||
address: submittedFormData.address,
|
|
||||||
phone: submittedFormData.phone,
|
|
||||||
email: submittedFormData.email,
|
|
||||||
zipcode: submittedFormData.zipcode,
|
|
||||||
city: submittedFormData.city,
|
|
||||||
state: submittedFormData.state,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Call API to create client
|
// Call API to create client
|
||||||
const response = await Api.createClient(clientData);
|
const response = await Api.createClient(formData);
|
||||||
|
|
||||||
if (response && response.success) {
|
if (response && response.success) {
|
||||||
showStatusMessage("Client created successfully!", "success");
|
showStatusMessage("Client created successfully!", "success");
|
||||||
@ -366,12 +384,7 @@ watch(isVisible, async () => {
|
|||||||
try {
|
try {
|
||||||
const names = await Api.getCustomerNames();
|
const names = await Api.getCustomerNames();
|
||||||
console.log("Loaded customer names:", names);
|
console.log("Loaded customer names:", names);
|
||||||
console.log("Customer names type:", typeof names, Array.isArray(names));
|
|
||||||
console.log("First customer name:", names[0], typeof names[0]);
|
|
||||||
customerNames.value = names;
|
customerNames.value = names;
|
||||||
|
|
||||||
// Debug: Let's also set some test data to see if autocomplete works at all
|
|
||||||
console.log("Setting customerNames to:", customerNames.value);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading customer names:", error);
|
console.error("Error loading customer names:", error);
|
||||||
// Set some test data to debug if autocomplete works
|
// Set some test data to debug if autocomplete works
|
||||||
@ -406,31 +419,6 @@ watch(isVisible, async () => {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form styling adjustments for PrimeVue components */
|
|
||||||
:deep(.p-inputtext),
|
|
||||||
:deep(.p-dropdown),
|
|
||||||
:deep(.p-autocomplete) {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure AutoComplete panel appears above modal */
|
|
||||||
:global(.p-autocomplete-overlay) {
|
|
||||||
z-index: 9999 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.p-autocomplete-panel) {
|
|
||||||
z-index: 9999 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.p-button) {
|
|
||||||
text-transform: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.p-button:not(.p-button-text)) {
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status message styling */
|
/* Status message styling */
|
||||||
.status-message {
|
.status-message {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
|||||||
@ -14,7 +14,13 @@ import { onMounted, ref } from "vue";
|
|||||||
import DataTable from "../common/DataTable.vue";
|
import DataTable from "../common/DataTable.vue";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { FilterMatchMode } from "@primevue/core";
|
import { FilterMatchMode } from "@primevue/core";
|
||||||
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
|
||||||
|
const itemCount = ref(0);
|
||||||
|
const page = ref(0);
|
||||||
|
const pageLength = ref(30);
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
@ -35,20 +41,31 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Appt. Scheduled",
|
label: "Appt. Scheduled",
|
||||||
fieldName: "appointmentScheduled",
|
fieldName: "appointmentStatus",
|
||||||
type: "status",
|
type: "status",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
{ label: "Estimate Sent", fieldName: "estimateSent", type: "status", sortable: true },
|
{ label: "Estimate Sent", fieldName: "estimateStatus", type: "status", sortable: true },
|
||||||
{ label: "Payment Received", fieldName: "paymentReceived", type: "status", sortable: true },
|
{ label: "Payment Received", fieldName: "paymentStatus", type: "status", sortable: true },
|
||||||
{ label: "Job Status", fieldName: "jobStatus", type: "status", sortable: true },
|
{ label: "Job Status", fieldName: "jobStatus", type: "status", sortable: true },
|
||||||
];
|
];
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (tableData.value.length > 0) {
|
if (tableData.value.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let data = await Api.getClientDetails();
|
|
||||||
tableData.value = data;
|
try {
|
||||||
|
// Use the loading store to track this API call
|
||||||
|
const data = await loadingStore.withComponentLoading(
|
||||||
|
"clients",
|
||||||
|
() => Api.getClientDetails(),
|
||||||
|
"Loading client data...",
|
||||||
|
);
|
||||||
|
tableData.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading client data:", error);
|
||||||
|
// You could also show a toast or other error notification here
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="css"></style>
|
<style lang="css"></style>
|
||||||
|
|||||||
138
frontend/src/stores/loading.js
Normal file
138
frontend/src/stores/loading.js
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useLoadingStore = defineStore("loading", {
|
||||||
|
state: () => ({
|
||||||
|
// Global loading state
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
// Component-specific loading states for more granular control
|
||||||
|
componentLoading: {
|
||||||
|
dataTable: false,
|
||||||
|
form: false,
|
||||||
|
clients: false,
|
||||||
|
jobs: false,
|
||||||
|
timesheets: false,
|
||||||
|
warranties: false,
|
||||||
|
routes: false,
|
||||||
|
api: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Loading messages for different contexts
|
||||||
|
loadingMessage: "Loading...",
|
||||||
|
|
||||||
|
// Track loading operations with custom keys
|
||||||
|
operations: new Map(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
// Check if any loading is happening
|
||||||
|
isAnyLoading: (state) => {
|
||||||
|
return (
|
||||||
|
state.isLoading ||
|
||||||
|
Object.values(state.componentLoading).some((loading) => loading) ||
|
||||||
|
state.operations.size > 0
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get loading state for a specific component
|
||||||
|
getComponentLoading: (state) => (componentName) => {
|
||||||
|
return state.componentLoading[componentName] || false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if a specific operation is loading
|
||||||
|
isOperationLoading: (state) => (operationKey) => {
|
||||||
|
return state.operations.has(operationKey);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// Set global loading state
|
||||||
|
setLoading(isLoading, message = "Loading...") {
|
||||||
|
this.isLoading = isLoading;
|
||||||
|
this.loadingMessage = message;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set component-specific loading state
|
||||||
|
setComponentLoading(componentName, isLoading, message = "Loading...") {
|
||||||
|
if (this.componentLoading.hasOwnProperty(componentName)) {
|
||||||
|
this.componentLoading[componentName] = isLoading;
|
||||||
|
} else {
|
||||||
|
this.componentLoading[componentName] = isLoading;
|
||||||
|
}
|
||||||
|
if (isLoading) {
|
||||||
|
this.loadingMessage = message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Start loading for a specific operation
|
||||||
|
startOperation(operationKey, message = "Loading...") {
|
||||||
|
this.operations.set(operationKey, {
|
||||||
|
startTime: Date.now(),
|
||||||
|
message: message,
|
||||||
|
});
|
||||||
|
this.loadingMessage = message;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stop loading for a specific operation
|
||||||
|
stopOperation(operationKey) {
|
||||||
|
this.operations.delete(operationKey);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear all loading states
|
||||||
|
clearAllLoading() {
|
||||||
|
this.isLoading = false;
|
||||||
|
Object.keys(this.componentLoading).forEach((key) => {
|
||||||
|
this.componentLoading[key] = false;
|
||||||
|
});
|
||||||
|
this.operations.clear();
|
||||||
|
this.loadingMessage = "Loading...";
|
||||||
|
},
|
||||||
|
|
||||||
|
// Convenience methods for common operations
|
||||||
|
startApiCall(apiName = "api") {
|
||||||
|
this.setComponentLoading("api", true, `Loading ${apiName}...`);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopApiCall() {
|
||||||
|
this.setComponentLoading("api", false);
|
||||||
|
},
|
||||||
|
|
||||||
|
startDataTableLoading(message = "Loading data...") {
|
||||||
|
this.setComponentLoading("dataTable", true, message);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopDataTableLoading() {
|
||||||
|
this.setComponentLoading("dataTable", false);
|
||||||
|
},
|
||||||
|
|
||||||
|
startFormLoading(message = "Processing...") {
|
||||||
|
this.setComponentLoading("form", true, message);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopFormLoading() {
|
||||||
|
this.setComponentLoading("form", false);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Async wrapper for operations
|
||||||
|
async withLoading(operationKey, asyncOperation, message = "Loading...") {
|
||||||
|
try {
|
||||||
|
this.startOperation(operationKey, message);
|
||||||
|
const result = await asyncOperation();
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this.stopOperation(operationKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Async wrapper for component loading
|
||||||
|
async withComponentLoading(componentName, asyncOperation, message = "Loading...") {
|
||||||
|
try {
|
||||||
|
this.setComponentLoading(componentName, true, message);
|
||||||
|
const result = await asyncOperation();
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this.setComponentLoading(componentName, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -3,6 +3,18 @@
|
|||||||
--secondary-background-color: #669084;
|
--secondary-background-color: #669084;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fix PrimeVue overlay z-index conflicts with Vuetify modals */
|
||||||
|
/* Vuetify dialogs typically use z-index 2400+, so PrimeVue overlays need to be higher */
|
||||||
|
.p-component-overlay {
|
||||||
|
z-index: 2500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-select-overlay,
|
||||||
|
.p-autocomplete-overlay,
|
||||||
|
.p-dropdown-overlay {
|
||||||
|
z-index: 2500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.page-turn-button {
|
.page-turn-button {
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Key } from "@iconoir/vue";
|
||||||
|
|
||||||
class DataUtils {
|
class DataUtils {
|
||||||
// static buildClientData(clients) {
|
// static buildClientData(clients) {
|
||||||
// const address = `${client["address"]["address_line_1"] || ""} ${client["address"]["address_line_2"] || ""} ${client["address"]["city"] || ""} ${client["address"]["state"] || ""}`.trim();
|
// const address = `${client["address"]["address_line_1"] || ""} ${client["address"]["address_line_2"] || ""} ${client["address"]["city"] || ""} ${client["address"]["state"] || ""}`.trim();
|
||||||
@ -1641,12 +1643,67 @@ class DataUtils {
|
|||||||
];
|
];
|
||||||
|
|
||||||
static US_STATES = [
|
static US_STATES = [
|
||||||
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
|
"AL",
|
||||||
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
|
"AK",
|
||||||
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
|
"AZ",
|
||||||
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
|
"AR",
|
||||||
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
|
"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",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
static toSnakeCaseObject(obj) {
|
||||||
|
const newObj = Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
|
const snakeKey = key.replace(/[A-Z]/g, "_$1").toLowerCase();
|
||||||
|
acc[snakeKey] = value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
console.log("DEBUG: toSnakeCaseObject -> newObj", newObj);
|
||||||
|
return newObj;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DataUtils;
|
export default DataUtils;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user