job creation working

This commit is contained in:
Casey 2026-01-16 09:06:59 -06:00
parent bd9e00c6f1
commit d3818d1985
22 changed files with 591 additions and 179 deletions

View file

@ -310,7 +310,6 @@ def upsert_client(data):
"phone": primary_contact.get("phone_number"), "phone": primary_contact.get("phone_number"),
"custom_customer_name": customer_name, "custom_customer_name": customer_name,
"customer_type": customer_type, "customer_type": customer_type,
"address_type": "Billing",
"companies": [{ "company": data.get("company_name") "companies": [{ "company": data.get("company_name")
}] }]
} }
@ -370,11 +369,13 @@ def upsert_client(data):
# Handle address creation # Handle address creation
address_docs = [] address_docs = []
for address in addresses: for address in addresses:
is_billing = True if address.get("is_billing_address") else False
print("#####DEBUG: Creating address with data:", address) print("#####DEBUG: Creating address with data:", address)
address_doc = AddressService.create_address({ address_doc = AddressService.create_address({
"address_title": build_address_title(customer_name, address), "address_title": AddressService.build_address_title(customer_name, address),
"address_line1": address.get("address_line1"), "address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"), "address_line2": address.get("address_line2"),
"address_type": "Billing" if is_billing else "Service",
"city": address.get("city"), "city": address.get("city"),
"state": address.get("state"), "state": address.get("state"),
"country": "United States", "country": "United States",
@ -385,6 +386,9 @@ def upsert_client(data):
}) })
AddressService.link_address_to_customer(address_doc, "Lead", client_doc.name) AddressService.link_address_to_customer(address_doc, "Lead", client_doc.name)
address_doc.reload() address_doc.reload()
if is_billing:
client_doc.custom_billing_address = address_doc.name
client_doc.save(ignore_permissions=True)
for contact_to_link_idx in address.get("contacts", []): for contact_to_link_idx in address.get("contacts", []):
contact_doc = contact_docs[contact_to_link_idx] contact_doc = contact_docs[contact_to_link_idx]
AddressService.link_address_to_contact(address_doc, contact_doc.name) AddressService.link_address_to_contact(address_doc, contact_doc.name)

View file

@ -37,7 +37,7 @@ def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
tableRows = [] tableRows = []
for estimate in estimates: for estimate in estimates:
full_address = frappe.db.get_value("Address", estimate.get("custom_installation_address"), "full_address") full_address = frappe.db.get_value("Address", estimate.get("custom_job_address"), "full_address")
tableRow = {} tableRow = {}
tableRow["id"] = estimate["name"] tableRow["id"] = estimate["name"]
tableRow["address"] = full_address tableRow["address"] = full_address
@ -69,7 +69,7 @@ def get_estimate(estimate_name):
estimate = frappe.get_doc("Quotation", estimate_name) estimate = frappe.get_doc("Quotation", estimate_name)
est_dict = estimate.as_dict() est_dict = estimate.as_dict()
address_name = estimate.custom_installation_address or estimate.customer_address address_name = estimate.custom_job_address or estimate.customer_address
if address_name: if address_name:
# Fetch Address Doc # Fetch Address Doc
address_doc = frappe.get_doc("Address", address_name).as_dict() address_doc = frappe.get_doc("Address", address_name).as_dict()
@ -386,8 +386,6 @@ def upsert_estimate(data):
print("DEBUG: Upsert estimate data:", data) print("DEBUG: Upsert estimate data:", data)
address_doc = AddressService.get_or_throw(data.get("address_name")) address_doc = AddressService.get_or_throw(data.get("address_name"))
estimate_name = data.get("estimate_name") estimate_name = data.get("estimate_name")
client_doctype = ClientService.get_client_doctype(address_doc.customer_name)
print("DEBUG: Retrieved client doctype:", client_doctype)
project_template = data.get("project_template", None) project_template = data.get("project_template", None)
# If estimate_name exists, update existing estimate # If estimate_name exists, update existing estimate
@ -432,6 +430,12 @@ def upsert_estimate(data):
else: else:
print("DEBUG: Creating new estimate") print("DEBUG: Creating new estimate")
print("DEBUG: Retrieved address name:", data.get("address_name")) print("DEBUG: Retrieved address name:", data.get("address_name"))
client_doc = ClientService.get_client_or_throw(address_doc.customer_name)
# billing_address = next((addr for addr in address_doc if addr.address_type == "Billing"), None)
# if billing_address:
# print("DEBUG: Found billing address:", billing_address.name)
# else:
# print("DEBUG: No billing address found for client:", client_doc.name)
new_estimate = frappe.get_doc({ new_estimate = frappe.get_doc({
"doctype": "Quotation", "doctype": "Quotation",
"custom_requires_half_payment": data.get("requires_half_payment", 0), "custom_requires_half_payment": data.get("requires_half_payment", 0),
@ -441,9 +445,9 @@ def upsert_estimate(data):
"party_name": data.get("contact_name"), "party_name": data.get("contact_name"),
"quotation_to": "Contact", "quotation_to": "Contact",
"company": data.get("company"), "company": data.get("company"),
"actual_customer_name": address_doc.customer_name, "actual_customer_name": client_doc.name,
"customer_type": client_doctype, "customer_type": address_doc.customer_type,
"customer_address": data.get("address_name"), "customer_address": client_doc.custom_billing_address,
"contact_person": data.get("contact_name"), "contact_person": data.get("contact_name"),
"letter_head": data.get("company"), "letter_head": data.get("company"),
"custom_project_template": data.get("project_template", None), "custom_project_template": data.get("project_template", None),

View file

@ -1,5 +1,6 @@
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, build_error_response from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
from custom_ui.services import AddressService, ClientService
# =============================================================================== # ===============================================================================
# JOB MANAGEMENT API METHODS # JOB MANAGEMENT API METHODS
@ -45,6 +46,10 @@ def get_job(job_id=""):
print("DEBUG: Loading Job from database:", job_id) print("DEBUG: Loading Job from database:", job_id)
try: try:
project = frappe.get_doc("Project", job_id) project = frappe.get_doc("Project", job_id)
address_doc = AddressService.get_or_throw(project.job_address)
project = project.as_dict()
project["job_address"] = address_doc
project["client"] = ClientService.get_client_or_throw(project.customer)
return build_success_response(project) return build_success_response(project)
except Exception as e: except Exception as e:
return build_error_response(str(e), 500) return build_error_response(str(e), 500)

View file

@ -1,5 +1,17 @@
import frappe import frappe
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
from custom_ui.services import DbService
@frappe.whitelist()
def set_task_status(task_name, new_status):
"""Set the status of a specific task."""
try:
task = DbService.get_or_throw("Task", task_name)
task.status = new_status
task.save()
return build_success_response(f"Task {task_name} status updated to {new_status}.")
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist() @frappe.whitelist()

View file

@ -7,6 +7,9 @@ def after_insert(doc, method):
AddressService.append_link_v2( AddressService.append_link_v2(
doc.custom_job_address, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template} doc.custom_job_address, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template}
) )
AddressService.append_link_v2(
doc.custom_job_address, "links", {"link_doctype": "Quotation", "link_name": doc.name}
)
ClientService.append_link_v2( ClientService.append_link_v2(
doc.actual_customer_name, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template} doc.actual_customer_name, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template}
) )
@ -74,11 +77,11 @@ def on_update_after_submit(doc, method):
new_sales_order.set_payment_schedule() new_sales_order.set_payment_schedule()
print("DEBUG: Inserting Sales Order:", new_sales_order.as_dict()) print("DEBUG: Inserting Sales Order:", new_sales_order.as_dict())
new_sales_order.delivery_date = new_sales_order.transaction_date new_sales_order.delivery_date = new_sales_order.transaction_date
backup = new_sales_order.customer_address # backup = new_sales_order.customer_address
new_sales_order.customer_address = None # new_sales_order.customer_address = None
new_sales_order.insert() new_sales_order.insert()
print("DEBUG: Submitting Sales Order") print("DEBUG: Submitting Sales Order")
new_sales_order.customer_address = backup # new_sales_order.customer_address = backup
new_sales_order.submit() new_sales_order.submit()
frappe.db.commit() frappe.db.commit()
print("DEBUG: Sales Order created successfully:", new_sales_order.name) print("DEBUG: Sales Order created successfully:", new_sales_order.name)

