Compare commits
1 Commits
main
...
test-fixtu
| Author | SHA1 | Date | |
|---|---|---|---|
| 90fb04e44b |
Binary file not shown.
|
Before Width: | Height: | Size: 496 KiB |
@ -1,7 +1,7 @@
|
||||
import frappe
|
||||
import json
|
||||
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()
|
||||
def get_address_by_full_address(full_address):
|
||||
@ -35,33 +35,6 @@ def get_address(address_name):
|
||||
# except Exception as e:
|
||||
# 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()
|
||||
def get_addresses(fields=["*"], filters={}):
|
||||
"""Get addresses with optional filtering."""
|
||||
|
||||
@ -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
|
||||
|
||||
@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."""
|
||||
try:
|
||||
meetings = frappe.db.get_all(
|
||||
"On-Site Meeting",
|
||||
fields=["*"],
|
||||
filters=[
|
||||
["status", "!=", "Cancelled"],
|
||||
["start_time", ">=", week_start],
|
||||
["start_time", "<=", week_end],
|
||||
["company", "=", company]
|
||||
["start_time", "<=", week_end]
|
||||
],
|
||||
order_by="start_time asc"
|
||||
)
|
||||
@ -27,21 +25,9 @@ def get_week_bid_meetings(week_start, week_end, company):
|
||||
except Exception as e:
|
||||
frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed")
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_bid_meeting_note_form(project_template):
|
||||
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):
|
||||
def get_bid_meetings(fields=["*"], filters={}):
|
||||
"""Get paginated On-Site Meetings with filtering and sorting support."""
|
||||
try:
|
||||
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")
|
||||
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()
|
||||
def get_unscheduled_bid_meetings(company):
|
||||
def get_unscheduled_bid_meetings():
|
||||
"""Get On-Site Meetings that are unscheduled."""
|
||||
try:
|
||||
meetings = frappe.db.get_all(
|
||||
"On-Site Meeting",
|
||||
fields=["*"],
|
||||
filters={"status": "Unscheduled", "company": company},
|
||||
filters={"status": "Unscheduled"},
|
||||
order_by="creation desc"
|
||||
)
|
||||
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")
|
||||
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()
|
||||
def get_bid_meeting(name):
|
||||
@ -170,9 +89,6 @@ def get_bid_meeting(name):
|
||||
if meeting_dict.get("contact"):
|
||||
contact_doc = ContactService.get_or_throw(meeting_dict["contact"])
|
||||
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)
|
||||
except frappe.DoesNotExistError:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 custom_ui.api.db.addresses import address_exists
|
||||
from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links
|
||||
@ -167,81 +167,6 @@ def get_client_v2(client_name):
|
||||
return build_error_response(str(ve), 400)
|
||||
except Exception as e:
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_clients_table_data_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()
|
||||
@ -445,16 +370,12 @@ def upsert_client(data):
|
||||
address_docs = []
|
||||
for address in addresses:
|
||||
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)
|
||||
address_doc = AddressService.create_address({
|
||||
"address_title": AddressService.build_address_title(customer_name, address),
|
||||
"address_line1": address.get("address_line1"),
|
||||
"address_line2": address.get("address_line2"),
|
||||
"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"),
|
||||
"state": address.get("state"),
|
||||
"country": "United States",
|
||||
@ -484,12 +405,13 @@ def upsert_client(data):
|
||||
"address": address_doc.name
|
||||
})
|
||||
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 = []
|
||||
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:
|
||||
return build_error_response(str(ve), 400)
|
||||
except Exception as e:
|
||||
|
||||
@ -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)
|
||||
@ -1,47 +1,15 @@
|
||||
import frappe, json
|
||||
from frappe.utils.pdf import get_pdf
|
||||
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 custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
|
||||
from custom_ui.services import DbService, ClientService, AddressService, ContactService, EstimateService, ItemService
|
||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||
from custom_ui.services import DbService, ClientService, AddressService, ContactService
|
||||
|
||||
# ===============================================================================
|
||||
# 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()
|
||||
def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||
@ -86,25 +54,11 @@ def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_quotation_items(project_template:str = None):
|
||||
def get_quotation_items():
|
||||
"""Get all available quotation items."""
|
||||
try:
|
||||
filters = EstimateService.map_project_template_to_filter(project_template)
|
||||
items = frappe.get_all("Item", fields=["item_code", "item_group"], filters=filters)
|
||||
grouped_item_dicts = {}
|
||||
for item in items:
|
||||
item_dict = ItemService.get_full_dict(item.item_code)
|
||||
if item_dict["bom"]:
|
||||
if "Packages" not in grouped_item_dicts:
|
||||
grouped_item_dicts["Packages"] = {}
|
||||
if item.item_group not in grouped_item_dicts["Packages"]:
|
||||
grouped_item_dicts["Packages"][item.item_group] = []
|
||||
grouped_item_dicts["Packages"][item.item_group].append(item_dict)
|
||||
else:
|
||||
if item.item_group not in grouped_item_dicts:
|
||||
grouped_item_dicts[item.item_group] = []
|
||||
grouped_item_dicts[item.item_group].append(item_dict)
|
||||
return build_success_response(grouped_item_dicts)
|
||||
items = frappe.get_all("Item", fields=["*"], filters={"item_group": "SNW-S"})
|
||||
return build_success_response(items)
|
||||
except Exception as e:
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
@ -191,7 +145,7 @@ def send_estimate_email(estimate_name):
|
||||
print("DEBUG: Sending estimate email for:", estimate_name)
|
||||
quotation = frappe.get_doc("Quotation", estimate_name)
|
||||
|
||||
# Get recipient email
|
||||
|
||||
if not DbService.exists("Contact", quotation.contact_person):
|
||||
return build_error_response("No email found for the customer.", 400)
|
||||
party = ContactService.get_or_throw(quotation.contact_person)
|
||||
@ -210,71 +164,21 @@ def send_estimate_email(estimate_name):
|
||||
if not email:
|
||||
return build_error_response("No email found for the customer or address.", 400)
|
||||
|
||||
# Get customer name
|
||||
customer_name = party.first_name or party.name or "Valued Customer"
|
||||
if party.last_name:
|
||||
customer_name = f"{party.first_name} {party.last_name}"
|
||||
|
||||
# Get full address
|
||||
full_address = "Address not specified"
|
||||
if quotation.custom_job_address:
|
||||
address_doc = frappe.get_doc("Address", quotation.custom_job_address)
|
||||
full_address = address_doc.full_address or address_doc.address_line1 or "Address not specified"
|
||||
|
||||
# Format price
|
||||
price = frappe.utils.fmt_money(quotation.grand_total, currency=quotation.currency)
|
||||
|
||||
# Get additional notes
|
||||
additional = quotation.terms or ""
|
||||
|
||||
# Get company phone
|
||||
company_phone = ""
|
||||
if quotation.company:
|
||||
company_doc = frappe.get_doc("Company", quotation.company)
|
||||
company_phone = getattr(company_doc, 'phone_no', '') or getattr(company_doc, 'phone', '')
|
||||
|
||||
# Get base URL
|
||||
base_url = frappe.utils.get_url()
|
||||
|
||||
# Get letterhead image
|
||||
letterhead_image = ""
|
||||
if quotation.letter_head:
|
||||
letterhead_doc = frappe.get_doc("Letter Head", quotation.letter_head)
|
||||
if letterhead_doc.image:
|
||||
letterhead_image = frappe.utils.get_url() + letterhead_doc.image
|
||||
|
||||
# Prepare template context
|
||||
template_context = {
|
||||
"company": quotation.company,
|
||||
"customer_name": customer_name,
|
||||
"price": price,
|
||||
"address": full_address,
|
||||
"additional": additional,
|
||||
"company_phone": company_phone,
|
||||
"base_url": base_url,
|
||||
"estimate_name": quotation.name,
|
||||
"letterhead_image": letterhead_image
|
||||
}
|
||||
|
||||
# Render the email template
|
||||
template_path = "custom_ui/templates/emails/general_estimation.html"
|
||||
message = frappe.render_template(template_path, template_context)
|
||||
subject = f"Estimate from {quotation.company} - {quotation.name}"
|
||||
|
||||
print("DEBUG: Subject:", subject)
|
||||
print("DEBUG: Sending email to:", email)
|
||||
|
||||
# Generate PDF attachment
|
||||
# email = "casey@shilohcode.com"
|
||||
template_name = "Quote with Actions - SNW"
|
||||
template = frappe.get_doc("Email Template", template_name)
|
||||
message = frappe.render_template(template.response, {"name": quotation.name})
|
||||
subject = frappe.render_template(template.subject, {"doc": quotation})
|
||||
print("DEBUG: Message: ", message)
|
||||
print("DEBUG: Subject: ", subject)
|
||||
html = frappe.get_print("Quotation", quotation.name, print_format="Quotation - SNW - Standard", letterhead=True)
|
||||
print("DEBUG: Generated HTML for PDF.")
|
||||
pdf = get_pdf(html)
|
||||
print("DEBUG: Generated PDF for email attachment.")
|
||||
|
||||
# Send email
|
||||
frappe.sendmail(
|
||||
recipients=email,
|
||||
subject=subject,
|
||||
message=message,
|
||||
content=message,
|
||||
doctype="Quotation",
|
||||
name=quotation.name,
|
||||
read_receipt=1,
|
||||
@ -282,14 +186,11 @@ def send_estimate_email(estimate_name):
|
||||
attachments=[{"fname": f"{quotation.name}.pdf", "fcontent": pdf}]
|
||||
)
|
||||
print(f"DEBUG: Email sent to {email} successfully.")
|
||||
|
||||
# Update quotation status
|
||||
quotation.custom_current_status = "Submitted"
|
||||
quotation.custom_sent = 1
|
||||
quotation.save()
|
||||
quotation.submit()
|
||||
frappe.db.commit()
|
||||
|
||||
updated_quotation = frappe.get_doc("Quotation", estimate_name)
|
||||
return build_success_response(updated_quotation.as_dict())
|
||||
except Exception as e:
|
||||
@ -322,6 +223,45 @@ def manual_response(name, response):
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def update_response(name, response):
|
||||
"""Update the response for a given estimate."""
|
||||
print("DEBUG: RESPONSE RECEIVED:", name, response)
|
||||
try:
|
||||
if not frappe.db.exists("Quotation", name):
|
||||
raise Exception("Estimate not found.")
|
||||
estimate = frappe.get_doc("Quotation", name)
|
||||
if estimate.docstatus != 1:
|
||||
raise Exception("Estimate must be submitted to update response.")
|
||||
accepted = True if response == "Accepted" else False
|
||||
new_status = "Estimate Accepted" if accepted else "Lost"
|
||||
|
||||
estimate.custom_response = response
|
||||
estimate.custom_current_status = new_status
|
||||
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
|
||||
# estimate.status = "Ordered" if accepted else "Closed"
|
||||
estimate.flags.ignore_permissions = True
|
||||
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
|
||||
estimate.save()
|
||||
|
||||
if accepted:
|
||||
template = "custom_ui/templates/estimates/accepted.html"
|
||||
# if check_if_customer(estimate.party_name):
|
||||
# print("DEBUG: Party is already a customer:", estimate.party_name)
|
||||
# else:
|
||||
# print("DEBUG: Converting lead to customer for party:", estimate.party_name)
|
||||
# convert_lead_to_customer(estimate.party_name)
|
||||
elif response == "Requested call":
|
||||
template = "custom_ui/templates/estimates/request-call.html"
|
||||
else:
|
||||
template = "custom_ui/templates/estimates/rejected.html"
|
||||
html = frappe.render_template(template, {"doc": estimate})
|
||||
return Response(html, mimetype="text/html")
|
||||
except Exception as e:
|
||||
template = "custom_ui/templates/estimates/error.html"
|
||||
html = frappe.render_template(template, {"error": str(e)})
|
||||
return Response(html, mimetype="text/html")
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_estimate_templates(company):
|
||||
"""Get available estimate templates."""
|
||||
@ -458,7 +398,7 @@ def upsert_estimate(data):
|
||||
# estimate.custom_job_address = data.get("address_name")
|
||||
# estimate.party_name = data.get("customer")
|
||||
# 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_quotation_template = data.get("quotation_template", None)
|
||||
# estimate.company = data.get("company")
|
||||
@ -468,7 +408,7 @@ def upsert_estimate(data):
|
||||
# estimate.customer_address = data.get("address_name")
|
||||
# estimate.letter_head = data.get("company")
|
||||
# estimate.from_onsite_meeting = data.get("onsite_meeting", None)
|
||||
|
||||
|
||||
# Clear existing items and add new ones
|
||||
estimate.items = []
|
||||
for item in data.get("items", []):
|
||||
@ -476,13 +416,11 @@ def upsert_estimate(data):
|
||||
estimate.append("items", {
|
||||
"item_code": item.get("item_code"),
|
||||
"qty": item.get("qty"),
|
||||
"rate": item.get("rate"),
|
||||
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
|
||||
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
|
||||
})
|
||||
|
||||
estimate.save()
|
||||
frappe.db.commit()
|
||||
estimate_dict = estimate.as_dict()
|
||||
estimate_dict["history"] = get_doc_history("Quotation", estimate_name)
|
||||
print(f"DEBUG: Estimate updated: {estimate.name}")
|
||||
@ -500,7 +438,7 @@ def upsert_estimate(data):
|
||||
# print("DEBUG: No billing address found for client:", client_doc.name)
|
||||
new_estimate = frappe.get_doc({
|
||||
"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_current_status": "Draft",
|
||||
"contact_email": data.get("contact_email"),
|
||||
@ -514,14 +452,13 @@ def upsert_estimate(data):
|
||||
"letter_head": data.get("company"),
|
||||
"custom_project_template": data.get("project_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", []):
|
||||
item = json.loads(item) if isinstance(item, str) else item
|
||||
new_estimate.append("items", {
|
||||
"item_code": item.get("item_code"),
|
||||
"qty": item.get("qty"),
|
||||
"rate": item.get("rate"),
|
||||
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
|
||||
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
|
||||
})
|
||||
@ -537,31 +474,6 @@ def upsert_estimate(data):
|
||||
print(f"DEBUG: Error in upsert_estimate: {str(e)}")
|
||||
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):
|
||||
"""Get the history of changes for a specific estimate."""
|
||||
pass
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import frappe
|
||||
from custom_ui.db_utils import build_history_entries, build_success_response, build_error_response
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
from custom_ui.db_utils import build_history_entries
|
||||
|
||||
def get_doc_history(doctype, docname):
|
||||
"""Get the history of changes for a specific document."""
|
||||
@ -58,43 +56,4 @@ def search_any_field(doctype, text):
|
||||
query,
|
||||
[like] * len(conditions),
|
||||
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)
|
||||
)
|
||||
@ -1,36 +1,11 @@
|
||||
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 erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
|
||||
# ===============================================================================
|
||||
# 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()
|
||||
def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||
"""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:
|
||||
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(
|
||||
"Sales Invoice",
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
import frappe
|
||||
import json
|
||||
from custom_ui.models import PackageCreationData
|
||||
from custom_ui.services import ProjectService, ItemService
|
||||
from custom_ui.db_utils import build_error_response, build_success_response
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_by_project_template(project_template: str) -> dict:
|
||||
"""Retrieve items associated with a given project template."""
|
||||
print(f"DEBUG: Getting items for Project Template {project_template}")
|
||||
item_groups = ProjectService.get_project_item_groups(project_template)
|
||||
items = ItemService.get_items_by_groups(item_groups)
|
||||
print(f"DEBUG: Retrieved {len(items)} items for Project Template {project_template}")
|
||||
categorized_items = ItemService.build_category_dict(items)
|
||||
return build_success_response(categorized_items)
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_as_package_item(data):
|
||||
"""Save a new Package Item based on the provided data."""
|
||||
from custom_ui.models import BOMItem
|
||||
data = json.loads(data)
|
||||
print(f"DEBUG: Saving Package Item with data: {data}")
|
||||
# Map 'category' to 'item_group' for the model
|
||||
data['item_group'] = data.pop('category')
|
||||
# Convert items dictionaries to BOMItem instances
|
||||
data['items'] = [
|
||||
BOMItem(
|
||||
item_code=item['item_code'],
|
||||
qty=item['qty'],
|
||||
uom=item['uom']
|
||||
) for item in data['items']
|
||||
]
|
||||
data = PackageCreationData(**data)
|
||||
item = frappe.get_doc({
|
||||
"doctype": "Item",
|
||||
"item_code": ItemService.build_item_code(data.code_prefix, data.package_name),
|
||||
"item_name": data.package_name,
|
||||
"is_stock_item": 0,
|
||||
"item_group": data.item_group,
|
||||
"description": data.description,
|
||||
"standard_rate": data.rate or 0.0,
|
||||
"company": data.company,
|
||||
"has_variants": 0,
|
||||
"stock_uom": "Nos",
|
||||
"is_sales_item": 1,
|
||||
"is_purchase_item": 0,
|
||||
"is_pro_applicable": 0,
|
||||
"is_fixed_asset": 0,
|
||||
"is_service_item": 0
|
||||
}).insert()
|
||||
bom = frappe.get_doc({
|
||||
"doctype": "BOM",
|
||||
"item": item.name,
|
||||
"uom": "Nos",
|
||||
"is_active": 1,
|
||||
"is_default": 1,
|
||||
"items": [{
|
||||
"item_code": bom_item.item_code,
|
||||
"qty": bom_item.qty,
|
||||
"uom": bom_item.uom
|
||||
} for bom_item in data.items]
|
||||
}).insert()
|
||||
bom.submit()
|
||||
item.reload() # Refresh to get latest version after BOM submission
|
||||
item.default_bom = bom.name
|
||||
item.save()
|
||||
print(f"DEBUG: Created Package Item with name: {item.name}")
|
||||
item_dict = item.as_dict()
|
||||
item_dict["bom"] = ItemService.get_full_bom_dict(item.item_code) # Attach BOM details to the item dict
|
||||
return build_success_response(item_dict)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_item_categories():
|
||||
"""Retrieve all item groups for categorization."""
|
||||
print("DEBUG: Getting item categories")
|
||||
item_groups = frappe.get_all("Item Group", pluck="name")
|
||||
print(f"DEBUG: Retrieved item categories: {item_groups}")
|
||||
return build_success_response(item_groups)
|
||||
|
||||
@ -1,70 +1,11 @@
|
||||
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.services import AddressService, ClientService, ServiceAppointmentService
|
||||
from frappe.utils import getdate
|
||||
from custom_ui.services import AddressService, ClientService
|
||||
|
||||
# ===============================================================================
|
||||
# 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()
|
||||
def get_job_templates(company=None):
|
||||
"""Get list of job (project) templates."""
|
||||
@ -77,14 +18,13 @@ def get_job_templates(company=None):
|
||||
except Exception as e:
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_job_from_sales_order(sales_order_name):
|
||||
"""Create a Job (Project) from a given Sales Order"""
|
||||
try:
|
||||
sales_order = frappe.get_doc("Sales Order", sales_order_name)
|
||||
project_template = frappe.get_doc("Project Template", "SNW Install")
|
||||
new_project = frappe.get_doc({
|
||||
new_job = frappe.get_doc({
|
||||
"doctype": "Project",
|
||||
"custom_address": sales_order.custom_job_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,
|
||||
"custom_company": sales_order.company
|
||||
})
|
||||
new_project.insert()
|
||||
for sales_order_item in sales_order.items:
|
||||
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())
|
||||
new_job.insert()
|
||||
return build_success_response(new_job.as_dict())
|
||||
except Exception as e:
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
@ -124,8 +50,6 @@ def get_job(job_id=""):
|
||||
project = project.as_dict()
|
||||
project["job_address"] = address_doc
|
||||
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)
|
||||
except Exception as e:
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
tableRows = []
|
||||
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["id"] = task["name"]
|
||||
tableRow["subject"] = task["subject"]
|
||||
tableRow["address"] = full_address
|
||||
tableRow["address"] = task.get("custom_property", "")
|
||||
tableRow["status"] = task.get("status", "")
|
||||
tableRows.append(tableRow)
|
||||
|
||||
@ -211,10 +132,9 @@ def get_jobs_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||
tableRow = {}
|
||||
tableRow["id"] = 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["status"] = project.get("status", "")
|
||||
tableRow["invoice_status"] = project.get("invoice_status")
|
||||
tableRow["percent_complete"] = project.get("percent_complete", 0)
|
||||
tableRows.append(tableRow)
|
||||
|
||||
@ -247,56 +167,55 @@ def upsert_job(data):
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@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."""
|
||||
# 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:
|
||||
filters = {
|
||||
"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)]
|
||||
filters = {"project_template": "SNW Install"}
|
||||
# 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.
|
||||
# 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)
|
||||
print("DEBUG: Found scheduled project names:", project_names)
|
||||
unscheduled_project_names = frappe.get_all("Project", pluck="name", filters=unscheduled_filters)
|
||||
print("DEBUG: Found unscheduled project names:", unscheduled_project_names)
|
||||
projects = [frappe.get_doc("Project", name).as_dict() for name in project_names]
|
||||
unscheduled_projects = [frappe.get_doc("Project", name).as_dict() for name in unscheduled_project_names]
|
||||
return build_success_response({ "projects": projects, "unscheduled_projects": unscheduled_projects })
|
||||
projects = frappe.get_all("Project", fields=["*"], filters=filters)
|
||||
|
||||
calendar_events = []
|
||||
for project in projects:
|
||||
# Determine status
|
||||
status = "unscheduled"
|
||||
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:
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_job_scheduled_dates(job_name: str, new_start_date: str = None, new_end_date: str = None, foreman_name: str = None):
|
||||
"""Update job (project) schedule dates."""
|
||||
print("DEBUG: Updating job schedule:", job_name, new_start_date, new_end_date, foreman_name)
|
||||
def get_project_templates_for_company(company_name):
|
||||
"""Get project templates for a specific company."""
|
||||
try:
|
||||
project = frappe.get_doc("Project", job_name)
|
||||
project.expected_start_date = getdate(new_start_date) if new_start_date else None
|
||||
project.expected_end_date = getdate(new_end_date) if new_end_date else None
|
||||
if new_start_date and new_end_date:
|
||||
project.is_scheduled = 1
|
||||
else:
|
||||
project.is_scheduled = 0
|
||||
if foreman_name:
|
||||
project.custom_foreman = foreman_name
|
||||
project.save()
|
||||
return build_success_response(project.as_dict())
|
||||
templates = frappe.get_all(
|
||||
"Project Template",
|
||||
fields=["*"],
|
||||
filters={"company": company_name}
|
||||
)
|
||||
return build_success_response(templates)
|
||||
except Exception as e:
|
||||
return build_error_response(str(e), 500)
|
||||
return build_error_response(str(e), 500),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
@ -1,5 +1,4 @@
|
||||
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.services import DbService
|
||||
|
||||
@ -13,7 +12,7 @@ def set_task_status(task_name, new_status):
|
||||
return build_success_response(f"Task {task_name} status updated to {new_status}.")
|
||||
except Exception as e:
|
||||
return build_error_response(str(e), 500)
|
||||
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_job_task_list(job_id=""):
|
||||
@ -43,42 +42,6 @@ def get_task_status_options():
|
||||
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()
|
||||
def get_tasks_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||
"""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=["*"],
|
||||
filters=processed_filters,
|
||||
limit=page_size,
|
||||
start=(page-1) * page_size,
|
||||
start=page * page_size,
|
||||
order_by=processed_sortings
|
||||
)
|
||||
|
||||
print("TASKS?", tasks, page, page_size)
|
||||
|
||||
tableRows = []
|
||||
for task in tasks:
|
||||
tableRow = {}
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
import frappe
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def update_response(name, response):
|
||||
"""Update the response for a given estimate."""
|
||||
print("DEBUG: RESPONSE RECEIVED:", name, response)
|
||||
try:
|
||||
if not frappe.db.exists("Quotation", name):
|
||||
raise Exception("Estimate not found.")
|
||||
estimate = frappe.get_doc("Quotation", name)
|
||||
if estimate.docstatus != 1:
|
||||
raise Exception("Estimate must be submitted to update response.")
|
||||
accepted = True if response == "Accepted" else False
|
||||
new_status = "Estimate Accepted" if accepted else "Lost"
|
||||
|
||||
estimate.custom_response = response
|
||||
estimate.custom_current_status = new_status
|
||||
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
|
||||
# estimate.status = "Ordered" if accepted else "Closed"
|
||||
estimate.flags.ignore_permissions = True
|
||||
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
|
||||
estimate.save()
|
||||
|
||||
if accepted:
|
||||
template = "custom_ui/templates/estimates/accepted.html"
|
||||
# if check_if_customer(estimate.party_name):
|
||||
# print("DEBUG: Party is already a customer:", estimate.party_name)
|
||||
# else:
|
||||
# print("DEBUG: Converting lead to customer for party:", estimate.party_name)
|
||||
# convert_lead_to_customer(estimate.party_name)
|
||||
elif response == "Requested call":
|
||||
template = "custom_ui/templates/estimates/request-call.html"
|
||||
else:
|
||||
template = "custom_ui/templates/estimates/rejected.html"
|
||||
html = frappe.render_template(template, {"doc": estimate})
|
||||
frappe.db.commit()
|
||||
return Response(html, mimetype="text/html")
|
||||
except Exception as e:
|
||||
template = "custom_ui/templates/estimates/error.html"
|
||||
html = frappe.render_template(template, {"error": str(e)})
|
||||
return Response(html, mimetype="text/html")
|
||||
@ -1,96 +0,0 @@
|
||||
import frappe
|
||||
import json
|
||||
from datetime import datetime
|
||||
from frappe.utils.data import flt
|
||||
from custom_ui.services import DbService, StripeService, PaymentService
|
||||
from custom_ui.models import PaymentData
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def half_down_stripe_payment(sales_order):
|
||||
"""Public endpoint for initiating a half-down advance payment for a sales order."""
|
||||
if not DbService.exists("Sales Order", sales_order):
|
||||
frappe.throw("Sales Order does not exist.")
|
||||
so = DbService.get_or_throw("Sales Order", sales_order)
|
||||
if not so.requires_half_payment:
|
||||
frappe.throw("This sales order does not require a half-down payment.")
|
||||
if so.docstatus != 1:
|
||||
frappe.throw("Sales Order must be submitted to proceed with payment.")
|
||||
if so.custom_halfdown_is_paid or so.advance_paid >= so.custom_halfdown_amount:
|
||||
frappe.throw("Half-down payment has already been made for this sales order.")
|
||||
stripe_session = StripeService.create_checkout_session(
|
||||
company=so.company,
|
||||
amount=so.custom_halfdown_amount,
|
||||
service=so.custom_project_template,
|
||||
order_num=so.name,
|
||||
for_advance_payment=True
|
||||
)
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = stripe_session.url
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def stripe_webhook():
|
||||
"""Endpoint to handle Stripe webhooks."""
|
||||
payload = frappe.request.get_data()
|
||||
sig_header = frappe.request.headers.get('Stripe-Signature')
|
||||
session, metadata = StripeService.get_session_and_metadata(payload, sig_header)
|
||||
|
||||
# Validate required metadata
|
||||
if not metadata.get("company"):
|
||||
raise frappe.ValidationError("Missing required metadata key: company")
|
||||
if not metadata.get("payment_type"):
|
||||
raise frappe.ValidationError("Missing required metadata key: payment_type")
|
||||
|
||||
# Determine reference document based on payment type
|
||||
payment_type = metadata.get("payment_type")
|
||||
reference_doctype = None
|
||||
reference_doc_name = None
|
||||
|
||||
if payment_type == "advance":
|
||||
reference_doctype = "Sales Order"
|
||||
reference_doc_name = metadata.get("sales_order")
|
||||
if not reference_doc_name:
|
||||
raise frappe.ValidationError("Missing sales_order in metadata for advance payment")
|
||||
elif payment_type == "full":
|
||||
reference_doctype = "Sales Invoice"
|
||||
reference_doc_name = metadata.get("sales_invoice")
|
||||
if not reference_doc_name:
|
||||
raise frappe.ValidationError("Missing sales_invoice in metadata for full payment")
|
||||
else:
|
||||
raise frappe.ValidationError(f"Invalid payment type in metadata: {payment_type}")
|
||||
|
||||
# Check if payment already exists
|
||||
if DbService.exists("Payment Entry", {"reference_no": session.id}):
|
||||
raise frappe.ValidationError("Payment Entry already exists for this session.")
|
||||
|
||||
amount_paid = flt(session.amount_total) / 100
|
||||
|
||||
# Convert Unix timestamp to date string (YYYY-MM-DD)
|
||||
reference_date = datetime.fromtimestamp(session.created).strftime('%Y-%m-%d')
|
||||
|
||||
# Set Administrator context to create Payment Entry
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
try:
|
||||
pe = PaymentService.create_payment_entry(
|
||||
data=PaymentData(
|
||||
mode_of_payment="Stripe",
|
||||
reference_no=session.id,
|
||||
reference_date=reference_date,
|
||||
received_amount=amount_paid,
|
||||
company=metadata.get("company"),
|
||||
reference_doc_name=reference_doc_name
|
||||
)
|
||||
)
|
||||
pe.flags.ignore_permissions = True
|
||||
pe.submit()
|
||||
frappe.db.commit()
|
||||
return "Payment Entry created and submitted successfully."
|
||||
finally:
|
||||
# Reset to Guest user
|
||||
frappe.set_user("Guest")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -229,20 +229,3 @@ def build_history_entries(comments, versions):
|
||||
# Sort by timestamp descending
|
||||
history.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
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
|
||||
@ -1,6 +0,0 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def before_save(doc, method):
|
||||
print("DEBUG: Before save hook triggered for Customer:", doc.name)
|
||||
print("DEBUG: current state: ", doc.as_dict())
|
||||
@ -31,13 +31,12 @@ def before_insert(doc, method):
|
||||
print("DEBUG: CHECKING CUSTOMER NAME")
|
||||
print(doc.actual_customer_name)
|
||||
print("Quotation_to:", doc.quotation_to)
|
||||
doc.customer_address = frappe.get_value(doc.customer_type, doc.actual_customer_name, "custom_billing_address")
|
||||
# print("Party_type:", doc.party_type)
|
||||
if doc.custom_project_template == "SNW Install":
|
||||
print("DEBUG: Quotation uses SNW Install template, making sure no duplicate linked estimates.")
|
||||
address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
||||
# if "SNW Install" in [link.project_template for link in address_doc.quotations]:
|
||||
# raise frappe.ValidationError("An Estimate with project template 'SNW Install' is already linked to this address.")
|
||||
if "SNW Install" in [link.project_template for link in address_doc.quotations]:
|
||||
raise frappe.ValidationError("An Estimate with project template 'SNW Install' is already linked to this address.")
|
||||
|
||||
def before_submit(doc, method):
|
||||
print("DEBUG: Before submit hook triggered for Quotation:", doc.name)
|
||||
@ -54,27 +53,25 @@ def before_submit(doc, method):
|
||||
def on_update_after_submit(doc, method):
|
||||
print("DEBUG: on_update_after_submit hook triggered for Quotation:", doc.name)
|
||||
print("DEBUG: Current custom_current_status:", doc.custom_current_status)
|
||||
if doc.custom_current_status == "Won":
|
||||
print("DEBUG: Quotation is already marked as Won, no action needed.")
|
||||
return
|
||||
if doc.custom_current_status == "Estimate Accepted":
|
||||
doc.custom_current_status = "Won"
|
||||
print("DEBUG: Quotation marked as Won, updating current status.")
|
||||
if doc.customer_type == "Lead":
|
||||
print("DEBUG: Customer is a Lead, converting to Customer and updating Quotation.")
|
||||
new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name)
|
||||
doc.customer_type = "Customer"
|
||||
new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name, update_quotations=False)
|
||||
doc.actual_customer_name = new_customer.name
|
||||
doc.customer_type = "Customer"
|
||||
new_customer.reload()
|
||||
ClientService.append_link_v2(
|
||||
new_customer.name, "quotations", {"quotation": doc.name}
|
||||
)
|
||||
doc.save()
|
||||
print("DEBUG: Creating Sales Order from accepted Estimate")
|
||||
new_sales_order = make_sales_order(doc.name)
|
||||
new_sales_order.requires_half_payment = doc.requires_half_payment
|
||||
new_sales_order.custom_requires_half_payment = doc.requires_half_payment
|
||||
new_sales_order.customer = doc.actual_customer_name
|
||||
new_sales_order.customer_name = doc.actual_customer_name
|
||||
new_sales_order.customer_address = doc.customer_address
|
||||
# new_sales_order.custom_installation_address = doc.custom_installation_address
|
||||
new_sales_order.custom_job_address = doc.custom_job_address
|
||||
# new_sales_order.custom_job_address = doc.custom_job_address
|
||||
new_sales_order.payment_schedule = []
|
||||
print("DEBUG: Setting payment schedule for Sales Order")
|
||||
new_sales_order.set_payment_schedule()
|
||||
@ -86,5 +83,5 @@ def on_update_after_submit(doc, method):
|
||||
print("DEBUG: Submitting Sales Order")
|
||||
# new_sales_order.customer_address = backup
|
||||
new_sales_order.submit()
|
||||
# frappe.db.commit()
|
||||
frappe.db.commit()
|
||||
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
|
||||
|
||||
@ -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)
|
||||
@ -1,7 +1,6 @@
|
||||
import frappe
|
||||
from custom_ui.services import SalesOrderService, AddressService, ClientService, ServiceAppointmentService, TaskService
|
||||
from datetime import timedelta
|
||||
import traceback
|
||||
from custom_ui.services import AddressService, ClientService
|
||||
|
||||
|
||||
def after_insert(doc, method):
|
||||
print("DEBUG: After Insert Triggered for Project")
|
||||
@ -16,96 +15,14 @@ def after_insert(doc, method):
|
||||
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
|
||||
)
|
||||
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(
|
||||
doc.job_address,
|
||||
"job_status",
|
||||
"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):
|
||||
# This is where we will add logic to set tasks and other properties of a job based on it's project_template
|
||||
if doc.requires_half_payment:
|
||||
print("DEBUG: Project requires half payment, setting flag.")
|
||||
doc.ready_to_schedule = 0
|
||||
|
||||
def before_save(doc, method):
|
||||
print("DEBUG: Before Save Triggered for Project:", doc.name)
|
||||
print("DEBUG: Checking status: ", doc.status)
|
||||
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.status == "Completed":
|
||||
print("DEBUG: Project marked as Completed. Generating and sending final invoice.")
|
||||
sales_order_status = frappe.get_value("Sales Order", doc.sales_order, "billing_status")
|
||||
if sales_order_status == "Not Billed":
|
||||
SalesOrderService.create_sales_invoice_from_sales_order(doc.sales_order)
|
||||
if doc.ready_to_schedule:
|
||||
service_apt_ready_to_schedule = frappe.get_value("Service Address 2", doc.service_appointment, "ready_to_schedule")
|
||||
if not service_apt_ready_to_schedule:
|
||||
print("DEBUG: Project is ready to schedule, setting Service Appointment to ready to schedule.")
|
||||
service_apt_doc = frappe.get_doc("Service Address 2", doc.service_appointment)
|
||||
service_apt_doc.ready_to_schedule = 1
|
||||
service_apt_doc.save(ignore_permissions=True)
|
||||
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
|
||||
)
|
||||
pass
|
||||
@ -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.
|
||||
for link in address_doc.onsite_meetings:
|
||||
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):
|
||||
print("DEBUG: After Insert Triggered for On-Site Meeting")
|
||||
@ -23,19 +22,17 @@ def after_insert(doc, method):
|
||||
|
||||
|
||||
def before_save(doc, method):
|
||||
|
||||
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")
|
||||
doc.status = "Scheduled"
|
||||
if doc.project_template == "SNW Install":
|
||||
print("DEBUG: Project template is SNW Install")
|
||||
if doc.status == "Completed":
|
||||
print("DEBUG: Meeting marked as Completed, updating Address status")
|
||||
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
|
||||
if doc.status == "Cancelled":
|
||||
print("DEBUG: Meeting marked as Cancelled, updating Address status")
|
||||
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Not Started")
|
||||
current_status = AddressService.get_value(doc.address, "onsite_meeting_scheduled")
|
||||
if current_status != doc.status:
|
||||
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
|
||||
|
||||
def validate_address_link(doc, method):
|
||||
print("DEBUG: Validating Address link for On-Site Meeting")
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import frappe
|
||||
|
||||
def on_submit(doc, method):
|
||||
print("DEBUG: On Submit Triggered for Payment Entry")
|
||||
is_advance_payment = any(ref.reference_doctype == "Sales Order" for ref in doc.references)
|
||||
if is_advance_payment:
|
||||
print("DEBUG: Payment Entry is for an advance payment, checking Sales Order if half down requirement is met.")
|
||||
so_ref = next((ref for ref in doc.references if ref.reference_doctype == "Sales Order"), None)
|
||||
if so_ref:
|
||||
so_doc = frappe.get_doc("Sales Order", so_ref.reference_name)
|
||||
if so_doc.requires_half_payment:
|
||||
is_paid = so_doc.custom_halfdown_amount <= so_doc.advance_paid or so_doc.advance_paid >= so_doc.grand_total / 2
|
||||
if is_paid and not so_doc.custom_halfdown_is_paid:
|
||||
print("DEBUG: Sales Order requires half payment and it has not been marked as paid, marking it as paid now.")
|
||||
so_doc.custom_halfdown_is_paid = 1
|
||||
so_doc.save()
|
||||
@ -1,16 +0,0 @@
|
||||
import frappe
|
||||
from custom_ui.services.email_service import EmailService
|
||||
|
||||
def on_submit(doc, method):
|
||||
print("DEBUG: On Submit Triggered for Sales Invoice:", doc.name)
|
||||
|
||||
# Send invoice email to customer
|
||||
try:
|
||||
print("DEBUG: Preparing to send invoice email for", doc.name)
|
||||
EmailService.send_invoice_email(doc.name)
|
||||
print("DEBUG: Invoice email sent successfully for", doc.name)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send invoice email: {str(e)}")
|
||||
# Don't raise the exception - we don't want to block the invoice submission
|
||||
frappe.log_error(f"Failed to send invoice email for {doc.name}: {str(e)}", "Invoice Email Error")
|
||||
|
||||
@ -1,43 +1,22 @@
|
||||
import frappe
|
||||
from custom_ui.services import DbService, AddressService, ClientService
|
||||
|
||||
|
||||
def before_save(doc, method):
|
||||
print("DEBUG: before_save hook triggered for Sales Order", doc.name)
|
||||
if doc.docstatus == 1:
|
||||
if doc.requires_half_payment:
|
||||
half_down_is_paid = doc.custom_halfdown_is_paid or doc.advance_paid >= doc.custom_halfdown_amount or doc.advance_paid >= doc.grand_total / 2
|
||||
if half_down_is_paid and not doc.custom_halfdown_is_paid:
|
||||
doc.custom_halfdown_is_paid = 1
|
||||
|
||||
|
||||
def before_insert(doc, method):
|
||||
print("DEBUG: Before Insert triggered for Sales Order: ", doc.name)
|
||||
print("DEBUG: before_insert hook triggered for Sales Order:", doc.name)
|
||||
if doc.custom_project_template == "SNW Install":
|
||||
print("DEBUG: Sales Order uses SNW Install template, checking for duplicates.")
|
||||
print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
|
||||
address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
||||
for link in address_doc.sales_orders:
|
||||
if link.project_template == "SNW Install":
|
||||
raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
|
||||
print("DEBUG: before_insert hook triggered for Sales Order")
|
||||
if doc.requires_half_payment:
|
||||
print("DEBUG: Sales Order requires half payment, calculating half-down amount.")
|
||||
half_down_amount = doc.grand_total / 2
|
||||
doc.custom_halfdown_amount = half_down_amount
|
||||
print("DEBUG: Half-down amount set to:", half_down_amount)
|
||||
|
||||
|
||||
if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
|
||||
raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
|
||||
|
||||
def on_submit(doc, method):
|
||||
print("DEBUG: on_submit hook triggered for Sales Order:", doc.name)
|
||||
print("DEBUG: Info from Sales Order")
|
||||
print(f"Sales Order Name: {doc.name}")
|
||||
print(f"Grand Total: {doc.grand_total}")
|
||||
print(f"Company: {doc.company}")
|
||||
print(f"Requires Half Payment: {doc.requires_half_payment}")
|
||||
print(f"Customer: {doc.customer}")
|
||||
print(f"Job Address: {doc.custom_job_address}")
|
||||
print(f"Project Template: {doc.custom_project_template}")
|
||||
print(doc.custom_installation_address)
|
||||
print(doc.company)
|
||||
print(doc.transaction_date)
|
||||
print(doc.customer)
|
||||
print(doc.custom_job_address)
|
||||
print(doc.custom_project_template)
|
||||
# Create Invoice and Project from Sales Order
|
||||
try:
|
||||
print("Creating Project from Sales Order", doc.name)
|
||||
@ -50,16 +29,14 @@ def on_submit(doc, method):
|
||||
"custom_warranty_duration_days": 90,
|
||||
"customer": doc.customer,
|
||||
"job_address": doc.custom_job_address,
|
||||
"sales_order": doc.name,
|
||||
"requires_half_payment": doc.requires_half_payment
|
||||
"sales_order": doc.name
|
||||
})
|
||||
# attatch the job to the sales_order links
|
||||
new_job.insert()
|
||||
# frappe.db.commit()
|
||||
frappe.db.commit()
|
||||
except Exception as e:
|
||||
print("ERROR creating Project from Sales Order:", str(e))
|
||||
|
||||
|
||||
def after_insert(doc, method):
|
||||
print("DEBUG: after_insert hook triggered for Sales Order:", doc.name)
|
||||
AddressService.append_link_v2(
|
||||
@ -71,71 +48,42 @@ def after_insert(doc, method):
|
||||
ClientService.append_link_v2(
|
||||
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
|
||||
)
|
||||
|
||||
# Send down payment email if required
|
||||
if doc.requires_half_payment:
|
||||
try:
|
||||
print("DEBUG: Sales Order requires half payment, preparing to send down payment email")
|
||||
from custom_ui.services.email_service import EmailService
|
||||
|
||||
# Use EmailService to send the down payment email
|
||||
EmailService.send_downpayment_email(doc.name)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send down payment email: {str(e)}")
|
||||
# Don't raise the exception - we don't want to block the sales order creation
|
||||
frappe.log_error(f"Failed to send down payment email for {doc.name}: {str(e)}", "Down Payment Email Error")
|
||||
|
||||
|
||||
def on_update_after_submit(doc, method):
|
||||
print("DEBUG: on_update_after_submit hook triggered for Sales Order:", doc.name)
|
||||
if doc.requires_half_payment and doc.custom_halfdown_is_paid:
|
||||
project_is_scheduable = frappe.get_value("Project", doc.project, "ready_to_schedule")
|
||||
if not project_is_scheduable:
|
||||
print("DEBUG: Half-down payment made, setting Project to ready to schedule.")
|
||||
project_doc = frappe.get_doc("Project", doc.project)
|
||||
project_doc.ready_to_schedule = 1
|
||||
project_doc.save()
|
||||
|
||||
|
||||
|
||||
def create_sales_invoice_from_sales_order(doc, method):
|
||||
pass
|
||||
# try:
|
||||
# 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
|
||||
# items = []
|
||||
# for so_item in doc.items:
|
||||
# # proportionally reduce rate if half-payment
|
||||
# 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
|
||||
# items.append({
|
||||
# "item_code": so_item.item_code,
|
||||
# "qty": qty,
|
||||
# "rate": rate,
|
||||
# "income_account": so_item.income_account,
|
||||
# "cost_center": so_item.cost_center,
|
||||
# "so_detail": so_item.name # links item to Sales Order
|
||||
# })
|
||||
# invoice = frappe.get_doc({
|
||||
# "doctype": "Sales Invoice",
|
||||
# "customer": doc.customer,
|
||||
# "company": doc.company,
|
||||
# "posting_date": frappe.utils.nowdate(),
|
||||
# "due_date": frappe.utils.nowdate(), # or calculate from payment terms
|
||||
# "currency": doc.currency,
|
||||
# "update_stock": 0,
|
||||
# "items": items,
|
||||
# "sales_order": doc.name, # link invoice to Sales Order
|
||||
# "ignore_pricing_rule": 1,
|
||||
# "payment_schedule": doc.payment_schedule if not half_payment else [] # optional
|
||||
# })
|
||||
|
||||
# invoice.insert()
|
||||
# invoice.submit()
|
||||
# frappe.db.commit()
|
||||
# return invoice
|
||||
# except Exception as 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")
|
||||
try:
|
||||
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
|
||||
items = []
|
||||
for so_item in doc.items:
|
||||
# proportionally reduce rate if half-payment
|
||||
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
|
||||
items.append({
|
||||
"item_code": so_item.item_code,
|
||||
"qty": qty,
|
||||
"rate": rate,
|
||||
"income_account": so_item.income_account,
|
||||
"cost_center": so_item.cost_center,
|
||||
"so_detail": so_item.name # links item to Sales Order
|
||||
})
|
||||
invoice = frappe.get_doc({
|
||||
"doctype": "Sales Invoice",
|
||||
"customer": doc.customer,
|
||||
"company": doc.company,
|
||||
"posting_date": frappe.utils.nowdate(),
|
||||
"due_date": frappe.utils.nowdate(), # or calculate from payment terms
|
||||
"currency": doc.currency,
|
||||
"update_stock": 0,
|
||||
"items": items,
|
||||
"sales_order": doc.name, # link invoice to Sales Order
|
||||
"ignore_pricing_rule": 1,
|
||||
"payment_schedule": doc.payment_schedule if not half_payment else [] # optional
|
||||
})
|
||||
|
||||
invoice.insert()
|
||||
invoice.submit()
|
||||
frappe.db.commit()
|
||||
return invoice
|
||||
except Exception as 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")
|
||||
|
||||
@ -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())
|
||||
@ -1,34 +1,7 @@
|
||||
import frappe
|
||||
from custom_ui.services import AddressService, ClientService, TaskService
|
||||
|
||||
def before_insert(doc, method):
|
||||
"""Set values before inserting a Task."""
|
||||
print("DEBUG: Before Insert Triggered for Task")
|
||||
project_doc = frappe.get_doc("Project", doc.project)
|
||||
doc.project_template = project_doc.project_template
|
||||
doc.customer = project_doc.customer
|
||||
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())
|
||||
|
||||
if project_doc.custom_installation_address:
|
||||
doc.custom_property = project_doc.custom_installation_address
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
[]
|
||||
@ -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,
|
||||
"doc_type": "Item",
|
||||
@ -7343,6 +7327,22 @@
|
||||
"row_name": null,
|
||||
"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,
|
||||
"doc_type": "Contact",
|
||||
@ -7375,6 +7375,22 @@
|
||||
"row_name": null,
|
||||
"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,
|
||||
"doc_type": "Contact",
|
||||
@ -10959,6 +10975,22 @@
|
||||
"row_name": null,
|
||||
"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,
|
||||
"doc_type": "Payment Entry",
|
||||
@ -11583,6 +11615,22 @@
|
||||
"row_name": null,
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"default_value": null,
|
||||
"doc_type": "Sales Order",
|
||||
"docstatus": 0,
|
||||
"doctype": "Property Setter",
|
||||
"doctype_or_field": "DocType",
|
||||
"field_name": null,
|
||||
"is_system_generated": 0,
|
||||
"modified": "2025-04-25 03:31:21.087382",
|
||||
"module": null,
|
||||
"name": "Sales Order-main-field_order",
|
||||
"property": "field_order",
|
||||
"property_type": "Data",
|
||||
"row_name": null,
|
||||
"value": "[\"customer_section\", \"column_break0\", \"custom_installation_address\", \"custom_requires_halfdown\", \"title\", \"naming_series\", \"customer\", \"customer_name\", \"tax_id\", \"column_break_7\", \"transaction_date\", \"order_type\", \"delivery_date\", \"custom_department_type\", \"custom_project_complete\", \"column_break1\", \"po_no\", \"po_date\", \"company\", \"skip_delivery_note\", \"amended_from\", \"custom_section_break_htf05\", \"custom_workflow_related_custom_fields__landry\", \"custom_coordinator_notification\", \"custom_sales_order_addon\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"sec_warehouse\", \"scan_barcode\", \"column_break_28\", \"set_warehouse\", \"reserve_stock\", \"items_section\", \"items\", \"section_break_31\", \"total_qty\", \"total_net_weight\", \"column_break_33\", \"base_total\", \"base_net_total\", \"column_break_33a\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"exempt_from_sales_tax\", \"column_break_38\", \"shipping_rule\", \"column_break_49\", \"incoterm\", \"named_place\", \"section_break_40\", \"taxes\", \"section_break_43\", \"base_total_taxes_and_charges\", \"column_break_46\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"in_words\", \"advance_paid\", \"disable_rounded_total\", \"section_break_48\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_50\", \"additional_discount_percentage\", \"discount_amount\", \"sec_tax_breakup\", \"other_charges_calculation\", \"packing_list\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"contact_info\", \"billing_address_column\", \"customer_address\", \"address_display\", \"customer_group\", \"territory\", \"column_break_84\", \"contact_person\", \"contact_display\", \"contact_phone\", \"contact_mobile\", \"contact_email\", \"shipping_address_column\", \"shipping_address_name\", \"shipping_address\", \"column_break_93\", \"dispatch_address_name\", \"dispatch_address\", \"col_break46\", \"company_address\", \"column_break_92\", \"company_address_display\", \"payment_schedule_section\", \"payment_terms_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info\", \"section_break_78\", \"status\", \"delivery_status\", \"per_delivered\", \"column_break_81\", \"per_billed\", \"per_picked\", \"billing_status\", \"sales_team_section_break\", \"sales_partner\", \"column_break7\", \"amount_eligible_for_commission\", \"commission_rate\", \"total_commission\", \"section_break1\", \"sales_team\", \"loyalty_points_redemption\", \"loyalty_points\", \"column_break_116\", \"loyalty_amount\", \"subscription_section\", \"from_date\", \"to_date\", \"column_break_108\", \"auto_repeat\", \"update_auto_repeat_reference\", \"printing_details\", \"letter_head\", \"group_same_items\", \"column_break4\", \"select_print_heading\", \"language\", \"additional_info_section\", \"is_internal_customer\", \"represents_company\", \"column_break_152\", \"source\", \"inter_company_order_reference\", \"campaign\", \"party_account_currency\", \"connections_tab\"]"
|
||||
},
|
||||
{
|
||||
"default_value": null,
|
||||
"doc_type": "Address",
|
||||
@ -11663,6 +11711,38 @@
|
||||
"row_name": null,
|
||||
"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,
|
||||
"doc_type": "Address",
|
||||
@ -12095,6 +12175,22 @@
|
||||
"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\"]"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"doc_type": "Attendance",
|
||||
@ -12207,6 +12303,54 @@
|
||||
"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\"]"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"doc_type": "Payment Entry",
|
||||
@ -12527,6 +12671,22 @@
|
||||
"row_name": null,
|
||||
"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,
|
||||
"doc_type": "Contact",
|
||||
@ -12575,6 +12735,22 @@
|
||||
"row_name": null,
|
||||
"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,
|
||||
"doc_type": "Address",
|
||||
@ -12591,102 +12767,6 @@
|
||||
"row_name": null,
|
||||
"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,
|
||||
"doc_type": "Project",
|
||||
@ -14942,213 +15022,5 @@
|
||||
"property_type": "Check",
|
||||
"row_name": null,
|
||||
"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\"]"
|
||||
},
|
||||
{
|
||||
"default_value": null,
|
||||
"doc_type": "Sales Order",
|
||||
"docstatus": 0,
|
||||
"doctype": "Property Setter",
|
||||
"doctype_or_field": "DocType",
|
||||
"field_name": null,
|
||||
"is_system_generated": 0,
|
||||
"modified": "2026-02-05 12:10:08.553140",
|
||||
"module": null,
|
||||
"name": "Sales Order-main-field_order",
|
||||
"property": "field_order",
|
||||
"property_type": "Data",
|
||||
"row_name": null,
|
||||
"value": "[\"customer_section\", \"column_break0\", \"custom_installation_address\", \"custom_job_address\", \"requires_half_payment\", \"custom_project_template\", \"custom_requires_halfdown\", \"title\", \"naming_series\", \"customer\", \"customer_name\", \"tax_id\", \"custom_halfdown_is_paid\", \"custom_halfdown_amount\", \"column_break_7\", \"transaction_date\", \"order_type\", \"delivery_date\", \"custom_department_type\", \"custom_project_complete\", \"column_break1\", \"po_no\", \"po_date\", \"company\", \"skip_delivery_note\", \"has_unit_price_items\", \"amended_from\", \"custom_section_break_htf05\", \"custom_workflow_related_custom_fields__landry\", \"custom_coordinator_notification\", \"custom_sales_order_addon\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"sec_warehouse\", \"scan_barcode\", \"last_scanned_warehouse\", \"column_break_28\", \"set_warehouse\", \"reserve_stock\", \"items_section\", \"items\", \"section_break_31\", \"total_qty\", \"total_net_weight\", \"column_break_33\", \"base_total\", \"base_net_total\", \"column_break_33a\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"exempt_from_sales_tax\", \"column_break_38\", \"shipping_rule\", \"column_break_49\", \"incoterm\", \"named_place\", \"section_break_40\", \"taxes\", \"section_break_43\", \"base_total_taxes_and_charges\", \"column_break_46\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"in_words\", \"advance_paid\", \"disable_rounded_total\", \"section_break_48\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_50\", \"additional_discount_percentage\", \"discount_amount\", \"sec_tax_breakup\", \"other_charges_calculation\", \"packing_list\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"contact_info\", \"billing_address_column\", \"customer_address\", \"address_display\", \"customer_group\", \"territory\", \"column_break_84\", \"contact_person\", \"contact_display\", \"contact_phone\", \"contact_mobile\", \"contact_email\", \"shipping_address_column\", \"shipping_address_name\", \"shipping_address\", \"column_break_93\", \"dispatch_address_name\", \"dispatch_address\", \"col_break46\", \"company_address\", \"column_break_92\", \"company_contact_person\", \"company_address_display\", \"payment_schedule_section\", \"payment_terms_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info\", \"section_break_78\", \"status\", \"delivery_status\", \"per_delivered\", \"column_break_81\", \"per_billed\", \"per_picked\", \"billing_status\", \"sales_team_section_break\", \"sales_partner\", \"column_break7\", \"amount_eligible_for_commission\", \"commission_rate\", \"total_commission\", \"section_break1\", \"sales_team\", \"loyalty_points_redemption\", \"loyalty_points\", \"column_break_116\", \"loyalty_amount\", \"subscription_section\", \"from_date\", \"to_date\", \"column_break_108\", \"auto_repeat\", \"update_auto_repeat_reference\", \"printing_details\", \"letter_head\", \"group_same_items\", \"column_break4\", \"select_print_heading\", \"language\", \"additional_info_section\", \"is_internal_customer\", \"represents_company\", \"column_break_152\", \"source\", \"inter_company_order_reference\", \"campaign\", \"party_account_currency\", \"connections_tab\"]"
|
||||
},
|
||||
{
|
||||
"default_value": null,
|
||||
"doc_type": "Stripe Settings",
|
||||
"docstatus": 0,
|
||||
"doctype": "Property Setter",
|
||||
"doctype_or_field": "DocType",
|
||||
"field_name": null,
|
||||
"is_system_generated": 0,
|
||||
"modified": "2026-02-06 08:00:17.665416",
|
||||
"module": null,
|
||||
"name": "Stripe Settings-main-field_order",
|
||||
"property": "field_order",
|
||||
"property_type": "Data",
|
||||
"row_name": null,
|
||||
"value": "[\"gateway_name\", \"publishable_key\", \"custom_webhook_secret\", \"column_break_3\", \"secret_key\", \"custom_company\", \"custom_account\", \"section_break_5\", \"header_img\", \"column_break_7\", \"redirect_url\"]"
|
||||
}
|
||||
]
|
||||
@ -26,10 +26,6 @@ add_to_apps_screen = [
|
||||
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
||||
}
|
||||
]
|
||||
|
||||
requires = [
|
||||
"holidays==0.89"
|
||||
]
|
||||
# Apps
|
||||
# ------------------
|
||||
|
||||
@ -37,13 +33,13 @@ requires = [
|
||||
|
||||
# Each item in the list will be shown as an app in the apps page
|
||||
# add_to_apps_screen = [
|
||||
# {
|
||||
# "name": "custom_ui",
|
||||
# "logo": "/assets/custom_ui/logo.png",
|
||||
# "title": "Custom Ui",
|
||||
# "route": "/custom_ui",
|
||||
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
||||
# }
|
||||
# {
|
||||
# "name": "custom_ui",
|
||||
# "logo": "/assets/custom_ui/logo.png",
|
||||
# "title": "Custom Ui",
|
||||
# "route": "/custom_ui",
|
||||
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
||||
# }
|
||||
# ]
|
||||
|
||||
# Includes in <head>
|
||||
@ -86,7 +82,7 @@ requires = [
|
||||
|
||||
# website user home page (by Role)
|
||||
# role_home_page = {
|
||||
# "Role": "home_page"
|
||||
# "Role": "home_page"
|
||||
# }
|
||||
|
||||
# Generators
|
||||
@ -100,8 +96,8 @@ requires = [
|
||||
|
||||
# add methods and filters to jinja environment
|
||||
# jinja = {
|
||||
# "methods": "custom_ui.utils.jinja_methods",
|
||||
# "filters": "custom_ui.utils.jinja_filters"
|
||||
# "methods": "custom_ui.utils.jinja_methods",
|
||||
# "filters": "custom_ui.utils.jinja_filters"
|
||||
# }
|
||||
|
||||
# Installation
|
||||
@ -143,11 +139,11 @@ requires = [
|
||||
# Permissions evaluated in scripted ways
|
||||
|
||||
# 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 = {
|
||||
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
||||
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
||||
# }
|
||||
|
||||
# DocType Class
|
||||
@ -155,7 +151,7 @@ requires = [
|
||||
# Override standard doctype classes
|
||||
|
||||
# override_doctype_class = {
|
||||
# "ToDo": "custom_app.overrides.CustomToDo"
|
||||
# "ToDo": "custom_app.overrides.CustomToDo"
|
||||
# }
|
||||
|
||||
# Document Events
|
||||
@ -163,11 +159,11 @@ requires = [
|
||||
# Hook on document methods and events
|
||||
|
||||
doc_events = {
|
||||
"On-Site Meeting": {
|
||||
"after_insert": "custom_ui.events.onsite_meeting.after_insert",
|
||||
"On-Site Meeting": {
|
||||
"after_insert": "custom_ui.events.onsite_meeting.after_insert",
|
||||
"before_save": "custom_ui.events.onsite_meeting.before_save",
|
||||
"before_insert": "custom_ui.events.onsite_meeting.before_insert"
|
||||
},
|
||||
},
|
||||
"Address": {
|
||||
"before_insert": "custom_ui.events.address.before_insert"
|
||||
},
|
||||
@ -179,63 +175,38 @@ doc_events = {
|
||||
"on_update_after_submit": "custom_ui.events.estimate.on_update_after_submit"
|
||||
},
|
||||
"Sales Order": {
|
||||
"before_save": "custom_ui.events.sales_order.before_save",
|
||||
"before_insert": "custom_ui.events.sales_order.before_insert",
|
||||
"after_insert": "custom_ui.events.sales_order.after_insert",
|
||||
"on_submit": "custom_ui.events.sales_order.on_submit",
|
||||
"on_update_after_submit": "custom_ui.events.sales_order.on_update_after_submit"
|
||||
"on_submit": "custom_ui.events.sales_order.on_submit"
|
||||
},
|
||||
"Project": {
|
||||
"before_insert": "custom_ui.events.jobs.before_insert",
|
||||
"after_insert": "custom_ui.events.jobs.after_insert",
|
||||
"before_save": "custom_ui.events.jobs.before_save",
|
||||
"on_update": "custom_ui.events.jobs.after_save",
|
||||
"after_save": "custom_ui.events.jobs.after_save"
|
||||
"after_insert": "custom_ui.events.jobs.after_insert"
|
||||
},
|
||||
"Task": {
|
||||
"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"
|
||||
},
|
||||
"Payment Entry": {
|
||||
"on_submit": "custom_ui.events.payments.on_submit"
|
||||
},
|
||||
"Sales Invoice": {
|
||||
"on_submit": "custom_ui.events.sales_invoice.on_submit"
|
||||
"before_insert": "custom_ui.events.task.before_insert"
|
||||
}
|
||||
}
|
||||
|
||||
fixtures = [
|
||||
{
|
||||
"dt": "Email Template",
|
||||
"filters": [
|
||||
["name", "in", ["Customer Invoice"]]
|
||||
]
|
||||
},
|
||||
{
|
||||
"dt": "DocType",
|
||||
"filters": [
|
||||
["custom", "=", 1]
|
||||
]
|
||||
},
|
||||
|
||||
# These don't have reliable flags → export all
|
||||
{"dt": "Custom Field"},
|
||||
{"dt": "Property Setter"},
|
||||
{"dt": "Client Script"},
|
||||
{"dt": "Server Script"},
|
||||
# {"dt": "Report"},
|
||||
# {"dt": "Print Format"},
|
||||
# {"dt": "Dashboard"},
|
||||
# {"dt": "Workspace"},
|
||||
{
|
||||
"dt": "Custom Field"
|
||||
},
|
||||
{
|
||||
"dt": "Property Setter"
|
||||
},
|
||||
{
|
||||
"dt": "Client Script"
|
||||
},
|
||||
{
|
||||
"dt": "Server Script"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -247,21 +218,21 @@ fixtures = [
|
||||
# ---------------
|
||||
|
||||
# scheduler_events = {
|
||||
# "all": [
|
||||
# "custom_ui.tasks.all"
|
||||
# ],
|
||||
# "daily": [
|
||||
# "custom_ui.tasks.daily"
|
||||
# ],
|
||||
# "hourly": [
|
||||
# "custom_ui.tasks.hourly"
|
||||
# ],
|
||||
# "weekly": [
|
||||
# "custom_ui.tasks.weekly"
|
||||
# ],
|
||||
# "monthly": [
|
||||
# "custom_ui.tasks.monthly"
|
||||
# ],
|
||||
# "all": [
|
||||
# "custom_ui.tasks.all"
|
||||
# ],
|
||||
# "daily": [
|
||||
# "custom_ui.tasks.daily"
|
||||
# ],
|
||||
# "hourly": [
|
||||
# "custom_ui.tasks.hourly"
|
||||
# ],
|
||||
# "weekly": [
|
||||
# "custom_ui.tasks.weekly"
|
||||
# ],
|
||||
# "monthly": [
|
||||
# "custom_ui.tasks.monthly"
|
||||
# ],
|
||||
# }
|
||||
|
||||
# Testing
|
||||
@ -273,14 +244,14 @@ fixtures = [
|
||||
# ------------------------------
|
||||
#
|
||||
# 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;
|
||||
# generated from the base implementation of the doctype dashboard,
|
||||
# along with any modifications made in other Frappe apps
|
||||
# override_doctype_dashboards = {
|
||||
# "Task": "custom_ui.task.get_dashboard_data"
|
||||
# "Task": "custom_ui.task.get_dashboard_data"
|
||||
# }
|
||||
|
||||
# exempt linked doctypes from being automatically cancelled
|
||||
@ -306,37 +277,37 @@ fixtures = [
|
||||
# --------------------
|
||||
|
||||
# user_data_fields = [
|
||||
# {
|
||||
# "doctype": "{doctype_1}",
|
||||
# "filter_by": "{filter_by}",
|
||||
# "redact_fields": ["{field_1}", "{field_2}"],
|
||||
# "partial": 1,
|
||||
# },
|
||||
# {
|
||||
# "doctype": "{doctype_2}",
|
||||
# "filter_by": "{filter_by}",
|
||||
# "partial": 1,
|
||||
# },
|
||||
# {
|
||||
# "doctype": "{doctype_3}",
|
||||
# "strict": False,
|
||||
# },
|
||||
# {
|
||||
# "doctype": "{doctype_4}"
|
||||
# }
|
||||
# {
|
||||
# "doctype": "{doctype_1}",
|
||||
# "filter_by": "{filter_by}",
|
||||
# "redact_fields": ["{field_1}", "{field_2}"],
|
||||
# "partial": 1,
|
||||
# },
|
||||
# {
|
||||
# "doctype": "{doctype_2}",
|
||||
# "filter_by": "{filter_by}",
|
||||
# "partial": 1,
|
||||
# },
|
||||
# {
|
||||
# "doctype": "{doctype_3}",
|
||||
# "strict": False,
|
||||
# },
|
||||
# {
|
||||
# "doctype": "{doctype_4}"
|
||||
# }
|
||||
# ]
|
||||
|
||||
# Authentication and authorization
|
||||
# --------------------------------
|
||||
|
||||
# auth_hooks = [
|
||||
# "custom_ui.auth.validate"
|
||||
# "custom_ui.auth.validate"
|
||||
# ]
|
||||
|
||||
# Automatically update python controller files with type annotations for this app.
|
||||
# export_python_type_annotations = True
|
||||
|
||||
# default_log_clearing_doctypes = {
|
||||
# "Logging DocType Name": 30 # days to retain logs
|
||||
# "Logging DocType Name": 30 # days to retain logs
|
||||
# }
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
from .payments import PaymentData
|
||||
from .item_models import BOMItem, PackageCreationData
|
||||
@ -1,18 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class BOMItem:
|
||||
item_code: str
|
||||
qty: float
|
||||
uom: str
|
||||
item_name: str = None
|
||||
|
||||
@dataclass
|
||||
class PackageCreationData:
|
||||
package_name: str
|
||||
items: list[BOMItem]
|
||||
item_group: str
|
||||
code_prefix: str
|
||||
rate: float = 0.0
|
||||
company: str = None
|
||||
description: str = None
|
||||
@ -1,10 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class PaymentData:
|
||||
mode_of_payment: str
|
||||
reference_no: str
|
||||
reference_date: str
|
||||
received_amount: float
|
||||
company: str = None
|
||||
reference_doc_name: str = None
|
||||
@ -3,12 +3,4 @@ from .contact_service import ContactService
|
||||
from .db_service import DbService
|
||||
from .client_service import ClientService
|
||||
from .estimate_service import EstimateService
|
||||
from .onsite_meeting_service import OnSiteMeetingService
|
||||
from .task_service import TaskService
|
||||
from .service_appointment_service import ServiceAppointmentService
|
||||
from .stripe_service import StripeService
|
||||
from .payment_service import PaymentService
|
||||
from .item_service import ItemService
|
||||
from .project_service import ProjectService
|
||||
from .sales_order_service import SalesOrderService
|
||||
from .email_service import EmailService
|
||||
from .onsite_meeting_service import OnSiteMeetingService
|
||||
@ -186,7 +186,6 @@ class AddressService:
|
||||
address_doc.append(field, link)
|
||||
print("DEBUG: Saving address document after appending link.")
|
||||
address_doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
print(f"DEBUG: Set link field {field} for Address {address_name} with link data {link}")
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -55,7 +55,6 @@ class ClientService:
|
||||
client_doc.append(field, link)
|
||||
print("DEBUG: Saving client document after appending link.")
|
||||
client_doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
print(f"DEBUG: Set link field {field} for client {client_doc.get('name')} with link data {link}")
|
||||
|
||||
@staticmethod
|
||||
@ -92,7 +91,6 @@ class ClientService:
|
||||
try:
|
||||
print(f"DEBUG: Processing address: {address.get('address')}")
|
||||
ClientService.append_link_v2(customer_doc.name, "properties", {"address": address.get("address")})
|
||||
customer_doc.reload()
|
||||
address_doc = AddressService.get_or_throw(address.get("address"))
|
||||
AddressService.link_address_to_customer(address_doc, "Customer", customer_doc.name)
|
||||
print(f"DEBUG: Linked address {address.get('address')} to customer")
|
||||
@ -106,7 +104,6 @@ class ClientService:
|
||||
try:
|
||||
print(f"DEBUG: Processing contact: {contact.get('contact')}")
|
||||
ClientService.append_link_v2(customer_doc.name, "contacts", {"contact": contact.get("contact")})
|
||||
customer_doc.reload()
|
||||
contact_doc = ContactService.get_or_throw(contact.get("contact"))
|
||||
ContactService.link_contact_to_customer(contact_doc, "Customer", customer_doc.name)
|
||||
print(f"DEBUG: Linked contact {contact.get('contact')} to customer")
|
||||
@ -120,12 +117,12 @@ class ClientService:
|
||||
try:
|
||||
print(f"DEBUG: Processing quotation: {quotation.get('quotation')}")
|
||||
ClientService.append_link_v2(customer_doc.name, "quotations", {"quotation": quotation.get("quotation")})
|
||||
customer_doc.reload()
|
||||
quotation_doc = EstimateService.get_or_throw(quotation.get("quotation"))
|
||||
EstimateService.link_estimate_to_customer(quotation_doc, "Customer", customer_doc.name)
|
||||
print(f"DEBUG: Linked quotation {quotation.get('quotation')} to customer")
|
||||
except Exception as 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:
|
||||
print(f"DEBUG: Updating onsite meetings. Count: {len(lead_doc.get('onsite_meetings', []))}")
|
||||
@ -134,7 +131,6 @@ class ClientService:
|
||||
print(f"DEBUG: Processing onsite meeting: {meeting.get('onsite_meeting')}")
|
||||
meeting_doc = DbService.get_or_throw("On-Site Meeting",meeting.get("onsite_meeting"))
|
||||
ClientService.append_link_v2(customer_doc.name, "onsite_meetings", {"onsite_meeting": meeting.get("onsite_meeting")})
|
||||
customer_doc.reload()
|
||||
OnSiteMeetingService.link_onsite_meeting_to_customer(meeting_doc, "Customer", customer_doc.name)
|
||||
print(f"DEBUG: Linked onsite meeting {meeting.get('onsite_meeting')} to customer")
|
||||
except Exception as e:
|
||||
@ -146,13 +142,11 @@ class ClientService:
|
||||
try:
|
||||
print(f"DEBUG: Processing company: {company.get('company')}")
|
||||
ClientService.append_link_v2(customer_doc.name, "companies", {"company": company.get("company")})
|
||||
customer_doc.reload()
|
||||
print(f"DEBUG: Linked company {company.get('company')} to customer")
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to link company {company.get('company')}: {str(e)}")
|
||||
frappe.log_error(f"Company linking error: {str(e)}", "convert_lead_to_customer")
|
||||
print(f"DEBUG: Converted Lead {lead_name} to Customer {customer_doc.name}")
|
||||
frappe.db.commit()
|
||||
return customer_doc
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -1,240 +0,0 @@
|
||||
import frappe
|
||||
from frappe.utils import get_url
|
||||
|
||||
class EmailService:
|
||||
|
||||
@staticmethod
|
||||
def get_customer_email(customer_name: str, doctype: str = "Customer") -> str | None:
|
||||
"""
|
||||
Get the primary email for a customer or lead.
|
||||
|
||||
Args:
|
||||
customer_name: Name of the Customer or Lead
|
||||
doctype: Either "Customer" or "Lead"
|
||||
|
||||
Returns:
|
||||
Email address if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
customer_doc = frappe.get_doc(doctype, customer_name)
|
||||
email = None
|
||||
|
||||
# Try primary_contact field
|
||||
if hasattr(customer_doc, 'primary_contact') and customer_doc.primary_contact:
|
||||
try:
|
||||
primary_contact = frappe.get_doc("Contact", customer_doc.primary_contact)
|
||||
email = primary_contact.email_id
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get primary_contact: {str(e)}")
|
||||
|
||||
# Fallback to customer_primary_contact
|
||||
if not email and hasattr(customer_doc, 'customer_primary_contact') and customer_doc.customer_primary_contact:
|
||||
try:
|
||||
primary_contact = frappe.get_doc("Contact", customer_doc.customer_primary_contact)
|
||||
email = primary_contact.email_id
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get customer_primary_contact: {str(e)}")
|
||||
|
||||
# Last resort - get any contact linked to this customer/lead
|
||||
if not email:
|
||||
contact_links = frappe.get_all("Dynamic Link",
|
||||
filters={
|
||||
"link_doctype": doctype,
|
||||
"link_name": customer_name,
|
||||
"parenttype": "Contact"
|
||||
},
|
||||
pluck="parent"
|
||||
)
|
||||
if contact_links:
|
||||
try:
|
||||
contact = frappe.get_doc("Contact", contact_links[0])
|
||||
email = contact.email_id
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get contact from dynamic link: {str(e)}")
|
||||
|
||||
return email
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to get email for {doctype} {customer_name}: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def send_templated_email(
|
||||
recipients: str | list,
|
||||
subject: str,
|
||||
template_path: str,
|
||||
template_context: dict,
|
||||
doctype: str = None,
|
||||
docname: str = None,
|
||||
cc: str | list = None,
|
||||
bcc: str | list = None,
|
||||
attachments: list = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email using a Jinja2 template.
|
||||
|
||||
Args:
|
||||
recipients: Email address(es) to send to
|
||||
subject: Email subject line
|
||||
template_path: Path to the Jinja2 template (relative to app root)
|
||||
template_context: Dictionary of variables to pass to template
|
||||
doctype: Optional doctype to link email to
|
||||
docname: Optional document name to link email to
|
||||
cc: Optional CC recipients
|
||||
bcc: Optional BCC recipients
|
||||
attachments: Optional list of attachments
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Render the email template
|
||||
message = frappe.render_template(template_path, template_context)
|
||||
|
||||
# Prepare sendmail arguments
|
||||
email_args = {
|
||||
"recipients": recipients,
|
||||
"subject": subject,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if doctype:
|
||||
email_args["doctype"] = doctype
|
||||
if docname:
|
||||
email_args["name"] = docname
|
||||
if cc:
|
||||
email_args["cc"] = cc
|
||||
if bcc:
|
||||
email_args["bcc"] = bcc
|
||||
if attachments:
|
||||
email_args["attachments"] = attachments
|
||||
|
||||
# Send email
|
||||
frappe.sendmail(**email_args)
|
||||
|
||||
print(f"DEBUG: Email sent successfully to {recipients}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send email: {str(e)}")
|
||||
frappe.log_error(f"Failed to send email to {recipients}: {str(e)}", "Email Service Error")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def send_downpayment_email(sales_order_name: str) -> bool:
|
||||
"""
|
||||
Send a down payment email for a Sales Order.
|
||||
|
||||
Args:
|
||||
sales_order_name: Name of the Sales Order
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
doc = frappe.get_doc("Sales Order", sales_order_name)
|
||||
|
||||
# Get customer email
|
||||
email = EmailService.get_customer_email(doc.customer, "Customer")
|
||||
|
||||
if not email:
|
||||
print(f"ERROR: No email found for customer {doc.customer}, cannot send down payment email")
|
||||
return False
|
||||
|
||||
# Prepare template context
|
||||
half_down_amount = doc.custom_halfdown_amount or (doc.grand_total / 2)
|
||||
base_url = get_url()
|
||||
|
||||
template_context = {
|
||||
"company_name": doc.company,
|
||||
"customer_name": doc.customer_name or doc.customer,
|
||||
"sales_order_number": doc.name,
|
||||
"total_amount": frappe.utils.fmt_money(half_down_amount, currency=doc.currency),
|
||||
"base_url": base_url
|
||||
}
|
||||
|
||||
# Send email
|
||||
template_path = "custom_ui/templates/emails/downpayment.html"
|
||||
subject = f"Down Payment Required - {doc.company} - {doc.name}"
|
||||
|
||||
return EmailService.send_templated_email(
|
||||
recipients=email,
|
||||
subject=subject,
|
||||
template_path=template_path,
|
||||
template_context=template_context,
|
||||
doctype="Sales Order",
|
||||
docname=doc.name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send down payment email for {sales_order_name}: {str(e)}")
|
||||
frappe.log_error(f"Failed to send down payment email for {sales_order_name}: {str(e)}", "Down Payment Email Error")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def send_invoice_email(sales_invoice_name: str) -> bool:
|
||||
"""
|
||||
Send an invoice email for a Sales Invoice.
|
||||
|
||||
Args:
|
||||
sales_invoice_name: Name of the Sales Invoice
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
doc = frappe.get_doc("Sales Invoice", sales_invoice_name)
|
||||
|
||||
# Get customer email
|
||||
email = EmailService.get_customer_email(doc.customer, "Customer")
|
||||
|
||||
if not email:
|
||||
print(f"ERROR: No email found for customer {doc.customer}, cannot send invoice email")
|
||||
return False
|
||||
|
||||
# Calculate amounts
|
||||
outstanding_amount = doc.outstanding_amount
|
||||
paid_amount = doc.grand_total - outstanding_amount
|
||||
|
||||
# Get related Sales Order if available
|
||||
sales_order = None
|
||||
if hasattr(doc, 'items') and doc.items:
|
||||
for item in doc.items:
|
||||
if item.sales_order:
|
||||
sales_order = item.sales_order
|
||||
break
|
||||
|
||||
# Prepare template context
|
||||
base_url = get_url()
|
||||
|
||||
template_context = {
|
||||
"company_name": doc.company,
|
||||
"customer_name": doc.customer_name or doc.customer,
|
||||
"invoice_number": doc.name,
|
||||
"invoice_date": doc.posting_date,
|
||||
"due_date": doc.due_date,
|
||||
"grand_total": frappe.utils.fmt_money(doc.grand_total, currency=doc.currency),
|
||||
"outstanding_amount": frappe.utils.fmt_money(outstanding_amount, currency=doc.currency),
|
||||
"paid_amount": frappe.utils.fmt_money(paid_amount, currency=doc.currency),
|
||||
"sales_order": sales_order,
|
||||
"base_url": base_url,
|
||||
"payment_url": f"{base_url}/api/method/custom_ui.api.public.payments.invoice_stripe_payment?sales_invoice={doc.name}" if outstanding_amount > 0 else None
|
||||
}
|
||||
|
||||
# Send email
|
||||
template_path = "custom_ui/templates/emails/invoice.html"
|
||||
subject = f"Invoice {doc.name} - {doc.company}"
|
||||
|
||||
return EmailService.send_templated_email(
|
||||
recipients=email,
|
||||
subject=subject,
|
||||
template_path=template_path,
|
||||
template_context=template_context,
|
||||
doctype="Sales Invoice",
|
||||
docname=doc.name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to send invoice email for {sales_invoice_name}: {str(e)}")
|
||||
frappe.log_error(f"Failed to send invoice email for {sales_invoice_name}: {str(e)}", "Invoice Email Error")
|
||||
return False
|
||||
@ -1,5 +1,4 @@
|
||||
import frappe
|
||||
from .item_service import ItemService
|
||||
|
||||
class EstimateService:
|
||||
|
||||
@ -94,18 +93,4 @@ class EstimateService:
|
||||
estimate_doc.customer = customer_name
|
||||
estimate_doc.save(ignore_permissions=True)
|
||||
print(f"DEBUG: Linked Quotation {estimate_doc.name} to {customer_type} {customer_name}")
|
||||
|
||||
@staticmethod
|
||||
def map_project_template_to_filter(project_template: str = None) -> dict | None:
|
||||
"""Map a project template to a filter."""
|
||||
print(f"DEBUG: Mapping project template {project_template} to quotation category")
|
||||
if not project_template:
|
||||
print("DEBUG: No project template provided, defaulting to 'General'")
|
||||
return None
|
||||
mapping = {
|
||||
# SNW Install is both Irrigation and SNW-S categories
|
||||
"SNW Install": ["in", ["Irrigation", "SNW-S", "Landscaping"]],
|
||||
}
|
||||
category = mapping.get(project_template, "General")
|
||||
print(f"DEBUG: Mapped to quotation category: {category}")
|
||||
return { "item_group": category }
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
import frappe
|
||||
|
||||
class ItemService:
|
||||
|
||||
@staticmethod
|
||||
def get_item_category(item_code: str) -> str:
|
||||
"""Retrieve the category of an Item document by item code."""
|
||||
print(f"DEBUG: Getting category for Item {item_code}")
|
||||
category = frappe.db.get_value("Item", item_code, "item_group")
|
||||
print(f"DEBUG: Retrieved category: {category}")
|
||||
return category
|
||||
|
||||
@staticmethod
|
||||
def get_full_dict(item_code: str) -> frappe._dict:
|
||||
"""Retrieve the full Item document by item code."""
|
||||
print(f"DEBUG: Getting full document for Item {item_code}")
|
||||
item_doc = frappe.get_doc("Item", item_code).as_dict()
|
||||
item_doc["bom"] = ItemService.get_full_bom_dict(item_code) if item_doc.get("default_bom") else None
|
||||
return item_doc
|
||||
|
||||
@staticmethod
|
||||
def get_full_bom_dict(item_code: str):
|
||||
"""Retrieve the Bill of Materials (BOM) associated with an Item."""
|
||||
print(f"DEBUG: Getting BOM for Item {item_code}")
|
||||
bom_name = frappe.db.get_value("BOM", {"item": item_code, "is_active": 1}, "name")
|
||||
bom_dict = frappe.get_doc("BOM", bom_name).as_dict()
|
||||
for item in bom_dict.get('items', []):
|
||||
bom_no = item.get("bom_no")
|
||||
if bom_no:
|
||||
bom_item_code = frappe.db.get_value("BOM", bom_no, "item")
|
||||
item["bom"] = ItemService.get_full_bom_dict(bom_item_code)
|
||||
return bom_dict
|
||||
|
||||
@staticmethod
|
||||
def exists(item_code: str) -> bool:
|
||||
"""Check if an Item document exists by item code."""
|
||||
print(f"DEBUG: Checking existence of Item {item_code}")
|
||||
exists = frappe.db.exists("Item", item_code) is not None
|
||||
print(f"DEBUG: Item {item_code} exists: {exists}")
|
||||
return exists
|
||||
|
||||
@staticmethod
|
||||
def get_child_groups(item_group: str) -> list[str]:
|
||||
"""Retrieve all child item groups of a given item group."""
|
||||
print(f"DEBUG: Getting child groups for Item Group {item_group}")
|
||||
children = []
|
||||
child_groups = frappe.get_all("Item Group", filters={"parent_item_group": item_group}, pluck="name")
|
||||
if child_groups:
|
||||
children.extend(child_groups)
|
||||
print(f"DEBUG: Found child groups: {child_groups}. Checking for further children.")
|
||||
for child_group in child_groups:
|
||||
additional_child_groups = ItemService.get_child_groups(child_group)
|
||||
children.extend(additional_child_groups)
|
||||
|
||||
print(f"DEBUG: Retrieved child groups: {child_groups}")
|
||||
return children
|
||||
|
||||
@staticmethod
|
||||
def get_item_names_by_group(item_groups: set[str]) -> list[str]:
|
||||
"""Retrieve item names for items belonging to the specified item groups."""
|
||||
print(f"DEBUG: Getting item names for Item Groups {item_groups}")
|
||||
items = frappe.get_all("Item", filters={"item_group": ["in", list(item_groups)]}, pluck="name")
|
||||
print(f"DEBUG: Retrieved item names: {items}")
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def get_items_by_groups(item_groups: list[str]) -> list[dict]:
|
||||
"""Retrieve all items belonging to the specified item groups."""
|
||||
print(f"DEBUG: Getting items for Item Groups {item_groups}")
|
||||
all_groups = set(item_groups)
|
||||
for group in item_groups:
|
||||
all_groups.update(ItemService.get_child_groups(group))
|
||||
|
||||
# Batch fetch all items at once with all needed fields
|
||||
items = frappe.get_all(
|
||||
"Item",
|
||||
filters={"item_group": ["in", list(all_groups)]},
|
||||
fields=[
|
||||
"name", "item_code", "item_name", "item_group", "description",
|
||||
"standard_rate", "stock_uom", "default_bom"
|
||||
]
|
||||
)
|
||||
|
||||
# Get all item codes that have BOMs
|
||||
items_with_boms = [item for item in items if item.get("default_bom")]
|
||||
item_codes_with_boms = [item["item_code"] for item in items_with_boms]
|
||||
|
||||
# Batch fetch all BOMs and their nested structure
|
||||
bom_dict = ItemService.batch_fetch_boms(item_codes_with_boms) if item_codes_with_boms else {}
|
||||
|
||||
# Attach BOMs to items
|
||||
for item in items:
|
||||
if item.get("default_bom"):
|
||||
item["bom"] = bom_dict.get(item["item_code"])
|
||||
else:
|
||||
item["bom"] = None
|
||||
|
||||
print(f"DEBUG: Retrieved {len(items)} items")
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def batch_fetch_boms(item_codes: list[str]) -> dict:
|
||||
"""Batch fetch all BOMs and build nested structure efficiently."""
|
||||
if not item_codes:
|
||||
return {}
|
||||
|
||||
print(f"DEBUG: Batch fetching BOMs for {len(item_codes)} items")
|
||||
|
||||
# Fetch all active BOMs for the given items
|
||||
boms = frappe.get_all(
|
||||
"BOM",
|
||||
filters={"item": ["in", item_codes], "is_active": 1},
|
||||
fields=["name", "item"]
|
||||
)
|
||||
|
||||
if not boms:
|
||||
return {}
|
||||
|
||||
bom_names = [bom["name"] for bom in boms]
|
||||
|
||||
# Fetch all BOM items (children) in one query
|
||||
bom_items = frappe.get_all(
|
||||
"BOM Item",
|
||||
filters={"parent": ["in", bom_names]},
|
||||
fields=["parent", "item_code", "item_name", "qty", "uom", "bom_no"],
|
||||
order_by="idx"
|
||||
)
|
||||
|
||||
# Group BOM items by their parent BOM
|
||||
bom_items_map = {}
|
||||
nested_bom_items = set()
|
||||
|
||||
for bom_item in bom_items:
|
||||
parent = bom_item["parent"]
|
||||
if parent not in bom_items_map:
|
||||
bom_items_map[parent] = []
|
||||
bom_items_map[parent].append(bom_item)
|
||||
|
||||
# Track which items have nested BOMs
|
||||
if bom_item.get("bom_no"):
|
||||
nested_bom_items.add(bom_item["item_code"])
|
||||
|
||||
# Recursively fetch nested BOMs if any
|
||||
nested_bom_dict = {}
|
||||
if nested_bom_items:
|
||||
nested_bom_dict = ItemService.batch_fetch_boms(list(nested_bom_items))
|
||||
|
||||
# Build the result dictionary mapping item_code to its BOM structure
|
||||
result = {}
|
||||
for bom in boms:
|
||||
bom_name = bom["name"]
|
||||
item_code = bom["item"]
|
||||
|
||||
items = bom_items_map.get(bom_name, [])
|
||||
# Attach nested BOMs to items
|
||||
for item in items:
|
||||
if item.get("bom_no"):
|
||||
item["bom"] = nested_bom_dict.get(item["item_code"])
|
||||
else:
|
||||
item["bom"] = None
|
||||
|
||||
result[item_code] = {
|
||||
"name": bom_name,
|
||||
"items": items
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def build_category_dict(items: list[dict]) -> dict:
|
||||
"""Build a dictionary categorizing items by their item group."""
|
||||
print(f"DEBUG: Building category dictionary for items")
|
||||
category_dict = {}
|
||||
category_dict["Packages"] = {}
|
||||
for item in items:
|
||||
if item.get("bom"):
|
||||
if item.get("item_group", "Uncategorized") not in category_dict["Packages"]:
|
||||
category_dict["Packages"][item.get("item_group", "Uncategorized")] = []
|
||||
category_dict["Packages"][item.get("item_group", "Uncategorized")].append(item)
|
||||
else:
|
||||
category = item.get("item_group", "Uncategorized")
|
||||
if category not in category_dict:
|
||||
category_dict[category] = []
|
||||
category_dict[category].append(item)
|
||||
print(f"DEBUG: Built category dictionary with categories: {list(category_dict.keys())}")
|
||||
return category_dict
|
||||
|
||||
@staticmethod
|
||||
def build_item_code(prefix: str, item_name: str) -> str:
|
||||
"""Build a unique item code based on the provided prefix and item name."""
|
||||
print(f"DEBUG: Building item code with prefix: {prefix} and item name: {item_name}")
|
||||
# Replace all " " with "-" and convert to uppercase
|
||||
base_code = f"{prefix}-{item_name.replace(' ', '-').upper()}"
|
||||
# Check for existing items with the same base code and append a number if necessary
|
||||
existing_codes = frappe.get_all("Item", filters={"item_code": ["like", f"{base_code}-%"]}, pluck="item_code")
|
||||
if base_code in existing_codes:
|
||||
suffix = 1
|
||||
while f"{base_code}-{suffix}" in existing_codes:
|
||||
suffix += 1
|
||||
final_code = f"{base_code}-{suffix}"
|
||||
else:
|
||||
final_code = base_code
|
||||
print(f"DEBUG: Built item code: {final_code}")
|
||||
return final_code
|
||||
@ -1,52 +0,0 @@
|
||||
import frappe
|
||||
from custom_ui.services import DbService, StripeService
|
||||
from dataclasses import dataclass
|
||||
from custom_ui.models import PaymentData
|
||||
|
||||
|
||||
|
||||
class PaymentService:
|
||||
|
||||
@staticmethod
|
||||
def create_payment_entry(data: PaymentData) -> frappe._dict:
|
||||
"""Create a Payment Entry document based on the reference document."""
|
||||
print(f"DEBUG: Creating Payment Entry for {data.reference_doc_name} with data: {data}")
|
||||
reference_doctype = PaymentService.determine_reference_doctype(data.reference_doc_name)
|
||||
reference_doc = DbService.get_or_throw(reference_doctype, data.reference_doc_name)
|
||||
account = StripeService.get_stripe_settings(data.company).custom_account
|
||||
pe = frappe.get_doc({
|
||||
"doctype": "Payment Entry",
|
||||
"company": data.company,
|
||||
"payment_type": "Receive",
|
||||
"party_type": "Customer",
|
||||
"mode_of_payment": data.mode_of_payment or "Stripe",
|
||||
"party": reference_doc.customer,
|
||||
"party_name": reference_doc.customer,
|
||||
"paid_to": account,
|
||||
"reference_no": data.reference_no,
|
||||
"reference_date": data.reference_date or frappe.utils.nowdate(),
|
||||
"paid_amount": data.received_amount,
|
||||
"received_amount": data.received_amount,
|
||||
"paid_currency": "USD",
|
||||
"received_currency": "USD",
|
||||
"references": [{
|
||||
"reference_doctype": reference_doc.doctype,
|
||||
"reference_name": reference_doc.name,
|
||||
"allocated_amount": data.received_amount,
|
||||
}]
|
||||
})
|
||||
pe.insert()
|
||||
print(f"DEBUG: Created Payment Entry with name: {pe.name}")
|
||||
return pe
|
||||
|
||||
@staticmethod
|
||||
def determine_reference_doctype(reference_doc_name: str) -> str:
|
||||
"""Determine the reference doctype based on the document name pattern."""
|
||||
print(f"DEBUG: Determining reference doctype for document name: {reference_doc_name}")
|
||||
if DbService.exists("Sales Order", reference_doc_name):
|
||||
return "Sales Order"
|
||||
elif DbService.exists("Sales Invoice", reference_doc_name):
|
||||
return "Sales Invoice"
|
||||
else:
|
||||
frappe.throw("Unable to determine reference doctype from document name.")
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import frappe
|
||||
|
||||
class ProjectService:
|
||||
|
||||
@staticmethod
|
||||
def get_project_item_groups(project_template: str) -> list[str]:
|
||||
"""Retrieve item groups associated with a given project template."""
|
||||
print(f"DEBUG: Getting item groups for Project Template {project_template}")
|
||||
item_groups_str = frappe.db.get_value("Project Template", project_template, "item_groups") or ""
|
||||
item_groups = [item_group.strip() for item_group in item_groups_str.split(",") if item_group.strip()]
|
||||
print(f"DEBUG: Retrieved item groups: {item_groups}")
|
||||
return item_groups
|
||||
@ -1,23 +0,0 @@
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
|
||||
class SalesOrderService:
|
||||
|
||||
@staticmethod
|
||||
def create_sales_invoice_from_sales_order(sales_order_name):
|
||||
try:
|
||||
sales_order_doc = frappe.get_doc("Sales Order", sales_order_name)
|
||||
sales_invoice = make_sales_invoice(sales_order_doc.name)
|
||||
sales_invoice.project = sales_order_doc.project
|
||||
sales_invoice.posting_date = today()
|
||||
sales_invoice.due_date = today()
|
||||
sales_invoice.remarks = f"Auto-generated from Sales Order {sales_order_doc.name}"
|
||||
sales_invoice.job_address = sales_order_doc.custom_job_address
|
||||
sales_invoice.project_template = sales_order_doc.custom_project_template
|
||||
sales_invoice.insert()
|
||||
sales_invoice.submit()
|
||||
return sales_invoice.name
|
||||
except Exception as e:
|
||||
print("ERROR creating Sales Invoice from Sales Order:", str(e))
|
||||
return None
|
||||
@ -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
|
||||
@ -1,168 +0,0 @@
|
||||
import frappe
|
||||
import stripe
|
||||
import json
|
||||
from custom_ui.services import DbService
|
||||
from frappe.utils import get_url
|
||||
|
||||
class StripeService:
|
||||
|
||||
@staticmethod
|
||||
def get_stripe_settings(company: str):
|
||||
"""Fetch Stripe settings for a given company."""
|
||||
settings_name = frappe.get_all("Stripe Settings", pluck="name", filters={"custom_company": company})
|
||||
if not settings_name:
|
||||
frappe.throw(f"Stripe Settings not found for company: {company}")
|
||||
settings = frappe.get_doc("Stripe Settings", settings_name[0]) if settings_name else None
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
def get_api_key(company: str) -> str:
|
||||
"""Retrieve the Stripe API key for the specified company."""
|
||||
settings = StripeService.get_stripe_settings(company)
|
||||
return settings.get_password("secret_key")
|
||||
|
||||
@staticmethod
|
||||
def get_webhook_secret(company: str) -> str:
|
||||
"""Retrieve the Stripe webhook secret for the specified company."""
|
||||
settings = StripeService.get_stripe_settings(company)
|
||||
if not settings.custom_webhook_secret:
|
||||
frappe.throw(f"Stripe Webhook Secret not configured for company: {company}")
|
||||
return settings.custom_webhook_secret
|
||||
|
||||
@staticmethod
|
||||
def create_checkout_session(
|
||||
company: str,
|
||||
amount: float,
|
||||
service: str,
|
||||
order_num: str,
|
||||
currency: str = "usd",
|
||||
for_advance_payment: bool = False,
|
||||
line_items: list | None = None,
|
||||
sales_invoice: str = None
|
||||
) -> stripe.checkout.Session:
|
||||
"""
|
||||
Create a Stripe Checkout Session.
|
||||
|
||||
Args:
|
||||
company: Company name
|
||||
amount: Payment amount (should be the outstanding amount for invoices)
|
||||
service: Service description
|
||||
order_num: Sales Order name if for_advance_payment is True, otherwise Sales Invoice name
|
||||
currency: Currency code (default: "usd")
|
||||
for_advance_payment: True if this is an advance/down payment, False for full invoice payment
|
||||
line_items: Optional custom line items for the checkout session
|
||||
sales_invoice: Sales Invoice name (for full payments)
|
||||
|
||||
Returns:
|
||||
stripe.checkout.Session object
|
||||
"""
|
||||
stripe.api_key = StripeService.get_api_key(company)
|
||||
|
||||
# Determine payment description
|
||||
if for_advance_payment:
|
||||
description = f"Advance payment for {company}{' - ' + service if service else ''}"
|
||||
else:
|
||||
description = f"Invoice payment for {company}{' - ' + service if service else ''}"
|
||||
if sales_invoice:
|
||||
description = f"Invoice {sales_invoice} - {company}"
|
||||
|
||||
# Use custom line items if provided and not an advance payment, otherwise create default line item
|
||||
line_items = line_items if line_items and not for_advance_payment else [{
|
||||
"price_data": {
|
||||
"currency": currency.lower(),
|
||||
"product_data": {
|
||||
"name": description
|
||||
},
|
||||
"unit_amount": int(amount * 100), # Stripe expects amount in cents
|
||||
},
|
||||
"quantity": 1,
|
||||
}]
|
||||
|
||||
# Prepare metadata
|
||||
metadata = {
|
||||
"company": company,
|
||||
"payment_type": "advance" if for_advance_payment else "full"
|
||||
}
|
||||
|
||||
# Add appropriate document reference to metadata
|
||||
if for_advance_payment:
|
||||
metadata["sales_order"] = order_num
|
||||
else:
|
||||
metadata["sales_invoice"] = sales_invoice or order_num
|
||||
if sales_invoice:
|
||||
# Check if there's a related sales order
|
||||
invoice_doc = frappe.get_doc("Sales Invoice", sales_invoice)
|
||||
if hasattr(invoice_doc, 'items') and invoice_doc.items:
|
||||
for item in invoice_doc.items:
|
||||
if item.sales_order:
|
||||
metadata["sales_order"] = item.sales_order
|
||||
break
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
mode="payment",
|
||||
payment_method_types=["card"],
|
||||
line_items=line_items,
|
||||
metadata=metadata,
|
||||
success_url=f"{get_url()}/payment_success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{get_url()}/payment_cancelled",
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def get_event(payload: bytes, sig_header: str, company: str = None) -> stripe.Event:
|
||||
print("DEBUG: Stripe webhook received")
|
||||
print(f"DEBUG: Signature header present: {bool(sig_header)}")
|
||||
|
||||
# If company not provided, try to extract from payload metadata
|
||||
if not company:
|
||||
try:
|
||||
payload_dict = json.loads(payload)
|
||||
print(f"DEBUG: Parsed payload type: {payload_dict.get('type')}")
|
||||
|
||||
metadata = payload_dict.get("data", {}).get("object", {}).get("metadata", {})
|
||||
print(f"DEBUG: Metadata from payload: {metadata}")
|
||||
|
||||
company = metadata.get("company")
|
||||
print(f"DEBUG: Extracted company from metadata: {company}")
|
||||
except (json.JSONDecodeError, KeyError, AttributeError) as e:
|
||||
print(f"DEBUG: Failed to parse payload: {str(e)}")
|
||||
|
||||
# If we still don't have a company, reject the webhook
|
||||
if not company:
|
||||
print("ERROR: Company information missing in webhook payload")
|
||||
frappe.throw("Company information missing in webhook payload.")
|
||||
|
||||
print(f"DEBUG: Validating webhook signature for company: {company}")
|
||||
|
||||
# Validate webhook signature with the specified company's secret
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload=payload,
|
||||
sig_header=sig_header,
|
||||
secret=StripeService.get_webhook_secret(company)
|
||||
)
|
||||
print(f"DEBUG: Webhook signature validated successfully for company: {company}")
|
||||
print(f"DEBUG: Event type: {event.type}")
|
||||
print(f"DEBUG: Event ID: {event.id}")
|
||||
except ValueError as e:
|
||||
print(f"ERROR: Invalid payload: {str(e)}")
|
||||
frappe.throw(f"Invalid payload: {str(e)}")
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
print(f"ERROR: Invalid signature for company {company}: {str(e)}")
|
||||
frappe.throw(f"Invalid signature for company {company}: {str(e)}")
|
||||
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def get_session_and_metadata(payload: bytes, sig_header: str, company: str = None) -> tuple[stripe.checkout.Session, dict]:
|
||||
"""Retrieve the Stripe Checkout Session and its metadata from a webhook payload."""
|
||||
event = StripeService.get_event(payload, sig_header, company)
|
||||
if event.type != "checkout.session.completed":
|
||||
frappe.throw(f"Unhandled event type: {event.type}")
|
||||
session = event.data.object
|
||||
metadata = session["metadata"] if "metadata" in session else {}
|
||||
|
||||
return session, metadata
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Already Completed</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.message-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.message-icon {
|
||||
font-size: 5rem;
|
||||
color: #74b9ff;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.message-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
.message-text {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
.contact-info {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border-left: 5px solid #74b9ff;
|
||||
text-align: left;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="message-container">
|
||||
<div class="message-icon">ℹ️</div>
|
||||
<h1 class="message-title">Payment Already Completed</h1>
|
||||
<p class="message-text">The half down payment for this sales order has already been paid.</p>
|
||||
<div class="contact-info">
|
||||
<strong>If you have any questions:</strong><br>
|
||||
Please contact our sales team for assistance or clarification regarding your order.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -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="{{ base_url }}/api/method/custom_ui.api.public.payments.half_down_stripe_payment?sales_order={{ sales_order_number }}" 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>
|
||||
@ -1,251 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Estimate from {{ company }}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.letterhead {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 3px solid #0066cc;
|
||||
}
|
||||
.letterhead img {
|
||||
max-width: 250px;
|
||||
height: auto;
|
||||
}
|
||||
.company-name {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 18px;
|
||||
color: #333333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.intro-text {
|
||||
font-size: 16px;
|
||||
color: #555555;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.estimate-box {
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.estimate-label {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.estimate-value {
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.estimate-value:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.price-section {
|
||||
background-color: #0066cc;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.price-label {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.price-amount {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.additional-section {
|
||||
background-color: #fff9e6;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 20px;
|
||||
margin: 30px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.additional-label {
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.additional-text {
|
||||
font-size: 15px;
|
||||
color: #333333;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.action-buttons {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 14px 28px;
|
||||
margin: 8px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.btn-accept {
|
||||
background-color: #28a745;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn-decline {
|
||||
background-color: #dc3545;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn-call {
|
||||
background-color: #ffc107;
|
||||
color: #333333;
|
||||
}
|
||||
.closing-text {
|
||||
font-size: 16px;
|
||||
color: #555555;
|
||||
line-height: 1.6;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.contact-info {
|
||||
font-size: 15px;
|
||||
color: #0066cc;
|
||||
font-weight: bold;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
.footer-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
.content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.estimate-box {
|
||||
padding: 20px;
|
||||
}
|
||||
.price-amount {
|
||||
font-size: 28px;
|
||||
}
|
||||
.company-name {
|
||||
font-size: 24px;
|
||||
}
|
||||
.btn {
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Letterhead Section -->
|
||||
<div class="letterhead">
|
||||
{% if letterhead_image %}
|
||||
<img src="{{ letterhead_image }}" alt="{{ company }} Logo">
|
||||
{% else %}
|
||||
<div class="company-name">{{ company }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="content">
|
||||
<div class="greeting">Hello {{ customer_name }},</div>
|
||||
|
||||
<div class="intro-text">
|
||||
Thank you for considering {{ company }} for your project. We are pleased to provide you with the following estimate for the services requested.
|
||||
</div>
|
||||
|
||||
<!-- Estimate Details Box -->
|
||||
<div class="estimate-box">
|
||||
<div class="estimate-label">Service Location</div>
|
||||
<div class="estimate-value">{{ address }}</div>
|
||||
|
||||
<!-- Price Section -->
|
||||
<div class="price-section">
|
||||
<div class="price-label">Total Estimate</div>
|
||||
<div class="price-amount">{{ price }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Notes (Conditional) -->
|
||||
{% if additional %}
|
||||
<div class="additional-section">
|
||||
<div class="additional-label">Additional Notes</div>
|
||||
<div class="additional-text">{{ additional }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Accepted" class="btn btn-accept">Accept</a>
|
||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Rejected" class="btn btn-decline">Decline</a>
|
||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Requested%20call" class="btn btn-call">Request a Call</a>
|
||||
</div>
|
||||
|
||||
<!-- Closing -->
|
||||
<div class="closing-text">
|
||||
This estimate is valid for 30 days from the date of this email. If you have any questions or would like to proceed with this estimate, please don't hesitate to contact us.
|
||||
{% if company_phone %}
|
||||
<div class="contact-info">Call us at: {{ company_phone }}</div>
|
||||
{% endif %}
|
||||
<br>
|
||||
We look forward to working with you!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="footer-text">
|
||||
<strong>{{ company }}</strong><br>
|
||||
This is an automated message. Please do not reply directly to this email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,152 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invoice - {{ invoice_number }}</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;
|
||||
}
|
||||
.invoice-details {
|
||||
background-color: #ecf0f1;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.invoice-details h2 {
|
||||
margin-top: 0;
|
||||
color: #3498db;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 2px solid #3498db;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background-color: #27ae60;
|
||||
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;
|
||||
}
|
||||
.note {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Invoice</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Dear {{ customer_name }},</p>
|
||||
<p>Thank you for your business with {{ company_name }}. Please find your invoice details below:</p>
|
||||
|
||||
<div class="invoice-details">
|
||||
<h2>Invoice Details</h2>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Invoice Number:</span>
|
||||
<span>{{ invoice_number }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Invoice Date:</span>
|
||||
<span>{{ invoice_date }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Due Date:</span>
|
||||
<span>{{ due_date }}</span>
|
||||
</div>
|
||||
{% if sales_order %}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Related Sales Order:</span>
|
||||
<span>{{ sales_order }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Invoice Total:</span>
|
||||
<span>{{ grand_total }}</span>
|
||||
</div>
|
||||
{% if paid_amount and paid_amount != "$0.00" %}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Amount Paid:</span>
|
||||
<span>{{ paid_amount }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Amount Due:</span>
|
||||
<span>{{ outstanding_amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if payment_url and outstanding_amount != "$0.00" %}
|
||||
<div class="note">
|
||||
<strong>Payment Required:</strong> There is an outstanding balance on this invoice. Please click the button below to make a secure payment.
|
||||
</div>
|
||||
<a href="{{ payment_url }}" class="cta-button">Pay Now</a>
|
||||
{% else %}
|
||||
<div class="note">
|
||||
<strong>Paid in Full:</strong> This invoice has been paid in full. Thank you!
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>If you have any questions about this invoice, please don't hesitate to contact us.</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>
|
||||
@ -1,71 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>No Down Payment Required</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.message-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.message-icon {
|
||||
font-size: 5rem;
|
||||
color: #4ecdc4;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.message-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
.message-text {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
.contact-info {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border-left: 5px solid #4ecdc4;
|
||||
text-align: left;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="message-container">
|
||||
<div class="message-icon">✅</div>
|
||||
<h1 class="message-title">No Down Payment Required</h1>
|
||||
<p class="message-text">This sales order does not require any down payment.</p>
|
||||
<div class="contact-info">
|
||||
<strong>If you have any questions:</strong><br>
|
||||
Please contact our sales team for assistance or clarification regarding your order.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,86 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - Something Went Wrong</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.error-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.error-icon {
|
||||
font-size: 5rem;
|
||||
color: #ff6b6b;
|
||||
margin-bottom: 30px;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
.error-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
.error-message {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
.error-details {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border-left: 5px solid #ff6b6b;
|
||||
text-align: left;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h1 class="error-title">Oops, something went wrong!</h1>
|
||||
<p class="error-message">We're sorry, but an error occurred while processing your request. Please try again later or contact support if the problem persists.</p>
|
||||
{% if error_message %}
|
||||
<div class="error-details">
|
||||
<strong>Error Details:</strong><br>
|
||||
{{ error_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,141 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Cancelled</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.payment-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.cancelled-icon {
|
||||
font-size: 5rem;
|
||||
color: #e74c3c;
|
||||
margin-bottom: 30px;
|
||||
animation: cancelledAnimation 1.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cancelledAnimation {
|
||||
0% {
|
||||
transform: scale(0) rotate(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-message {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cancelled-notice {
|
||||
background-color: #ffeaea;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 30px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.cancelled-notice h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #721c24;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cancelled-notice p {
|
||||
margin: 0;
|
||||
color: #721c24;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.next-steps h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.next-steps ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.next-steps li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="payment-container">
|
||||
<div class="cancelled-icon">✕</div>
|
||||
<h1 class="payment-title">Payment Cancelled</h1>
|
||||
<p class="payment-message">Your payment has been cancelled.</p>
|
||||
|
||||
<div class="cancelled-notice">
|
||||
<h3>Payment Not Processed</h3>
|
||||
<p>No charges have been made to your account. If you cancelled by mistake or need assistance, please try again or contact support.</p>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h4>What happens next?</h4>
|
||||
<ul>
|
||||
<li>No payment has been processed</li>
|
||||
<li>You can safely close this window</li>
|
||||
<li>Try your payment again if needed</li>
|
||||
<li>Contact us if you need help</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,141 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Cancelled</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.payment-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.cancelled-icon {
|
||||
font-size: 5rem;
|
||||
color: #e74c3c;
|
||||
margin-bottom: 30px;
|
||||
animation: cancelledAnimation 1.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cancelledAnimation {
|
||||
0% {
|
||||
transform: scale(0) rotate(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-message {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cancelled-notice {
|
||||
background-color: #ffeaea;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 30px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.cancelled-notice h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #721c24;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cancelled-notice p {
|
||||
margin: 0;
|
||||
color: #721c24;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.next-steps h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.next-steps ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.next-steps li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="payment-container">
|
||||
<div class="cancelled-icon">✕</div>
|
||||
<h1 class="payment-title">Payment Cancelled</h1>
|
||||
<p class="payment-message">Your payment has been cancelled.</p>
|
||||
|
||||
<div class="cancelled-notice">
|
||||
<h3>Payment Not Processed</h3>
|
||||
<p>No charges have been made to your account. If you cancelled by mistake or need assistance, please try again or contact support.</p>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<h4>What happens next?</h4>
|
||||
<ul>
|
||||
<li>No payment has been processed</li>
|
||||
<li>You can safely close this window</li>
|
||||
<li>Try your payment again if needed</li>
|
||||
<li>Contact us if you need help</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,212 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Payment Successful</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.payment-container {
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.success-icon {
|
||||
font-size: 5rem;
|
||||
color: #00b894;
|
||||
margin-bottom: 30px;
|
||||
animation: checkmarkAnimation 1.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes checkmarkAnimation {
|
||||
0% {
|
||||
transform: scale(0) rotate(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-message {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 30px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.advance-notice {
|
||||
background-color: #e3f2fd;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
border-left: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
.contact-section {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-top: 30px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.contact-section h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-section > p {
|
||||
margin: 0 0 15px 0;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.contact-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contact-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.contact-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.contact-label {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-value {
|
||||
font-weight: 400;
|
||||
color: #6c757d;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.contact-value a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contact-value a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="payment-container">
|
||||
<div class="success-icon">✓</div>
|
||||
{% if reference_doc %}
|
||||
<h1 class="payment-title">
|
||||
{% if company_doc and company_doc.company_name %}
|
||||
{{ company_doc.company_name }}
|
||||
{% else %}
|
||||
Payment Received
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if reference_doc.doctype == "Sales Order" %}
|
||||
<p class="payment-message">
|
||||
{% if reference_doc.customer %}
|
||||
Thank you {{ reference_doc.customer }} for your advance payment!
|
||||
{% else %}
|
||||
Thank you for your advance payment!
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="advance-notice">
|
||||
<p>The remaining balance will be invoiced once the project is complete.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="payment-message">
|
||||
{% if reference_doc.customer %}
|
||||
Thank you {{ reference_doc.customer }} for your payment!
|
||||
{% else %}
|
||||
Thank you for your payment!
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if company_doc %}
|
||||
<div class="contact-section">
|
||||
<h3>Have Questions?</h3>
|
||||
<p>We're here to help! Contact us if you need assistance.</p>
|
||||
<div class="contact-details">
|
||||
{% if company_doc.company_name %}
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Company:</span>
|
||||
<span class="contact-value">{{ company_doc.company_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company_doc.phone_no %}
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Phone:</span>
|
||||
<span class="contact-value">{{ company_doc.phone_no }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company_doc.email %}
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Email:</span>
|
||||
<span class="contact-value"><a href="mailto:{{ company_doc.email }}">{{ company_doc.email }}</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company_doc.website %}
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Website:</span>
|
||||
<span class="contact-value"><a href="{{ company_doc.website }}" target="_blank">{{ company_doc.website }}</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1 class="payment-title">Payment Received</h1>
|
||||
<p class="payment-message">Thank you for your payment!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,18 +0,0 @@
|
||||
import frappe
|
||||
|
||||
def get_context(context):
|
||||
context.no_cache = 1
|
||||
|
||||
context.title = "Payment Received"
|
||||
context.message = "Thank you for your payment! Your transaction was successful."
|
||||
|
||||
context.session_id = frappe.form_dict.get("session_id")
|
||||
|
||||
payment_entry = frappe.get_value("Payment Entry", {"reference_no": context.session_id}, "name")
|
||||
payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry) if payment_entry else None
|
||||
reference = payment_entry_doc.references[0] if payment_entry_doc and payment_entry_doc.references else None
|
||||
reference_doc = frappe.get_doc(reference.reference_doctype, reference.reference_name) if reference else None
|
||||
company_doc = frappe.get_doc("Company", reference_doc.company) if reference_doc and reference_doc.company else None
|
||||
context.reference_doc = reference_doc.as_dict() if reference_doc else None
|
||||
context.company_doc = company_doc.as_dict() if company_doc else None
|
||||
return context
|
||||
@ -1,8 +0,0 @@
|
||||
services:
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: mailhog
|
||||
ports:
|
||||
- "8025:8025" # MailHog web UI
|
||||
- "1025:1025" # SMTP server
|
||||
restart: unless-stopped
|
||||
@ -1,151 +0,0 @@
|
||||
# DocType Field Differences Report
|
||||
|
||||
## Fields present in LOCAL but missing in STAGE
|
||||
|
||||
### Address
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| is_service_address | Is Service Address | Check | None | 0 | 0 | False |
|
||||
|
||||
### Event
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| participants | Participants | Section Break | None | 0 | 0 | False |
|
||||
|
||||
### Project
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| service_appointment | Service Appointment | Link | Service Address 2 | 0 | 0 | False |
|
||||
| tasks | Tasks | Table | Project Task Link | 0 | 0 | False |
|
||||
| ready_to_schedule | Ready to Schedule | Check | None | 0 | 0 | False |
|
||||
|
||||
### Project Template
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| bid_meeting_note_form | Bid Meeting Note Form | Link | Bid Meeting Note Form | 0 | 0 | False |
|
||||
| item_groups | Item Groups | Data | None | 0 | 0 | False |
|
||||
|
||||
### Quotation
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| from_template | From Template | Link | Quotation Template | 0 | 0 | False |
|
||||
| project_template | Project Template | Link | Project Template | 0 | 0 | False |
|
||||
|
||||
### Sales Invoice
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| project_template | Project Template | Link | Project Template | 0 | 0 | False |
|
||||
| job_address | Job Address | Link | Address | 0 | 0 | False |
|
||||
|
||||
### Task
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| customer | Customer | Link | Customer | 0 | 0 | False |
|
||||
|
||||
### Task Type
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| base_date | Base Date | Select | Start
|
||||
End
|
||||
Completion
|
||||
Creation | 1 | 0 | False |
|
||||
| offset_days | Offset Days | Int | None | 1 | 0 | False |
|
||||
| skip_weekends | Skip Weekends | Check | None | 0 | 0 | False |
|
||||
| skip_holidays | Skip Holidays | Check | None | 0 | 0 | False |
|
||||
| logic_key | Logic Key | Data | None | 0 | 0 | False |
|
||||
| offset_direction | Offset Direction | Select | After
|
||||
Before | 1 | 0 | False |
|
||||
| title | Title | Data | None | 1 | 0 | False |
|
||||
| days | Days | Int | None | 0 | 0 | False |
|
||||
| calculate_from | Calculate From | Select | Service Address 2
|
||||
Project
|
||||
Task | 1 | 0 | False |
|
||||
| trigger | Trigger | Select | Scheduled
|
||||
Completed
|
||||
Created | 1 | 0 | False |
|
||||
| task_type_calculate_from | Task Type For Task Calculate From | Link | Task Type | 0 | 0 | False |
|
||||
| work_type | Work Type | Select | Admin
|
||||
Labor
|
||||
QA | 1 | 0 | False |
|
||||
| no_due_date | No Due Date | Check | None | 0 | 0 | False |
|
||||
| triggering_doctype | Triggering Doctype | Select | Service Address 2
|
||||
Project
|
||||
Task | 1 | 0 | False |
|
||||
|
||||
## Fields present in STAGE but missing in LOCAL
|
||||
|
||||
### Communication Link
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| communication_date | Communication Date | Datetime | None | 0 | 0 | False |
|
||||
|
||||
### Event
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| notifications | Notifications | Table | Event Notifications | 0 | 0 | False |
|
||||
| location | Location | Data | None | 0 | 0 | False |
|
||||
| attending | Attending | Select |
|
||||
Yes
|
||||
No
|
||||
Maybe | 0 | 0 | False |
|
||||
| participants_tab | Participants | Tab Break | None | 0 | 0 | False |
|
||||
| links_tab | Links | Tab Break | None | 0 | 0 | False |
|
||||
| notifications_tab | Notifications | Tab Break | None | 0 | 0 | False |
|
||||
|
||||
### Event Notifications
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| type | Type | Select | Notification
|
||||
Email | 0 | 0 | False |
|
||||
| before | Before | Int | None | 0 | 0 | False |
|
||||
| interval | Interval | Select | None | 0 | 0 | False |
|
||||
| time | Time | Time | None | 0 | 0 | False |
|
||||
|
||||
### Event Participants
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| attending | Attending | Select |
|
||||
Yes
|
||||
No
|
||||
Maybe | 0 | 0 | False |
|
||||
|
||||
### Job Opening
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| job_opening_template | Job Opening Template | Link | Job Opening Template | 0 | 0 | False |
|
||||
|
||||
### Job Opening Template
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| template_title | Template Title | Data | None | 1 | 0 | False |
|
||||
| department | Department | Link | Department | 0 | 0 | False |
|
||||
| column_break_wkcr | None | Column Break | None | 0 | 0 | False |
|
||||
| employment_type | Employment Type | Link | Employment Type | 0 | 0 | False |
|
||||
| location | Location | Link | Branch | 0 | 0 | False |
|
||||
| section_break_dwfh | None | Section Break | None | 0 | 0 | False |
|
||||
| description | Description | Text Editor | None | 0 | 0 | False |
|
||||
|
||||
### Payment Ledger Entry
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| project | Project | Link | Project | 0 | 0 | False |
|
||||
|
||||
### Quotation Item
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| ordered_qty | Ordered Qty | Float | None | 1 | 1 | False |
|
||||
|
||||
### Salary Structure Assignment
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| leave_encashment_amount_per_day | Leave Encashment Amount Per Day | Currency | currency | 0 | 0 | False |
|
||||
|
||||
### Selling Settings
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| set_zero_rate_for_expired_batch | Set Incoming Rate as Zero for Expired Batch | Check | None | 0 | 0 | False |
|
||||
|
||||
### Service Appointment
|
||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
||||
| custom_location_of_meeting | Service Address | Link | Address | 0 | 0 | False |
|
||||
@ -5,76 +5,47 @@ import { useErrorStore } from "./stores/errors";
|
||||
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
||||
// Proxy method for external API calls
|
||||
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
|
||||
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_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_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_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_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";
|
||||
// Item methods
|
||||
const FRAPPE_SAVE_AS_PACKAGE_ITEM_METHOD = "custom_ui.api.db.items.save_as_package_item";
|
||||
const FRAPPE_GET_ITEMS_BY_PROJECT_TEMPLATE_METHOD = "custom_ui.api.db.items.get_by_project_template";
|
||||
// Job methods
|
||||
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_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_list";
|
||||
const FRAPPE_GET_JOB_TASK_LIST_METHOD = "custom_ui.api.db.jobs.get_job_task_table_data";
|
||||
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_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
|
||||
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_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
|
||||
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_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
|
||||
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
|
||||
// On-Site Meeting methods
|
||||
const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD =
|
||||
"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_SUBMIT_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.submit_bid_meeting_note_form";
|
||||
// Address methods
|
||||
const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
|
||||
// Client methods
|
||||
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_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_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 {
|
||||
// ============================================================================
|
||||
// CORE REQUEST METHOPD
|
||||
// CORE REQUEST METHOD
|
||||
// ============================================================================
|
||||
|
||||
static async request(frappeMethod, args = {}) {
|
||||
@ -114,17 +85,6 @@ class Api {
|
||||
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
|
||||
* @param {Object} paginationParams - Pagination parameters from store
|
||||
@ -176,22 +136,9 @@ class Api {
|
||||
// ON-SITE MEETING METHODS
|
||||
// ============================================================================
|
||||
|
||||
static async getBidMeetingNoteForm(projectTemplate) {
|
||||
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) {
|
||||
static async getUnscheduledBidMeetings() {
|
||||
return await this.request(
|
||||
"custom_ui.api.db.bid_meetings.get_unscheduled_bid_meetings",
|
||||
{ company }
|
||||
);
|
||||
}
|
||||
|
||||
@ -199,8 +146,8 @@ class Api {
|
||||
return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters });
|
||||
}
|
||||
|
||||
static async getWeekBidMeetings(weekStart, weekEnd, company) {
|
||||
return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd, company });
|
||||
static async getWeekBidMeetings(weekStart, weekEnd) {
|
||||
return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd });
|
||||
}
|
||||
|
||||
static async updateBidMeeting(name, data) {
|
||||
@ -222,18 +169,12 @@ class Api {
|
||||
});
|
||||
}
|
||||
|
||||
static async getBidMeetingNote(name) {
|
||||
return await this.request("custom_ui.api.db.bid_meetings.get_bid_meeting_note", {
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ESTIMATE / QUOTATION METHODS
|
||||
// ============================================================================
|
||||
|
||||
static async getQuotationItems(projectTemplate) {
|
||||
return await this.request("custom_ui.api.db.estimates.get_quotation_items", { projectTemplate });
|
||||
static async getQuotationItems() {
|
||||
return await this.request("custom_ui.api.db.estimates.get_quotation_items");
|
||||
}
|
||||
|
||||
static async getEstimateFromAddress(fullAddress) {
|
||||
@ -272,12 +213,7 @@ class Api {
|
||||
|
||||
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});
|
||||
return result;
|
||||
}
|
||||
|
||||
static async getIncompleteBidsCount(currentCompany) {
|
||||
const result = await this.request(FRAPPE_GET_INCOMPLETE_BIDS_METHOD, { company: currentCompany });
|
||||
const result = await this.request(FRAPPE_GET_ESTIMATES_METHOD, { page, pageSize, filters, sorting});
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -306,14 +242,6 @@ class Api {
|
||||
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
|
||||
// ============================================================================
|
||||
@ -370,39 +298,6 @@ class Api {
|
||||
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) {
|
||||
if (frappe.db.exists("Project", jobName)) {
|
||||
const result = await this.request(FRAPPE_GET_JOB_METHOD, { jobId: jobName })
|
||||
@ -449,29 +344,10 @@ class Api {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
@ -525,16 +401,6 @@ class Api {
|
||||
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
|
||||
// ============================================================================
|
||||
@ -571,16 +437,6 @@ class Api {
|
||||
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
|
||||
// ============================================================================
|
||||
@ -660,22 +516,6 @@ class Api {
|
||||
return await this.request(FRAPPE_GET_ADDRESSES_METHOD, { fields, filters });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ITEM/PACKAGE METHODS
|
||||
// ============================================================================
|
||||
|
||||
static async getItemsByProjectTemplate(projectTemplate) {
|
||||
return await this.request(FRAPPE_GET_ITEMS_BY_PROJECT_TEMPLATE_METHOD, { projectTemplate });
|
||||
}
|
||||
|
||||
static async saveAsPackageItem(data) {
|
||||
return await this.request(FRAPPE_SAVE_AS_PACKAGE_ITEM_METHOD, { data });
|
||||
}
|
||||
|
||||
static async getItemCategories() {
|
||||
return await this.request("custom_ui.api.db.items.get_item_categories");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVICE / ROUTE / TIMESHEET METHODS
|
||||
// ============================================================================
|
||||
@ -718,26 +558,6 @@ class Api {
|
||||
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
|
||||
// ============================================================================
|
||||
@ -752,21 +572,20 @@ class Api {
|
||||
*/
|
||||
static async getDocsList(
|
||||
doctype,
|
||||
fields = ["*"],
|
||||
fields = [],
|
||||
filters = {},
|
||||
pluck = null,
|
||||
page = 0,
|
||||
start = 0,
|
||||
pageLength = 0,
|
||||
) {
|
||||
const docs = await this.request(
|
||||
FRAPPE_GET_DOC_LIST_METHOD,
|
||||
{
|
||||
doctype,
|
||||
fields,
|
||||
filters,
|
||||
pluck,
|
||||
}
|
||||
);
|
||||
const docs = await frappe.db.get_list(doctype, {
|
||||
fields,
|
||||
filters,
|
||||
start: start,
|
||||
limit: pageLength,
|
||||
});
|
||||
console.log(
|
||||
`DEBUG: API - Fetched ${doctype} list: `,
|
||||
`DEBUG: API - Fetched ${doctype} list (page ${page + 1}, start ${start}): `,
|
||||
docs,
|
||||
);
|
||||
return docs;
|
||||
|
||||
@ -1,24 +1,42 @@
|
||||
<template>
|
||||
<div class="calendar-navigation">
|
||||
<Tabs value="0" v-if="companyStore.currentCompany == 'Sprinklers Northwest'">
|
||||
<Tabs value="0">
|
||||
<TabList>
|
||||
<Tab value="0">Bids</Tab>
|
||||
<Tab value="1">Projects</Tab>
|
||||
<Tab value="1">Install</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>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel header="Bids" value="0">
|
||||
<ScheduleBid />
|
||||
</TabPanel>
|
||||
<TabPanel header="Projects" value="1">
|
||||
<SNWProjectCalendar />
|
||||
<TabPanel header="Install" value="1">
|
||||
<InstallsCalendar />
|
||||
</TabPanel>
|
||||
<TabPanel header="Service" value="2">
|
||||
<div class="coming-soon">
|
||||
<p>Service feature coming soon!</p>
|
||||
</div>
|
||||
</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">
|
||||
<div class="coming-soon">
|
||||
<p>Warranties Calendar coming soon!</p>
|
||||
@ -26,37 +44,6 @@
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -69,35 +56,15 @@ import TabPanel from 'primevue/tabpanel';
|
||||
import TabPanels from 'primevue/tabpanels';
|
||||
import ScheduleBid from '../calendar/bids/ScheduleBid.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 { useCompanyStore } from '../../stores/company';
|
||||
|
||||
const notifications = useNotificationStore();
|
||||
const companyStore = useCompanyStore();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-navigation {
|
||||
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 {
|
||||
|
||||
@ -115,8 +115,8 @@
|
||||
)"
|
||||
:key="meeting.id"
|
||||
class="meeting-event"
|
||||
:class="[getMeetingColorClass(meeting), { 'meeting-completed-locked': meeting.status === 'Completed' }]"
|
||||
:draggable="meeting.status !== 'Completed'"
|
||||
:class="getMeetingColorClass(meeting)"
|
||||
draggable="true"
|
||||
@dragstart="handleMeetingDragStart($event, meeting)"
|
||||
@dragend="handleDragEnd($event)"
|
||||
@click.stop="showMeetingDetails(meeting)"
|
||||
@ -206,7 +206,6 @@
|
||||
:meeting="selectedMeeting"
|
||||
@close="closeMeetingModal"
|
||||
@meeting-updated="handleMeetingUpdated"
|
||||
@complete-meeting="openNoteForm"
|
||||
/>
|
||||
|
||||
<!-- New Meeting Modal -->
|
||||
@ -217,17 +216,6 @@
|
||||
@confirm="handleNewMeetingConfirm"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
@ -236,17 +224,14 @@ import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import BidMeetingModal from "../../modals/BidMeetingModal.vue";
|
||||
import MeetingDetailsModal from "../../modals/MeetingDetailsModal.vue";
|
||||
import BidMeetingNoteForm from "../../modals/BidMeetingNoteForm.vue";
|
||||
import { useLoadingStore } from "../../../stores/loading";
|
||||
import { useNotificationStore } from "../../../stores/notifications-primevue";
|
||||
import { useCompanyStore } from "../../../stores/company";
|
||||
import Api from "../../../api";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loadingStore = useLoadingStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
const companyStore = useCompanyStore();
|
||||
|
||||
// Query parameters
|
||||
const isNewMode = computed(() => route.query.new === "true");
|
||||
@ -264,8 +249,6 @@ const unscheduledMeetings = ref([]);
|
||||
const selectedMeeting = ref(null);
|
||||
const showMeetingModal = ref(false);
|
||||
const showNewMeetingModal = ref(false);
|
||||
const showNoteFormModal = ref(false);
|
||||
const selectedMeetingForNotes = ref(null);
|
||||
|
||||
// Drag and drop state
|
||||
const isDragOver = ref(false);
|
||||
@ -491,63 +474,6 @@ const handleMeetingUpdated = async () => {
|
||||
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 = () => {
|
||||
showNewMeetingModal.value = true;
|
||||
};
|
||||
@ -563,16 +489,7 @@ const handleNewMeetingConfirm = async (meetingData) => {
|
||||
|
||||
showNewMeetingModal.value = false;
|
||||
|
||||
// Optimistically add the new meeting to the unscheduled list
|
||||
unscheduledMeetings.value.unshift({
|
||||
name: result.name,
|
||||
address: meetingData.address,
|
||||
projectTemplate: meetingData.projectTemplate,
|
||||
contact: meetingData.contact,
|
||||
status: "Unscheduled",
|
||||
});
|
||||
|
||||
// Reload unscheduled meetings to ensure consistency
|
||||
// Reload unscheduled meetings to show the new one
|
||||
await loadUnscheduledMeetings();
|
||||
|
||||
notificationStore.addNotification({
|
||||
@ -617,7 +534,6 @@ const handleDragStart = (event, meeting = null) => {
|
||||
notes: meeting.notes || "",
|
||||
assigned_employee: meeting.assigned_employee || "",
|
||||
status: meeting.status,
|
||||
projectTemplate: meeting.projectTemplate,
|
||||
};
|
||||
} else if (!draggedMeeting.value) {
|
||||
// If no meeting data is set, use query address
|
||||
@ -633,12 +549,6 @@ const handleDragStart = (event, meeting = null) => {
|
||||
};
|
||||
|
||||
const handleMeetingDragStart = (event, meeting) => {
|
||||
// Prevent dragging completed meetings
|
||||
if (meeting.status === 'Completed') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle dragging a scheduled meeting
|
||||
draggedMeeting.value = {
|
||||
id: meeting.name,
|
||||
@ -647,7 +557,6 @@ const handleMeetingDragStart = (event, meeting) => {
|
||||
assigned_employee: meeting.assigned_employee || "",
|
||||
status: meeting.status,
|
||||
isRescheduling: true, // Flag to indicate this is a reschedule
|
||||
projectTemplate: meeting.projectTemplate,
|
||||
};
|
||||
|
||||
// Store the original meeting data in case drag is cancelled
|
||||
@ -758,7 +667,6 @@ const handleDrop = async (event, date, time) => {
|
||||
notes: droppedMeeting.notes || "",
|
||||
assigned_employee: droppedMeeting.assigned_employee || "",
|
||||
status: "Scheduled",
|
||||
projectTemplate: droppedMeeting.projectTemplate,
|
||||
};
|
||||
|
||||
// If this is an existing meeting, update it in the backend
|
||||
@ -882,7 +790,6 @@ const handleDropToUnscheduled = async (event) => {
|
||||
notes: droppedMeeting.notes || "",
|
||||
status: "Unscheduled",
|
||||
assigned_employee: droppedMeeting.assigned_employee || "",
|
||||
projectTemplate: droppedMeeting.projectTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
@ -909,7 +816,7 @@ const handleDropToUnscheduled = async (event) => {
|
||||
const loadUnscheduledMeetings = async () => {
|
||||
loadingStore.setLoading(true);
|
||||
try {
|
||||
const result = await Api.getUnscheduledBidMeetings(companyStore.currentCompany);
|
||||
const result = await Api.getUnscheduledBidMeetings();
|
||||
// Ensure we always have an array
|
||||
unscheduledMeetings.value = Array.isArray(result) ? result : [];
|
||||
console.log("Loaded unscheduled meetings:", unscheduledMeetings.value);
|
||||
@ -958,7 +865,7 @@ const loadWeekMeetings = async () => {
|
||||
|
||||
// Try to get meetings from API
|
||||
try {
|
||||
const apiResult = await Api.getWeekBidMeetings(weekStartStr, weekEndStr, companyStore.currentCompany);
|
||||
const apiResult = await Api.getWeekBidMeetings(weekStartStr, weekEndStr);
|
||||
if (Array.isArray(apiResult)) {
|
||||
// Transform the API data to match what the calendar expects
|
||||
meetings.value = apiResult
|
||||
@ -1178,15 +1085,6 @@ watch(currentWeekStart, () => {
|
||||
loadWeekMeetings();
|
||||
});
|
||||
|
||||
// Watch for company changes
|
||||
watch(
|
||||
() => companyStore.currentCompany,
|
||||
async () => {
|
||||
await loadWeekMeetings();
|
||||
await loadUnscheduledMeetings();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query.new,
|
||||
(newVal) => {
|
||||
@ -1200,10 +1098,9 @@ watch(
|
||||
<style scoped>
|
||||
.schedule-bid-container {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
@ -1213,7 +1110,6 @@ watch(
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
@ -1254,9 +1150,9 @@ watch(
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 150px);
|
||||
transition: width 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
@ -1286,13 +1182,11 @@ watch(
|
||||
.sidebar-header h4 {
|
||||
font-size: 1.1em;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unscheduled-meetings-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@ -1567,24 +1461,11 @@ watch(
|
||||
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 {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.meeting-event.meeting-completed-locked:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-weight: 600;
|
||||
font-size: 0.8em;
|
||||
|
||||
1200
frontend/src/components/calendar/jobs/InstallsCalendar.vue
Normal file
1200
frontend/src/components/calendar/jobs/InstallsCalendar.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -59,15 +59,6 @@
|
||||
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>
|
||||
<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 class="form-row">
|
||||
<div class="form-field">
|
||||
@ -129,7 +120,7 @@
|
||||
<Select
|
||||
:id="`primaryContact-${index}`"
|
||||
v-model="address.primaryContact"
|
||||
:options="address.contacts.map(c => contactOptions.find(opt => opt.value === c))"
|
||||
:options="contactOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="isSubmitting || contactOptions.length === 0"
|
||||
@ -183,7 +174,6 @@ const localFormData = computed({
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
isBillingAddress: true,
|
||||
isServiceAddress: true,
|
||||
pincode: "",
|
||||
city: "",
|
||||
state: "",
|
||||
@ -215,7 +205,6 @@ onMounted(() => {
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
isBillingAddress: true,
|
||||
isServiceAddress: true,
|
||||
pincode: "",
|
||||
city: "",
|
||||
state: "",
|
||||
@ -232,7 +221,6 @@ const addAddress = () => {
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
isBillingAddress: false,
|
||||
isServiceAddress: true,
|
||||
pincode: "",
|
||||
city: "",
|
||||
state: "",
|
||||
@ -271,7 +259,6 @@ const handleBillingChange = (selectedIndex) => {
|
||||
localFormData.value.addresses.forEach((addr, idx) => {
|
||||
if (idx !== selectedIndex) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
} 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
@ -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>
|
||||
@ -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>
|
||||
@ -7,7 +7,7 @@
|
||||
:class="getStatusClass(onsiteMeetingStatus)"
|
||||
@click="handleBidMeetingClick"
|
||||
>
|
||||
<span class="status-label">Bid Meeting</span>
|
||||
<span class="status-label">Meeting</span>
|
||||
<span class="status-badge">{{ onsiteMeetingStatus }}</span>
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
:class="getStatusClass(estimateSentStatus)"
|
||||
@click="handleEstimateClick"
|
||||
>
|
||||
<span class="status-label">Estimate Sent</span>
|
||||
<span class="status-label">Estimate</span>
|
||||
<span class="status-badge">{{ estimateSentStatus }}</span>
|
||||
</div>
|
||||
|
||||
@ -109,10 +109,8 @@ const handleBidMeetingClick = () => {
|
||||
};
|
||||
|
||||
const handleEstimateClick = () => {
|
||||
if ((props.estimateSentStatus === "Not Started") && props.onsiteMeetingStatus != "Completed") {
|
||||
notificationStore.addWarning("Bid Meeting must be scheduled and completed before an Estimate can be made for a SNW Install")
|
||||
} else if (props.estimateSentStatus === "Not Started") {
|
||||
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&project-template=SNW%20Install&from-meeting=${encodeURIComponent(props.bidMeeting)}`);
|
||||
if (props.estimateSentStatus === "Not Started") {
|
||||
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&template=SNW%20Install`);
|
||||
} else {
|
||||
router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -529,6 +529,7 @@ const setMenuRef = (el, label, id) => {
|
||||
if (el) {
|
||||
const refName = `${label}-${id}`;
|
||||
menuRefs[refName] = el;
|
||||
console.log("Setting Menu Ref:", refName, el);
|
||||
}
|
||||
}
|
||||
|
||||
@ -783,9 +784,7 @@ const hasExactlyOneRowSelected = computed(() => {
|
||||
onMounted(() => {
|
||||
const currentFilters = filtersStore.getTableFilters(props.tableName);
|
||||
filterableColumns.value.forEach((col) => {
|
||||
// Use defaultValue from column definition if provided, otherwise use stored filter value
|
||||
const storedValue = currentFilters[col.fieldName]?.value || "";
|
||||
pendingFilters.value[col.fieldName] = col.defaultValue || storedValue;
|
||||
pendingFilters.value[col.fieldName] = currentFilters[col.fieldName]?.value || "";
|
||||
});
|
||||
});
|
||||
|
||||
@ -1075,9 +1074,12 @@ const handleActionClick = (action, rowData = null) => {
|
||||
};
|
||||
|
||||
const toggleMenu = (event, action, rowData) => {
|
||||
console.log("Menu button toggled");
|
||||
const menuKey = `${action.label}-${rowData.id}`;
|
||||
console.log("Looking for menu:", menuKey, menuRefs);
|
||||
const menu = menuRefs[menuKey];
|
||||
if (menu) {
|
||||
console.log("Found menu, toggling:", menu);
|
||||
menu.toggle(event);
|
||||
activeMenuKey.value = menuKey;
|
||||
} else {
|
||||
@ -1086,9 +1088,11 @@ const toggleMenu = (event, action, rowData) => {
|
||||
};
|
||||
|
||||
const buildMenuItems = (menuItems, rowData) => {
|
||||
console.log("DEBUG: Building menuItems:", menuItems);
|
||||
return menuItems.map(item => ({
|
||||
...item,
|
||||
command: () => {
|
||||
console.log("Clicked from Datatable");
|
||||
if (typeof item.command === 'function') {
|
||||
item.command(rowData);
|
||||
}
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
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="spinner-container">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<div class="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full mx-4 text-center">
|
||||
<div class="mb-4">
|
||||
<i class="pi pi-spin pi-spinner text-4xl text-blue-500"></i>
|
||||
</div>
|
||||
<h3 class="loading-title">Loading</h3>
|
||||
<p class="loading-message">{{ loadingMessage }}</p>
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">Loading</h3>
|
||||
<p class="text-gray-600">{{ loadingMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -45,51 +46,15 @@ const loadingMessage = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
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;
|
||||
/* Additional styling for better visual appearance */
|
||||
.bg-opacity-30 {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
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;
|
||||
/* Backdrop blur effect for modern browsers */
|
||||
@supports (backdrop-filter: blur(4px)) {
|
||||
.fixed.inset-0 {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,167 +0,0 @@
|
||||
<template>
|
||||
<div class="items-container">
|
||||
<div v-if="items.length === 0" class="no-items-message">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<p>{{ emptyMessage }}</p>
|
||||
</div>
|
||||
<div v-else v-for="item in items" :key="item.itemCode" class="item-card" :class="{ 'item-selected': isItemSelected(item.itemCode) }" @click="handleItemClick(item, $event)">
|
||||
<div class="item-card-header">
|
||||
<span class="item-code">{{ item.itemCode }}</span>
|
||||
<span class="item-name">{{ item.itemName }}</span>
|
||||
<span class="item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
|
||||
<Button
|
||||
:label="isItemSelected(item.itemCode) ? 'Selected' : 'Select'"
|
||||
:icon="isItemSelected(item.itemCode) ? 'pi pi-check' : 'pi pi-plus'"
|
||||
@click.stop="handleItemClick(item, $event)"
|
||||
size="small"
|
||||
:severity="isItemSelected(item.itemCode) ? 'success' : 'secondary'"
|
||||
class="select-item-button"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="item.description" class="item-description">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, shallowRef } from "vue";
|
||||
import Button from "primevue/button";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
selectedItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: "No items found in this category"
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const internalSelection = ref([]);
|
||||
const selectionSet = shallowRef(new Set());
|
||||
|
||||
// Sync internal selection with prop
|
||||
watch(() => props.selectedItems, (newVal) => {
|
||||
internalSelection.value = [...newVal];
|
||||
selectionSet.value = new Set(newVal.map(item => item.itemCode));
|
||||
}, { immediate: true });
|
||||
|
||||
const isItemSelected = (itemCode) => {
|
||||
return selectionSet.value.has(itemCode);
|
||||
};
|
||||
|
||||
const handleItemClick = (item, event) => {
|
||||
// Always multi-select mode - toggle item in selection
|
||||
const index = internalSelection.value.findIndex(i => i.itemCode === item.itemCode);
|
||||
const newSet = new Set(selectionSet.value);
|
||||
|
||||
if (index >= 0) {
|
||||
internalSelection.value.splice(index, 1);
|
||||
newSet.delete(item.itemCode);
|
||||
} else {
|
||||
internalSelection.value.push(item);
|
||||
newSet.add(item.itemCode);
|
||||
}
|
||||
|
||||
// Update Set directly instead of recreating from array
|
||||
selectionSet.value = newSet;
|
||||
// Emit the entire selection array
|
||||
emit('select', [...internalSelection.value]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.no-items-message {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-items-message i {
|
||||
font-size: 3em;
|
||||
color: #ccc;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.no-items-message p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background-color: #fafafa;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.item-selected {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.item-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr 120px 100px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-code {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
font-weight: 600;
|
||||
color: #2196f3;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.select-item-button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.item-description {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@ -59,7 +59,6 @@ const props = defineProps({
|
||||
const mapElement = ref(null);
|
||||
let map = null;
|
||||
let marker = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
const initializeMap = async () => {
|
||||
if (!mapElement.value) return;
|
||||
@ -76,12 +75,8 @@ const initializeMap = async () => {
|
||||
|
||||
// Only create map if we have valid coordinates
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
// Wait for next tick to ensure DOM is updated
|
||||
await nextTick();
|
||||
|
||||
// Additional delay to ensure container has proper dimensions
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Initialize map
|
||||
map = L.map(mapElement.value, {
|
||||
zoomControl: props.interactive,
|
||||
@ -111,28 +106,6 @@ const initializeMap = async () => {
|
||||
`,
|
||||
)
|
||||
.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
|
||||
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
|
||||
if (marker) {
|
||||
marker.setLatLng([lat, lng]);
|
||||
@ -201,10 +164,6 @@ onUnmounted(() => {
|
||||
map = null;
|
||||
marker = null;
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -219,7 +178,6 @@ onUnmounted(() => {
|
||||
|
||||
.map {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
// Register Chart.js components
|
||||
@ -29,13 +29,17 @@ Chart.register(...registerables);
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
categories: Object,
|
||||
todoNumber: Number,
|
||||
completedNumber: Number,
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
//Constants
|
||||
const categories = ["To-do", "Completed"];
|
||||
|
||||
//Reactive data
|
||||
const centerData = ref(null);
|
||||
const hoveredSegment = ref(null);
|
||||
@ -52,19 +56,21 @@ const getHoveredCategoryIndex = () => {
|
||||
}
|
||||
|
||||
const getCategoryValue = (categoryIndex) => {
|
||||
return props.categories.data[categoryIndex];
|
||||
if (categoryIndex === 0) {
|
||||
return props.todoNumber
|
||||
} else {
|
||||
return props.completedNumber
|
||||
}
|
||||
}
|
||||
|
||||
const getChartData = () => {
|
||||
const categoryData = props.categories.data;
|
||||
const categoryColors = props.categories.colors;
|
||||
const chartData = {
|
||||
name: props.title,
|
||||
datasets: [
|
||||
{
|
||||
label: "",
|
||||
data: categoryData,
|
||||
backgroundColor: categoryColors
|
||||
data: [props.todoNumber, props.completedNumber],
|
||||
backgroundColor: ["#b22222", "#4caf50"]
|
||||
},
|
||||
]
|
||||
};
|
||||
@ -73,11 +79,8 @@ const getChartData = () => {
|
||||
|
||||
|
||||
const updateCenterData = () => {
|
||||
let total = 0;
|
||||
for (let i=0; i<props.categories.data.length; i++) {
|
||||
total += props.categories.data[i];
|
||||
}
|
||||
const todos = props.categories.data[0];
|
||||
const total = props.todoNumber + props.completedNumber;
|
||||
const todos = props.todoNumber;
|
||||
|
||||
if (todos === 0 && total > 0) {
|
||||
centerData.value = {
|
||||
@ -104,14 +107,14 @@ const updateCenterData = () => {
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) + "%" : "0%";
|
||||
|
||||
centerData.value = {
|
||||
label: props.categories.labels[hoveredCategoryIndex],
|
||||
label: categories[hoveredCategoryIndex],
|
||||
value: value,
|
||||
percentage: percentage,
|
||||
};
|
||||
} else {
|
||||
centerData.value = {
|
||||
label: props.categories.labels[0],
|
||||
value: todos,
|
||||
label: "To-do",
|
||||
value: props.todoNumber,
|
||||
percentage: null,
|
||||
};
|
||||
}
|
||||
@ -177,6 +180,8 @@ const getChartOptions = () => {
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || props.loading) return;
|
||||
|
||||
console.log(`DEBUG: Creating chart for ${props.title}`);
|
||||
console.log(props);
|
||||
|
||||
const ctx = chartCanvas.value.getContext("2d");
|
||||
if (chartInstance.value) {
|
||||
@ -209,9 +214,9 @@ onMounted(() => {
|
||||
createChart();
|
||||
});
|
||||
|
||||
watch(() => props.categories, (newValue) => {
|
||||
watch(() => props.completedNumber, (newValue) => {
|
||||
updateChart();
|
||||
}, {deep: true});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,649 +0,0 @@
|
||||
<template>
|
||||
<Modal
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
@close="handleClose"
|
||||
:options="{ showActions: false, maxWidth: '90vw', width: '1350px' }"
|
||||
class="add-item-modal"
|
||||
>
|
||||
<template #title>
|
||||
<div class="modal-title-container">
|
||||
<span>Add Item</span>
|
||||
<span v-if="selectedItemsCount > 0" class="selection-badge">{{ selectedItemsCount }} selected</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="modal-content items-modal-content">
|
||||
<div class="search-section">
|
||||
<label for="item-search" class="field-label">Search Items</label>
|
||||
<InputText
|
||||
id="item-search"
|
||||
v-model="searchTerm"
|
||||
placeholder="Search by item code or name..."
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
<div class="tabs-container">
|
||||
<Tabs v-model="activeItemTab" v-if="itemGroups.length > 0 || packageGroups.length > 0">
|
||||
<TabList>
|
||||
<Tab v-if="packageGroups.length > 0" value="Packages">
|
||||
<i class="pi pi-box"></i>
|
||||
<span>Packages</span>
|
||||
</Tab>
|
||||
<Tab v-for="group in itemGroups" :key="group" :value="group">{{ group }}</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<!-- Packages tab with nested sub-tabs -->
|
||||
<TabPanel v-if="packageGroups.length > 0" value="Packages">
|
||||
<Tabs v-model="activePackageTab" class="nested-tabs">
|
||||
<TabList>
|
||||
<Tab v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">{{ packageGroup }}</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">
|
||||
<div class="package-items-container">
|
||||
<div v-for="item in getFilteredPackageItemsForGroup(packageGroup)" :key="item.itemCode" class="package-item" :class="{ 'package-item-selected': item._selected }">
|
||||
<div class="package-item-header">
|
||||
<Button
|
||||
:icon="isPackageExpanded(item.itemCode) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
|
||||
@click="togglePackageExpansion(item.itemCode)"
|
||||
text
|
||||
rounded
|
||||
class="expand-button"
|
||||
/>
|
||||
<span class="package-item-code">{{ item.itemCode }}</span>
|
||||
<span class="package-item-name">{{ item.itemName }}</span>
|
||||
<span class="package-item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
|
||||
<Button
|
||||
:label="item._selected ? 'Selected' : 'Select'"
|
||||
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
|
||||
@click="handleItemSelection(item)"
|
||||
size="small"
|
||||
:severity="item._selected ? 'success' : 'secondary'"
|
||||
class="add-package-button"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isPackageExpanded(item.itemCode) && item.bom && item.bom.items" class="bom-details">
|
||||
<div class="bom-header">Bill of Materials:</div>
|
||||
<div v-for="bomItem in item.bom.items" :key="bomItem.itemCode" class="bom-item-wrapper">
|
||||
<BomItem :item="bomItem" :parentPath="item.itemCode" :level="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
<!-- Regular category tabs -->
|
||||
<TabPanel v-for="group in itemGroups" :key="group" :value="group">
|
||||
<ItemSelector
|
||||
:items="getFilteredItemsForGroup(group)"
|
||||
:selected-items="getSelectedItemsForGroup(group)"
|
||||
@select="handleItemSelection"
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
<!-- Fallback if no categories -->
|
||||
<ItemSelector v-else :items="[]" empty-message="No items available. Please select a Project Template first." />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<Button
|
||||
label="Clear Selection"
|
||||
@click="clearSelection"
|
||||
severity="secondary"
|
||||
:disabled="selectedItemsCount === 0"
|
||||
/>
|
||||
<Button
|
||||
:label="`Add ${selectedItemsCount} Item${selectedItemsCount !== 1 ? 's' : ''}`"
|
||||
@click="addItems"
|
||||
icon="pi pi-plus"
|
||||
:disabled="selectedItemsCount === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, defineComponent, h, shallowRef } from "vue";
|
||||
import Modal from "../common/Modal.vue";
|
||||
import ItemSelector from "../common/ItemSelector.vue";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Button from "primevue/button";
|
||||
import Tabs from "primevue/tabs";
|
||||
import TabList from "primevue/tablist";
|
||||
import Tab from "primevue/tab";
|
||||
import TabPanels from "primevue/tabpanels";
|
||||
import TabPanel from "primevue/tabpanel";
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
quotationItems: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'add-items']);
|
||||
|
||||
const searchTerm = ref("");
|
||||
const expandedPackageItems = shallowRef(new Set());
|
||||
const selectedItemsInModal = shallowRef(new Set());
|
||||
|
||||
// BomItem component for recursive rendering
|
||||
const BomItem = defineComponent({
|
||||
name: 'BomItem',
|
||||
props: {
|
||||
item: Object,
|
||||
parentPath: String,
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const itemPath = computed(() => {
|
||||
return props.parentPath ? `${props.parentPath}.${props.item.itemCode}` : props.item.itemCode;
|
||||
});
|
||||
|
||||
const isPackage = computed(() => {
|
||||
return props.item.bom && props.item.bom.items && props.item.bom.items.length > 0;
|
||||
});
|
||||
|
||||
const isExpanded = computed(() => {
|
||||
return expandedPackageItems.value.has(itemPath.value);
|
||||
});
|
||||
|
||||
const toggleExpansion = () => {
|
||||
if (expandedPackageItems.value.has(itemPath.value)) {
|
||||
expandedPackageItems.value.delete(itemPath.value);
|
||||
} else {
|
||||
expandedPackageItems.value.add(itemPath.value);
|
||||
}
|
||||
expandedPackageItems.value = new Set(expandedPackageItems.value);
|
||||
};
|
||||
|
||||
return () => h('div', {
|
||||
class: 'bom-item',
|
||||
style: { paddingLeft: `${props.level * 1}rem` }
|
||||
}, [
|
||||
h('div', { class: 'bom-item-content' }, [
|
||||
isPackage.value ? h(Button, {
|
||||
icon: isExpanded.value ? 'pi pi-chevron-down' : 'pi pi-chevron-right',
|
||||
onClick: toggleExpansion,
|
||||
text: true,
|
||||
rounded: true,
|
||||
size: 'small',
|
||||
class: 'bom-expand-button'
|
||||
}) : h('i', { class: 'pi pi-circle-fill bom-item-bullet' }),
|
||||
isPackage.value ? h('i', { class: 'pi pi-box package-icon' }) : null,
|
||||
h('span', { class: 'bom-item-code' }, props.item.itemCode),
|
||||
h('span', { class: 'bom-item-name' }, props.item.itemName),
|
||||
h('span', { class: 'bom-item-qty' }, `Qty: ${props.item.qty}`)
|
||||
]),
|
||||
isPackage.value && isExpanded.value && props.item.bom?.items ? h('div', { class: 'nested-bom' },
|
||||
props.item.bom.items.map(nestedItem =>
|
||||
h(BomItem, {
|
||||
key: nestedItem.itemCode,
|
||||
item: nestedItem,
|
||||
parentPath: itemPath.value,
|
||||
level: props.level + 1
|
||||
})
|
||||
)
|
||||
) : null
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const itemGroups = computed(() => {
|
||||
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
|
||||
// Get all keys except 'Packages'
|
||||
const groups = Object.keys(props.quotationItems).filter(key => key !== 'Packages').sort();
|
||||
return groups;
|
||||
});
|
||||
|
||||
const packageGroups = computed(() => {
|
||||
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
|
||||
return Object.keys(props.quotationItems.Packages).sort();
|
||||
});
|
||||
|
||||
// Active tabs with default to Packages
|
||||
const activeItemTab = computed({
|
||||
get: () => _activeItemTab.value || (packageGroups.value.length > 0 ? "Packages" : itemGroups.value[0]) || "",
|
||||
set: (val) => { _activeItemTab.value = val; }
|
||||
});
|
||||
|
||||
const activePackageTab = computed({
|
||||
get: () => _activePackageTab.value || packageGroups.value[0] || "",
|
||||
set: (val) => { _activePackageTab.value = val; }
|
||||
});
|
||||
|
||||
const _activeItemTab = ref("");
|
||||
const _activePackageTab = ref("");
|
||||
|
||||
const getFilteredItemsForGroup = (group) => {
|
||||
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
|
||||
|
||||
let items = [];
|
||||
|
||||
// Get items from the specified group
|
||||
if (group && props.quotationItems[group]) {
|
||||
items = [...props.quotationItems[group]];
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm.value.trim()) {
|
||||
const term = searchTerm.value.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.itemCode?.toLowerCase().includes(term) ||
|
||||
item.itemName?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
// Map items and mark those that are selected
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
id: item.itemCode
|
||||
}));
|
||||
};
|
||||
|
||||
const getSelectedItemsForGroup = (group) => {
|
||||
if (selectedItemsInModal.value.size === 0) return [];
|
||||
const allItems = getFilteredItemsForGroup(group);
|
||||
return allItems.filter(item => selectedItemsInModal.value.has(item.itemCode));
|
||||
};
|
||||
|
||||
const getFilteredPackageItemsForGroup = (packageGroup) => {
|
||||
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
|
||||
|
||||
let items = [];
|
||||
|
||||
// Get items from the specified package group
|
||||
if (packageGroup && props.quotationItems.Packages[packageGroup]) {
|
||||
items = [...props.quotationItems.Packages[packageGroup]];
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm.value.trim()) {
|
||||
const term = searchTerm.value.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.itemCode?.toLowerCase().includes(term) ||
|
||||
item.itemName?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
id: item.itemCode,
|
||||
_selected: selectedItemsInModal.value.has(item.itemCode)
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedItemsCount = computed(() => selectedItemsInModal.value.size);
|
||||
|
||||
const togglePackageExpansion = (itemCode) => {
|
||||
const newExpanded = new Set(expandedPackageItems.value);
|
||||
if (newExpanded.has(itemCode)) {
|
||||
newExpanded.delete(itemCode);
|
||||
} else {
|
||||
newExpanded.add(itemCode);
|
||||
}
|
||||
expandedPackageItems.value = newExpanded;
|
||||
};
|
||||
|
||||
const isPackageExpanded = (itemCode) => {
|
||||
return expandedPackageItems.value.has(itemCode);
|
||||
};
|
||||
|
||||
const handleItemSelection = (itemOrRows) => {
|
||||
// Handle both single item (from package cards) and array (from DataTable)
|
||||
if (Array.isArray(itemOrRows)) {
|
||||
// From ItemSelector - merge with existing selection
|
||||
const newSelection = new Set(selectedItemsInModal.value);
|
||||
const itemCodes = itemOrRows.map(row => row.itemCode);
|
||||
|
||||
// Check if all items are already selected
|
||||
const allSelected = itemCodes.every(code => newSelection.has(code));
|
||||
|
||||
if (allSelected) {
|
||||
// Deselect all items
|
||||
itemCodes.forEach(code => newSelection.delete(code));
|
||||
} else {
|
||||
// Select all items
|
||||
itemCodes.forEach(code => newSelection.add(code));
|
||||
}
|
||||
|
||||
selectedItemsInModal.value = newSelection;
|
||||
} else {
|
||||
// From package card - toggle single item
|
||||
const newSelection = new Set(selectedItemsInModal.value);
|
||||
if (newSelection.has(itemOrRows.itemCode)) {
|
||||
newSelection.delete(itemOrRows.itemCode);
|
||||
} else {
|
||||
newSelection.add(itemOrRows.itemCode);
|
||||
}
|
||||
selectedItemsInModal.value = newSelection;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedItemsInModal.value = new Set();
|
||||
};
|
||||
|
||||
const addItems = () => {
|
||||
// Get all selected items from all categories
|
||||
const allItems = [];
|
||||
|
||||
// Collect from regular categories
|
||||
if (props.quotationItems && typeof props.quotationItems === 'object') {
|
||||
Object.keys(props.quotationItems).forEach(key => {
|
||||
if (key !== 'Packages' && Array.isArray(props.quotationItems[key])) {
|
||||
props.quotationItems[key].forEach(item => {
|
||||
if (selectedItemsInModal.value.has(item.itemCode)) {
|
||||
allItems.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Collect from Packages sub-categories
|
||||
if (props.quotationItems.Packages && typeof props.quotationItems.Packages === 'object') {
|
||||
Object.keys(props.quotationItems.Packages).forEach(subKey => {
|
||||
if (Array.isArray(props.quotationItems.Packages[subKey])) {
|
||||
props.quotationItems.Packages[subKey].forEach(item => {
|
||||
if (selectedItemsInModal.value.has(item.itemCode)) {
|
||||
allItems.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (allItems.length > 0) {
|
||||
emit('add-items', allItems);
|
||||
selectedItemsInModal.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
selectedItemsInModal.value = new Set();
|
||||
searchTerm.value = "";
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// Watch modal visibility to reset state when closing
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
// Modal is opening - reset to first tabs
|
||||
_activeItemTab.value = "";
|
||||
_activePackageTab.value = "";
|
||||
} else {
|
||||
// Modal is closing - reset state
|
||||
selectedItemsInModal.value = new Set();
|
||||
searchTerm.value = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.items-modal-content {
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabpanels) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-container :deep(.p-tabpanel) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
/* margin removed - parent gap handles spacing */
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tip-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #2196f3;
|
||||
border-radius: 4px;
|
||||
color: #1565c0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tip-section i {
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.tip-section kbd {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-title-container :deep(.p-tab) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.selection-badge {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nested-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabpanels) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nested-tabs :deep(.p-tabpanel) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.package-items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.package-item {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background-color: #fafafa;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.package-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.package-item-selected {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.package-item-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 120px 1fr 100px 80px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.expand-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.package-item-code {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.package-item-name {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.package-item-price {
|
||||
font-weight: 600;
|
||||
color: #2196f3;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.add-package-button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.bom-details {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bom-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bom-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.bom-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bom-item-content {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 24px 120px 1fr 100px;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bom-expand-button {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bom-item-bullet {
|
||||
font-size: 0.4rem;
|
||||
color: #ccc;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.package-icon {
|
||||
color: #2196f3;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bom-item-code {
|
||||
font-family: monospace;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bom-item-name {
|
||||
color: #555;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bom-item-qty {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nested-bom {
|
||||
background-color: #fafafa;
|
||||
border-left: 2px solid #e0e0e0;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
@ -1,980 +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" />
|
||||
<div
|
||||
v-for="row in groupedFields"
|
||||
:key="`row-${row.rowIndex}`"
|
||||
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>
|
||||
</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(props.projectTemplate);
|
||||
|
||||
// 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
|
||||
<template #title>
|
||||
<div class="modal-header">
|
||||
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||
Meeting Details
|
||||
</div>
|
||||
</template>
|
||||
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
|
||||
<template #title>
|
||||
<div class="modal-header">
|
||||
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||
Meeting Details
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="meeting" class="meeting-details">
|
||||
<!-- Status Badge -->
|
||||
<div class="status-section">
|
||||
@ -130,25 +129,14 @@
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<v-btn
|
||||
v-if="meeting.status === 'Scheduled'"
|
||||
v-if="meeting.status !== 'Completed' && meeting.status !== 'Unscheduled'"
|
||||
@click="handleMarkComplete"
|
||||
color="success"
|
||||
variant="elevated"
|
||||
:loading="isUpdating"
|
||||
>
|
||||
<v-icon left>mdi-file-edit</v-icon>
|
||||
Create Notes and Complete
|
||||
</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-icon left>mdi-check</v-icon>
|
||||
Mark as Completed
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@ -160,98 +148,15 @@
|
||||
<v-icon left>mdi-file-document-outline</v-icon>
|
||||
Create Estimate
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Modal from "../common/Modal.vue";
|
||||
import BidMeetingNotes from "./BidMeetingNotes.vue";
|
||||
import Api from "../../api";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
|
||||
@ -271,16 +176,10 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["update:visible", "close", "meetingUpdated", "completeMeeting"]);
|
||||
const emit = defineEmits(["update:visible", "close", "meetingUpdated"]);
|
||||
|
||||
// Local state
|
||||
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({
|
||||
get() {
|
||||
@ -299,13 +198,6 @@ const modalOptions = computed(() => ({
|
||||
confirmButtonColor: "primary",
|
||||
}));
|
||||
|
||||
const bidNotesModalOptions = computed(() => ({
|
||||
maxWidth: "1000px",
|
||||
showCancelButton: false,
|
||||
confirmButtonText: "Close",
|
||||
confirmButtonColor: "primary",
|
||||
}));
|
||||
|
||||
// Computed properties for data extraction
|
||||
const customerName = computed(() => {
|
||||
if (props.meeting?.address?.customerName) {
|
||||
@ -377,20 +269,34 @@ const handleClose = () => {
|
||||
const handleMarkComplete = async () => {
|
||||
if (!props.meeting?.name) return;
|
||||
|
||||
// Check if meeting has a project template
|
||||
if (!props.meeting.projectTemplate) {
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
|
||||
await Api.updateBidMeeting(props.meeting.name, {
|
||||
status: "Completed",
|
||||
});
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: "warning",
|
||||
title: "Missing Project Template",
|
||||
message: "This meeting requires a project template to create notes.",
|
||||
type: "success",
|
||||
title: "Meeting Completed",
|
||||
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,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
|
||||
// Open the note form modal
|
||||
emit("completeMeeting", props.meeting);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleCreateEstimate = () => {
|
||||
@ -407,7 +313,7 @@ const handleCreateEstimate = () => {
|
||||
new: "true",
|
||||
address: addressText,
|
||||
"from-meeting": fromMeeting,
|
||||
"project-template": template,
|
||||
template: template,
|
||||
contact: contactName,
|
||||
},
|
||||
});
|
||||
@ -430,81 +336,6 @@ const formatDateTime = (dateString) => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@ -546,24 +377,5 @@ const handleCancelMeeting = async () => {
|
||||
padding-top: 16px;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
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>
|
||||
|
||||
@ -1,382 +0,0 @@
|
||||
<template>
|
||||
<Modal
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
@close="handleClose"
|
||||
:options="{ showActions: false }"
|
||||
>
|
||||
<template #title>Save as Package</template>
|
||||
<div class="modal-content">
|
||||
<div class="form-section">
|
||||
<label for="packageName" class="field-label">
|
||||
Package Name
|
||||
<span class="required">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="packageName"
|
||||
v-model="formData.packageName"
|
||||
placeholder="Enter package name"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="description" class="field-label">
|
||||
Description
|
||||
</label>
|
||||
<InputText
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
placeholder="Enter package description (optional)"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="codePrefix" class="field-label">
|
||||
Code Prefix
|
||||
<span class="required">*</span>
|
||||
</label>
|
||||
<Select
|
||||
id="codePrefix"
|
||||
v-model="formData.codePrefix"
|
||||
:options="codePrefixOptions"
|
||||
placeholder="Select a code prefix"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="category" class="field-label">
|
||||
Category
|
||||
<span class="required">*</span>
|
||||
</label>
|
||||
<Select
|
||||
id="category"
|
||||
v-model="formData.category"
|
||||
:options="categories"
|
||||
placeholder="Select a category"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="rate" class="field-label">
|
||||
Rate
|
||||
<span class="required">*</span>
|
||||
</label>
|
||||
<InputNumber
|
||||
id="rate"
|
||||
v-model="formData.rate"
|
||||
mode="currency"
|
||||
currency="USD"
|
||||
locale="en-US"
|
||||
:min="0"
|
||||
placeholder="$0.00"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4>Package Contents</h4>
|
||||
<div v-if="items.length === 0" class="no-items">
|
||||
No items selected
|
||||
</div>
|
||||
<div v-else class="items-list">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="package-item"
|
||||
>
|
||||
<div class="item-header" @click="toggleItemExpansion(index)">
|
||||
<div class="item-info">
|
||||
<i
|
||||
v-if="isPackage(item)"
|
||||
:class="[
|
||||
'pi',
|
||||
expandedItems.has(index) ? 'pi-chevron-down' : 'pi-chevron-right',
|
||||
'expand-icon'
|
||||
]"
|
||||
></i>
|
||||
<span class="item-name">{{ item.itemName || item.itemCode }}</span>
|
||||
<span v-if="isPackage(item)" class="package-badge">Package</span>
|
||||
</div>
|
||||
<span class="item-qty">Qty: {{ item.qty || 1 }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPackage(item) && expandedItems.has(index)"
|
||||
class="package-contents"
|
||||
>
|
||||
<div
|
||||
v-for="(bomItem, bomIndex) in item.bom"
|
||||
:key="bomIndex"
|
||||
class="bom-item"
|
||||
>
|
||||
<span class="bom-item-name">{{ bomItem.itemName || bomItem.itemCode }}</span>
|
||||
<span class="bom-item-qty">Qty: {{ bomItem.qty || 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<Button label="Cancel" @click="handleClose" severity="secondary" />
|
||||
<Button
|
||||
label="Save Package"
|
||||
@click="handleSave"
|
||||
:disabled="!isFormValid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import Modal from "../common/Modal.vue";
|
||||
import InputText from "primevue/inputtext";
|
||||
import InputNumber from "primevue/inputnumber";
|
||||
import Button from "primevue/button";
|
||||
import Select from "primevue/select";
|
||||
import Api from "../../api";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
defaultRate: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "save"]);
|
||||
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const formData = reactive({
|
||||
packageName: "",
|
||||
description: "",
|
||||
codePrefix: null,
|
||||
category: null,
|
||||
rate: null,
|
||||
});
|
||||
|
||||
const codePrefixOptions = ref(["BLDR", "SNW-I"]);
|
||||
const categories = ref([]);
|
||||
const expandedItems = ref(new Set());
|
||||
const isLoading = ref(false);
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return formData.packageName.trim() !== "" &&
|
||||
formData.codePrefix !== null &&
|
||||
formData.category !== null &&
|
||||
formData.rate !== null &&
|
||||
formData.rate > 0;
|
||||
});
|
||||
|
||||
const isPackage = (item) => {
|
||||
return item.bom && Array.isArray(item.bom) && item.bom.length > 0;
|
||||
};
|
||||
|
||||
const toggleItemExpansion = (index) => {
|
||||
const item = props.items[index];
|
||||
if (!isPackage(item)) return;
|
||||
|
||||
if (expandedItems.value.has(index)) {
|
||||
expandedItems.value.delete(index);
|
||||
} else {
|
||||
expandedItems.value.add(index);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const result = await Api.getItemCategories();
|
||||
categories.value = result || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching item categories:", error);
|
||||
notificationStore.addNotification("Failed to fetch item categories", "error");
|
||||
categories.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset form
|
||||
formData.packageName = "";
|
||||
formData.description = "";
|
||||
formData.codePrefix = null;
|
||||
formData.category = null;
|
||||
formData.rate = null;
|
||||
expandedItems.value.clear();
|
||||
emit("update:visible", false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!isFormValid.value) {
|
||||
notificationStore.addNotification("Please fill in all required fields", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const packageData = {
|
||||
packageName: formData.packageName,
|
||||
description: formData.description,
|
||||
codePrefix: formData.codePrefix,
|
||||
category: formData.category,
|
||||
rate: formData.rate,
|
||||
items: props.items.map(item => ({
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
qty: item.qty || 1,
|
||||
uom: item.uom || item.stockUom,
|
||||
})),
|
||||
};
|
||||
|
||||
emit("save", packageData);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// Watch for modal opening to fetch categories
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
fetchCategories();
|
||||
// Set rate to defaultRate when modal opens
|
||||
if (props.defaultRate > 0) {
|
||||
formData.rate = props.defaultRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-content {
|
||||
padding: 1.5rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.package-item {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.package-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.item-header:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.package-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-qty {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.package-contents {
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.bom-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bom-item-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bom-item-qty {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
@ -7,7 +7,4 @@ import CalendarNavigation from '@/components/calendar/CalendarNavigation.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.calendar-navigation) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,79 +1,31 @@
|
||||
<template>
|
||||
<div class="client-page">
|
||||
<!-- Client Header -->
|
||||
<GeneralClientInfo
|
||||
v-if="client.customerName"
|
||||
:client-data="client"
|
||||
/>
|
||||
<TopBar :selectedAddressIdx="selectedAddressIdx" :client="client" :nextVisitDate="nextVisitDate" v-if="client.customerName" @update:selectedAddressIdx="selectedAddressIdx = $event" />
|
||||
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
|
||||
|
||||
<!-- Address Selector (only shows if multiple addresses) -->
|
||||
<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">
|
||||
<Tabs value="0">
|
||||
<TabList>
|
||||
<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>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<!-- Overview Tab -->
|
||||
<TabPanel value="0">
|
||||
<Overview
|
||||
:selected-address="selectedAddressData"
|
||||
:all-contacts="client.contacts"
|
||||
:edit-mode="editMode"
|
||||
:client-data="client"
|
||||
:selected-address="selectedAddress"
|
||||
:is-new="isNew"
|
||||
:full-address="fullAddress"
|
||||
:client="client"
|
||||
@edit-mode-enabled="enableEditMode"
|
||||
@update:address-contacts="handleAddressContactsUpdate"
|
||||
@update:primary-contact="handlePrimaryContactUpdate"
|
||||
@update:client="handleClientUpdate"
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Projects Tab -->
|
||||
<TabPanel value="1">
|
||||
<div class="coming-soon-section">
|
||||
<i class="pi pi-wrench"></i>
|
||||
<h3>Projects</h3>
|
||||
<p>Section coming soon</p>
|
||||
</div>
|
||||
<div id="projects-tab"><h3>Project Status</h3></div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Financials Tab -->
|
||||
<TabPanel value="2">
|
||||
<div class="coming-soon-section">
|
||||
<i class="pi pi-dollar"></i>
|
||||
<h3>Financials</h3>
|
||||
<p>Section coming soon</p>
|
||||
</div>
|
||||
<div id="financials-tab"><h3>Accounting</h3></div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</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>
|
||||
</template>
|
||||
<script setup>
|
||||
@ -83,25 +35,21 @@ import TabList from "primevue/tablist";
|
||||
import Tab from "primevue/tab";
|
||||
import TabPanels from "primevue/tabpanels";
|
||||
import TabPanel from "primevue/tabpanel";
|
||||
import Button from "primevue/button";
|
||||
import Api from "../../api";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useLoadingStore } from "../../stores/loading";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
import { useCompanyStore } from "../../stores/company";
|
||||
import DataUtils from "../../utils";
|
||||
import AddressSelector from "../clientView/AddressSelector.vue";
|
||||
import GeneralClientInfo from "../clientView/GeneralClientInfo.vue";
|
||||
import Overview from "../clientSubPages/Overview.vue";
|
||||
import ProjectStatus from "../clientSubPages/ProjectStatus.vue";
|
||||
import TopBar from "../clientView/TopBar.vue";
|
||||
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
|
||||
import Overview from "../clientView/Overview.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loadingStore = useLoadingStore();
|
||||
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 isNew = computed(() => route.query.new === "true" || false);
|
||||
|
||||
@ -118,17 +66,13 @@ const selectedAddressObject = computed(() =>
|
||||
);
|
||||
const addresses = computed(() => {
|
||||
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 [];
|
||||
});
|
||||
|
||||
const nextVisitDate = ref(null); // Placeholder, update as needed
|
||||
|
||||
// Tab and edit state
|
||||
const editMode = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const selectedAddressIdx = computed({
|
||||
get: () => addresses.value.indexOf(selectedAddress.value),
|
||||
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) => {
|
||||
loadingStore.setLoading(true);
|
||||
try {
|
||||
@ -174,11 +102,10 @@ const getClient = async (name) => {
|
||||
// Set initial selected address if provided in route or use first address
|
||||
if (address && client.value.addresses) {
|
||||
const fullAddresses = client.value.addresses.map((addr) =>
|
||||
DataUtils.calculateFullAddress(addr).trim(),
|
||||
DataUtils.calculateFullAddress(addr),
|
||||
);
|
||||
const trimmedAddress = address.trim();
|
||||
if (fullAddresses.includes(trimmedAddress)) {
|
||||
selectedAddress.value = trimmedAddress;
|
||||
if (fullAddresses.includes(address)) {
|
||||
selectedAddress.value = address;
|
||||
} else if (fullAddresses.length > 0) {
|
||||
selectedAddress.value = fullAddresses[0];
|
||||
}
|
||||
@ -196,16 +123,6 @@ const getClient = async (name) => {
|
||||
} else if (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) {
|
||||
console.error("Error fetching client data in Client.vue: ", error.message || error);
|
||||
} 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!");
|
||||
const strippedName = createdClient.name.split("-#-")[0].trim();
|
||||
// Navigate to the created client
|
||||
router.push('/client?client=' + encodeURIComponent(strippedName));
|
||||
} 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>
|
||||
<style lang="css">
|
||||
.tab-info-alert {
|
||||
@ -361,63 +184,4 @@ const handleClientUpdate = (newClientData) => {
|
||||
padding-top: 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>
|
||||
|
||||
@ -29,12 +29,10 @@ import { useFiltersStore } from "../../stores/filters";
|
||||
import { useModalStore } from "../../stores/modal";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
import { useCompanyStore } from "../../stores/company";
|
||||
import TodoChart from "../common/TodoChart.vue";
|
||||
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
|
||||
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
|
||||
WateringSoil, Soil, Truck, SoilAlt,
|
||||
Filter} from "@iconoir/vue";
|
||||
WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
|
||||
|
||||
const notifications = useNotificationStore();
|
||||
const loadingStore = useLoadingStore();
|
||||
@ -43,7 +41,6 @@ const filtersStore = useFiltersStore();
|
||||
const modalStore = useModalStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const companyStore = useCompanyStore();
|
||||
|
||||
const tableData = ref([]);
|
||||
const totalRecords = ref(0);
|
||||
@ -53,26 +50,10 @@ const currentWeekParams = ref({});
|
||||
const chartLoading = ref(true); // Start with loading state
|
||||
|
||||
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
|
||||
const currentFilters = computed(() => {
|
||||
filters = { ...filtersStore.getTableFilters("clients"),
|
||||
company: { value: companyStore.currentCompany, matchMode: FilterMatchMode.CONTAINS}
|
||||
};
|
||||
return filtersStore.getTableFilters("clients");
|
||||
});
|
||||
|
||||
// Handle week change from chart
|
||||
@ -239,7 +220,6 @@ const tableActions = [
|
||||
// Handle lazy loading events from DataTable
|
||||
const handleLazyLoad = async (event) => {
|
||||
console.log("Clients page - handling lazy load:", event);
|
||||
lastLazyLoadEvent.value = event;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
@ -283,9 +263,8 @@ const handleLazyLoad = async (event) => {
|
||||
filters,
|
||||
sortingArray,
|
||||
});
|
||||
filters["company"] = { value: companyStore.currentCompany, matchMode: FilterMatchMode.CONTAINS};
|
||||
|
||||
const result = await Api.getPaginatedClientDetailsV2(
|
||||
const result = await Api.getPaginatedClientDetails(
|
||||
paginationParams,
|
||||
filters,
|
||||
sortingArray,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Incomplete Bids"
|
||||
:categories="chartData.bids"
|
||||
:todoNumber="bidsTodoNumber"
|
||||
:completedNumber="bidsCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -37,7 +38,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Unapproved Estimates"
|
||||
:categories="chartData.estimates"
|
||||
:todoNumber="estimatesTodoNumber"
|
||||
:completedNumber="estimatesCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -59,7 +61,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Half Down Payments"
|
||||
:categories="chartData.halfDown"
|
||||
:todoNumber="halfDownTodoNumber"
|
||||
:completedNumber="halfDownCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -81,7 +84,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Jobs In Queue"
|
||||
:categories="chartData.jobsInQueue"
|
||||
:todoNumber="jobQueueTodoNumber"
|
||||
:completedNumber="jobQueueCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -128,15 +132,13 @@ import Card from "../common/Card.vue";
|
||||
import DataTable from "../common/DataTable.vue";
|
||||
import TodoChart from "../common/TodoChart.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 { useLoadingStore } from "../../stores/loading";
|
||||
import { usePaginationStore } from "../../stores/pagination";
|
||||
import { useFiltersStore } from "../../stores/filters";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useCompanyStore } from "../../stores/company";
|
||||
|
||||
const companyStore = useCompanyStore();
|
||||
const loadingStore = useLoadingStore();
|
||||
const paginationStore = usePaginationStore();
|
||||
const filtersStore = useFiltersStore();
|
||||
@ -146,25 +148,15 @@ const tableData = ref([]);
|
||||
const totalRecords = ref(0);
|
||||
const isLoading = ref(false);
|
||||
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
|
||||
const filteredItems= []
|
||||
// End junk
|
||||
|
||||
const columns = [
|
||||
{ label: "Estimate Address", fieldName: "address", type: "link", sortable: true, filterable:true,
|
||||
onLinkClick: (link, rowData) => handlePropertyClick(link, rowData),
|
||||
},
|
||||
{ label: "Estimate Address", fieldName: "address", type: "text", sortable: true, filterable: true },
|
||||
//{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
|
||||
{ label: "Customer", fieldName: "customer", type: "link", sortable: true, filterable: true,
|
||||
onLinkClick: (link, rowData) => handleCustomerClick(link, rowData),
|
||||
},
|
||||
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
|
||||
{
|
||||
label: "Status",
|
||||
fieldName: "status",
|
||||
@ -202,19 +194,6 @@ const closeSubmitEstimateModal = () => {
|
||||
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) => {
|
||||
console.log("Estimates page - handling lazy load:", event);
|
||||
@ -235,7 +214,7 @@ const handleLazyLoad = async (event) => {
|
||||
};
|
||||
|
||||
// Get filters (convert PrimeVue format to API format)
|
||||
const filters = {company: companyStore.currentCompany};
|
||||
const filters = {};
|
||||
if (event.filters) {
|
||||
Object.keys(event.filters).forEach((key) => {
|
||||
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
|
||||
onMounted(async () => {
|
||||
// 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>
|
||||
<style lang="css">
|
||||
.widgets-grid {
|
||||
|
||||
@ -15,10 +15,11 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Locates"
|
||||
:categories="chartData.locates"
|
||||
:todoNumber="locatesTodoNumber"
|
||||
:completedNumber="locatesCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Locate')">
|
||||
<button class="sidebar-button" @click="navigateTo('/jobs')">
|
||||
View Locates
|
||||
</button>
|
||||
</div>
|
||||
@ -36,10 +37,11 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Permits"
|
||||
:categories="chartData.permits"
|
||||
:todoNumber="permitsTodoNumber"
|
||||
:completedNumber="permitsCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Permit(s)')">
|
||||
<button class="sidebar-button" @click="navigateTo('/jobs')">
|
||||
View Permits
|
||||
</button>
|
||||
</div>
|
||||
@ -57,10 +59,11 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Permit Finalization"
|
||||
:categories="chartData.permitFinalizations"
|
||||
:todoNumber="permitFinalizationsTodoNumber"
|
||||
:completedNumber="permitFinalizationsCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Close-out')">
|
||||
<button class="sidebar-button" @click="navigateTo('/jobs')">
|
||||
View Finalizations
|
||||
</button>
|
||||
</div>
|
||||
@ -78,10 +81,11 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Warranty Claims"
|
||||
:categories="chartData.warranties"
|
||||
:todoNumber="warrantyTodoNumber"
|
||||
:completedNumber="warrantyCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button" @click="navigateTo('/warranties')">
|
||||
<button class="sidebar-button" @click="navigateTo('/jobs')">
|
||||
View Warranties
|
||||
</button>
|
||||
</div>
|
||||
@ -99,7 +103,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Incomplete Bids"
|
||||
:categories="chartData.bids"
|
||||
:todoNumber="bidsTodoNumber"
|
||||
:completedNumber="bidsCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -121,7 +126,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Unapproved Estimates"
|
||||
:categories="chartData.estimates"
|
||||
:todoNumber="estimatesTodoNumber"
|
||||
:completedNumber="estimatesCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -143,11 +149,12 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Half Down Payments"
|
||||
:categories="chartData.halfDown"
|
||||
:todoNumber="halfDownTodoNumber"
|
||||
:completedNumber="halfDownCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@click="navigateTo('/invoices')">
|
||||
@click="navigateTo('/jobs')">
|
||||
Half Down Payments
|
||||
</button>
|
||||
</div>
|
||||
@ -165,11 +172,12 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="15 Day Follow Ups"
|
||||
:categories="chartData.fifteenDayFollowups"
|
||||
:todoNumber="fifteenDayTodoNumber"
|
||||
:completedNumber="fifteenDayCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@click="navigateTo('/tasks?subject=15-Day')">
|
||||
@click="navigateTo('/calendar')">
|
||||
View Follow Ups
|
||||
</button>
|
||||
</div>
|
||||
@ -187,7 +195,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Late Balances"
|
||||
:categories="chartData.lateBalances"
|
||||
:todoNumber="balancesTodoNumber"
|
||||
:completedNumber="balancesCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -209,11 +218,12 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Backflow Tests"
|
||||
:categories="chartData.backflows"
|
||||
:todoNumber="backflowsTodoNumber"
|
||||
:completedNumber="backflowsCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@click="navigateTo('/tasks?subject=backflow')">
|
||||
@click="navigateTo('/jobs')">
|
||||
Late Balances
|
||||
</button>
|
||||
</div>
|
||||
@ -231,11 +241,12 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Curbing"
|
||||
:categories="chartData.curbing"
|
||||
:todoNumber="curbingTodoNumber"
|
||||
:completedNumber="curbingCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@click="navigateTo('/tasks?subject=Curbing')">
|
||||
@click="navigateTo('/jobs')">
|
||||
Curbing
|
||||
</button>
|
||||
</div>
|
||||
@ -253,11 +264,12 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Hydroseeding"
|
||||
:categories="chartData.hydroseed"
|
||||
:todoNumber="hydroseedingTodoNumber"
|
||||
:completedNumber="hydroseedingCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@click="navigateTo('/tasks?subject=Hydroseed')">
|
||||
@click="navigateTo('/jobs')">
|
||||
Hydroseeding
|
||||
</button>
|
||||
</div>
|
||||
@ -275,11 +287,12 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Machines"
|
||||
:categories="chartData.machines"
|
||||
:todoNumber="machinesTodoNumber"
|
||||
:completedNumber="machinesCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@click="navigateTo('/tasks?subject=machines')">
|
||||
@click="navigateTo('/jobs')">
|
||||
Machines
|
||||
</button>
|
||||
</div>
|
||||
@ -297,11 +310,12 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Deliveries"
|
||||
:categories="chartData.deliveries"
|
||||
:todoNumber="deliveriesTodoNumber"
|
||||
:completedNumber="delivieriesCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@click="navigateTo('/tasks?subject=machines')">
|
||||
@click="navigateTo('/jobs')">
|
||||
Deliveries
|
||||
</button>
|
||||
</div>
|
||||
@ -312,7 +326,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
//import Card from "primevue/card";
|
||||
import Card from "../common/Card.vue";
|
||||
@ -320,48 +334,26 @@ import Tag from "primevue/tag";
|
||||
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
|
||||
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
|
||||
WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
|
||||
import Api from "../../api.js";
|
||||
import DataUtils from "../../utils.js";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
import { useCompanyStore } from "../../stores/company.js";
|
||||
//import SimpleChart from "../common/SimpleChart.vue";
|
||||
import TodoChart from "../common/TodoChart.vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const defaultColors = ['blue', 'green', 'red'];
|
||||
|
||||
// Dummy data from utils
|
||||
const clientData = ref(DataUtils.dummyClientData);
|
||||
const jobData = ref(DataUtils.dummyJobData);
|
||||
const locatesTodoNumber = ref(0);
|
||||
const locatesCompletedNumber = ref(0);
|
||||
const permitsTodoNumber = ref(0);
|
||||
const permitsCompletedNumber = ref(0);
|
||||
const locatesTodoNumber = ref(45);
|
||||
const locatesCompletedNumber = ref(5);
|
||||
const permitsTodoNumber = ref(24);
|
||||
const permitsCompletedNumber = ref(7);
|
||||
const permitFinalizationsTodoNumber = ref(35);
|
||||
const permitFinalizationsCompletedNumber = ref(2);
|
||||
const warrantyTodoNumber = ref(0);
|
||||
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 companyStore = useCompanyStore();
|
||||
|
||||
// Computed values for dashboard metrics
|
||||
const totalRevenue = computed(() => "$47,250");
|
||||
@ -373,31 +365,9 @@ const avgResponseTime = computed(() => 2.3);
|
||||
const navigateTo = (path) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
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() => {
|
||||
onMounted(() => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Ready To Invoice"
|
||||
:categories="chartData.jobsToInvoice"
|
||||
:todoNumber="invoiceTodoNumber"
|
||||
:completedNumber="invoiceCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -37,7 +38,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Late Balances"
|
||||
:categories="chartData.invoicesLate"
|
||||
:todoNumber="balancesTodoNumber"
|
||||
:completedNumber="balancesCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -63,28 +65,21 @@
|
||||
import Card from "../common/Card.vue";
|
||||
import DataTable from "../common/DataTable.vue";
|
||||
import TodoChart from "../common/TodoChart.vue";
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import Api from "../../api";
|
||||
import { useLoadingStore } from "../../stores/loading";
|
||||
import { usePaginationStore } from "../../stores/pagination";
|
||||
import { useFiltersStore } from "../../stores/filters";
|
||||
import { useCompanyStore } from "../../stores/company.js";
|
||||
import { CardNoAccess, CalendarCheck } from "@iconoir/vue";
|
||||
|
||||
const loadingStore = useLoadingStore();
|
||||
const paginationStore = usePaginationStore();
|
||||
const filtersStore = useFiltersStore();
|
||||
const companyStore = useCompanyStore();
|
||||
|
||||
const tableData = ref([]);
|
||||
const totalRecords = ref(0);
|
||||
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 = [
|
||||
{ label: "Customer Address", fieldName: "address", type: "text", sortable: 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
|
||||
onMounted(async () => {
|
||||
// Initialize pagination and filters
|
||||
@ -199,12 +188,6 @@ onMounted(async () => {
|
||||
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
||||
filters: initialFilters,
|
||||
});
|
||||
|
||||
await loadChartData();
|
||||
});
|
||||
|
||||
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
|
||||
await loadChartData();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="widget-content">
|
||||
{{ job.jobAddress["fullAddress"] || "" }}
|
||||
{{ job.customInstallationAddress || "" }}
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
@ -32,26 +32,6 @@
|
||||
</template>
|
||||
</Card>
|
||||
</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 class="task-list">
|
||||
<DataTable
|
||||
@ -86,7 +66,7 @@ const notifications = useNotificationStore();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const jobIdQuery = computed(() => route.query.name || "");
|
||||
const jobIdQuery = computed(() => route.query.jobId || "");
|
||||
const isNew = computed(() => route.query.new === "true");
|
||||
|
||||
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) => {
|
||||
console.log("Task list on Job Page - handling lazy load:", event);
|
||||
try {
|
||||
@ -257,7 +232,7 @@ const handleLazyLoad = async (event) => {
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("DEBUG: Query params:", route.query);
|
||||
|
||||
|
||||
try {
|
||||
const optionsResult = await Api.getTaskStatusOptions();
|
||||
if (optionsResult && optionsResult.length > 0) {
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Jobs In Queue"
|
||||
:categories="chartData.jobsInQueue"
|
||||
:todoNumber="jobQueueTodoNumber"
|
||||
:completedNumber="jobQueueCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -37,7 +38,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Jobs in Progress"
|
||||
:categories="chartData.jobsInProgress"
|
||||
:todoNumber="progressTodoNumber"
|
||||
:completedNumber="progressCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -59,7 +61,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Late Jobs"
|
||||
:categories="chartData.jobsLate"
|
||||
:todoNumber="lateTodoNumber"
|
||||
:completedNumber="lateCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -81,7 +84,8 @@
|
||||
<div class="widget-content">
|
||||
<TodoChart
|
||||
title="Ready To Invoice"
|
||||
:categories="chartData.jobsToInvoice"
|
||||
:todoNumber="invoiceTodoNumber"
|
||||
:completedNumber="invoiceCompletedNumber"
|
||||
>
|
||||
</TodoChart>
|
||||
<button class="sidebar-button"
|
||||
@ -108,39 +112,29 @@
|
||||
import Card from "../common/Card.vue";
|
||||
import DataTable from "../common/DataTable.vue";
|
||||
import TodoChart from "../common/TodoChart.vue";
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Api from "../../api";
|
||||
import { useLoadingStore } from "../../stores/loading";
|
||||
import { usePaginationStore } from "../../stores/pagination";
|
||||
import { useFiltersStore } from "../../stores/filters";
|
||||
import { useCompanyStore } from "../../stores/company.js";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
import { Alarm, CalendarCheck, Hammer } from "@iconoir/vue";
|
||||
|
||||
const loadingStore = useLoadingStore();
|
||||
const paginationStore = usePaginationStore();
|
||||
const filtersStore = useFiltersStore();
|
||||
const companyStore = useCompanyStore();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
const tableData = ref([]);
|
||||
const totalRecords = ref(0);
|
||||
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 = [
|
||||
{ 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: "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 }
|
||||
];
|
||||
|
||||
@ -263,14 +257,7 @@ const handleLazyLoad = async (event) => {
|
||||
|
||||
const handleRowClick = (event) => {
|
||||
const rowData = event.data;
|
||||
router.push(`/job?name=${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);
|
||||
router.push(`/job?jobId=${rowData.name}`);
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
@ -293,16 +280,7 @@ onMounted(async () => {
|
||||
sortField: initialSorting.field || initialPagination.sortField,
|
||||
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
||||
});
|
||||
|
||||
// Chart Data
|
||||
await loadChartData();
|
||||
|
||||
});
|
||||
|
||||
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
|
||||
await loadChartData();
|
||||
});
|
||||
|
||||
</script>
|
||||
<style lang="css">
|
||||
.widgets-grid {
|
||||
|
||||
@ -21,22 +21,18 @@
|
||||
<script setup>
|
||||
import DataTable from "../common/DataTable.vue";
|
||||
import { ref, onMounted, watch, computed } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useRouter } from "vue-router";
|
||||
import Api from "../../api";
|
||||
import { useLoadingStore } from "../../stores/loading";
|
||||
import { usePaginationStore } from "../../stores/pagination";
|
||||
import { useFiltersStore } from "../../stores/filters";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
import { FilterMatchMode } from "@primevue/core";
|
||||
|
||||
const loadingStore = useLoadingStore();
|
||||
const paginationStore = usePaginationStore();
|
||||
const filtersStore = useFiltersStore();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
const route = useRoute();
|
||||
const subject = route.query.subject;
|
||||
|
||||
const tableData = ref([]);
|
||||
const totalRecords = ref(0);
|
||||
const isLoading = ref(false);
|
||||
@ -50,18 +46,13 @@ const statusOptions = ref([
|
||||
"Cancelled",
|
||||
]);
|
||||
|
||||
const filters = {
|
||||
subject: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
};
|
||||
|
||||
// Computed property to get current filters for the chart
|
||||
const currentFilters = computed(() => {
|
||||
return filtersStore.getTableFilters("tasks");
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ label: "Task", fieldName: "subject", type: "text", sortable: true, filterable: true,
|
||||
filterInputID: "subjectFilterId", defaultValue: subject || null },
|
||||
{ label: "Task", fieldName: "subject", type: "text", sortable: true, filterable: true },
|
||||
{ label: "Job", fieldName: "project", type: "link", sortable: true,
|
||||
onLinkClick: (link, rowData) => handleProjectClick(link, rowData)
|
||||
},
|
||||
@ -101,7 +92,7 @@ const tableActions = [
|
||||
try {
|
||||
// Uncomment when API is ready
|
||||
await Api.setTaskStatus(rowData.id, option);
|
||||
|
||||
|
||||
// Find and update the row in the table data
|
||||
const rowIndex = tableData.value.findIndex(row => row.id === rowData.id);
|
||||
if (rowIndex >= 0) {
|
||||
@ -220,6 +211,7 @@ watch(showCompleted, () => {
|
||||
|
||||
// Load initial data
|
||||
onMounted(async () => {
|
||||
notifications.addWarning("Tasks page coming soon");
|
||||
// Initialize pagination and filters
|
||||
paginationStore.initializeTablePagination("tasks", { rows: 10 });
|
||||
filtersStore.initializeTableFilters("tasks", columns);
|
||||
@ -230,11 +222,6 @@ onMounted(async () => {
|
||||
const initialFilters = filtersStore.getTableFilters("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();
|
||||
statusOptions.value = optionsResult;
|
||||
console.log("DEBUG: Loaded Status options: ", statusOptions.value)
|
||||
@ -247,8 +234,6 @@ onMounted(async () => {
|
||||
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
||||
filters: initialFilters,
|
||||
});
|
||||
|
||||
notifications.addWarning("Tasks page coming soon");
|
||||
});
|
||||
</script>
|
||||
<style lang="css">
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to initialize and run Stripe CLI webhook forwarding
|
||||
# Usage: ./stripe-init-webhook.sh --site <site> --port <port>
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
SITE=""
|
||||
PORT="8000"
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--site)
|
||||
SITE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 --site <site> [--port <port>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --site Required. The site domain (e.g., erp.local)"
|
||||
echo " --port Optional. The port number (default: 8000)"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " $0 --site erp.local --port 8000"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if required flag is provided
|
||||
if [ -z "$SITE" ]; then
|
||||
echo "Error: --site flag is required"
|
||||
echo "Usage: $0 --site <site> [--port <port>]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking Stripe CLI installation..."
|
||||
|
||||
# Check if Stripe CLI is installed
|
||||
if ! command -v stripe &> /dev/null; then
|
||||
echo "Stripe CLI is not installed."
|
||||
read -p "Would you like to install it now? (y/n) " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Installing Stripe CLI..."
|
||||
|
||||
# Add GPG key
|
||||
echo "Adding Stripe GPG key..."
|
||||
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
|
||||
|
||||
# Add repository
|
||||
echo "Adding Stripe repository..."
|
||||
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
|
||||
|
||||
# Update and install
|
||||
echo "Updating package list..."
|
||||
sudo apt update
|
||||
|
||||
echo "Installing Stripe CLI..."
|
||||
sudo apt install stripe -y
|
||||
|
||||
echo "Stripe CLI installed successfully!"
|
||||
else
|
||||
echo "Installation cancelled. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Stripe CLI is already installed."
|
||||
fi
|
||||
|
||||
# Check if Stripe CLI is authenticated
|
||||
echo "Checking authentication status..."
|
||||
if ! stripe config --list &> /dev/null; then
|
||||
echo "Stripe CLI is not authenticated."
|
||||
echo "Please log in to your Stripe account..."
|
||||
stripe login --interactive
|
||||
else
|
||||
# Try to verify authentication by running a simple command
|
||||
if stripe config --list | grep -q "test_mode_api_key"; then
|
||||
echo "Stripe CLI is authenticated."
|
||||
else
|
||||
echo "Stripe CLI authentication may be invalid."
|
||||
echo "Please log in to your Stripe account..."
|
||||
stripe login --interactive
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start Docker containers
|
||||
echo ""
|
||||
echo "Starting Docker containers..."
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Start listening for webhooks
|
||||
WEBHOOK_URL="http://${SITE}:${PORT}/api/method/custom_ui.api.public.payments.stripe_webhook"
|
||||
echo ""
|
||||
echo "Starting Stripe webhook listener..."
|
||||
echo "Forwarding to: $WEBHOOK_URL"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
stripe listen --forward-to "$WEBHOOK_URL"
|
||||
@ -1,150 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to initialize and run Stripe CLI webhook forwarding
|
||||
# Usage: ./stripe-init-webhook.sh --site <site> --port <port>
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Display banner
|
||||
echo ""
|
||||
echo -e "${BLUE}+-----------------------------------------------+${NC}"
|
||||
echo -e "${BLUE} ██████╗ ██╗ ██╗██╗██╗ ██████╗ ██╗ ██╗${NC}"
|
||||
echo -e "${BLUE} ██╔════╝ ██║ ██║██║██║ ██╔═══██╗██║ ██║${NC}"
|
||||
echo -e "${BLUE} ╚█████╗ ███████║██║██║ ██║ ██║███████║${NC}"
|
||||
echo -e "${BLUE} ╚═══██╗ ██╔══██║██║██║ ██║ ██║██╔══██║${NC}"
|
||||
echo -e "${BLUE} ██████╔╝ ██║ ██║██║███████╗╚██████╔╝██║ ██║${NC}"
|
||||
echo -e "${BLUE} ╚═════╝ ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝${NC}"
|
||||
echo -e "${BLUE}+-----------------------------------------------+${NC}"
|
||||
echo -e "${YELLOW} 🚀 Automated Local Stripe Environment${NC}"
|
||||
echo -e "${YELLOW} 💼 For ERPNext Development${NC}"
|
||||
echo -e "${BLUE}+-----------------------------------------------+${NC}"
|
||||
echo ""
|
||||
|
||||
# Default values
|
||||
SITE=""
|
||||
PORT="8000"
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--site)
|
||||
SITE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 --site <site> [--port <port>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --site Required. The site domain (e.g., erp.local)"
|
||||
echo " --port Optional. The port number (default: 8000)"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " $0 --site erp.local --port 8000"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if required flag is provided
|
||||
if [ -z "$SITE" ]; then
|
||||
echo -e "${RED}❌ Error: --site flag is required${NC}"
|
||||
echo "Usage: $0 --site <site> [--port <port>]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}🔍 Checking Stripe CLI installation...${NC}"
|
||||
|
||||
# Check if Stripe CLI is installed
|
||||
if ! command -v stripe &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ Stripe CLI is not installed.${NC}"
|
||||
read -p "Would you like to install it now? (y/n) " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${BLUE}📦 Installing Stripe CLI...${NC}"
|
||||
|
||||
# Add GPG key
|
||||
echo -e "${BLUE}🔑 Adding Stripe GPG key...${NC}"
|
||||
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
|
||||
|
||||
# Add repository
|
||||
echo -e "${BLUE}📚 Adding Stripe repository...${NC}"
|
||||
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
|
||||
|
||||
# Update and install
|
||||
echo -e "${BLUE}🔄 Updating package list...${NC}"
|
||||
sudo apt update
|
||||
|
||||
echo -e "${BLUE}⬇️ Installing Stripe CLI...${NC}"
|
||||
sudo apt install stripe -y
|
||||
|
||||
echo -e "${GREEN}✅ Stripe CLI installed successfully!${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Installation cancelled. Exiting.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}✅ Stripe CLI is already installed.${NC}"
|
||||
fi
|
||||
|
||||
# Check if Stripe CLI is authenticated
|
||||
echo -e "${BLUE}🔐 Checking authentication status...${NC}"
|
||||
if ! stripe config --list &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ Stripe CLI is not authenticated.${NC}"
|
||||
echo -e "${BLUE}🔑 Please log in to your Stripe account...${NC}"
|
||||
stripe login --interactive
|
||||
else
|
||||
# Try to verify authentication by running a simple command
|
||||
if stripe config --list | grep -q "test_mode_api_key"; then
|
||||
echo -e "${GREEN}✅ Stripe CLI is authenticated.${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Stripe CLI authentication may be invalid.${NC}"
|
||||
echo -e "${BLUE}🔑 Please log in to your Stripe account...${NC}"
|
||||
stripe login --interactive
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start Docker containers
|
||||
echo ""
|
||||
echo -e "${BLUE}🐳 Starting Docker containers...${NC}"
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Cleanup function to run on exit
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}🛑 Shutting down...${NC}"
|
||||
echo -e "${BLUE}🐳 Stopping Docker containers...${NC}"
|
||||
docker compose -f docker-compose.local.yaml down
|
||||
echo -e "${GREEN}✨ Cleanup complete. Goodbye!${NC}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trap Ctrl+C (SIGINT) and termination signals
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# Start listening for webhooks
|
||||
WEBHOOK_URL="http://${SITE}:${PORT}/api/method/custom_ui.api.public.payments.stripe_webhook"
|
||||
echo ""
|
||||
echo -e "${GREEN}🎧 Starting Stripe webhook listener...${NC}"
|
||||
echo -e "${BLUE}📡 Forwarding to: ${YELLOW}$WEBHOOK_URL${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⌨️ Press Ctrl+C to stop${NC}"
|
||||
echo ""
|
||||
|
||||
stripe listen --forward-to "$WEBHOOK_URL"
|
||||
Loading…
x
Reference in New Issue
Block a user