Compare commits

..

1 commit

Author SHA1 Message Date
90fb04e44b update hooks and fixtures 2026-01-16 11:14:28 -06:00
63 changed files with 9188 additions and 22813 deletions

View file

@ -1,7 +1,7 @@
import frappe import frappe
import json import json
from custom_ui.db_utils import build_error_response, build_success_response from custom_ui.db_utils import build_error_response, build_success_response
from custom_ui.services import ClientService, AddressService, ContactService from custom_ui.services import ClientService, AddressService
@frappe.whitelist() @frappe.whitelist()
def get_address_by_full_address(full_address): def get_address_by_full_address(full_address):
@ -35,33 +35,6 @@ def get_address(address_name):
# except Exception as e: # except Exception as e:
# return build_error_response(str(e), 500) # return build_error_response(str(e), 500)
@frappe.whitelist()
def create_address(address_data, company, customer_name):
"""Create a new address."""
print(f"DEBUG: create_address called with address_data: {address_data}, company: {company}, customer_name: {customer_name}")
if isinstance(address_data, str):
address_data = json.loads(address_data)
customer_doctype = ClientService.get_client_doctype(customer_name)
address_data["customer_name"] = customer_name
address_data["customer_type"] = customer_doctype
address_data["address_title"] = AddressService.build_address_title(customer_name, address_data)
address_data["address_type"] = "Service"
address_data["custom_billing_address"] = 0
address_data["is_service_address"] = 1
address_data["country"] = "United States"
address_data["companies"] = [{ "company": company }]
print(f"DEBUG: Final address_data before creation: {address_data}")
try:
address_doc = AddressService.create_address(address_data)
for contact in address_data.get("contacts", []):
AddressService.link_address_to_contact(address_doc, contact)
contact_doc = ContactService.get_or_throw(contact)
ContactService.link_contact_to_address(contact_doc, address_doc)
ClientService.append_link_v2(customer_name, "properties", {"address": address_doc.name})
return build_success_response(address_doc.as_dict())
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."""

View file

@ -4,17 +4,15 @@ from custom_ui.db_utils import build_error_response, build_success_response, pro
from custom_ui.services import DbService, ClientService, AddressService, ContactService from custom_ui.services import DbService, ClientService, AddressService, ContactService
@frappe.whitelist() @frappe.whitelist()
def get_week_bid_meetings(week_start, week_end, company): def get_week_bid_meetings(week_start, week_end):
"""Get On-Site Meetings scheduled within a specific week.""" """Get On-Site Meetings scheduled within a specific week."""
try: try:
meetings = frappe.db.get_all( meetings = frappe.db.get_all(
"On-Site Meeting", "On-Site Meeting",
fields=["*"], fields=["*"],
filters=[ filters=[
["status", "!=", "Cancelled"],
["start_time", ">=", week_start], ["start_time", ">=", week_start],
["start_time", "<=", week_end], ["start_time", "<=", week_end]
["company", "=", company]
], ],
order_by="start_time asc" order_by="start_time asc"
) )
@ -29,19 +27,7 @@ def get_week_bid_meetings(week_start, week_end, company):
return build_error_response(str(e), 500) return build_error_response(str(e), 500)
@frappe.whitelist() @frappe.whitelist()
def get_bid_meeting_note_form(project_template): def get_bid_meetings(fields=["*"], filters={}):
bid_meeting_note_form_name = frappe.db.get_value("Project Template", project_template, "bid_meeting_note_form")
if not bid_meeting_note_form_name:
return build_error_response(f"No Bid Meeting Note Form configured for Project Template '{project_template}'", 404)
try:
note_form = frappe.get_doc("Bid Meeting Note Form", bid_meeting_note_form_name)
return build_success_response(note_form.as_dict())
except Exception as e:
frappe.log_error(message=str(e), title="Get Bid Meeting Note Form Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_bid_meetings(fields=["*"], filters={}, company=None):
"""Get paginated On-Site Meetings with filtering and sorting support.""" """Get paginated On-Site Meetings with filtering and sorting support."""
try: try:
print("DEBUG: Raw bid meeting options received:", filters) print("DEBUG: Raw bid meeting options received:", filters)
@ -65,26 +51,15 @@ def get_bid_meetings(fields=["*"], filters={}, company=None):
frappe.log_error(message=str(e), title="Get On-Site Meetings Failed") frappe.log_error(message=str(e), title="Get On-Site Meetings Failed")
return build_error_response(str(e), 500) return build_error_response(str(e), 500)
@frappe.whitelist()
def get_bid_meeting_note(name):
"""Get a specific Bid Meeting Note by name."""
try:
note = frappe.get_doc("Bid Meeting Note", name)
return build_success_response(note.as_dict())
except frappe.DoesNotExistError:
return build_error_response(f"Bid Meeting Note '{name}' does not exist.", 404)
except Exception as e:
frappe.log_error(message=str(e), title="Get Bid Meeting Note Failed")
return build_error_response(str(e), 500)
@frappe.whitelist() @frappe.whitelist()
def get_unscheduled_bid_meetings(company): def get_unscheduled_bid_meetings():
"""Get On-Site Meetings that are unscheduled.""" """Get On-Site Meetings that are unscheduled."""
try: try:
meetings = frappe.db.get_all( meetings = frappe.db.get_all(
"On-Site Meeting", "On-Site Meeting",
fields=["*"], fields=["*"],
filters={"status": "Unscheduled", "company": company}, filters={"status": "Unscheduled"},
order_by="creation desc" order_by="creation desc"
) )
for meeting in meetings: for meeting in meetings:
@ -99,62 +74,6 @@ def get_unscheduled_bid_meetings(company):
frappe.log_error(message=str(e), title="Get Unscheduled On-Site Meetings Failed") frappe.log_error(message=str(e), title="Get Unscheduled On-Site Meetings Failed")
return build_error_response(str(e), 500) return build_error_response(str(e), 500)
@frappe.whitelist()
def submit_bid_meeting_note_form(bid_meeting, project_template, fields, form_template):
"""Submit Bid Meeting Note Form data for a specific On-Site Meeting."""
if isinstance(fields, str):
fields = json.loads(fields)
try:
print(f"DEBUG: Submitting Bid Meeting Note Form for meeting='{bid_meeting}' from template='{form_template}' with fields='{fields}'")
meeting = DbService.get_or_throw("On-Site Meeting", bid_meeting)
# Update fields on the meeting
meeting_note_field_docs = [{
"label": field.get("label"),
"type": field.get("type"),
"value": json.dumps(field.get("value")) if isinstance(field.get("value"), (list, dict)) else field.get("value"),
"row": field.get("row"),
"column": field.get("column"),
"value_doctype": field.get("doctype_for_select"),
"available_options": field.get("options"),
"include_available_options": field.get("include_available_options", False),
"conditional_on_field": field.get("conditional_on_field"),
"conditional_on_value": field.get("conditional_on_value"),
"doctype_label_field": field.get("doctype_label_field")
} for field in fields]
new_bid_meeting_note_doc = frappe.get_doc({
"doctype": "Bid Meeting Note",
"bid_meeting": bid_meeting,
"project_template": project_template,
"form_template": form_template,
"fields": meeting_note_field_docs
})
new_bid_meeting_note_doc.insert(ignore_permissions=True)
for field_row, field in zip(new_bid_meeting_note_doc.fields, fields):
print(f"DEBUG: {field_row.label} - {field.get("label")}")
if not isinstance(field.get("value"), list):
continue
for item in field["value"]:
if not isinstance(item, dict):
continue
new_bid_meeting_note_doc.append("quantities", {
"meeting_note_field": field_row.name,
"item": item.get("item"),
"quantity": item.get("quantity")
})
new_bid_meeting_note_doc.save(ignore_permissions=True)
meeting.bid_notes = new_bid_meeting_note_doc.name
meeting.status = "Completed"
meeting.save()
frappe.db.commit()
return build_success_response(meeting.as_dict())
except frappe.DoesNotExistError:
return build_error_response(f"On-Site Meeting '{bid_meeting}' does not exist.", 404)
except Exception as e:
frappe.log_error(message=str(e), title="Submit Bid Meeting Note Form Failed")
return build_error_response(str(e), 500)
@frappe.whitelist() @frappe.whitelist()
def get_bid_meeting(name): def get_bid_meeting(name):
@ -170,9 +89,6 @@ def get_bid_meeting(name):
if meeting_dict.get("contact"): if meeting_dict.get("contact"):
contact_doc = ContactService.get_or_throw(meeting_dict["contact"]) contact_doc = ContactService.get_or_throw(meeting_dict["contact"])
meeting_dict["contact"] = contact_doc.as_dict() meeting_dict["contact"] = contact_doc.as_dict()
if meeting_dict.get("bid_notes"):
bid_meeting_note_doc = frappe.get_doc("Bid Meeting Note", meeting_dict["bid_notes"])
meeting_dict["bid_notes"] = bid_meeting_note_doc.as_dict()
return build_success_response(meeting_dict) return build_success_response(meeting_dict)
except frappe.DoesNotExistError: except frappe.DoesNotExistError:

View file

@ -1,5 +1,5 @@
import frappe, json import frappe, json
from custom_ui.db_utils import build_error_response, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, map_lead_client, build_address_title, normalize_name from custom_ui.db_utils import build_error_response, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, map_lead_client, build_address_title
from erpnext.crm.doctype.lead.lead import make_customer from erpnext.crm.doctype.lead.lead import make_customer
from custom_ui.api.db.addresses import address_exists from custom_ui.api.db.addresses import address_exists
from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links
@ -169,81 +169,6 @@ def get_client_v2(client_name):
return build_error_response(str(e), 500) return build_error_response(str(e), 500)
@frappe.whitelist()
def get_clients_table_data_v2(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated client table data with filtering and sorting support."""
try:
filters = json.loads(filters) if isinstance(filters, str) else filters
sortings = json.loads(sortings) if isinstance(sortings, str) else sortings
page = int(page)
page_size = int(page_size)
print("DEBUG: Raw client table query received:", {
"filters": filters,
"sortings": sortings,
"page": page,
"page_size": page_size
})
where_clauses = []
values = []
if filters.get("company"):
where_clauses.append("c.company = %s")
values.append(filters["company"]["value"])
if filters.get("address"):
where_clauses.append("a.full_address LIKE %s")
values.append(f"%{filters['address']['value']}%")
if filters.get("customer_name"):
where_clauses.append("a.customer_name LIKE %s")
values.append(f"%{filters['customer_name']['value']}%")
where_sql = ""
if where_clauses:
where_sql = "WHERE " + " AND ".join(where_clauses)
offset = (page - 1) * page_size
address_names = frappe.db.sql(f"""
SELECT DISTINCT a.name
FROM `tabAddress` a
LEFT JOIN `tabAddress Company Link` c ON c.parent = a.name
{where_sql}
ORDER BY a.modified DESC
LIMIT %s OFFSET %s
""", values + [page_size, offset], as_dict=True)
print("DEBUG: Address names retrieved:", address_names)
count = frappe.db.sql(f"""
SELECT COUNT(DISTINCT a.name) as count
FROM `tabAddress` a
LEFT JOIN `tabAddress Company Link` c ON c.parent = a.name
{where_sql}
""", values, as_dict=True)[0]["count"]
tableRows = []
for address_name in address_names:
address = AddressService.get_or_throw(address_name["name"])
tableRow = {}
tableRow["id"] = address.name
tableRow["address"] = address.full_address
tableRow["client_type"] = address.customer_type
tableRow["customer_name"] = normalize_name(address.customer_name, "-#-")
tableRow["companies"] = ", ".join([link.company for link in address.get("companies", [])])
tableRows.append(tableRow)
table_data = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(table_data)
except frappe.ValidationError as ve:
return build_error_response(str(ve), 400)
except Exception as e:
print("ERROR in get_clients_table_data_v2:", str(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."""
@ -445,16 +370,12 @@ def upsert_client(data):
address_docs = [] address_docs = []
for address in addresses: for address in addresses:
is_billing = True if address.get("is_billing_address") else False is_billing = True if address.get("is_billing_address") else False
is_service = True if address.get("is_service_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": AddressService.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", "address_type": "Billing" if is_billing else "Service",
"custom_billing_address": is_billing,
"is_service_address": is_service,
"is_primary_address": is_billing,
"city": address.get("city"), "city": address.get("city"),
"state": address.get("state"), "state": address.get("state"),
"country": "United States", "country": "United States",
@ -484,12 +405,13 @@ def upsert_client(data):
"address": address_doc.name "address": address_doc.name
}) })
client_doc.save(ignore_permissions=True) client_doc.save(ignore_permissions=True)
client_dict = client_doc.as_dict()
client_dict["contacts"] = [contact.as_dict() for contact in contact_docs]
client_dict["addresses"] = [address.as_dict() for address in address_docs]
frappe.local.message_log = [] frappe.local.message_log = []
return build_success_response(client_dict) return build_success_response({
"customer": client_doc.as_dict(),
"address": [address_doc.as_dict() for address_doc in address_docs],
"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)
except Exception as e: except Exception as e:

View file

@ -1,49 +0,0 @@
import frappe, json
from custom_ui.db_utils import build_success_response, build_error_response
# ===============================================================================
# EMPLOYEE API METHODS
# ===============================================================================
@frappe.whitelist()
def get_employees(company: str, roles=[]):
"""Get a list of employees for a given company. Can be filtered by role."""
roles = json.loads(roles) if isinstance(roles, str) else roles
filters = {"company": company}
if roles:
filters["designation"] = ["in", roles]
try:
employee_names = frappe.get_all(
"Employee",
filters=filters,
pluck="name"
)
employees = [frappe.get_doc("Employee", name).as_dict() for name in employee_names]
return build_success_response(employees)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_employees_organized(company: str, roles=[]):
"""Get all employees for a company organized by designation."""
roles = json.loads(roles) if isinstance(roles, str) else roles
try:
filters = {"company": company}
if roles:
filters["designation"] = ["in", roles]
employee_names = frappe.get_all(
"Employee",
filters=filters,
pluck="name"
)
employees = [frappe.get_doc("Employee", name).as_dict() for name in employee_names]
organized = {}
for emp in employees:
designation = emp.get("designation", "Unassigned")
if designation not in organized:
organized[designation] = []
organized[designation].append(emp)
return build_success_response(organized)
except Exception as e:
return build_error_response(str(e), 500)

View file

@ -1,7 +1,7 @@
import frappe, json import frappe, json
from frappe.utils.pdf import get_pdf from frappe.utils.pdf import get_pdf
from custom_ui.api.db.general import get_doc_history from custom_ui.api.db.general import get_doc_history
from custom_ui.db_utils import DbUtils, 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 werkzeug.wrappers import Response from werkzeug.wrappers import Response
from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
from custom_ui.services import DbService, ClientService, AddressService, ContactService from custom_ui.services import DbService, ClientService, AddressService, ContactService
@ -10,38 +10,6 @@ from custom_ui.services import DbService, ClientService, AddressService, Contact
# ESTIMATES & INVOICES API METHODS # ESTIMATES & INVOICES API METHODS
# =============================================================================== # ===============================================================================
@frappe.whitelist()
def get_estimate_table_data_v2(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated estimate table data with filtering and sorting."""
print("DEBUG: Raw estimate options received:", filters, sortings, page, page_size)
filters, sortings, page, page_size = DbUtils.process_datatable_request(filters, sortings, page, page_size)
sortings = "modified desc" if not sortings else sortings
count = frappe.db.count("Quotation", filters=filters)
print(f"DEBUG: Number of estimates returned: {count}")
estimate_names = frappe.db.get_all(
"Quotation",
filters=filters,
pluck="name",
limit=page_size,
start=(page) * page_size,
order_by=sortings
)
estimates = [frappe.get_doc("Quotation", name).as_dict() for name in estimate_names]
tableRows = []
for estimate in estimates:
tableRow = {
"id": estimate["name"],
"address": frappe.db.get_value("Address", estimate.get("custom_job_address"), "full_address"),
# strip "-#-" from actual_customer_name and anything that comes after it
"customer": estimate.get("actual_customer_name").split("-#-")[0] if estimate.get("actual_customer_name") else estimate.get("customer_name") if estimate.get("customer_name") else "",
"status": estimate.get("custom_current_status", ""),
"order_type": estimate.get("order_type", ""),
}
tableRows.append(tableRow)
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(table_data_dict)
@frappe.whitelist() @frappe.whitelist()
def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10): def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
@ -430,7 +398,7 @@ def upsert_estimate(data):
# estimate.custom_job_address = data.get("address_name") # estimate.custom_job_address = data.get("address_name")
# estimate.party_name = data.get("customer") # estimate.party_name = data.get("customer")
# estimate.contact_person = data.get("contact_name") # estimate.contact_person = data.get("contact_name")
estimate.requires_half_payment = data.get("requires_half_payment", 0) estimate.custom_requires_half_payment = data.get("requires_half_payment", 0)
estimate.custom_project_template = project_template estimate.custom_project_template = project_template
estimate.custom_quotation_template = data.get("quotation_template", None) estimate.custom_quotation_template = data.get("quotation_template", None)
# estimate.company = data.get("company") # estimate.company = data.get("company")
@ -453,7 +421,6 @@ def upsert_estimate(data):
}) })
estimate.save() estimate.save()
frappe.db.commit()
estimate_dict = estimate.as_dict() estimate_dict = estimate.as_dict()
estimate_dict["history"] = get_doc_history("Quotation", estimate_name) estimate_dict["history"] = get_doc_history("Quotation", estimate_name)
print(f"DEBUG: Estimate updated: {estimate.name}") print(f"DEBUG: Estimate updated: {estimate.name}")
@ -471,7 +438,7 @@ def upsert_estimate(data):
# print("DEBUG: No billing address found for client:", client_doc.name) # 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",
"requires_half_payment": data.get("requires_half_payment", 0), "custom_requires_half_payment": data.get("requires_half_payment", 0),
"custom_job_address": data.get("address_name"), "custom_job_address": data.get("address_name"),
"custom_current_status": "Draft", "custom_current_status": "Draft",
"contact_email": data.get("contact_email"), "contact_email": data.get("contact_email"),
@ -485,7 +452,7 @@ def upsert_estimate(data):
"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),
"custom_quotation_template": data.get("quotation_template", None), "custom_quotation_template": data.get("quotation_template", None),
"from_onsite_meeting": data.get("from_onsite_meeting", None) "from_onsite_meeting": data.get("onsite_meeting", None)
}) })
for item in data.get("items", []): for item in data.get("items", []):
item = json.loads(item) if isinstance(item, str) else item item = json.loads(item) if isinstance(item, str) else item
@ -507,31 +474,6 @@ def upsert_estimate(data):
print(f"DEBUG: Error in upsert_estimate: {str(e)}") print(f"DEBUG: Error in upsert_estimate: {str(e)}")
return build_error_response(str(e), 500) return build_error_response(str(e), 500)
@frappe.whitelist()
def get_unapproved_estimates_count(company):
"""Get the number of unapproved estimates."""
try:
draft_filters = {'status': "Draft", "company": company}
submitted_filters = {'status': "Submitted", "company": company}
draft_count = frappe.db.count("Quotation", filters=draft_filters)
submitted_count = frappe.db.count("Quotation", filters=submitted_filters)
return build_success_response([draft_count, submitted_count])
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_estimates_half_down_count(company):
"""Get the number unpaid half-down estimates."""
try:
filters = {'requires_half_payment': True, 'company': company}
count = frappe.db.count("Quotation", filters=filters)
return build_success_response([count])
except Exception as e:
return build_error_response(str(e), 500)
def get_estimate_history(estimate_name): def get_estimate_history(estimate_name):
"""Get the history of changes for a specific estimate.""" """Get the history of changes for a specific estimate."""
pass pass

View file

@ -1,7 +1,5 @@
import frappe import frappe
from custom_ui.db_utils import build_history_entries, build_success_response, build_error_response from custom_ui.db_utils import build_history_entries
from datetime import datetime, timedelta
import json
def get_doc_history(doctype, docname): def get_doc_history(doctype, docname):
"""Get the history of changes for a specific document.""" """Get the history of changes for a specific document."""
@ -59,42 +57,3 @@ def search_any_field(doctype, text):
[like] * len(conditions), [like] * len(conditions),
as_dict=True as_dict=True
) )
@frappe.whitelist()
def get_week_holidays(week_start_date: str):
"""Get holidays within a week starting from the given date."""
start_date = datetime.strptime(week_start_date, "%Y-%m-%d").date()
end_date = start_date + timedelta(days=6)
holidays = frappe.get_all(
"Holiday",
filters={
"holiday_date": ["between", (start_date, end_date)]
},
fields=["holiday_date", "description"],
order_by="holiday_date asc"
)
print(f"DEBUG: Retrieved holidays from {start_date} to {end_date}: {holidays}")
return build_success_response(holidays)
@frappe.whitelist()
def get_doc_list(doctype, fields=["*"], filters={}, pluck=None):
"""Get list of documents for a given doctype with specified fields and filters."""
if isinstance(fields, str):
fields = json.loads(fields)
if isinstance(filters, str):
filters = json.loads(filters)
try:
docs = frappe.get_all(
doctype,
fields=fields,
filters=filters,
order_by="creation desc",
# pluck=pluck
)
print(f"DEBUG: Retrieved documents for {doctype} with filters {filters}: {docs}")
return build_success_response(docs)
except Exception as e:
return build_error_response(str(e), 500)

View file

@ -1,36 +1,11 @@
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 erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
# =============================================================================== # ===============================================================================
# INVOICES API METHODS # INVOICES API METHODS
# =============================================================================== # ===============================================================================
@frappe.whitelist()
def create_invoice_for_job(job_name):
"""Create the invoice from a sales order of a job."""
try:
project = frappe.get_doc("Project", job_name)
sales_order = project.sales_order
invoice = make_sales_invoice(sales_order)
invoice.save()
return build_success_response(invoice.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_invoices_late_count():
"""Return Due, 30-day late, 90-day late, and Lien-worthy late accounts."""
try:
dummy_result = [10, 4, 5, 1]
print("DEBUG: DUMMY RESULT:", dummy_result)
return build_success_response(dummy_result)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist() @frappe.whitelist()
def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10): def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated invoice table data with filtering and sorting support.""" """Get paginated invoice table data with filtering and sorting support."""
@ -43,7 +18,7 @@ def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10):
else: else:
count = frappe.db.count("Sales Invoice", filters=processed_filters) count = frappe.db.count("Sales Invoice", filters=processed_filters)
print(f"DEBUG: Number of invoices returned: {count}") print(f"DEBUG: Number of invoice returned: {count}")
invoices = frappe.db.get_all( invoices = frappe.db.get_all(
"Sales Invoice", "Sales Invoice",

View file

@ -1,70 +1,11 @@
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, ServiceAppointmentService from custom_ui.services import AddressService, ClientService
from frappe.utils import getdate
# =============================================================================== # ===============================================================================
# JOB MANAGEMENT API METHODS # JOB MANAGEMENT API METHODS
# =============================================================================== # ===============================================================================
@frappe.whitelist()
def get_jobs_in_queue_count(company):
try:
filters = {
'company': company,
'is_scheduled': True,
}
count = frappe.db.count("Project", filters=filters)
return build_success_response([count])
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_jobs_in_progress_count(company):
try:
today = getdate()
filters = {
'company': company,
'invoice_status': 'Not Ready',
'expected_start_date': ['<=', today],
'expected_end_date': ['>=', today],
}
count = frappe.db.count("Project", filters=filters)
return build_success_response([count])
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_jobs_late_count(company):
try:
today = getdate()
filters = {
'company': company,
'invoice_status': 'Not Ready',
'expected_end_date': ['<', today]
}
count = frappe.db.count("Project", filters=filters)
return build_success_response([count])
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_jobs_to_invoice_count(company):
try:
filters = {
'company': company,
'invoice_status': 'Ready to Invoice',
}
count = frappe.db.count("Project", filters=filters)
return build_success_response([count])
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist() @frappe.whitelist()
def get_job_templates(company=None): def get_job_templates(company=None):
"""Get list of job (project) templates.""" """Get list of job (project) templates."""
@ -77,14 +18,13 @@ def get_job_templates(company=None):
except Exception as e: except Exception as e:
return build_error_response(str(e), 500) return build_error_response(str(e), 500)
@frappe.whitelist() @frappe.whitelist()
def create_job_from_sales_order(sales_order_name): def create_job_from_sales_order(sales_order_name):
"""Create a Job (Project) from a given Sales Order""" """Create a Job (Project) from a given Sales Order"""
try: try:
sales_order = frappe.get_doc("Sales Order", sales_order_name) sales_order = frappe.get_doc("Sales Order", sales_order_name)
project_template = frappe.get_doc("Project Template", "SNW Install") project_template = frappe.get_doc("Project Template", "SNW Install")
new_project = frappe.get_doc({ new_job = frappe.get_doc({
"doctype": "Project", "doctype": "Project",
"custom_address": sales_order.custom_job_address, "custom_address": sales_order.custom_job_address,
# "custom_installation_address": sales_order.custom_installation_address, # "custom_installation_address": sales_order.custom_installation_address,
@ -94,22 +34,8 @@ def create_job_from_sales_order(sales_order_name):
"sales_order": sales_order, "sales_order": sales_order,
"custom_company": sales_order.company "custom_company": sales_order.company
}) })
new_project.insert() new_job.insert()
for sales_order_item in sales_order.items: return build_success_response(new_job.as_dict())
new_task = frappe.get_doc({
"doctype": "Task",
"project": new_project.name,
"company": sales_order.company,
"custom_property": sales_order.custom_job_address,
"subject": sales_order_item.description,
})
new_task.insert()
# Iterate through new tasks (if any) and set customer, address
# job_tasks = frappe.get_all("Task", filters={"Project": new_job.name})
# for task in job_tasks:
# task.custom_property = new_job.job_address
# task.save()
return build_success_response(new_project.as_dict())
except Exception as e: except Exception as e:
return build_error_response(str(e), 500) return build_error_response(str(e), 500)
@ -124,8 +50,6 @@ def get_job(job_id=""):
project = project.as_dict() project = project.as_dict()
project["job_address"] = address_doc project["job_address"] = address_doc
project["client"] = ClientService.get_client_or_throw(project.customer) project["client"] = ClientService.get_client_or_throw(project.customer)
task_names = frappe.get_all("Task", filters={"project": job_id})
project["tasks"] = [frappe.get_doc("Task", task_name).as_dict() for task_name in task_names]
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)
@ -156,15 +80,12 @@ def get_job_task_table_data(filters={}, sortings={}, page=1, page_size=10):
order_by=processed_sortings order_by=processed_sortings
) )
tableRows = [] tableRows = []
for task in tasks: for task in tasks:
address_name = frappe.get_value("Project", task.project, "job_address")
full_address = frappe.get_value("Address", address_name, "full_address")
tableRow = {} tableRow = {}
tableRow["id"] = task["name"] tableRow["id"] = task["name"]
tableRow["subject"] = task["subject"] tableRow["subject"] = task["subject"]
tableRow["address"] = full_address tableRow["address"] = task.get("custom_property", "")
tableRow["status"] = task.get("status", "") tableRow["status"] = task.get("status", "")
tableRows.append(tableRow) tableRows.append(tableRow)
@ -211,10 +132,9 @@ def get_jobs_table_data(filters={}, sortings=[], page=1, page_size=10):
tableRow = {} tableRow = {}
tableRow["id"] = project["name"] tableRow["id"] = project["name"]
tableRow["name"] = project["name"] tableRow["name"] = project["name"]
tableRow["job_address"] = project["job_address"] tableRow["installation_address"] = project.get("custom_installation_address", "")
tableRow["customer"] = project.get("customer", "") tableRow["customer"] = project.get("customer", "")
tableRow["status"] = project.get("status", "") tableRow["status"] = project.get("status", "")
tableRow["invoice_status"] = project.get("invoice_status")
tableRow["percent_complete"] = project.get("percent_complete", 0) tableRow["percent_complete"] = project.get("percent_complete", 0)
tableRows.append(tableRow) tableRows.append(tableRow)
@ -247,56 +167,55 @@ def upsert_job(data):
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
@frappe.whitelist() @frappe.whitelist()
def get_projects_for_calendar(start_date, end_date, company=None, project_templates=[]): def get_install_projects(start_date=None, end_date=None):
"""Get install projects for the calendar.""" """Get install projects for the calendar."""
# Parse project_templates if it's a JSON string
if isinstance(project_templates, str):
project_templates = json.loads(project_templates)
# put some emojis in the print to make it stand out
print("📅📅📅", start_date, end_date, " company:", company, "project_templates:", project_templates, "type:", type(project_templates))
try: try:
filters = { filters = {"project_template": "SNW Install"}
"company": company
} if company else {}
if project_templates and len(project_templates) > 0:
filters["project_template"] = ["in", project_templates]
unscheduled_filters = filters.copy()
unscheduled_filters["is_scheduled"] = 0
# add to filter for if expected_start_date is between start_date and end_date OR expected_end_date is between start_date and end_date
filters["expected_start_date"] = ["<=", getdate(end_date)]
filters["expected_end_date"] = [">=", getdate(start_date)]
# If date range provided, we could filter, but for now let's fetch all open/active ones # If date range provided, we could filter, but for now let's fetch all open/active ones
# or maybe filter by status not Closed/Completed if we want active ones. # or maybe filter by status not Closed/Completed if we want active ones.
# The user said "unscheduled" are those with status "Open" (and no date). # The user said "unscheduled" are those with status "Open" (and no date).
# extend filters into unscheduled_filters
project_names = frappe.get_all("Project", pluck="name", filters=filters) projects = frappe.get_all("Project", fields=["*"], filters=filters)
print("DEBUG: Found scheduled project names:", project_names)
unscheduled_project_names = frappe.get_all("Project", pluck="name", filters=unscheduled_filters) calendar_events = []
print("DEBUG: Found unscheduled project names:", unscheduled_project_names) for project in projects:
projects = [frappe.get_doc("Project", name).as_dict() for name in project_names] # Determine status
unscheduled_projects = [frappe.get_doc("Project", name).as_dict() for name in unscheduled_project_names] status = "unscheduled"
return build_success_response({ "projects": projects, "unscheduled_projects": unscheduled_projects }) if project.get("expected_start_date"):
status = "scheduled"
# Map to calendar event format
event = {
"id": project.name,
"serviceType": project.project_name, # Using project name as service type/title
"customer": project.customer,
"status": status,
"scheduledDate": project.expected_start_date,
"scheduledTime": "08:00", # Default time if not specified? Project doesn't seem to have time.
"duration": 480, # Default 8 hours?
"foreman": project.get("custom_install_crew"),
"crew": [], # Need to map crew
"estimatedCost": project.estimated_costing,
"priority": project.priority.lower() if project.priority else "medium",
"notes": project.notes,
"address": project.custom_installation_address
}
calendar_events.append(event)
return {"status": "success", "data": calendar_events}
except Exception as e: except Exception as e:
return build_error_response(str(e), 500) return {"status": "error", "message": str(e)}
@frappe.whitelist() @frappe.whitelist()
def update_job_scheduled_dates(job_name: str, new_start_date: str = None, new_end_date: str = None, foreman_name: str = None): def get_project_templates_for_company(company_name):
"""Update job (project) schedule dates.""" """Get project templates for a specific company."""
print("DEBUG: Updating job schedule:", job_name, new_start_date, new_end_date, foreman_name)
try: try:
project = frappe.get_doc("Project", job_name) templates = frappe.get_all(
project.expected_start_date = getdate(new_start_date) if new_start_date else None "Project Template",
project.expected_end_date = getdate(new_end_date) if new_end_date else None fields=["*"],
if new_start_date and new_end_date: filters={"company": company_name}
project.is_scheduled = 1 )
else: return build_success_response(templates)
project.is_scheduled = 0
if foreman_name:
project.custom_foreman = foreman_name
project.save()
return build_success_response(project.as_dict())
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,15 +0,0 @@
import frappe
from custom_ui.db_utils import build_success_response, build_error_response
@frappe.whitelist()
def get_incomplete_bids(company):
print("Getting Incomplete Bids")
try:
filters = {'status': "Unscheduled", 'company': company}
count = frappe.db.count("On-Site Meeting", filters=filters)
print("Incomplete Bids:", count)
return build_success_response([count])
except Exception as e:
return build_error_response(str(e), 500)

View file

@ -1,69 +0,0 @@
import frappe, json
from custom_ui.db_utils import build_success_response, build_error_response
from custom_ui.services import ServiceAppointmentService
@frappe.whitelist()
def get_service_appointments(companies, filters={}):
"""Get Service Appointments for given companies."""
try:
if isinstance(companies, str):
companies = json.loads(companies)
if isinstance(filters, str):
filters = json.loads(filters)
filters["company"] = ["in", companies]
service_appointment_names = frappe.get_all(
"Service Address 2",
filters=filters,
pluck="name"
)
service_appointments = [
ServiceAppointmentService.get_full_dict(name)
for name in service_appointment_names
]
"is_half_down_paid"
return build_success_response(service_appointments)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_unscheduled_service_appointments(companies):
"""Get unscheduled Service Appointments for given companies."""
try:
if isinstance(companies, str):
companies = json.loads(companies)
filters = {
"company": ["in", companies],
"expected_start_date": None,
"status": "Open"
}
service_appointment_names = frappe.get_all(
"Service Address 2",
filters=filters,
pluck="name"
)
service_appointments = [
ServiceAppointmentService.get_full_dict(name)
for name in service_appointment_names
]
return build_success_response(service_appointments)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def update_service_appointment_scheduled_dates(service_appointment_name: str, start_date, end_date, crew_lead_name, start_time=None, end_time=None):
"""Update scheduled dates for a Service Appointment."""
print(f"DEBUG: Updating scheduled dates for Service Appointment {service_appointment_name} to start: {start_date}, end: {end_date}, crew lead: {crew_lead_name}, start time: {start_time}, end time: {end_time}")
try:
updated_service_appointment = ServiceAppointmentService.update_scheduled_dates(
service_appointment_name,
crew_lead_name,
start_date,
end_date,
start_time,
end_time
)
return build_success_response(updated_service_appointment.as_dict())
except Exception as e:
return build_error_response(str(e), 500)

View file

@ -1,5 +1,4 @@
import frappe import frappe
import datetime
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 from custom_ui.services import DbService
@ -43,42 +42,6 @@ def get_task_status_options():
return build_error_response(str(e), 500) return build_error_response(str(e), 500)
@frappe.whitelist()
def get_tasks_due(subject_filter, current_company):
"""Return the number of items due today of the type of subject_filter"""
try:
today = datetime.date.today()
due_filters = {
'subject': ['like', f'%{subject_filter}%'],
'status': ['not in', ["Template", "Completed", "Cancelled"]],
'company': current_company,
'exp_end_date': today,
# Add due date filter here
}
completed_filters = {
'subject': ['like', f'%{subject_filter}%'],
'status': ['not in', ["Template", "Cancelled"]],
'company': current_company,
'exp_end_date': today,
# Add due date filter here
}
overdue_filters = {
'subject': ['like', f'%{subject_filter}%'],
'status': ['not in', ["Template", "Completed", "Cancelled"]],
'company': current_company,
'exp_end_date': ["<", today]
# Add overdue date filtering here
}
due_count = frappe.db.count("Task", filters=due_filters)
completed_count = frappe.db.count("Task", filters=completed_filters)
overdue_count = frappe.db.count("Task", filters=overdue_filters)
return build_success_response([due_count, completed_count, overdue_count])
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_tasks_table_data(filters={}, sortings=[], page=1, page_size=10): def get_tasks_table_data(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated task table data with filtering and sorting support.""" """Get paginated task table data with filtering and sorting support."""
@ -98,12 +61,10 @@ def get_tasks_table_data(filters={}, sortings=[], page=1, page_size=10):
fields=["*"], fields=["*"],
filters=processed_filters, filters=processed_filters,
limit=page_size, limit=page_size,
start=(page-1) * page_size, start=page * page_size,
order_by=processed_sortings order_by=processed_sortings
) )
print("TASKS?", tasks, page, page_size)
tableRows = [] tableRows = []
for task in tasks: for task in tasks:
tableRow = {} tableRow = {}