View file

@ -1,4 +1,27 @@
import frappe import frappe
from custom_ui.services import AddressService, ClientService
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Project")
print("DEBUG: Linking Project to address and Customer")
AddressService.append_link_v2(
doc.job_address, "projects", {"project": doc.name, "project_template": doc.project_template}
)
AddressService.append_link_v2(
doc.job_address, "links", {"link_doctype": "Project", "link_name": doc.name}
)
ClientService.append_link_v2(
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
)
if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
AddressService.update_value(
doc.job_address,
"job_status",
"In Progress"
)
def before_insert(doc, method): def before_insert(doc, method):
# This is where we will add logic to set tasks and other properties of a job based on it's project_template # This is where we will add logic to set tasks and other properties of a job based on it's project_template

View file

@ -14,6 +14,7 @@ def after_insert(doc, method):
print("DEBUG: After Insert Triggered for On-Site Meeting") print("DEBUG: After Insert Triggered for On-Site Meeting")
print("DEBUG: Linking bid meeting to customer and address") print("DEBUG: Linking bid meeting to customer and address")
AddressService.append_link_v2(doc.address, "onsite_meetings", {"onsite_meeting": doc.name, "project_template": doc.project_template}) AddressService.append_link_v2(doc.address, "onsite_meetings", {"onsite_meeting": doc.name, "project_template": doc.project_template})
AddressService.append_link_v2(doc.address, "links", {"link_doctype": "On-Site Meeting", "link_name": doc.name})
ClientService.append_link(doc.party_name, "onsite_meetings", "onsite_meeting", doc.name) ClientService.append_link(doc.party_name, "onsite_meetings", "onsite_meeting", doc.name)
if doc.project_template == "SNW Install": if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install, updating Address status to In Progress") print("DEBUG: Project template is SNW Install, updating Address status to In Progress")

View file

@ -1,5 +1,13 @@
import frappe import frappe
from custom_ui.services import DbService from custom_ui.services import DbService, AddressService, ClientService
def before_insert(doc, method):
print("DEBUG: before_insert hook triggered for Sales Order:", doc.name)
if doc.custom_project_template == "SNW Install":
print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
address_doc = AddressService.get_or_throw(doc.custom_job_address)
if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
def on_submit(doc, method): def on_submit(doc, method):
print("DEBUG: Info from Sales Order") print("DEBUG: Info from Sales Order")
@ -7,20 +15,21 @@ def on_submit(doc, method):
print(doc.company) print(doc.company)
print(doc.transaction_date) print(doc.transaction_date)
print(doc.customer) print(doc.customer)
print(doc.custom_job_address)
print(doc.custom_project_template)
# Create Invoice and Project from Sales Order # Create Invoice and Project from Sales Order
try: try:
print("Creating Project from Sales Order", doc.name) print("Creating Project from Sales Order", doc.name)
sales_order = frappe.get_doc("Sales Order", doc.name) if doc.custom_project_template or doc.project_template:
if not sales_order.custom_project_template:
return
project_template = DbService.get("Project Template", sales_order.custom_project_template)
new_job = frappe.get_doc({ new_job = frappe.get_doc({
"doctype": "Project", "doctype": "Project",
"custom_job_address": sales_order.custom_job_address, "custom_job_address": doc.custom_job_address,
"project_name": f"{sales_order.custom_project_template} - {sales_order.custom_job_address}", "project_name": f"{doc.custom_project_template} - {doc.custom_job_address}",
"project_template": project_template.name, "project_template": doc.custom_project_template,
"custom_warranty_duration_days": 90, "custom_warranty_duration_days": 90,
"sales_order": sales_order.name "customer": doc.customer,
"job_address": doc.custom_job_address,
"sales_order": doc.name
}) })
# attatch the job to the sales_order links # attatch the job to the sales_order links
new_job.insert() new_job.insert()
@ -28,6 +37,17 @@ def on_submit(doc, method):
except Exception as e: except Exception as e:
print("ERROR creating Project from Sales Order:", str(e)) print("ERROR creating Project from Sales Order:", str(e))
def after_insert(doc, method):
print("DEBUG: after_insert hook triggered for Sales Order:", doc.name)
AddressService.append_link_v2(
doc.custom_job_address, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
)
AddressService.append_link_v2(
doc.custom_job_address, "links", {"link_doctype": "Sales Order", "link_name": doc.name}
)
ClientService.append_link_v2(
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
)
def create_sales_invoice_from_sales_order(doc, method): def create_sales_invoice_from_sales_order(doc, method):
try: try:

View file

