diff --git a/custom_ui/api/db.py b/custom_ui/api/db.py new file mode 100644 index 0000000..19a5607 --- /dev/null +++ b/custom_ui/api/db.py @@ -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 + } \ No newline at end of file diff --git a/frontend/documentation/LOADING_USAGE.md b/frontend/documentation/LOADING_USAGE.md new file mode 100644 index 0000000..7bf24e5 --- /dev/null +++ b/frontend/documentation/LOADING_USAGE.md @@ -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 + + + +``` + +## Form Component + +The Form component also integrates with loading: + +```vue + + + +``` + +## 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 + + + + + + :minDisplayTime="500" +/> +``` + +## 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 + } +}; +``` diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1341e66..0e0e2bb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,6 +2,7 @@ import { IconoirProvider } from "@iconoir/vue"; import SideBar from "./components/SideBar.vue"; import CreateClientModal from "./components/modals/CreatClientModal.vue"; +import GlobalLoadingOverlay from "./components/common/GlobalLoadingOverlay.vue"; import ScrollPanel from "primevue/scrollpanel"; @@ -22,9 +23,12 @@ import ScrollPanel from "primevue/scrollpanel"; - + + + + diff --git a/frontend/src/api.js b/frontend/src/api.js index c420247..a8ec74f 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,16 +1,17 @@ +import { da } from "vuetify/locale"; import DataUtils from "./utils"; 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 { - static async request(url, method = "GET", data = {}) { + static async request(frappeMethod, args = {}) { try { const response = await frappe.call({ - method: "custom_ui.api.proxy.request", + method: frappeMethod, args: { - url, - method, - data: JSON.stringify(data), + ...args, }, }); console.log("DEBUG: API - Request Response: ", response); @@ -22,20 +23,72 @@ class Api { } } - static async getClientDetails() { - // const data = []; - // const addresses = await this.getDocsList("Address"); - // for (const addr of addresses) { - // const clientDetail = {}; - // const fullAddress = await this.getDetailedDoc("Address", addr["name"] || addr["Name"]); - // const customer = await this.getDetailedCustomer(fullAddress["links"][0]["link_name"]); - // clientDetail.customer = customer; - // clientDetail.address = fullAddress; - // data.push(clientDetail); - // } - // console.log("DEBUG: API - Fetched Client Details: ", data); - const data = DataUtils.dummyClientData; - console.log("DEBUG: API - getClientDetails result: ", data); + static async getClientDetails(forTable = true) { + const data = []; + const addresses = await this.getDocsList("Address", ["*"]); + for (const addr of addresses) { + const clientDetail = {}; + + const customer = await this.getDetailedDoc( + "Customer", + addr["custom_customer_to_bill"], + ); + + const quotations = await this.getDocsList("Quotation", [], { + custom_installation_address: addr["name"], + }); + 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; } @@ -77,10 +130,16 @@ class Api { * * @param {String} doctype * @param {string[]} fields + * @param {Object} filters * @returns {Promise} */ - static async getDocsList(doctype, fields = []) { - const docs = await frappe.db.get_list(doctype, { fields }); + static async getDocsList(doctype, fields = [], filters = {}, page = 0, pageLength = 600) { + const docs = await frappe.db.get_list(doctype, { + fields, + filters, + start: page * pageLength, + limit: pageLength, + }); console.log(`DEBUG: API - Fetched ${doctype} list: `, docs); return docs; } @@ -90,14 +149,48 @@ class Api { * * @param {String} doctype * @param {String} name + * @param {Object} filters * @returns {Promise} */ - static async getDetailedDoc(doctype, name) { - const doc = await frappe.db.get_doc(doctype, name); + static async getDetailedDoc(doctype, name, filters = {}) { + const doc = await frappe.db.get_doc(doctype, name, filters); console.log(`DEBUG: API - Fetched Detailed ${doctype}: `, 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. * @@ -106,22 +199,13 @@ class Api { */ static async getCityStateByZip(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 || {}; if (!places || places.length === 0) { throw new Error(`No location data found for zip code ${zipcode}`); } return places; } - - /** - * Fetch a list of Customer names. - * @returns {Promise} - */ - static async getCustomerNames() { - const customers = await this.getDocsList("Customer", ["name"]); - return customers.map((customer) => customer.name); - } } export default Api; diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index a9c2218..4d76e33 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -14,7 +14,21 @@ selectionMode="multiple" metaKeySelection="true" dataKey="id" + :loading="loading" + :loadingIcon="loadingIcon" > + + diff --git a/frontend/src/stores/loading.js b/frontend/src/stores/loading.js new file mode 100644 index 0000000..bda9605 --- /dev/null +++ b/frontend/src/stores/loading.js @@ -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); + } + }, + }, +}); diff --git a/frontend/src/style.css b/frontend/src/style.css index d4d2a54..45e8578 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -3,6 +3,18 @@ --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 { border-radius: 5px; border: none; diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 17e08dc..b7b1157 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,3 +1,5 @@ +import { Key } from "@iconoir/vue"; + class DataUtils { // static buildClientData(clients) { // 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 = [ - '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' + "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", ]; + + 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;