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" > + + + + {{ emptyMessage || "No data available" }} + + + + + + {{ loadingMessage || "Loading data. Please wait..." }} + + @@ -57,8 +72,10 @@ import InputText from "primevue/inputtext"; import { ref } from "vue"; import { FilterMatchMode } from "@primevue/core"; import { useFiltersStore } from "../../stores/filters"; +import { useLoadingStore } from "../../stores/loading"; const filtersStore = useFiltersStore(); +const loadingStore = useLoadingStore(); const props = defineProps({ columns: { @@ -79,10 +96,44 @@ const props = defineProps({ type: String, 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"]); +// 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 onMounted(() => { filtersStore.initializeTableFilters(props.tableName, props.columns); @@ -97,39 +148,43 @@ const filterRef = computed({ }, set(newFilters) { // Update store when filters change - Object.keys(newFilters).forEach(key => { - if (key !== 'global' && newFilters[key]) { + Object.keys(newFilters).forEach((key) => { + if (key !== "global" && newFilters[key]) { const filter = newFilters[key]; filtersStore.updateTableFilter( - props.tableName, - key, - filter.value, - filter.matchMode + 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 - ); +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 }); + }); + }, + { deep: true }, +); const selectedRows = ref(); @@ -138,7 +193,7 @@ 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 @@ -160,6 +215,12 @@ const getBadgeColor = (status) => { }; console.log("DEBUG: - DataTable props.columns", props.columns); 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, +}); - + diff --git a/frontend/src/components/common/Form.vue b/frontend/src/components/common/Form.vue index 5cd8c95..ec688f6 100644 --- a/frontend/src/components/common/Form.vue +++ b/frontend/src/components/common/Form.vue @@ -19,16 +19,15 @@ v-model="fieldValues[field.name]" :type="field.format || 'text'" :placeholder="field.placeholder" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :readonly="field.readonly" :invalid="!!getFieldError(field.name)" - :fluid="true" - @input=" - handleFieldChange( - field, - $event.target ? $event.target.value : $event, - ) - " + fluid + :maxlength="field.maxLength" + :inputmode="field.inputMode" + :pattern="field.pattern" + @keydown="handleKeyDown(field, $event)" + @input="handleTextInput(field, $event)" @blur=" handleFieldBlur( field, @@ -59,13 +58,13 @@ :id="field.name" v-model="fieldValues[field.name]" :placeholder="field.placeholder" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :readonly="field.readonly" :min="field.min" :max="field.max" :step="field.step" :invalid="!!getFieldError(field.name)" - :fluid="true" + fluid @input="handleFieldChange(field, $event.value)" @blur="handleFieldBlur(field, fieldValues[field.name])" /> @@ -92,11 +91,11 @@ :id="field.name" v-model="fieldValues[field.name]" :placeholder="field.placeholder" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :readonly="field.readonly" :rows="field.rows || 3" :invalid="!!getFieldError(field.name)" - :fluid="true" + fluid :autoResize="field.autoResize !== false" @input=" handleFieldChange( @@ -134,14 +133,13 @@ :id="field.name" v-model="fieldValues[field.name]" :options="field.options" - :optionLabel="field.optionLabel || 'label'" - :optionValue="field.optionValue || 'value'" - :disabled="field.disabled" + optionLabel="label" + optionValue="value" + :disabled="field.disabled || isFormDisabled" :placeholder="field.placeholder" :invalid="!!getFieldError(field.name)" - :fluid="true" - :filter="field.filter !== false" - :showClear="field.showClear !== false" + fluid + appendTo="body" @update:model-value="handleFieldChange(field, $event)" @blur="handleFieldBlur(field, fieldValues[field.name])" /> @@ -168,15 +166,13 @@ :id="field.name" v-model="fieldValues[field.name]" :suggestions="field.filteredOptions || field.options || []" - :option-label="field.optionLabel" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :placeholder="field.placeholder" :invalid="!!getFieldError(field.name)" - :fluid="true" - :dropdown="field.dropdown !== false" - :multiple="field.multiple === true" - :force-selection="field.forceSelection === true" - dropdown-mode="blank" + fluid + :dropdown="field.dropdown" + :forceSelection="field.forceSelection" + appendTo="body" @complete="handleAutocompleteSearch(field, $event)" @update:model-value="handleFieldChange(field, $event)" @blur="handleFieldBlur(field, fieldValues[field.name])" @@ -201,7 +197,7 @@ :id="field.name" v-model="fieldValues[field.name]" :binary="true" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :invalid="!!getFieldError(field.name)" @update:model-value="handleFieldChange(field, $event)" /> @@ -240,7 +236,7 @@ v-model="fieldValues[field.name]" :name="field.name" :value="option.value" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :invalid="!!getFieldError(field.name)" @update:model-value="handleFieldChange(field, $event)" /> @@ -272,13 +268,13 @@ :id="field.name" v-model="fieldValues[field.name]" :placeholder="field.placeholder" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :readonly="field.readonly" :minDate="field.minDate" :maxDate="field.maxDate" :invalid="!!getFieldError(field.name)" - :fluid="true" - :showIcon="true" + fluid + showIcon iconDisplay="input" :dateFormat="field.dateFormat || 'dd/mm/yy'" @update:model-value="handleFieldChange(field, $event)" @@ -307,15 +303,15 @@ :id="field.name" v-model="fieldValues[field.name]" :placeholder="field.placeholder" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :readonly="field.readonly" :minDate="field.minDate" :maxDate="field.maxDate" :invalid="!!getFieldError(field.name)" - :fluid="true" - :showIcon="true" + fluid + showIcon iconDisplay="input" - :showTime="true" + showTime :hourFormat="field.hourFormat || '24'" :dateFormat="field.dateFormat || 'dd/mm/yy'" @update:model-value="handleFieldChange(field, $event)" @@ -344,7 +340,7 @@ :id="field.name" v-model="fieldValues[field.name]" mode="basic" - :disabled="field.disabled" + :disabled="field.disabled || isFormDisabled" :accept="field.accept" :multiple="field.multiple" :invalidFileTypeMessage="`Invalid file type. Accepted types: ${field.accept || 'any'}`" @@ -378,13 +374,15 @@ v-if="showSubmitButton" type="submit" :label="submitButtonText" - :loading="isSubmitting" + :loading="isLoading" + :disabled="isFormDisabled" severity="primary" /> { + 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 const fieldValues = computed({ get() { @@ -605,6 +644,71 @@ const validateField = (field, value) => { 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 const handleFieldChange = (field, value) => { // Update form data @@ -693,19 +797,24 @@ const handleAutocompleteSearch = (field, event) => { const getFieldColumnClasses = (field) => { const classes = []; - // Default column sizes based on field.cols or defaults + // Base column size (mobile-first) const cols = field.cols || 12; - const sm = field.sm || 12; - const md = field.md || 6; - const lg = field.lg || 6; + classes.push(`col-${cols}`); - // Convert to CSS Grid or Flexbox classes - // This is a basic implementation - you might want to use a CSS framework - if (cols === 12) classes.push("col-full"); - else if (cols === 6) classes.push("col-half"); - else if (cols === 4) classes.push("col-third"); - else if (cols === 3) classes.push("col-quarter"); - else classes.push(`col-${cols}`); + // Small breakpoint (sm) + if (field.sm && field.sm !== cols) { + classes.push(`sm-${field.sm}`); + } + + // Medium breakpoint (md) + 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(" "); }; @@ -805,6 +914,11 @@ defineExpose({ } return true; }, + // Loading control methods + startLoading: (message) => + loadingStore.setComponentLoading(props.formName, true, message || props.loadingMessage), + stopLoading: () => loadingStore.setComponentLoading(props.formName, false), + isLoading: () => isLoading.value, }); @@ -831,19 +945,7 @@ defineExpose({ flex-direction: column; } -/* Responsive column classes */ -.col-full { - grid-column: span 12; -} -.col-half { - grid-column: span 6; -} -.col-third { - grid-column: span 4; -} -.col-quarter { - grid-column: span 3; -} +/* Base column classes (mobile-first) */ .col-1 { grid-column: span 1; } @@ -881,14 +983,130 @@ defineExpose({ grid-column: span 12; } -/* Mobile responsive */ -@media (max-width: 768px) { - .form-row { - grid-template-columns: 1fr; +/* Small breakpoint (576px and up) */ +@media (min-width: 576px) { + .sm-1 { + 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 { - grid-column: span 1 !important; + grid-column: span 12 !important; } } @@ -959,12 +1177,11 @@ defineExpose({ justify-content: flex-start; } -/* Tablet responsive */ -@media (max-width: 1024px) { - .col-half, - .col-third, - .col-quarter { - grid-column: span 6; +/* Tablet responsive - let the responsive classes handle the layout */ +@media (max-width: 767px) { + /* Fields without md specified should span full width on tablets */ + .form-field:not([class*="md-"]) { + grid-column: span 12; } } diff --git a/frontend/src/components/common/GlobalLoadingOverlay.vue b/frontend/src/components/common/GlobalLoadingOverlay.vue new file mode 100644 index 0000000..7a257fe --- /dev/null +++ b/frontend/src/components/common/GlobalLoadingOverlay.vue @@ -0,0 +1,60 @@ + + + + + + + Loading + {{ loadingMessage }} + + + + + + + diff --git a/frontend/src/components/modals/CreatClientModal.vue b/frontend/src/components/modals/CreatClientModal.vue index aa05192..d51b52a 100644 --- a/frontend/src/components/modals/CreatClientModal.vue +++ b/frontend/src/components/modals/CreatClientModal.vue @@ -29,7 +29,7 @@ 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;
{{ emptyMessage || "No data available" }}
{{ loadingMessage || "Loading data. Please wait..." }}
{{ loadingMessage }}