@ -114,8 +114,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.065211", "modified": "2026-01-16 03:16:17.476708",
"module": "Custom", "module": "Custom",
"name": "Lead Companies Link", "name": "Lead Companies Link",
"naming_rule": "", "naming_rule": "",
@ -332,8 +332,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.117683", "modified": "2026-01-16 03:16:17.527399",
"module": "Custom", "module": "Custom",
"name": "Address Project Link", "name": "Address Project Link",
"naming_rule": "", "naming_rule": "",
@ -550,8 +550,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.173958", "modified": "2026-01-16 03:16:17.579759",
"module": "Custom", "module": "Custom",
"name": "Address Quotation Link", "name": "Address Quotation Link",
"naming_rule": "", "naming_rule": "",
@ -768,8 +768,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.229451", "modified": "2026-01-16 03:16:17.630680",
"module": "Custom", "module": "Custom",
"name": "Address On-Site Meeting Link", "name": "Address On-Site Meeting Link",
"naming_rule": "", "naming_rule": "",
@ -901,6 +901,70 @@
"trigger": null, "trigger": null,
"unique": 0, "unique": 0,
"width": null "width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "project_template",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Project Template",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Project Template",
"parent": "Address Sales Order Link",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
} }
], ],
"force_re_route_to_default_view": 0, "force_re_route_to_default_view": 0,
@ -922,8 +986,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.280251", "modified": "2026-01-16 03:19:33.624850",
"module": "Custom", "module": "Custom",
"name": "Address Sales Order Link", "name": "Address Sales Order Link",
"naming_rule": "", "naming_rule": "",
@ -1076,8 +1140,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.332720", "modified": "2026-01-16 03:16:17.729324",
"module": "Custom", "module": "Custom",
"name": "Contact Address Link", "name": "Contact Address Link",
"naming_rule": "", "naming_rule": "",
@ -1230,8 +1294,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.385343", "modified": "2026-01-16 03:16:17.780526",
"module": "Custom", "module": "Custom",
"name": "Lead On-Site Meeting Link", "name": "Lead On-Site Meeting Link",
"naming_rule": "", "naming_rule": "",
@ -1832,8 +1896,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.457103", "modified": "2026-01-16 03:16:17.847539",
"module": "Selling", "module": "Selling",
"name": "Quotation Template", "name": "Quotation Template",
"naming_rule": "", "naming_rule": "",
@ -2330,8 +2394,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.529451", "modified": "2026-01-16 03:16:17.916692",
"module": "Selling", "module": "Selling",
"name": "Quotation Template Item", "name": "Quotation Template Item",
"naming_rule": "", "naming_rule": "",
@ -2484,8 +2548,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.580626", "modified": "2026-01-16 03:16:17.965428",
"module": "Custom UI", "module": "Custom UI",
"name": "Customer Company Link", "name": "Customer Company Link",
"naming_rule": "", "naming_rule": "",
@ -2638,8 +2702,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.630918", "modified": "2026-01-16 03:16:18.018887",
"module": "Custom UI", "module": "Custom UI",
"name": "Customer Address Link", "name": "Customer Address Link",
"naming_rule": "", "naming_rule": "",
@ -2792,8 +2856,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.681698", "modified": "2026-01-16 03:16:18.066488",
"module": "Custom UI", "module": "Custom UI",
"name": "Customer Contact Link", "name": "Customer Contact Link",
"naming_rule": "", "naming_rule": "",
@ -2946,8 +3010,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.731378", "modified": "2026-01-16 03:16:18.115936",
"module": "Custom", "module": "Custom",
"name": "Address Contact Link", "name": "Address Contact Link",
"naming_rule": "", "naming_rule": "",
@ -3100,8 +3164,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.783154", "modified": "2026-01-16 03:16:18.164485",
"module": "Custom", "module": "Custom",
"name": "Customer On-Site Meeting Link", "name": "Customer On-Site Meeting Link",
"naming_rule": "", "naming_rule": "",
@ -3254,8 +3318,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.832605", "modified": "2026-01-16 03:16:18.212622",
"module": "Custom", "module": "Custom",
"name": "Customer Project Link", "name": "Customer Project Link",
"naming_rule": "", "naming_rule": "",
@ -3408,8 +3472,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.882511", "modified": "2026-01-16 03:16:18.262231",
"module": "Custom", "module": "Custom",
"name": "Customer Quotation Link", "name": "Customer Quotation Link",
"naming_rule": "", "naming_rule": "",
@ -3562,8 +3626,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.934072", "modified": "2026-01-16 03:16:18.311056",
"module": "Custom", "module": "Custom",
"name": "Customer Sales Order Link", "name": "Customer Sales Order Link",
"naming_rule": "", "naming_rule": "",
@ -3716,8 +3780,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:57.984126", "modified": "2026-01-16 03:16:18.359782",
"module": "Custom", "module": "Custom",
"name": "Lead Address Link", "name": "Lead Address Link",
"naming_rule": "", "naming_rule": "",
@ -3870,8 +3934,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:58.034758", "modified": "2026-01-16 03:16:18.408094",
"module": "Custom", "module": "Custom",
"name": "Lead Contact Link", "name": "Lead Contact Link",
"naming_rule": "", "naming_rule": "",
@ -4024,8 +4088,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:58.086245", "modified": "2026-01-16 03:16:18.457175",
"module": "Custom", "module": "Custom",
"name": "Lead Quotation Link", "name": "Lead Quotation Link",
"naming_rule": "", "naming_rule": "",
@ -4178,8 +4242,8 @@
"make_attachments_public": 0, "make_attachments_public": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": null, "menu_index": null,
"migration_hash": "717d2b4e3d605ae371d8c05bb650b364", "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
"modified": "2026-01-15 10:05:58.139052", "modified": "2026-01-16 03:16:18.506817",
"module": "Custom", "module": "Custom",
"name": "Address Company Link", "name": "Address Company Link",
"naming_rule": "", "naming_rule": "",

View file

@ -175,8 +175,14 @@ doc_events = {
"on_update_after_submit": "custom_ui.events.estimate.on_update_after_submit" "on_update_after_submit": "custom_ui.events.estimate.on_update_after_submit"
}, },
"Sales Order": { "Sales Order": {
"before_insert": "custom_ui.events.sales_order.before_insert",
"after_insert": "custom_ui.events.sales_order.after_insert",
"on_submit": "custom_ui.events.sales_order.on_submit" "on_submit": "custom_ui.events.sales_order.on_submit"
}, },
"Project": {
"before_insert": "custom_ui.events.jobs.before_insert",
"after_insert": "custom_ui.events.jobs.after_insert"
},
"Task": { "Task": {
"before_insert": "custom_ui.events.task.before_insert" "before_insert": "custom_ui.events.task.before_insert"
} }

View file