View file

@ -1,6 +0,0 @@
import frappe
@frappe.whitelist(allow_guest=True)
def start_payment(invoice_name: str):
pass

View file

@ -229,20 +229,3 @@ def build_history_entries(comments, versions):
# Sort by timestamp descending # Sort by timestamp descending
history.sort(key=lambda x: x["timestamp"], reverse=True) history.sort(key=lambda x: x["timestamp"], reverse=True)
return history return history
def normalize_name(name: str, split_target: str = "_") -> str:
"""Normalize a name by splitting off anything after and including the split_target."""
return name.split(split_target)[0] if split_target in name else name
class DbUtils:
@staticmethod
def process_datatable_request(filters, sortings, page, page_size):
# turn filters and sortings from json strings to dicts/lists
if isinstance(filters, str):
filters = json.loads(filters)
if isinstance(sortings, str):
sortings = json.loads(sortings)
page = int(page)
page_size = int(page_size)
return filters, sortings,page, page_size

View file

@ -58,7 +58,7 @@ def on_update_after_submit(doc, method):
print("DEBUG: Quotation marked as Won, updating current status.") print("DEBUG: Quotation marked as Won, updating current status.")
if doc.customer_type == "Lead": if doc.customer_type == "Lead":
print("DEBUG: Customer is a Lead, converting to Customer and updating Quotation.") print("DEBUG: Customer is a Lead, converting to Customer and updating Quotation.")
new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name) new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name, update_quotations=False)
doc.actual_customer_name = new_customer.name doc.actual_customer_name = new_customer.name
doc.customer_type = "Customer" doc.customer_type = "Customer"
new_customer.reload() new_customer.reload()
@ -83,5 +83,5 @@ def on_update_after_submit(doc, method):
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,6 +0,0 @@
import frappe
def attach_bid_note_form_to_project_template(doc, method):
"""Attatch Bid Meeting Note Form to Project Template on insert."""
print("DEBUG: Attaching Bid Meeting Note Form to Project Template")
frappe.set_value("Project Template", doc.project_template, "bid_meeting_note_form", doc.name)

View file

@ -1,7 +1,6 @@
import frappe import frappe
from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, TaskService from custom_ui.services import AddressService, ClientService
from datetime import timedelta
import traceback
def after_insert(doc, method): def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Project") print("DEBUG: After Insert Triggered for Project")
@ -16,81 +15,14 @@ def after_insert(doc, method):
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template} doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
) )
if doc.project_template == "SNW Install": if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install, creating Service Appointment") print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
AddressService.update_value( AddressService.update_value(
doc.job_address, doc.job_address,
"job_status", "job_status",
"In Progress" "In Progress"
) )
try:
service_apt = ServiceAppointmentService.create({
"project": doc.name,
"customer": doc.customer,
"service_address": doc.job_address,
"company": doc.company,
"project_template": doc.project_template
})
doc.service_appointment = service_apt.name
doc.save(ignore_permissions=True)
print("DEBUG: Created Service Appointment:", service_apt.name)
except Exception as e:
print("ERROR: Failed to create Service Appointment for Project:", e)
print(traceback.format_exc())
raise e
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.name)]
for task_name in task_names:
doc.append("tasks", {
"task": task_name
})
AddressService.append_link_v2(
doc.job_address, "tasks", {"task": task_name}
)
ClientService.append_link_v2(
doc.customer, "tasks", {"task": task_name}
)
if task_names:
doc.save(ignore_permissions=True)
TaskService.calculate_and_set_due_dates(task_names, "Created", current_triggering_dict=doc.as_dict())
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
pass pass
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Project:", doc.name)
if doc.expected_start_date and doc.expected_end_date:
print("DEBUG: Project has expected start and end dates, marking as scheduled")
doc.is_scheduled = 1
while frappe.db.exists("Holiday", {"holiday_date": doc.expected_end_date}):
print("DEBUG: Expected end date falls on a holiday, extending end date by 1 day")
doc.expected_end_date += timedelta(days=1)
elif not doc.expected_start_date or not doc.expected_end_date:
print("DEBUG: Project missing expected start or end date, marking as unscheduled")
doc.is_scheduled = 0
event = TaskService.determine_event(doc)
if event:
TaskService.calculate_and_set_due_dates(
[task.task for task in doc.tasks],
event,
current_triggering_dict=doc.as_dict()
)
def after_save(doc, method):
print("DEBUG: After Save Triggered for Project:", doc.name)
if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install, updating Address Job Status based on Project status")
status_mapping = {
"Open": "In Progress",
"Completed": "Completed",
"Closed": "Completed"
}
new_status = status_mapping.get(doc.status, "In Progress")
if frappe.db.get_value("Address", doc.job_address, "job_status") != new_status:
print("DEBUG: Updating Address job_status to:", new_status)
AddressService.update_value(
doc.job_address,
"job_status",
new_status
)

View file

@ -8,8 +8,7 @@ def before_insert(doc, method):
# Address.onsite_meetings is a child table with two fields: onsite_meeting (Link) and project_template (Link). Iterate through to see if there is already an SNW Install meeting linked. # Address.onsite_meetings is a child table with two fields: onsite_meeting (Link) and project_template (Link). Iterate through to see if there is already an SNW Install meeting linked.
for link in address_doc.onsite_meetings: for link in address_doc.onsite_meetings:
if link.project_template == "SNW Install": if link.project_template == "SNW Install":
if frappe.db.get_value("On-Site Meeting", link.onsite_meeting, "status") != "Cancelled": raise frappe.ValidationError("An On-Site Meeting with project template 'SNW Install' is already linked to this address.")
raise frappe.ValidationError("An On-Site Meeting with project template 'SNW Install' is already linked to this address.")
def after_insert(doc, method): def after_insert(doc, method):
print("DEBUG: After Insert Triggered for On-Site Meeting") print("DEBUG: After Insert Triggered for On-Site Meeting")
@ -23,19 +22,17 @@ def after_insert(doc, method):
def before_save(doc, method): def before_save(doc, method):
print("DEBUG: Before Save Triggered for On-Site Meeting") print("DEBUG: Before Save Triggered for On-Site Meeting")
if doc.status != "Scheduled" and doc.start_time and doc.end_time and doc.status != "Completed" and doc.status != "Cancelled": if doc.status != "Scheduled" and doc.start_time and doc.end_time and doc.status != "Completed":
print("DEBUG: Meeting has start and end time, setting status to Scheduled") print("DEBUG: Meeting has start and end time, setting status to Scheduled")
doc.status = "Scheduled" doc.status = "Scheduled"
if doc.project_template == "SNW Install": if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install") print("DEBUG: Project template is SNW Install")
if doc.status == "Completed": if doc.status == "Completed":
print("DEBUG: Meeting marked as Completed, updating Address status") print("DEBUG: Meeting marked as Completed, updating Address status")
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed") current_status = AddressService.get_value(doc.address, "onsite_meeting_scheduled")
if doc.status == "Cancelled": if current_status != doc.status:
print("DEBUG: Meeting marked as Cancelled, updating Address status") AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Not Started")
def validate_address_link(doc, method): def validate_address_link(doc, method):
print("DEBUG: Validating Address link for On-Site Meeting") print("DEBUG: Validating Address link for On-Site Meeting")

View file

@ -1,24 +1,13 @@
import frappe import frappe
from custom_ui.services import DbService, AddressService, ClientService from custom_ui.services import DbService, AddressService, ClientService
def on_save(doc, method):
print("DEBUG: on_save hook triggered for Sales Order", doc.name)
if doc.advance_paid >= doc.grand_total/2:
if doc.project and doc.half_down_required:
print("DEBUG: Advance payments exceed required threshold of half down, setting project half down paid.")
project = frappe.get_doc("Project", doc.project)
project.is_half_down_paid = True
def before_insert(doc, method): def before_insert(doc, method):
print("DEBUG: before_insert hook triggered for Sales Order") print("DEBUG: before_insert hook triggered for Sales Order:", doc.name)
# if doc.custom_project_template == "SNW Install": if doc.custom_project_template == "SNW Install":
# print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.") print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
# address_doc = AddressService.get_or_throw(doc.custom_job_address) address_doc = AddressService.get_or_throw(doc.custom_job_address)
# if "SNW Install" in [link.project_template for link in address_doc.sales_orders]: 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.") 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")
@ -40,16 +29,14 @@ def on_submit(doc, method):
"custom_warranty_duration_days": 90, "custom_warranty_duration_days": 90,
"customer": doc.customer, "customer": doc.customer,
"job_address": doc.custom_job_address, "job_address": doc.custom_job_address,
"sales_order": doc.name, "sales_order": doc.name
"requires_half_payment": doc.requires_half_payment
}) })
# attatch the job to the sales_order links # attatch the job to the sales_order links
new_job.insert() new_job.insert()
# frappe.db.commit() frappe.db.commit()
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): def after_insert(doc, method):
print("DEBUG: after_insert hook triggered for Sales Order:", doc.name) print("DEBUG: after_insert hook triggered for Sales Order:", doc.name)
AddressService.append_link_v2( AddressService.append_link_v2(
@ -62,43 +49,41 @@ def after_insert(doc, method):
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template} 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):
pass try:
# try: print("DEBUG: after_submit hook triggered for Sales Order:", doc.name)
# print("DEBUG: after_submit hook triggered for Sales Order:", doc.name) invoice_ammount = doc.grand_total / 2 if doc.requires_half_payment else doc.grand_total
# invoice_ammount = doc.grand_total / 2 if doc.requires_half_payment else doc.grand_total items = []
# items = [] for so_item in doc.items:
# for so_item in doc.items: # proportionally reduce rate if half-payment
# # proportionally reduce rate if half-payment rate = so_item.rate / 2 if doc.requires_half_payment else so_item.rate
# rate = so_item.rate / 2 if doc.requires_half_payment else so_item.rate qty = so_item.qty # usually full qty, but depends on half-payment rules
# qty = so_item.qty # usually full qty, but depends on half-payment rules items.append({
# items.append({ "item_code": so_item.item_code,
# "item_code": so_item.item_code, "qty": qty,
# "qty": qty, "rate": rate,
# "rate": rate, "income_account": so_item.income_account,
# "income_account": so_item.income_account, "cost_center": so_item.cost_center,
# "cost_center": so_item.cost_center, "so_detail": so_item.name # links item to Sales Order
# "so_detail": so_item.name # links item to Sales Order })
# }) invoice = frappe.get_doc({
# invoice = frappe.get_doc({ "doctype": "Sales Invoice",
# "doctype": "Sales Invoice", "customer": doc.customer,
# "customer": doc.customer, "company": doc.company,
# "company": doc.company, "posting_date": frappe.utils.nowdate(),
# "posting_date": frappe.utils.nowdate(), "due_date": frappe.utils.nowdate(), # or calculate from payment terms
# "due_date": frappe.utils.nowdate(), # or calculate from payment terms "currency": doc.currency,
# "currency": doc.currency, "update_stock": 0,
# "update_stock": 0, "items": items,
# "items": items, "sales_order": doc.name, # link invoice to Sales Order
# "sales_order": doc.name, # link invoice to Sales Order "ignore_pricing_rule": 1,
# "ignore_pricing_rule": 1, "payment_schedule": doc.payment_schedule if not half_payment else [] # optional
# "payment_schedule": doc.payment_schedule if not half_payment else [] # optional })
# })
# invoice.insert() invoice.insert()
# invoice.submit() invoice.submit()
# frappe.db.commit() frappe.db.commit()
# return invoice return invoice
# except Exception as e: except Exception as e:
# print("ERROR creating Sales Invoice from Sales Order:", str(e)) print("ERROR creating Sales Invoice from Sales Order:", str(e))
# frappe.log_error(f"Error creating Sales Invoice from Sales Order {doc.name}: {str(e)}", "Sales Order after_submit Error") frappe.log_error(f"Error creating Sales Invoice from Sales Order {doc.name}: {str(e)}", "Sales Order after_submit Error")

View file

@ -1,31 +0,0 @@
import frappe
from custom_ui.services import TaskService
def on_update(doc, method):
print("DEBUG: On Update Triggered for Service Appointment")
# event = TaskService.determine_event(doc)
# if event:
# tasks = TaskService.get_tasks_by_project(doc.project)
# task_names = [task.name for task in tasks]
# TaskService.calculate_and_set_due_dates(task_names=task_names, event=event, triggering_doctype="Service Address 2")
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Service Appointment")
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.calculate_and_set_due_dates(task_names=task_names, event="Created", current_triggering_dict=doc.as_dict())
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Service Appointment")
if doc.status == "Open" and doc.expected_start_date:
doc.status = "Scheduled"
elif doc.status == "Scheduled" and not doc.expected_start_date:
doc.status = "Open"
if doc.status == "Scheduled" and doc.actual_start_date:
doc.status = "Started"
elif doc.status != "Completed" and doc.status != "Canceled" and doc.actual_end_date:
doc.status = "Completed"
event = TaskService.determine_event(doc)
if event:
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.calculate_and_set_due_dates(task_names=task_names, event=event, current_triggering_dict=doc.as_dict())

View file

@ -1,34 +1,7 @@
import frappe import frappe
from custom_ui.services import AddressService, ClientService, TaskService
def before_insert(doc, method): def before_insert(doc, method):
"""Set values before inserting a Task.""" """Set values before inserting a Task."""
print("DEBUG: Before Insert Triggered for Task")
project_doc = frappe.get_doc("Project", doc.project) project_doc = frappe.get_doc("Project", doc.project)
doc.project_template = project_doc.project_template if project_doc.custom_installation_address:
doc.customer = project_doc.customer doc.custom_property = project_doc.custom_installation_address
if project_doc.job_address:
doc.custom_property = project_doc.job_address
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Task")
print("DEBUG: Linking Task to Customer and Address")
AddressService.append_link_v2(
doc.custom_property, "tasks", {"task": doc.name, "project_template": doc.project_template }
)
AddressService.append_link_v2(
doc.custom_property, "links", {"link_doctype": "Task", "link_name": doc.name}
)
ClientService.append_link_v2(
doc.customer, "tasks", {"task": doc.name, "project_template": doc.project_template }
)
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.calculate_and_set_due_dates(task_names, "Created", current_triggering_dict=doc.as_dict())
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Task:", doc.name)
event = TaskService.determine_event(doc)
if event:
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.calculate_and_set_due_dates(task_names, event, current_triggering_dict=doc.as_dict())

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
[]

View file

@ -1,20 +1,4 @@
[ [
{
"default_value": null,
"doc_type": "Pre-Built Routes",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "naming_series",
"is_system_generated": 1,
"modified": "2026-01-21 10:16:27.072272",
"module": null,
"name": "Pre-Built Routes-naming_series-options",
"property": "options",
"property_type": "Text",
"row_name": null,
"value": "Route - .#####"
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Item", "doc_type": "Item",
@ -7343,6 +7327,22 @@
"row_name": null, "row_name": null,
"value": "1" "value": "1"
}, },
{
"default_value": null,
"doc_type": "Contact",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2024-12-23 13:33:08.995392",
"module": null,
"name": "Contact-main-naming_rule",
"property": "naming_rule",
"property_type": "Data",
"row_name": null,
"value": "Set by user"
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Contact", "doc_type": "Contact",
@ -7375,6 +7375,22 @@
"row_name": null, "row_name": null,
"value": "creation" "value": "creation"
}, },
{
"default_value": null,
"doc_type": "Contact",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2024-12-23 16:11:50.106128",
"module": null,
"name": "Contact-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": "full_name"
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Contact", "doc_type": "Contact",
@ -10959,6 +10975,22 @@
"row_name": null, "row_name": null,
"value": "1" "value": "1"
}, },
{
"default_value": null,
"doc_type": "Project",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-03-04 19:46:31.636284",
"module": null,
"name": "Project-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": ".custom_installation_address.-PRO-.#####.-.YYYY."
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Payment Entry", "doc_type": "Payment Entry",
@ -11679,6 +11711,38 @@
"row_name": null, "row_name": null,
"value": "1" "value": "1"
}, },
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-04-25 12:53:57.474107",
"module": null,
"name": "Address-main-naming_rule",
"property": "naming_rule",
"property_type": "Data",
"row_name": null,
"value": ""
},
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-04-25 12:53:57.545546",
"module": null,
"name": "Address-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": ""
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Address", "doc_type": "Address",
@ -12111,6 +12175,22 @@
"row_name": null, "row_name": null,
"value": "[\"subject\", \"project\", \"custom_property\", \"issue\", \"type\", \"color\", \"is_group\", \"is_template\", \"column_break0\", \"status\", \"priority\", \"task_weight\", \"parent_task\", \"completed_by\", \"completed_on\", \"custom_foreman\", \"sb_timeline\", \"exp_start_date\", \"expected_time\", \"start\", \"column_break_11\", \"exp_end_date\", \"progress\", \"duration\", \"is_milestone\", \"sb_details\", \"description\", \"sb_depends_on\", \"depends_on\", \"depends_on_tasks\", \"sb_actual\", \"act_start_date\", \"actual_time\", \"column_break_15\", \"act_end_date\", \"sb_costing\", \"total_costing_amount\", \"total_expense_claim\", \"column_break_20\", \"total_billing_amount\", \"sb_more_info\", \"review_date\", \"closing_date\", \"column_break_22\", \"department\", \"company\", \"lft\", \"rgt\", \"old_parent\", \"template_task\"]" "value": "[\"subject\", \"project\", \"custom_property\", \"issue\", \"type\", \"color\", \"is_group\", \"is_template\", \"column_break0\", \"status\", \"priority\", \"task_weight\", \"parent_task\", \"completed_by\", \"completed_on\", \"custom_foreman\", \"sb_timeline\", \"exp_start_date\", \"expected_time\", \"start\", \"column_break_11\", \"exp_end_date\", \"progress\", \"duration\", \"is_milestone\", \"sb_details\", \"description\", \"sb_depends_on\", \"depends_on\", \"depends_on_tasks\", \"sb_actual\", \"act_start_date\", \"actual_time\", \"column_break_15\", \"act_end_date\", \"sb_costing\", \"total_costing_amount\", \"total_expense_claim\", \"column_break_20\", \"total_billing_amount\", \"sb_more_info\", \"review_date\", \"closing_date\", \"column_break_22\", \"department\", \"company\", \"lft\", \"rgt\", \"old_parent\", \"template_task\"]"
}, },
{
"default_value": null,
"doc_type": "Project",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-08-26 18:57:43.989451",
"module": null,
"name": "Project-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"custom_column_break_k7sgq\", \"custom_installation_address\", \"naming_series\", \"project_name\", \"status\", \"custom_warranty_duration_days\", \"custom_warranty_expiration_date\", \"custom_warranty_information\", \"project_type\", \"percent_complete_method\", \"percent_complete\", \"column_break_5\", \"project_template\", \"expected_start_date\", \"expected_end_date\", \"custom_completion_date\", \"priority\", \"custom_foreman\", \"custom_hidden_fields\", \"department\", \"is_active\", \"custom_address\", \"custom_section_break_lgkpd\", \"custom_workflow_related_custom_fields__landry\", \"custom_permit_status\", \"custom_utlity_locate_status\", \"custom_crew_scheduling\", \"customer_details\", \"customer\", \"column_break_14\", \"sales_order\", \"users_section\", \"users\", \"copied_from\", \"section_break0\", \"notes\", \"section_break_18\", \"actual_start_date\", \"actual_time\", \"column_break_20\", \"actual_end_date\", \"project_details\", \"estimated_costing\", \"total_costing_amount\", \"total_expense_claim\", \"total_purchase_cost\", \"company\", \"column_break_28\", \"total_sales_amount\", \"total_billable_amount\", \"total_billed_amount\", \"total_consumed_material_cost\", \"cost_center\", \"margin\", \"gross_margin\", \"column_break_37\", \"per_gross_margin\", \"monitor_progress\", \"collect_progress\", \"holiday_list\", \"frequency\", \"from_time\", \"to_time\", \"first_email\", \"second_email\", \"daily_time_to_send\", \"day_to_send\", \"weekly_time_to_send\", \"column_break_45\", \"message\"]"
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Attendance", "doc_type": "Attendance",
@ -12223,6 +12303,54 @@
"row_name": null, "row_name": null,
"value": "[\"section_break_toee\", \"custom_location_of_meeting\", \"contact\", \"custom_phone_number\", \"custom_sms_optin\", \"custom_email_address\", \"custom_column_break_dsqvk\", \"appointment_date\", \"appointment_time\", \"status\", \"custom_internal_company\", \"custom_section_break_gndxh\", \"service_details\", \"custom_assigned_to\", \"auto_repeat\"]" "value": "[\"section_break_toee\", \"custom_location_of_meeting\", \"contact\", \"custom_phone_number\", \"custom_sms_optin\", \"custom_email_address\", \"custom_column_break_dsqvk\", \"appointment_date\", \"appointment_time\", \"status\", \"custom_internal_company\", \"custom_section_break_gndxh\", \"service_details\", \"custom_assigned_to\", \"auto_repeat\"]"
}, },
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-11-10 01:29:00.331516",
"module": null,
"name": "Address-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"address_details\", \"custom_column_break_vqa4d\", \"custom_column_break_jw2ty\", \"custom_installationservice_address\", \"custom_billing_address\", \"is_shipping_address\", \"is_primary_address\", \"custom_is_compnay_address\", \"custom_column_break_ky1zo\", \"custom_estimate_sent_status\", \"custom_onsite_meeting_scheduled\", \"custom_job_status\", \"custom_payment_received_status\", \"custom_section_break_fvgdt\", \"address_title\", \"address_type\", \"address_line1\", \"address_line2\", \"custom_linked_city\", \"custom_subdivision\", \"is_your_company_address\", \"custom_column_break_3mo7x\", \"state\", \"city\", \"pincode\", \"county\", \"country\", \"custom_column_break_rrto0\", \"custom_customer_to_bill\", \"custom_contact_name\", \"phone\", \"email_id\", \"fax\", \"tax_category\", \"disabled\", \"custom_section_break_aecpx\", \"column_break0\", \"custom_show_irrigation_district\", \"custom_google_map\", \"custom_latitude\", \"custom_longitude\", \"custom_address_for_coordinates\", \"linked_with\", \"custom_linked_contacts\", \"links\", \"custom_column_break_9cbvb\", \"custom_linked_companies\", \"custom_irrigation\", \"custom_upcoming_services\", \"custom_service_type\", \"custom_service_route\", \"custom_confirmation_status\", \"custom_backflow_test_form_filed\", \"custom_column_break_j79td\", \"custom_technician_assigned\", \"custom_scheduled_date\", \"custom_column_break_sqplk\", \"custom_test_route\", \"custom_tech\", \"custom_column_break_wcs7g\", \"custom_section_break_zruvq\", \"custom_irrigation_district\", \"custom_serial_\", \"custom_makemodel_\", \"custom_column_break_djjw3\", \"custom_backflow_location\", \"custom_shutoff_location\", \"custom_valve_boxes\", \"custom_timer_type_and_location\", \"custom_column_break_slusf\", \"custom_section_break_5d1cf\", \"custom_installed_by_sprinklers_nw\", \"custom_column_break_th7rq\", \"custom_installed_for\", \"custom_install_month\", \"custom_install_year\", \"custom_column_break_4itse\", \"custom_section_break_xfdtv\", \"custom_backflow_test_report\", \"custom_column_break_oxppn\", \"custom_photo_attachment\"]"
},
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-11-10 01:29:00.632202",
"module": null,
"name": "Address-main-links_order",
"property": "links_order",
"property_type": "Small Text",
"row_name": null,
"value": "[\"21ddd8462e\", \"c26b89d0d3\", \"ee207f2316\"]"
},
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-11-10 01:29:00.867308",
"module": null,
"name": "Address-main-states_order",
"property": "states_order",
"property_type": "Small Text",
"row_name": null,
"value": "[\"62m56h85vo\", \"62m5uugrvr\", \"62m57bgpkf\", \"62m5fgrjb0\"]"
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Payment Entry", "doc_type": "Payment Entry",
@ -12543,6 +12671,22 @@
"row_name": null, "row_name": null,
"value": "ISS-.YYYY.-" "value": "ISS-.YYYY.-"
}, },
{
"default_value": null,
"doc_type": "Contact",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-11-26 03:43:13.493067",
"module": null,
"name": "Contact-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"sb_01\", \"custom_column_break_g4zvy\", \"first_name\", \"custom_column_break_hpz5b\", \"middle_name\", \"custom_column_break_3pehb\", \"last_name\", \"contact_section\", \"links\", \"phone_nos\", \"email_ids\", \"custom_column_break_nfqbi\", \"is_primary_contact\", \"is_billing_contact\", \"custom_service_address\", \"user\", \"unsubscribed\", \"more_info\", \"custom_column_break_sn9hu\", \"full_name\", \"address\", \"company_name\", \"designation\", \"department\", \"image\", \"sb_00\", \"custom_column_break_kmlkz\", \"email_id\", \"mobile_no\", \"phone\", \"status\", \"gender\", \"salutation\", \"contact_details\", \"cb_00\", \"custom_test_label\", \"google_contacts\", \"google_contacts_id\", \"sync_with_google_contacts\", \"cb00\", \"pulled_from_google_contacts\", \"custom_column_break_ejxjz\"]"
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Contact", "doc_type": "Contact",
@ -12591,6 +12735,22 @@
"row_name": null, "row_name": null,
"value": "[\"qagt2h4psk\"]" "value": "[\"qagt2h4psk\"]"
}, },
{
"default_value": null,
"doc_type": "Lead",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-07 04:42:08.600100",
"module": null,
"name": "Lead-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"naming_series\", \"salutation\", \"first_name\", \"middle_name\", \"last_name\", \"custom_customer_name\", \"column_break_1\", \"lead_name\", \"customer_type\", \"job_title\", \"gender\", \"source\", \"col_break123\", \"lead_owner\", \"status\", \"customer\", \"type\", \"request_type\", \"contact_info_tab\", \"email_id\", \"website\", \"column_break_20\", \"mobile_no\", \"whatsapp_no\", \"column_break_16\", \"phone\", \"phone_ext\", \"organization_section\", \"company_name\", \"no_of_employees\", \"column_break_28\", \"annual_revenue\", \"industry\", \"market_segment\", \"column_break_31\", \"territory\", \"fax\", \"address_section\", \"address_html\", \"column_break_38\", \"city\", \"state\", \"country\", \"column_break2\", \"contact_html\", \"qualification_tab\", \"qualification_status\", \"column_break_64\", \"qualified_by\", \"qualified_on\", \"other_info_tab\", \"campaign_name\", \"company\", \"column_break_22\", \"language\", \"image\", \"title\", \"column_break_50\", \"disabled\", \"unsubscribed\", \"blog_subscriber\", \"activities_tab\", \"open_activities_html\", \"all_activities_section\", \"all_activities_html\", \"notes_tab\", \"notes_html\", \"notes\", \"dashboard_tab\"]"
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Address", "doc_type": "Address",
@ -12607,102 +12767,6 @@
"row_name": null, "row_name": null,
"value": "Billing\nShipping\nOffice\nPersonal\nPlant\nPostal\nShop\nSubsidiary\nWarehouse\nCurrent\nPermanent\nOther\nService" "value": "Billing\nShipping\nOffice\nPersonal\nPlant\nPostal\nShop\nSubsidiary\nWarehouse\nCurrent\nPermanent\nOther\nService"
}, },
{
"default_value": null,
"doc_type": "Lead",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-19 15:35:00.062846",
"module": null,
"name": "Lead-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"naming_series\", \"salutation\", \"first_name\", \"middle_name\", \"last_name\", \"custom_customer_name\", \"column_break_1\", \"lead_name\", \"customer_type\", \"companies\", \"quotations\", \"onsite_meetings\", \"job_title\", \"gender\", \"source\", \"col_break123\", \"lead_owner\", \"status\", \"customer\", \"type\", \"request_type\", \"contact_info_tab\", \"email_id\", \"website\", \"column_break_20\", \"mobile_no\", \"whatsapp_no\", \"column_break_16\", \"phone\", \"phone_ext\", \"organization_section\", \"company_name\", \"no_of_employees\", \"column_break_28\", \"annual_revenue\", \"industry\", \"market_segment\", \"column_break_31\", \"territory\", \"fax\", \"address_section\", \"address_html\", \"column_break_38\", \"city\", \"state\", \"country\", \"column_break2\", \"contact_html\", \"qualification_tab\", \"qualification_status\", \"column_break_64\", \"qualified_by\", \"qualified_on\", \"other_info_tab\", \"campaign_name\", \"company\", \"column_break_22\", \"language\", \"image\", \"title\", \"column_break_50\", \"disabled\", \"unsubscribed\", \"blog_subscriber\", \"activities_tab\", \"open_activities_html\", \"all_activities_section\", \"all_activities_html\", \"notes_tab\", \"notes_html\", \"notes\", \"dashboard_tab\", \"contacts\", \"primary_contact\", \"properties\", \"custom_billing_address\"]"
},
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-21 03:31:53.715660",
"module": null,
"name": "Address-main-naming_rule",
"property": "naming_rule",
"property_type": "Data",
"row_name": null,
"value": "Expression"
},
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-21 03:31:53.785594",
"module": null,
"name": "Address-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": "format:{full_address)-#-{MM}-{YYYY}-{####}"
},
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-21 03:31:53.827100",
"module": null,
"name": "Address-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"address_details\", \"custom_column_break_vqa4d\", \"custom_column_break_jw2ty\", \"custom_installationservice_address\", \"custom_billing_address\", \"is_shipping_address\", \"is_primary_address\", \"custom_is_compnay_address\", \"custom_column_break_ky1zo\", \"custom_estimate_sent_status\", \"custom_onsite_meeting_scheduled\", \"custom_job_status\", \"custom_payment_received_status\", \"custom_section_break_fvgdt\", \"address_title\", \"primary_contact\", \"address_type\", \"address_line1\", \"address_line2\", \"custom_linked_city\", \"custom_subdivision\", \"is_your_company_address\", \"custom_column_break_3mo7x\", \"state\", \"city\", \"pincode\", \"county\", \"country\", \"full_address\", \"latitude\", \"longitude\", \"onsite_meeting_scheduled\", \"estimate_sent_status\", \"job_status\", \"payment_received_status\", \"custom_column_break_rrto0\", \"custom_customer_to_bill\", \"lead_name\", \"customer_type\", \"customer_name\", \"contacts\", \"companies\", \"quotations\", \"onsite_meetings\", \"projects\", \"sales_orders\", \"tasks\", \"custom_contact_name\", \"phone\", \"email_id\", \"fax\", \"tax_category\", \"disabled\", \"custom_section_break_aecpx\", \"column_break0\", \"custom_show_irrigation_district\", \"custom_google_map\", \"custom_latitude\", \"custom_longitude\", \"custom_address_for_coordinates\", \"linked_with\", \"custom_linked_contacts\", \"links\", \"custom_column_break_9cbvb\", \"custom_linked_companies\", \"custom_irrigation\", \"custom_upcoming_services\", \"custom_service_type\", \"custom_service_route\", \"custom_confirmation_status\", \"custom_backflow_test_form_filed\", \"custom_column_break_j79td\", \"custom_technician_assigned\", \"custom_scheduled_date\", \"custom_column_break_sqplk\", \"custom_test_route\", \"custom_tech\", \"custom_column_break_wcs7g\", \"custom_section_break_zruvq\", \"custom_irrigation_district\", \"custom_serial_\", \"custom_makemodel_\", \"custom_column_break_djjw3\", \"custom_backflow_location\", \"custom_shutoff_location\", \"custom_valve_boxes\", \"custom_timer_type_and_location\", \"custom_column_break_slusf\", \"custom_section_break_5d1cf\", \"custom_installed_by_sprinklers_nw\", \"custom_column_break_th7rq\", \"custom_installed_for\", \"custom_install_month\", \"custom_install_year\", \"custom_column_break_4itse\", \"custom_section_break_xfdtv\", \"custom_backflow_test_report\", \"custom_column_break_oxppn\", \"custom_photo_attachment\"]"
},
{
"default_value": null,
"doc_type": "Project Template",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-21 04:34:22.663180",
"module": null,
"name": "Project Template-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"project_type\", \"tasks\", \"company\", \"calendar_color\"]"
},
{
"default_value": null,
"doc_type": "Project Template",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "calendar_color",
"is_system_generated": 0,
"modified": "2026-01-21 04:34:22.740335",
"module": null,
"name": "Project Template-calendar_color-in_list_view",
"property": "in_list_view",
"property_type": "Check",
"row_name": null,
"value": "1"
},
{ {
"default_value": null, "default_value": null,
"doc_type": "Project", "doc_type": "Project",
@ -14958,181 +15022,5 @@
"property_type": "Check", "property_type": "Check",
"row_name": null, "row_name": null,
"value": "1" "value": "1"
},
{
"default_value": null,
"doc_type": "Task Type",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-22 06:23:06.078264",
"module": null,
"name": "Task Type-main-naming_rule",
"property": "naming_rule",
"property_type": "Data",
"row_name": null,
"value": "Expression"
},
{
"default_value": null,
"doc_type": "Task Type",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-22 06:23:06.134175",
"module": null,
"name": "Task Type-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": "format:{title}"
},
{
"default_value": null,
"doc_type": "Task Type",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "task_type_calculate_from",
"is_system_generated": 0,
"modified": "2026-01-22 09:31:29.877718",
"module": null,
"name": "Task Type-task_type_calculate_from-mandatory_depends_on",
"property": "mandatory_depends_on",
"property_type": "Data",
"row_name": null,
"value": "eval:doc.calculate_from == \"Task\""
},
{
"default_value": null,
"doc_type": "Lead",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-26 01:51:02.536818",
"module": null,
"name": "Lead-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": "format:{custom_customer_name}-#-{YYYY}-{MM}-{####}"
},
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-26 02:35:09.522811",
"module": null,
"name": "Address-main-links_order",
"property": "links_order",
"property_type": "Small Text",
"row_name": null,
"value": "[\"21ddd8462e\", \"c26b89d0d3\", \"ee207f2316\"]"
},
{
"default_value": null,
"doc_type": "Address",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-26 02:35:09.598292",
"module": null,
"name": "Address-main-states_order",
"property": "states_order",
"property_type": "Small Text",
"row_name": null,
"value": "[\"62m56h85vo\", \"62m5uugrvr\", \"62m57bgpkf\", \"62m5fgrjb0\"]"
},
{
"default_value": null,
"doc_type": "Contact",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-26 02:40:01.394710",
"module": null,
"name": "Contact-main-naming_rule",
"property": "naming_rule",
"property_type": "Data",
"row_name": null,
"value": "Expression"
},
{
"default_value": null,
"doc_type": "Contact",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-26 02:40:01.427255",
"module": null,
"name": "Contact-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": "format:{full-name}-#-{MM}-{YYYY}-{####}"
},
{
"default_value": null,
"doc_type": "Contact",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-26 02:40:01.458831",
"module": null,
"name": "Contact-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"sb_01\", \"custom_column_break_g4zvy\", \"first_name\", \"custom_column_break_hpz5b\", \"middle_name\", \"custom_column_break_3pehb\", \"last_name\", \"email\", \"customer_type\", \"customer_name\", \"addresses\", \"contact_section\", \"links\", \"phone_nos\", \"email_ids\", \"custom_column_break_nfqbi\", \"is_primary_contact\", \"is_billing_contact\", \"custom_service_address\", \"user\", \"unsubscribed\", \"more_info\", \"custom_column_break_sn9hu\", \"full_name\", \"address\", \"company_name\", \"designation\", \"role\", \"department\", \"image\", \"sb_00\", \"custom_column_break_kmlkz\", \"email_id\", \"mobile_no\", \"phone\", \"status\", \"gender\", \"salutation\", \"contact_details\", \"cb_00\", \"custom_test_label\", \"google_contacts\", \"google_contacts_id\", \"sync_with_google_contacts\", \"cb00\", \"pulled_from_google_contacts\", \"custom_column_break_ejxjz\"]"
},
{
"default_value": null,
"doc_type": "Project",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-26 10:42:06.682515",
"module": null,
"name": "Project-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": "format:{project_template}-#-PRO-{#####}-{YYYY}"
},
{
"default_value": null,
"doc_type": "Project",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-01-26 10:42:06.862234",
"module": null,
"name": "Project-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"custom_column_break_k7sgq\", \"custom_installation_address\", \"naming_series\", \"project_name\", \"job_address\", \"status\", \"custom_warranty_duration_days\", \"custom_warranty_expiration_date\", \"custom_warranty_information\", \"project_type\", \"percent_complete_method\", \"percent_complete\", \"column_break_5\", \"project_template\", \"expected_start_date\", \"expected_start_time\", \"expected_end_date\", \"expected_end_time\", \"is_scheduled\", \"invoice_status\", \"custom_completion_date\", \"priority\", \"custom_foreman\", \"custom_hidden_fields\", \"department\", \"service_appointment\", \"tasks\", \"is_active\", \"custom_address\", \"custom_section_break_lgkpd\", \"custom_workflow_related_custom_fields__landry\", \"custom_permit_status\", \"custom_utlity_locate_status\", \"custom_crew_scheduling\", \"customer_details\", \"customer\", \"column_break_14\", \"sales_order\", \"users_section\", \"users\", \"copied_from\", \"section_break0\", \"notes\", \"section_break_18\", \"actual_start_date\", \"actual_start_time\", \"actual_time\", \"column_break_20\", \"actual_end_date\", \"actual_end_time\", \"project_details\", \"estimated_costing\", \"total_costing_amount\", \"total_expense_claim\", \"total_purchase_cost\", \"company\", \"column_break_28\", \"total_sales_amount\", \"total_billable_amount\", \"total_billed_amount\", \"total_consumed_material_cost\", \"cost_center\", \"margin\", \"gross_margin\", \"column_break_37\", \"per_gross_margin\", \"monitor_progress\", \"collect_progress\", \"holiday_list\", \"frequency\", \"from_time\", \"to_time\", \"first_email\", \"second_email\", \"daily_time_to_send\", \"day_to_send\", \"weekly_time_to_send\", \"column_break_45\", \"subject\", \"message\"]"
} }
] ]

