create estimate
This commit is contained in:
parent
2ea20a86e3
commit
afa161a0cf
@ -1,6 +1,43 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from custom_ui.db_utils import build_error_response, build_success_response
|
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()
|
@frappe.whitelist()
|
||||||
def get_addresses(fields=["*"], filters={}):
|
def get_addresses(fields=["*"], filters={}):
|
||||||
"""Get addresses with optional filtering."""
|
"""Get addresses with optional filtering."""
|
||||||
|
|||||||
@ -92,97 +92,46 @@ def get_client_status_counts(weekly=False, week_start_date=None, week_end_date=N
|
|||||||
def get_client(client_name):
|
def get_client(client_name):
|
||||||
"""Get detailed information for a specific client including address, customer, and projects."""
|
"""Get detailed information for a specific client including address, customer, and projects."""
|
||||||
try:
|
try:
|
||||||
clientData = {"addresses": []}
|
clientData = {"addresses": [], "contacts": [], "jobs": [], "sales_invoices": [], "payment_entries": [], "sales_orders": [], "tasks": []}
|
||||||
customer = frappe.get_doc("Customer", client_name)
|
customer = frappe.get_doc("Customer", client_name)
|
||||||
clientData = {**clientData, **customer.as_dict()}
|
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})
|
for contact_link in customer.custom_add_contacts:
|
||||||
clientData["contacts"] = contacts
|
contact_doc = frappe.get_doc("Contact", contact_link.contact)
|
||||||
for address in addresses if addresses else []:
|
clientData["contacts"].append(contact_doc.as_dict())
|
||||||
addressData = {"jobs": []}
|
|
||||||
addressData = {**addressData, **address}
|
for address_link in customer.custom_select_address:
|
||||||
addressData["estimates"] = frappe.db.get_all("Quotation", fields=["*"], filters={"custom_installation_address": address.address_title})
|
address_doc = frappe.get_doc("Address", address_link.address_name)
|
||||||
addressData["onsite_meetings"] = frappe.db.get_all("On-Site Meeting", fields=["*"], filters={"address": address.address_title})
|
# # addressData = {"jobs": [], "contacts": []}
|
||||||
jobs = frappe.db.get_all("Project", fields=["*"], or_filters=[
|
# addressData = {**addressData, **address_doc.as_dict()}
|
||||||
["custom_installation_address", "=", address.address_title],
|
# addressData["estimates"] = frappe.db.get_all("Quotation", fields=["*"], filters={"custom_installation_address": address_doc.address_title})
|
||||||
["custom_address", "=", address.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=[
|
||||||
for job in jobs if jobs else []:
|
# ["custom_installation_address", "=", address.address_title],
|
||||||
jobData = {}
|
# ["custom_address", "=", address.address_title]
|
||||||
jobData = {**jobData, **job}
|
# ])
|
||||||
jobData["sales_invoices"] = frappe.db.get_all("Sales Invoice", fields=["*"], filters={"project": job.name})
|
# for job in jobs if jobs else []:
|
||||||
jobData["payment_entries"] = frappe.db.get_all(
|
# jobData = {}
|
||||||
"Payment Entry",
|
# jobData = {**jobData, **job}
|
||||||
fields=["*"],
|
# jobData["sales_invoices"] = frappe.db.get_all("Sales Invoice", fields=["*"], filters={"project": job.name})
|
||||||
filters={"party_type": "Customer"},
|
# jobData["payment_entries"] = frappe.db.get_all(
|
||||||
or_filters=[
|
# "Payment Entry",
|
||||||
["party", "=", client_name],
|
# fields=["*"],
|
||||||
["party_name", "=", client_name]
|
# filters={"party_type": "Customer"},
|
||||||
])
|
# or_filters=[
|
||||||
jobData["sales_orders"] = frappe.db.get_all("Sales Order", fields=["*"], filters={"project": job.name})
|
# ["party", "=", client_name],
|
||||||
jobData["tasks"] = frappe.db.get_all("Task", fields=["*"], filters={"project": job.name})
|
# ["party_name", "=", client_name]
|
||||||
addressData["jobs"].append(jobData)
|
# ])
|
||||||
clientData["addresses"].append(addressData)
|
# 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)
|
return build_success_response(clientData)
|
||||||
except frappe.ValidationError as ve:
|
except frappe.ValidationError as ve:
|
||||||
return build_error_response(str(ve), 400)
|
return build_error_response(str(ve), 400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return build_error_response(str(e), 500)
|
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()
|
@frappe.whitelist()
|
||||||
def get_clients_table_data(filters={}, sortings=[], page=1, page_size=10):
|
def get_clients_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||||
"""Get paginated client table data with filtering and sorting support."""
|
"""Get paginated client table data with filtering and sorting support."""
|
||||||
@ -253,10 +202,11 @@ def get_clients_table_data(filters={}, sortings=[], page=1, page_size=10):
|
|||||||
def upsert_client(data):
|
def upsert_client(data):
|
||||||
"""Create or update a client (customer and address)."""
|
"""Create or update a client (customer and address)."""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
data = json.loads(data)
|
data = json.loads(data)
|
||||||
|
|
||||||
# Handle customer creation/update
|
# 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")})
|
customer = frappe.db.exists("Customer", {"customer_name": data.get("customer_name")})
|
||||||
if not customer:
|
if not customer:
|
||||||
customer_doc = frappe.get_doc({
|
customer_doc = frappe.get_doc({
|
||||||
@ -266,21 +216,20 @@ def upsert_client(data):
|
|||||||
}).insert(ignore_permissions=True)
|
}).insert(ignore_permissions=True)
|
||||||
else:
|
else:
|
||||||
customer_doc = frappe.get_doc("Customer", data.get("customer_name"))
|
customer_doc = frappe.get_doc("Customer", data.get("customer_name"))
|
||||||
|
|
||||||
print("Customer:", customer_doc.as_dict())
|
print("Customer:", customer_doc.as_dict())
|
||||||
|
|
||||||
# Check for existing address
|
# Handle address creation
|
||||||
filters = {
|
print("#####DEBUG: Checking for existing address for customer:", data.get("customer_name"))
|
||||||
"address_line1": data.get("address_line1"),
|
existing_address = frappe.db.exists(
|
||||||
"city": data.get("city"),
|
"Address",
|
||||||
"state": data.get("state"),
|
{
|
||||||
}
|
"address_line1": data.get("address_line1"),
|
||||||
existing_address = frappe.db.exists("Address", filters)
|
"city": data.get("city"),
|
||||||
|
"state": data.get("state"),
|
||||||
|
})
|
||||||
print("Existing address check:", existing_address)
|
print("Existing address check:", existing_address)
|
||||||
if existing_address:
|
if existing_address:
|
||||||
frappe.throw(f"Address already exists for customer {data.get('customer_name')}.", frappe.ValidationError)
|
frappe.throw(f"Address already exists for customer {data.get('customer_name')}.", frappe.ValidationError)
|
||||||
|
|
||||||
# Create address
|
|
||||||
address_doc = frappe.get_doc({
|
address_doc = frappe.get_doc({
|
||||||
"doctype": "Address",
|
"doctype": "Address",
|
||||||
"address_title": data.get("address_title"),
|
"address_title": data.get("address_title"),
|
||||||
@ -292,37 +241,97 @@ def upsert_client(data):
|
|||||||
"pincode": data.get("pincode"),
|
"pincode": data.get("pincode"),
|
||||||
"custom_customer_to_bill": customer_doc.name
|
"custom_customer_to_bill": customer_doc.name
|
||||||
}).insert(ignore_permissions=True)
|
}).insert(ignore_permissions=True)
|
||||||
|
print("Address:", address_doc.as_dict())
|
||||||
|
|
||||||
# Link address to customer
|
#Handle contact creation
|
||||||
link = {
|
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_doctype": "Customer",
|
||||||
"link_name": customer_doc.name
|
"link_name": customer_doc.name
|
||||||
}
|
})
|
||||||
address_doc.append("links", link)
|
|
||||||
contact_exists = frappe.db.exists("Contact", {"email_id": data.get("contact_email")})
|
# Address -> Contact
|
||||||
if not contact_exists:
|
print("#####DEBUG: Linking address to contacts.")
|
||||||
contact_doc = frappe.get_doc({
|
address_doc.custom_contact = next((c.name for c in contact_docs if c.is_primary_contact), contact_docs[0].name)
|
||||||
"doctype": "Contact",
|
for contact_doc in contact_docs:
|
||||||
"first_name": data.get("first_name"),
|
address_doc.append("custom_linked_contacts", {
|
||||||
"last_name": data.get("last_name"),
|
"contact": contact_doc.name,
|
||||||
"email_id": data.get("email"),
|
"email": contact_doc.email_id,
|
||||||
"phone": data.get("phone_number"),
|
"phone": contact_doc.phone,
|
||||||
"custom_customer": customer_doc.name,
|
"role": contact_doc.role
|
||||||
"links": [{
|
})
|
||||||
"link_doctype": "Customer",
|
|
||||||
"link_name": customer_doc.name
|
# Contact -> Customer & Address
|
||||||
}]
|
print("#####DEBUG: Linking contacts to customer.")
|
||||||
}).insert(ignore_permissions=True)
|
for contact_doc in contact_docs:
|
||||||
print("Created new contact:", contact_doc.as_dict())
|
contact_doc.append("links", {
|
||||||
else:
|
"link_doctype": "Customer",
|
||||||
contact_doc = frappe.get_doc("Contact", {"email_id": data.get("contact_email")})
|
"link_name": customer_doc.name
|
||||||
print("Contact already exists:", contact_doc.as_dict())
|
})
|
||||||
address_doc.custom_contact = contact_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)
|
address_doc.save(ignore_permissions=True)
|
||||||
|
customer_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
return build_success_response({
|
return build_success_response({
|
||||||
"customer": customer_doc.as_dict(),
|
"customer": customer_doc.as_dict(),
|
||||||
"address": address_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:
|
except frappe.ValidationError as ve:
|
||||||
return build_error_response(str(ve), 400)
|
return build_error_response(str(ve), 400)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import frappe, json
|
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
|
# 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)
|
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()
|
@frappe.whitelist()
|
||||||
def upsert_estimate(data):
|
def upsert_estimate(data):
|
||||||
"""Create or update an estimate."""
|
"""Create or update an estimate."""
|
||||||
# TODO: Implement estimate creation/update logic
|
# TODO: Implement estimate creation/update logic
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_estimate_items():
|
||||||
|
items = frappe.db.get_all("Quotation Item", fields=["*"])
|
||||||
|
return build_success_response(items)
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def upsert_invoice(data):
|
def get_estimate_from_address(full_address):
|
||||||
"""Create or update an invoice."""
|
quotation = frappe.db.sql("""
|
||||||
# TODO: Implement invoice creation/update logic
|
SELECT q.name, q.custom_installation_address
|
||||||
pass
|
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)
|
||||||
@ -15,6 +15,9 @@ def get_week_onsite_meetings(week_start, week_end):
|
|||||||
],
|
],
|
||||||
order_by="start_time asc"
|
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)
|
return build_success_response(meetings)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed")
|
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,
|
filters=processed_filters,
|
||||||
order_by="creation desc"
|
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(
|
return build_success_response(
|
||||||
meetings
|
meetings
|
||||||
|
|||||||
@ -115,6 +115,22 @@ def add_custom_fields():
|
|||||||
insert_after="job_status"
|
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": [
|
"On-Site Meeting": [
|
||||||
dict(
|
dict(
|
||||||
fieldname="notes",
|
fieldname="notes",
|
||||||
|
|||||||
@ -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) {
|
static async searchAddresses(searchTerm) {
|
||||||
const filters = {
|
const filters = {
|
||||||
full_address: ["like", `%${searchTerm}%`],
|
full_address: ["like", `%${searchTerm}%`],
|
||||||
@ -201,7 +237,6 @@ class Api {
|
|||||||
|
|
||||||
const result = await this.request(FRAPPE_GET_ESTIMATES_METHOD, { options });
|
const result = await this.request(FRAPPE_GET_ESTIMATES_METHOD, { options });
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -352,9 +387,7 @@ class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async createEstimate(estimateData) {
|
static async createEstimate(estimateData) {
|
||||||
const payload = DataUtils.toSnakeCaseObject(estimateData);
|
const result = await this.request(FRAPPE_UPSERT_ESTIMATE_METHOD, { data: estimateData });
|
||||||
const result = await this.request(FRAPPE_UPSERT_ESTIMATE_METHOD, { data: payload });
|
|
||||||
console.log("DEBUG: API - Created Estimate: ", result);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,7 @@ const createButtons = ref([
|
|||||||
label: "Estimate",
|
label: "Estimate",
|
||||||
command: () => {
|
command: () => {
|
||||||
//frappe.new_doc("Estimate");
|
//frappe.new_doc("Estimate");
|
||||||
router.push("/createEstimate/new");
|
router.push("/estimate?new=true");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3>Client Information</h3>
|
<h3>Client Information</h3>
|
||||||
<div class="toggle-container" v-if="!isEditMode">
|
<label class="toggle-container" v-if="!isEditMode">
|
||||||
<label for="new-client-toggle" class="toggle-label">New Client</label>
|
<v-switch v-model="isNewClient" color="success" />
|
||||||
<ToggleSwitch v-model="isNewClient" inputId="new-client-toggle" />
|
<span class="toggle-label">New Client</span>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
@ -23,10 +23,9 @@
|
|||||||
@click="searchCustomers"
|
@click="searchCustomers"
|
||||||
:disabled="isSubmitting || !localFormData.customerName.trim()"
|
:disabled="isSubmitting || !localFormData.customerName.trim()"
|
||||||
size="small"
|
size="small"
|
||||||
class="iconoir-btn"
|
icon="pi pi-search"
|
||||||
>
|
class="search-btn"
|
||||||
<IconoirMagnifyingGlass width="20" height="20" />
|
></Button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
@ -77,11 +76,9 @@
|
|||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch, computed } from "vue";
|
||||||
import InputText from "primevue/inputtext";
|
import InputText from "primevue/inputtext";
|
||||||
import Select from "primevue/select";
|
import Select from "primevue/select";
|
||||||
import ToggleSwitch from "primevue/toggleswitch";
|
|
||||||
import Dialog from "primevue/dialog";
|
import Dialog from "primevue/dialog";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
import { DocMagnifyingGlass as IconoirMagnifyingGlass } from "@iconoir/vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
formData: {
|
formData: {
|
||||||
@ -189,11 +186,12 @@ defineExpose({
|
|||||||
.toggle-container {
|
.toggle-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-label {
|
.toggle-label {
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -308,6 +306,28 @@ defineExpose({
|
|||||||
background: var(--surface-hover);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@ -2,111 +2,102 @@
|
|||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3>Contact Information</h3>
|
<h3>Contact Information</h3>
|
||||||
<div class="toggle-container" v-if="!isEditMode">
|
|
||||||
<label for="new-contact-toggle" class="toggle-label">New Contact</label>
|
|
||||||
<ToggleSwitch
|
|
||||||
v-model="isNewContact"
|
|
||||||
inputId="new-contact-toggle"
|
|
||||||
:disabled="isNewClientLocked"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- Select existing contact mode -->
|
<div
|
||||||
<template v-if="!isNewContact">
|
v-for="(contact, index) in localFormData.contacts"
|
||||||
<div class="form-field full-width">
|
:key="index"
|
||||||
<label for="contact-select"> Contact <span class="required">*</span> </label>
|
class="contact-item"
|
||||||
<Select
|
>
|
||||||
id="contact-select"
|
<div class="contact-header">
|
||||||
v-model="selectedContact"
|
<h4>Contact {{ index + 1 }}</h4>
|
||||||
:options="contactOptions"
|
<Button
|
||||||
optionLabel="label"
|
v-if="localFormData.contacts.length > 1"
|
||||||
:disabled="isSubmitting || contactOptions.length === 0"
|
@click="removeContact(index)"
|
||||||
placeholder="Select a contact"
|
size="small"
|
||||||
class="w-full"
|
severity="danger"
|
||||||
@change="handleContactSelect"
|
label="Delete"
|
||||||
/>
|
class="remove-btn"
|
||||||
<small v-if="contactOptions.length === 0" class="helper-text">
|
|
||||||
No contacts available. Toggle "New Contact" to add one.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label for="contact-phone">Phone</label>
|
|
||||||
<InputText
|
|
||||||
id="contact-phone"
|
|
||||||
v-model="localFormData.phoneNumber"
|
|
||||||
disabled
|
|
||||||
class="w-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-rows">
|
||||||
<label for="contact-email">Email</label>
|
<div class="form-row">
|
||||||
<InputText
|
<div class="form-field">
|
||||||
id="contact-email"
|
<label :for="`first-name-${index}`">
|
||||||
v-model="localFormData.email"
|
First Name <span class="required">*</span>
|
||||||
disabled
|
</label>
|
||||||
class="w-full"
|
<InputText
|
||||||
/>
|
:id="`first-name-${index}`"
|
||||||
</div>
|
v-model="contact.firstName"
|
||||||
</template>
|
:disabled="isSubmitting"
|
||||||
|
placeholder="Enter first name"
|
||||||
<!-- New contact mode -->
|
class="w-full"
|
||||||
<template v-else>
|
/>
|
||||||
<div class="form-field full-width">
|
</div>
|
||||||
<div class="checkbox-container">
|
<div class="form-field">
|
||||||
<Checkbox
|
<label :for="`last-name-${index}`">
|
||||||
v-model="sameAsClientName"
|
Last Name <span class="required">*</span>
|
||||||
inputId="same-as-client"
|
</label>
|
||||||
:binary="true"
|
<InputText
|
||||||
:disabled="isSubmitting || isEditMode || !isNewClientLocked"
|
:id="`last-name-${index}`"
|
||||||
/>
|
v-model="contact.lastName"
|
||||||
<label for="same-as-client" class="checkbox-label">
|
:disabled="isSubmitting"
|
||||||
Same as Client Name
|
placeholder="Enter last name"
|
||||||
</label>
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label :for="`contact-role-${index}`">Role</label>
|
||||||
|
<Select
|
||||||
|
:id="`contact-role-${index}`"
|
||||||
|
v-model="contact.contactRole"
|
||||||
|
:options="roleOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
placeholder="Select a role"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-field">
|
||||||
|
<label :for="`email-${index}`">Email</label>
|
||||||
|
<InputText
|
||||||
|
:id="`email-${index}`"
|
||||||
|
v-model="contact.email"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
type="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label :for="`phone-number-${index}`">Phone</label>
|
||||||
|
<InputText
|
||||||
|
:id="`phone-number-${index}`"
|
||||||
|
v-model="contact.phoneNumber"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
placeholder="(555) 123-4567"
|
||||||
|
class="w-full"
|
||||||
|
@input="formatPhone(index, $event)"
|
||||||
|
@keydown="handlePhoneKeydown($event, index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="contact.isPrimary"
|
||||||
|
label="Primary Contact"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@change="setPrimary(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
</div>
|
||||||
<label for="first-name"> First Name <span class="required">*</span> </label>
|
<div class="form-field full-width">
|
||||||
<InputText
|
<Button label="Add another contact" @click="addContact" :disabled="isSubmitting" />
|
||||||
id="first-name"
|
</div>
|
||||||
v-model="localFormData.firstName"
|
|
||||||
:disabled="isSubmitting || sameAsClientName"
|
|
||||||
placeholder="Enter first name"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label for="last-name"> Last Name <span class="required">*</span> </label>
|
|
||||||
<InputText
|
|
||||||
id="last-name"
|
|
||||||
v-model="localFormData.lastName"
|
|
||||||
:disabled="isSubmitting || sameAsClientName"
|
|
||||||
placeholder="Enter last name"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label for="phone-number">Phone</label>
|
|
||||||
<InputText
|
|
||||||
id="phone-number"
|
|
||||||
v-model="localFormData.phoneNumber"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
placeholder="(555) 123-4567"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-field">
|
|
||||||
<label for="email">Email</label>
|
|
||||||
<InputText
|
|
||||||
id="email"
|
|
||||||
v-model="localFormData.email"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
type="email"
|
|
||||||
placeholder="email@example.com"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -115,8 +106,7 @@
|
|||||||
import { ref, watch, computed, onMounted } from "vue";
|
import { ref, watch, computed, onMounted } from "vue";
|
||||||
import InputText from "primevue/inputtext";
|
import InputText from "primevue/inputtext";
|
||||||
import Select from "primevue/select";
|
import Select from "primevue/select";
|
||||||
import ToggleSwitch from "primevue/toggleswitch";
|
import Button from "primevue/button";
|
||||||
import Checkbox from "primevue/checkbox";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
formData: {
|
formData: {
|
||||||
@ -135,131 +125,137 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
availableContacts: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:formData", "newContactToggle"]);
|
const emit = defineEmits(["update:formData"]);
|
||||||
|
|
||||||
const localFormData = computed({
|
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),
|
set: (value) => emit("update:formData", value),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default to true for new-client flows; if editing keep it off
|
const roleOptions = ref([
|
||||||
const isNewContact = ref(!props.isEditMode);
|
{ label: "Owner", value: "Owner" },
|
||||||
const selectedContact = ref(null);
|
{ label: "Property Manager", value: "Property Manager" },
|
||||||
const sameAsClientName = ref(false);
|
{ 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
|
// Ensure at least one contact
|
||||||
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
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.isNewClientLocked) {
|
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
|
||||||
isNewContact.value = true;
|
localFormData.value.contacts = [
|
||||||
|
{
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
email: "",
|
||||||
|
contactRole: "",
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-check "Same as Client Name" when customer type is Individual
|
const addContact = () => {
|
||||||
watch(
|
localFormData.value.contacts.push({
|
||||||
() => props.formData.customerType,
|
firstName: "",
|
||||||
(customerType) => {
|
lastName: "",
|
||||||
if (customerType === "Individual" && props.isNewClientLocked && !props.isEditMode) {
|
phoneNumber: "",
|
||||||
sameAsClientName.value = true;
|
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({
|
const setPrimary = (index) => {
|
||||||
isNewContact,
|
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({});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -285,23 +281,47 @@ defineExpose({
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-container {
|
.contact-item {
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--surface-section);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-label {
|
.contact-header h4 {
|
||||||
font-size: 0.9rem;
|
margin: 0;
|
||||||
font-weight: 500;
|
color: var(--text-color);
|
||||||
color: var(--text-color-secondary);
|
font-size: 1.1rem;
|
||||||
cursor: pointer;
|
font-weight: 600;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.remove-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item .form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,25 +345,6 @@ defineExpose({
|
|||||||
color: var(--red-500);
|
color: var(--red-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.helper-text {
|
|
||||||
color: var(--text-color-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-color-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-full {
|
.w-full {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,20 +92,35 @@
|
|||||||
<!-- Contact Info Card -->
|
<!-- Contact Info Card -->
|
||||||
<div class="info-card" v-if="selectedAddressData">
|
<div class="info-card" v-if="selectedAddressData">
|
||||||
<h3>Contact Information</h3>
|
<h3>Contact Information</h3>
|
||||||
<div class="info-grid">
|
<template v-if="contactsForAddress.length > 0">
|
||||||
<div class="info-item">
|
<div v-if="contactsForAddress.length > 1" class="contact-selector">
|
||||||
<label>Contact Name:</label>
|
<Dropdown
|
||||||
<span>{{ contactFullName }}</span>
|
v-model="selectedContactIndex"
|
||||||
|
:options="contactOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
placeholder="Select Contact"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-grid">
|
||||||
<label>Phone:</label>
|
<div class="info-item">
|
||||||
<span>{{ selectedAddressData.phone || "N/A" }}</span>
|
<label>Contact Name:</label>
|
||||||
|
<span>{{ contactFullName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Phone:</label>
|
||||||
|
<span>{{ primaryContactPhone }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Email:</label>
|
||||||
|
<span>{{ primaryContactEmail }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
</template>
|
||||||
<label>Email:</label>
|
<template v-else>
|
||||||
<span>{{ selectedAddressData.emailId || "N/A" }}</span>
|
<p>No contacts available for this address.</p>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -113,30 +128,34 @@
|
|||||||
<div class="status-cards" v-if="!isNew && !editMode && selectedAddressData">
|
<div class="status-cards" v-if="!isNew && !editMode && selectedAddressData">
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<h4>On-Site Meeting</h4>
|
<h4>On-Site Meeting</h4>
|
||||||
<Badge
|
<Button
|
||||||
:value="selectedAddressData.customOnsiteMeetingScheduled || 'Not Started'"
|
:label="selectedAddressData.customOnsiteMeetingScheduled || 'Not Started'"
|
||||||
:severity="getStatusSeverity(selectedAddressData.customOnsiteMeetingScheduled)"
|
:severity="getStatusSeverity(selectedAddressData.customOnsiteMeetingScheduled)"
|
||||||
|
@click="handleStatusClick('onsite')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<h4>Estimate Sent</h4>
|
<h4>Estimate Sent</h4>
|
||||||
<Badge
|
<Button
|
||||||
:value="selectedAddressData.customEstimateSentStatus || 'Not Started'"
|
:label="selectedAddressData.customEstimateSentStatus || 'Not Started'"
|
||||||
:severity="getStatusSeverity(selectedAddressData.customEstimateSentStatus)"
|
:severity="getStatusSeverity(selectedAddressData.customEstimateSentStatus)"
|
||||||
|
@click="handleStatusClick('estimate')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<h4>Job Status</h4>
|
<h4>Job Status</h4>
|
||||||
<Badge
|
<Button
|
||||||
:value="selectedAddressData.customJobStatus || 'Not Started'"
|
:label="selectedAddressData.customJobStatus || 'Not Started'"
|
||||||
:severity="getStatusSeverity(selectedAddressData.customJobStatus)"
|
:severity="getStatusSeverity(selectedAddressData.customJobStatus)"
|
||||||
|
@click="handleStatusClick('job')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<h4>Payment Received</h4>
|
<h4>Payment Received</h4>
|
||||||
<Badge
|
<Button
|
||||||
:value="selectedAddressData.customPaymentReceivedStatus || 'Not Started'"
|
:label="selectedAddressData.customPaymentReceivedStatus || 'Not Started'"
|
||||||
:severity="getStatusSeverity(selectedAddressData.customPaymentReceivedStatus)"
|
:severity="getStatusSeverity(selectedAddressData.customPaymentReceivedStatus)"
|
||||||
|
@click="handleStatusClick('payment')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -204,6 +223,7 @@ import { computed, ref, watch, onMounted } from "vue";
|
|||||||
import Badge from "primevue/badge";
|
import Badge from "primevue/badge";
|
||||||
import Button from "primevue/button";
|
import Button from "primevue/button";
|
||||||
import Dialog from "primevue/dialog";
|
import Dialog from "primevue/dialog";
|
||||||
|
import Dropdown from "primevue/dropdown";
|
||||||
import LeafletMap from "../common/LeafletMap.vue";
|
import LeafletMap from "../common/LeafletMap.vue";
|
||||||
import ClientInformationForm from "./ClientInformationForm.vue";
|
import ClientInformationForm from "./ClientInformationForm.vue";
|
||||||
import ContactInformationForm from "./ContactInformationForm.vue";
|
import ContactInformationForm from "./ContactInformationForm.vue";
|
||||||
@ -252,10 +272,7 @@ const formData = ref({
|
|||||||
pincode: "",
|
pincode: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
firstName: "",
|
contacts: [],
|
||||||
lastName: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
email: "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize form data when component mounts
|
// Initialize form data when component mounts
|
||||||
@ -335,12 +352,51 @@ const fullAddress = computed(() => {
|
|||||||
return DataUtils.calculateFullAddress(selectedAddressData.value);
|
return DataUtils.calculateFullAddress(selectedAddressData.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get contacts linked to the selected address
|
||||||
|
const contactsForAddress = computed(() => {
|
||||||
|
if (!selectedAddressData.value?.customLinkedContacts || !props.clientData?.contacts) return [];
|
||||||
|
return selectedAddressData.value.customLinkedContacts
|
||||||
|
.map((link) => props.clientData.contacts.find((c) => c.name === link.contact))
|
||||||
|
.filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selected contact index for display
|
||||||
|
const selectedContactIndex = ref(0);
|
||||||
|
|
||||||
|
// Options for contact dropdown
|
||||||
|
const contactOptions = computed(() =>
|
||||||
|
contactsForAddress.value.map((c, i) => ({
|
||||||
|
label:
|
||||||
|
c.fullName || `${c.firstName || ""} ${c.lastName || ""}`.trim() || "Unnamed Contact",
|
||||||
|
value: i,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selected contact for display
|
||||||
|
const selectedContact = computed(
|
||||||
|
() => contactsForAddress.value[selectedContactIndex.value] || null,
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate contact full name
|
// Calculate contact full name
|
||||||
const contactFullName = computed(() => {
|
const contactFullName = computed(() => {
|
||||||
if (!selectedAddressData.value) return "N/A";
|
if (!selectedContact.value) return "N/A";
|
||||||
const firstName = selectedAddressData.value.customContactFirstName || "";
|
return (
|
||||||
const lastName = selectedAddressData.value.customContactLastName || "";
|
selectedContact.value.fullName ||
|
||||||
return `${firstName} ${lastName}`.trim() || "N/A";
|
`${selectedContact.value.firstName || ""} ${selectedContact.value.lastName || ""}`.trim() ||
|
||||||
|
"N/A"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate primary contact phone
|
||||||
|
const primaryContactPhone = computed(() => {
|
||||||
|
if (!selectedContact.value) return "N/A";
|
||||||
|
return selectedContact.value.phone || selectedContact.value.mobileNo || "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate primary contact email
|
||||||
|
const primaryContactEmail = computed(() => {
|
||||||
|
if (!selectedContact.value) return "N/A";
|
||||||
|
return selectedContact.value.emailId || selectedContact.value.customEmail || "N/A";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
@ -352,8 +408,10 @@ const isFormValid = computed(() => {
|
|||||||
const hasPincode = formData.value.pincode?.trim();
|
const hasPincode = formData.value.pincode?.trim();
|
||||||
const hasCity = formData.value.city?.trim();
|
const hasCity = formData.value.city?.trim();
|
||||||
const hasState = formData.value.state?.trim();
|
const hasState = formData.value.state?.trim();
|
||||||
const hasFirstName = formData.value.firstName?.trim();
|
const hasContacts = formData.value.contacts && formData.value.contacts.length > 0;
|
||||||
const hasLastName = formData.value.lastName?.trim();
|
const primaryContact = formData.value.contacts?.find((c) => c.isPrimary);
|
||||||
|
const hasFirstName = primaryContact?.firstName?.trim();
|
||||||
|
const hasLastName = primaryContact?.lastName?.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
hasCustomerName &&
|
hasCustomerName &&
|
||||||
@ -363,6 +421,7 @@ const isFormValid = computed(() => {
|
|||||||
hasPincode &&
|
hasPincode &&
|
||||||
hasCity &&
|
hasCity &&
|
||||||
hasState &&
|
hasState &&
|
||||||
|
hasContacts &&
|
||||||
hasFirstName &&
|
hasFirstName &&
|
||||||
hasLastName
|
hasLastName
|
||||||
);
|
);
|
||||||
@ -372,7 +431,7 @@ const isFormValid = computed(() => {
|
|||||||
const getStatusSeverity = (status) => {
|
const getStatusSeverity = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "Not Started":
|
case "Not Started":
|
||||||
return "secondary";
|
return "danger";
|
||||||
case "In Progress":
|
case "In Progress":
|
||||||
return "warn";
|
return "warn";
|
||||||
case "Completed":
|
case "Completed":
|
||||||
@ -382,6 +441,40 @@ const getStatusSeverity = (status) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle status button clicks
|
||||||
|
const handleStatusClick = (type) => {
|
||||||
|
let status;
|
||||||
|
let path;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "onsite":
|
||||||
|
status = selectedAddressData.value.customOnsiteMeetingScheduled || "Not Started";
|
||||||
|
path = "/schedule-onsite";
|
||||||
|
break;
|
||||||
|
case "estimate":
|
||||||
|
status = selectedAddressData.value.customEstimateSentStatus || "Not Started";
|
||||||
|
path = "/estimate";
|
||||||
|
break;
|
||||||
|
case "job":
|
||||||
|
status = selectedAddressData.value.customJobStatus || "Not Started";
|
||||||
|
path = "/job";
|
||||||
|
break;
|
||||||
|
case "payment":
|
||||||
|
status = selectedAddressData.value.customPaymentReceivedStatus || "Not Started";
|
||||||
|
path = "/invoices";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = { address: fullAddress.value };
|
||||||
|
if (status === "Not Started") {
|
||||||
|
query.new = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({ path, query });
|
||||||
|
};
|
||||||
|
|
||||||
// Form methods
|
// Form methods
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
formData.value = {
|
formData.value = {
|
||||||
@ -393,10 +486,7 @@ const resetForm = () => {
|
|||||||
pincode: "",
|
pincode: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
firstName: "",
|
contacts: [],
|
||||||
lastName: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
email: "",
|
|
||||||
};
|
};
|
||||||
availableContacts.value = [];
|
availableContacts.value = [];
|
||||||
isNewClientMode.value = false;
|
isNewClientMode.value = false;
|
||||||
@ -416,15 +506,20 @@ const populateFormFromClientData = () => {
|
|||||||
pincode: selectedAddressData.value.pincode || "",
|
pincode: selectedAddressData.value.pincode || "",
|
||||||
city: selectedAddressData.value.city || "",
|
city: selectedAddressData.value.city || "",
|
||||||
state: selectedAddressData.value.state || "",
|
state: selectedAddressData.value.state || "",
|
||||||
firstName: selectedAddressData.value.customContactFirstName || "",
|
contacts:
|
||||||
lastName: selectedAddressData.value.customContactLastName || "",
|
contactsForAddress.value.map((c) => ({
|
||||||
phoneNumber: selectedAddressData.value.phone || "",
|
firstName: c.firstName || "",
|
||||||
email: selectedAddressData.value.emailId || "",
|
lastName: c.lastName || "",
|
||||||
|
phoneNumber: c.phone || c.mobileNo || "",
|
||||||
|
email: c.emailId || c.customEmail || "",
|
||||||
|
contactRole: c.role || "",
|
||||||
|
isPrimary: c.isPrimaryContact || false,
|
||||||
|
})) || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Populate available contacts if any
|
// Populate available contacts if any
|
||||||
if (selectedAddressData.value.contacts && selectedAddressData.value.contacts.length > 0) {
|
if (contactsForAddress.value.length > 0) {
|
||||||
availableContacts.value = selectedAddressData.value.contacts;
|
availableContacts.value = contactsForAddress.value;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -483,10 +578,7 @@ const handleSave = async () => {
|
|||||||
pincode: formData.value.pincode,
|
pincode: formData.value.pincode,
|
||||||
city: formData.value.city,
|
city: formData.value.city,
|
||||||
state: formData.value.state,
|
state: formData.value.state,
|
||||||
firstName: formData.value.firstName,
|
contacts: formData.value.contacts,
|
||||||
lastName: formData.value.lastName,
|
|
||||||
phoneNumber: formData.value.phoneNumber,
|
|
||||||
email: formData.value.email,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Upserting client with data:", clientData);
|
console.log("Upserting client with data:", clientData);
|
||||||
@ -606,6 +698,10 @@ const handleCancel = () => {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-selector {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Form input styling */
|
/* Form input styling */
|
||||||
.info-item :deep(.p-inputtext),
|
.info-item :deep(.p-inputtext),
|
||||||
.info-item :deep(.p-autocomplete),
|
.info-item :deep(.p-autocomplete),
|
||||||
|
|||||||
@ -360,7 +360,7 @@ const handleEstimateClick = (status, rowData) => {
|
|||||||
if (status?.toLowerCase() === "not started") {
|
if (status?.toLowerCase() === "not started") {
|
||||||
// Navigate to create quotation/estimate
|
// Navigate to create quotation/estimate
|
||||||
const address = encodeURIComponent(rowData.address);
|
const address = encodeURIComponent(rowData.address);
|
||||||
router.push(`/quotations?new=true&address=${address}`);
|
router.push(`/estimate?new=true&address=${address}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
520
frontend/src/components/pages/Estimate.vue
Normal file
520
frontend/src/components/pages/Estimate.vue
Normal file
@ -0,0 +1,520 @@
|
|||||||
|
<template>
|
||||||
|
<div class="estimate-page">
|
||||||
|
<h2>Create Estimate</h2>
|
||||||
|
|
||||||
|
<!-- Address Section -->
|
||||||
|
<div class="address-section">
|
||||||
|
<label for="address" class="field-label">
|
||||||
|
Address
|
||||||
|
<span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="address-input-group">
|
||||||
|
<InputText
|
||||||
|
id="address"
|
||||||
|
v-model="formData.address"
|
||||||
|
placeholder="Enter address to search"
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Search"
|
||||||
|
icon="pi pi-search"
|
||||||
|
@click="searchAddresses"
|
||||||
|
:disabled="!formData.address.trim()"
|
||||||
|
class="search-button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedAddress" class="verification-info">
|
||||||
|
<strong>Customer:</strong> {{ selectedAddress.customCustomerToBill }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Section -->
|
||||||
|
<div class="contact-section">
|
||||||
|
<label for="contact" class="field-label">
|
||||||
|
Contact
|
||||||
|
<span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
:key="contactOptions.length"
|
||||||
|
v-model="formData.contact"
|
||||||
|
:options="contactOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="Select a contact"
|
||||||
|
:disabled="!formData.address"
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
<div v-if="selectedContact" class="verification-info">
|
||||||
|
<strong>Email:</strong> {{ selectedContact.customEmail || "N/A" }} <br />
|
||||||
|
<strong>Phone:</strong> {{ selectedContact.phone || "N/A" }} <br />
|
||||||
|
<strong>Primary Contact:</strong>
|
||||||
|
{{ selectedContact.isPrimaryContact ? "Yes" : "No" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Section -->
|
||||||
|
<div class="items-section">
|
||||||
|
<h3>Items</h3>
|
||||||
|
<Button label="Add Item" icon="pi pi-plus" @click="showAddItemModal = true" />
|
||||||
|
<div v-for="(item, index) in selectedItems" :key="item.itemCode" class="item-row">
|
||||||
|
<span>{{ item.itemName }}</span>
|
||||||
|
<InputNumber
|
||||||
|
v-model="item.qty"
|
||||||
|
:min="1"
|
||||||
|
showButtons
|
||||||
|
buttonLayout="horizontal"
|
||||||
|
@input="updateTotal"
|
||||||
|
/>
|
||||||
|
<span>Price: ${{ (item.standardRate || 0).toFixed(2) }}</span>
|
||||||
|
<span>Total: ${{ ((item.qty || 0) * (item.standardRate || 0)).toFixed(2) }}</span>
|
||||||
|
<Button icon="pi pi-trash" @click="removeItem(index)" severity="danger" />
|
||||||
|
</div>
|
||||||
|
<div class="total-section">
|
||||||
|
<strong>Total Cost: ${{ totalCost.toFixed(2) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<Button label="Clear Items" @click="clearItems" severity="secondary" />
|
||||||
|
<Button
|
||||||
|
label="Submit"
|
||||||
|
@click="showConfirmationModal = true"
|
||||||
|
:disabled="selectedItems.length === 0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address Search Modal -->
|
||||||
|
<Modal
|
||||||
|
:visible="showAddressModal"
|
||||||
|
@update:visible="showAddressModal = $event"
|
||||||
|
@close="showAddressModal = false"
|
||||||
|
>
|
||||||
|
<template #title>Address Search Results</template>
|
||||||
|
<div class="address-search-results">
|
||||||
|
<div v-if="addressSearchResults.length === 0" class="no-results">
|
||||||
|
<i class="pi pi-info-circle"></i>
|
||||||
|
<p>No addresses found matching your search.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="results-list">
|
||||||
|
<div
|
||||||
|
v-for="(address, index) in addressSearchResults"
|
||||||
|
:key="index"
|
||||||
|
class="address-result-item"
|
||||||
|
@click="selectAddress(address)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-map-marker"></i>
|
||||||
|
<span>{{ address }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Add Item Modal -->
|
||||||
|
<Modal
|
||||||
|
:visible="showAddItemModal"
|
||||||
|
@update:visible="showAddItemModal = $event"
|
||||||
|
@close="closeAddItemModal"
|
||||||
|
:options="{ showActions: false }"
|
||||||
|
>
|
||||||
|
<template #title>Add Item</template>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="search-section">
|
||||||
|
<label for="item-search" class="field-label">Search Items</label>
|
||||||
|
<InputText
|
||||||
|
id="item-search"
|
||||||
|
v-model="itemSearchTerm"
|
||||||
|
placeholder="Search by item code or name..."
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
:data="filteredItems"
|
||||||
|
:columns="itemColumns"
|
||||||
|
:tableName="'estimate-items'"
|
||||||
|
:tableActions="tableActions"
|
||||||
|
selectable
|
||||||
|
:paginator="false"
|
||||||
|
:rows="filteredItems.length"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Confirmation Modal -->
|
||||||
|
<Modal
|
||||||
|
:visible="showConfirmationModal"
|
||||||
|
@update:visible="showConfirmationModal = $event"
|
||||||
|
@close="showConfirmationModal = false"
|
||||||
|
>
|
||||||
|
<template #title>Confirm Estimate</template>
|
||||||
|
<div class="modal-content">
|
||||||
|
<h4>Does this information look correct?</h4>
|
||||||
|
<p><strong>Address:</strong> {{ formData.address }}</p>
|
||||||
|
<p>
|
||||||
|
<strong>Contact:</strong>
|
||||||
|
{{
|
||||||
|
selectedContact
|
||||||
|
? `${selectedContact.firstName} ${selectedContact.lastName}`
|
||||||
|
: ""
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p><strong>Items:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in selectedItems" :key="item.itemCode">
|
||||||
|
{{ item.itemName }} - Qty: {{ item.qty || 0 }} - Total: ${{
|
||||||
|
((item.qty || 0) * (item.standardRate || 0)).toFixed(2)
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Total:</strong> ${{ totalCost.toFixed(2) }}</p>
|
||||||
|
<div class="confirmation-buttons">
|
||||||
|
<Button
|
||||||
|
label="No"
|
||||||
|
@click="showConfirmationModal = false"
|
||||||
|
severity="secondary"
|
||||||
|
/>
|
||||||
|
<Button label="Yes" @click="confirmSubmit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import Modal from "../common/Modal.vue";
|
||||||
|
import DataTable from "../common/DataTable.vue";
|
||||||
|
import InputText from "primevue/inputtext";
|
||||||
|
import InputNumber from "primevue/inputnumber";
|
||||||
|
import Button from "primevue/button";
|
||||||
|
import Select from "primevue/select";
|
||||||
|
import Api from "../../api";
|
||||||
|
import DataUtils from "../../utils";
|
||||||
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
|
const addressQuery = route.query.address;
|
||||||
|
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
address: "",
|
||||||
|
addressName: "",
|
||||||
|
contact: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedAddress = ref(null);
|
||||||
|
const selectedContact = ref(null);
|
||||||
|
const contacts = ref([]);
|
||||||
|
const contactOptions = ref([]);
|
||||||
|
const quotationItems = ref([]);
|
||||||
|
const selectedItems = ref([]);
|
||||||
|
|
||||||
|
const showAddressModal = ref(false);
|
||||||
|
const showAddItemModal = ref(false);
|
||||||
|
const showConfirmationModal = ref(false);
|
||||||
|
const addressSearchResults = ref([]);
|
||||||
|
const itemSearchTerm = ref("");
|
||||||
|
|
||||||
|
const itemColumns = [
|
||||||
|
{ label: "Item Code", fieldName: "itemCode", type: "text" },
|
||||||
|
{ label: "Item Name", fieldName: "itemName", type: "text" },
|
||||||
|
{ label: "Price", fieldName: "standardRate", type: "number" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const searchAddresses = async () => {
|
||||||
|
const searchTerm = formData.address.trim();
|
||||||
|
if (!searchTerm) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Api.searchAddresses(searchTerm);
|
||||||
|
addressSearchResults.value = results;
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
notificationStore.addNotification(
|
||||||
|
"No addresses found matching your search.",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAddressModal.value = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching addresses:", error);
|
||||||
|
addressSearchResults.value = [];
|
||||||
|
notificationStore.addNotification(
|
||||||
|
"Failed to search addresses. Please try again.",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAddress = async (address) => {
|
||||||
|
formData.address = address;
|
||||||
|
selectedAddress.value = await Api.getAddressByFullAddress(address);
|
||||||
|
formData.addressName = selectedAddress.value.name;
|
||||||
|
contacts.value = selectedAddress.value.contacts;
|
||||||
|
contactOptions.value = contacts.value.map((c) => ({
|
||||||
|
label: `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.name,
|
||||||
|
value: c.name,
|
||||||
|
}));
|
||||||
|
const primary = contacts.value.find((c) => c.isPrimaryContact);
|
||||||
|
formData.contact = primary ? primary.name : contacts.value[0]?.name || "";
|
||||||
|
showAddressModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = (item) => {
|
||||||
|
const existing = selectedItems.value.find((i) => i.itemCode === item.itemCode);
|
||||||
|
if (!existing) {
|
||||||
|
selectedItems.value.push({ ...item, qty: 1 });
|
||||||
|
}
|
||||||
|
showAddItemModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSelectedItems = (selectedRows) => {
|
||||||
|
selectedRows.forEach((item) => {
|
||||||
|
const existing = selectedItems.value.find((i) => i.itemCode === item.itemCode);
|
||||||
|
if (existing) {
|
||||||
|
// Increase quantity by 1 if item already exists
|
||||||
|
existing.qty += 1;
|
||||||
|
} else {
|
||||||
|
// Add new item with quantity 1
|
||||||
|
selectedItems.value.push({ ...item, qty: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
showAddItemModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAddItemModal = () => {
|
||||||
|
showAddItemModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index) => {
|
||||||
|
selectedItems.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearItems = () => {
|
||||||
|
selectedItems.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTotal = () => {
|
||||||
|
// Computed will update
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSubmit = async () => {
|
||||||
|
isSubmitting.value = true;
|
||||||
|
showConfirmationModal.value = false;
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
addressName: formData.addressName,
|
||||||
|
contactName: selectedContact.value.name,
|
||||||
|
items: selectedItems.value.map((i) => ({ itemCode: i.itemCode, qty: i.qty })),
|
||||||
|
};
|
||||||
|
await Api.createEstimate(data);
|
||||||
|
notificationStore.addNotification("Estimate created successfully", "success");
|
||||||
|
router.push(`/estimate?address=${encodeURIComponent(formData.address)}`);
|
||||||
|
// Reset form
|
||||||
|
formData.address = "";
|
||||||
|
formData.addressName = "";
|
||||||
|
formData.contact = "";
|
||||||
|
selectedAddress.value = null;
|
||||||
|
selectedContact.value = null;
|
||||||
|
selectedItems.value = [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating estimate:", error);
|
||||||
|
notificationStore.addNotification("Failed to create estimate", "error");
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableActions = [
|
||||||
|
{
|
||||||
|
label: "Add Selected Items",
|
||||||
|
action: addSelectedItems,
|
||||||
|
requiresMultipleSelection: true,
|
||||||
|
icon: "pi pi-plus",
|
||||||
|
style: "primary",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const totalCost = computed(() => {
|
||||||
|
return (selectedItems.value || []).reduce((sum, item) => {
|
||||||
|
const qty = item.qty || 0;
|
||||||
|
const rate = item.standardRate || 0;
|
||||||
|
return sum + qty * rate;
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
if (!itemSearchTerm.value.trim()) {
|
||||||
|
return quotationItems.value.map((item) => ({ ...item, id: item.itemCode }));
|
||||||
|
}
|
||||||
|
const term = itemSearchTerm.value.toLowerCase();
|
||||||
|
return quotationItems.value
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.itemCode.toLowerCase().includes(term) ||
|
||||||
|
item.itemName.toLowerCase().includes(term),
|
||||||
|
)
|
||||||
|
.map((item) => ({ ...item, id: item.itemCode }));
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => formData.contact,
|
||||||
|
(newVal) => {
|
||||||
|
selectedContact.value = contacts.value.find((c) => c.name === newVal) || null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
quotationItems.value = await Api.getQuotationItems();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading quotation items:", error);
|
||||||
|
}
|
||||||
|
if (addressQuery) {
|
||||||
|
await selectAddress(addressQuery);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.estimate-page {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-section,
|
||||||
|
.contact-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-info {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-search-results {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results i {
|
||||||
|
font-size: 2em;
|
||||||
|
color: #f39c12;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #2196f3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result-item i {
|
||||||
|
color: #2196f3;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result-item span {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<parameter name="filePath"></parameter>
|
||||||
@ -5,6 +5,7 @@ import router from "./router";
|
|||||||
import PrimeVue from "primevue/config";
|
import PrimeVue from "primevue/config";
|
||||||
import { globalSettings } from "./globalSettings";
|
import { globalSettings } from "./globalSettings";
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
|
import 'primeicons/primeicons.css';
|
||||||
|
|
||||||
// Vuetify
|
// Vuetify
|
||||||
import "@primeuix/themes/aura";
|
import "@primeuix/themes/aura";
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import TestDateForm from "./components/pages/TestDateForm.vue";
|
|||||||
import Client from "./components/pages/Client.vue";
|
import Client from "./components/pages/Client.vue";
|
||||||
import ErrorHandlingDemo from "./components/pages/ErrorHandlingDemo.vue";
|
import ErrorHandlingDemo from "./components/pages/ErrorHandlingDemo.vue";
|
||||||
import ScheduleOnSite from "./components/pages/ScheduleOnSite.vue";
|
import ScheduleOnSite from "./components/pages/ScheduleOnSite.vue";
|
||||||
|
import Estimate from "./components/pages/Estimate.vue";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@ -25,6 +26,7 @@ const routes = [
|
|||||||
{ path: "/schedule-onsite", component: ScheduleOnSite },
|
{ path: "/schedule-onsite", component: ScheduleOnSite },
|
||||||
{ path: "/jobs", component: Jobs },
|
{ path: "/jobs", component: Jobs },
|
||||||
{ path: "/estimates", component: Estimates },
|
{ path: "/estimates", component: Estimates },
|
||||||
|
{ path: "/estimate", component: Estimate },
|
||||||
{ path: "/routes", component: Routes },
|
{ path: "/routes", component: Routes },
|
||||||
{ path: "/create", component: Create },
|
{ path: "/create", component: Create },
|
||||||
{ path: "/timesheets", component: TimeSheets },
|
{ path: "/timesheets", component: TimeSheets },
|
||||||
|
|||||||
@ -56,18 +56,19 @@
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix ToggleSwitch z-index so slider is visible but input receives clicks */
|
/* Vuetify Switch and Checkbox Styling */
|
||||||
.p-toggleswitch {
|
.v-switch {
|
||||||
position: relative;
|
align-self: center;
|
||||||
|
transform: scale(0.85);
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-toggleswitch-slider {
|
.v-switch .v-switch__thumb {
|
||||||
position: relative;
|
pointer-events: auto !important; /* Make thumb clickable */
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-toggleswitch-input {
|
.v-checkbox {
|
||||||
position: absolute;
|
align-self: center;
|
||||||
z-index: 1;
|
transform: scale(0.75);
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user