@ -30,7 +30,7 @@ def after_migrate():
frappe.clear_cache(doctype=doctype) frappe.clear_cache(doctype=doctype)
frappe.reload_doctype(doctype) frappe.reload_doctype(doctype)
update_address_fields() # update_address_fields()
# build_frontend() # build_frontend()
@ -71,6 +71,17 @@ def add_custom_fields():
print("\n🔧 Adding custom fields to doctypes...") print("\n🔧 Adding custom fields to doctypes...")
try:
address_meta = frappe.get_meta("Address")
address_type_params = address_meta.get_field("address_type")
if address_type_params and "Service" not in (address_type_params.options or ""):
print(" Adding 'Service' to Address type options...")
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
make_property_setter("Address", "address_type", "options", (address_type_params.options or "") + "\nService", "DocField")
print(" ✅ Added 'Service' to Address address_type options.")
except Exception as e:
print(f" ⚠️ Failed to update Address address_type: {e}")
custom_fields = { custom_fields = {
"Customer": [ "Customer": [
dict( dict(
@ -145,6 +156,13 @@ def add_custom_fields():
options="Lead On-Site Meeting Link", options="Lead On-Site Meeting Link",
insert_after="quotations" insert_after="quotations"
), ),
dict(
fieldname="custom_billing_address",
label="Custom Address",
fieldtype="Link",
options="Address",
insert_after="customer_name"
),
dict( dict(
fieldname="quotations", fieldname="quotations",
label="Quotations", label="Quotations",
@ -505,6 +523,24 @@ def add_custom_fields():
allow_on_submit=1 allow_on_submit=1
) )
], ],
"Project": [
dict(
fieldname="job_address",
label="Job Address",
fieldtype="Link",
options="Address",
insert_after="project_name",
description="The address where the job is being performed."
),
dict(
fieldname="customer",
label="Customer",
fieldtype="Link",
options="Customer",
insert_after="job_address",
description="The customer for whom the project is being executed."
)
],
"Project Template": [ "Project Template": [
dict( dict(
fieldname="company", fieldname="company",

View file

@ -1,13 +1,22 @@
import frappe import frappe
from frappe.model.document import Document
import requests import requests
from .contact_service import ContactService, DbService from .contact_service import ContactService, DbService
class AddressService: class AddressService:
@staticmethod
def build_address_title(customer_name, address_data) -> str:
"""Build a title for the address based on its fields."""
print(f"DEBUG: Building address title for customer '{customer_name}' with address data: {address_data}")
is_billing = address_data.get("is_billing_address", False)
address_type = "Billing" if is_billing else "Service"
return f"{customer_name} - {address_data.get('address_line1', '')} {address_data.get('city')} - {address_type}"
@staticmethod @staticmethod
def build_full_dict( def build_full_dict(
address_doc, address_doc: Document,
included_links: list = ["contacts", "on-site meetings", "quotations", "sales orders", "projects", "companies"]) -> dict: included_links: list = ["contacts", "on-site meetings", "quotations", "sales orders", "projects", "companies"]) -> frappe._dict:
"""Build a full dictionary representation of an address, including all links. Can optionally exclude links.""" """Build a full dictionary representation of an address, including all links. Can optionally exclude links."""
print(f"DEBUG: Building full dict for Address {address_doc.name}") print(f"DEBUG: Building full dict for Address {address_doc.name}")
address_dict = address_doc.as_dict() address_dict = address_doc.as_dict()
@ -27,12 +36,12 @@ class AddressService:
return address_dict return address_dict
@staticmethod @staticmethod
def get_address_by_full_address(full_address: str): def get_address_by_full_address(full_address: str) -> Document:
"""Retrieve an address document by its full_address field. Returns None if not found.""" """Retrieve an address document by its full_address field. Returns None if not found."""
print(f"DEBUG: Retrieving Address document with full_address: {full_address}") print(f"DEBUG: Retrieving Address document with full_address: {full_address}")
address_name = frappe.db.get_value("Address", {"full_address": full_address}) address_name = frappe.db.get_value("Address", {"full_address": full_address})
if address_name: if address_name:
address_doc = frappe.get_doc("Address", address_name) address_doc = DbService.get_or_throw("Address", address_name)
print("DEBUG: Address document found.") print("DEBUG: Address document found.")
return address_doc return address_doc
print("DEBUG: Address document not found.") print("DEBUG: Address document not found.")
@ -47,18 +56,18 @@ class AddressService:
return result return result
@staticmethod @staticmethod
def get(address_name: str): def get(address_name: str) -> Document:
"""Retrieve an address document by name. Returns None if not found.""" """Retrieve an address document by name. Returns None if not found."""
print(f"DEBUG: Retrieving Address document with name: {address_name}") print(f"DEBUG: Retrieving Address document with name: {address_name}")
if AddressService.exists(address_name): if AddressService.exists(address_name):
address_doc = frappe.get_doc("Address", address_name) address_doc = DbService.get_or_throw("Address", address_name)
print("DEBUG: Address document found.") print("DEBUG: Address document found.")
return address_doc return address_doc
print("DEBUG: Address document not found.") print("DEBUG: Address document not found.")
return None return None
@staticmethod @staticmethod
def get_or_throw(address_name: str): def get_or_throw(address_name: str) -> Document:
"""Retrieve an address document by name or throw an error if not found.""" """Retrieve an address document by name or throw an error if not found."""
address_doc = AddressService.get(address_name) address_doc = AddressService.get(address_name)
if address_doc: if address_doc:
@ -66,43 +75,43 @@ class AddressService:
raise ValueError(f"Address with name {address_name} does not exist.") raise ValueError(f"Address with name {address_name} does not exist.")
@staticmethod @staticmethod
def update_value(docname: str, fieldname: str, value, save: bool = True) -> frappe._dict: def update_value(doc_name: str, fieldname: str, value, save: bool = True) -> Document:
"""Update a specific field value of a document.""" """Update a specific field value of a document."""
print(f"DEBUG: Updating Address {docname}, setting {fieldname} to {value}") print(f"DEBUG: Updating Address {doc_name}, setting {fieldname} to {value}")
if AddressService.exists(docname) is False: if AddressService.exists(doc_name) is False:
raise ValueError(f"Address with name {docname} does not exist.") raise ValueError(f"Address with name {doc_name} does not exist.")
if save: if save:
print("DEBUG: Saving updated Address document.") print("DEBUG: Saving updated Address document.")
address_doc = AddressService.get_or_throw(docname) address_doc = AddressService.get_or_throw(doc_name)
setattr(address_doc, fieldname, value) setattr(address_doc, fieldname, value)
address_doc.save(ignore_permissions=True) address_doc.save(ignore_permissions=True)
else: else:
print("DEBUG: Not saving Address document as save=False.") print("DEBUG: Not saving Address document as save=False.")
frappe.db.set_value("Address", docname, fieldname, value) frappe.db.set_value("Address", doc_name, fieldname, value)
print(f"DEBUG: Updated Address {docname}: set {fieldname} to {value}") print(f"DEBUG: Updated Address {doc_name}: set {fieldname} to {value}")
return address_doc return address_doc
@staticmethod @staticmethod
def get_value(docname: str, fieldname: str): def get_value(doc_name: str, fieldname: str) -> any:
"""Get a specific field value of a document. Returns None if document does not exist.""" """Get a specific field value of a document. Returns None if document does not exist."""
print(f"DEBUG: Getting value of field {fieldname} from Address {docname}") print(f"DEBUG: Getting value of field {fieldname} from Address {doc_name}")
if not AddressService.exists(docname): if not AddressService.exists(doc_name):
print("DEBUG: Value cannot be retrieved; Address does not exist.") print("DEBUG: Value cannot be retrieved; Address does not exist.")
return None return None
value = frappe.db.get_value("Address", docname, fieldname) value = frappe.db.get_value("Address", doc_name, fieldname)
print(f"DEBUG: Retrieved value: {value}") print(f"DEBUG: Retrieved value: {value}")
return value return value
@staticmethod @staticmethod
def get_value_or_throw(docname: str, fieldname: str): def get_value_or_throw(doc_name: str, fieldname: str) -> any:
"""Get a specific field value of a document or throw an error if document does not exist.""" """Get a specific field value of a document or throw an error if document does not exist."""
value = AddressService.get_value(docname, fieldname) value = AddressService.get_value(doc_name, fieldname)
if value is not None: if value is not None:
return value return value
raise ValueError(f"Address with name {docname} does not exist.") raise ValueError(f"Address with name {doc_name} does not exist.")
@staticmethod @staticmethod
def create(address_data: dict): def create(address_data: dict) -> Document:
"""Create a new address.""" """Create a new address."""
print("DEBUG: Creating new Address with data:", address_data) print("DEBUG: Creating new Address with data:", address_data)
address = frappe.get_doc({ address = frappe.get_doc({
@ -114,26 +123,34 @@ class AddressService:
return address return address
@staticmethod @staticmethod
def link_address_to_customer(address_doc, customer_type, customer_name): def link_address_to_customer(address_doc: Document, customer_type: str, customer_name: str):
"""Link an address to a customer or lead.""" """Link an address to a customer or lead."""
print(f"DEBUG: Linking Address {address_doc.name} to {customer_type} {customer_name}") print(f"DEBUG: Linking Address {address_doc.name} to {customer_type} {customer_name}")
address_doc.customer_type = customer_type address_doc.customer_type = customer_type
address_doc.customer_name = customer_name address_doc.customer_name = customer_name
address_doc.append("links", {
"link_doctype": customer_type,
"link_name": customer_name
})
address_doc.save(ignore_permissions=True) address_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_doc.name} to {customer_type} {customer_name}") print(f"DEBUG: Linked Address {address_doc.name} to {customer_type} {customer_name}")
@staticmethod @staticmethod
def link_address_to_contact(address_doc, contact_name): def link_address_to_contact(address_doc: Document, contact_name: str):
"""Link an address to a contact.""" """Link an address to a contact."""
print(f"DEBUG: Linking Address {address_doc.name} to Contact {contact_name}") print(f"DEBUG: Linking Address {address_doc.name} to Contact {contact_name}")
address_doc.append("contacts", { address_doc.append("contacts", {
"contact": contact_name "contact": contact_name
}) })
address_doc.append("links", {
"link_doctype": "Contact",
"link_name": contact_name
})
address_doc.save(ignore_permissions=True) address_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_doc.name} to Contact {contact_name}") print(f"DEBUG: Linked Address {address_doc.name} to Contact {contact_name}")
@staticmethod @staticmethod
def create_address(address_data): def create_address(address_data: dict) -> Document:
"""Create a new address.""" """Create a new address."""
address = frappe.get_doc({ address = frappe.get_doc({
"doctype": "Address", "doctype": "Address",
@ -172,7 +189,7 @@ class AddressService:
print(f"DEBUG: Set link field {field} for Address {address_name} with link data {link}") print(f"DEBUG: Set link field {field} for Address {address_name} with link data {link}")
@staticmethod @staticmethod
def get_county_and_set(address_doc, save: bool = False): def get_county_and_set(address_doc: Document, save: bool = False):
"""Get the county from the address document and set it if not already set.""" """Get the county from the address document and set it if not already set."""
if not address_doc.county: if not address_doc.county:
print(f"DEBUG: Getting county for Address {address_doc.name}") print(f"DEBUG: Getting county for Address {address_doc.name}")
@ -196,7 +213,11 @@ class AddressService:
except (KeyError, IndexError): except (KeyError, IndexError):
return None return None
return { county_info = {
"county": county, "county": county,
"county_fips": county_fips "county_fips": county_fips
} }
AddressService.update_value(address_doc.name, "county", county_info, save)
AddressService.update_value(address_doc.name, "county_fips", county_fips, save)

View file

@ -1,4 +1,5 @@
import frappe import frappe
from frappe.model.document import Document
from .db_service import DbService from .db_service import DbService
from erpnext.crm.doctype.lead.lead import make_customer from erpnext.crm.doctype.lead.lead import make_customer
from .address_service import AddressService from .address_service import AddressService
@ -8,6 +9,12 @@ from .onsite_meeting_service import OnSiteMeetingService
class ClientService: class ClientService:
@staticmethod
def get_client_or_throw(client_name: str) -> Document:
"""Retrieve a Client document (Customer or Lead) or throw an error if it does not exist."""
doctype = ClientService.get_client_doctype(client_name)
return DbService.get_or_throw(doctype, client_name)
@staticmethod @staticmethod
def get_client_doctype(client_name: str) -> str: def get_client_doctype(client_name: str) -> str:
"""Determine if the client is a Customer or Lead.""" """Determine if the client is a Customer or Lead."""
@ -56,8 +63,9 @@ class ClientService:
update_quotations: bool = True, update_quotations: bool = True,
update_addresses: bool = True, update_addresses: bool = True,
update_contacts: bool = True, update_contacts: bool = True,
update_onsite_meetings: bool = True update_onsite_meetings: bool = True,
): update_companies: bool = True
) -> Document:
"""Convert a Lead to a Customer.""" """Convert a Lead to a Customer."""
print(f"DEBUG: Converting Lead {lead_name} to Customer") print(f"DEBUG: Converting Lead {lead_name} to Customer")
try: try:
@ -68,7 +76,7 @@ class ClientService:
customer_doc = make_customer(lead_doc.name) customer_doc = make_customer(lead_doc.name)
print(f"DEBUG: make_customer() returned document type: {type(customer_doc)}") print(f"DEBUG: make_customer() returned document type: {type(customer_doc)}")
print(f"DEBUG: Customer doc name: {customer_doc.name if hasattr(customer_doc, 'name') else 'NO NAME'}") print(f"DEBUG: Customer doc name: {customer_doc.name if hasattr(customer_doc, 'name') else 'NO NAME'}")
customer_doc.custom_billing_address = lead_doc.custom_billing_address
print("DEBUG: Calling customer_doc.insert()") print("DEBUG: Calling customer_doc.insert()")
customer_doc.insert(ignore_permissions=True) customer_doc.insert(ignore_permissions=True)
print(f"DEBUG: Customer inserted successfully: {customer_doc.name}") print(f"DEBUG: Customer inserted successfully: {customer_doc.name}")
@ -128,6 +136,16 @@ class ClientService:
except Exception as e: except Exception as e:
print(f"ERROR: Failed to link onsite meeting {meeting.get('onsite_meeting')}: {str(e)}") print(f"ERROR: Failed to link onsite meeting {meeting.get('onsite_meeting')}: {str(e)}")
frappe.log_error(f"Onsite meeting linking error: {str(e)}", "convert_lead_to_customer") frappe.log_error(f"Onsite meeting linking error: {str(e)}", "convert_lead_to_customer")
if update_companies:
print(f"DEBUG: Updating companies. Count: {len(lead_doc.get('companies', []))}")
for company in lead_doc.get("companies", []):
try:
print(f"DEBUG: Processing company: {company.get('company')}")
ClientService.append_link_v2(customer_doc.name, "companies", {"company": company.get("company")})
print(f"DEBUG: Linked company {company.get('company')} to customer")
except Exception as e:
print(f"ERROR: Failed to link company {company.get('company')}: {str(e)}")
frappe.log_error(f"Company linking error: {str(e)}", "convert_lead_to_customer")
print(f"DEBUG: Converted Lead {lead_name} to Customer {customer_doc.name}") print(f"DEBUG: Converted Lead {lead_name} to Customer {customer_doc.name}")
return customer_doc return customer_doc

View file

@ -1,10 +1,11 @@
import frappe import frappe
from frappe.model.document import Document
from .db_service import DbService from .db_service import DbService
class ContactService: class ContactService:
@staticmethod @staticmethod
def create(data: dict): def create(data: dict) -> Document:
"""Create a new contact.""" """Create a new contact."""
print("DEBUG: Creating new Contact with data:", data) print("DEBUG: Creating new Contact with data:", data)
contact = frappe.get_doc({ contact = frappe.get_doc({
@ -16,25 +17,33 @@ class ContactService:
return contact return contact
@staticmethod @staticmethod
def link_contact_to_customer(contact_doc, customer_type, customer_name): def link_contact_to_customer(contact_doc: Document, customer_type: str, customer_name: str):
"""Link a contact to a customer or lead.""" """Link a contact to a customer or lead."""
print(f"DEBUG: Linking Contact {contact_doc.name} to {customer_type} {customer_name}") print(f"DEBUG: Linking Contact {contact_doc.name} to {customer_type} {customer_name}")
contact_doc.customer_type = customer_type contact_doc.customer_type = customer_type
contact_doc.customer_name = customer_name contact_doc.customer_name = customer_name
contact_doc.append("links", {
"link_doctype": customer_type,
"link_name": customer_name
})
contact_doc.save(ignore_permissions=True) contact_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Contact {contact_doc.name} to {customer_type} {customer_name}") print(f"DEBUG: Linked Contact {contact_doc.name} to {customer_type} {customer_name}")
@staticmethod @staticmethod
def link_contact_to_address(contact_doc, address_name): def link_contact_to_address(contact_doc: Document, address_name: str):
"""Link an address to a contact.""" """Link an address to a contact."""
print(f"DEBUG: Linking Address {address_name} to Contact {contact_doc.name}") print(f"DEBUG: Linking Address {address_name} to Contact {contact_doc.name}")
contact_doc.append("addresses", { contact_doc.append("addresses", {
"address": address_name "address": address_name
}) })
contact_doc.append("links", {
"link_doctype": "Address",
"link_name": address_name
})
contact_doc.save(ignore_permissions=True) contact_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_name} to Contact {contact_doc.name}") print(f"DEBUG: Linked Address {address_name} to Contact {contact_doc.name}")
@staticmethod @staticmethod
def get_or_throw(contact_name: str): def get_or_throw(contact_name: str) -> Document:
"""Retrieve a Contact document or throw an error if it does not exist.""" """Retrieve a Contact document or throw an error if it does not exist."""
return DbService.get_or_throw("Contact", contact_name) return DbService.get_or_throw("Contact", contact_name)

View file

@ -394,7 +394,11 @@ class Api {
static async getTaskStatusOptions() { static async getTaskStatusOptions() {
console.log("DEBUG: API - Loading Task Status options form the backend."); console.log("DEBUG: API - Loading Task Status options form the backend.");
const result = await this.request(FRAPPE_GET_TASKS_STATUS_OPTIONS, {}); const result = await this.request(FRAPPE_GET_TASKS_STATUS_OPTIONS, {});
return result return result;
}
static async setTaskStatus(taskName, newStatus) {
return await this.request(FRAPPE_SET_TASK_STATUS_METHOD, { taskName, newStatus });
} }
// ============================================================================ // ============================================================================

View file

@ -55,6 +55,7 @@
:id="`isBilling-${index}`" :id="`isBilling-${index}`"
v-model="address.isBillingAddress" v-model="address.isBillingAddress"
:disabled="isSubmitting" :disabled="isSubmitting"
@change="handleBillingChange(index)"
style="margin-top: 0" style="margin-top: 0"
/> />
<label :for="`isBilling-${index}`"><i class="pi pi-dollar" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Billing Address</label> <label :for="`isBilling-${index}`"><i class="pi pi-dollar" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Billing Address</label>
@ -251,6 +252,36 @@ const formatAddressLine = (index, field, event) => {
localFormData.value.addresses[index][field] = formatted; localFormData.value.addresses[index][field] = formatted;
}; };
const handleBillingChange = (selectedIndex) => {
// If the selected address is now checked as billing
if (localFormData.value.addresses[selectedIndex].isBillingAddress) {
// Uncheck all other addresses
localFormData.value.addresses.forEach((addr, idx) => {
if (idx !== selectedIndex) {
addr.isBillingAddress = false;
}
});
// Auto-select all contacts
if (contactOptions.value.length > 0) {
localFormData.value.addresses[selectedIndex].contacts = contactOptions.value.map(
(opt) => opt.value,
);
}
// Auto-select primary contact
if (localFormData.value.contacts && localFormData.value.contacts.length > 0) {
const primaryIndex = localFormData.value.contacts.findIndex((c) => c.isPrimary);
if (primaryIndex !== -1) {
localFormData.value.addresses[selectedIndex].primaryContact = primaryIndex;
} else {
// Fallback to first contact if no primary found
localFormData.value.addresses[selectedIndex].primaryContact = 0;
}
}
}
};
const handleZipcodeInput = async (index, event) => { const handleZipcodeInput = async (index, event) => {
const input = event.target.value; const input = event.target.value;
@ -299,7 +330,7 @@ const handleZipcodeInput = async (index, event) => {
.form-section { .form-section {
background: var(--surface-card); background: var(--surface-card);
border-radius: 6px; border-radius: 6px;
padding: 1rem; padding: 0.75rem;
border: 1px solid var(--surface-border); border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease; transition: box-shadow 0.2s ease;
@ -313,7 +344,7 @@ const handleZipcodeInput = async (index, event) => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border); border-bottom: 2px solid var(--surface-border);
} }

View file

@ -254,7 +254,7 @@ defineExpose({
.form-section { .form-section {
background: var(--surface-card); background: var(--surface-card);
border-radius: 6px; border-radius: 6px;
padding: 1rem; padding: 0.75rem;
border: 1px solid var(--surface-border); border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease; transition: box-shadow 0.2s ease;
@ -268,7 +268,7 @@ defineExpose({
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border); border-bottom: 2px solid var(--surface-border);
} }

View file

@ -285,7 +285,7 @@ defineExpose({});
.form-section { .form-section {
background: var(--surface-card); background: var(--surface-card);
border-radius: 6px; border-radius: 6px;
padding: 1rem; padding: 0.75rem;
border: 1px solid var(--surface-border); border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease; transition: box-shadow 0.2s ease;
@ -299,7 +299,7 @@ defineExpose({});
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border); border-bottom: 2px solid var(--surface-border);
} }

View file

@ -1074,8 +1074,9 @@ const handleCancel = () => {
.status-cards { .status-cards {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 1rem; gap: 0.5rem;
width: 100%;
} }
.status-card { .status-card {

View file

@ -50,19 +50,30 @@
placeholder="Select a contact" placeholder="Select a contact"
:disabled="!formData.address || !isEditable" :disabled="!formData.address || !isEditable"
fluid fluid
/> >
<template #option="slotProps">
<div class="contact-option">
<div class="contact-name">{{ slotProps.option.label }}</div>
<div class="contact-detail" v-if="slotProps.option.email">{{ slotProps.option.email }}</div>
<div class="contact-detail" v-if="slotProps.option.phone">{{ slotProps.option.phone }}</div>
</div>
</template>
</Select>
<div v-if="selectedContact" class="verification-info"> <div v-if="selectedContact" class="verification-info">
<strong>Email:</strong> {{ selectedContact.emailId || "N/A" }} <br /> <strong>Email:</strong> {{ selectedContact.emailId || "N/A" }} <br />
<strong>Phone:</strong> {{ selectedContact.phone || "N/A" }} <br /> <strong>Phone:</strong> {{ selectedContact.phone || "N/A" }} <br />
<strong>Primary Contact:</strong> <strong>Primary Contact:</strong>
{{ selectedContact.isPrimaryContact ? "Yes" : "No" }} {{ selectedAddress?.primaryContact === selectedContact.name ? "Yes" : "No" }}
</div> </div>
</div> </div>
<!-- Template Section --> <!-- Template Section -->
<div class="template-section"> <div class="template-section">
<div v-if="isNew"> <div v-if="isNew">
<label for="template" class="field-label">From Template</label> <label for="template" class="field-label">
From Template
<i class="pi pi-question-circle help-icon" v-tooltip.right="'Pre-fills estimate items and sets default Project Template. Serves as a starting point for this estimate.'"></i>
</label>
<div class="template-input-group"> <div class="template-input-group">
<Select <Select
v-model="selectedTemplate" v-model="selectedTemplate"
@ -99,6 +110,7 @@
<label for="projectTemplate" class="field-label"> <label for="projectTemplate" class="field-label">
Project Template Project Template
<span class="required">*</span> <span class="required">*</span>
<i class="pi pi-question-circle help-icon" v-tooltip.right="'Used when generating a Project from this estimate. Defines tasks and default settings for the new Project.'"></i>
</label> </label>
<Select <Select
v-model="formData.projectTemplate" v-model="formData.projectTemplate"
@ -122,6 +134,8 @@
/> />
<div v-for="(item, index) in selectedItems" :key="item.itemCode" class="item-row"> <div v-for="(item, index) in selectedItems" :key="item.itemCode" class="item-row">
<span>{{ item.itemName }}</span> <span>{{ item.itemName }}</span>
<div class="input-wrapper">
<span class="input-label">Quantity</span>
<InputNumber <InputNumber
v-model="item.qty" v-model="item.qty"
:min="1" :min="1"
@ -131,7 +145,10 @@
@input="onQtyChange(item)" @input="onQtyChange(item)"
class="qty-input" class="qty-input"
/> />
</div>
<span>Price: ${{ (item.standardRate || 0).toFixed(2) }}</span> <span>Price: ${{ (item.standardRate || 0).toFixed(2) }}</span>
<div class="input-wrapper">
<span class="input-label">Discount</span>
<div class="discount-container"> <div class="discount-container">
<div class="discount-input-wrapper"> <div class="discount-input-wrapper">
<InputNumber <InputNumber
@ -175,6 +192,7 @@
/> />
</div> </div>
</div> </div>
</div>
<span>Total: ${{ ((item.qty || 0) * (item.standardRate || 0) - (item.discountAmount || 0)).toFixed(2) }}</span> <span>Total: ${{ ((item.qty || 0) * (item.standardRate || 0) - (item.discountAmount || 0)).toFixed(2) }}</span>
<Button <Button
v-if="isEditable" v-if="isEditable"
@ -202,7 +220,7 @@
/> />
</div> </div>
<div v-if="estimate"> <div v-if="estimate">
<Button label="Send Estimate" @click="showConfirmationModal = true" :disabled="estimate.customSent === 1"/> <Button label="Send Estimate" @click="initiateSendEstimate" :disabled="estimate.customSent === 1"/>
</div> </div>
<div v-if="estimate && estimate.customSent === 1" class="response-status"> <div v-if="estimate && estimate.customSent === 1" class="response-status">
<h4>Customer Response:</h4> <h4>Customer Response:</h4>
@ -296,6 +314,27 @@
</div> </div>
</Modal> </Modal>
<!-- Down Payment Warning Modal -->
<Modal
:visible="showDownPaymentWarningModal"
@update:visible="showDownPaymentWarningModal = $event"
@close="showDownPaymentWarningModal = false"
:options="{ showActions: false }"
>
<template #title>Warning</template>
<div class="modal-content">
<p>Down payment is not required for this estimate. Ok to proceed?</p>
<div class="confirmation-buttons">
<Button
label="No"
@click="showDownPaymentWarningModal = false"
severity="secondary"
/>
<Button label="Yes" @click="proceedFromWarning" />
</div>
</div>
</Modal>
<!-- Confirmation Modal --> <!-- Confirmation Modal -->
<Modal <Modal
:visible="showConfirmationModal" :visible="showConfirmationModal"
@ -356,6 +395,7 @@ import InputText from "primevue/inputtext";
import InputNumber from "primevue/inputnumber"; import InputNumber from "primevue/inputnumber";
import Button from "primevue/button"; import Button from "primevue/button";
import Select from "primevue/select"; import Select from "primevue/select";
import Tooltip from "primevue/tooltip";
import Api from "../../api"; import Api from "../../api";
import DataUtils from "../../utils"; import DataUtils from "../../utils";
import { useLoadingStore } from "../../stores/loading"; import { useLoadingStore } from "../../stores/loading";
@ -367,6 +407,7 @@ const router = useRouter();
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const company = useCompanyStore(); const company = useCompanyStore();
const vTooltip = Tooltip;
const addressQuery = computed(() => route.query.address || ""); const addressQuery = computed(() => route.query.address || "");
const nameQuery = computed(() => route.query.name || ""); const nameQuery = computed(() => route.query.name || "");
@ -406,6 +447,7 @@ const selectedTemplate = ref(null);
const showAddressModal = ref(false); const showAddressModal = ref(false);
const showAddItemModal = ref(false); const showAddItemModal = ref(false);
const showConfirmationModal = ref(false); const showConfirmationModal = ref(false);
const showDownPaymentWarningModal = ref(false);
const showResponseModal = ref(false); const showResponseModal = ref(false);
const showSaveTemplateModal = ref(false); const showSaveTemplateModal = ref(false);
const addressSearchResults = ref([]); const addressSearchResults = ref([]);
@ -459,10 +501,20 @@ const fetchTemplates = async () => {
const templateParam = route.query.template; const templateParam = route.query.template;
if (templateParam) { if (templateParam) {
console.log("DEBUG: Setting template from query param:", templateParam); console.log("DEBUG: Setting template from query param:", templateParam);
console.log("DEBUG: Available templates:", templates.value.map(t => t.name));
selectedTemplate.value = templateParam; // Find template by name (ID) or templateName (Label)
const matchedTemplate = templates.value.find(t =>
t.name === templateParam || t.templateName === templateParam
);
if (matchedTemplate) {
console.log("DEBUG: Found matched template:", matchedTemplate);
selectedTemplate.value = matchedTemplate.name;
// Trigger template change to load items and project template // Trigger template change to load items and project template
onTemplateChange(); onTemplateChange();
} else {
console.log("DEBUG: No matching template found for param:", templateParam);
}
} }
} catch (error) { } catch (error) {
console.error("Error fetching templates:", error); console.error("Error fetching templates:", error);
@ -580,8 +632,10 @@ const selectAddress = async (address) => {
contactOptions.value = contacts.value.map((c) => ({ contactOptions.value = contacts.value.map((c) => ({
label: `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.name, label: `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.name,
value: c.name, value: c.name,
email: c.emailId,
phone: c.phone || c.mobileNo
})); }));
const primary = contacts.value.find((c) => c.isPrimaryContact); const primary = contacts.value.find((c) => c.name === selectedAddress.value.primaryContact);
console.log("DEBUG: Selected address contacts:", contacts.value); console.log("DEBUG: Selected address contacts:", contacts.value);
const existingContactName = estimate.value ? contacts.value.find((c) => c.fullName === estimate.value.partyName)?.name || "" : null; const existingContactName = estimate.value ? contacts.value.find((c) => c.fullName === estimate.value.partyName)?.name || "" : null;
// Check for contact query param, then existing contact, then primary, then first contact // Check for contact query param, then existing contact, then primary, then first contact
@ -721,6 +775,19 @@ const getResponseText = (response) => {
return "No response yet"; return "No response yet";
}; };
const initiateSendEstimate = () => {
if (!formData.requiresHalfPayment) {
showDownPaymentWarningModal.value = true;
} else {
showConfirmationModal.value = true;
}
};
const proceedFromWarning = () => {
showDownPaymentWarningModal.value = false;
showConfirmationModal.value = true;
};
const confirmAndSendEstimate = async () => { const confirmAndSendEstimate = async () => {
loadingStore.setLoading(true, "Sending estimate..."); loadingStore.setLoading(true, "Sending estimate...");
const updatedEstimate = await Api.sendEstimateEmail(estimate.value.name); const updatedEstimate = await Api.sendEstimateEmail(estimate.value.name);
@ -1265,5 +1332,38 @@ onMounted(async () => {
.field-group { .field-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.help-icon {
margin-left: 0.5rem;
font-size: 0.9rem;
color: #2196f3;
cursor: help;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.input-label {
font-size: 0.8rem;
color: #666;
font-weight: 500;
}
.contact-option {
display: flex;
flex-direction: column;
}
.contact-name {
font-weight: 500;
}
.contact-detail {
font-size: 0.85rem;
color: #666;
}
</style> </style>
<parameter name="filePath"></parameter> <parameter name="filePath"></parameter>

View file

@ -37,6 +37,7 @@
<DataTable <DataTable
:data="tableData" :data="tableData"
:columns="columns" :columns="columns"
:tableActions="tableActions"
tableName="jobtasks" tableName="jobtasks"
:lazy="true" :lazy="true"
:totalRecords="totalRecords" :totalRecords="totalRecords"
@ -80,9 +81,49 @@ const columns = [
{ label: "ID", fieldName: "id", type: "text", sortable: true, filterable: true }, { label: "ID", fieldName: "id", type: "text", sortable: true, filterable: true },
{ label: "Address", fieldname: "address", type: "text" }, { label: "Address", fieldname: "address", type: "text" },
{ label: "Category", fieldName: "", type: "text", sortable: true, filterable: true }, { label: "Category", fieldName: "", type: "text", sortable: true, filterable: true },
{ label: "Status", fieldName: "status", type: "text", sortable: true, filterable: true }, { label: "Status", fieldName: "status", type: "status", sortable: true, filterable: true },
]; ];
const statusOptions = ref([
"Open",
"Working",
"Pending Review",
"Overdue",
"Completed",
"Cancelled",
]);
const tableActions = computed(() => [
{
label: "Set Status",
rowAction: true,
type: "menu",
menuItems: statusOptions.value.map((option) => ({
label: option,
command: async (rowData) => {
console.log("Setting status for row:", rowData, "to:", option);
try {
await Api.setTaskStatus(rowData.id, option);
// Find and update the row in the table data
const rowIndex = tableData.value.findIndex((row) => row.id === rowData.id);
if (rowIndex >= 0) {
// Update reactively
tableData.value[rowIndex].status = option;
notifications.addSuccess(`Status updated to ${option}`);
}
} catch (error) {
console.error("Error updating status:", error);
notifications.addError("Failed to update status");
}
},
})),
layout: {
priority: "menu",
},
},
]);
const handleLazyLoad = async (event) => { const handleLazyLoad = async (event) => {
console.log("Task list on Job Page - handling lazy load:", event); console.log("Task list on Job Page - handling lazy load:", event);
try { try {
@ -191,6 +232,16 @@ const handleLazyLoad = async (event) => {
onMounted(async () => { onMounted(async () => {
console.log("DEBUG: Query params:", route.query); console.log("DEBUG: Query params:", route.query);
try {
const optionsResult = await Api.getTaskStatusOptions();
if (optionsResult && optionsResult.length > 0) {
statusOptions.value = optionsResult;
}
} catch (error) {
console.error("Error loading task status options:", error);
}
if (jobIdQuery.value) { if (jobIdQuery.value) {
// Viewing existing Job // Viewing existing Job
try { try {

View file

@ -40,8 +40,7 @@ const showCompleted = ref(false);
const statusOptions = ref([ const statusOptions = ref([
"Open", "Open",
"Working", "Working",
"Pending", "Pending Review",
"Review",
"Overdue", "Overdue",
"Completed", "Completed",
"Cancelled", "Cancelled",
@ -92,7 +91,7 @@ const tableActions = [
console.log("Setting status for row:", rowData, "to:", option); console.log("Setting status for row:", rowData, "to:", option);
try { try {
// Uncomment when API is ready // Uncomment when API is ready
// await Api.setTaskStatus(rowData.id, option); await Api.setTaskStatus(rowData.id, option);
// Find and update the row in the table data // Find and update the row in the table data
const rowIndex = tableData.value.findIndex(row => row.id === rowData.id); const rowIndex = tableData.value.findIndex(row => row.id === rowData.id);