View file

@ -26,10 +26,6 @@ add_to_apps_screen = [
# "has_permission": "custom_ui.api.permission.has_app_permission" # "has_permission": "custom_ui.api.permission.has_app_permission"
} }
] ]
requires = [
"holidays==0.89"
]
# Apps # Apps
# ------------------ # ------------------
@ -37,13 +33,13 @@ requires = [
# Each item in the list will be shown as an app in the apps page # Each item in the list will be shown as an app in the apps page
# add_to_apps_screen = [ # add_to_apps_screen = [
# { # {
# "name": "custom_ui", # "name": "custom_ui",
# "logo": "/assets/custom_ui/logo.png", # "logo": "/assets/custom_ui/logo.png",
# "title": "Custom Ui", # "title": "Custom Ui",
# "route": "/custom_ui", # "route": "/custom_ui",
# "has_permission": "custom_ui.api.permission.has_app_permission" # "has_permission": "custom_ui.api.permission.has_app_permission"
# } # }
# ] # ]
# Includes in <head> # Includes in <head>
@ -86,7 +82,7 @@ requires = [
# website user home page (by Role) # website user home page (by Role)
# role_home_page = { # role_home_page = {
# "Role": "home_page" # "Role": "home_page"
# } # }
# Generators # Generators
@ -100,8 +96,8 @@ requires = [
# add methods and filters to jinja environment # add methods and filters to jinja environment
# jinja = { # jinja = {
# "methods": "custom_ui.utils.jinja_methods", # "methods": "custom_ui.utils.jinja_methods",
# "filters": "custom_ui.utils.jinja_filters" # "filters": "custom_ui.utils.jinja_filters"
# } # }
# Installation # Installation
@ -143,11 +139,11 @@ requires = [
# Permissions evaluated in scripted ways # Permissions evaluated in scripted ways
# permission_query_conditions = { # permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# } # }
# #
# has_permission = { # has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission", # "Event": "frappe.desk.doctype.event.event.has_permission",
# } # }
# DocType Class # DocType Class
@ -155,7 +151,7 @@ requires = [
# Override standard doctype classes # Override standard doctype classes
# override_doctype_class = { # override_doctype_class = {
# "ToDo": "custom_app.overrides.CustomToDo" # "ToDo": "custom_app.overrides.CustomToDo"
# } # }
# Document Events # Document Events
@ -163,11 +159,11 @@ requires = [
# Hook on document methods and events # Hook on document methods and events
doc_events = { doc_events = {
"On-Site Meeting": { "On-Site Meeting": {
"after_insert": "custom_ui.events.onsite_meeting.after_insert", "after_insert": "custom_ui.events.onsite_meeting.after_insert",
"before_save": "custom_ui.events.onsite_meeting.before_save", "before_save": "custom_ui.events.onsite_meeting.before_save",
"before_insert": "custom_ui.events.onsite_meeting.before_insert" "before_insert": "custom_ui.events.onsite_meeting.before_insert"
}, },
"Address": { "Address": {
"before_insert": "custom_ui.events.address.before_insert" "before_insert": "custom_ui.events.address.before_insert"
}, },
@ -185,48 +181,32 @@ doc_events = {
}, },
"Project": { "Project": {
"before_insert": "custom_ui.events.jobs.before_insert", "before_insert": "custom_ui.events.jobs.before_insert",
"after_insert": "custom_ui.events.jobs.after_insert", "after_insert": "custom_ui.events.jobs.after_insert"
"before_save": "custom_ui.events.jobs.before_save",
"on_update": "custom_ui.events.jobs.after_save"
}, },
"Task": { "Task": {
"before_insert": "custom_ui.events.task.before_insert", "before_insert": "custom_ui.events.task.before_insert"
"after_insert": "custom_ui.events.task.after_insert",
"before_save": "custom_ui.events.task.before_save"
},
"Bid Meeting Note Form": {
"after_insert": "custom_ui.events.general.attach_bid_note_form_to_project_template"
},
"Service Address 2": {
"before_save": "custom_ui.events.service_appointment.before_save",
"after_insert": "custom_ui.events.service_appointment.after_insert",
"on_update": "custom_ui.events.service_appointment.on_update"
} }
} }
fixtures = [ fixtures = [
{
"dt": "Email Template",
"filters": [
["name", "in", ["Customer Invoice"]]
]
},
{ {
"dt": "DocType", "dt": "DocType",
"filters": [ "filters": [
["custom", "=", 1] ["custom", "=", 1]
] ]
}, },
{
# These don't have reliable flags → export all "dt": "Custom Field"
{"dt": "Custom Field"}, },
{"dt": "Property Setter"}, {
{"dt": "Client Script"}, "dt": "Property Setter"
{"dt": "Server Script"}, },
# {"dt": "Report"}, {
# {"dt": "Print Format"}, "dt": "Client Script"
# {"dt": "Dashboard"}, },
# {"dt": "Workspace"}, {
"dt": "Server Script"
}
] ]
@ -238,21 +218,21 @@ fixtures = [
# --------------- # ---------------
# scheduler_events = { # scheduler_events = {
# "all": [ # "all": [
# "custom_ui.tasks.all" # "custom_ui.tasks.all"
# ], # ],
# "daily": [ # "daily": [
# "custom_ui.tasks.daily" # "custom_ui.tasks.daily"
# ], # ],
# "hourly": [ # "hourly": [
# "custom_ui.tasks.hourly" # "custom_ui.tasks.hourly"
# ], # ],
# "weekly": [ # "weekly": [
# "custom_ui.tasks.weekly" # "custom_ui.tasks.weekly"
# ], # ],
# "monthly": [ # "monthly": [
# "custom_ui.tasks.monthly" # "custom_ui.tasks.monthly"
# ], # ],
# } # }
# Testing # Testing
@ -264,14 +244,14 @@ fixtures = [
# ------------------------------ # ------------------------------
# #
# override_whitelisted_methods = { # override_whitelisted_methods = {
# "frappe.desk.doctype.event.event.get_events": "custom_ui.event.get_events" # "frappe.desk.doctype.event.event.get_events": "custom_ui.event.get_events"
# } # }
# #
# each overriding function accepts a `data` argument; # each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard, # generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps # along with any modifications made in other Frappe apps
# override_doctype_dashboards = { # override_doctype_dashboards = {
# "Task": "custom_ui.task.get_dashboard_data" # "Task": "custom_ui.task.get_dashboard_data"
# } # }
# exempt linked doctypes from being automatically cancelled # exempt linked doctypes from being automatically cancelled
@ -297,37 +277,37 @@ fixtures = [
# -------------------- # --------------------
# user_data_fields = [ # user_data_fields = [
# { # {
# "doctype": "{doctype_1}", # "doctype": "{doctype_1}",
# "filter_by": "{filter_by}", # "filter_by": "{filter_by}",
# "redact_fields": ["{field_1}", "{field_2}"], # "redact_fields": ["{field_1}", "{field_2}"],
# "partial": 1, # "partial": 1,
# }, # },
# { # {
# "doctype": "{doctype_2}", # "doctype": "{doctype_2}",
# "filter_by": "{filter_by}", # "filter_by": "{filter_by}",
# "partial": 1, # "partial": 1,
# }, # },
# { # {
# "doctype": "{doctype_3}", # "doctype": "{doctype_3}",
# "strict": False, # "strict": False,
# }, # },
# { # {
# "doctype": "{doctype_4}" # "doctype": "{doctype_4}"
# } # }
# ] # ]
# Authentication and authorization # Authentication and authorization
# -------------------------------- # --------------------------------
# auth_hooks = [ # auth_hooks = [
# "custom_ui.auth.validate" # "custom_ui.auth.validate"
# ] # ]
# Automatically update python controller files with type annotations for this app. # Automatically update python controller files with type annotations for this app.
# export_python_type_annotations = True # export_python_type_annotations = True
# default_log_clearing_doctypes = { # default_log_clearing_doctypes = {
# "Logging DocType Name": 30 # days to retain logs # "Logging DocType Name": 30 # days to retain logs
# } # }

View file

@ -4,8 +4,6 @@ import subprocess
import sys import sys
import frappe import frappe
from .utils import create_module from .utils import create_module
import holidays
from datetime import date, timedelta
def after_install(): def after_install():
create_module() create_module()
@ -19,11 +17,6 @@ def after_install():
frappe.reload_doctype("On-Site Meeting") frappe.reload_doctype("On-Site Meeting")
update_onsite_meeting_fields() update_onsite_meeting_fields()
update_address_fields() update_address_fields()
check_and_create_holiday_list()
create_project_templates()
create_task_types()
# create_tasks()
create_bid_meeting_note_form_templates()
build_frontend() build_frontend()
def after_migrate(): def after_migrate():
@ -37,13 +30,7 @@ def after_migrate():
frappe.clear_cache(doctype=doctype) frappe.clear_cache(doctype=doctype)
frappe.reload_doctype(doctype) frappe.reload_doctype(doctype)
check_and_create_holiday_list() update_address_fields()
# create_project_templates()
create_task_types()
# create_tasks()
create_bid_meeting_note_form_templates()
# update_address_fields()
# build_frontend() # build_frontend()
@ -159,13 +146,6 @@ def add_custom_fields():
fieldtype="Link", fieldtype="Link",
options="Contact", options="Contact",
insert_after="contacts" insert_after="contacts"
),
dict(
fieldname="tasks",
label="Tasks",
fieldtype="Table",
options="Customer Task Link",
insert_after="projects"
) )
], ],
"Lead": [ "Lead": [
@ -347,13 +327,6 @@ def add_custom_fields():
fieldtype="Table", fieldtype="Table",
options="Address Company Link", options="Address Company Link",
insert_after="contacts" insert_after="contacts"
),
dict(
fieldname="tasks",
label="Tasks",
fieldtype="Table",
options="Address Task Link",
insert_after="projects"
) )
], ],
"Contact": [ "Contact": [
@ -566,60 +539,7 @@ def add_custom_fields():
options="Customer", options="Customer",
insert_after="job_address", insert_after="job_address",
description="The customer for whom the project is being executed." description="The customer for whom the project is being executed."
), )
dict(
fieldname="expected_start_time",
label="Expected Start Time",
fieldtype="Time",
insert_after="expected_start_date"
),
dict(
fieldname="expected_end_time",
label="Expected End Time",
fieldtype="Time",
insert_after="expected_end_date"
),
dict(
fieldname="actual_start_time",
label="Actual Start Time",
fieldtype="Time",
insert_after="actual_start_date"
),
dict(
fieldname="actual_end_time",
label="Actual End Time",
fieldtype="Time",
insert_after="actual_end_date"
),
dict(
fieldname="is_scheduled",
label="Is Scheduled",
fieldtype="Check",
default=0,
insert_after="is_half_down_paid"
),
dict(
fieldname="invoice_status",
label="Invoice Status",
fieldtype="Select",
default="Not Ready",
options="Not Ready\nReady to Invoice\nInvoice Created\nInvoice Sent",
insert_after="is_scheduled"
),
dict(
fieldname="requires_half_payment",
label="Requires Half Payment",
fieldtype="Check",
default=0,
insert_after="expected_end_time"
),
dict(
fieldname="is_half_down_paid",
label="Is Half Down Paid",
fieldtype="Check",
default=0,
insert_after="requires_half_payment"
),
], ],
"Project Template": [ "Project Template": [
dict( dict(
@ -629,21 +549,6 @@ def add_custom_fields():
options="Company", options="Company",
insert_after="project_type", insert_after="project_type",
description="The company associated with this project template." description="The company associated with this project template."
),
dict(
fieldname="calendar_color",
label="Calendar Color",
fieldtype="Color",
insert_after="company"
)
],
"Task": [
dict(
fieldname="project_template",
label="Project Template",
fieldtype="Link",
options="Project Template",
insert_after="project"
) )
] ]
} }
@ -993,388 +898,3 @@ def build_missing_field_specs(custom_fields, missing_fields):
break break
return missing_field_specs return missing_field_specs
def check_and_create_holiday_list(year=2026, country="US", weekly_off="Sunday"):
"""Check if Holiday List for the given year exists, if not create it."""
print(f"\n🔧 Checking for Holiday List for {country} in {year}...")
holiday_list_name = f"{country} Holidays {year}"
if frappe.db.exists("Holiday List", holiday_list_name):
print(f"✅ Holiday List '{holiday_list_name}' already exists.")
return
else:
print(f"❌ Holiday List '{holiday_list_name}' does not exist. Creating...")
us_holidays = holidays.US(years=[year])
sundays = get_all_sundays(year)
hl = frappe.get_doc({
"doctype": "Holiday List",
"holiday_list_name": holiday_list_name,
"country": country,
"year": year,
"from_date": f"{year}-01-01",
"to_date": f"{year}-12-31",
"weekly_off": weekly_off,
"holidays": [
{
"holiday_date": holiday_date,
"description": holiday_name
} for holiday_date, holiday_name in us_holidays.items()
]
})
for sunday in sundays:
hl.append("holidays", {
"holiday_date": sunday,
"description": "Sunday"
})
hl.insert()
# hl.make_holiday_entries()
frappe.db.commit()
print(f"✅ Holiday List '{holiday_list_name}' created successfully.")
def get_all_sundays(year):
sundays = []
d = date(year, 1, 1)
while d.weekday() != 6:
d += timedelta(days=1)
while d.year == year:
sundays.append(d)
d += timedelta(days=7)
return sundays
def create_task_types():
task_types = [
{
"title": "811/Locate",
"name": "811/Locate",
"description": "Utility locate request prior to installation start.",
"base_date": "Start",
"offset_days": 7,
"offset_direction": "Before",
"calculate_from": "Service Address 2",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
},
{
"title": "Permit",
"name": "Permit",
"description": "Permits required prior to installation start.",
"base_date": "Start",
"offset_days": 7,
"offset_direction": "Before",
"calculate_from": "Service Address 2",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
},
{
"title": "1/2 Down Payment",
"name": "1/2 Down Payment",
"description": "Collect half down payment on project creation.",
"calculate_from": "Project",
"trigger": "Created",
"triggering_doctype": "Project",
"no_due_date": 1,
},
{
"title": "Machine Staging",
"name": "Machine Staging",
"description": "Stage machinery one day before installation start.",
"base_date": "Start",
"offset_days": 1,
"offset_direction": "Before",
"calculate_from": "Service Address 2",
"triggering_doctype": "Service Address 2",
"trigger": "Scheduled",
},
{
"title": "Final Invoice",
"name": "Final Invoice",
"description": "Send final invoice within 5 days of job completion.",
"base_date": "Completion",
"offset_days": 5,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"triggering_doctype": "Service Address 2",
"trigger": "Completed",
},
{
"title": "Backflow Test",
"name": "Backflow Test",
"description": "Backflow test after job completion if quoted.",
"base_date": "Completion",
"offset_days": 0,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"triggering_doctype": "Service Address 2",
"trigger": "Completed",
},
{
"title": "Schedule Permit Inspection",
"name": "Schedule Permit Inspection",
"description": "Schedule permit inspection 5 days after completion.",
"base_date": "Completion",
"offset_days": 5,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"trigger": "Completed",
"triggering_doctype": "Service Address 2"
},
{
"title": "15 Day Warranty Follow-Up",
"name": "15 Day Warranty Follow-Up",
"description": "15-day warranty follow-up after completion.",
"base_date": "Completion",
"offset_days": 15,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"trigger": "Completed",
"triggering_doctype": "Service Address 2"
},
{
"title": "30 Day Warranty Check",
"name": "30 Day Warranty Check",
"description": "30-day warranty check after completion.",
"base_date": "Completion",
"offset_days": 30,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"triggering_doctype": "Service Address 2",
"trigger": "Completed",
},
{
"title": "30 Day Payment Reminder",
"name": "30 Day Payment Reminder",
"description": "Payment reminder sent 30 days after completion.",
"base_date": "Completion",
"offset_days": 30,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"trigger": "Completed",
"triggering_doctype": "Service Address 2"
},
{
"title": "60 Day Late Payment Notice",
"name": "60 Day Late Payment Notice",
"description": "Late payment notification at 60 days post completion.",
"base_date": "Completion",
"offset_days": 60,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"trigger": "Completed",
"triggering_doctype": "Service Address 2"
},
{
"title": "80 Day Lien Notice",
"name": "80 Day Lien Notice",
"description": "Lien notice if payment is still late after 80 days.",
"base_date": "Completion",
"offset_days": 80,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"trigger": "Completed",
"triggering_doctype": "Service Address 2"
},
{
"title": "365 Day Warranty Call / Walk",
"name": "365 Day Warranty Call / Walk",
"description": "One-year warranty call or walk-through.",
"base_date": "Completion",
"offset_days": 365,
"offset_direction": "After",
"calculate_from": "Service Address 2",
"trigger": "Completed",
"triggering_doctype": "Service Address 2"
},
]
for task_type in task_types:
# Idempotency check
if frappe.db.exists("Task Type", task_type["name"]):
continue
doc = frappe.get_doc({
"doctype": "Task Type",
"title": task_type["title"],
"name": task_type["name"],
"description": task_type["description"],
"base_date": task_type.get("base_date"),
"offset_days": task_type.get("offset_days", 0),
"offset_direction": task_type.get("offset_direction"),
"calculate_from": task_type.get("calculate_from", "Service Address 2"),
"trigger": task_type["trigger"],
"no_due_date": task_type.get("no_due_date", 0),
"triggering_doctype": task_type.get("triggering_doctype", "Service Address 2")
})
doc.insert(ignore_permissions=True)
frappe.db.commit()
def create_tasks():
print("\n🔧 Creating default Tasks if they do not exist...")
default_tasks = [
{
"task_name": "Initial Consultation",
"description": "Conduct an initial consultation with the client to discuss project requirements.",
"status": "Open",
"priority": "High",
"type": "Consultation"
},
{
"task_name": "Site Survey",
"description": "Perform a site survey to assess conditions and gather necessary data.",
"status": "Open",
"priority": "Medium"
},
{
"task_name": "Design Proposal",
"description": "Prepare and present a design proposal based on client needs and site survey findings.",
"status": "Open",
"priority": "High"
}
]
for task in default_tasks:
if frappe.db.exists("Task", task["task_name"]):
continue
doc = frappe.get_doc({
"doctype": "Task",
"is_template": 1,
"task_name": task["task_name"],
"description": task["description"],
"status": task["status"],
"priority": task["priority"],
"type": task["type"]
})
doc.insert(ignore_permissions=True)
def create_project_templates():
"""Create default Project Templates if they do not exist."""
print("\n🔧 Checking for default Project Templates...")
templates = {
"snw_templates": [
{
"name": "SNW Install",
"project_type": "Service",
"company": "Sprinklers Northwest",
"calendar_color": "#FF5733" # Example color
}
]
}
def create_bid_meeting_note_form_templates():
"""Create Bid Meeting Note Forms if they do not exist."""
print("\n🔧 Checking for Bid Meeting Note Forms...")
forms = {
"Sprinklers Northwest": [
{
"title": "SNW Install Bid Meeting Notes",
"description": "Notes form for SNW Install bid meetings.",
"project_template": "SNW Install",
"fields": [
{
"label": "Locate Needed",
"type": "Check",
"required": 1,
"help_text": "Indicate if a locate is needed for this project.",
"row": 1,
"column": 1,
},
{
"label": "Permit Needed",
"type": "Check",
"required": 1,
"help_text": "Indicate if a permit is needed for this project.",
"row": 1,
"column": 2,
},
{
"label": "Back Flow Test Required",
"type": "Check",
"required": 1,
"help_text": "Indicate if a backflow test is required after installation.",
"row": 1,
"column": 3,
},
{
"label": "Machine Access",
"type": "Check",
"required": 1,
"row": 2,
"column": 1,
},
{
"label": "Machines",
"type": "Multi-Select",
"options": "MT, Skip Steer, Excavator-E-50, Link Belt, Tre?, Forks, Auger, Backhoe, Loader, Duzer",
"include_options": 1,
"conditional_on_field": "Machine Access",
"row": 2,
"column": 2,
},
{
"label": "Materials Required",
"type": "Check",
"required": 1,
"row": 3,
"column": 0,
},
{
"label": "Materials",
"type": "Multi-Select w/ Quantity",
"doctype_for_select": "Item",
"doctype_label_field": "itemName",
"conditional_on_field": "Materials Required",
"row": 4,
"column": 0,
},
],
}
]
}
for company, form_list in forms.items():
for form in form_list:
# Idempotency check
if frappe.db.exists(
"Bid Meeting Note Form",
{"title": form["title"], "company": company},
):
continue
doc = frappe.new_doc("Bid Meeting Note Form")
doc.company = company
doc.title = form["title"]
doc.description = form.get("description")
doc.project_template = form.get("project_template")
for idx, field in enumerate(form.get("fields", []), start=1):
doc.append(
"fields",
{
"label": field["label"],
"type": field["type"],
"options": field.get("options"),
"required": field.get("required", 0),
"default_value": field.get("default_value"),
"read_only": field.get("read_only", 0),
"order": field.get("order", 0),
"help_text": field.get("help_text"),
"doctype_for_select": field.get("doctype_for_select"),
"include_options": field.get("include_options", 0),
"conditional_on_field": field.get("conditional_on_field"),
"conditional_on_value": field.get("conditional_on_value"),
"doctype_label_field": field.get("doctype_label_field"),
"row": field.get("row"),
"column": field.get("column"),
"idx": idx,
},
)
doc.insert(ignore_permissions=True)

View file

@ -4,5 +4,3 @@ from .db_service import DbService
from .client_service import ClientService from .client_service import ClientService
from .estimate_service import EstimateService from .estimate_service import EstimateService
from .onsite_meeting_service import OnSiteMeetingService from .onsite_meeting_service import OnSiteMeetingService
from .task_service import TaskService
from .service_appointment_service import ServiceAppointmentService

View file

@ -122,6 +122,7 @@ class ClientService:
print(f"DEBUG: Linked quotation {quotation.get('quotation')} to customer") print(f"DEBUG: Linked quotation {quotation.get('quotation')} to customer")
except Exception as e: except Exception as e:
print(f"ERROR: Failed to link quotation {quotation.get('quotation')}: {str(e)}") print(f"ERROR: Failed to link quotation {quotation.get('quotation')}: {str(e)}")
frappe.log_error(f"Quotation linking error: {str(e)}", "convert_lead_to_customer")
if update_onsite_meetings: if update_onsite_meetings:
print(f"DEBUG: Updating onsite meetings. Count: {len(lead_doc.get('onsite_meetings', []))}") print(f"DEBUG: Updating onsite meetings. Count: {len(lead_doc.get('onsite_meetings', []))}")

View file

@ -1,54 +0,0 @@
import frappe
from custom_ui.services import ContactService, AddressService, ClientService, DbService
class ServiceAppointmentService:
@staticmethod
def create(data):
"""Create a new Service Appointment document."""
print("DEBUG: Creating Service Appointment with data:", data)
service_appointment_doc = frappe.get_doc({
"doctype": "Service Address 2",
**data
})
service_appointment_doc.insert()
print("DEBUG: Created Service Appointment with name:", service_appointment_doc.name)
return service_appointment_doc
@staticmethod
def get_full_dict(service_appointment_name: str) -> dict:
"""Retrieve a Service Appointment document as a full dictionary."""
print(f"DEBUG: Retrieving Service Appointment document with name: {service_appointment_name}")
service_appointment = frappe.get_doc("Service Address 2", service_appointment_name).as_dict()
service_appointment["service_address"] = AddressService.get_or_throw(service_appointment["service_address"]).as_dict()
service_appointment["customer"] = ClientService.get_client_or_throw(service_appointment["customer"]).as_dict()
service_appointment["project"] = DbService.get_or_throw("Project", service_appointment["project"]).as_dict()
return service_appointment
@staticmethod
def update_scheduled_dates(service_appointment_name: str, crew_lead_name: str,start_date, end_date, start_time=None, end_time=None):
"""Update the scheduled start and end dates of a Service Appointment."""
print(f"DEBUG: Updating scheduled dates for Service Appointment {service_appointment_name} to start: {start_date}, end: {end_date}")
service_appointment = DbService.get_or_throw("Service Address 2", service_appointment_name)
service_appointment.expected_start_date = start_date
service_appointment.expected_end_date = end_date
service_appointment.foreman = crew_lead_name
if start_time:
service_appointment.expected_start_time = start_time
if end_time:
service_appointment.expected_end_time = end_time
service_appointment.save()
print(f"DEBUG: Updated scheduled dates for Service Appointment {service_appointment_name}")
return service_appointment
@staticmethod
def update_field(service_appointment_name: str, updates: list[tuple[str, any]]):
"""Update specific fields of a Service Appointment."""
print(f"DEBUG: Updating fields for Service Appointment {service_appointment_name} with updates: {updates}")
service_appointment = DbService.get_or_throw("Service Address 2", service_appointment_name)
for field, value in updates:
setattr(service_appointment, field, value)
service_appointment.save()
print(f"DEBUG: Updated fields for Service Appointment {service_appointment_name}")
return service_appointment

View file

@ -1,171 +0,0 @@
import frappe
from frappe.utils.safe_exec import safe_eval
from datetime import timedelta, datetime, date
from frappe.utils import getdate
class TaskService:
@staticmethod
def calculate_and_set_due_dates(task_names: list[str], event: str, current_triggering_dict=None):
"""Calculate the due date for a list of tasks based on their expected end dates."""
for task_name in task_names:
TaskService.check_and_update_task_due_date(task_name, event, current_triggering_dict)
@staticmethod
def get_tasks_by_project(project_name: str):
"""Retrieve all tasks associated with a given project."""
task_names = frappe.get_all("Task", filters={"project": project_name}, pluck="name")
tasks = [frappe.get_doc("Task", task_name) for task_name in task_names]
return tasks
@staticmethod
def check_and_update_task_due_date(task_name: str, event: str, current_triggering_dict=None):
"""Determine the triggering configuration for a given task."""
task_type_doc = TaskService.get_task_type_doc(task_name)
if task_type_doc.no_due_date:
print(f"DEBUG: Task {task_name} is marked as no due date, skipping calculation.")
return
if task_type_doc.triggering_doctype != current_triggering_dict.get("doctype") and current_triggering_dict:
print(f"DEBUG: Task {task_name} triggering doctype {task_type_doc.triggering_doctype} does not match triggering doctype {current_triggering_dict.get('doctype')}, skipping calculation.")
return
if task_type_doc.trigger != event:
print(f"DEBUG: Task {task_name} trigger {task_type_doc.trigger} does not match event {event}, skipping calculation.")
return
if task_type_doc.logic_key:
print(f"DEBUG: Task {task_name} has a logic key set, skipping calculations and running logic.")
safe_eval(task_type_doc.logic_key, {"task_name": task_name, "task_type_doc": task_type_doc})
if task_type_doc.no_due_date:
print(f"DEBUG: Task {task_name} is marked as no due date, skipping calculation.")
return
calculate_from = task_type_doc.calculate_from
trigger = task_type_doc.trigger
print(f"DEBUG: Calculating triggering data for Task {task_name} from {calculate_from} on trigger {trigger}")
triggering_doc_dict = current_triggering_dict if current_triggering_dict else TaskService.get_triggering_doc_dict(task_name=task_name, task_type_doc=task_type_doc)
calculated_due_date, calculated_start_date = TaskService.calculate_dates(
task_name=task_name,
triggering_doc_dict=triggering_doc_dict,
task_type_doc=task_type_doc
)
update_required = TaskService.determine_update_required(
task_name=task_name,
calculated_due_date=calculated_due_date,
calculated_start_date=calculated_start_date
)
if update_required:
TaskService.update_task_dates(
task_name=task_name,
calculated_due_date=calculated_due_date,
calculated_start_date=calculated_start_date
)
@staticmethod
def get_task_type_doc(task_name: str):
task_type_name = frappe.get_value("Task", task_name, "type")
return frappe.get_doc("Task Type", task_type_name)
@staticmethod
def calculate_dates(task_name: str, triggering_doc_dict: dict, task_type_doc) -> tuple[date | None, date | None]:
offset_direction = task_type_doc.offset_direction
offset_days = task_type_doc.offset_days
base_date_field = TaskService.map_base_date_to_field(task_type_doc.base_date, task_type_doc.triggering_doctype)
print(f"DEBUG: base_date_field for Task {task_name} is {base_date_field}")
if offset_direction == "Before":
offset_days = -offset_days
base_date_field_value = triggering_doc_dict.get(base_date_field)
print(f"DEBUG: base_date_field_value for Task {task_name} is {base_date_field_value}")
if isinstance(base_date_field_value, datetime):
base_date_field_value = base_date_field_value
else:
base_date_field_value = getdate(base_date_field_value)
calculated_due_date = base_date_field_value + timedelta(days=offset_days)
calculated_start_date = None
if task_type_doc.days > 1:
calculated_start_date = calculated_due_date - timedelta(days=task_type_doc.days)
print(f"DEBUG: Calculated dates for Task {task_name} - Due Date: {calculated_due_date}, Start Date: {calculated_start_date}")
return calculated_due_date, calculated_start_date
@staticmethod
def determine_update_required(task_name: str, calculated_due_date: date | None, calculated_start_date: date | None) -> bool:
current_due_date = frappe.get_value("Task", task_name, "exp_end_date")
current_start_date = frappe.get_value("Task", task_name, "exp_start_date")
if current_due_date != calculated_due_date or current_start_date != calculated_start_date:
print(f"DEBUG: Update required for Task {task_name}. Current due date: {current_due_date}, Calculated due date: {calculated_due_date}. Current start date: {current_start_date}, Calculated start date: {calculated_start_date}")
return True
else:
print(f"DEBUG: No update required for Task {task_name}. Dates are up to date.")
return False
@staticmethod
def get_triggering_doc_dict(task_name: str, task_type_doc) -> dict | None:
project_name = frappe.get_value("Task", task_name, "project")
print(f"DEBUG: Project name: {project_name}")
dict = None
if task_type_doc.calculate_from == "Project":
dict = frappe.get_doc("Project", project_name).as_dict()
if task_type_doc.calculate_from == "Service Address 2":
service_name = frappe.get_value("Project", project_name, "service_appointment")
dict = frappe.get_doc("Service Address 2", service_name).as_dict()
if task_type_doc.calculate_from == "Task":
project_doc = frappe.get_doc("Project", project_name)
for task in project_doc.tasks:
if task.task_type == task_type_doc.task_type_calculate_from:
dict = frappe.get_doc("Task", task.task).as_dict()
print(f"DEBUG: Triggering doc dict for Task {task_name}: {dict}")
return dict
@staticmethod
def update_task_dates(task_name: str, calculated_due_date: date | None, calculated_start_date: date | None):
task_doc = frappe.get_doc("Task", task_name)
task_doc.exp_end_date = calculated_due_date
task_doc.exp_start_date = calculated_start_date
task_doc.save(ignore_permissions=True)
print(f"DEBUG: Updated Task {task_name} with new dates - Start: {calculated_start_date}, End: {calculated_due_date}")
@staticmethod
def map_base_date_to_field(base_date: str, triggering_doctype: str) -> str:
"""Map a base date configuration to a corresponding field name."""
base_date_field_map = {
"Start": "expected_start_date",
"End": "expected_end_date",
"Creation": "creation",
"Completion": "actual_end_date"
}
task_date_field_map = {
"Start": "exp_start_date",
"End": "exp_end_date",
"Creation": "creation",
"Completion": "actual_end_date"
}
if triggering_doctype == "Task":
return task_date_field_map.get(base_date, "exp_end_date")
return base_date_field_map.get(base_date, "expected_end_date")
@staticmethod
def determine_event(triggering_doc) -> str | None:
print("DEBUG: Current Document:", triggering_doc.as_dict())
if not frappe.db.exists(triggering_doc.doctype, triggering_doc.name):
print("DEBUG: Document does not exist in database, returning None for event.")
return None
prev_doc = frappe.get_doc(triggering_doc.doctype, triggering_doc.name, as_dict=False, ignore_if_missing=True)
start_date_field = "expected_start_date" if triggering_doc.doctype != "Task" else "exp_start_date"
end_date_field = "expected_end_date" if triggering_doc.doctype != "Task" else "exp_end_date"
print("DEBUG: Previous Document:", prev_doc.as_dict() if prev_doc else "None")
if not prev_doc:
return None
if getattr(prev_doc, end_date_field) != getattr(triggering_doc, end_date_field) or getattr(prev_doc, start_date_field) != getattr(triggering_doc, start_date_field):
return "Scheduled"
elif prev_doc.status != triggering_doc.status and triggering_doc.status == "Completed":
return "Completed"
else:
return None

View file

