diff --git a/custom_ui/api/db/addresses.py b/custom_ui/api/db/addresses.py index 17da89e..3244836 100644 --- a/custom_ui/api/db/addresses.py +++ b/custom_ui/api/db/addresses.py @@ -1,6 +1,43 @@ import frappe from custom_ui.db_utils import build_error_response, build_success_response +@frappe.whitelist() +def get_address_by_full_address(full_address): + """Get address by full_address, including associated contacts.""" + try: + address = frappe.get_doc("Address", {"full_address": full_address}).as_dict() + address["customer"] = frappe.get_doc("Customer", address.get("custom_customer_to_bill")).as_dict() + contacts = [] + for contact_link in address.custom_linked_contacts: + contact_doc = frappe.get_doc("Contact", contact_link.contact) + contacts.append(contact_doc.as_dict()) + address["contacts"] = contacts + return build_success_response(address) + except Exception as e: + return build_error_response(str(e), 500) + +@frappe.whitelist() +def get_address(address_name): + """Get a specific address by name.""" + try: + address = frappe.get_doc("Address", address_name) + return build_success_response(address.as_dict()) + except Exception as e: + return build_error_response(str(e), 500) + +@frappe.whitelist() +def get_contacts_for_address(address_name): + """Get contacts linked to a specific address.""" + try: + address = frappe.get_doc("Address", address_name) + contacts = [] + for contact_link in address.custom_linked_contacts: + contact = frappe.get_doc("Contact", contact_link.contact) + contacts.append(contact.as_dict()) + return build_success_response(contacts) + except Exception as e: + return build_error_response(str(e), 500) + @frappe.whitelist() def get_addresses(fields=["*"], filters={}): """Get addresses with optional filtering.""" diff --git a/custom_ui/api/db/clients.py b/custom_ui/api/db/clients.py index cb7165c..7d4dbb7 100644 --- a/custom_ui/api/db/clients.py +++ b/custom_ui/api/db/clients.py @@ -92,96 +92,45 @@ def get_client_status_counts(weekly=False, week_start_date=None, week_end_date=N def get_client(client_name): """Get detailed information for a specific client including address, customer, and projects.""" try: - clientData = {"addresses": []} + clientData = {"addresses": [], "contacts": [], "jobs": [], "sales_invoices": [], "payment_entries": [], "sales_orders": [], "tasks": []} customer = frappe.get_doc("Customer", client_name) clientData = {**clientData, **customer.as_dict()} - addresses = frappe.db.get_all("Address", fields=["*"], filters={"custom_customer_to_bill": client_name}) - contacts = frappe.db.get_all("Contact", fields=["*"], filters={"custom_customer": client_name}) - clientData["contacts"] = contacts - for address in addresses if addresses else []: - addressData = {"jobs": []} - addressData = {**addressData, **address} - addressData["estimates"] = frappe.db.get_all("Quotation", fields=["*"], filters={"custom_installation_address": address.address_title}) - addressData["onsite_meetings"] = frappe.db.get_all("On-Site Meeting", fields=["*"], filters={"address": address.address_title}) - jobs = frappe.db.get_all("Project", fields=["*"], or_filters=[ - ["custom_installation_address", "=", address.address_title], - ["custom_address", "=", address.address_title] - ]) - for job in jobs if jobs else []: - jobData = {} - jobData = {**jobData, **job} - jobData["sales_invoices"] = frappe.db.get_all("Sales Invoice", fields=["*"], filters={"project": job.name}) - jobData["payment_entries"] = frappe.db.get_all( - "Payment Entry", - fields=["*"], - filters={"party_type": "Customer"}, - or_filters=[ - ["party", "=", client_name], - ["party_name", "=", client_name] - ]) - jobData["sales_orders"] = frappe.db.get_all("Sales Order", fields=["*"], filters={"project": job.name}) - jobData["tasks"] = frappe.db.get_all("Task", fields=["*"], filters={"project": job.name}) - addressData["jobs"].append(jobData) - clientData["addresses"].append(addressData) + + for contact_link in customer.custom_add_contacts: + contact_doc = frappe.get_doc("Contact", contact_link.contact) + clientData["contacts"].append(contact_doc.as_dict()) + + for address_link in customer.custom_select_address: + address_doc = frappe.get_doc("Address", address_link.address_name) + # # addressData = {"jobs": [], "contacts": []} + # addressData = {**addressData, **address_doc.as_dict()} + # addressData["estimates"] = frappe.db.get_all("Quotation", fields=["*"], filters={"custom_installation_address": address_doc.address_title}) + # addressData["onsite_meetings"] = frappe.db.get_all("On-Site Meeting", fields=["*"], filters={"address": address_doc.address_title}) + # jobs = frappe.db.get_all("Project", fields=["*"], or_filters=[ + # ["custom_installation_address", "=", address.address_title], + # ["custom_address", "=", address.address_title] + # ]) + # for job in jobs if jobs else []: + # jobData = {} + # jobData = {**jobData, **job} + # jobData["sales_invoices"] = frappe.db.get_all("Sales Invoice", fields=["*"], filters={"project": job.name}) + # jobData["payment_entries"] = frappe.db.get_all( + # "Payment Entry", + # fields=["*"], + # filters={"party_type": "Customer"}, + # or_filters=[ + # ["party", "=", client_name], + # ["party_name", "=", client_name] + # ]) + # jobData["sales_orders"] = frappe.db.get_all("Sales Order", fields=["*"], filters={"project": job.name}) + # jobData["tasks"] = frappe.db.get_all("Task", fields=["*"], filters={"project": job.name}) + # addressData["jobs"].append(jobData) + clientData["addresses"].append(address_doc.as_dict()) return build_success_response(clientData) except frappe.ValidationError as ve: return build_error_response(str(ve), 400) except Exception as e: return build_error_response(str(e), 500) - - - # address = frappe.get_doc("Address", client_name) - # customer_name = address.custom_customer_to_bill if address.custom_customer_to_bill else [link.link_name for link in address.links if link.link_doctype == "Customer"][0] if address.links else None - # if not customer_name: - # raise Exception(f"No customer linked to address {client_name}. Suggested fix: Ensure the address is linked to a customer via the ERPnext UI.") - # project_names = frappe.db.get_all("Project", fields=["name"], or_filters=[ - # ["custom_installation_address", "=", address.address_title], - # ["custom_address", "=", address.address_title] - # ], limit_page_length=100) - # # contacts = [] # currently not needed as the customer doctype comes with contacts - # onsite_meetings = frappe.db.get_all( - # "On-Site Meeting", - # fields=["*"], - # filters={"address": address.address_title} - # ) - # quotations = frappe.db.get_all( - # "Quotation", - # fields=["*"], - # filters={"custom_installation_address": address.address_title} - # ) - # sales_orders = [] - # projects = [frappe.get_doc("Project", proj["name"]) for proj in project_names] - # sales_invoices = [] - # payment_entries = frappe.db.get_all( - # "Payment Entry", - # fields=["*"], - # filters={"party_type": "Customer"}, - # or_filters=[ - # ["party", "=", customer_name], - # ["party_name", "=", customer_name] - # ]) - # payment_orders = [] - # jobs = [] - # for project in projects: - # job = [] - # jobs.append(job) - # customer = frappe.get_doc("Customer", customer_name) - # # get all associated data as needed - # return build_success_response({ - # "address": address, - # "customer": customer, - # # "contacts": [], # currently not needed as the customer doctype comes with contacts - # "jobs": jobs, - # "sales_invoices": sales_invoices, - # "payment_entries": payment_entries, - # "sales_orders": sales_orders, - # "quotations": quotations, - # "onsite_meetings": onsite_meetings, - # }) - except frappe.ValidationError as ve: - return build_error_response(str(ve), 400) - except Exception as e: - return build_error_response(str(e), 500) @frappe.whitelist() def get_clients_table_data(filters={}, sortings=[], page=1, page_size=10): @@ -253,10 +202,11 @@ def get_clients_table_data(filters={}, sortings=[], page=1, page_size=10): def upsert_client(data): """Create or update a client (customer and address).""" try: - data = json.loads(data) # Handle customer creation/update + print("#####DEBUG: Upsert client data received:", data) + print("#####DEBUG: Checking for existing customer with name:", data.get("customer_name")) customer = frappe.db.exists("Customer", {"customer_name": data.get("customer_name")}) if not customer: customer_doc = frappe.get_doc({ @@ -266,21 +216,20 @@ def upsert_client(data): }).insert(ignore_permissions=True) else: customer_doc = frappe.get_doc("Customer", data.get("customer_name")) - print("Customer:", customer_doc.as_dict()) - # Check for existing address - filters = { - "address_line1": data.get("address_line1"), - "city": data.get("city"), - "state": data.get("state"), - } - existing_address = frappe.db.exists("Address", filters) + # Handle address creation + print("#####DEBUG: Checking for existing address for customer:", data.get("customer_name")) + existing_address = frappe.db.exists( + "Address", + { + "address_line1": data.get("address_line1"), + "city": data.get("city"), + "state": data.get("state"), + }) print("Existing address check:", existing_address) if existing_address: frappe.throw(f"Address already exists for customer {data.get('customer_name')}.", frappe.ValidationError) - - # Create address address_doc = frappe.get_doc({ "doctype": "Address", "address_title": data.get("address_title"), @@ -292,37 +241,97 @@ def upsert_client(data): "pincode": data.get("pincode"), "custom_customer_to_bill": customer_doc.name }).insert(ignore_permissions=True) + print("Address:", address_doc.as_dict()) - # Link address to customer - link = { + #Handle contact creation + contact_docs = [] + for contact_data in data.get("contacts", []): + if isinstance(contact_data, str): + contact_data = json.loads(contact_data) + print("#####DEBUG: Processing contact data:", contact_data) + contact_exists = frappe.db.exists("Contact", {"email_id": contact_data.get("email"), "phone": contact_data.get("phone_number")}) + if not contact_exists: + is_primary_contact = 1 if contact_data.get("is_primary_contact") else 0 + contact_doc = frappe.get_doc({ + "doctype": "Contact", + "first_name": contact_data.get("first_name"), + "last_name": contact_data.get("last_name"), + "email_id": contact_data.get("email"), + "phone": contact_data.get("phone_number"), + "custom_customer": customer_doc.name, + "role": contact_data.get("contact_role", "Other"), + "custom_email": contact_data.get("email"), + "is_primary_contact": is_primary_contact + }).insert(ignore_permissions=True) + print("Created new contact:", contact_doc.as_dict()) + else: + contact_doc = frappe.get_doc("Contact", {"email_id": data.get("email")}) + print("Contact already exists:", contact_doc.as_dict()) + contact_docs.append(contact_doc) + + ##### Create links + # Customer -> Address + print("#####DEBUG: Creating links between customer, address, and contacts.") + customer_doc.append("custom_select_address", { + "address_name": address_doc.name, + "address_line_1": address_doc.address_line1, + "city": address_doc.city, + "state": address_doc.state, + "pincode": address_doc.pincode + }) + + # Customer -> Contact + print("#####DEBUG: Linking contacts to customer.") + for contact_doc in contact_docs: + print("Linking contact:", contact_doc.as_dict()) + print("with role:", contact_doc.role) + print("customer to append to:", customer_doc.as_dict()) + customer_doc.append("custom_add_contacts", { + "contact": contact_doc.name, + "email": contact_doc.custom_email, + "phone": contact_doc.phone, + "role": contact_doc.role + }) + + # Address -> Customer + print("#####DEBUG: Linking address to customer.") + address_doc.append("links", { "link_doctype": "Customer", "link_name": customer_doc.name - } - address_doc.append("links", link) - contact_exists = frappe.db.exists("Contact", {"email_id": data.get("contact_email")}) - if not contact_exists: - contact_doc = frappe.get_doc({ - "doctype": "Contact", - "first_name": data.get("first_name"), - "last_name": data.get("last_name"), - "email_id": data.get("email"), - "phone": data.get("phone_number"), - "custom_customer": customer_doc.name, - "links": [{ - "link_doctype": "Customer", - "link_name": customer_doc.name - }] - }).insert(ignore_permissions=True) - print("Created new contact:", contact_doc.as_dict()) - else: - contact_doc = frappe.get_doc("Contact", {"email_id": data.get("contact_email")}) - print("Contact already exists:", contact_doc.as_dict()) - address_doc.custom_contact = contact_doc.name + }) + + # Address -> Contact + print("#####DEBUG: Linking address to contacts.") + address_doc.custom_contact = next((c.name for c in contact_docs if c.is_primary_contact), contact_docs[0].name) + for contact_doc in contact_docs: + address_doc.append("custom_linked_contacts", { + "contact": contact_doc.name, + "email": contact_doc.email_id, + "phone": contact_doc.phone, + "role": contact_doc.role + }) + + # Contact -> Customer & Address + print("#####DEBUG: Linking contacts to customer.") + for contact_doc in contact_docs: + contact_doc.append("links", { + "link_doctype": "Customer", + "link_name": customer_doc.name + }) + contact_doc.append("links", { + "link_doctype": "Address", + "link_name": address_doc.name + }) + contact_doc.custom_customer = customer_doc.name + contact_doc.save(ignore_permissions=True) + address_doc.save(ignore_permissions=True) + customer_doc.save(ignore_permissions=True) + return build_success_response({ "customer": customer_doc.as_dict(), "address": address_doc.as_dict(), - "contact": contact_doc.as_dict() + "contacts": [contact_doc.as_dict() for contact_doc in contact_docs] }) except frappe.ValidationError as ve: return build_error_response(str(ve), 400) diff --git a/custom_ui/api/db/estimates.py b/custom_ui/api/db/estimates.py index d9ad39e..1e9b16a 100644 --- a/custom_ui/api/db/estimates.py +++ b/custom_ui/api/db/estimates.py @@ -1,5 +1,5 @@ import frappe, json -from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response +from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response # =============================================================================== # ESTIMATES & INVOICES API METHODS @@ -47,15 +47,72 @@ def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10): return build_success_response(table_data_dict) +@frappe.whitelist() +def get_quotation_items(): + """Get all available quotation items.""" + try: + items = frappe.get_all("Item", fields=["*"], filters={"item_group": "SNW-S"}) + return build_success_response(items) + except Exception as e: + return build_error_response(str(e), 500) + +@frappe.whitelist() +def get_estimate(estimate_name): + """Get detailed information for a specific estimate.""" + try: + estimate = frappe.get_doc("Quotation", estimate_name) + return build_success_response(estimate.as_dict()) + except Exception as e: + return build_error_response(str(e), 500) + @frappe.whitelist() def upsert_estimate(data): """Create or update an estimate.""" # TODO: Implement estimate creation/update logic pass +@frappe.whitelist() +def get_estimate_items(): + items = frappe.db.get_all("Quotation Item", fields=["*"]) + return build_success_response(items) @frappe.whitelist() -def upsert_invoice(data): - """Create or update an invoice.""" - # TODO: Implement invoice creation/update logic - pass +def get_estimate_from_address(full_address): + quotation = frappe.db.sql(""" + SELECT q.name, q.custom_installation_address + FROM `tabQuotation` q + JOIN `tabAddress` a + ON q.custom_installation_address = a.name + WHERE a.full_address =%s + """, (full_address,), as_dict=True) + if quotation: + return build_success_response(quotation) + else: + return build_error_response("No quotation found for the given address.", 404) + +@frappe.whitelist() +def upsert_estimate(data): + """Create or update an estimate.""" + print("DOIFJSEOFJISLFK") + try: + data = json.loads(data) if isinstance(data, str) else data + print("DEBUG: Upsert estimate data received:", data) + address_name = frappe.get_value("Address", fieldname="name", filters={"full_address": data.get("address")}) + new_estimate = frappe.get_doc({ + "doctype": "Quotation", + "custom_installation_address": address_name, + "contact_email": data.get("contact_email"), + "party_name": data.get("contact_name"), + "customer_name": data.get("customer_name"), + }) + for item in data.get("items", []): + item = json.loads(item) if isinstance(item, str) else item + new_estimate.append("items", { + "item_code": item.get("item_code"), + "qty": item.get("qty"), + }) + new_estimate.insert() + print("DEBUG: New estimate created with name:", new_estimate.name) + return build_success_response(new_estimate.as_dict()) + except Exception as e: + return build_error_response(str(e), 500) \ No newline at end of file diff --git a/custom_ui/api/db/onsite_meetings.py b/custom_ui/api/db/onsite_meetings.py index d5fc705..fddaf09 100644 --- a/custom_ui/api/db/onsite_meetings.py +++ b/custom_ui/api/db/onsite_meetings.py @@ -15,6 +15,9 @@ def get_week_onsite_meetings(week_start, week_end): ], order_by="start_time asc" ) + for meeting in meetings: + address_doc = frappe.get_doc("Address", meeting["address"]) + meeting["address"] = address_doc.as_dict() return build_success_response(meetings) except Exception as e: frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed") @@ -34,6 +37,9 @@ def get_onsite_meetings(fields=["*"], filters={}): filters=processed_filters, order_by="creation desc" ) + for meeting in meetings: + address_doc = frappe.get_doc("Address", meeting["address"]) + meeting["address"] = address_doc.as_dict() return build_success_response( meetings diff --git a/custom_ui/install.py b/custom_ui/install.py index cdcc50d..c814eba 100644 --- a/custom_ui/install.py +++ b/custom_ui/install.py @@ -115,6 +115,22 @@ def add_custom_fields(): insert_after="job_status" ) ], + "Contact": [ + dict( + fieldname="role", + label="Role", + fieldtype="Select", + options="Owner\nProperty Manager\nTenant\nBuilder\nNeighbor\nFamily Member\nRealtor\nOther", + insert_after="designation" + ), + dict( + fieldname="email", + label="Email", + fieldtype="Data", + insert_after="last_name", + options="Email" + ) + ], "On-Site Meeting": [ dict( fieldname="notes", diff --git a/frontend/src/api.js b/frontend/src/api.js index a273954..cc25e51 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -49,6 +49,42 @@ class Api { } } + static async getAddressByFullAddress(fullAddress) { + return await this.request("custom_ui.api.db.addresses.get_address_by_full_address", { + full_address: fullAddress, + }); + } + + static async getQuotationItems() { + return await this.request("custom_ui.api.db.estimates.get_quotation_items"); + } + + static async getEstimateFromAddress(fullAddress) { + return await this.request("custom_ui.api.db.estimates.get_estimate_from_address", { + full_address: fullAddress, + }); + } + + static async getAddress(fullAddress) { + return await this.request("custom_ui.api.db.addresses.get_address", { fullAddress }); + } + + static async getContactsForAddress(fullAddress) { + return await this.request("custom_ui.api.db.addresses.get_contacts_for_address", { + fullAddress, + }); + } + + static async getEstimate(estimateName) { + return await this.request("custom_ui.api.db.estimates.get_estimate", { + estimate_name: estimateName, + }); + } + + static async getEstimateItems() { + return await this.request("custom_ui.api.db.estimates.get_estimate_items"); + } + static async searchAddresses(searchTerm) { const filters = { full_address: ["like", `%${searchTerm}%`], @@ -201,7 +237,6 @@ class Api { const result = await this.request(FRAPPE_GET_ESTIMATES_METHOD, { options }); return result; - } /** @@ -352,9 +387,7 @@ class Api { } static async createEstimate(estimateData) { - const payload = DataUtils.toSnakeCaseObject(estimateData); - const result = await this.request(FRAPPE_UPSERT_ESTIMATE_METHOD, { data: payload }); - console.log("DEBUG: API - Created Estimate: ", result); + const result = await this.request(FRAPPE_UPSERT_ESTIMATE_METHOD, { data: estimateData }); return result; } diff --git a/frontend/src/components/SideBar.vue b/frontend/src/components/SideBar.vue index 4983c10..08750a5 100644 --- a/frontend/src/components/SideBar.vue +++ b/frontend/src/components/SideBar.vue @@ -54,7 +54,7 @@ const createButtons = ref([ label: "Estimate", command: () => { //frappe.new_doc("Estimate"); - router.push("/createEstimate/new"); + router.push("/estimate?new=true"); }, }, { diff --git a/frontend/src/components/clientSubPages/ClientInformationForm.vue b/frontend/src/components/clientSubPages/ClientInformationForm.vue index 04132ae..9be730e 100644 --- a/frontend/src/components/clientSubPages/ClientInformationForm.vue +++ b/frontend/src/components/clientSubPages/ClientInformationForm.vue @@ -2,10 +2,10 @@

Client Information

-
- - -
+
@@ -23,10 +23,9 @@ @click="searchCustomers" :disabled="isSubmitting || !localFormData.customerName.trim()" size="small" - class="iconoir-btn" - > - - + icon="pi pi-search" + class="search-btn" + >
@@ -77,11 +76,9 @@ import { ref, watch, computed } from "vue"; import InputText from "primevue/inputtext"; import Select from "primevue/select"; -import ToggleSwitch from "primevue/toggleswitch"; import Dialog from "primevue/dialog"; import Api from "../../api"; import { useNotificationStore } from "../../stores/notifications-primevue"; -import { DocMagnifyingGlass as IconoirMagnifyingGlass } from "@iconoir/vue"; const props = defineProps({ formData: { @@ -189,11 +186,12 @@ defineExpose({ .toggle-container { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.25rem; + font-size: 0.85rem; } .toggle-label { - font-size: 0.9rem; + font-size: 0.85rem; font-weight: 500; color: var(--text-color-secondary); cursor: pointer; @@ -308,6 +306,28 @@ defineExpose({ background: var(--surface-hover); } +.search-btn { + background: var(--primary-color); + border: 1px solid var(--primary-color); + padding: 0.25rem 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background 0.2s; + color: white; +} + +.search-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.search-btn:hover:not(:disabled) { + background: var(--surface-hover); +} + @media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; diff --git a/frontend/src/components/clientSubPages/ContactInformationForm.vue b/frontend/src/components/clientSubPages/ContactInformationForm.vue index 4728742..b6f69d7 100644 --- a/frontend/src/components/clientSubPages/ContactInformationForm.vue +++ b/frontend/src/components/clientSubPages/ContactInformationForm.vue @@ -2,111 +2,102 @@

Contact Information

-
- - -
- - - - - +
+
+
@@ -115,8 +106,7 @@ import { ref, watch, computed, onMounted } from "vue"; import InputText from "primevue/inputtext"; import Select from "primevue/select"; -import ToggleSwitch from "primevue/toggleswitch"; -import Checkbox from "primevue/checkbox"; +import Button from "primevue/button"; const props = defineProps({ formData: { @@ -135,131 +125,137 @@ const props = defineProps({ type: Boolean, default: false, }, - availableContacts: { - type: Array, - default: () => [], - }, }); -const emit = defineEmits(["update:formData", "newContactToggle"]); +const emit = defineEmits(["update:formData"]); const localFormData = computed({ - get: () => props.formData, + get: () => { + if (!props.formData.contacts || props.formData.contacts.length === 0) { + props.formData.contacts = [ + { + firstName: "", + lastName: "", + phoneNumber: "", + email: "", + contactRole: "", + isPrimary: true, + }, + ]; + } + return props.formData; + }, set: (value) => emit("update:formData", value), }); -// Default to true for new-client flows; if editing keep it off -const isNewContact = ref(!props.isEditMode); -const selectedContact = ref(null); -const sameAsClientName = ref(false); +const roleOptions = ref([ + { label: "Owner", value: "Owner" }, + { label: "Property Manager", value: "Property Manager" }, + { label: "Tenant", value: "Tenant" }, + { label: "Builder", value: "Builder" }, + { label: "Neighbor", value: "Neighbor" }, + { label: "Family Member", value: "Family Member" }, + { label: "Realtor", value: "Realtor" }, + { label: "Other", value: "Other" }, +]); -// Compute contact options from available contacts -const contactOptions = computed(() => { - if (!props.availableContacts || props.availableContacts.length === 0) { - return []; - } - - return props.availableContacts.map((contact) => ({ - label: `${contact.firstName} ${contact.lastName}`, - value: contact, - })); -}); - -// Ensure New Contact is ON and locked when New Client is ON -watch( - () => props.isNewClientLocked, - (locked) => { - if (locked) { - isNewContact.value = true; - } else { - isNewContact.value = false; - } - }, - { immediate: true }, -); - -// On mount, set isNewContact to true if isNewClientLocked is true +// Ensure at least one contact onMounted(() => { - if (props.isNewClientLocked) { - isNewContact.value = true; + if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) { + localFormData.value.contacts = [ + { + firstName: "", + lastName: "", + phoneNumber: "", + email: "", + contactRole: "", + isPrimary: true, + }, + ]; } }); -// Auto-check "Same as Client Name" when customer type is Individual -watch( - () => props.formData.customerType, - (customerType) => { - if (customerType === "Individual" && props.isNewClientLocked && !props.isEditMode) { - sameAsClientName.value = true; +const addContact = () => { + localFormData.value.contacts.push({ + firstName: "", + lastName: "", + phoneNumber: "", + email: "", + contactRole: "", + isPrimary: false, + }); +}; + +const removeContact = (index) => { + if (localFormData.value.contacts.length > 1) { + const wasPrimary = localFormData.value.contacts[index].isPrimary; + localFormData.value.contacts.splice(index, 1); + if (wasPrimary && localFormData.value.contacts.length > 0) { + localFormData.value.contacts[0].isPrimary = true; } - }, - { immediate: true }, -); - -// Reset "Same as Client Name" when editing or using existing customer -watch([() => props.isEditMode, () => props.isNewClientLocked], ([editMode, newClientLocked]) => { - if (editMode || !newClientLocked) { - sameAsClientName.value = false; - } -}); - -// Auto-fill name fields when "Same as Client Name" is checked -watch(sameAsClientName, (checked) => { - if (checked && props.formData.customerName) { - const nameParts = props.formData.customerName.trim().split(" "); - if (nameParts.length === 1) { - localFormData.value.firstName = nameParts[0]; - localFormData.value.lastName = ""; - } else if (nameParts.length >= 2) { - localFormData.value.firstName = nameParts[0]; - localFormData.value.lastName = nameParts.slice(1).join(" "); - } - } -}); - -// Watch for customer name changes when "Same as Client Name" is checked -watch( - () => props.formData.customerName, - (newName) => { - if (sameAsClientName.value && newName) { - const nameParts = newName.trim().split(" "); - if (nameParts.length === 1) { - localFormData.value.firstName = nameParts[0]; - localFormData.value.lastName = ""; - } else if (nameParts.length >= 2) { - localFormData.value.firstName = nameParts[0]; - localFormData.value.lastName = nameParts.slice(1).join(" "); - } - } - }, -); - -// Watch for toggle changes -watch(isNewContact, (newValue) => { - if (newValue) { - // Clear contact selection when switching to new contact mode - selectedContact.value = null; - localFormData.value.firstName = ""; - localFormData.value.lastName = ""; - localFormData.value.phoneNumber = ""; - localFormData.value.email = ""; - } - emit("newContactToggle", newValue); -}); - -const handleContactSelect = () => { - if (selectedContact.value && selectedContact.value.value) { - const contact = selectedContact.value.value; - localFormData.value.firstName = contact.firstName; - localFormData.value.lastName = contact.lastName; - localFormData.value.phoneNumber = contact.phone || ""; - localFormData.value.email = contact.email || ""; } }; -defineExpose({ - isNewContact, -}); +const setPrimary = (index) => { + localFormData.value.contacts.forEach((contact, i) => { + contact.isPrimary = i === index; + }); +}; + +const formatPhoneNumber = (value) => { + const digits = value.replace(/\D/g, "").slice(0, 10); + if (digits.length <= 3) return digits; + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +}; + +const formatPhone = (index, event) => { + const value = event.target.value; + const formatted = formatPhoneNumber(value); + localFormData.value.contacts[index].phoneNumber = formatted; +}; + +const handlePhoneKeydown = (event, index) => { + const allowedKeys = [ + "Backspace", + "Delete", + "Tab", + "Escape", + "Enter", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + ]; + + if (allowedKeys.includes(event.key)) { + return; + } + + // Allow Ctrl+A, Ctrl+C, Ctrl+V, etc. + if (event.ctrlKey || event.metaKey) { + return; + } + + // Check if it's a digit + if (!/\d/.test(event.key)) { + event.preventDefault(); + return; + } + + // Check current digit count + const currentDigits = localFormData.value.contacts[index].phoneNumber.replace( + /\D/g, + "", + ).length; + if (currentDigits >= 10) { + event.preventDefault(); + } +}; + +defineExpose({}); + diff --git a/frontend/src/main.js b/frontend/src/main.js index 1424341..fbfb926 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -5,6 +5,7 @@ import router from "./router"; import PrimeVue from "primevue/config"; import { globalSettings } from "./globalSettings"; import { createPinia } from "pinia"; +import 'primeicons/primeicons.css'; // Vuetify import "@primeuix/themes/aura"; diff --git a/frontend/src/router.js b/frontend/src/router.js index 2fd655e..edbdb44 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -13,6 +13,7 @@ import TestDateForm from "./components/pages/TestDateForm.vue"; import Client from "./components/pages/Client.vue"; import ErrorHandlingDemo from "./components/pages/ErrorHandlingDemo.vue"; import ScheduleOnSite from "./components/pages/ScheduleOnSite.vue"; +import Estimate from "./components/pages/Estimate.vue"; const routes = [ { @@ -25,6 +26,7 @@ const routes = [ { path: "/schedule-onsite", component: ScheduleOnSite }, { path: "/jobs", component: Jobs }, { path: "/estimates", component: Estimates }, + { path: "/estimate", component: Estimate }, { path: "/routes", component: Routes }, { path: "/create", component: Create }, { path: "/timesheets", component: TimeSheets }, diff --git a/frontend/src/style.css b/frontend/src/style.css index 7bfb5af..5cafc24 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -56,18 +56,19 @@ gap: 5px; } -/* Fix ToggleSwitch z-index so slider is visible but input receives clicks */ -.p-toggleswitch { - position: relative; +/* Vuetify Switch and Checkbox Styling */ +.v-switch { + align-self: center; + transform: scale(0.85); + transform-origin: center; } -.p-toggleswitch-slider { - position: relative; - z-index: 0; - pointer-events: none; +.v-switch .v-switch__thumb { + pointer-events: auto !important; /* Make thumb clickable */ } -.p-toggleswitch-input { - position: absolute; - z-index: 1; +.v-checkbox { + align-self: center; + transform: scale(0.75); + transform-origin: center; }