@ -5,73 +5,47 @@ import { useErrorStore } from "./stores/errors";
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us"; const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
// Proxy method for external API calls // Proxy method for external API calls
const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request"; const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request";
// On-Site Meeting methods
const FRAPPE_GET_INCOMPLETE_BIDS_METHOD = "custom_ui.api.db.on_site_meetings.get_incomplete_bids";
// Estimate methods // Estimate methods
const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate"; const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate";
const FRAPPE_GET_ESTIMATES_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data"; const FRAPPE_GET_ESTIMATES_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data";
const FRAPPE_GET_ESTIMATES_TABLE_DATA_V2_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data_v2";
const FRAPPE_GET_ESTIMATE_BY_ADDRESS_METHOD = "custom_ui.api.db.estimates.get_estimate_from_address"; const FRAPPE_GET_ESTIMATE_BY_ADDRESS_METHOD = "custom_ui.api.db.estimates.get_estimate_from_address";
const FRAPPE_SEND_ESTIMATE_EMAIL_METHOD = "custom_ui.api.db.estimates.send_estimate_email"; const FRAPPE_SEND_ESTIMATE_EMAIL_METHOD = "custom_ui.api.db.estimates.send_estimate_email";
const FRAPPE_LOCK_ESTIMATE_METHOD = "custom_ui.api.db.estimates.lock_estimate"; const FRAPPE_LOCK_ESTIMATE_METHOD = "custom_ui.api.db.estimates.lock_estimate";
const FRAPPE_ESTIMATE_UPDATE_RESPONSE_METHOD = "custom_ui.api.db.estimates.manual_response"; const FRAPPE_ESTIMATE_UPDATE_RESPONSE_METHOD = "custom_ui.api.db.estimates.manual_response";
const FRAPPE_GET_ESTIMATE_TEMPLATES_METHOD = "custom_ui.api.db.estimates.get_estimate_templates"; const FRAPPE_GET_ESTIMATE_TEMPLATES_METHOD = "custom_ui.api.db.estimates.get_estimate_templates";
const FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD = "custom_ui.api.db.estimates.create_estimate_template"; const FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD = "custom_ui.api.db.estimates.create_estimate_template";
const FRAPPE_GET_UNAPPROVED_ESTIMATES_COUNT_METHOD = "custom_ui.api.db.estimates.get_unapproved_estimates_count";
const FRAPPE_GET_ESTIMATES_HALF_DOWN_COUNT_METHOD = "custom_ui.api.db.estimates.get_estimates_half_down_count";
// Job methods // Job methods
const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job"; const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job";
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data"; const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data";
const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job"; const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job";
const FRAPPE_GET_JOB_TASK_TABLE_DATA_METHOD = "custom_ui.api.db.jobs.get_job_task_table_data"; const FRAPPE_GET_JOB_TASK_LIST_METHOD = "custom_ui.api.db.jobs.get_job_task_table_data";
const FRAPPE_GET_JOB_TASK_LIST_METHOD = "custom_ui.api.db.jobs.get_job_task_list";
const FRAPPE_GET_INSTALL_PROJECTS_METHOD = "custom_ui.api.db.jobs.get_install_projects"; const FRAPPE_GET_INSTALL_PROJECTS_METHOD = "custom_ui.api.db.jobs.get_install_projects";
const FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD = "custom_ui.api.db.jobs.get_projects_for_calendar";
const FRAPPE_GET_JOB_TEMPLATES_METHOD = "custom_ui.api.db.jobs.get_job_templates"; const FRAPPE_GET_JOB_TEMPLATES_METHOD = "custom_ui.api.db.jobs.get_job_templates";
const FRAPPE_UPDATE_JOB_SCHEDULED_DATES_METHOD = "custom_ui.api.db.jobs.update_job_scheduled_dates";
const FRAPPE_GET_JOBS_IN_QUEUE_METHOD = "custom_ui.api.db.jobs.get_jobs_in_queue_count";
const FRAPPE_GET_JOBS_IN_PROGRESS_METHOD = "custom_ui.api.db.jobs.get_jobs_in_progress_count";
const FRAPPE_GET_JOBS_LATE_METHOD = "custom_ui.api.db.jobs.get_jobs_late_count";
const FRAPPE_GET_JOBS_TO_INVOICE_METHOD = "custom_ui.api.db.jobs.get_jobs_to_invoice_count";
// Task methods // Task methods
const FRAPPE_GET_TASKS_METHOD = "custom_ui.api.db.tasks.get_tasks_table_data"; const FRAPPE_GET_TASKS_METHOD = "custom_ui.api.db.tasks.get_tasks_table_data";
const FRAPPE_GET_TASKS_STATUS_OPTIONS = "custom_ui.api.db.tasks.get_task_status_options"; const FRAPPE_GET_TASKS_STATUS_OPTIONS = "custom_ui.api.db.tasks.get_task_status_options";
const FRAPPE_SET_TASK_STATUS_METHOD = "custom_ui.api.db.tasks.set_task_status"; const FRAPPE_SET_TASK_STATUS_METHOD = "custom_ui.api.db.tasks.set_task_status";
const FRAPPE_GET_TASKS_DUE_METHOD = "custom_ui.api.db.tasks.get_tasks_due";
// Invoice methods // Invoice methods
const FRAPPE_GET_INVOICES_METHOD = "custom_ui.api.db.invoices.get_invoice_table_data"; const FRAPPE_GET_INVOICES_METHOD = "custom_ui.api.db.invoices.get_invoice_table_data";
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice"; const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
const FRAPPE_GET_INVOICES_LATE_METHOD = "custom_ui.api.db.invoices.get_invoices_late_count";
const FRAPPE_CREATE_INVOICE_FOR_JOB = "custom_ui.api.db.invoices.create_invoice_for_job";
// Warranty methods // Warranty methods
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims"; const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
// On-Site Meeting methods // On-Site Meeting methods
const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD = const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD =
"custom_ui.api.db.bid_meetings.get_week_bid_meetings"; "custom_ui.api.db.bid_meetings.get_week_bid_meetings";
const FRAPPE_GET_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meeting_note_form";
const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meetings"; const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meetings";
const FRAPPE_SUBMIT_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.submit_bid_meeting_note_form";
// Address methods // Address methods
const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses"; const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
// Client methods // Client methods
const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client"; const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client";
const FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD = "custom_ui.api.db.clients.get_client_status_counts"; const FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD = "custom_ui.api.db.clients.get_client_status_counts";
const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data"; const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data";
const FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD = "custom_ui.api.db.clients.get_clients_table_data_v2";
const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client_v2"; const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client_v2";
const FRAPPE_GET_CLIENT_NAMES_METHOD = "custom_ui.api.db.clients.get_client_names"; const FRAPPE_GET_CLIENT_NAMES_METHOD = "custom_ui.api.db.clients.get_client_names";
// Employee methods
const FRAPPE_GET_EMPLOYEES_METHOD = "custom_ui.api.db.employees.get_employees";
const FRAPPE_GET_EMPLOYEES_ORGANIZED_METHOD = "custom_ui.api.db.employees.get_employees_organized";
// Other methods
const FRAPPE_GET_WEEK_HOLIDAYS_METHOD = "custom_ui.api.db.general.get_week_holidays";
const FRAPPE_GET_DOC_LIST_METHOD = "custom_ui.api.db.general.get_doc_list";
// Service Appointment methods
const FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD = "custom_ui.api.db.service_appointments.get_service_appointments";
const FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD = "custom_ui.api.db.service_appointments.update_service_appointment_scheduled_dates";
class Api { class Api {
// ============================================================================ // ============================================================================
// CORE REQUEST METHOPD // CORE REQUEST METHOD
// ============================================================================ // ============================================================================
static async request(frappeMethod, args = {}) { static async request(frappeMethod, args = {}) {
@ -111,17 +85,6 @@ class Api {
return await this.request(FRAPPE_GET_CLIENT_METHOD, { clientName }); return await this.request(FRAPPE_GET_CLIENT_METHOD, { clientName });
} }
static async getPaginatedClientDetailsV2(paginationParams = {}, filters = {}, sortings = []) {
const { page = 0, pageSize = 10 } = paginationParams;
const result = await this.request(FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD, {
filters,
sortings,
page: page + 1,
pageSize,
});
return result;
}
/** /**
* Get paginated client data with filtering and sorting * Get paginated client data with filtering and sorting
* @param {Object} paginationParams - Pagination parameters from store * @param {Object} paginationParams - Pagination parameters from store
@ -173,22 +136,9 @@ class Api {
// ON-SITE MEETING METHODS // ON-SITE MEETING METHODS
// ============================================================================ // ============================================================================
static async getBidMeetingNoteForm(projectTemplate) { static async getUnscheduledBidMeetings() {
return await this.request(FRAPPE_GET_BID_MEETING_NOTE_FORM_METHOD, { projectTemplate });
}
static async submitBidMeetingNoteForm(data) {
return await this.request(FRAPPE_SUBMIT_BID_MEETING_NOTE_FORM_METHOD, {
bidMeeting: data.bidMeeting,
projectTemplate: data.projectTemplate,
formTemplate: data.formTemplate,
fields: data.fields});
}
static async getUnscheduledBidMeetings(company) {
return await this.request( return await this.request(
"custom_ui.api.db.bid_meetings.get_unscheduled_bid_meetings", "custom_ui.api.db.bid_meetings.get_unscheduled_bid_meetings",
{ company }
); );
} }
@ -196,8 +146,8 @@ class Api {
return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters }); return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters });
} }
static async getWeekBidMeetings(weekStart, weekEnd, company) { static async getWeekBidMeetings(weekStart, weekEnd) {
return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd, company }); return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd });
} }
static async updateBidMeeting(name, data) { static async updateBidMeeting(name, data) {
@ -219,12 +169,6 @@ class Api {
}); });
} }
static async getBidMeetingNote(name) {
return await this.request("custom_ui.api.db.bid_meetings.get_bid_meeting_note", {
name,
});
}
// ============================================================================ // ============================================================================
// ESTIMATE / QUOTATION METHODS // ESTIMATE / QUOTATION METHODS
// ============================================================================ // ============================================================================
@ -269,12 +213,7 @@ class Api {
console.log("DEBUG: API - Sending estimate options to backend:", page, pageSize, filters, sorting); console.log("DEBUG: API - Sending estimate options to backend:", page, pageSize, filters, sorting);
const result = await this.request(FRAPPE_GET_ESTIMATES_TABLE_DATA_V2_METHOD, { page, pageSize, filters, sorting}); const result = await this.request(FRAPPE_GET_ESTIMATES_METHOD, { page, pageSize, filters, sorting});
return result;
}
static async getIncompleteBidsCount(currentCompany) {
const result = await this.request(FRAPPE_GET_INCOMPLETE_BIDS_METHOD, { company: currentCompany });
return result; return result;
} }
@ -303,14 +242,6 @@ class Api {
return await this.request(FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD, { data }); return await this.request(FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD, { data });
} }
static async getUnapprovedEstimatesCount(currentCompany) {
return await this.request(FRAPPE_GET_UNAPPROVED_ESTIMATES_COUNT_METHOD, {company: currentCompany});
}
static async getEstimatesHalfDownCount(currentCompany) {
return await this.request(FRAPPE_GET_ESTIMATES_HALF_DOWN_COUNT_METHOD, {company: currentCompany});
}
// ============================================================================ // ============================================================================
// JOB / PROJECT METHODS // JOB / PROJECT METHODS
// ============================================================================ // ============================================================================
@ -367,39 +298,6 @@ class Api {
return result; return result;
} }
static async getJobsForCalendar(startDate, endDate, company = null, projectTemplates = []) {
return await this.request(FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD, { startDate, endDate, company, projectTemplates });
}
static async updateJobScheduledDates(jobName, newStartDate, newEndDate, foremanName) {
return await this.request(FRAPPE_UPDATE_JOB_SCHEDULED_DATES_METHOD, {
jobName,
newStartDate,
newEndDate,
foremanName,
});
}
static async getJobsInQueueCount(currentCompany) {
return await this.request(FRAPPE_GET_JOBS_IN_QUEUE_METHOD, {company: currentCompany});
}
static async getJobsInProgressCount(currentCompany) {
return await this.request(FRAPPE_GET_JOBS_IN_PROGRESS_METHOD, {company: currentCompany});
}
static async getJobsLateCount(currentCompany) {
return await this.request(FRAPPE_GET_JOBS_LATE_METHOD, {company: currentCompany});
}
static async getJobsToInvoiceCount(currentCompany) {
return await this.request(FRAPPE_GET_JOBS_TO_INVOICE_METHOD, {company: currentCompany});
}
static async setJobCompleted(jobName) {
return await this.request(FRAPPE_SET_JOB_COMPLETE_METHOD, {jobName: jobName});
}
static async getJob(jobName) { static async getJob(jobName) {
if (frappe.db.exists("Project", jobName)) { if (frappe.db.exists("Project", jobName)) {
const result = await this.request(FRAPPE_GET_JOB_METHOD, { jobId: jobName }) const result = await this.request(FRAPPE_GET_JOB_METHOD, { jobId: jobName })
@ -446,29 +344,10 @@ class Api {
console.log("DEBUG: API - Sending job task options to backend:", options); console.log("DEBUG: API - Sending job task options to backend:", options);
const result = await this.request(FRAPPE_GET_JOB_TASK_TABLE_DATA_METHOD, { filters, sortings: sorting, page:page+1, pageSize }); const result = await this.request(FRAPPE_GET_JOB_TASK_LIST_METHOD, { options, filters });
return result; return result;
} }
// ============================================================================
// SERVICE APPOINTMENT METHODS
// ============================================================================
static async getServiceAppointments(companies = [], filters = {}) {
return await this.request(FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD, { companies, filters });
}
static async updateServiceAppointmentScheduledDates(serviceAppointmentName, startDate, endDate, crewLeadName, startTime = null, endTime = null) {
return await this.request(FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD, {
serviceAppointmentName,
startDate,
endDate,
crewLeadName,
startTime,
endTime
})
}
// ============================================================================ // ============================================================================
// TASK METHODS // TASK METHODS
// ============================================================================ // ============================================================================
@ -522,16 +401,6 @@ class Api {
return await this.request(FRAPPE_SET_TASK_STATUS_METHOD, { taskName, newStatus }); return await this.request(FRAPPE_SET_TASK_STATUS_METHOD, { taskName, newStatus });
} }
static async getTasksDue(subjectFilter, currentCompany) {
const result = await this.request(FRAPPE_GET_TASKS_DUE_METHOD, {subjectFilter, currentCompany});
return result;
}
static async getTasksCompleted(subjectFilter) {
const result = await this.request(FRAPPE_GET_TASKS_COMPLETED_METHOD, {subjectFilter});
return result;
}
// ============================================================================ // ============================================================================
// INVOICE / PAYMENT METHODS // INVOICE / PAYMENT METHODS
// ============================================================================ // ============================================================================
@ -568,16 +437,6 @@ class Api {
return result; return result;
} }
static async getInvoicesLateCount(currentCompany) {
const result = await this.request(FRAPPE_GET_INVOICES_LATE_METHOD, { company: currentCompany });
return result;
}
static async createInvoiceForJob(jobName) {
const result = await this.request(FRAPPE_CREATE_INVOICE_FOR_JOB, { jobName: jobName });
return result;
}
// ============================================================================ // ============================================================================
// WARRANTY METHODS // WARRANTY METHODS
// ============================================================================ // ============================================================================
@ -699,26 +558,6 @@ class Api {
return data; return data;
} }
// ============================================================================
// EMPLOYEE METHODS
// ============================================================================
static async getEmployees(company, roles = []) {
return await this.request(FRAPPE_GET_EMPLOYEES_METHOD, { company, roles });
}
static async getEmployeesOrganized (company, roles = []) {
return await this.request(FRAPPE_GET_EMPLOYEES_ORGANIZED_METHOD, { company, roles });
}
// ============================================================================
// OTHER METHODS
// ============================================================================
static async getWeekHolidays(startDate) {
return await this.request(FRAPPE_GET_WEEK_HOLIDAYS_METHOD, { weekStartDate: startDate });
}
// ============================================================================ // ============================================================================
// GENERIC DOCTYPE METHODS // GENERIC DOCTYPE METHODS
// ============================================================================ // ============================================================================
@ -733,21 +572,20 @@ class Api {
*/ */
static async getDocsList( static async getDocsList(
doctype, doctype,
fields = ["*"], fields = [],
filters = {}, filters = {},
pluck = null, page = 0,
start = 0,
pageLength = 0,
) { ) {
const docs = await this.request( const docs = await frappe.db.get_list(doctype, {
FRAPPE_GET_DOC_LIST_METHOD, fields,
{ filters,
doctype, start: start,
fields, limit: pageLength,
filters, });
pluck,
}
);
console.log( console.log(
`DEBUG: API - Fetched ${doctype} list: `, `DEBUG: API - Fetched ${doctype} list (page ${page + 1}, start ${start}): `,
docs, docs,
); );
return docs; return docs;

View file

@ -1,24 +1,42 @@
<template> <template>
<div class="calendar-navigation"> <div class="calendar-navigation">
<Tabs value="0" v-if="companyStore.currentCompany == 'Sprinklers Northwest'"> <Tabs value="0">
<TabList> <TabList>
<Tab value="0">Bids</Tab> <Tab value="0">Bids</Tab>
<Tab value="1">Projects</Tab> <Tab value="1">Install</Tab>
<Tab value="2">Service</Tab> <Tab value="2">Service</Tab>
<Tab value="3">Lowe Fencing</Tab>
<Tab value="4">Daniel's Landscaping</Tab>
<Tab value="5">Nuco Yardcare</Tab>
<Tab value="6">Warranties</Tab> <Tab value="6">Warranties</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel header="Bids" value="0"> <TabPanel header="Bids" value="0">
<ScheduleBid /> <ScheduleBid />
</TabPanel> </TabPanel>
<TabPanel header="Projects" value="1"> <TabPanel header="Install" value="1">
<SNWProjectCalendar /> <InstallsCalendar />
</TabPanel> </TabPanel>
<TabPanel header="Service" value="2"> <TabPanel header="Service" value="2">
<div class="coming-soon"> <div class="coming-soon">
<p>Service feature coming soon!</p> <p>Service feature coming soon!</p>
</div> </div>
</TabPanel> </TabPanel>
<TabPanel header="Lowe Fencing" value="3">
<div class="coming-soon">
<p>Lowe Fencing calendar coming soon!</p>
</div>
</TabPanel>
<TabPanel header="Daniel's Landscaping" value="4">
<div class="coming-soon">
<p>Daniel's Calendar coming soon!</p>
</div>
</TabPanel>
<TabPanel header="Nuco Yardcare" value="5">
<div class="coming-soon">
<p>Nuco calendar coming soon!</p>
</div>
</TabPanel>
<TabPanel header="Warranties" value="6"> <TabPanel header="Warranties" value="6">
<div class="coming-soon"> <div class="coming-soon">
<p>Warranties Calendar coming soon!</p> <p>Warranties Calendar coming soon!</p>
@ -26,37 +44,6 @@
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
<Tabs v-else value="0">
<TabList>
<Tab value="0">Bids</Tab>
<Tab value="1">Projects</Tab>
<Tab value="2">Service</Tab>
<Tab value="3">Warranties</Tab>
</TabList>
<TabPanels>
<TabPanel header="Bids" value="0">
<ScheduleBid />
</TabPanel>
<TabPanel header="Projects" value="1">
<div class="coming-soon">
<p>Calendar Coming Soon!</p>
</div>
</TabPanel>
<TabPanel header="Service" value="2">
<div class="coming-soon">
<p>Calendar Coming Soon!</p>
</div>
</TabPanel>
<TabPanel header="Service" value="2">
<div class="coming-soon">
<p>Calendar Coming Soon!</p>
</div>
</TabPanel>
<TabPanel header="Warranties" value="3">
<p>Calendar Coming Soon!</p>
</TabPanel>
</TabPanels>
</Tabs>
</div> </div>
</template> </template>
@ -69,35 +56,15 @@ import TabPanel from 'primevue/tabpanel';
import TabPanels from 'primevue/tabpanels'; import TabPanels from 'primevue/tabpanels';
import ScheduleBid from '../calendar/bids/ScheduleBid.vue'; import ScheduleBid from '../calendar/bids/ScheduleBid.vue';
import JobsCalendar from '../calendar/jobs/JobsCalendar.vue'; import JobsCalendar from '../calendar/jobs/JobsCalendar.vue';
import SNWProjectCalendar from './jobs/SNWProjectCalendar.vue'; import InstallsCalendar from '../calendar/jobs/InstallsCalendar.vue';
import { useNotificationStore } from '../../stores/notifications-primevue'; import { useNotificationStore } from '../../stores/notifications-primevue';
import { useCompanyStore } from '../../stores/company';
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const companyStore = useCompanyStore();
</script> </script>
<style scoped> <style scoped>
.calendar-navigation { .calendar-navigation {
width: 100%; width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.calendar-navigation :deep(.p-tabs) {
height: 100%;
display: flex;
flex-direction: column;
}
.calendar-navigation :deep(.p-tabpanels) {
flex: 1;
overflow: hidden;
}
.calendar-navigation :deep(.p-tabpanel) {
height: 100%;
} }
.coming-soon { .coming-soon {

View file

@ -115,8 +115,8 @@
)" )"
:key="meeting.id" :key="meeting.id"
class="meeting-event" class="meeting-event"
:class="[getMeetingColorClass(meeting), { 'meeting-completed-locked': meeting.status === 'Completed' }]" :class="getMeetingColorClass(meeting)"
:draggable="meeting.status !== 'Completed'" draggable="true"
@dragstart="handleMeetingDragStart($event, meeting)" @dragstart="handleMeetingDragStart($event, meeting)"
@dragend="handleDragEnd($event)" @dragend="handleDragEnd($event)"
@click.stop="showMeetingDetails(meeting)" @click.stop="showMeetingDetails(meeting)"
@ -206,7 +206,6 @@
:meeting="selectedMeeting" :meeting="selectedMeeting"
@close="closeMeetingModal" @close="closeMeetingModal"
@meeting-updated="handleMeetingUpdated" @meeting-updated="handleMeetingUpdated"
@complete-meeting="openNoteForm"
/> />
<!-- New Meeting Modal --> <!-- New Meeting Modal -->
@ -217,17 +216,6 @@
@confirm="handleNewMeetingConfirm" @confirm="handleNewMeetingConfirm"
@cancel="handleNewMeetingCancel" @cancel="handleNewMeetingCancel"
/> />
<!-- Bid Meeting Note Form Modal -->
<BidMeetingNoteForm
v-if="selectedMeetingForNotes"
:visible="showNoteFormModal"
@update:visible="showNoteFormModal = $event"
:bid-meeting-name="selectedMeetingForNotes.name"
:project-template="selectedMeetingForNotes.projectTemplate"
@submit="handleNoteFormSubmit"
@cancel="handleNoteFormCancel"
/>
</div> </div>
</template> </template>
@ -236,17 +224,14 @@ import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import BidMeetingModal from "../../modals/BidMeetingModal.vue"; import BidMeetingModal from "../../modals/BidMeetingModal.vue";
import MeetingDetailsModal from "../../modals/MeetingDetailsModal.vue"; import MeetingDetailsModal from "../../modals/MeetingDetailsModal.vue";
import BidMeetingNoteForm from "../../modals/BidMeetingNoteForm.vue";
import { useLoadingStore } from "../../../stores/loading"; import { useLoadingStore } from "../../../stores/loading";
import { useNotificationStore } from "../../../stores/notifications-primevue"; import { useNotificationStore } from "../../../stores/notifications-primevue";
import { useCompanyStore } from "../../../stores/company";
import Api from "../../../api"; import Api from "../../../api";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const companyStore = useCompanyStore();
// Query parameters // Query parameters
const isNewMode = computed(() => route.query.new === "true"); const isNewMode = computed(() => route.query.new === "true");
@ -264,8 +249,6 @@ const unscheduledMeetings = ref([]);
const selectedMeeting = ref(null); const selectedMeeting = ref(null);
const showMeetingModal = ref(false); const showMeetingModal = ref(false);
const showNewMeetingModal = ref(false); const showNewMeetingModal = ref(false);
const showNoteFormModal = ref(false);
const selectedMeetingForNotes = ref(null);
// Drag and drop state // Drag and drop state
const isDragOver = ref(false); const isDragOver = ref(false);
@ -491,63 +474,6 @@ const handleMeetingUpdated = async () => {
await loadUnscheduledMeetings(); await loadUnscheduledMeetings();
}; };
const openNoteForm = (meeting) => {
// Verify meeting has required data
if (!meeting || !meeting.name) {
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Meeting information is incomplete",
duration: 5000,
});
return;
}
if (!meeting.projectTemplate) {
notificationStore.addNotification({
type: "error",
title: "Missing Project Template",
message: "This meeting does not have a project template assigned. Cannot open note form.",
duration: 5000,
});
return;
}
selectedMeetingForNotes.value = meeting;
showNoteFormModal.value = true;
};
const handleNoteFormSubmit = async () => {
// After successful submission, mark the meeting as completed
try {
loadingStore.setLoading(true);
await Api.updateBidMeeting(selectedMeetingForNotes.value.name, {
status: "Completed",
});
notificationStore.addNotification({
type: "success",
title: "Success",
message: "Meeting marked as completed",
duration: 5000,
});
// Reload meetings
await handleMeetingUpdated();
} catch (error) {
console.error("Error updating meeting status:", error);
} finally {
loadingStore.setLoading(false);
showNoteFormModal.value = false;
selectedMeetingForNotes.value = null;
}
};
const handleNoteFormCancel = () => {
showNoteFormModal.value = false;
selectedMeetingForNotes.value = null;
};
const openNewMeetingModal = () => { const openNewMeetingModal = () => {
showNewMeetingModal.value = true; showNewMeetingModal.value = true;
}; };
@ -563,16 +489,7 @@ const handleNewMeetingConfirm = async (meetingData) => {
showNewMeetingModal.value = false; showNewMeetingModal.value = false;
// Optimistically add the new meeting to the unscheduled list // Reload unscheduled meetings to show the new one
unscheduledMeetings.value.unshift({
name: result.name,
address: meetingData.address,
projectTemplate: meetingData.projectTemplate,
contact: meetingData.contact,
status: "Unscheduled",
});
// Reload unscheduled meetings to ensure consistency
await loadUnscheduledMeetings(); await loadUnscheduledMeetings();
notificationStore.addNotification({ notificationStore.addNotification({
@ -617,7 +534,6 @@ const handleDragStart = (event, meeting = null) => {
notes: meeting.notes || "", notes: meeting.notes || "",
assigned_employee: meeting.assigned_employee || "", assigned_employee: meeting.assigned_employee || "",
status: meeting.status, status: meeting.status,
projectTemplate: meeting.projectTemplate,
}; };
} else if (!draggedMeeting.value) { } else if (!draggedMeeting.value) {
// If no meeting data is set, use query address // If no meeting data is set, use query address
@ -633,12 +549,6 @@ const handleDragStart = (event, meeting = null) => {
}; };
const handleMeetingDragStart = (event, meeting) => { const handleMeetingDragStart = (event, meeting) => {
// Prevent dragging completed meetings
if (meeting.status === 'Completed') {
event.preventDefault();
return;
}
// Handle dragging a scheduled meeting // Handle dragging a scheduled meeting
draggedMeeting.value = { draggedMeeting.value = {
id: meeting.name, id: meeting.name,
@ -647,7 +557,6 @@ const handleMeetingDragStart = (event, meeting) => {
assigned_employee: meeting.assigned_employee || "", assigned_employee: meeting.assigned_employee || "",
status: meeting.status, status: meeting.status,
isRescheduling: true, // Flag to indicate this is a reschedule isRescheduling: true, // Flag to indicate this is a reschedule
projectTemplate: meeting.projectTemplate,
}; };
// Store the original meeting data in case drag is cancelled // Store the original meeting data in case drag is cancelled
@ -758,7 +667,6 @@ const handleDrop = async (event, date, time) => {
notes: droppedMeeting.notes || "", notes: droppedMeeting.notes || "",
assigned_employee: droppedMeeting.assigned_employee || "", assigned_employee: droppedMeeting.assigned_employee || "",
status: "Scheduled", status: "Scheduled",
projectTemplate: droppedMeeting.projectTemplate,
}; };
// If this is an existing meeting, update it in the backend // If this is an existing meeting, update it in the backend
@ -882,7 +790,6 @@ const handleDropToUnscheduled = async (event) => {
notes: droppedMeeting.notes || "", notes: droppedMeeting.notes || "",
status: "Unscheduled", status: "Unscheduled",
assigned_employee: droppedMeeting.assigned_employee || "", assigned_employee: droppedMeeting.assigned_employee || "",
projectTemplate: droppedMeeting.projectTemplate,
}); });
} }
@ -909,7 +816,7 @@ const handleDropToUnscheduled = async (event) => {
const loadUnscheduledMeetings = async () => { const loadUnscheduledMeetings = async () => {
loadingStore.setLoading(true); loadingStore.setLoading(true);
try { try {
const result = await Api.getUnscheduledBidMeetings(companyStore.currentCompany); const result = await Api.getUnscheduledBidMeetings();
// Ensure we always have an array // Ensure we always have an array
unscheduledMeetings.value = Array.isArray(result) ? result : []; unscheduledMeetings.value = Array.isArray(result) ? result : [];
console.log("Loaded unscheduled meetings:", unscheduledMeetings.value); console.log("Loaded unscheduled meetings:", unscheduledMeetings.value);
@ -958,7 +865,7 @@ const loadWeekMeetings = async () => {
// Try to get meetings from API // Try to get meetings from API
try { try {
const apiResult = await Api.getWeekBidMeetings(weekStartStr, weekEndStr, companyStore.currentCompany); const apiResult = await Api.getWeekBidMeetings(weekStartStr, weekEndStr);
if (Array.isArray(apiResult)) { if (Array.isArray(apiResult)) {
// Transform the API data to match what the calendar expects // Transform the API data to match what the calendar expects
meetings.value = apiResult meetings.value = apiResult
@ -1178,15 +1085,6 @@ watch(currentWeekStart, () => {
loadWeekMeetings(); loadWeekMeetings();
}); });
// Watch for company changes
watch(
() => companyStore.currentCompany,
async () => {
await loadWeekMeetings();
await loadUnscheduledMeetings();
}
);
watch( watch(
() => route.query.new, () => route.query.new,
(newVal) => { (newVal) => {
@ -1200,10 +1098,9 @@ watch(
<style scoped> <style scoped>
.schedule-bid-container { .schedule-bid-container {
padding: 20px; padding: 20px;
height: 100%; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.header { .header {
@ -1213,7 +1110,6 @@ watch(
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 15px; padding-bottom: 15px;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
flex-shrink: 0;
} }
.header-controls { .header-controls {
@ -1254,9 +1150,9 @@ watch(
padding: 0 16px; padding: 0 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow-y: auto;
max-height: calc(100vh - 150px);
transition: width 0.3s ease; transition: width 0.3s ease;
flex-shrink: 0;
} }
.sidebar.collapsed { .sidebar.collapsed {
@ -1286,13 +1182,11 @@ watch(
.sidebar-header h4 { .sidebar-header h4 {
font-size: 1.1em; font-size: 1.1em;
margin: 0; margin: 0;
flex-shrink: 0;
} }
.unscheduled-meetings-list { .unscheduled-meetings-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
padding: 8px 0; padding: 8px 0;
} }
@ -1567,24 +1461,11 @@ watch(
background: linear-gradient(135deg, #f44336, #d32f2f); background: linear-gradient(135deg, #f44336, #d32f2f);
} }
.meeting-event.meeting-completed-locked {
cursor: default !important;
opacity: 0.9;
}
.meeting-event.meeting-completed-locked:active {
cursor: default !important;
}
.meeting-event:hover { .meeting-event:hover {
transform: scale(1.02); transform: scale(1.02);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
} }
.meeting-event.meeting-completed-locked:hover {
transform: none;
}
.event-time { .event-time {
font-weight: 600; font-weight: 600;
font-size: 0.8em; font-size: 0.8em;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -59,15 +59,6 @@
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>
<input
type="checkbox"
:id="`isService-${index}`"
v-model="address.isServiceAddress"
:disabled="isSubmitting"
@change="handleServiceChange(index)"
style="margin-top: 0; margin-left: 1.5rem;"
/>
<label :for="`isService-${index}`"><i class="pi pi-truck" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Service Address</label>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-field"> <div class="form-field">
@ -129,7 +120,7 @@
<Select <Select
:id="`primaryContact-${index}`" :id="`primaryContact-${index}`"
v-model="address.primaryContact" v-model="address.primaryContact"
:options="address.contacts.map(c => contactOptions.find(opt => opt.value === c))" :options="contactOptions"
optionLabel="label" optionLabel="label"
optionValue="value" optionValue="value"
:disabled="isSubmitting || contactOptions.length === 0" :disabled="isSubmitting || contactOptions.length === 0"
@ -183,7 +174,6 @@ const localFormData = computed({
addressLine1: "", addressLine1: "",
addressLine2: "", addressLine2: "",
isBillingAddress: true, isBillingAddress: true,
isServiceAddress: true,
pincode: "", pincode: "",
city: "", city: "",
state: "", state: "",
@ -215,7 +205,6 @@ onMounted(() => {
addressLine1: "", addressLine1: "",
addressLine2: "", addressLine2: "",
isBillingAddress: true, isBillingAddress: true,
isServiceAddress: true,
pincode: "", pincode: "",
city: "", city: "",
state: "", state: "",
@ -232,7 +221,6 @@ const addAddress = () => {
addressLine1: "", addressLine1: "",
addressLine2: "", addressLine2: "",
isBillingAddress: false, isBillingAddress: false,
isServiceAddress: true,
pincode: "", pincode: "",
city: "", city: "",
state: "", state: "",
@ -271,7 +259,6 @@ const handleBillingChange = (selectedIndex) => {
localFormData.value.addresses.forEach((addr, idx) => { localFormData.value.addresses.forEach((addr, idx) => {
if (idx !== selectedIndex) { if (idx !== selectedIndex) {
addr.isBillingAddress = false; addr.isBillingAddress = false;
addr.isServiceAddress = true; // Ensure service address is true for others
} }
}); });
@ -292,21 +279,6 @@ const handleBillingChange = (selectedIndex) => {
localFormData.value.addresses[selectedIndex].primaryContact = 0; localFormData.value.addresses[selectedIndex].primaryContact = 0;
} }
} }
} else {
localFormData.value.addresses[selectedIndex].isBillingAddress = true;
notificationStore.addInfo("At least one of Billing Address must be selected.");
}
};
const handleServiceChange = (selectedIndex) => {
// If the address does not have billing address selected, ensure that service address is always true
if (!localFormData.value.addresses[selectedIndex].isBillingAddress) {
localFormData.value.addresses[selectedIndex].isServiceAddress = true;
notificationStore.addInfo("Service Address must be selected if not a Billing Address.");
}
if (!localFormData.value.addresses.some(addr => addr.isServiceAddress)) {
localFormData.value.addresses[selectedIndex].isServiceAddress = true;
notificationStore.addInfo("At least one Service Address must be selected.");
} }
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,385 +0,0 @@
<template>
<div v-if="addresses.length > 1" class="address-selector">
<div class="selector-header">
<h4>Select Address</h4>
<Button
@click="showAddAddressModal = true"
icon="pi pi-plus"
label="Add An Address"
size="small"
severity="secondary"
/>
</div>
<Select
v-model="selectedAddressIndex"
:options="addressOptions"
optionLabel="label"
optionValue="value"
placeholder="Select an address"
class="w-full address-dropdown"
@change="handleAddressChange"
>
<template #value="slotProps">
<div v-if="slotProps.value !== null && slotProps.value !== undefined" class="dropdown-value">
<span class="address-title">{{ addresses[slotProps.value]?.fullAddress || 'Unnamed Address' }}</span>
<div class="address-badges">
<Badge
v-if="addresses[slotProps.value]?.isPrimaryAddress && !addresses[slotProps.value]?.isServiceAddress"
value="Billing Only"
severity="info"
/>
<Badge
v-if="addresses[slotProps.value]?.isPrimaryAddress && addresses[slotProps.value]?.isServiceAddress"
value="Billing & Service"
severity="success"
/>
<Badge
v-if="!addresses[slotProps.value]?.isPrimaryAddress && addresses[slotProps.value]?.isServiceAddress"
value="Service"
severity="secondary"
/>
<Badge
v-if="addresses[slotProps.value]?.isServiceAddress"
:value="`${addresses[slotProps.value]?.projects?.length || 0} Projects`"
severity="contrast"
/>
</div>
</div>
</template>
<template #option="slotProps">
<div class="dropdown-option">
<span class="option-title">{{ slotProps.option.addressTitle || 'Unnamed Address' }}</span>
<div class="option-badges">
<Badge
v-if="slotProps.option.isPrimaryAddress && !slotProps.option.isServiceAddress"
value="Billing Only"
severity="info"
size="small"
/>
<Badge
v-if="slotProps.option.isPrimaryAddress && slotProps.option.isServiceAddress"
value="Billing & Service"
severity="success"
size="small"
/>
<Badge
v-if="!slotProps.option.isPrimaryAddress && slotProps.option.isServiceAddress"
value="Service"
severity="secondary"
size="small"
/>
<Badge
v-if="slotProps.option.isServiceAddress"
:value="`${slotProps.option.projectCount} Projects`"
severity="contrast"
size="small"
/>
</div>
</div>
</template>
</Select>
<!-- Selected Address Info -->
<div v-if="selectedAddress" class="selected-address-info">
<div class="address-status">
<Badge
v-if="selectedAddress.isPrimaryAddress && !selectedAddress.isServiceAddress"
value="Billing Only Address"
severity="info"
/>
<Badge
v-if="selectedAddress.isPrimaryAddress && selectedAddress.isServiceAddress"
value="Billing & Service Address"
severity="success"
/>
<Badge
v-if="!selectedAddress.isPrimaryAddress && selectedAddress.isServiceAddress"
value="Service Address"
severity="secondary"
/>
</div>
<!-- Service Address Details -->
<div v-if="selectedAddress.isServiceAddress" class="service-details">
<div class="detail-item">
<i class="pi pi-briefcase"></i>
<span>{{ selectedAddress.projects?.length || 0 }} Projects</span>
</div>
<div class="detail-item">
<i class="pi pi-calendar"></i>
<span>{{ selectedAddress.onsiteMeetings?.length || 0 }} Bid Meetings</span>
</div>
<div v-if="primaryContact" class="detail-item primary-contact">
<i class="pi pi-user"></i>
<div class="contact-info">
<span class="contact-name">{{ primaryContactName }}</span>
<span class="contact-detail">{{ primaryContactEmail }}</span>
<span class="contact-detail">{{ primaryContactPhone }}</span>
</div>
</div>
</div>
</div>
<!-- Add Address Modal -->
<Dialog
:visible="showAddAddressModal"
@update:visible="showAddAddressModal = $event"
header="Add Address"
:modal="true"
:closable="true"
class="add-address-dialog"
>
<div class="coming-soon">
<i class="pi pi-hourglass"></i>
<p>Feature coming soon</p>
</div>
<template #footer>
<Button
label="Close"
severity="secondary"
@click="showAddAddressModal = false"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { computed, ref, watch, nextTick } from "vue";
import { useRoute } from "vue-router";
import Badge from "primevue/badge";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import Select from "primevue/select";
import DataUtils from "../../utils";
const route = useRoute();
const addressParam = route.query.address || null;
const props = defineProps({
addresses: {
type: Array,
required: true,
},
selectedAddressIdx: {
type: Number,
default: 0,
},
contacts: {
type: Array,
default: () => [],
},
});
const findAddressIndexByParam = (addressStr) => {
const trimmedParam = addressStr.trim();
for (let i = 0; i < props.addresses.length; i++) {
const addr = props.addresses[i];
const fullAddr = (addr.fullAddress || DataUtils.calculateFullAddress(addr)).trim();
if (fullAddr === trimmedParam) {
return i;
}
}
return null;
};
const emit = defineEmits(["update:selectedAddressIdx"]);
const showAddAddressModal = ref(false);
const selectedAddressIndex = ref(addressParam ? findAddressIndexByParam(addressParam) : props.selectedAddressIdx);
// Emit update if the initial index is different from props
if (addressParam && selectedAddressIndex.value !== null && selectedAddressIndex.value !== props.selectedAddressIdx) {
nextTick(() => emit("update:selectedAddressIdx", selectedAddressIndex.value));
}
// Watch for external changes to selectedAddressIdx
watch(() => props.selectedAddressIdx, (newVal) => {
selectedAddressIndex.value = newVal;
});
// Selected address object
const selectedAddress = computed(() => {
if (selectedAddressIndex.value >= 0 && selectedAddressIndex.value < props.addresses.length) {
return props.addresses[selectedAddressIndex.value];
}
return null;
});
// Address options for dropdown
const addressOptions = computed(() => {
return props.addresses.map((addr, idx) => ({
label: addr.fullAddress || DataUtils.calculateFullAddress(addr),
value: idx,
addressTitle: addr.fullAddress || 'Unnamed Address',
isPrimaryAddress: addr.isPrimaryAddress,
isServiceAddress: addr.isServiceAddress,
projectCount: addr.projects?.length || 0,
}));
});
// Primary contact for selected address
const primaryContact = computed(() => {
if (!selectedAddress.value?.primaryContact || !props.contacts) return null;
return props.contacts.find(c => c.name === selectedAddress.value.primaryContact);
});
const primaryContactName = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.fullName || primaryContact.value.name || "N/A";
});
const primaryContactEmail = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.emailId || primaryContact.value.customEmail || "N/A";
});
const primaryContactPhone = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.phone || primaryContact.value.mobileNo || "N/A";
});
// Handle address change
const handleAddressChange = () => {
emit("update:selectedAddressIdx", selectedAddressIndex.value);
};
</script>
<style scoped>
.address-selector {
background: var(--surface-card);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 1rem;
}
.selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.selector-header h4 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.address-dropdown {
width: 100%;
margin-bottom: 1rem;
}
.dropdown-value,
.dropdown-option {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.25rem 0;
}
.address-title,
.option-title {
font-weight: 600;
color: var(--text-color);
}
.address-badges,
.option-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.selected-address-info {
display: flex;
flex-direction: row;
gap: 1rem;
padding: 0.75rem;
background: var(--surface-ground);
border-radius: 8px;
align-items: center;
}
.address-status {
display: flex;
gap: 0.5rem;
}
.service-details {
display: flex;
flex-direction: row;
gap: 1rem;
flex-wrap: wrap;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--surface-card);
border-radius: 6px;
}
.detail-item i {
font-size: 1.25rem;
color: var(--primary-color);
}
.detail-item span {
font-size: 0.95rem;
font-weight: 500;
color: var(--text-color);
}
.detail-item.primary-contact {
flex: 1;
}
.contact-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-name {
font-weight: 600;
color: var(--text-color);
font-size: 1rem;
}
.contact-detail {
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.coming-soon {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.coming-soon i {
font-size: 3rem;
color: var(--text-color-secondary);
}
.coming-soon p {
font-size: 1.1rem;
color: var(--text-color-secondary);
margin: 0;
}
.w-full {
width: 100%;
}
</style>

View file

@ -1,335 +0,0 @@
<template>
<div class="general-client-info">
<div class="info-grid">
<!-- Lead Badge -->
<div v-if="isLead" class="lead-badge-container">
<Badge value="LEAD" severity="warn" size="large" />
<div class="action-buttons">
<v-btn
size="small"
variant="outlined"
color="primary"
@click="addAddress"
>
<v-icon left size="small">mdi-map-marker-plus</v-icon>
Add Address
</v-btn>
<v-btn
size="small"
variant="outlined"
color="primary"
@click="addContact"
>
<v-icon left size="small">mdi-account-plus</v-icon>
Add Contact
</v-btn>
</div>
</div>
<!-- Client Name (only show for Company type) -->
<div v-if="clientData.customerType === 'Company'" class="info-section">
<label>Company Name</label>
<span class="info-value large">{{ displayClientName }}</span>
</div>
<!-- Client Type -->
<div class="info-section">
<label>Client Type</label>
<span class="info-value">{{ clientData.customerType || "N/A" }}</span>
</div>
<!-- Associated Companies -->
<div v-if="associatedCompanies.length > 0" class="info-section">
<label>Associated Companies</label>
<div class="companies-list">
<Tag
v-for="company in associatedCompanies"
:key="company"
:value="company"
severity="info"
/>
</div>
</div>
<!-- Billing Address -->
<div v-if="billingAddress" class="info-section">
<label>Billing Address</label>
<span class="info-value">{{ billingAddress }}</span>
</div>
<!-- Primary Contact Info -->
<div v-if="primaryContact" class="info-section primary-contact">
<label>{{ clientData.customerType === 'Individual' ? 'Contact Information' : 'Primary Contact' }}</label>
<div class="contact-details">
<div class="contact-item">
<i class="pi pi-user"></i>
<span>{{ primaryContact.fullName || primaryContact.name || "N/A" }}</span>
</div>
<div class="contact-item">
<i class="pi pi-envelope"></i>
<span>{{ primaryContact.emailId || primaryContact.customEmail || "N/A" }}</span>
</div>
<div class="contact-item">
<i class="pi pi-phone"></i>
<span>{{ primaryContact.phone || primaryContact.mobileNo || "N/A" }}</span>
</div>
</div>
</div>
<!-- Statistics -->
<div class="info-section stats">
<label>Overview</label>
<div class="stats-grid">
<div class="stat-item">
<i class="pi pi-map-marker"></i>
<span class="stat-value">{{ addressCount }}</span>
<span class="stat-label">Addresses</span>
</div>
<div class="stat-item">
<i class="pi pi-users"></i>
<span class="stat-value">{{ contactCount }}</span>
<span class="stat-label">Contacts</span>
</div>
<div class="stat-item">
<i class="pi pi-briefcase"></i>
<span class="stat-value">{{ projectCount }}</span>
<span class="stat-label">Projects</span>
</div>
</div>
</div>
<!-- Creation Date -->
<div class="info-section">
<label>Created</label>
<span class="info-value">{{ formattedCreationDate }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import Badge from "primevue/badge";
import Tag from "primevue/tag";
const props = defineProps({
clientData: {
type: Object,
required: true,
},
});
// Check if client is a Lead
const isLead = computed(() => props.clientData.doctype === "Lead");
// Strip "-#-" from client name
const displayClientName = computed(() => {
if (!props.clientData.customerName) return "N/A";
return props.clientData.customerName.split("-#-")[0].trim();
});
// Get associated companies
const associatedCompanies = computed(() => {
if (!props.clientData.companies || !Array.isArray(props.clientData.companies)) {
return [];
}
return props.clientData.companies.map(c => c.company).filter(Boolean);
});
// Strip "-#-" from billing address
const billingAddress = computed(() => {
if (!props.clientData.customBillingAddress) return null;
return props.clientData.customBillingAddress.split("-#-")[0].trim();
});
// Get primary contact
const primaryContact = computed(() => {
if (!props.clientData.contacts || !props.clientData.primaryContact) return null;
return props.clientData.contacts.find(
c => c.name === props.clientData.primaryContact
);
});
// Counts
const addressCount = computed(() => props.clientData.addresses?.length || 0);
const contactCount = computed(() => props.clientData.contacts?.length || 0);
const projectCount = computed(() => props.clientData.jobs?.length || 0);
// Format creation date
const formattedCreationDate = computed(() => {
if (!props.clientData.creation) return "N/A";
const date = new Date(props.clientData.creation);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
});
// Placeholder methods for adding address and contact
const addAddress = () => {
console.log("Add Address modal would open here");
// TODO: Open add address modal
};
const addContact = () => {
console.log("Add Contact modal would open here");
// TODO: Open add contact modal
};
</script>
<style scoped>
.general-client-info {
background: var(--surface-card);
border-radius: 8px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
margin-bottom: 0.75rem;
}
.lead-badge-container {
grid-column: 1 / -1;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
align-items: start;
}
.info-section {
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
gap: 0.75rem;
min-height: 2rem;
padding: 0.25rem 0;
}
.info-section label {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
justify-self: start;
align-self: center;
margin: 0;
}
.info-value {
font-size: 0.85rem;
color: var(--text-color);
font-weight: 500;
justify-self: start;
align-self: center;
margin: 0;
}
.info-value.large {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-color);
}
.companies-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
justify-content: flex-start;
align-items: center;
}
.primary-contact {
grid-column: span 1;
justify-self: stretch;
}
.contact-details {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: var(--surface-ground);
border-radius: 4px;
width: 100%;
justify-content: flex-start;
align-items: center;
}
.contact-item {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
flex: 1;
}
.contact-item i {
color: var(--primary-color);
font-size: 0.85rem;
}
.contact-item span {
font-size: 0.8rem;
color: var(--text-color);
}
.stats {
grid-column: span 1;
justify-self: stretch;
}
.stats-grid {
display: flex;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: var(--surface-ground);
border-radius: 4px;
width: 100%;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
text-align: center;
min-width: 60px;
}
.stat-item i {
font-size: 0.9rem;
color: var(--primary-color);
}
.stat-value {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
line-height: 1.2;
}
.stat-label {
font-size: 0.7rem;
color: var(--text-color-secondary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
line-height: 1.2;
}
</style>

View file

@ -7,7 +7,7 @@
:class="getStatusClass(onsiteMeetingStatus)" :class="getStatusClass(onsiteMeetingStatus)"
@click="handleBidMeetingClick" @click="handleBidMeetingClick"
> >
<span class="status-label">Bid Meeting</span> <span class="status-label">Meeting</span>
<span class="status-badge">{{ onsiteMeetingStatus }}</span> <span class="status-badge">{{ onsiteMeetingStatus }}</span>
</div> </div>
@ -16,7 +16,7 @@
:class="getStatusClass(estimateSentStatus)" :class="getStatusClass(estimateSentStatus)"
@click="handleEstimateClick" @click="handleEstimateClick"
> >
<span class="status-label">Estimate Sent</span> <span class="status-label">Estimate</span>
<span class="status-badge">{{ estimateSentStatus }}</span> <span class="status-badge">{{ estimateSentStatus }}</span>
</div> </div>
@ -109,10 +109,8 @@ const handleBidMeetingClick = () => {
}; };
const handleEstimateClick = () => { const handleEstimateClick = () => {
if ((props.estimateSentStatus === "Not Started") && props.onsiteMeetingStatus != "Completed") { if (props.estimateSentStatus === "Not Started") {
notificationStore.addWarning("Bid Meeting must be scheduled and completed before an Estimate can be made for a SNW Install") router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&template=SNW%20Install`);
} else if (props.estimateSentStatus === "Not Started") {
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&project-template=SNW%20Install&from-meeting=${encodeURIComponent(props.bidMeeting)}`);
} else { } else {
router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`); router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`);
} }

View file

@ -1,132 +0,0 @@
<template>
<div class="overview-content">
<!-- New Client Forms -->
<div v-if="isNew" class="new-client-forms">
<ClientInformationForm
:form-data="client"
:is-submitting="false"
:is-edit-mode="false"
@update:formData="handleFormDataUpdate"
@newClientToggle="handleNewClientToggle"
@customerSelected="handleCustomerSelected"
/>
<ContactInformationForm
:form-data="client"
:is-submitting="false"
:is-edit-mode="false"
@update:formData="handleFormDataUpdate"
/>
<AddressInformationForm
:form-data="client"
:is-submitting="false"
:is-edit-mode="false"
@update:formData="handleFormDataUpdate"
/>
</div>
<!-- Quick Actions (only in non-edit mode) -->
<QuickActions
v-if="!editMode && !isNew"
:full-address="fullAddress"
@edit-mode-enabled="handleEditModeEnabled"
/>
<!-- Special Modules Section -->
<SpecialModules
v-if="!isNew && !editMode"
:selected-address="selectedAddress"
:full-address="fullAddress"
/>
<!-- Property Details -->
<PropertyDetails
v-if="!isNew && selectedAddress"
:address-data="selectedAddress"
:all-contacts="allContacts"
:edit-mode="editMode"
@update:address-contacts="handleAddressContactsUpdate"
@update:primary-contact="handlePrimaryContactUpdate"
/>
</div>
</template>
<script setup>
import QuickActions from "./QuickActions.vue";
import SpecialModules from "./SpecialModules.vue";
import PropertyDetails from "./PropertyDetails.vue";
import ClientInformationForm from "../clientSubPages/ClientInformationForm.vue";
import ContactInformationForm from "../clientSubPages/ContactInformationForm.vue";
import AddressInformationForm from "../clientSubPages/AddressInformationForm.vue";
const props = defineProps({
selectedAddress: {
type: Object,
default: null,
},
allContacts: {
type: Array,
default: () => [],
},
editMode: {
type: Boolean,
default: false,
},
isNew: {
type: Boolean,
default: false,
},
fullAddress: {
type: String,
default: "",
},
client: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits([
"edit-mode-enabled",
"update:address-contacts",
"update:primary-contact",
"update:client",
]);
const handleEditModeEnabled = () => {
emit("edit-mode-enabled");
};
const handleAddressContactsUpdate = (contactNames) => {
emit("update:address-contacts", contactNames);
};
const handlePrimaryContactUpdate = (contactName) => {
emit("update:primary-contact", contactName);
};
const handleFormDataUpdate = (newFormData) => {
emit("update:client", newFormData);
};
const handleNewClientToggle = (isNewClient) => {
// Handle if needed
};
const handleCustomerSelected = (customer) => {
// Handle if needed
};
</script>
<style scoped>
.overview-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.new-client-forms {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
</style>

View file

@ -1,622 +0,0 @@
<template>
<div class="property-details">
<h3>Property Details</h3>
<div class="details-grid">
<!-- Address Information -->
<div class="detail-section">
<div class="section-header">
<i class="pi pi-map-marker"></i>
<h4>Address</h4>
</div>
<div class="address-info">
<p class="full-address">{{ fullAddress }}</p>
<div class="address-badges">
<Badge
v-if="addressData.isPrimaryAddress && !addressData.isServiceAddress"
value="Billing Only"
severity="info"
/>
<Badge
v-if="addressData.isPrimaryAddress && addressData.isServiceAddress"
value="Billing & Service"
severity="success"
/>
<Badge
v-if="!addressData.isPrimaryAddress && addressData.isServiceAddress"
value="Service Address"
severity="secondary"
/>
</div>
</div>
</div>
<!-- Associated Companies -->
<div class="detail-section">
<div class="section-header">
<i class="pi pi-building"></i>
<h4>Companies</h4>
</div>
<div v-if="associatedCompanies.length > 0" class="companies-list">
<div
v-for="company in associatedCompanies"
:key="company"
class="company-item"
>
<i class="pi pi-building"></i>
<span>{{ company }}</span>
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-building"></i>
<p>No companies associated</p>
</div>
</div>
<!-- Primary Contact -->
<div class="detail-section">
<div class="section-header">
<i class="pi pi-user"></i>
<h4>Primary Contact</h4>
</div>
<div v-if="primaryContact" class="contact-card primary">
<div class="contact-badge">
<Badge value="Primary" severity="success" />
</div>
<div class="contact-info">
<h5>{{ primaryContactName }}</h5>
<div class="contact-details">
<div class="contact-detail">
<i class="pi pi-envelope"></i>
<span>{{ primaryContactEmail }}</span>
</div>
<div class="contact-detail">
<i class="pi pi-phone"></i>
<span>{{ primaryContactPhone }}</span>
</div>
<div v-if="primaryContact.role" class="contact-detail">
<i class="pi pi-briefcase"></i>
<span>{{ primaryContact.role }}</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-user-minus"></i>
<p>No primary contact</p>
</div>
</div>
<!-- Other Contacts -->
<div class="detail-section">
<div class="section-header">
<i class="pi pi-users"></i>
<h4>Other Contacts</h4>
</div>
<div v-if="otherContacts.length > 0" class="contacts-grid">
<div
v-for="contact in otherContacts"
:key="contact.name"
class="contact-card small"
>
<div class="contact-info-compact">
<span class="contact-name">{{ getContactName(contact) }}</span>
<span class="contact-email">{{ getContactEmail(contact) }}</span>
<span class="contact-phone">{{ getContactPhone(contact) }}</span>
<span v-if="contact.role" class="contact-role">{{ contact.role }}</span>
</div>
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-user-minus"></i>
<p>No other contacts</p>
</div>
</div>
<!-- Edit Mode -->
<div v-if="editMode" class="detail-section full-width">
<div class="section-header">
<i class="pi pi-pencil"></i>
<h4>Edit Contacts</h4>
</div>
<div class="contacts-edit">
<div class="edit-instructions">
<i class="pi pi-info-circle"></i>
<span>Select contacts to associate with this address. One must be marked as primary.</span>
</div>
<div class="contacts-list">
<div
v-for="contact in allContacts"
:key="contact.name"
class="contact-checkbox-item"
:class="{ 'is-selected': isContactSelected(contact) }"
>
<Checkbox
:model-value="isContactSelected(contact)"
:binary="true"
@update:model-value="toggleContact(contact)"
:input-id="`contact-${contact.name}`"
/>
<label :for="`contact-${contact.name}`" class="contact-label">
<div class="contact-info-inline">
<span class="contact-name">{{ getContactName(contact) }}</span>
<span class="contact-email">{{ getContactEmail(contact) }}</span>
<span class="contact-phone">{{ getContactPhone(contact) }}</span>
</div>
</label>
<div v-if="isContactSelected(contact)" class="primary-checkbox">
<Checkbox
:model-value="isPrimaryContact(contact)"
:binary="true"
@update:model-value="setPrimaryContact(contact)"
:input-id="`primary-${contact.name}`"
/>
<label :for="`primary-${contact.name}`">Primary</label>
</div>
</div>
</div>
</div>
</div>
<!-- Map Section -->
<div class="detail-section full-width">
<div class="section-header">
<i class="pi pi-map"></i>
<h4>Location</h4>
</div>
<LeafletMap
:latitude="latitude"
:longitude="longitude"
:address-title="addressData.addressTitle || 'Property Location'"
map-height="350px"
:zoom-level="16"
/>
<div v-if="latitude && longitude" class="coordinates-info">
<small>
<strong>Coordinates:</strong>
{{ parseFloat(latitude).toFixed(6) }}, {{ parseFloat(longitude).toFixed(6) }}
</small>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import Badge from "primevue/badge";
import Checkbox from "primevue/checkbox";
import LeafletMap from "../common/LeafletMap.vue";
import DataUtils from "../../utils";
const props = defineProps({
addressData: {
type: Object,
required: true,
},
allContacts: {
type: Array,
default: () => [],
},
editMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:addressContacts", "update:primaryContact"]);
// Local state for editing
const selectedContactNames = ref([]);
const selectedPrimaryContactName = ref(null);
// Initialize from props when edit mode is enabled
watch(() => props.editMode, (isEditMode) => {
if (isEditMode) {
// Initialize selected contacts from address
selectedContactNames.value = (props.addressData.contacts || [])
.map(c => c.contact)
.filter(Boolean);
selectedPrimaryContactName.value = props.addressData.primaryContact || null;
}
});
// Full address
const fullAddress = computed(() => {
return DataUtils.calculateFullAddress(props.addressData);
});
// Get contacts associated with this address
const addressContacts = computed(() => {
if (!props.addressData.contacts || !props.allContacts) return [];
const addressContactNames = props.addressData.contacts.map(c => c.contact);
return props.allContacts.filter(c => addressContactNames.includes(c.name));
});
// Primary contact
const primaryContact = computed(() => {
if (!props.addressData.primaryContact || !props.allContacts) return null;
return props.allContacts.find(c => c.name === props.addressData.primaryContact);
});
const primaryContactName = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.fullName || primaryContact.value.name || "N/A";
});
const primaryContactEmail = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.emailId || primaryContact.value.customEmail || "N/A";
});
const primaryContactPhone = computed(() => {
if (!primaryContact.value) return "N/A";
return primaryContact.value.phone || primaryContact.value.mobileNo || "N/A";
});
// Other contacts (non-primary)
const otherContacts = computed(() => {
return addressContacts.value.filter(c => c.name !== props.addressData.primaryContact);
});
// Map coordinates
const latitude = computed(() => {
return props.addressData.customLatitude || props.addressData.latitude || null;
});
const longitude = computed(() => {
return props.addressData.customLongitude || props.addressData.longitude || null;
});
// Associated companies
const associatedCompanies = computed(() => {
if (!props.addressData.companies) return [];
return props.addressData.companies.map(company => company.company).filter(Boolean);
});
// Helper functions for contact display
const getContactName = (contact) => {
return contact.fullName || contact.name || "N/A";
};
const getContactEmail = (contact) => {
return contact.emailId || contact.customEmail || "N/A";
};
const getContactPhone = (contact) => {
return contact.phone || contact.mobileNo || "N/A";
};
// Edit mode functions
const isContactSelected = (contact) => {
return selectedContactNames.value.includes(contact.name);
};
const isPrimaryContact = (contact) => {
return selectedPrimaryContactName.value === contact.name;
};
const toggleContact = (contact) => {
const index = selectedContactNames.value.indexOf(contact.name);
if (index > -1) {
// Removing contact
selectedContactNames.value.splice(index, 1);
// If this was the primary contact, clear it
if (selectedPrimaryContactName.value === contact.name) {
selectedPrimaryContactName.value = null;
}
} else {
// Adding contact
selectedContactNames.value.push(contact.name);
}
emitChanges();
};
const setPrimaryContact = (contact) => {
if (isContactSelected(contact)) {
selectedPrimaryContactName.value = contact.name;
emitChanges();
}
};
const emitChanges = () => {
emit("update:addressContacts", selectedContactNames.value);
emit("update:primaryContact", selectedPrimaryContactName.value);
};
</script>
<style scoped>
.property-details {
background: var(--surface-card);
border-radius: 12px;
padding: 0.75rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 1rem;
}
.property-details > h3 {
margin: 0 0 0.75rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: start;
}
.detail-section {
background: var(--surface-ground);
border-radius: 8px;
padding: 0.75rem;
}
.detail-section.full-width {
grid-column: span 2;
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--surface-border);
}
.section-header i {
font-size: 1rem;
color: var(--primary-color);
}
.section-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
}
.address-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.full-address {
font-size: 1rem;
font-weight: 500;
color: var(--text-color);
margin: 0;
}
.address-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Contacts Display Mode */
.contacts-display {
display: flex;
flex-direction: column;
gap: 1rem;
}
.contact-card {
background: var(--surface-card);
border-radius: 6px;
padding: 0.75rem;
border: 1px solid var(--surface-border);
}
.contact-card.primary {
border: 2px solid var(--green-500);
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.15);
}
.contact-badge {
margin-bottom: 0.5rem;
}
.contact-info h5 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.contact-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-detail {
display: flex;
align-items: center;
gap: 0.5rem;
}
.contact-detail i {
font-size: 0.9rem;
color: var(--primary-color);
min-width: 18px;
}
.contact-detail span {
font-size: 0.9rem;
color: var(--text-color);
}
/* Other Contacts */
.other-contacts h6 {
margin: 0 0 0.75rem 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.contacts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.contact-card.small {
padding: 0.75rem;
}
.contact-info-compact {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-info-compact .contact-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-color);
}
.contact-info-compact .contact-email,
.contact-info-compact .contact-phone,
.contact-info-compact .contact-role {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
/* Contacts Edit Mode */
.contacts-edit {
display: flex;
flex-direction: column;
gap: 1rem;
}
.edit-instructions {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: var(--blue-50);
border-radius: 6px;
border: 1px solid var(--blue-200);
}
.edit-instructions i {
font-size: 1.25rem;
color: var(--blue-500);
}
.edit-instructions span {
font-size: 0.9rem;
color: var(--blue-700);
}
.contacts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.contact-checkbox-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--surface-card);
border-radius: 6px;
border: 2px solid var(--surface-border);
transition: all 0.2s ease;
}
.contact-checkbox-item.is-selected {
border-color: var(--primary-color);
background: var(--primary-50);
}
.contact-label {
flex: 1;
cursor: pointer;
}
.contact-info-inline {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-info-inline .contact-name {
font-weight: 600;
font-size: 1rem;
color: var(--text-color);
}
.contact-info-inline .contact-email,
.contact-info-inline .contact-phone {
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.primary-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--green-50);
border-radius: 4px;
border: 1px solid var(--green-200);
}
.primary-checkbox label {
font-size: 0.875rem;
font-weight: 600;
color: var(--green-700);
cursor: pointer;
}
/* Companies */
.companies-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.company-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--surface-card);
border-radius: 4px;
border: 1px solid var(--surface-border);
}
.company-item i {
font-size: 0.9rem;
color: var(--primary-color);
min-width: 18px;
}
.company-item span {
font-size: 0.9rem;
color: var(--text-color);
font-weight: 500;
}
/* Map */
.coordinates-info {
margin-top: 0.75rem;
text-align: center;
color: var(--text-color-secondary);
padding-top: 0.75rem;
border-top: 1px solid var(--surface-border);
}
</style>

View file

@ -1,118 +0,0 @@
<template>
<div class="quick-actions">
<Button
@click="handleEdit"
icon="pi pi-pencil"
label="Edit Information"
size="small"
severity="secondary"
/>
<Button
@click="handleCreateEstimate"
icon="pi pi-file-edit"
label="Create Estimate"
size="small"
severity="secondary"
/>
<Button
@click="handleCreateBidMeeting"
icon="pi pi-calendar-plus"
label="Create Bid Meeting"
size="small"
severity="secondary"
/>
<!-- Edit Confirmation Dialog -->
<Dialog
:visible="showEditConfirmDialog"
@update:visible="showEditConfirmDialog = $event"
header="Confirm Edit"
:modal="true"
:closable="false"
class="confirm-dialog"
>
<p>Are you sure you want to edit this client information? This will enable editing mode.</p>
<template #footer>
<Button
label="Cancel"
severity="secondary"
@click="showEditConfirmDialog = false"
/>
<Button
label="Yes, Edit"
@click="confirmEdit"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref } from "vue";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import { useRouter } from "vue-router";
import DataUtils from "../../utils";
const props = defineProps({
fullAddress: {
type: String,
required: true,
},
});
const emit = defineEmits(["edit-mode-enabled"]);
const router = useRouter();
const showEditConfirmDialog = ref(false);
const handleEdit = () => {
showEditConfirmDialog.value = true;
};
const confirmEdit = () => {
showEditConfirmDialog.value = false;
emit("edit-mode-enabled");
};
const handleCreateEstimate = () => {
router.push({
path: "/estimate",
query: {
new: "true",
address: props.fullAddress,
},
});
};
const handleCreateBidMeeting = () => {
router.push({
path: "/calendar",
query: {
tab: "bids",
new: "true",
address: props.fullAddress,
},
});
};
</script>
<style scoped>
.quick-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.confirm-dialog {
max-width: 400px;
}
.confirm-dialog :deep(.p-dialog-footer) {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
</style>

View file

@ -1,89 +0,0 @@
<template>
<div v-if="shouldDisplayModule" class="special-modules">
<!-- SNW Install Module -->
<InstallStatus
v-if="currentCompany === 'Sprinklers Northwest'"
:onsite-meeting-status="snwInstallData.onsiteMeetingStatus"
:estimate-sent-status="snwInstallData.estimateSentStatus"
:job-status="snwInstallData.jobStatus"
:payment-status="snwInstallData.paymentStatus"
:full-address="fullAddress"
:bid-meeting="snwInstallData.onsiteMeeting"
:estimate="snwInstallData.estimate"
:job="snwInstallData.job"
:payment="snwInstallData.payment"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useCompanyStore } from "../../stores/company";
import InstallStatus from "./InstallStatus.vue";
const props = defineProps({
selectedAddress: {
type: Object,
default: null,
},
fullAddress: {
type: String,
default: "",
},
});
const companyStore = useCompanyStore();
const currentCompany = computed(() => companyStore.currentCompany);
// Check if we should display any module
const shouldDisplayModule = computed(() => {
return currentCompany.value === "Sprinklers Northwest";
});
// Computed data for SNW Install status
const snwInstallData = computed(() => {
if (!props.selectedAddress) {
return {
onsiteMeetingStatus: "Not Started",
estimateSentStatus: "Not Started",
jobStatus: "Not Started",
paymentStatus: "Not Started",
onsiteMeeting: "",
estimate: "",
job: "",
payment: "dummy-payment-string",
};
}
const addr = props.selectedAddress;
// Filter for SNW Install template
const snwBidMeeting = addr.onsiteMeetings?.find(
(m) => m.projectTemplate === "SNW Install" && m.status !== "Cancelled"
);
const snwEstimate = addr.quotations?.find(
(q) => q.projectTemplate === "SNW Install" && q.status !== "Cancelled"
);
const snwJob = addr.projects?.find(
(p) => p.projectTemplate === "SNW Install" && p.status !== "Cancelled"
);
return {
onsiteMeetingStatus: addr.onsiteMeetingScheduled || "Not Started",
estimateSentStatus: addr.estimateSentStatus || "Not Started",
jobStatus: addr.jobStatus || "Not Started",
paymentStatus: addr.paymentReceivedStatus || "Not Started",
onsiteMeeting: snwBidMeeting?.onsiteMeeting || "",
estimate: snwEstimate?.quotation || "",
job: snwJob?.project || "",
payment: "dummy-payment-string",
};
});
</script>
<style scoped>
.special-modules {
margin-bottom: 1rem;
}
</style>

View file

@ -529,6 +529,7 @@ const setMenuRef = (el, label, id) => {
if (el) { if (el) {
const refName = `${label}-${id}`; const refName = `${label}-${id}`;
menuRefs[refName] = el; menuRefs[refName] = el;
console.log("Setting Menu Ref:", refName, el);
} }
} }
@ -783,9 +784,7 @@ const hasExactlyOneRowSelected = computed(() => {
onMounted(() => { onMounted(() => {
const currentFilters = filtersStore.getTableFilters(props.tableName); const currentFilters = filtersStore.getTableFilters(props.tableName);
filterableColumns.value.forEach((col) => { filterableColumns.value.forEach((col) => {
// Use defaultValue from column definition if provided, otherwise use stored filter value pendingFilters.value[col.fieldName] = currentFilters[col.fieldName]?.value || "";
const storedValue = currentFilters[col.fieldName]?.value || "";
pendingFilters.value[col.fieldName] = col.defaultValue || storedValue;
}); });
}); });
@ -1075,9 +1074,12 @@ const handleActionClick = (action, rowData = null) => {
}; };
const toggleMenu = (event, action, rowData) => { const toggleMenu = (event, action, rowData) => {
console.log("Menu button toggled");
const menuKey = `${action.label}-${rowData.id}`; const menuKey = `${action.label}-${rowData.id}`;
console.log("Looking for menu:", menuKey, menuRefs);
const menu = menuRefs[menuKey]; const menu = menuRefs[menuKey];
if (menu) { if (menu) {
console.log("Found menu, toggling:", menu);
menu.toggle(event); menu.toggle(event);
activeMenuKey.value = menuKey; activeMenuKey.value = menuKey;
} else { } else {
@ -1086,9 +1088,11 @@ const toggleMenu = (event, action, rowData) => {
}; };
const buildMenuItems = (menuItems, rowData) => { const buildMenuItems = (menuItems, rowData) => {
console.log("DEBUG: Building menuItems:", menuItems);
return menuItems.map(item => ({ return menuItems.map(item => ({
...item, ...item,
command: () => { command: () => {
console.log("Clicked from Datatable");
if (typeof item.command === 'function') { if (typeof item.command === 'function') {
item.command(rowData); item.command(rowData);
} }

View file

@ -1,14 +1,15 @@
<template> <template>
<div <div
v-if="showOverlay" v-if="showOverlay"
class="global-loading-overlay" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-[9999] transition-opacity duration-200"
:class="{ 'opacity-100': showOverlay, 'opacity-0 pointer-events-none': !showOverlay }"
> >
<div class="loading-content"> <div class="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full mx-4 text-center">
<div class="spinner-container"> <div class="mb-4">
<i class="pi pi-spin pi-spinner"></i> <i class="pi pi-spin pi-spinner text-4xl text-blue-500"></i>
</div> </div>
<h3 class="loading-title">Loading</h3> <h3 class="text-lg font-semibold text-gray-800 mb-2">Loading</h3>
<p class="loading-message">{{ loadingMessage }}</p> <p class="text-gray-600">{{ loadingMessage }}</p>
</div> </div>
</div> </div>
</template> </template>
@ -45,51 +46,15 @@ const loadingMessage = computed(() => {
</script> </script>
<style scoped> <style scoped>
.global-loading-overlay { /* Additional styling for better visual appearance */
position: fixed; .bg-opacity-30 {
top: 0; background-color: rgba(0, 0, 0, 0.3);
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
transition: opacity 0.2s ease;
} }
.loading-content { /* Backdrop blur effect for modern browsers */
background: white; @supports (backdrop-filter: blur(4px)) {
border-radius: 12px; .fixed.inset-0 {
padding: 2rem; backdrop-filter: blur(4px);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); }
max-width: 400px;
width: 90%;
text-align: center;
}
.spinner-container {
margin-bottom: 1rem;
}
.spinner-container i {
font-size: 3rem;
color: #1976d2;
}
.loading-title {
font-size: 1.25rem;
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
}
.loading-message {
color: #666;
font-size: 0.95rem;
} }
</style> </style>

View file

@ -59,7 +59,6 @@ const props = defineProps({
const mapElement = ref(null); const mapElement = ref(null);
let map = null; let map = null;
let marker = null; let marker = null;
let resizeObserver = null;
const initializeMap = async () => { const initializeMap = async () => {
if (!mapElement.value) return; if (!mapElement.value) return;
@ -76,12 +75,8 @@ const initializeMap = async () => {
// Only create map if we have valid coordinates // Only create map if we have valid coordinates
if (!isNaN(lat) && !isNaN(lng)) { if (!isNaN(lat) && !isNaN(lng)) {
// Wait for next tick to ensure DOM is updated
await nextTick(); await nextTick();
// Additional delay to ensure container has proper dimensions
await new Promise(resolve => setTimeout(resolve, 50));
// Initialize map // Initialize map
map = L.map(mapElement.value, { map = L.map(mapElement.value, {
zoomControl: props.interactive, zoomControl: props.interactive,
@ -111,28 +106,6 @@ const initializeMap = async () => {
`, `,
) )
.openPopup(); .openPopup();
// Ensure map renders correctly - call invalidateSize multiple times
const invalidateMap = () => {
if (map) {
map.invalidateSize();
}
};
setTimeout(invalidateMap, 100);
setTimeout(invalidateMap, 300);
setTimeout(invalidateMap, 500);
// Set up resize observer to handle container size changes
if (resizeObserver) {
resizeObserver.disconnect();
}
resizeObserver = new ResizeObserver(() => {
if (map) {
map.invalidateSize();
}
});
resizeObserver.observe(mapElement.value);
} }
}; };
@ -144,16 +117,6 @@ const updateMap = () => {
// Update map view // Update map view
map.setView([lat, lng], props.zoomLevel); map.setView([lat, lng], props.zoomLevel);
// Ensure map renders correctly after view change
const invalidateMap = () => {
if (map) {
map.invalidateSize();
}
};
setTimeout(invalidateMap, 100);
setTimeout(invalidateMap, 300);
// Update marker // Update marker
if (marker) { if (marker) {
marker.setLatLng([lat, lng]); marker.setLatLng([lat, lng]);
@ -201,10 +164,6 @@ onUnmounted(() => {
map = null; map = null;
marker = null; marker = null;
} }
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
}); });
</script> </script>
@ -219,7 +178,6 @@ onUnmounted(() => {
.map { .map {
width: 100%; width: 100%;
position: relative;
z-index: 1; z-index: 1;
} }

View file

@ -21,7 +21,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, nextTick, computed, onUnmounted, toRaw} from "vue"; import { ref, onMounted, watch, nextTick, computed, onUnmounted} from "vue";
import { Chart, registerables } from "chart.js"; import { Chart, registerables } from "chart.js";
// Register Chart.js components // Register Chart.js components
@ -29,13 +29,17 @@ Chart.register(...registerables);
const props = defineProps({ const props = defineProps({
title: String, title: String,
categories: Object, todoNumber: Number,
completedNumber: Number,
loading: { loading: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}); });
//Constants
const categories = ["To-do", "Completed"];
//Reactive data //Reactive data
const centerData = ref(null); const centerData = ref(null);
const hoveredSegment = ref(null); const hoveredSegment = ref(null);
@ -52,19 +56,21 @@ const getHoveredCategoryIndex = () => {
} }
const getCategoryValue = (categoryIndex) => { const getCategoryValue = (categoryIndex) => {
return props.categories.data[categoryIndex]; if (categoryIndex === 0) {
return props.todoNumber
} else {
return props.completedNumber
}
} }
const getChartData = () => { const getChartData = () => {
const categoryData = props.categories.data;
const categoryColors = props.categories.colors;
const chartData = { const chartData = {
name: props.title, name: props.title,
datasets: [ datasets: [
{ {
label: "", label: "",
data: categoryData, data: [props.todoNumber, props.completedNumber],
backgroundColor: categoryColors backgroundColor: ["#b22222", "#4caf50"]
}, },
] ]
}; };
@ -73,11 +79,8 @@ const getChartData = () => {
const updateCenterData = () => { const updateCenterData = () => {
let total = 0; const total = props.todoNumber + props.completedNumber;
for (let i=0; i<props.categories.data.length; i++) { const todos = props.todoNumber;
total += props.categories.data[i];
}
const todos = props.categories.data[0];
if (todos === 0 && total > 0) { if (todos === 0 && total > 0) {
centerData.value = { centerData.value = {
@ -104,14 +107,14 @@ const updateCenterData = () => {
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) + "%" : "0%"; const percentage = total > 0 ? ((value / total) * 100).toFixed(1) + "%" : "0%";
centerData.value = { centerData.value = {
label: props.categories.labels[hoveredCategoryIndex], label: categories[hoveredCategoryIndex],
value: value, value: value,
percentage: percentage, percentage: percentage,
}; };
} else { } else {
centerData.value = { centerData.value = {
label: props.categories.labels[0], label: "To-do",
value: todos, value: props.todoNumber,
percentage: null, percentage: null,
}; };
} }
@ -177,6 +180,8 @@ const getChartOptions = () => {
const createChart = () => { const createChart = () => {
if (!chartCanvas.value || props.loading) return; if (!chartCanvas.value || props.loading) return;
console.log(`DEBUG: Creating chart for ${props.title}`);
console.log(props);
const ctx = chartCanvas.value.getContext("2d"); const ctx = chartCanvas.value.getContext("2d");
if (chartInstance.value) { if (chartInstance.value) {
@ -209,9 +214,9 @@ onMounted(() => {
createChart(); createChart();
}); });
watch(() => props.categories, (newValue) => { watch(() => props.completedNumber, (newValue) => {
updateChart(); updateChart();
}, {deep: true}); });
</script> </script>

View file

@ -1,978 +0,0 @@
<template>
<Modal
:visible="showModal"
@update:visible="showModal = $event"
:options="modalOptions"
@confirm="handleSubmit"
@cancel="handleCancel"
>
<template #title>
<div class="modal-header">
<i class="pi pi-file-edit" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
{{ formTitle }}
</div>
</template>
<div v-if="isLoading" class="loading-container">
<ProgressSpinner style="width: 50px; height: 50px" strokeWidth="4" />
<p>Loading form...</p>
</div>
<div v-else-if="formConfig" class="form-container">
<Button @click="debugLog" label="Debug" severity="secondary" size="small" class="debug-button" />
<template v-for="row in groupedFields" :key="`row-${row.rowIndex}`">
<div class="form-row">
<div
v-for="field in row.fields"
:key="field.name"
:class="`form-column-${Math.min(Math.max(field.columns || 12, 1), 12)}`"
>
<div class="form-field">
<!-- Field Label -->
<label :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required-indicator">*</span>
</label>
<!-- Help Text -->
<small v-if="field.helpText" class="field-help-text">
{{ field.helpText }}
</small>
<!-- Data/Text Field -->
<template v-if="field.type === 'Data' || field.type === 'Text'">
<InputText
v-if="field.type === 'Data'"
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="field.label"
class="w-full"
/>
<Textarea
v-else
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="field.label"
rows="3"
class="w-full"
/>
</template>
<!-- Check Field -->
<template v-else-if="field.type === 'Check'">
<div class="checkbox-container">
<Checkbox
:id="field.name"
v-model="formData[field.name].value"
:binary="true"
:disabled="field.readOnly || !isFieldVisible(field)"
/>
<label :for="field.name" class="checkbox-label">
{{ formData[field.name].value ? 'Yes' : 'No' }}
</label>
</div>
</template>
<!-- Date Field -->
<template v-else-if="field.type === 'Date'">
<DatePicker
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
dateFormat="yy-mm-dd"
class="w-full"
/>
</template>
<!-- Datetime Field -->
<template v-else-if="field.type === 'Datetime'">
<DatePicker
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
showTime
hourFormat="12"
dateFormat="yy-mm-dd"
class="w-full"
/>
</template>
<!-- Time Field -->
<template v-else-if="field.type === 'Time'">
<DatePicker
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
timeOnly
hourFormat="12"
class="w-full"
/>
</template>
<!-- Number Field -->
<template v-else-if="field.type === 'Number'">
<InputNumber
:id="field.name"
v-model="formData[field.name].value"
:disabled="field.readOnly || !isFieldVisible(field)"
class="w-full"
/>
</template>
<!-- Select Field -->
<template v-else-if="field.type === 'Select'">
<div @click="console.log('Select wrapper clicked:', field.name, 'disabled:', field.readOnly || !isFieldVisible(field), 'options:', optionsForFields[field.name])">
<Select
:id="field.name"
v-model="formData[field.name].value"
:options="optionsForFields[field.name]"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="`Select ${field.label}`"
:optionLabel="'label'"
:optionValue="'value'"
:editable="false"
:showClear="true"
:baseZIndex="10000"
@click.native="console.log('Select native click:', field.name)"
class="w-full"
/>
</div>
</template>
<!-- Multi-Select Field -->
<template v-else-if="field.type === 'Multi-Select'">
<MultiSelect
:id="field.name"
v-model="formData[field.name].value"
:options="optionsForFields[field.name]"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="`Select ${field.label}`"
:key="`${field.name}-${field.readOnly || !isFieldVisible(field)}`"
:optionLabel="'label'"
:optionValue="'value'"
:showClear="true"
:baseZIndex="9999"
display="chip"
class="w-full"
/>
</template>
<!-- Multi-Select w/ Quantity Field -->
<template v-else-if="field.type === 'Multi-Select w/ Quantity'">
<div class="multi-select-quantity-container">
<!-- Item Selector -->
<div class="item-selector">
<Select
v-model="currentItemSelection[field.name]"
:options="optionsForFields[field.name]"
:disabled="field.readOnly || !isFieldVisible(field)"
:placeholder="`Add ${field.label}`"
:key="`${field.name}-${field.readOnly || !isFieldVisible(field)}`"
:optionLabel="'label'"
:showClear="true"
:baseZIndex="9999"
class="w-full"
@change="addItemToQuantityList(field)"
/>
</div>
<!-- Selected Items with Quantities -->
<div v-if="formData[field.name].value && formData[field.name].value.length > 0" class="selected-items-list">
<div
v-for="(item, index) in formData[field.name].value"
:key="index"
class="quantity-item"
>
<div class="item-name">{{ getOptionLabel(field, item) }}</div>
<div class="quantity-controls">
<InputNumber
v-model="item.quantity"
:min="1"
:disabled="field.readOnly || !isFieldVisible(field)"
showButtons
buttonLayout="horizontal"
:step="1"
decrementButtonClass="p-button-secondary"
incrementButtonClass="p-button-secondary"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
@click="removeItemFromQuantityList(field, index)"
:disabled="field.readOnly || !isFieldVisible(field)"
/>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
<div v-else class="error-container">
<i class="pi pi-exclamation-triangle" style="font-size: 2rem; color: var(--red-500);"></i>
<p>Failed to load form configuration</p>
</div>
</Modal>
</template>
<script setup>
import { ref, computed, watch, onMounted, reactive } from "vue";
import Modal from "../common/Modal.vue";
import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import Checkbox from "primevue/checkbox";
import DatePicker from "primevue/datepicker";
import InputNumber from "primevue/inputnumber";
import Select from "primevue/select";
import MultiSelect from "primevue/multiselect";
import Button from "primevue/button";
import ProgressSpinner from "primevue/progressspinner";
import Api from "../../api";
import { useLoadingStore } from "../../stores/loading";
import { useNotificationStore } from "../../stores/notifications-primevue";
const docsForSelectFields = ref({});
const props = defineProps({
visible: {
type: Boolean,
required: true,
},
bidMeetingName: {
type: String,
required: true,
},
projectTemplate: {
type: String,
required: true,
},
});
const emit = defineEmits(["update:visible", "submit", "cancel"]);
const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const showModal = computed({
get: () => props.visible,
set: (value) => emit("update:visible", value),
});
const isLoading = ref(false);
const formConfig = ref(null);
const formData = ref({}); // Will store fieldName: {fieldConfig, value}
const currentItemSelection = ref({}); // For tracking current selection in Multi-Select w/ Quantity
const doctypeOptions = ref({}); // Cache for doctype options
const formTitle = computed(() => {
return formConfig.value?.title || "Bid Meeting Notes";
});
// Include all fields from config plus a general notes field
const allFields = computed(() => {
if (!formConfig.value) return [];
const fields = [...(formConfig.value.fields || [])];
// Always add a general notes field at the end
const generalNotesField = {
name: 'general_notes',
label: 'General Notes',
type: 'Text',
required: 0,
readOnly: 0,
helpText: 'Any additional notes or observations from the meeting',
row: Math.max(...fields.map(f => f.row || 1), 0) + 1,
columns: 12,
};
fields.push(generalNotesField);
return fields;
});
// Group fields by row for grid layout
const groupedFields = computed(() => {
const groups = {};
allFields.value.forEach(field => {
const rowNum = field.row || 1;
if (!groups[rowNum]) {
groups[rowNum] = { rowIndex: rowNum, fields: [] };
}
groups[rowNum].fields.push(field);
});
// Sort fields by column and set columns span
Object.values(groups).forEach(group => {
group.fields.sort((a, b) => (a.column || 0) - (b.column || 0));
const numFields = group.fields.length;
const span = Math.floor(12 / numFields);
group.fields.forEach(field => {
field.columns = span;
});
});
return Object.values(groups).sort((a, b) => a.rowIndex - b.rowIndex);
});
// Update field value in reactive form data
const updateFieldValue = (fieldName, value) => {
if (formData.value[fieldName]) {
formData.value[fieldName].value = value;
}
};
// Get CSS class for field column span
const getFieldColumnClass = (field) => {
const columns = field.columns || 12; // Default to full width if not specified
return `form-column-${Math.min(Math.max(columns, 1), 12)}`; // Ensure between 1-12
};
const modalOptions = computed(() => ({
maxWidth: "800px",
showCancelButton: true,
confirmButtonText: "Submit",
cancelButtonText: "Cancel",
confirmButtonColor: "primary",
zIndex: 1000, // Lower than select baseZIndex
}));
// Helper to find field name by label
const findFieldNameByLabel = (label) => {
if (!formConfig.value || !formConfig.value.fields) return null;
const field = formConfig.value.fields.find(f => f.label === label);
return field ? field.name : null;
};
const fetchDocsForSelectField = async (doctype, fieldName) => {
const docs = await Api.getDocsList(doctype);
docsForSelectFields[fieldName] = docs;
}
// Check if a field should be visible based on conditional logic
const isFieldVisible = (field) => {
if (!field.conditionalOnField) {
return true;
}
// Find the actual field name from the label (conditionalOnField contains the label)
const dependentFieldName = findFieldNameByLabel(field.conditionalOnField);
if (!dependentFieldName) {
console.warn(`Could not find field with label: ${field.conditionalOnField}`);
return true; // Show field if we can't find the dependency
}
const dependentFieldValue = formData.value[dependentFieldName]?.value;
console.log(`Checking visibility for ${field.label}:`, {
conditionalOnField: field.conditionalOnField,
dependentFieldName,
dependentFieldValue,
conditionalOnValue: field.conditionalOnValue,
});
// If the dependent field is a checkbox, it should be true
if (typeof dependentFieldValue === "boolean") {
return dependentFieldValue === true;
}
// If conditional_on_value is specified, check for exact match
if (field.conditionalOnValue !== null && field.conditionalOnValue !== undefined) {
return dependentFieldValue === field.conditionalOnValue;
}
// Otherwise, just check if the dependent field has any truthy value
return !!dependentFieldValue;
};
// Get options for select/multi-select fields
const getFieldOptions = (field) => {
// Access reactive data to ensure reactivity
const optionsData = docsForSelectFields.value[field.name];
console.log(`getFieldOptions called for ${field.label}:`, {
type: field.type,
options: field.options,
optionsType: typeof field.options,
doctypeForSelect: field.doctypeForSelect,
doctypeLabelField: field.doctypeLabelField,
hasDoctypeOptions: !!optionsData,
});
// If options should be fetched from a doctype
if (field.doctypeForSelect && optionsData) {
console.log(`Using doctype options for ${field.label}:`, optionsData);
return [...optionsData]; // Return a copy to ensure reactivity
}
// If options are provided as a string (comma-separated), parse them
if (field.options && typeof field.options === "string" && field.options.trim() !== "") {
const optionStrings = field.options.split(",").map((opt) => opt.trim()).filter(opt => opt !== "");
// Convert to objects for consistency with PrimeVue MultiSelect
const options = optionStrings.map((opt) => ({
label: opt,
value: opt,
}));
console.log(`Parsed options for ${field.label}:`, options);
return options;
}
console.warn(`No options found for ${field.label}`);
return [];
};
// Get options for select/multi-select fields
const optionsForFields = computed(() => {
// Ensure reactivity by accessing docsForSelectFields
const docsData = docsForSelectFields.value;
const opts = {};
allFields.value.forEach(field => {
const options = getFieldOptions(field);
opts[field.name] = options;
console.log(`Computed options for ${field.name}:`, options);
});
console.log('optionsForFields computed:', opts);
return opts;
});
// Add item to quantity list for Multi-Select w/ Quantity fields
const addItemToQuantityList = (field) => {
const selectedItem = currentItemSelection.value[field.name];
if (!selectedItem) return;
// selectedItem is now an object with { label, value }
const itemValue = selectedItem.value || selectedItem;
const itemLabel = selectedItem.label || selectedItem;
// Initialize array if it doesn't exist
const fieldData = formData.value[field.name];
if (!fieldData.value) {
fieldData.value = [];
}
// Check if item already exists (compare by value)
const existingItem = fieldData.value.find((item) => item.item === itemValue);
if (existingItem) {
// Increment quantity if item already exists
existingItem.quantity += 1;
} else {
// Add new item with quantity 1
fieldData.value.push({
item: itemValue,
label: itemLabel,
quantity: 1,
});
}
// Clear selection
currentItemSelection.value[field.name] = null;
};
// Remove item from quantity list
const removeItemFromQuantityList = (field, index) => {
const fieldData = formData.value[field.name];
if (fieldData && fieldData.value) {
fieldData.value.splice(index, 1);
}
};
// Get option label for display
const getOptionLabel = (field, item) => {
const options = optionsForFields.value[field.name];
const option = options.find(o => o.value === item.item);
return option ? option.label : item.label || item.item;
};
// Initialize form data with default values
const initializeFormData = () => {
if (!formConfig.value) return;
const data = {};
allFields.value.forEach((field) => {
// Create reactive object with field config and value
data[field.name] = {
...field, // Include all field configuration
value: null, // Initialize value
};
// Set default value if provided
if (field.defaultValue !== null && field.defaultValue !== undefined) {
data[field.name].value = field.defaultValue;
} else {
// Initialize based on field type
switch (field.type) {
case "Check":
data[field.name].value = false;
break;
case "Multi-Select":
data[field.name].value = [];
break;
case "Multi-Select w/ Quantity":
data[field.name].value = [];
break;
case "Number":
data[field.name].value = null;
break;
case "Select":
data[field.name].value = null;
break;
default:
data[field.name].value = "";
}
}
});
formData.value = data;
};
// Load form configuration
const loadFormConfig = async () => {
if (!props.projectTemplate) return;
isLoading.value = true;
try {
const config = await Api.getBidMeetingNoteForm(props.projectTemplate);
formConfig.value = config;
console.log("Loaded form config:", config);
// Load doctype options for fields that need them
await loadDoctypeOptions();
// Initialize form data
initializeFormData();
} catch (error) {
console.error("Error loading form config:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to load form configuration",
duration: 5000,
});
} finally {
isLoading.value = false;
}
};
// Load options for fields that reference doctypes
const loadDoctypeOptions = async () => {
if (!formConfig.value || !formConfig.value.fields) return;
const fieldsWithDoctype = formConfig.value.fields.filter(
(field) => field.doctypeForSelect && field.doctypeForSelect !== ""
);
for (const field of fieldsWithDoctype) {
try {
// Use the new API method for fetching docs
let docs = await Api.getQuotationItems();
// Deduplicate by value field
const valueField = field.doctypeValueField || 'name';
const seen = new Set();
docs = docs.filter(doc => {
const val = doc[valueField] || doc.name || doc;
if (seen.has(val)) return false;
seen.add(val);
return true;
});
// Transform docs into options format
// Use doctypeLabelField if specified, otherwise default to 'name'
const labelField = field.doctypeLabelField || 'name';
const options = docs.map((doc) => ({
label: doc[labelField] || doc.name || doc,
value: doc[valueField] || doc.name || doc,
}));
docsForSelectFields.value[field.name] = options;
console.log(`Loaded ${options.length} options for ${field.label} from ${field.doctypeForSelect}`);
} catch (error) {
console.error(`Error loading options for ${field.doctypeForSelect}:`, error);
}
}
};
// Validate form
const validateForm = () => {
const errors = [];
if (!formConfig.value) return errors;
allFields.value.forEach((field) => {
// Only validate if field is visible
if (!isFieldVisible(field)) return;
// Skip required validation for checkboxes (they always have a value: true or false)
if (field.type === 'Check') return;
if (field.required) {
const value = formData.value[field.name]?.value;
if (value === null || value === undefined || value === "") {
errors.push(`${field.label} is required`);
} else if (Array.isArray(value) && value.length === 0) {
errors.push(`${field.label} is required`);
}
}
});
return errors;
};
// Format field data for submission
const formatFieldData = (field) => {
const value = formData.value[field.name]?.value;
// Include the entire field configuration
const fieldData = {
...field, // Include all field properties
value: value, // Override with current value
};
// Handle options: include unless fetched from doctype
if (field.doctypeForSelect) {
// Remove options if they were fetched from doctype
delete fieldData.options;
}
// For fields with include_options flag, include the selected options
if (field.includeOptions) {
if (field.type === "Multi-Select") {
fieldData.selectedOptions = value || [];
} else if (field.type === "Multi-Select w/ Quantity") {
fieldData.items = value || [];
} else if (field.type === "Select") {
fieldData.selectedOption = value;
}
}
// Format dates as strings
if (field.type === "Date" || field.type === "Datetime" || field.type === "Time") {
if (value instanceof Date) {
if (field.type === "Date") {
fieldData.value = value.toISOString().split("T")[0];
} else if (field.type === "Datetime") {
fieldData.value = value.toISOString();
} else if (field.type === "Time") {
fieldData.value = value.toTimeString().split(" ")[0];
}
}
}
return fieldData;
};
// Handle form submission
const handleSubmit = async () => {
// Validate form
const errors = validateForm();
if (errors.length > 0) {
notificationStore.addNotification({
type: "error",
title: "Validation Error",
message: errors.join(", "),
duration: 5000,
});
return;
}
try {
loadingStore.setLoading(true);
// Format data for submission
const submissionData = {
bidMeeting: props.bidMeetingName,
projectTemplate: props.projectTemplate,
formName: formConfig.value?.name || formConfig.value?.title,
formTemplate: formConfig.value?.name || formConfig.value?.title,
fields: allFields.value
.filter((field) => isFieldVisible(field))
.map((field) => formatFieldData(field)),
};
console.log("Submitting form data:", submissionData);
// Submit to API
await Api.submitBidMeetingNoteForm(submissionData);
notificationStore.addNotification({
type: "success",
title: "Success",
message: "Bid meeting notes submitted successfully",
duration: 5000,
});
emit("submit", submissionData);
showModal.value = false;
} catch (error) {
console.error("Error submitting form:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to submit form. Please try again.",
duration: 5000,
});
} finally {
loadingStore.setLoading(false);
}
};
// Handle cancel
const handleCancel = () => {
emit("cancel");
showModal.value = false;
};
// Debug function to log current form data and select options
const debugLog = () => {
console.log("=== FORM DEBUG ===");
const debugData = {};
allFields.value.forEach(field => {
const fieldValue = formData.value[field.name]?.value;
const fieldOptions = optionsForFields.value[field.name];
const isVisible = isFieldVisible(field);
const isDisabled = field.readOnly || !isVisible;
debugData[field.name] = {
label: field.label,
type: field.type,
value: fieldValue,
isVisible,
isDisabled,
...(field.type.includes('Select') ? {
options: fieldOptions,
optionsCount: fieldOptions?.length || 0
} : {}),
};
});
console.log("Current Form Data:", debugData);
console.log("==================");
};
// Watch for modal visibility changes
watch(
() => props.visible,
(newVal) => {
if (newVal) {
loadFormConfig();
}
}
);
// Load form config on mount if modal is visible
onMounted(() => {
if (props.visible) {
loadFormConfig();
}
});
</script>
<style scoped>
.modal-header {
display: flex;
align-items: center;
font-size: 1.25rem;
font-weight: 600;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
color: var(--text-color-secondary);
}
.debug-button {
margin-bottom: 1rem;
}
/* Grid Layout Styles */
.form-row {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1rem;
width: 100%;
margin-bottom: 1rem;
}
.form-column-1 { grid-column: span 1; }
.form-column-2 { grid-column: span 2; }
.form-column-3 { grid-column: span 3; }
.form-column-4 { grid-column: span 4; }
.form-column-5 { grid-column: span 5; }
.form-column-6 { grid-column: span 6; }
.form-column-7 { grid-column: span 7; }
.form-column-8 { grid-column: span 8; }
.form-column-9 { grid-column: span 9; }
.form-column-10 { grid-column: span 10; }
.form-column-11 { grid-column: span 11; }
.form-column-12 { grid-column: span 12; }
.form-field-wrapper {
width: 100%;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: auto !important;
}
.form-field :deep(.p-select) {
pointer-events: auto !important;
}
.form-field :deep(.p-multiselect) {
pointer-events: auto !important;
}
.field-label {
font-weight: 600;
color: var(--text-color);
font-size: 0.95rem;
}
.required-indicator {
color: var(--red-500);
margin-left: 0.25rem;
}
.field-help-text {
color: var(--text-color-secondary);
font-size: 0.85rem;
margin-top: -0.25rem;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
}
.checkbox-container :deep(.p-checkbox) {
width: 1.25rem !important;
height: 1.25rem !important;
position: relative !important;
}
.checkbox-container :deep(.p-checkbox .p-checkbox-box) {
width: 1.25rem !important;
height: 1.25rem !important;
border: 1px solid var(--surface-border) !important;
border-radius: 3px !important;
position: relative !important;
}
.checkbox-container :deep(.p-checkbox .p-checkbox-input) {
width: 100% !important;
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
opacity: 0 !important;
cursor: pointer !important;
z-index: 1 !important;
}
.checkbox-container :deep(.p-checkbox .p-checkbox-box .p-checkbox-icon) {
font-size: 0.875rem !important;
color: var(--primary-color) !important;
}
.checkbox-label {
font-size: 0.9rem;
color: var(--text-color-secondary);
margin: 0;
font-weight: normal;
cursor: pointer;
user-select: none;
}
.multi-select-quantity-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.item-selector {
width: 100%;
}
.selected-items-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background-color: var(--surface-50);
border-radius: 6px;
border: 1px solid var(--surface-border);
}
.quantity-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background-color: var(--surface-0);
border-radius: 4px;
border: 1px solid var(--surface-border);
}
.item-name {
flex: 1;
font-weight: 500;
color: var(--text-color);
}
.quantity-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.form-container {
max-height: 60vh;
}
.quantity-item {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.quantity-controls {
width: 100%;
justify-content: space-between;
}
}
</style>

View file

@ -1,566 +0,0 @@
<template>
<div class="bid-meeting-notes">
<div v-if="loading" class="loading-state">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<p>Loading bid notes...</p>
</div>
<div v-else-if="error" class="error-state">
<i class="pi pi-exclamation-triangle"></i>
<p>{{ error }}</p>
</div>
<div v-else-if="bidNote" class="notes-content">
<!-- General Notes (if exists) -->
<div v-if="bidNote.notes" class="general-notes">
<div class="section-header">
<i class="pi pi-file-edit"></i>
<span>General Notes</span>
</div>
<div class="notes-text">{{ bidNote.notes }}</div>
</div>
<!-- Dynamic Fields organized by rows -->
<div v-if="fieldsByRow && Object.keys(fieldsByRow).length > 0" class="fields-section">
<div v-for="(rowFields, rowIndex) in fieldsByRow" :key="rowIndex" class="field-row">
<div v-for="field in rowFields" :key="field.name" class="field-item" :class="`field-type-${field.type.toLowerCase().replace(/\s+/g, '-')}`">
<!-- Check if field should be displayed based on conditionals -->
<template v-if="shouldShowField(field)">
<!-- Check Type -->
<div v-if="field.type === 'Check'" class="field-check">
<i :class="field.value === '1' ? 'pi pi-check-square' : 'pi pi-square'" :style="{ color: field.value === '1' ? 'var(--primary-color)' : '#999' }"></i>
<span class="field-label">{{ field.label }}</span>
</div>
<!-- Text Type -->
<div v-else-if="field.type === 'Text'" class="field-text">
<div class="field-label">{{ field.label }}</div>
<div class="field-value">{{ field.value || 'N/A' }}</div>
</div>
<!-- Multi-Select Type -->
<div v-else-if="field.type === 'Multi-Select'" class="field-multiselect">
<div class="field-label">{{ field.label }}</div>
<div class="field-value">
<div v-if="getParsedMultiSelect(field.value).length > 0" class="selected-items">
<span v-for="(item, idx) in getParsedMultiSelect(field.value)" :key="idx" class="selected-item">
{{ item }}
</span>
</div>
<div v-else class="no-selection">No items selected</div>
</div>
</div>
<!-- Multi-Select w/ Quantity Type -->
<div v-else-if="field.type === 'Multi-Select w/ Quantity'" class="field-multiselect-qty">
<div class="field-label">{{ field.label }}</div>
<div class="field-value">
<div v-if="loading" class="loading-items">
<v-progress-circular size="20" indeterminate></v-progress-circular>
<span>Loading items...</span>
</div>
<div v-else-if="getParsedMultiSelectQty(field.value).length > 0" class="quantity-items">
<div v-for="(item, idx) in getParsedMultiSelectQty(field.value)" :key="idx" class="quantity-item">
<span class="item-label">{{ getItemLabel(field, item) }}</span>
<span class="item-quantity">Qty: {{ item.quantity }}</span>
</div>
</div>
<div v-else class="no-selection">No items selected</div>
</div>
</div>
<!-- Default/Unknown Type -->
<div v-else class="field-unknown">
<div class="field-label">{{ field.label }}</div>
<div class="field-value">{{ field.value || 'N/A' }}</div>
<div class="field-type-note">(Type: {{ field.type }})</div>
</div>
</template>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<i class="pi pi-inbox"></i>
<p>No fields to display</p>
</div>
<!-- Header Information (moved to bottom) -->
<div class="notes-header">
<div class="header-info">
<div class="info-item">
<span class="label">Created By:</span>
<span class="value">{{ bidNote.owner }}</span>
</div>
<div class="info-item">
<span class="label">Submitted on:</span>
<span class="value">{{ formatDate(bidNote.creation) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import Api from "../../api";
import { useNotificationStore } from "../../stores/notifications-primevue";
const notificationStore = useNotificationStore();
// Props
const props = defineProps({
bidNote: {
type: Object,
required: true,
},
});
// Local state
const loading = ref(false);
const error = ref(null);
const doctypeCache = ref({});
// Organize fields by row
const fieldsByRow = computed(() => {
if (!props.bidNote?.fields) return {};
const rows = {};
props.bidNote.fields.forEach(field => {
const rowNum = field.row || 0;
if (!rows[rowNum]) {
rows[rowNum] = [];
}
rows[rowNum].push(field);
});
// Sort fields within each row by column
Object.keys(rows).forEach(rowNum => {
rows[rowNum].sort((a, b) => (a.column || 0) - (b.column || 0));
});
return rows;
});
// Methods
const shouldShowField = (field) => {
// If no conditional, always show
if (!field.conditionalOnField) {
return true;
}
// Find the field this one depends on
const parentField = props.bidNote.fields.find(f => f.label === field.conditionalOnField);
if (!parentField) {
return true; // If parent not found, show anyway
}
// For checkboxes, show if checked
if (parentField.type === 'Check') {
return parentField.value === '1';
}
// If conditional value is specified, check against it
if (field.conditionalOnValue) {
return parentField.value === field.conditionalOnValue;
}
// Otherwise, show if parent has any value
return !!parentField.value;
};
const getParsedMultiSelect = (value) => {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error("Error parsing multi-select value:", e);
return [];
}
};
const getParsedMultiSelectQty = (value) => {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error("Error parsing multi-select with quantity value:", e);
return [];
}
};
const getItemLabel = (field, item) => {
// If we have a cached label from doctype lookup
if (item.fetchedLabel) {
return item.fetchedLabel;
}
// If label is provided in the item itself
if (item.label) {
return item.label;
}
// Otherwise use the item ID
return item.item || 'Unknown Item';
};
const loadDoctypeLabels = async () => {
if (!props.bidNote?.fields) return;
// Check if there are quantities to fetch
if (!props.bidNote.quantities || props.bidNote.quantities.length === 0) {
return;
}
// Find all Multi-Select w/ Quantity fields that have valueDoctype
const quantityFields = props.bidNote.fields.filter(
f => f.type === 'Multi-Select w/ Quantity' && f.valueDoctype
);
if (quantityFields.length === 0) {
return;
}
// For each field type (valueDoctype), collect all item IDs and fetch them in batch
for (const field of quantityFields) {
const items = getParsedMultiSelectQty(field.value);
if (items.length === 0) continue;
// Collect all item IDs for this field
const itemIds = items.map(item => item.item).filter(Boolean);
if (itemIds.length === 0) continue;
// Check which items are not already cached
const uncachedItemIds = itemIds.filter(itemId => {
const cacheKey = `${field.valueDoctype}:${itemId}`;
return !doctypeCache.value[cacheKey];
});
// If all items are cached, skip the API call
if (uncachedItemIds.length === 0) {
// Just map the cached data to the items
items.forEach(item => {
if (item.item) {
const cacheKey = `${field.valueDoctype}:${item.item}`;
const cachedData = doctypeCache.value[cacheKey];
if (cachedData && field.doctypeLabelField) {
item.fetchedLabel = cachedData[field.doctypeLabelField] || item.item;
}
}
});
continue;
}
try {
// Build filter to fetch all uncached items at once
const filters = {
name: ['in', uncachedItemIds]
};
// Fetch all items in one API call
const fetchedItems = await Api.getDocsList(field.valueDoctype, ["*"], filters);
// Cache the fetched items
if (Array.isArray(fetchedItems)) {
fetchedItems.forEach(docData => {
const cacheKey = `${field.valueDoctype}:${docData.name}`;
doctypeCache.value[cacheKey] = docData;
});
}
// Now map the labels to all items (including previously cached ones)
items.forEach(item => {
if (item.item) {
const cacheKey = `${field.valueDoctype}:${item.item}`;
const cachedData = doctypeCache.value[cacheKey];
if (cachedData && field.doctypeLabelField) {
item.fetchedLabel = cachedData[field.doctypeLabelField] || item.item;
}
}
});
} catch (error) {
console.error(`Error fetching doctype ${field.valueDoctype}:`, error);
// On error, items will just show their IDs
}
}
};
const formatDate = (dateString) => {
if (!dateString) return '';
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Lifecycle
onMounted(async () => {
try {
loading.value = true;
await loadDoctypeLabels();
} catch (err) {
console.error("Error loading bid note details:", err);
error.value = "Failed to load some field details";
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.bid-meeting-notes {
width: 100%;
max-width: 900px;
margin: 0 auto;
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
text-align: center;
color: #666;
}
.error-state {
color: #f44336;
}
.error-state i {
font-size: 3rem;
margin-bottom: 16px;
}
.notes-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.notes-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 16px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-top: 20px;
}
.header-info {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
}
.info-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 120px;
}
.info-item .label {
font-size: 0.75em;
opacity: 0.9;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-item .value {
font-weight: 600;
font-size: 0.9em;
}
.general-notes {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 16px;
border-radius: 4px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #667eea;
margin-bottom: 12px;
font-size: 1.1em;
}
.section-header i {
font-size: 1.2em;
}
.notes-text {
color: #333;
line-height: 1.6;
white-space: pre-wrap;
}
.fields-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.field-item {
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
transition: all 0.2s;
}
.field-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-color: #667eea;
}
.field-check {
display: flex;
align-items: center;
gap: 8px;
}
.field-check i {
font-size: 1.2em;
}
.field-check .field-label {
font-weight: 500;
color: #333;
}
.field-text,
.field-multiselect,
.field-multiselect-qty,
.field-unknown {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-label {
font-weight: 600;
color: #667eea;
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.field-value {
color: #333;
line-height: 1.5;
}
.selected-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.selected-item {
background: #e3f2fd;
color: #1976d2;
padding: 4px 12px;
border-radius: 16px;
font-size: 0.85em;
font-weight: 500;
}
.quantity-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.quantity-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.item-label {
font-weight: 500;
color: #333;
}
.item-quantity {
background: #667eea;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
}
.loading-items {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 0.9em;
}
.no-selection {
color: #999;
font-style: italic;
font-size: 0.9em;
}
.field-type-note {
font-size: 0.75em;
color: #999;
margin-top: 4px;
font-style: italic;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #999;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.header-info {
grid-template-columns: 1fr;
}
.field-row {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -1,341 +0,0 @@
<template>
<v-dialog v-model="showModal" max-width="900px" scrollable>
<v-card v-if="job">
<v-card-title class="d-flex justify-space-between align-center bg-primary">
<div>
<div class="text-h6">{{ job.project?.projectName || job.projectTemplate || job.serviceType || job.name }}</div>
<div class="text-caption">{{ job.name }}</div>
</div>
<v-chip :color="getPriorityColor(job.project?.priority || job.priority)" size="small">
{{ job.project?.priority || job.priority || 'Normal' }}
</v-chip>
</v-card-title>
<v-card-text class="pa-4">
<v-row>
<!-- Left Column -->
<v-col cols="12" md="6">
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Basic Information</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-account</v-icon>
<strong>Customer:</strong> {{ job.customer?.customerName || job.customer?.name || 'N/A' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-map-marker</v-icon>
<strong>Service Address:</strong> {{ job.serviceAddress?.fullAddress || job.serviceAddress?.addressTitle || 'N/A' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-office-building</v-icon>
<strong>Project:</strong> {{ job.project?.projectName || job.projectTemplate || 'N/A' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-clipboard-text</v-icon>
<strong>Status:</strong> {{ job.status || 'Open' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-file-document</v-icon>
<strong>Sales Order:</strong> {{ job.salesOrder || 'N/A' }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-wrench</v-icon>
<strong>Service Type:</strong> {{ job.serviceType || 'N/A' }}
</div>
</div>
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Schedule</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-calendar-start</v-icon>
<strong>Start Date:</strong> {{ formatDate(job.expectedStartDate || job.scheduledDate) }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-calendar-end</v-icon>
<strong>End Date:</strong> {{ formatDate(job.expectedEndDate || job.scheduledEndDate) }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-account-hard-hat</v-icon>
<strong>Assigned Crew:</strong> {{ getCrewName(job.foreman || job.customForeman) }}
</div>
<div class="detail-row" v-if="job.expectedStartTime">
<v-icon size="small" class="mr-2">mdi-clock-start</v-icon>
<strong>Start Time:</strong> {{ job.expectedStartTime }}
</div>
<div class="detail-row" v-if="job.expectedEndTime">
<v-icon size="small" class="mr-2">mdi-clock-end</v-icon>
<strong>End Time:</strong> {{ job.expectedEndTime }}
</div>
</div>
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Compliance</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-file-certificate</v-icon>
<strong>Permit Status:</strong>
<v-chip size="x-small" :color="getPermitStatusColor(job.customPermitStatus)" class="ml-2">
{{ job.customPermitStatus || 'N/A' }}
</v-chip>
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-map-search</v-icon>
<strong>Utility Locate:</strong>
<v-chip size="x-small" :color="getLocateStatusColor(job.customUtlityLocateStatus)" class="ml-2">
{{ job.customUtlityLocateStatus || 'N/A' }}
</v-chip>
</div>
<div v-if="job.customWarrantyDurationDays" class="detail-row">
<v-icon size="small" class="mr-2">mdi-shield-check</v-icon>
<strong>Warranty:</strong> {{ job.customWarrantyDurationDays }} days
</div>
<div class="detail-row" v-if="job.customJobType">
<v-icon size="small" class="mr-2">mdi-tools</v-icon>
<strong>Job Type:</strong> {{ job.customJobType }}
</div>
</div>
</v-col>
<!-- Right Column -->
<v-col cols="12" md="6">
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-3">Progress</h4>
<div class="mb-3">
<div class="d-flex justify-space-between mb-1">
<span class="text-caption">Completion</span>
<span class="text-caption font-weight-bold">{{ job.percentComplete || 0 }}%</span>
</div>
<v-progress-linear
:model-value="job.percentComplete || 0"
color="success"
height="8"
rounded
></v-progress-linear>
</div>
</div>
<div class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Financial Summary</h4>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-currency-usd</v-icon>
<strong>Total Sales:</strong> ${{ (job.totalSalesAmount || 0).toLocaleString() }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-receipt</v-icon>
<strong>Billed Amount:</strong> ${{ (job.totalBilledAmount || 0).toLocaleString() }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-calculator</v-icon>
<strong>Total Cost:</strong> ${{ (job.totalCostingAmount || 0).toLocaleString() }}
</div>
<div class="detail-row">
<v-icon size="small" class="mr-2">mdi-chart-line</v-icon>
<strong>Gross Margin:</strong> {{ (job.perGrossMargin || 0).toFixed(1) }}%
</div>
<div class="mt-3">
<div class="d-flex justify-space-between mb-1">
<span class="text-caption">Billing Progress</span>
<span class="text-caption font-weight-bold">
${{ (job.totalBilledAmount || 0).toLocaleString() }} / ${{ (job.totalSalesAmount || 0).toLocaleString() }}
</span>
</div>
<v-progress-linear
:model-value="getBillingProgress(job)"
color="primary"
height="8"
rounded
></v-progress-linear>
</div>
</div>
<!-- Map Display -->
<div v-if="hasCoordinates" class="detail-section mb-4">
<h4 class="text-subtitle-1 mb-2">Service Location</h4>
<div class="map-container">
<iframe
:src="mapUrl"
width="100%"
height="200"
frameborder="0"
style="border: 1px solid var(--surface-border); border-radius: 6px;"
allowfullscreen
></iframe>
</div>
</div>
<div v-if="job.notes || job.project?.notes" class="detail-section">
<h4 class="text-subtitle-1 mb-2">Notes</h4>
<div v-if="job.project?.notes" class="mb-3">
<strong>Project Notes:</strong>
<p class="text-body-2 mt-1">{{ job.project.notes }}</p>
</div>
<div v-if="job.notes">
<strong>Job Notes:</strong>
<p class="text-body-2 mt-1">{{ job.notes }}</p>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-btn
color="primary"
variant="flat"
@click="viewJob"
>
<v-icon left>mdi-open-in-new</v-icon>
View Job
</v-btn>
<v-spacer></v-spacer>
<v-btn variant="text" @click="handleClose">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed } from "vue";
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
job: {
type: Object,
default: null,
},
foremen: {
type: Array,
default: () => [],
},
});
// Emits
const emit = defineEmits(["update:modelValue", "close"]);
// Computed
const showModal = computed({
get() {
return props.modelValue;
},
set(value) {
emit("update:modelValue", value);
},
});
const hasCoordinates = computed(() => {
if (!props.job?.serviceAddress) return false;
// Check if service address has coordinates
const lat = props.job.serviceAddress.latitude || props.job.serviceAddress.customLatitude;
const lon = props.job.serviceAddress.longitude || props.job.serviceAddress.customLongitude;
return lat && lon && parseFloat(lat) !== 0 && parseFloat(lon) !== 0;
});
const mapUrl = computed(() => {
if (!hasCoordinates.value) return "";
const lat = parseFloat(props.job.serviceAddress.latitude || props.job.serviceAddress.customLatitude);
const lon = parseFloat(props.job.serviceAddress.longitude || props.job.serviceAddress.customLongitude);
// Using OpenStreetMap embed with marker
return `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.01},${lat - 0.01},${lon + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lon}`;
});
// Methods
const stripAddress = (address) => {
if (!address) return '';
const index = address.indexOf('-#-');
return index > -1 ? address.substring(0, index).trim() : address;
};
const formatDate = (dateStr) => {
if (!dateStr) return 'Not scheduled';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
return dateStr;
}
};
const getCrewName = (foremanId) => {
if (!foremanId) return 'Not assigned';
const foreman = props.foremen.find(f => f.name === foremanId);
if (!foreman) return foremanId;
return foreman.customCrew ? `${foreman.employeeName} (Crew ${foreman.customCrew})` : foreman.employeeName;
};
const getBillingProgress = (job) => {
if (!job.totalSalesAmount || job.totalSalesAmount === 0) return 0;
return Math.min(100, (job.totalBilledAmount / job.totalSalesAmount) * 100);
};
const getPermitStatusColor = (status) => {
if (!status) return 'grey';
if (status.toLowerCase().includes('approved')) return 'success';
if (status.toLowerCase().includes('pending')) return 'warning';
return 'error';
};
const getLocateStatusColor = (status) => {
if (!status) return 'grey';
if (status.toLowerCase().includes('complete')) return 'success';
if (status.toLowerCase().includes('incomplete')) return 'error';
return 'warning';
};
const getPriorityColor = (priority) => {
switch (priority) {
case "urgent":
return "red";
case "high":
return "orange";
case "medium":
return "yellow";
case "low":
return "green";
default:
return "grey";
}
};
const viewJob = () => {
if (props.job?.name) {
window.location.href = `/job?name=${encodeURIComponent(props.job.name)}`;
}
};
const handleClose = () => {
emit("close");
};
</script>
<style scoped>
.detail-section {
background-color: #f8f9fa;
padding: 12px;
border-radius: 8px;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.detail-row:last-child {
margin-bottom: 0;
}
.map-container {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -1,12 +1,11 @@
<template> <template>
<div> <Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose"> <template #title>
<template #title> <div class="modal-header">
<div class="modal-header"> <i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i> Meeting Details
Meeting Details </div>
</div> </template>
</template>
<div v-if="meeting" class="meeting-details"> <div v-if="meeting" class="meeting-details">
<!-- Status Badge --> <!-- Status Badge -->
<div class="status-section"> <div class="status-section">
@ -130,25 +129,14 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
<v-btn <v-btn
v-if="meeting.status === 'Scheduled'" v-if="meeting.status !== 'Completed' && meeting.status !== 'Unscheduled'"
@click="handleMarkComplete" @click="handleMarkComplete"
color="success" color="success"
variant="elevated" variant="elevated"
:loading="isUpdating" :loading="isUpdating"
> >
<v-icon left>mdi-file-edit</v-icon> <v-icon left>mdi-check</v-icon>
Create Notes and Complete Mark as Completed
</v-btn>
<v-btn
v-if="meeting.status === 'Completed' && meeting.bidNotes"
@click="handleViewBidNotes"
color="info"
variant="elevated"
:loading="loadingBidNotes"
>
<v-icon left>mdi-note-text</v-icon>
View Bid Notes
</v-btn> </v-btn>
<v-btn <v-btn
@ -160,98 +148,15 @@
<v-icon left>mdi-file-document-outline</v-icon> <v-icon left>mdi-file-document-outline</v-icon>
Create Estimate Create Estimate
</v-btn> </v-btn>
<v-btn
v-if="meeting.status !== 'Completed'"
@click="showCancelWarning = true"
color="error"
variant="outlined"
>
<v-icon left>mdi-cancel</v-icon>
Cancel Meeting
</v-btn>
</div> </div>
</div> </div>
</Modal> </Modal>
<!-- Bid Notes Modal -->
<Modal
:visible="showBidNotesModal"
@update:visible="showBidNotesModal = $event"
:options="bidNotesModalOptions"
@confirm="handleCloseBidNotes"
>
<template #title>
<div class="modal-header">
<i class="pi pi-file-edit" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
Bid Meeting Notes
</div>
</template>
<BidMeetingNotes v-if="bidNoteData" :bid-note="bidNoteData" />
<div v-else-if="bidNotesError" class="error-message">
<i class="pi pi-exclamation-circle"></i>
<span>{{ bidNotesError }}</span>
</div>
<div v-else class="loading-message">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<span>Loading bid notes...</span>
</div>
</Modal>
<!-- Cancel Meeting Warning Dialog -->
<v-dialog v-model="showCancelWarning" max-width="500px">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon color="error" class="mr-2">mdi-alert</v-icon>
Cancel Bid Meeting?
</v-card-title>
<v-card-text class="pt-4">
<p class="text-body-1 mb-3">
<strong>Warning:</strong> This will permanently cancel this bid meeting.
</p>
<template v-if="meeting?.status === 'Scheduled'">
<p class="text-body-2 mb-3">
If you want to:
</p>
<ul class="text-body-2 mb-3">
<li><strong>Reschedule:</strong> Drag and drop the meeting to a different time slot</li>
<li><strong>Unschedule:</strong> Drag the meeting back to the unscheduled section</li>
</ul>
<p class="text-body-2 mb-2">
<strong>Note:</strong> Cancelling permanently marks the meeting as cancelled, which is different from rescheduling or unscheduling it.
</p>
</template>
<p class="text-body-1 font-weight-bold">
Are you sure you want to proceed with canceling this meeting?
</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
variant="text"
@click="showCancelWarning = false"
>
No, Keep Meeting
</v-btn>
<v-btn
color="error"
variant="elevated"
@click="handleCancelMeeting"
:loading="isCanceling"
>
Yes, Cancel Meeting
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import Modal from "../common/Modal.vue"; import Modal from "../common/Modal.vue";
import BidMeetingNotes from "./BidMeetingNotes.vue";
import Api from "../../api"; import Api from "../../api";
import { useNotificationStore } from "../../stores/notifications-primevue"; import { useNotificationStore } from "../../stores/notifications-primevue";
@ -271,16 +176,10 @@ const props = defineProps({
}); });
// Emits // Emits
const emit = defineEmits(["update:visible", "close", "meetingUpdated", "completeMeeting"]); const emit = defineEmits(["update:visible", "close", "meetingUpdated"]);
// Local state // Local state
const isUpdating = ref(false); const isUpdating = ref(false);
const showBidNotesModal = ref(false);
const bidNoteData = ref(null);
const loadingBidNotes = ref(false);
const bidNotesError = ref(null);
const showCancelWarning = ref(false);
const isCanceling = ref(false);
const showModal = computed({ const showModal = computed({
get() { get() {
@ -299,13 +198,6 @@ const modalOptions = computed(() => ({
confirmButtonColor: "primary", confirmButtonColor: "primary",
})); }));
const bidNotesModalOptions = computed(() => ({
maxWidth: "1000px",
showCancelButton: false,
confirmButtonText: "Close",
confirmButtonColor: "primary",
}));
// Computed properties for data extraction // Computed properties for data extraction
const customerName = computed(() => { const customerName = computed(() => {
if (props.meeting?.address?.customerName) { if (props.meeting?.address?.customerName) {
@ -377,20 +269,34 @@ const handleClose = () => {
const handleMarkComplete = async () => { const handleMarkComplete = async () => {
if (!props.meeting?.name) return; if (!props.meeting?.name) return;
// Check if meeting has a project template try {
if (!props.meeting.projectTemplate) { isUpdating.value = true;
await Api.updateBidMeeting(props.meeting.name, {
status: "Completed",
});
notificationStore.addNotification({ notificationStore.addNotification({
type: "warning", type: "success",
title: "Missing Project Template", title: "Meeting Completed",
message: "This meeting requires a project template to create notes.", message: "The meeting has been marked as completed.",
duration: 4000,
});
// Emit event to refresh the calendar
emit("meetingUpdated");
handleClose();
} catch (error) {
console.error("Error marking meeting as complete:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to update meeting status.",
duration: 5000, duration: 5000,
}); });
return; } finally {
isUpdating.value = false;
} }
// Open the note form modal
emit("completeMeeting", props.meeting);
handleClose();
}; };
const handleCreateEstimate = () => { const handleCreateEstimate = () => {
@ -407,7 +313,7 @@ const handleCreateEstimate = () => {
new: "true", new: "true",
address: addressText, address: addressText,
"from-meeting": fromMeeting, "from-meeting": fromMeeting,
"project-template": template, template: template,
contact: contactName, contact: contactName,
}, },
}); });
@ -430,81 +336,6 @@ const formatDateTime = (dateString) => {
day: "numeric", day: "numeric",
}); });
}; };
const handleViewBidNotes = async () => {
if (!props.meeting?.bidNotes) return;
try {
loadingBidNotes.value = true;
bidNotesError.value = null;
bidNoteData.value = null;
// Fetch the bid meeting note
const noteData = await Api.getBidMeetingNote(props.meeting.bidNotes);
if (!noteData) {
throw new Error("Failed to load bid notes");
}
bidNoteData.value = noteData;
showBidNotesModal.value = true;
} catch (error) {
console.error("Error loading bid notes:", error);
bidNotesError.value = error.message || "Failed to load bid notes";
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to load bid notes. Please try again.",
duration: 5000,
});
} finally {
loadingBidNotes.value = false;
}
};
const handleCloseBidNotes = () => {
showBidNotesModal.value = false;
bidNoteData.value = null;
bidNotesError.value = null;
};
const handleCancelMeeting = async () => {
if (!props.meeting?.name) return;
try {
isCanceling.value = true;
// Update the meeting status to Cancelled
await Api.updateBidMeeting(props.meeting.name, {
status: "Cancelled",
});
showCancelWarning.value = false;
notificationStore.addNotification({
type: "success",
title: "Meeting Cancelled",
message: "The bid meeting has been cancelled successfully.",
duration: 5000,
});
// Emit meeting updated event to refresh the calendar
emit("meetingUpdated");
// Close the modal
handleClose();
} catch (error) {
console.error("Error canceling meeting:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to cancel meeting. Please try again.",
duration: 5000,
});
} finally {
isCanceling.value = false;
}
};
</script> </script>
<style scoped> <style scoped>
@ -546,24 +377,5 @@ const handleCancelMeeting = async () => {
padding-top: 16px; padding-top: 16px;
border-top: 2px solid #e0e0e0; border-top: 2px solid #e0e0e0;
justify-content: center; justify-content: center;
flex-wrap: wrap;
}
.error-message,
.loading-message {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
color: #666;
}
.error-message {
color: #f44336;
}
.error-message i {
font-size: 1.5rem;
} }
</style> </style>

View file

@ -7,7 +7,4 @@ import CalendarNavigation from '@/components/calendar/CalendarNavigation.vue'
</script> </script>
<style scoped> <style scoped>
:deep(.calendar-navigation) {
height: 100%;
}
</style> </style>

View file

@ -1,79 +1,31 @@
<template> <template>
<div class="client-page"> <div class="client-page">
<!-- Client Header --> <!-- Client Header -->
<GeneralClientInfo <TopBar :selectedAddressIdx="selectedAddressIdx" :client="client" :nextVisitDate="nextVisitDate" v-if="client.customerName" @update:selectedAddressIdx="selectedAddressIdx = $event" />
v-if="client.customerName"
:client-data="client"
/>
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" /> <AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
<!-- Address Selector (only shows if multiple addresses) --> <Tabs value="0">
<AddressSelector
v-if="!isNew && client.addresses && client.addresses.length > 1"
:addresses="client.addresses"
:selected-address-idx="selectedAddressIdx"
:contacts="client.contacts"
@update:selected-address-idx="handleAddressChange"
/>
<!-- Main Content Tabs -->
<Tabs value="0" class="overview-tabs">
<TabList> <TabList>
<Tab value="0">Overview</Tab> <Tab value="0">Overview</Tab>
<Tab value="1">Projects</Tab> <Tab value="1">Projects <span class="tab-info-alert">1</span></Tab>
<Tab value="2">Financials</Tab> <Tab value="2">Financials</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<!-- Overview Tab -->
<TabPanel value="0"> <TabPanel value="0">
<Overview <Overview
:selected-address="selectedAddressData" :client-data="client"
:all-contacts="client.contacts" :selected-address="selectedAddress"
:edit-mode="editMode"
:is-new="isNew" :is-new="isNew"
:full-address="fullAddress"
:client="client"
@edit-mode-enabled="enableEditMode"
@update:address-contacts="handleAddressContactsUpdate"
@update:primary-contact="handlePrimaryContactUpdate"
@update:client="handleClientUpdate"
/> />
</TabPanel> </TabPanel>
<!-- Projects Tab -->
<TabPanel value="1"> <TabPanel value="1">
<div class="coming-soon-section"> <div id="projects-tab"><h3>Project Status</h3></div>
<i class="pi pi-wrench"></i>
<h3>Projects</h3>
<p>Section coming soon</p>
</div>
</TabPanel> </TabPanel>
<!-- Financials Tab -->
<TabPanel value="2"> <TabPanel value="2">
<div class="coming-soon-section"> <div id="financials-tab"><h3>Accounting</h3></div>
<i class="pi pi-dollar"></i>
<h3>Financials</h3>
<p>Section coming soon</p>
</div>
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
<!-- Form Actions (for edit mode or new client) -->
<div class="form-actions" v-if="editMode || isNew">
<Button
@click="handleCancel"
label="Cancel"
severity="secondary"
:disabled="isSubmitting"
/>
<Button
@click="handleSubmit"
:label="isNew ? 'Create Client' : 'Save Changes'"
:loading="isSubmitting"
/>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
@ -83,25 +35,21 @@ import TabList from "primevue/tablist";
import Tab from "primevue/tab"; import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels"; import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel"; import TabPanel from "primevue/tabpanel";
import Button from "primevue/button";
import Api from "../../api"; import Api from "../../api";
import { useRoute, useRouter } from "vue-router"; import { useRoute } from "vue-router";
import { useLoadingStore } from "../../stores/loading"; import { useLoadingStore } from "../../stores/loading";
import { useNotificationStore } from "../../stores/notifications-primevue"; import { useNotificationStore } from "../../stores/notifications-primevue";
import { useCompanyStore } from "../../stores/company";
import DataUtils from "../../utils"; import DataUtils from "../../utils";
import AddressSelector from "../clientView/AddressSelector.vue"; import Overview from "../clientSubPages/Overview.vue";
import GeneralClientInfo from "../clientView/GeneralClientInfo.vue"; import ProjectStatus from "../clientSubPages/ProjectStatus.vue";
import TopBar from "../clientView/TopBar.vue";
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue"; import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
import Overview from "../clientView/Overview.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const companyStore = useCompanyStore();
const address = (route.query.address || '').trim(); const address = route.query.address || null;
const clientName = route.query.client || null; const clientName = route.query.client || null;
const isNew = computed(() => route.query.new === "true" || false); const isNew = computed(() => route.query.new === "true" || false);
@ -118,17 +66,13 @@ const selectedAddressObject = computed(() =>
); );
const addresses = computed(() => { const addresses = computed(() => {
if (client.value && client.value.addresses) { if (client.value && client.value.addresses) {
return client.value.addresses.map((addr) => DataUtils.calculateFullAddress(addr).trim()); return client.value.addresses.map((addr) => DataUtils.calculateFullAddress(addr));
} }
return []; return [];
}); });
const nextVisitDate = ref(null); // Placeholder, update as needed const nextVisitDate = ref(null); // Placeholder, update as needed
// Tab and edit state
const editMode = ref(false);
const isSubmitting = ref(false);
const selectedAddressIdx = computed({ const selectedAddressIdx = computed({
get: () => addresses.value.indexOf(selectedAddress.value), get: () => addresses.value.indexOf(selectedAddress.value),
set: (idx) => { set: (idx) => {
@ -138,22 +82,6 @@ const selectedAddressIdx = computed({
} }
}); });
// Find the address data object that matches the selected address string
const selectedAddressData = computed(() => {
if (!client.value?.addresses || !selectedAddress.value) {
return null;
}
return client.value.addresses.find(
(addr) => DataUtils.calculateFullAddress(addr).trim() === selectedAddress.value.trim()
);
});
// Calculate full address for display
const fullAddress = computed(() => {
if (!selectedAddressData.value) return "N/A";
return DataUtils.calculateFullAddress(selectedAddressData.value);
});
const getClientNames = async (type) => { const getClientNames = async (type) => {
loadingStore.setLoading(true); loadingStore.setLoading(true);
try { try {
@ -174,11 +102,10 @@ const getClient = async (name) => {
// Set initial selected address if provided in route or use first address // Set initial selected address if provided in route or use first address
if (address && client.value.addresses) { if (address && client.value.addresses) {
const fullAddresses = client.value.addresses.map((addr) => const fullAddresses = client.value.addresses.map((addr) =>
DataUtils.calculateFullAddress(addr).trim(), DataUtils.calculateFullAddress(addr),
); );
const trimmedAddress = address.trim(); if (fullAddresses.includes(address)) {
if (fullAddresses.includes(trimmedAddress)) { selectedAddress.value = address;
selectedAddress.value = trimmedAddress;
} else if (fullAddresses.length > 0) { } else if (fullAddresses.length > 0) {
selectedAddress.value = fullAddresses[0]; selectedAddress.value = fullAddresses[0];
} }
@ -196,16 +123,6 @@ const getClient = async (name) => {
} else if (selectedAddress.value) { } else if (selectedAddress.value) {
// geocode.value = await Api.getGeocode(selectedAddress.value); // geocode.value = await Api.getGeocode(selectedAddress.value);
} }
// Check if client is associated with current company
if (companyStore.currentCompany && client.value.companies) {
const clientHasCompany = client.value.companies.some(company => company.company === companyStore.currentCompany);
if (!clientHasCompany) {
notificationStore.addWarning(
`The selected company is not linked to this client.`,
);
}
}
} catch (error) { } catch (error) {
console.error("Error fetching client data in Client.vue: ", error.message || error); console.error("Error fetching client data in Client.vue: ", error.message || error);
} finally { } finally {
@ -256,100 +173,6 @@ watch(
} }
}, },
); );
watch(
() => companyStore.currentCompany,
(newCompany) => {
console.log("############# Company changed to:", newCompany);
if (!newCompany || !client.value.customerName) return;
// Check if client is associated with the company
let clientHasCompany = false;
if (client.value.companies) {
clientHasCompany = client.value.companies.some(company => company.company === newCompany);
}
// Check if selected address is associated with the company
let addressHasCompany = false;
if (selectedAddressData.value?.companies) {
addressHasCompany = selectedAddressData.value.companies.some(company => company.company === newCompany);
}
// Show warnings for missing associations
if (!clientHasCompany) {
notificationStore.addWarning(
`The selected company is not linked to this client.`,
);
} else if (!addressHasCompany) {
notificationStore.addWarning(
`The selected company is not linked to this address.`,
);
}
}
)
// Handle address change
const handleAddressChange = (newIdx) => {
selectedAddressIdx.value = newIdx;
// TODO: Update route query with new address
};
// Enable edit mode
const enableEditMode = () => {
editMode.value = true;
};
// Handle cancel edit or new
const handleCancel = () => {
if (isNew.value) {
// For new client, clear the form data
client.value = {};
} else {
editMode.value = false;
// Restore original data if editing
}
};
// Handle save edit or create new
const handleSubmit = async () => {
isSubmitting.value = true;
try {
if (isNew.value) {
const createdClient = await Api.createClient(client.value);
console.log("Created client:", createdClient);
notificationStore.addSuccess("Client created successfully!");
stripped_name = createdClient.customerName.split("-#-")[0].trim();
// Navigate to the created client
router.push('/client?client=' + encodeURIComponent(stripped_name));
} else {
// TODO: Implement save logic
notificationStore.addSuccess("Changes saved successfully!");
editMode.value = false;
}
} catch (error) {
console.error("Error submitting:", error);
notificationStore.addError(isNew.value ? "Failed to create client" : "Failed to save changes");
} finally {
isSubmitting.value = false;
}
};
// Handle address contacts update
const handleAddressContactsUpdate = (contactNames) => {
console.log("Address contacts updated:", contactNames);
// TODO: Store this for saving
};
// Handle primary contact update
const handlePrimaryContactUpdate = (contactName) => {
console.log("Primary contact updated:", contactName);
// TODO: Store this for saving
};
// Handle client update from forms
const handleClientUpdate = (newClientData) => {
client.value = { ...client.value, ...newClientData };
};
</script> </script>
<style lang="css"> <style lang="css">
.tab-info-alert { .tab-info-alert {
@ -361,63 +184,4 @@ const handleClientUpdate = (newClientData) => {
padding-top: 2px; padding-top: 2px;
padding-bottom: 2px; padding-bottom: 2px;
} }
.overview-tabs {
margin-bottom: 1rem;
}
.coming-soon-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem 2rem;
text-align: center;
background: var(--surface-card);
border-radius: 12px;
border: 1px solid var(--surface-border);
}
.coming-soon-section i {
font-size: 4rem;
color: var(--text-color-secondary);
opacity: 0.5;
}
.coming-soon-section h3 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--text-color);
}
.coming-soon-section p {
margin: 0;
font-size: 1.1rem;
color: var(--text-color-secondary);
}
.client-page {
padding-bottom: 5rem; /* Add padding to prevent content from being hidden behind fixed buttons */
}
.form-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
background: var(--surface-card);
border-radius: 0;
border: 1px solid var(--surface-border);
border-bottom: none;
border-left: none;
border-right: none;
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
</style> </style>

View file

@ -29,12 +29,10 @@ import { useFiltersStore } from "../../stores/filters";
import { useModalStore } from "../../stores/modal"; import { useModalStore } from "../../stores/modal";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { useNotificationStore } from "../../stores/notifications-primevue"; import { useNotificationStore } from "../../stores/notifications-primevue";
import { useCompanyStore } from "../../stores/company";
import TodoChart from "../common/TodoChart.vue"; import TodoChart from "../common/TodoChart.vue";
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch, import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit, ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
WateringSoil, Soil, Truck, SoilAlt, WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
Filter} from "@iconoir/vue";
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
@ -43,7 +41,6 @@ const filtersStore = useFiltersStore();
const modalStore = useModalStore(); const modalStore = useModalStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const companyStore = useCompanyStore();
const tableData = ref([]); const tableData = ref([]);
const totalRecords = ref(0); const totalRecords = ref(0);
@ -53,26 +50,10 @@ const currentWeekParams = ref({});
const chartLoading = ref(true); // Start with loading state const chartLoading = ref(true); // Start with loading state
const lookup = route.query.lookup; const lookup = route.query.lookup;
const lastLazyLoadEvent = ref(null);
// Watch for company changes to reload data
watch(
() => companyStore.currentCompany,
async () => {
console.log("Company changed, reloading client data...");
if (lastLazyLoadEvent.value) {
await handleLazyLoad(lastLazyLoadEvent.value);
}
// Also refresh status counts
await refreshStatusCounts();
}
);
// Computed property to get current filters for the chart // Computed property to get current filters for the chart
const currentFilters = computed(() => { const currentFilters = computed(() => {
filters = { ...filtersStore.getTableFilters("clients"), return filtersStore.getTableFilters("clients");
company: { value: companyStore.currentCompany, matchMode: FilterMatchMode.CONTAINS}
};
}); });
// Handle week change from chart // Handle week change from chart
@ -239,7 +220,6 @@ const tableActions = [
// Handle lazy loading events from DataTable // Handle lazy loading events from DataTable
const handleLazyLoad = async (event) => { const handleLazyLoad = async (event) => {
console.log("Clients page - handling lazy load:", event); console.log("Clients page - handling lazy load:", event);
lastLazyLoadEvent.value = event;
try { try {
isLoading.value = true; isLoading.value = true;
@ -283,9 +263,8 @@ const handleLazyLoad = async (event) => {
filters, filters,
sortingArray, sortingArray,
}); });
filters["company"] = { value: companyStore.currentCompany, matchMode: FilterMatchMode.CONTAINS};
const result = await Api.getPaginatedClientDetailsV2( const result = await Api.getPaginatedClientDetails(
paginationParams, paginationParams,
filters, filters,
sortingArray, sortingArray,

View file

@ -381,34 +381,6 @@
</div> </div>
</Modal> </Modal>
<!-- Bid Meeting Notes Side Tab -->
<Button v-if="bidMeeting?.bidNotes" class="bid-notes-side-tab" @click="onTabClick">
<div class="tab-content">
<i class="pi pi-file-edit"></i>
<span class="tab-text">Bid Notes</span>
</div>
</Button>
<!-- Bid Meeting Notes Drawer -->
<Drawer
:visible="showDrawer"
@update:visible="showDrawer = $event"
position="right"
:style="{ width: '1200px' }"
@hide="showDrawer = false"
class="bid-notes-drawer"
>
<template #header>
<div class="drawer-header">
<h3>Bid Meeting Notes</h3>
<Button icon="pi pi-times" @click="showDrawer = false" text rounded />
</div>
</template>
<div class="drawer-content">
<BidMeetingNotes v-if="bidMeeting?.bidNotes" :bid-note="bidMeeting.bidNotes" />
</div>
</Drawer>
</div> </div>
</template> </template>
@ -419,13 +391,11 @@ import Modal from "../common/Modal.vue";
import SaveTemplateModal from "../modals/SaveTemplateModal.vue"; import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
import DataTable from "../common/DataTable.vue"; import DataTable from "../common/DataTable.vue";
import DocHistory from "../common/DocHistory.vue"; import DocHistory from "../common/DocHistory.vue";
import BidMeetingNotes from "../modals/BidMeetingNotes.vue";
import InputText from "primevue/inputtext"; 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 Tooltip from "primevue/tooltip";
import Drawer from "primevue/drawer";
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";
@ -444,7 +414,6 @@ const nameQuery = computed(() => route.query.name || "");
const templateQuery = computed(() => route.query.template || ""); const templateQuery = computed(() => route.query.template || "");
const fromMeetingQuery = computed(() => route.query["from-meeting"] || ""); const fromMeetingQuery = computed(() => route.query["from-meeting"] || "");
const contactQuery = computed(() => route.query.contact || ""); const contactQuery = computed(() => route.query.contact || "");
const projectTemplateQuery = computed(() => route.query["project-template"] || "");
const isNew = computed(() => route.query.new === "true"); const isNew = computed(() => route.query.new === "true");
const isSubmitting = ref(false); const isSubmitting = ref(false);
@ -458,7 +427,7 @@ const formData = reactive({
estimateName: null, estimateName: null,
requiresHalfPayment: false, requiresHalfPayment: false,
projectTemplate: null, projectTemplate: null,
fromOnsiteMeeting: null, fromMeeting: null,
}); });
const selectedAddress = ref(null); const selectedAddress = ref(null);
@ -483,10 +452,8 @@ const showResponseModal = ref(false);
const showSaveTemplateModal = ref(false); const showSaveTemplateModal = ref(false);
const addressSearchResults = ref([]); const addressSearchResults = ref([]);
const itemSearchTerm = ref(""); const itemSearchTerm = ref("");
const showDrawer = ref(false);
const estimate = ref(null); const estimate = ref(null);
const bidMeeting = ref(null);
// Computed property to determine if fields are editable // Computed property to determine if fields are editable
const isEditable = computed(() => { const isEditable = computed(() => {
@ -758,7 +725,7 @@ const saveDraft = async () => {
estimateName: formData.estimateName, estimateName: formData.estimateName,
requiresHalfPayment: formData.requiresHalfPayment, requiresHalfPayment: formData.requiresHalfPayment,
projectTemplate: formData.projectTemplate, projectTemplate: formData.projectTemplate,
fromOnsiteMeeting: formData.fromOnsiteMeeting, fromMeeting: formData.fromMeeting,
company: company.currentCompany company: company.currentCompany
}; };
estimate.value = await Api.createEstimate(data); estimate.value = await Api.createEstimate(data);
@ -835,15 +802,6 @@ const toggleDiscountType = (item, type) => {
item.discountType = type; item.discountType = type;
}; };
const onTabClick = () => {
console.log('Bid notes tab clicked');
console.log('Current showDrawer value:', showDrawer.value);
console.log('bidMeeting:', bidMeeting.value);
console.log('bidMeeting?.bidNotes:', bidMeeting.value?.bidNotes);
showDrawer.value = true;
console.log('Set showDrawer to true');
};
const tableActions = [ const tableActions = [
{ {
label: "Add Selected Items", label: "Add Selected Items",
@ -924,8 +882,6 @@ watch(
const newIsNew = newQuery.new === "true"; const newIsNew = newQuery.new === "true";
const newAddressQuery = newQuery.address; const newAddressQuery = newQuery.address;
const newNameQuery = newQuery.name; const newNameQuery = newQuery.name;
const newFromMeetingQuery = newQuery["from-meeting"];
const newProjectTemplateQuery = newQuery["project-template"];
if (newAddressQuery && newIsNew) { if (newAddressQuery && newIsNew) {
// Creating new estimate - pre-fill address // Creating new estimate - pre-fill address
@ -969,15 +925,7 @@ watch(
}; };
}); });
} }
formData.requiresHalfPayment = Boolean(estimate.value.requiresHalfPayment || estimate.value.custom_requires_half_payment); formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false;
// If estimate has fromOnsiteMeeting, fetch bid meeting
if (estimate.value.fromOnsiteMeeting) {
try {
bidMeeting.value = await Api.getBidMeeting(estimate.value.fromOnsiteMeeting);
} catch (error) {
console.error("Error fetching bid meeting for existing estimate:", error);
}
}
} }
} catch (error) { } catch (error) {
console.error("Error loading estimate:", error); console.error("Error loading estimate:", error);
@ -987,35 +935,6 @@ watch(
); );
} }
} }
// Handle from-meeting for new estimates
if (newFromMeetingQuery && newIsNew) {
formData.fromOnsiteMeeting = newFromMeetingQuery;
try {
bidMeeting.value = await Api.getBidMeeting(newFromMeetingQuery);
if (bidMeeting.value?.bidNotes?.quantities) {
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
const item = quotationItems.value.find(i => i.itemCode === q.item);
return {
itemCode: q.item,
itemName: item?.itemName || q.item,
qty: q.quantity,
standardRate: item?.standardRate || 0,
discountAmount: null,
discountPercentage: null,
discountType: 'currency'
};
});
}
} catch (error) {
console.error("Error fetching bid meeting:", error);
}
}
// Handle project-template
if (newProjectTemplateQuery) {
formData.projectTemplate = newProjectTemplateQuery;
}
} }
}, },
{ deep: true } { deep: true }
@ -1035,36 +954,10 @@ onMounted(async () => {
// Handle from-meeting query parameter // Handle from-meeting query parameter
if (fromMeetingQuery.value) { if (fromMeetingQuery.value) {
formData.fromOnsiteMeeting = fromMeetingQuery.value; formData.fromMeeting = fromMeetingQuery.value;
// Fetch the bid meeting to check for bidNotes
try {
bidMeeting.value = await Api.getBidMeeting(fromMeetingQuery.value);
// If new estimate and bid notes have quantities, set default items
if (isNew.value && bidMeeting.value?.bidNotes?.quantities) {
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
const item = quotationItems.value.find(i => i.itemCode === q.item);
return {
itemCode: q.item,
itemName: item?.itemName || q.item,
qty: q.quantity,
standardRate: item?.standardRate || 0,
discountAmount: null,
discountPercentage: null,
discountType: 'currency'
};
});
}
} catch (error) {
console.error("Error fetching bid meeting:", error);
}
} }
} }
// Handle project-template query parameter
if (projectTemplateQuery.value) {
formData.projectTemplate = projectTemplateQuery.value;
}
if (addressQuery.value && isNew.value) { if (addressQuery.value && isNew.value) {
// Creating new estimate - pre-fill address // Creating new estimate - pre-fill address
await selectAddress(addressQuery.value); await selectAddress(addressQuery.value);
@ -1113,15 +1006,7 @@ onMounted(async () => {
}; };
}); });
} }
formData.requiresHalfPayment = Boolean(estimate.value.requiresHalfPayment || estimate.value.custom_requires_half_payment); formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false;
// If estimate has fromOnsiteMeeting, fetch bid meeting
if (estimate.value.fromOnsiteMeeting) {
try {
bidMeeting.value = await Api.getBidMeeting(estimate.value.fromOnsiteMeeting);
} catch (error) {
console.error("Error fetching bid meeting for existing estimate:", error);
}
}
estimateResponse.value = estimate.value.customResponse; estimateResponse.value = estimate.value.customResponse;
estimateResponseSelection.value = estimate.value.customResponse; estimateResponseSelection.value = estimate.value.customResponse;
} }
@ -1149,46 +1034,6 @@ onMounted(async () => {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.bid-notes-side-tab {
position: fixed;
right: -90px;
top: 50%;
transform: translateY(-50%);
background-color: #2196f3;
color: white;
padding: 12px 8px;
width: 110px;
border-radius: 0 4px 4px 0;
cursor: pointer;
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
z-index: 1000;
writing-mode: vertical-rl;
text-orientation: upright;
}
.bid-notes-side-tab:hover {
right: -80px;
background-color: #1976d2;
}
.tab-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.tab-content i {
font-size: 1.1em;
}
.tab-text {
font-weight: 500;
font-size: 0.9em;
white-space: nowrap;
}
.address-section, .address-section,
.contact-section, .contact-section,
.project-template-section, .project-template-section,
@ -1520,29 +1365,5 @@ onMounted(async () => {
font-size: 0.85rem; font-size: 0.85rem;
color: #666; color: #666;
} }
.bid-notes-drawer {
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e0e0e0;
background-color: #f8f9fa;
}
.drawer-header h3 {
margin: 0;
color: #333;
}
.drawer-content {
padding: 1rem;
height: calc(100vh - 80px);
overflow-y: auto;
}
</style> </style>
<parameter name="filePath"></parameter> <parameter name="filePath"></parameter>

View file

@ -15,7 +15,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Incomplete Bids" title="Incomplete Bids"
:categories="chartData.bids" :todoNumber="bidsTodoNumber"
:completedNumber="bidsCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -37,7 +38,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Unapproved Estimates" title="Unapproved Estimates"
:categories="chartData.estimates" :todoNumber="estimatesTodoNumber"
:completedNumber="estimatesCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -59,7 +61,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Half Down Payments" title="Half Down Payments"
:categories="chartData.halfDown" :todoNumber="halfDownTodoNumber"
:completedNumber="halfDownCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -81,7 +84,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Jobs In Queue" title="Jobs In Queue"
:categories="chartData.jobsInQueue" :todoNumber="jobQueueTodoNumber"
:completedNumber="jobQueueCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -128,15 +132,13 @@ import Card from "../common/Card.vue";
import DataTable from "../common/DataTable.vue"; import DataTable from "../common/DataTable.vue";
import TodoChart from "../common/TodoChart.vue"; import TodoChart from "../common/TodoChart.vue";
import { Edit, ChatBubbleQuestion, CreditCard, Hammer } from "@iconoir/vue"; import { Edit, ChatBubbleQuestion, CreditCard, Hammer } from "@iconoir/vue";
import { ref, onMounted, watch } from "vue"; import { ref, onMounted } from "vue";
import Api from "../../api"; import Api from "../../api";
import { useLoadingStore } from "../../stores/loading"; import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination"; import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters"; import { useFiltersStore } from "../../stores/filters";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useCompanyStore } from "../../stores/company";
const companyStore = useCompanyStore();
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore(); const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore(); const filtersStore = useFiltersStore();
@ -146,25 +148,15 @@ const tableData = ref([]);
const totalRecords = ref(0); const totalRecords = ref(0);
const isLoading = ref(false); const isLoading = ref(false);
const showSubmitEstimateModal = ref(true); const showSubmitEstimateModal = ref(true);
const chartData = ref({
bids: {labels: ["Unscheduled"], data: [0], colors: ['red']},
estimates: {labels: ["Draft", "Submitted"], data: [0, 0], colors: ['orange', 'blue']},
halfDown: {labels: ["Unpaid"], data: [0], colors: ['red']},
jobsInQueue: {labels: ["Queued"], data: [0], colors: ['blue']}
});
//Junk //Junk
const filteredItems= [] const filteredItems= []
// End junk // End junk
const columns = [ const columns = [
{ label: "Estimate Address", fieldName: "address", type: "link", sortable: true, filterable:true, { label: "Estimate Address", fieldName: "address", type: "text", sortable: true, filterable: true },
onLinkClick: (link, rowData) => handlePropertyClick(link, rowData),
},
//{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true }, //{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
{ label: "Customer", fieldName: "customer", type: "link", sortable: true, filterable: true, { label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
onLinkClick: (link, rowData) => handleCustomerClick(link, rowData),
},
{ {
label: "Status", label: "Status",
fieldName: "status", fieldName: "status",
@ -202,19 +194,6 @@ const closeSubmitEstimateModal = () => {
showSubmitEstimateModal.value = false; showSubmitEstimateModal.value = false;
}; };
const handleCustomerClick = (link, rowData) => {
console.log("DEBUG: Customer Link Clicked.");
const client = encodeURIComponent(rowData.customer);
const address = encodeURIComponent(rowData.address);
router.push(`/client?client=${client}&address=${address}`);
}
const handlePropertyClick = (link, rowData) => {
console.log("DEBUG: Property Link Clicked.");
const client = encodeURIComponent(rowData.customer);
const address = encodeURIComponent(rowData.address);
router.push(`/property?client=${client}&address=${address}`);
}
const handleLazyLoad = async (event) => { const handleLazyLoad = async (event) => {
console.log("Estimates page - handling lazy load:", event); console.log("Estimates page - handling lazy load:", event);
@ -235,7 +214,7 @@ const handleLazyLoad = async (event) => {
}; };
// Get filters (convert PrimeVue format to API format) // Get filters (convert PrimeVue format to API format)
const filters = {company: companyStore.currentCompany}; const filters = {};
if (event.filters) { if (event.filters) {
Object.keys(event.filters).forEach((key) => { Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) { if (key !== "global" && event.filters[key] && event.filters[key].value) {
@ -281,15 +260,6 @@ const handleLazyLoad = async (event) => {
} }
}; };
const loadChartData = async () => {
chartData.value.bids.data = await Api.getIncompleteBidsCount(companyStore.currentCompany);
chartData.value.estimates.data = await Api.getUnapprovedEstimatesCount(companyStore.currentCompany);
chartData.value.halfDown.data = await Api.getEstimatesHalfDownCount(companyStore.currentCompany);
chartData.value.jobsInQueue.data = await Api.getJobsInQueueCount(companyStore.currentCompany);
};
// Load initial data // Load initial data
onMounted(async () => { onMounted(async () => {
// Initialize pagination and filters // Initialize pagination and filters
@ -312,20 +282,6 @@ onMounted(async () => {
}); });
}); });
// watch the company store and refetch data when it changes
watch(
() => companyStore.currentCompany,
async (newCompany, oldCompany) => {
console.log("Company changed from", oldCompany, "to", newCompany, "- refetching estimates data.");
await handleLazyLoad({
page: paginationStore.getTablePagination("estimates").page,
rows: paginationStore.getTablePagination("estimates").rows,
first: paginationStore.getTablePagination("estimates").first,
filters: filtersStore.getTableFilters("estimates"),
});
}
);
</script> </script>
<style lang="css"> <style lang="css">
.widgets-grid { .widgets-grid {

View file

@ -15,10 +15,11 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Locates" title="Locates"
:categories="chartData.locates" :todoNumber="locatesTodoNumber"
:completedNumber="locatesCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Locate')"> <button class="sidebar-button" @click="navigateTo('/jobs')">
View Locates View Locates
</button> </button>
</div> </div>
@ -36,10 +37,11 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Permits" title="Permits"
:categories="chartData.permits" :todoNumber="permitsTodoNumber"
:completedNumber="permitsCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Permit(s)')"> <button class="sidebar-button" @click="navigateTo('/jobs')">
View Permits View Permits
</button> </button>
</div> </div>
@ -57,10 +59,11 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Permit Finalization" title="Permit Finalization"
:categories="chartData.permitFinalizations" :todoNumber="permitFinalizationsTodoNumber"
:completedNumber="permitFinalizationsCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Close-out')"> <button class="sidebar-button" @click="navigateTo('/jobs')">
View Finalizations View Finalizations
</button> </button>
</div> </div>
@ -78,10 +81,11 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Warranty Claims" title="Warranty Claims"
:categories="chartData.warranties" :todoNumber="warrantyTodoNumber"
:completedNumber="warrantyCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" @click="navigateTo('/warranties')"> <button class="sidebar-button" @click="navigateTo('/jobs')">
View Warranties View Warranties
</button> </button>
</div> </div>
@ -99,7 +103,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Incomplete Bids" title="Incomplete Bids"
:categories="chartData.bids" :todoNumber="bidsTodoNumber"
:completedNumber="bidsCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -121,7 +126,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Unapproved Estimates" title="Unapproved Estimates"
:categories="chartData.estimates" :todoNumber="estimatesTodoNumber"
:completedNumber="estimatesCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -143,11 +149,12 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Half Down Payments" title="Half Down Payments"
:categories="chartData.halfDown" :todoNumber="halfDownTodoNumber"
:completedNumber="halfDownCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@click="navigateTo('/invoices')"> @click="navigateTo('/jobs')">
Half Down Payments Half Down Payments
</button> </button>
</div> </div>
@ -165,11 +172,12 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="15 Day Follow Ups" title="15 Day Follow Ups"
:categories="chartData.fifteenDayFollowups" :todoNumber="fifteenDayTodoNumber"
:completedNumber="fifteenDayCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@click="navigateTo('/tasks?subject=15-Day')"> @click="navigateTo('/calendar')">
View Follow Ups View Follow Ups
</button> </button>
</div> </div>
@ -187,7 +195,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Late Balances" title="Late Balances"
:categories="chartData.lateBalances" :todoNumber="balancesTodoNumber"
:completedNumber="balancesCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -209,11 +218,12 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Backflow Tests" title="Backflow Tests"
:categories="chartData.backflows" :todoNumber="backflowsTodoNumber"
:completedNumber="backflowsCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@click="navigateTo('/tasks?subject=backflow')"> @click="navigateTo('/jobs')">
Late Balances Late Balances
</button> </button>
</div> </div>
@ -231,11 +241,12 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Curbing" title="Curbing"
:categories="chartData.curbing" :todoNumber="curbingTodoNumber"
:completedNumber="curbingCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@click="navigateTo('/tasks?subject=Curbing')"> @click="navigateTo('/jobs')">
Curbing Curbing
</button> </button>
</div> </div>
@ -253,11 +264,12 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Hydroseeding" title="Hydroseeding"
:categories="chartData.hydroseed" :todoNumber="hydroseedingTodoNumber"
:completedNumber="hydroseedingCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@click="navigateTo('/tasks?subject=Hydroseed')"> @click="navigateTo('/jobs')">
Hydroseeding Hydroseeding
</button> </button>
</div> </div>
@ -275,11 +287,12 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Machines" title="Machines"
:categories="chartData.machines" :todoNumber="machinesTodoNumber"
:completedNumber="machinesCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@click="navigateTo('/tasks?subject=machines')"> @click="navigateTo('/jobs')">
Machines Machines
</button> </button>
</div> </div>
@ -297,11 +310,12 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Deliveries" title="Deliveries"
:categories="chartData.deliveries" :todoNumber="deliveriesTodoNumber"
:completedNumber="delivieriesCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@click="navigateTo('/tasks?subject=machines')"> @click="navigateTo('/jobs')">
Deliveries Deliveries
</button> </button>
</div> </div>
@ -312,7 +326,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
//import Card from "primevue/card"; //import Card from "primevue/card";
import Card from "../common/Card.vue"; import Card from "../common/Card.vue";
@ -320,48 +334,26 @@ import Tag from "primevue/tag";
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch, import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit, ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue"; WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
import Api from "../../api.js";
import DataUtils from "../../utils.js"; import DataUtils from "../../utils.js";
import { useNotificationStore } from "../../stores/notifications-primevue"; import { useNotificationStore } from "../../stores/notifications-primevue";
import { useCompanyStore } from "../../stores/company.js";
//import SimpleChart from "../common/SimpleChart.vue"; //import SimpleChart from "../common/SimpleChart.vue";
import TodoChart from "../common/TodoChart.vue"; import TodoChart from "../common/TodoChart.vue";
const router = useRouter(); const router = useRouter();
const defaultColors = ['blue', 'green', 'red'];
// Dummy data from utils // Dummy data from utils
const clientData = ref(DataUtils.dummyClientData); const clientData = ref(DataUtils.dummyClientData);
const jobData = ref(DataUtils.dummyJobData); const jobData = ref(DataUtils.dummyJobData);
const locatesTodoNumber = ref(0); const locatesTodoNumber = ref(45);
const locatesCompletedNumber = ref(0); const locatesCompletedNumber = ref(5);
const permitsTodoNumber = ref(0); const permitsTodoNumber = ref(24);
const permitsCompletedNumber = ref(0); const permitsCompletedNumber = ref(7);
const permitFinalizationsTodoNumber = ref(35); const permitFinalizationsTodoNumber = ref(35);
const permitFinalizationsCompletedNumber = ref(2); const permitFinalizationsCompletedNumber = ref(2);
const warrantyTodoNumber = ref(0); const warrantyTodoNumber = ref(0);
const warrantyCompletedNumber = ref(10); const warrantyCompletedNumber = ref(10);
const chartData = ref({
locates: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
permits: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
curbing: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
hydroseed: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
permitFinalizations: {labels: ["Todo", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
warranties: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
fifteenDayFollowups: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
lateBalances: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
backflows: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
machines: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
deliveries: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
bids: {labels: ["Unscheduled"], data: [0], colors: ['red']},
estimates: {labels: ["Draft", "Submitted"], data: [0, 0], colors: ['orange', 'blue']},
halfDown: {labels: ["Unpaid"], data: [0], colors: ['red']},
});
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const companyStore = useCompanyStore();
// Computed values for dashboard metrics // Computed values for dashboard metrics
const totalRevenue = computed(() => "$47,250"); const totalRevenue = computed(() => "$47,250");
@ -373,31 +365,9 @@ const avgResponseTime = computed(() => 2.3);
const navigateTo = (path) => { const navigateTo = (path) => {
router.push(path); router.push(path);
}; };
onMounted(() => {
const loadChartData = async() => {
chartData.value.locates.data = await Api.getTasksDue("Locate", companyStore.currentCompany);
chartData.value.permits.data = await Api.getTasksDue("Permit(s)", companyStore.currentCompany);
chartData.value.curbing.data = await Api.getTasksDue("Curbing", companyStore.currentCompany);
chartData.value.hydroseed.data = await Api.getTasksDue("Hydroseed", companyStore.currentCompany);
chartData.value.permitFinalizations.data = await Api.getTasksDue("Permit Close-out", companyStore.currentCompany);
chartData.value.warranties.data = await Api.getTasksDue("Warranty", companyStore.currentCompany);
chartData.value.fifteenDayFollowups.data = await Api.getTasksDue("15-Day QA", companyStore.currentCompany);
chartData.value.backflows.data = await Api.getTasksDue("Backflow", companyStore.currentCompany);
//Uncomment below when we can check if half-down payments have/can been paid
//chartData.value.estimates.data = await Api.getEstimatesHalfDownCount();
chartData.value.bids.data = await Api.getIncompleteBidsCount(companyStore.currentCompany);
chartData.value.estimates.data = await Api.getUnapprovedEstimatesCount(companyStore.currentCompany);
};
onMounted(async() => {
notifications.addWarning("Dashboard metrics are based on dummy data for demonstration purposes. UPDATES COMING SOON!"); notifications.addWarning("Dashboard metrics are based on dummy data for demonstration purposes. UPDATES COMING SOON!");
await loadChartData();
}); });
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
await loadChartData();
});
</script> </script>
<style scoped> <style scoped>

View file

@ -15,7 +15,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Ready To Invoice" title="Ready To Invoice"
:categories="chartData.jobsToInvoice" :todoNumber="invoiceTodoNumber"
:completedNumber="invoiceCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -37,7 +38,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Late Balances" title="Late Balances"
:categories="chartData.invoicesLate" :todoNumber="balancesTodoNumber"
:completedNumber="balancesCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -63,28 +65,21 @@
import Card from "../common/Card.vue"; import Card from "../common/Card.vue";
import DataTable from "../common/DataTable.vue"; import DataTable from "../common/DataTable.vue";
import TodoChart from "../common/TodoChart.vue"; import TodoChart from "../common/TodoChart.vue";
import { ref, onMounted, watch } from "vue"; import { ref, onMounted } from "vue";
import Api from "../../api"; import Api from "../../api";
import { useLoadingStore } from "../../stores/loading"; import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination"; import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters"; import { useFiltersStore } from "../../stores/filters";
import { useCompanyStore } from "../../stores/company.js";
import { CardNoAccess, CalendarCheck } from "@iconoir/vue"; import { CardNoAccess, CalendarCheck } from "@iconoir/vue";
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore(); const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore(); const filtersStore = useFiltersStore();
const companyStore = useCompanyStore();
const tableData = ref([]); const tableData = ref([]);
const totalRecords = ref(0); const totalRecords = ref(0);
const isLoading = ref(false); const isLoading = ref(false);
const chartData = ref({
jobsToInvoice: {labels: ["Ready To Invoice"], data: [0], colors: ['green']},
invoicesLate: {labels: ["Due", "30 Days", "60 Days", "80 Days"], data: [0, 0, 0, 0], colors: ["blue", "yellow", "orange", "red"]}
})
const columns = [ const columns = [
{ label: "Customer Address", fieldName: "address", type: "text", sortable: true }, { label: "Customer Address", fieldName: "address", type: "text", sortable: true },
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true }, { label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
@ -173,12 +168,6 @@ const handleLazyLoad = async (event) => {
} }
}; };
// Load Chart Data
const loadChartData = async () => {
chartData.value.jobsToInvoice.data = await Api.getJobsToInvoiceCount(companyStore.currentCompany);
chartData.value.invoicesLate.data = await Api.getInvoicesLateCount(companyStore.currentCompany);
};
// Load initial data // Load initial data
onMounted(async () => { onMounted(async () => {
// Initialize pagination and filters // Initialize pagination and filters
@ -199,12 +188,6 @@ onMounted(async () => {
sortOrder: initialSorting.order || initialPagination.sortOrder, sortOrder: initialSorting.order || initialPagination.sortOrder,
filters: initialFilters, filters: initialFilters,
}); });
await loadChartData();
});
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
await loadChartData();
}); });
</script> </script>

View file

@ -13,7 +13,7 @@
</template> </template>
<template #content> <template #content>
<div class="widget-content"> <div class="widget-content">
{{ job.jobAddress["fullAddress"] || "" }} {{ job.customInstallationAddress || "" }}
</div> </div>
</template> </template>
</Card> </Card>
@ -32,26 +32,6 @@
</template> </template>
</Card> </Card>
</div> </div>
<div class="job-info">
<Card>
<template #header>
<div class="widget-header">
<h3>Job Status</h3>
</div>
</template>
<template #content>
<div class="widget-content">
Job is {{ job.status }}.
<button
class="sidebar-button"
@click="createInvoiceForJob()"
>
Create Invoice
</button>
</div>
</template>
</Card>
</div>
</div> </div>
<div class="task-list"> <div class="task-list">
<DataTable <DataTable
@ -86,7 +66,7 @@ const notifications = useNotificationStore();
const route = useRoute(); const route = useRoute();
const jobIdQuery = computed(() => route.query.name || ""); const jobIdQuery = computed(() => route.query.jobId || "");
const isNew = computed(() => route.query.new === "true"); const isNew = computed(() => route.query.new === "true");
const tableData = ref([]); const tableData = ref([]);
@ -144,11 +124,6 @@ const tableActions = computed(() => [
}, },
]); ]);
const createInvoiceForJob = async () => {
console.log(job);
await Api.createInvoiceForJob(job.value.name);
}
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 {

View file

@ -15,7 +15,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Jobs In Queue" title="Jobs In Queue"
:categories="chartData.jobsInQueue" :todoNumber="jobQueueTodoNumber"
:completedNumber="jobQueueCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -37,7 +38,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Jobs in Progress" title="Jobs in Progress"
:categories="chartData.jobsInProgress" :todoNumber="progressTodoNumber"
:completedNumber="progressCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -59,7 +61,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Late Jobs" title="Late Jobs"
:categories="chartData.jobsLate" :todoNumber="lateTodoNumber"
:completedNumber="lateCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -81,7 +84,8 @@
<div class="widget-content"> <div class="widget-content">
<TodoChart <TodoChart
title="Ready To Invoice" title="Ready To Invoice"
:categories="chartData.jobsToInvoice" :todoNumber="invoiceTodoNumber"
:completedNumber="invoiceCompletedNumber"
> >
</TodoChart> </TodoChart>
<button class="sidebar-button" <button class="sidebar-button"
@ -108,39 +112,29 @@
import Card from "../common/Card.vue"; import Card from "../common/Card.vue";
import DataTable from "../common/DataTable.vue"; import DataTable from "../common/DataTable.vue";
import TodoChart from "../common/TodoChart.vue"; import TodoChart from "../common/TodoChart.vue";
import { ref, onMounted, watch } from "vue"; import { ref, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import Api from "../../api"; import Api from "../../api";
import { useLoadingStore } from "../../stores/loading"; import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination"; import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters"; import { useFiltersStore } from "../../stores/filters";
import { useCompanyStore } from "../../stores/company.js";
import { useNotificationStore } from "../../stores/notifications-primevue"; import { useNotificationStore } from "../../stores/notifications-primevue";
import { Alarm, CalendarCheck, Hammer } from "@iconoir/vue"; import { Alarm, CalendarCheck, Hammer } from "@iconoir/vue";
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore(); const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore(); const filtersStore = useFiltersStore();
const companyStore = useCompanyStore();
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const tableData = ref([]); const tableData = ref([]);
const totalRecords = ref(0); const totalRecords = ref(0);
const isLoading = ref(false); const isLoading = ref(false);
const chartData = ref({
jobsInQueue: {labels: ["Queued"], data: [0], colors: ['blue']},
jobsInProgress: {labels: ["In Progress"], data: [0], colors: ['blue']},
jobsLate: {labels: ["Late"], data: [0], colors: ['red']},
jobsToInvoice: {labels: ["Ready To Invoice"], data: [0], colors: ['green']},
})
const columns = [ const columns = [
{ label: "Job ID", fieldName: "name", type: "text", sortable: true, filterable: true }, { label: "Job ID", fieldName: "name", type: "text", sortable: true, filterable: true },
{ label: "Address", fieldName: "jobAddress", type: "text", sortable: true }, { label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true }, { label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
{ label: "Overall Status", fieldName: "status", type: "status", sortable: true }, { label: "Overall Status", fieldName: "status", type: "status", sortable: true },
{ label: "Invoice Status", fieldName: "invoiceStatus", type: "text", sortable: true },
{ label: "Progress", fieldName: "percentComplete", type: "text", sortable: true } { label: "Progress", fieldName: "percentComplete", type: "text", sortable: true }
]; ];
@ -263,14 +257,7 @@ const handleLazyLoad = async (event) => {
const handleRowClick = (event) => { const handleRowClick = (event) => {
const rowData = event.data; const rowData = event.data;
router.push(`/job?name=${rowData.name}`); router.push(`/job?jobId=${rowData.name}`);
}
const loadChartData = async () => {
chartData.value.jobsInQueue.data = await Api.getJobsInQueueCount(companyStore.currentCompany);
chartData.value.jobsInProgress.data = await Api.getJobsInProgressCount(companyStore.currentCompany);
chartData.value.jobsLate.data = await Api.getJobsLateCount(companyStore.currentCompany);
chartData.value.jobsToInvoice.data = await Api.getJobsToInvoiceCount(companyStore.currentCompany);
} }
// Load initial data // Load initial data
@ -293,16 +280,7 @@ onMounted(async () => {
sortField: initialSorting.field || initialPagination.sortField, sortField: initialSorting.field || initialPagination.sortField,
sortOrder: initialSorting.order || initialPagination.sortOrder, sortOrder: initialSorting.order || initialPagination.sortOrder,
}); });
// Chart Data
await loadChartData();
}); });
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
await loadChartData();
});
</script> </script>
<style lang="css"> <style lang="css">
.widgets-grid { .widgets-grid {

View file

@ -21,22 +21,18 @@
<script setup> <script setup>
import DataTable from "../common/DataTable.vue"; import DataTable from "../common/DataTable.vue";
import { ref, onMounted, watch, computed } from "vue"; import { ref, onMounted, watch, computed } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter } from "vue-router";
import Api from "../../api"; import Api from "../../api";
import { useLoadingStore } from "../../stores/loading"; import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination"; import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters"; import { useFiltersStore } from "../../stores/filters";
import { useNotificationStore } from "../../stores/notifications-primevue"; import { useNotificationStore } from "../../stores/notifications-primevue";
import { FilterMatchMode } from "@primevue/core";
const loadingStore = useLoadingStore(); const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore(); const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore(); const filtersStore = useFiltersStore();
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const route = useRoute();
const subject = route.query.subject;
const tableData = ref([]); const tableData = ref([]);
const totalRecords = ref(0); const totalRecords = ref(0);
const isLoading = ref(false); const isLoading = ref(false);
@ -50,18 +46,13 @@ const statusOptions = ref([
"Cancelled", "Cancelled",
]); ]);
const filters = {
subject: { value: null, matchMode: FilterMatchMode.CONTAINS },
};
// Computed property to get current filters for the chart // Computed property to get current filters for the chart
const currentFilters = computed(() => { const currentFilters = computed(() => {
return filtersStore.getTableFilters("tasks"); return filtersStore.getTableFilters("tasks");
}); });
const columns = [ const columns = [
{ label: "Task", fieldName: "subject", type: "text", sortable: true, filterable: true, { label: "Task", fieldName: "subject", type: "text", sortable: true, filterable: true },
filterInputID: "subjectFilterId", defaultValue: subject || null },
{ label: "Job", fieldName: "project", type: "link", sortable: true, { label: "Job", fieldName: "project", type: "link", sortable: true,
onLinkClick: (link, rowData) => handleProjectClick(link, rowData) onLinkClick: (link, rowData) => handleProjectClick(link, rowData)
}, },
@ -220,6 +211,7 @@ watch(showCompleted, () => {
// Load initial data // Load initial data
onMounted(async () => { onMounted(async () => {
notifications.addWarning("Tasks page coming soon");
// Initialize pagination and filters // Initialize pagination and filters
paginationStore.initializeTablePagination("tasks", { rows: 10 }); paginationStore.initializeTablePagination("tasks", { rows: 10 });
filtersStore.initializeTableFilters("tasks", columns); filtersStore.initializeTableFilters("tasks", columns);
@ -230,11 +222,6 @@ onMounted(async () => {
const initialFilters = filtersStore.getTableFilters("tasks"); const initialFilters = filtersStore.getTableFilters("tasks");
const initialSorting = filtersStore.getTableSorting("tasks"); const initialSorting = filtersStore.getTableSorting("tasks");
if (subject) {
console.log("Setting subject filter from query param:", subject);
initialFilters.subject.value = subject;
}
const optionsResult = await Api.getTaskStatusOptions(); const optionsResult = await Api.getTaskStatusOptions();
statusOptions.value = optionsResult; statusOptions.value = optionsResult;
console.log("DEBUG: Loaded Status options: ", statusOptions.value) console.log("DEBUG: Loaded Status options: ", statusOptions.value)
@ -247,8 +234,6 @@ onMounted(async () => {
sortOrder: initialSorting.order || initialPagination.sortOrder, sortOrder: initialSorting.order || initialPagination.sortOrder,
filters: initialFilters, filters: initialFilters,
}); });
notifications.addWarning("Tasks page coming soon");
}); });
</script> </script>
<style lang="css"> <style lang="css">

View file

@ -1,90 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Down Payment Required</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.header h1 {
color: #2c3e50;
margin: 0;
}
.content {
padding: 20px 0;
}
.payment-details {
background-color: #ecf0f1;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.payment-details h2 {
margin-top: 0;
color: #e74c3c;
}
.cta-button {
display: inline-block;
background-color: #3498db;
color: #ffffff;
padding: 12px 24px;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
text-align: center;
margin: 20px 0;
}
.footer {
text-align: center;
padding-top: 20px;
border-top: 1px solid #eee;
color: #7f8c8d;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Thank You for Confirming Your Quote</h1>
</div>
<div class="content">
<p>Dear Valued Customer,</p>
<p>Thank you for accepting our quote for services at {{ company_name }}. We're excited to work with you and appreciate your trust in our team.</p>
<p>To proceed with scheduling your service, a half down payment is required. This helps us secure the necessary resources and ensures everything is prepared for your appointment.</p>
<div class="payment-details">
<h2>Payment Details</h2>
<p><strong>Sales Order Number:</strong> {{ sales_order_number }}</p>
<p><strong>Down Payment Amount:</strong> ${{ total_amount }}</p>
</div>
<p>Please click the button below to make your secure payment through our payment processor:</p>
<a href="https://yourdomain.com/downpayment?so={{ sales_order_number }}&amount={{ total_amount }}" class="cta-button">Make Payment</a>
<p>If you have any questions or need assistance, feel free to contact us. We're here to help!</p>
<p>Best regards,<br>The Team at {{ company_name }}</p>
</div>
<div class="footer">
<p>This is an automated email. Please do not reply directly.</p>
</div>
</div>
</body>
</html>