Compare commits
40 commits
test-fixtu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0dced9ec | ||
| 8452f57787 | |||
| 8542a9bf37 | |||
| 786d4ecd39 | |||
| 9596798bf4 | |||
| c024e7fd86 | |||
| 6cd3d138ad | |||
|
|
0ec89a1269 | ||
| 15f4d42e07 | |||
| b400be3f1a | |||
| 124b8775fb | |||
| 5150d82405 | |||
| 28c57c4ad0 | |||
| ba3e2a4d8e | |||
|
|
5e192a61e1 | ||
|
|
5ed964b49d | ||
|
|
c682ee8ccb | ||
|
|
bbe679cb4c | ||
|
|
fbc51301f3 | ||
|
|
00f6d69482 | ||
|
|
37bd0f60a3 | ||
|
|
81f3489a24 | ||
|
|
91e4d47d48 | ||
|
|
2fb82917b4 | ||
|
|
e730de3887 | ||
|
|
f0acbd0630 | ||
|
|
08c31a2e3f | ||
|
|
44c15961c7 | ||
|
|
5b2e362100 | ||
|
|
f386edf769 | ||
|
|
c0b1f3f37a | ||
| e67805c01f | |||
| 7395d3e048 | |||
|
|
0620060066 | ||
|
|
2badb6835b | ||
| 7710a7c8fe | |||
|
|
98ec082394 | ||
|
|
1429f68b9e | ||
|
|
6ae6ae6812 | ||
|
|
ddf758f4b6 |
63 changed files with 22819 additions and 9194 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import frappe
|
import frappe
|
||||||
import json
|
import json
|
||||||
from custom_ui.db_utils import build_error_response, build_success_response
|
from custom_ui.db_utils import build_error_response, build_success_response
|
||||||
from custom_ui.services import ClientService, AddressService
|
from custom_ui.services import ClientService, AddressService, ContactService
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_address_by_full_address(full_address):
|
def get_address_by_full_address(full_address):
|
||||||
|
|
@ -35,6 +35,33 @@ def get_address(address_name):
|
||||||
# except Exception as e:
|
# except Exception as e:
|
||||||
# return build_error_response(str(e), 500)
|
# return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def create_address(address_data, company, customer_name):
|
||||||
|
"""Create a new address."""
|
||||||
|
print(f"DEBUG: create_address called with address_data: {address_data}, company: {company}, customer_name: {customer_name}")
|
||||||
|
if isinstance(address_data, str):
|
||||||
|
address_data = json.loads(address_data)
|
||||||
|
customer_doctype = ClientService.get_client_doctype(customer_name)
|
||||||
|
address_data["customer_name"] = customer_name
|
||||||
|
address_data["customer_type"] = customer_doctype
|
||||||
|
address_data["address_title"] = AddressService.build_address_title(customer_name, address_data)
|
||||||
|
address_data["address_type"] = "Service"
|
||||||
|
address_data["custom_billing_address"] = 0
|
||||||
|
address_data["is_service_address"] = 1
|
||||||
|
address_data["country"] = "United States"
|
||||||
|
address_data["companies"] = [{ "company": company }]
|
||||||
|
print(f"DEBUG: Final address_data before creation: {address_data}")
|
||||||
|
try:
|
||||||
|
address_doc = AddressService.create_address(address_data)
|
||||||
|
for contact in address_data.get("contacts", []):
|
||||||
|
AddressService.link_address_to_contact(address_doc, contact)
|
||||||
|
contact_doc = ContactService.get_or_throw(contact)
|
||||||
|
ContactService.link_contact_to_address(contact_doc, address_doc)
|
||||||
|
ClientService.append_link_v2(customer_name, "properties", {"address": address_doc.name})
|
||||||
|
return build_success_response(address_doc.as_dict())
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_addresses(fields=["*"], filters={}):
|
def get_addresses(fields=["*"], filters={}):
|
||||||
"""Get addresses with optional filtering."""
|
"""Get addresses with optional filtering."""
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,17 @@ from custom_ui.db_utils import build_error_response, build_success_response, pro
|
||||||
from custom_ui.services import DbService, ClientService, AddressService, ContactService
|
from custom_ui.services import DbService, ClientService, AddressService, ContactService
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_week_bid_meetings(week_start, week_end):
|
def get_week_bid_meetings(week_start, week_end, company):
|
||||||
"""Get On-Site Meetings scheduled within a specific week."""
|
"""Get On-Site Meetings scheduled within a specific week."""
|
||||||
try:
|
try:
|
||||||
meetings = frappe.db.get_all(
|
meetings = frappe.db.get_all(
|
||||||
"On-Site Meeting",
|
"On-Site Meeting",
|
||||||
fields=["*"],
|
fields=["*"],
|
||||||
filters=[
|
filters=[
|
||||||
|
["status", "!=", "Cancelled"],
|
||||||
["start_time", ">=", week_start],
|
["start_time", ">=", week_start],
|
||||||
["start_time", "<=", week_end]
|
["start_time", "<=", week_end],
|
||||||
|
["company", "=", company]
|
||||||
],
|
],
|
||||||
order_by="start_time asc"
|
order_by="start_time asc"
|
||||||
)
|
)
|
||||||
|
|
@ -25,9 +27,21 @@ def get_week_bid_meetings(week_start, week_end):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed")
|
frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed")
|
||||||
return build_error_response(str(e), 500)
|
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()
|
@frappe.whitelist()
|
||||||
def get_bid_meetings(fields=["*"], filters={}):
|
def get_bid_meetings(fields=["*"], filters={}, company=None):
|
||||||
"""Get paginated On-Site Meetings with filtering and sorting support."""
|
"""Get paginated On-Site Meetings with filtering and sorting support."""
|
||||||
try:
|
try:
|
||||||
print("DEBUG: Raw bid meeting options received:", filters)
|
print("DEBUG: Raw bid meeting options received:", filters)
|
||||||
|
|
@ -51,15 +65,26 @@ def get_bid_meetings(fields=["*"], filters={}):
|
||||||
frappe.log_error(message=str(e), title="Get On-Site Meetings Failed")
|
frappe.log_error(message=str(e), title="Get On-Site Meetings Failed")
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_unscheduled_bid_meetings():
|
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):
|
||||||
"""Get On-Site Meetings that are unscheduled."""
|
"""Get On-Site Meetings that are unscheduled."""
|
||||||
try:
|
try:
|
||||||
meetings = frappe.db.get_all(
|
meetings = frappe.db.get_all(
|
||||||
"On-Site Meeting",
|
"On-Site Meeting",
|
||||||
fields=["*"],
|
fields=["*"],
|
||||||
filters={"status": "Unscheduled"},
|
filters={"status": "Unscheduled", "company": company},
|
||||||
order_by="creation desc"
|
order_by="creation desc"
|
||||||
)
|
)
|
||||||
for meeting in meetings:
|
for meeting in meetings:
|
||||||
|
|
@ -74,6 +99,62 @@ def get_unscheduled_bid_meetings():
|
||||||
frappe.log_error(message=str(e), title="Get Unscheduled On-Site Meetings Failed")
|
frappe.log_error(message=str(e), title="Get Unscheduled On-Site Meetings Failed")
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def submit_bid_meeting_note_form(bid_meeting, project_template, fields, form_template):
|
||||||
|
"""Submit Bid Meeting Note Form data for a specific On-Site Meeting."""
|
||||||
|
if isinstance(fields, str):
|
||||||
|
fields = json.loads(fields)
|
||||||
|
try:
|
||||||
|
print(f"DEBUG: Submitting Bid Meeting Note Form for meeting='{bid_meeting}' from template='{form_template}' with fields='{fields}'")
|
||||||
|
|
||||||
|
meeting = DbService.get_or_throw("On-Site Meeting", bid_meeting)
|
||||||
|
|
||||||
|
# Update fields on the meeting
|
||||||
|
meeting_note_field_docs = [{
|
||||||
|
"label": field.get("label"),
|
||||||
|
"type": field.get("type"),
|
||||||
|
"value": json.dumps(field.get("value")) if isinstance(field.get("value"), (list, dict)) else field.get("value"),
|
||||||
|
"row": field.get("row"),
|
||||||
|
"column": field.get("column"),
|
||||||
|
"value_doctype": field.get("doctype_for_select"),
|
||||||
|
"available_options": field.get("options"),
|
||||||
|
"include_available_options": field.get("include_available_options", False),
|
||||||
|
"conditional_on_field": field.get("conditional_on_field"),
|
||||||
|
"conditional_on_value": field.get("conditional_on_value"),
|
||||||
|
"doctype_label_field": field.get("doctype_label_field")
|
||||||
|
} for field in fields]
|
||||||
|
new_bid_meeting_note_doc = frappe.get_doc({
|
||||||
|
"doctype": "Bid Meeting Note",
|
||||||
|
"bid_meeting": bid_meeting,
|
||||||
|
"project_template": project_template,
|
||||||
|
"form_template": form_template,
|
||||||
|
"fields": meeting_note_field_docs
|
||||||
|
})
|
||||||
|
new_bid_meeting_note_doc.insert(ignore_permissions=True)
|
||||||
|
for field_row, field in zip(new_bid_meeting_note_doc.fields, fields):
|
||||||
|
print(f"DEBUG: {field_row.label} - {field.get("label")}")
|
||||||
|
if not isinstance(field.get("value"), list):
|
||||||
|
continue
|
||||||
|
for item in field["value"]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
new_bid_meeting_note_doc.append("quantities", {
|
||||||
|
"meeting_note_field": field_row.name,
|
||||||
|
"item": item.get("item"),
|
||||||
|
"quantity": item.get("quantity")
|
||||||
|
})
|
||||||
|
new_bid_meeting_note_doc.save(ignore_permissions=True)
|
||||||
|
meeting.bid_notes = new_bid_meeting_note_doc.name
|
||||||
|
meeting.status = "Completed"
|
||||||
|
meeting.save()
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
return build_success_response(meeting.as_dict())
|
||||||
|
except frappe.DoesNotExistError:
|
||||||
|
return build_error_response(f"On-Site Meeting '{bid_meeting}' does not exist.", 404)
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(message=str(e), title="Submit Bid Meeting Note Form Failed")
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_bid_meeting(name):
|
def get_bid_meeting(name):
|
||||||
|
|
@ -89,6 +170,9 @@ def get_bid_meeting(name):
|
||||||
if meeting_dict.get("contact"):
|
if meeting_dict.get("contact"):
|
||||||
contact_doc = ContactService.get_or_throw(meeting_dict["contact"])
|
contact_doc = ContactService.get_or_throw(meeting_dict["contact"])
|
||||||
meeting_dict["contact"] = contact_doc.as_dict()
|
meeting_dict["contact"] = contact_doc.as_dict()
|
||||||
|
if meeting_dict.get("bid_notes"):
|
||||||
|
bid_meeting_note_doc = frappe.get_doc("Bid Meeting Note", meeting_dict["bid_notes"])
|
||||||
|
meeting_dict["bid_notes"] = bid_meeting_note_doc.as_dict()
|
||||||
|
|
||||||
return build_success_response(meeting_dict)
|
return build_success_response(meeting_dict)
|
||||||
except frappe.DoesNotExistError:
|
except frappe.DoesNotExistError:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import frappe, json
|
import frappe, json
|
||||||
from custom_ui.db_utils import build_error_response, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, map_lead_client, build_address_title
|
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 erpnext.crm.doctype.lead.lead import make_customer
|
from erpnext.crm.doctype.lead.lead import make_customer
|
||||||
from custom_ui.api.db.addresses import address_exists
|
from custom_ui.api.db.addresses import address_exists
|
||||||
from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links
|
from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links
|
||||||
|
|
@ -167,6 +167,81 @@ def get_client_v2(client_name):
|
||||||
return build_error_response(str(ve), 400)
|
return build_error_response(str(ve), 400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_clients_table_data_v2(filters={}, sortings=[], page=1, page_size=10):
|
||||||
|
"""Get paginated client table data with filtering and sorting support."""
|
||||||
|
try:
|
||||||
|
filters = json.loads(filters) if isinstance(filters, str) else filters
|
||||||
|
sortings = json.loads(sortings) if isinstance(sortings, str) else sortings
|
||||||
|
page = int(page)
|
||||||
|
page_size = int(page_size)
|
||||||
|
print("DEBUG: Raw client table query received:", {
|
||||||
|
"filters": filters,
|
||||||
|
"sortings": sortings,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size
|
||||||
|
})
|
||||||
|
where_clauses = []
|
||||||
|
values = []
|
||||||
|
if filters.get("company"):
|
||||||
|
where_clauses.append("c.company = %s")
|
||||||
|
values.append(filters["company"]["value"])
|
||||||
|
|
||||||
|
if filters.get("address"):
|
||||||
|
where_clauses.append("a.full_address LIKE %s")
|
||||||
|
values.append(f"%{filters['address']['value']}%")
|
||||||
|
|
||||||
|
if filters.get("customer_name"):
|
||||||
|
where_clauses.append("a.customer_name LIKE %s")
|
||||||
|
values.append(f"%{filters['customer_name']['value']}%")
|
||||||
|
|
||||||
|
where_sql = ""
|
||||||
|
if where_clauses:
|
||||||
|
where_sql = "WHERE " + " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
address_names = frappe.db.sql(f"""
|
||||||
|
SELECT DISTINCT a.name
|
||||||
|
FROM `tabAddress` a
|
||||||
|
LEFT JOIN `tabAddress Company Link` c ON c.parent = a.name
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY a.modified DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", values + [page_size, offset], as_dict=True)
|
||||||
|
print("DEBUG: Address names retrieved:", address_names)
|
||||||
|
|
||||||
|
count = frappe.db.sql(f"""
|
||||||
|
SELECT COUNT(DISTINCT a.name) as count
|
||||||
|
FROM `tabAddress` a
|
||||||
|
LEFT JOIN `tabAddress Company Link` c ON c.parent = a.name
|
||||||
|
{where_sql}
|
||||||
|
""", values, as_dict=True)[0]["count"]
|
||||||
|
tableRows = []
|
||||||
|
for address_name in address_names:
|
||||||
|
address = AddressService.get_or_throw(address_name["name"])
|
||||||
|
tableRow = {}
|
||||||
|
tableRow["id"] = address.name
|
||||||
|
tableRow["address"] = address.full_address
|
||||||
|
tableRow["client_type"] = address.customer_type
|
||||||
|
tableRow["customer_name"] = normalize_name(address.customer_name, "-#-")
|
||||||
|
tableRow["companies"] = ", ".join([link.company for link in address.get("companies", [])])
|
||||||
|
tableRows.append(tableRow)
|
||||||
|
|
||||||
|
table_data = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
return build_success_response(table_data)
|
||||||
|
except frappe.ValidationError as ve:
|
||||||
|
return build_error_response(str(ve), 400)
|
||||||
|
except Exception as e:
|
||||||
|
print("ERROR in get_clients_table_data_v2:", str(e))
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|
@ -370,12 +445,16 @@ def upsert_client(data):
|
||||||
address_docs = []
|
address_docs = []
|
||||||
for address in addresses:
|
for address in addresses:
|
||||||
is_billing = True if address.get("is_billing_address") else False
|
is_billing = True if address.get("is_billing_address") else False
|
||||||
|
is_service = True if address.get("is_service_address") else False
|
||||||
print("#####DEBUG: Creating address with data:", address)
|
print("#####DEBUG: Creating address with data:", address)
|
||||||
address_doc = AddressService.create_address({
|
address_doc = AddressService.create_address({
|
||||||
"address_title": AddressService.build_address_title(customer_name, address),
|
"address_title": AddressService.build_address_title(customer_name, address),
|
||||||
"address_line1": address.get("address_line1"),
|
"address_line1": address.get("address_line1"),
|
||||||
"address_line2": address.get("address_line2"),
|
"address_line2": address.get("address_line2"),
|
||||||
"address_type": "Billing" if is_billing else "Service",
|
"address_type": "Billing" if is_billing else "Service",
|
||||||
|
"custom_billing_address": is_billing,
|
||||||
|
"is_service_address": is_service,
|
||||||
|
"is_primary_address": is_billing,
|
||||||
"city": address.get("city"),
|
"city": address.get("city"),
|
||||||
"state": address.get("state"),
|
"state": address.get("state"),
|
||||||
"country": "United States",
|
"country": "United States",
|
||||||
|
|
@ -405,13 +484,12 @@ def upsert_client(data):
|
||||||
"address": address_doc.name
|
"address": address_doc.name
|
||||||
})
|
})
|
||||||
client_doc.save(ignore_permissions=True)
|
client_doc.save(ignore_permissions=True)
|
||||||
|
client_dict = client_doc.as_dict()
|
||||||
|
client_dict["contacts"] = [contact.as_dict() for contact in contact_docs]
|
||||||
|
client_dict["addresses"] = [address.as_dict() for address in address_docs]
|
||||||
|
|
||||||
frappe.local.message_log = []
|
frappe.local.message_log = []
|
||||||
return build_success_response({
|
return build_success_response(client_dict)
|
||||||
"customer": client_doc.as_dict(),
|
|
||||||
"address": [address_doc.as_dict() for address_doc in address_docs],
|
|
||||||
"contacts": [contact_doc.as_dict() for contact_doc in contact_docs]
|
|
||||||
})
|
|
||||||
except frappe.ValidationError as ve:
|
except frappe.ValidationError as ve:
|
||||||
return build_error_response(str(ve), 400)
|
return build_error_response(str(ve), 400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
49
custom_ui/api/db/employees.py
Normal file
49
custom_ui/api/db/employees.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
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,7 +1,7 @@
|
||||||
import frappe, json
|
import frappe, json
|
||||||
from frappe.utils.pdf import get_pdf
|
from frappe.utils.pdf import get_pdf
|
||||||
from custom_ui.api.db.general import get_doc_history
|
from custom_ui.api.db.general import get_doc_history
|
||||||
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
from custom_ui.db_utils import DbUtils, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
|
from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
|
||||||
from custom_ui.services import DbService, ClientService, AddressService, ContactService
|
from custom_ui.services import DbService, ClientService, AddressService, ContactService
|
||||||
|
|
@ -10,6 +10,38 @@ from custom_ui.services import DbService, ClientService, AddressService, Contact
|
||||||
# ESTIMATES & INVOICES API METHODS
|
# ESTIMATES & INVOICES API METHODS
|
||||||
# ===============================================================================
|
# ===============================================================================
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_estimate_table_data_v2(filters={}, sortings=[], page=1, page_size=10):
|
||||||
|
"""Get paginated estimate table data with filtering and sorting."""
|
||||||
|
print("DEBUG: Raw estimate options received:", filters, sortings, page, page_size)
|
||||||
|
filters, sortings, page, page_size = DbUtils.process_datatable_request(filters, sortings, page, page_size)
|
||||||
|
sortings = "modified desc" if not sortings else sortings
|
||||||
|
count = frappe.db.count("Quotation", filters=filters)
|
||||||
|
print(f"DEBUG: Number of estimates returned: {count}")
|
||||||
|
estimate_names = frappe.db.get_all(
|
||||||
|
"Quotation",
|
||||||
|
filters=filters,
|
||||||
|
pluck="name",
|
||||||
|
limit=page_size,
|
||||||
|
start=(page) * page_size,
|
||||||
|
order_by=sortings
|
||||||
|
)
|
||||||
|
|
||||||
|
estimates = [frappe.get_doc("Quotation", name).as_dict() for name in estimate_names]
|
||||||
|
tableRows = []
|
||||||
|
for estimate in estimates:
|
||||||
|
tableRow = {
|
||||||
|
"id": estimate["name"],
|
||||||
|
"address": frappe.db.get_value("Address", estimate.get("custom_job_address"), "full_address"),
|
||||||
|
# strip "-#-" from actual_customer_name and anything that comes after it
|
||||||
|
"customer": estimate.get("actual_customer_name").split("-#-")[0] if estimate.get("actual_customer_name") else estimate.get("customer_name") if estimate.get("customer_name") else "",
|
||||||
|
"status": estimate.get("custom_current_status", ""),
|
||||||
|
"order_type": estimate.get("order_type", ""),
|
||||||
|
}
|
||||||
|
tableRows.append(tableRow)
|
||||||
|
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
||||||
|
return build_success_response(table_data_dict)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
|
def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||||
|
|
@ -145,7 +177,7 @@ def send_estimate_email(estimate_name):
|
||||||
print("DEBUG: Sending estimate email for:", estimate_name)
|
print("DEBUG: Sending estimate email for:", estimate_name)
|
||||||
quotation = frappe.get_doc("Quotation", estimate_name)
|
quotation = frappe.get_doc("Quotation", estimate_name)
|
||||||
|
|
||||||
|
|
||||||
if not DbService.exists("Contact", quotation.contact_person):
|
if not DbService.exists("Contact", quotation.contact_person):
|
||||||
return build_error_response("No email found for the customer.", 400)
|
return build_error_response("No email found for the customer.", 400)
|
||||||
party = ContactService.get_or_throw(quotation.contact_person)
|
party = ContactService.get_or_throw(quotation.contact_person)
|
||||||
|
|
@ -398,7 +430,7 @@ def upsert_estimate(data):
|
||||||
# estimate.custom_job_address = data.get("address_name")
|
# estimate.custom_job_address = data.get("address_name")
|
||||||
# estimate.party_name = data.get("customer")
|
# estimate.party_name = data.get("customer")
|
||||||
# estimate.contact_person = data.get("contact_name")
|
# estimate.contact_person = data.get("contact_name")
|
||||||
estimate.custom_requires_half_payment = data.get("requires_half_payment", 0)
|
estimate.requires_half_payment = data.get("requires_half_payment", 0)
|
||||||
estimate.custom_project_template = project_template
|
estimate.custom_project_template = project_template
|
||||||
estimate.custom_quotation_template = data.get("quotation_template", None)
|
estimate.custom_quotation_template = data.get("quotation_template", None)
|
||||||
# estimate.company = data.get("company")
|
# estimate.company = data.get("company")
|
||||||
|
|
@ -408,7 +440,7 @@ def upsert_estimate(data):
|
||||||
# estimate.customer_address = data.get("address_name")
|
# estimate.customer_address = data.get("address_name")
|
||||||
# estimate.letter_head = data.get("company")
|
# estimate.letter_head = data.get("company")
|
||||||
# estimate.from_onsite_meeting = data.get("onsite_meeting", None)
|
# estimate.from_onsite_meeting = data.get("onsite_meeting", None)
|
||||||
|
|
||||||
# Clear existing items and add new ones
|
# Clear existing items and add new ones
|
||||||
estimate.items = []
|
estimate.items = []
|
||||||
for item in data.get("items", []):
|
for item in data.get("items", []):
|
||||||
|
|
@ -421,6 +453,7 @@ def upsert_estimate(data):
|
||||||
})
|
})
|
||||||
|
|
||||||
estimate.save()
|
estimate.save()
|
||||||
|
frappe.db.commit()
|
||||||
estimate_dict = estimate.as_dict()
|
estimate_dict = estimate.as_dict()
|
||||||
estimate_dict["history"] = get_doc_history("Quotation", estimate_name)
|
estimate_dict["history"] = get_doc_history("Quotation", estimate_name)
|
||||||
print(f"DEBUG: Estimate updated: {estimate.name}")
|
print(f"DEBUG: Estimate updated: {estimate.name}")
|
||||||
|
|
@ -438,7 +471,7 @@ def upsert_estimate(data):
|
||||||
# print("DEBUG: No billing address found for client:", client_doc.name)
|
# print("DEBUG: No billing address found for client:", client_doc.name)
|
||||||
new_estimate = frappe.get_doc({
|
new_estimate = frappe.get_doc({
|
||||||
"doctype": "Quotation",
|
"doctype": "Quotation",
|
||||||
"custom_requires_half_payment": data.get("requires_half_payment", 0),
|
"requires_half_payment": data.get("requires_half_payment", 0),
|
||||||
"custom_job_address": data.get("address_name"),
|
"custom_job_address": data.get("address_name"),
|
||||||
"custom_current_status": "Draft",
|
"custom_current_status": "Draft",
|
||||||
"contact_email": data.get("contact_email"),
|
"contact_email": data.get("contact_email"),
|
||||||
|
|
@ -452,7 +485,7 @@ def upsert_estimate(data):
|
||||||
"letter_head": data.get("company"),
|
"letter_head": data.get("company"),
|
||||||
"custom_project_template": data.get("project_template", None),
|
"custom_project_template": data.get("project_template", None),
|
||||||
"custom_quotation_template": data.get("quotation_template", None),
|
"custom_quotation_template": data.get("quotation_template", None),
|
||||||
"from_onsite_meeting": data.get("onsite_meeting", None)
|
"from_onsite_meeting": data.get("from_onsite_meeting", None)
|
||||||
})
|
})
|
||||||
for item in data.get("items", []):
|
for item in data.get("items", []):
|
||||||
item = json.loads(item) if isinstance(item, str) else item
|
item = json.loads(item) if isinstance(item, str) else item
|
||||||
|
|
@ -474,6 +507,31 @@ def upsert_estimate(data):
|
||||||
print(f"DEBUG: Error in upsert_estimate: {str(e)}")
|
print(f"DEBUG: Error in upsert_estimate: {str(e)}")
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_unapproved_estimates_count(company):
|
||||||
|
"""Get the number of unapproved estimates."""
|
||||||
|
try:
|
||||||
|
draft_filters = {'status': "Draft", "company": company}
|
||||||
|
submitted_filters = {'status': "Submitted", "company": company}
|
||||||
|
draft_count = frappe.db.count("Quotation", filters=draft_filters)
|
||||||
|
submitted_count = frappe.db.count("Quotation", filters=submitted_filters)
|
||||||
|
return build_success_response([draft_count, submitted_count])
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_estimates_half_down_count(company):
|
||||||
|
"""Get the number unpaid half-down estimates."""
|
||||||
|
try:
|
||||||
|
filters = {'requires_half_payment': True, 'company': company}
|
||||||
|
count = frappe.db.count("Quotation", filters=filters)
|
||||||
|
return build_success_response([count])
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
def get_estimate_history(estimate_name):
|
def get_estimate_history(estimate_name):
|
||||||
"""Get the history of changes for a specific estimate."""
|
"""Get the history of changes for a specific estimate."""
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import frappe
|
import frappe
|
||||||
from custom_ui.db_utils import build_history_entries
|
from custom_ui.db_utils import build_history_entries, build_success_response, build_error_response
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import json
|
||||||
|
|
||||||
def get_doc_history(doctype, docname):
|
def get_doc_history(doctype, docname):
|
||||||
"""Get the history of changes for a specific document."""
|
"""Get the history of changes for a specific document."""
|
||||||
|
|
@ -56,4 +58,43 @@ def search_any_field(doctype, text):
|
||||||
query,
|
query,
|
||||||
[like] * len(conditions),
|
[like] * len(conditions),
|
||||||
as_dict=True
|
as_dict=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_week_holidays(week_start_date: str):
|
||||||
|
"""Get holidays within a week starting from the given date."""
|
||||||
|
|
||||||
|
start_date = datetime.strptime(week_start_date, "%Y-%m-%d").date()
|
||||||
|
end_date = start_date + timedelta(days=6)
|
||||||
|
|
||||||
|
holidays = frappe.get_all(
|
||||||
|
"Holiday",
|
||||||
|
filters={
|
||||||
|
"holiday_date": ["between", (start_date, end_date)]
|
||||||
|
},
|
||||||
|
fields=["holiday_date", "description"],
|
||||||
|
order_by="holiday_date asc"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"DEBUG: Retrieved holidays from {start_date} to {end_date}: {holidays}")
|
||||||
|
return build_success_response(holidays)
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_doc_list(doctype, fields=["*"], filters={}, pluck=None):
|
||||||
|
"""Get list of documents for a given doctype with specified fields and filters."""
|
||||||
|
if isinstance(fields, str):
|
||||||
|
fields = json.loads(fields)
|
||||||
|
if isinstance(filters, str):
|
||||||
|
filters = json.loads(filters)
|
||||||
|
try:
|
||||||
|
docs = frappe.get_all(
|
||||||
|
doctype,
|
||||||
|
fields=fields,
|
||||||
|
filters=filters,
|
||||||
|
order_by="creation desc",
|
||||||
|
# pluck=pluck
|
||||||
|
)
|
||||||
|
print(f"DEBUG: Retrieved documents for {doctype} with filters {filters}: {docs}")
|
||||||
|
return build_success_response(docs)
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
@ -1,11 +1,36 @@
|
||||||
import frappe, json
|
import frappe, json
|
||||||
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
||||||
|
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||||
|
|
||||||
# ===============================================================================
|
# ===============================================================================
|
||||||
# INVOICES API METHODS
|
# INVOICES API METHODS
|
||||||
# ===============================================================================
|
# ===============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def create_invoice_for_job(job_name):
|
||||||
|
"""Create the invoice from a sales order of a job."""
|
||||||
|
try:
|
||||||
|
project = frappe.get_doc("Project", job_name)
|
||||||
|
sales_order = project.sales_order
|
||||||
|
invoice = make_sales_invoice(sales_order)
|
||||||
|
invoice.save()
|
||||||
|
return build_success_response(invoice.as_dict())
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_invoices_late_count():
|
||||||
|
"""Return Due, 30-day late, 90-day late, and Lien-worthy late accounts."""
|
||||||
|
try:
|
||||||
|
dummy_result = [10, 4, 5, 1]
|
||||||
|
print("DEBUG: DUMMY RESULT:", dummy_result)
|
||||||
|
return build_success_response(dummy_result)
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10):
|
def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||||
"""Get paginated invoice table data with filtering and sorting support."""
|
"""Get paginated invoice table data with filtering and sorting support."""
|
||||||
|
|
@ -18,7 +43,7 @@ def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||||
else:
|
else:
|
||||||
count = frappe.db.count("Sales Invoice", filters=processed_filters)
|
count = frappe.db.count("Sales Invoice", filters=processed_filters)
|
||||||
|
|
||||||
print(f"DEBUG: Number of invoice returned: {count}")
|
print(f"DEBUG: Number of invoices returned: {count}")
|
||||||
|
|
||||||
invoices = frappe.db.get_all(
|
invoices = frappe.db.get_all(
|
||||||
"Sales Invoice",
|
"Sales Invoice",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,70 @@
|
||||||
import frappe, json
|
import frappe, json
|
||||||
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
||||||
from custom_ui.services import AddressService, ClientService
|
from custom_ui.services import AddressService, ClientService, ServiceAppointmentService
|
||||||
|
from frappe.utils import getdate
|
||||||
|
|
||||||
# ===============================================================================
|
# ===============================================================================
|
||||||
# JOB MANAGEMENT API METHODS
|
# JOB MANAGEMENT API METHODS
|
||||||
# ===============================================================================
|
# ===============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_jobs_in_queue_count(company):
|
||||||
|
try:
|
||||||
|
filters = {
|
||||||
|
'company': company,
|
||||||
|
'is_scheduled': True,
|
||||||
|
}
|
||||||
|
count = frappe.db.count("Project", filters=filters)
|
||||||
|
return build_success_response([count])
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_jobs_in_progress_count(company):
|
||||||
|
try:
|
||||||
|
today = getdate()
|
||||||
|
filters = {
|
||||||
|
'company': company,
|
||||||
|
'invoice_status': 'Not Ready',
|
||||||
|
'expected_start_date': ['<=', today],
|
||||||
|
'expected_end_date': ['>=', today],
|
||||||
|
}
|
||||||
|
count = frappe.db.count("Project", filters=filters)
|
||||||
|
return build_success_response([count])
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_jobs_late_count(company):
|
||||||
|
try:
|
||||||
|
today = getdate()
|
||||||
|
filters = {
|
||||||
|
'company': company,
|
||||||
|
'invoice_status': 'Not Ready',
|
||||||
|
'expected_end_date': ['<', today]
|
||||||
|
}
|
||||||
|
count = frappe.db.count("Project", filters=filters)
|
||||||
|
return build_success_response([count])
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_jobs_to_invoice_count(company):
|
||||||
|
try:
|
||||||
|
filters = {
|
||||||
|
'company': company,
|
||||||
|
'invoice_status': 'Ready to Invoice',
|
||||||
|
}
|
||||||
|
count = frappe.db.count("Project", filters=filters)
|
||||||
|
return build_success_response([count])
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_job_templates(company=None):
|
def get_job_templates(company=None):
|
||||||
"""Get list of job (project) templates."""
|
"""Get list of job (project) templates."""
|
||||||
|
|
@ -18,13 +77,14 @@ def get_job_templates(company=None):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_job_from_sales_order(sales_order_name):
|
def create_job_from_sales_order(sales_order_name):
|
||||||
"""Create a Job (Project) from a given Sales Order"""
|
"""Create a Job (Project) from a given Sales Order"""
|
||||||
try:
|
try:
|
||||||
sales_order = frappe.get_doc("Sales Order", sales_order_name)
|
sales_order = frappe.get_doc("Sales Order", sales_order_name)
|
||||||
project_template = frappe.get_doc("Project Template", "SNW Install")
|
project_template = frappe.get_doc("Project Template", "SNW Install")
|
||||||
new_job = frappe.get_doc({
|
new_project = frappe.get_doc({
|
||||||
"doctype": "Project",
|
"doctype": "Project",
|
||||||
"custom_address": sales_order.custom_job_address,
|
"custom_address": sales_order.custom_job_address,
|
||||||
# "custom_installation_address": sales_order.custom_installation_address,
|
# "custom_installation_address": sales_order.custom_installation_address,
|
||||||
|
|
@ -34,8 +94,22 @@ def create_job_from_sales_order(sales_order_name):
|
||||||
"sales_order": sales_order,
|
"sales_order": sales_order,
|
||||||
"custom_company": sales_order.company
|
"custom_company": sales_order.company
|
||||||
})
|
})
|
||||||
new_job.insert()
|
new_project.insert()
|
||||||
return build_success_response(new_job.as_dict())
|
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())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
@ -50,6 +124,8 @@ def get_job(job_id=""):
|
||||||
project = project.as_dict()
|
project = project.as_dict()
|
||||||
project["job_address"] = address_doc
|
project["job_address"] = address_doc
|
||||||
project["client"] = ClientService.get_client_or_throw(project.customer)
|
project["client"] = ClientService.get_client_or_throw(project.customer)
|
||||||
|
task_names = frappe.get_all("Task", filters={"project": job_id})
|
||||||
|
project["tasks"] = [frappe.get_doc("Task", task_name).as_dict() for task_name in task_names]
|
||||||
return build_success_response(project)
|
return build_success_response(project)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
@ -80,12 +156,15 @@ def get_job_task_table_data(filters={}, sortings={}, page=1, page_size=10):
|
||||||
order_by=processed_sortings
|
order_by=processed_sortings
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
tableRows = []
|
tableRows = []
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
|
address_name = frappe.get_value("Project", task.project, "job_address")
|
||||||
|
full_address = frappe.get_value("Address", address_name, "full_address")
|
||||||
tableRow = {}
|
tableRow = {}
|
||||||
tableRow["id"] = task["name"]
|
tableRow["id"] = task["name"]
|
||||||
tableRow["subject"] = task["subject"]
|
tableRow["subject"] = task["subject"]
|
||||||
tableRow["address"] = task.get("custom_property", "")
|
tableRow["address"] = full_address
|
||||||
tableRow["status"] = task.get("status", "")
|
tableRow["status"] = task.get("status", "")
|
||||||
tableRows.append(tableRow)
|
tableRows.append(tableRow)
|
||||||
|
|
||||||
|
|
@ -132,9 +211,10 @@ def get_jobs_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||||
tableRow = {}
|
tableRow = {}
|
||||||
tableRow["id"] = project["name"]
|
tableRow["id"] = project["name"]
|
||||||
tableRow["name"] = project["name"]
|
tableRow["name"] = project["name"]
|
||||||
tableRow["installation_address"] = project.get("custom_installation_address", "")
|
tableRow["job_address"] = project["job_address"]
|
||||||
tableRow["customer"] = project.get("customer", "")
|
tableRow["customer"] = project.get("customer", "")
|
||||||
tableRow["status"] = project.get("status", "")
|
tableRow["status"] = project.get("status", "")
|
||||||
|
tableRow["invoice_status"] = project.get("invoice_status")
|
||||||
tableRow["percent_complete"] = project.get("percent_complete", 0)
|
tableRow["percent_complete"] = project.get("percent_complete", 0)
|
||||||
tableRows.append(tableRow)
|
tableRows.append(tableRow)
|
||||||
|
|
||||||
|
|
@ -167,55 +247,56 @@ def upsert_job(data):
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_install_projects(start_date=None, end_date=None):
|
def get_projects_for_calendar(start_date, end_date, company=None, project_templates=[]):
|
||||||
"""Get install projects for the calendar."""
|
"""Get install projects for the calendar."""
|
||||||
|
# Parse project_templates if it's a JSON string
|
||||||
|
if isinstance(project_templates, str):
|
||||||
|
project_templates = json.loads(project_templates)
|
||||||
|
|
||||||
|
# put some emojis in the print to make it stand out
|
||||||
|
print("📅📅📅", start_date, end_date, " company:", company, "project_templates:", project_templates, "type:", type(project_templates))
|
||||||
try:
|
try:
|
||||||
filters = {"project_template": "SNW Install"}
|
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)]
|
||||||
# If date range provided, we could filter, but for now let's fetch all open/active ones
|
# If date range provided, we could filter, but for now let's fetch all open/active ones
|
||||||
# or maybe filter by status not Closed/Completed if we want active ones.
|
# or maybe filter by status not Closed/Completed if we want active ones.
|
||||||
# The user said "unscheduled" are those with status "Open" (and no date).
|
# The user said "unscheduled" are those with status "Open" (and no date).
|
||||||
|
# extend filters into unscheduled_filters
|
||||||
|
|
||||||
projects = frappe.get_all("Project", fields=["*"], filters=filters)
|
project_names = frappe.get_all("Project", pluck="name", filters=filters)
|
||||||
|
print("DEBUG: Found scheduled project names:", project_names)
|
||||||
calendar_events = []
|
unscheduled_project_names = frappe.get_all("Project", pluck="name", filters=unscheduled_filters)
|
||||||
for project in projects:
|
print("DEBUG: Found unscheduled project names:", unscheduled_project_names)
|
||||||
# Determine status
|
projects = [frappe.get_doc("Project", name).as_dict() for name in project_names]
|
||||||
status = "unscheduled"
|
unscheduled_projects = [frappe.get_doc("Project", name).as_dict() for name in unscheduled_project_names]
|
||||||
if project.get("expected_start_date"):
|
return build_success_response({ "projects": projects, "unscheduled_projects": unscheduled_projects })
|
||||||
status = "scheduled"
|
|
||||||
|
|
||||||
# Map to calendar event format
|
|
||||||
event = {
|
|
||||||
"id": project.name,
|
|
||||||
"serviceType": project.project_name, # Using project name as service type/title
|
|
||||||
"customer": project.customer,
|
|
||||||
"status": status,
|
|
||||||
"scheduledDate": project.expected_start_date,
|
|
||||||
"scheduledTime": "08:00", # Default time if not specified? Project doesn't seem to have time.
|
|
||||||
"duration": 480, # Default 8 hours?
|
|
||||||
"foreman": project.get("custom_install_crew"),
|
|
||||||
"crew": [], # Need to map crew
|
|
||||||
"estimatedCost": project.estimated_costing,
|
|
||||||
"priority": project.priority.lower() if project.priority else "medium",
|
|
||||||
"notes": project.notes,
|
|
||||||
"address": project.custom_installation_address
|
|
||||||
}
|
|
||||||
|
|
||||||
calendar_events.append(event)
|
|
||||||
|
|
||||||
return {"status": "success", "data": calendar_events}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e)}
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_project_templates_for_company(company_name):
|
def update_job_scheduled_dates(job_name: str, new_start_date: str = None, new_end_date: str = None, foreman_name: str = None):
|
||||||
"""Get project templates for a specific company."""
|
"""Update job (project) schedule dates."""
|
||||||
|
print("DEBUG: Updating job schedule:", job_name, new_start_date, new_end_date, foreman_name)
|
||||||
try:
|
try:
|
||||||
templates = frappe.get_all(
|
project = frappe.get_doc("Project", job_name)
|
||||||
"Project Template",
|
project.expected_start_date = getdate(new_start_date) if new_start_date else None
|
||||||
fields=["*"],
|
project.expected_end_date = getdate(new_end_date) if new_end_date else None
|
||||||
filters={"company": company_name}
|
if new_start_date and new_end_date:
|
||||||
)
|
project.is_scheduled = 1
|
||||||
return build_success_response(templates)
|
else:
|
||||||
|
project.is_scheduled = 0
|
||||||
|
if foreman_name:
|
||||||
|
project.custom_foreman = foreman_name
|
||||||
|
project.save()
|
||||||
|
return build_success_response(project.as_dict())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return build_error_response(str(e), 500),
|
return build_error_response(str(e), 500)
|
||||||
|
|
|
||||||
15
custom_ui/api/db/on_site_meetings.py
Normal file
15
custom_ui/api/db/on_site_meetings.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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)
|
||||||
|
|
||||||
69
custom_ui/api/db/service_appointments.py
Normal file
69
custom_ui/api/db/service_appointments.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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,4 +1,5 @@
|
||||||
import frappe
|
import frappe
|
||||||
|
import datetime
|
||||||
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
||||||
from custom_ui.services import DbService
|
from custom_ui.services import DbService
|
||||||
|
|
||||||
|
|
@ -12,7 +13,7 @@ def set_task_status(task_name, new_status):
|
||||||
return build_success_response(f"Task {task_name} status updated to {new_status}.")
|
return build_success_response(f"Task {task_name} status updated to {new_status}.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_job_task_list(job_id=""):
|
def get_job_task_list(job_id=""):
|
||||||
|
|
@ -42,6 +43,42 @@ def get_task_status_options():
|
||||||
return build_error_response(str(e), 500)
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_tasks_due(subject_filter, current_company):
|
||||||
|
"""Return the number of items due today of the type of subject_filter"""
|
||||||
|
try:
|
||||||
|
today = datetime.date.today()
|
||||||
|
due_filters = {
|
||||||
|
'subject': ['like', f'%{subject_filter}%'],
|
||||||
|
'status': ['not in', ["Template", "Completed", "Cancelled"]],
|
||||||
|
'company': current_company,
|
||||||
|
'exp_end_date': today,
|
||||||
|
# Add due date filter here
|
||||||
|
}
|
||||||
|
completed_filters = {
|
||||||
|
'subject': ['like', f'%{subject_filter}%'],
|
||||||
|
'status': ['not in', ["Template", "Cancelled"]],
|
||||||
|
'company': current_company,
|
||||||
|
'exp_end_date': today,
|
||||||
|
# Add due date filter here
|
||||||
|
}
|
||||||
|
overdue_filters = {
|
||||||
|
'subject': ['like', f'%{subject_filter}%'],
|
||||||
|
'status': ['not in', ["Template", "Completed", "Cancelled"]],
|
||||||
|
'company': current_company,
|
||||||
|
'exp_end_date': ["<", today]
|
||||||
|
# Add overdue date filtering here
|
||||||
|
}
|
||||||
|
due_count = frappe.db.count("Task", filters=due_filters)
|
||||||
|
completed_count = frappe.db.count("Task", filters=completed_filters)
|
||||||
|
overdue_count = frappe.db.count("Task", filters=overdue_filters)
|
||||||
|
return build_success_response([due_count, completed_count, overdue_count])
|
||||||
|
except frappe.ValidationError as ve:
|
||||||
|
return build_error_response(str(ve), 400)
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_tasks_table_data(filters={}, sortings=[], page=1, page_size=10):
|
def get_tasks_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||||
"""Get paginated task table data with filtering and sorting support."""
|
"""Get paginated task table data with filtering and sorting support."""
|
||||||
|
|
@ -61,10 +98,12 @@ def get_tasks_table_data(filters={}, sortings=[], page=1, page_size=10):
|
||||||
fields=["*"],
|
fields=["*"],
|
||||||
filters=processed_filters,
|
filters=processed_filters,
|
||||||
limit=page_size,
|
limit=page_size,
|
||||||
start=page * page_size,
|
start=(page-1) * page_size,
|
||||||
order_by=processed_sortings
|
order_by=processed_sortings
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print("TASKS?", tasks, page, page_size)
|
||||||
|
|
||||||
tableRows = []
|
tableRows = []
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
tableRow = {}
|
tableRow = {}
|
||||||
|
|
|
||||||
6
custom_ui/api/payments.py
Normal file
6
custom_ui/api/payments.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
def start_payment(invoice_name: str):
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
@ -229,3 +229,20 @@ def build_history_entries(comments, versions):
|
||||||
# Sort by timestamp descending
|
# Sort by timestamp descending
|
||||||
history.sort(key=lambda x: x["timestamp"], reverse=True)
|
history.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||||
return history
|
return history
|
||||||
|
|
||||||
|
def normalize_name(name: str, split_target: str = "_") -> str:
|
||||||
|
"""Normalize a name by splitting off anything after and including the split_target."""
|
||||||
|
return name.split(split_target)[0] if split_target in name else name
|
||||||
|
|
||||||
|
class DbUtils:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_datatable_request(filters, sortings, page, page_size):
|
||||||
|
# turn filters and sortings from json strings to dicts/lists
|
||||||
|
if isinstance(filters, str):
|
||||||
|
filters = json.loads(filters)
|
||||||
|
if isinstance(sortings, str):
|
||||||
|
sortings = json.loads(sortings)
|
||||||
|
page = int(page)
|
||||||
|
page_size = int(page_size)
|
||||||
|
return filters, sortings,page, page_size
|
||||||
|
|
@ -58,7 +58,7 @@ def on_update_after_submit(doc, method):
|
||||||
print("DEBUG: Quotation marked as Won, updating current status.")
|
print("DEBUG: Quotation marked as Won, updating current status.")
|
||||||
if doc.customer_type == "Lead":
|
if doc.customer_type == "Lead":
|
||||||
print("DEBUG: Customer is a Lead, converting to Customer and updating Quotation.")
|
print("DEBUG: Customer is a Lead, converting to Customer and updating Quotation.")
|
||||||
new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name, update_quotations=False)
|
new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name)
|
||||||
doc.actual_customer_name = new_customer.name
|
doc.actual_customer_name = new_customer.name
|
||||||
doc.customer_type = "Customer"
|
doc.customer_type = "Customer"
|
||||||
new_customer.reload()
|
new_customer.reload()
|
||||||
|
|
@ -83,5 +83,5 @@ def on_update_after_submit(doc, method):
|
||||||
print("DEBUG: Submitting Sales Order")
|
print("DEBUG: Submitting Sales Order")
|
||||||
# new_sales_order.customer_address = backup
|
# new_sales_order.customer_address = backup
|
||||||
new_sales_order.submit()
|
new_sales_order.submit()
|
||||||
frappe.db.commit()
|
# frappe.db.commit()
|
||||||
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
|
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
|
||||||
|
|
|
||||||
6
custom_ui/events/general.py
Normal file
6
custom_ui/events/general.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
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,6 +1,7 @@
|
||||||
import frappe
|
import frappe
|
||||||
from custom_ui.services import AddressService, ClientService
|
from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, TaskService
|
||||||
|
from datetime import timedelta
|
||||||
|
import traceback
|
||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
print("DEBUG: After Insert Triggered for Project")
|
print("DEBUG: After Insert Triggered for Project")
|
||||||
|
|
@ -15,14 +16,81 @@ def after_insert(doc, method):
|
||||||
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
|
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
|
||||||
)
|
)
|
||||||
if doc.project_template == "SNW Install":
|
if doc.project_template == "SNW Install":
|
||||||
print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
|
print("DEBUG: Project template is SNW Install, creating Service Appointment")
|
||||||
AddressService.update_value(
|
AddressService.update_value(
|
||||||
doc.job_address,
|
doc.job_address,
|
||||||
"job_status",
|
"job_status",
|
||||||
"In Progress"
|
"In Progress"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
service_apt = ServiceAppointmentService.create({
|
||||||
|
"project": doc.name,
|
||||||
|
"customer": doc.customer,
|
||||||
|
"service_address": doc.job_address,
|
||||||
|
"company": doc.company,
|
||||||
|
"project_template": doc.project_template
|
||||||
|
})
|
||||||
|
doc.service_appointment = service_apt.name
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
print("DEBUG: Created Service Appointment:", service_apt.name)
|
||||||
|
except Exception as e:
|
||||||
|
print("ERROR: Failed to create Service Appointment for Project:", e)
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise e
|
||||||
|
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.name)]
|
||||||
|
for task_name in task_names:
|
||||||
|
doc.append("tasks", {
|
||||||
|
"task": task_name
|
||||||
|
})
|
||||||
|
AddressService.append_link_v2(
|
||||||
|
doc.job_address, "tasks", {"task": task_name}
|
||||||
|
)
|
||||||
|
ClientService.append_link_v2(
|
||||||
|
doc.customer, "tasks", {"task": task_name}
|
||||||
|
)
|
||||||
|
if task_names:
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
TaskService.calculate_and_set_due_dates(task_names, "Created", current_triggering_dict=doc.as_dict())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def before_insert(doc, method):
|
def before_insert(doc, method):
|
||||||
# This is where we will add logic to set tasks and other properties of a job based on it's project_template
|
# This is where we will add logic to set tasks and other properties of a job based on it's project_template
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def before_save(doc, method):
|
||||||
|
print("DEBUG: Before Save Triggered for Project:", doc.name)
|
||||||
|
if doc.expected_start_date and doc.expected_end_date:
|
||||||
|
print("DEBUG: Project has expected start and end dates, marking as scheduled")
|
||||||
|
doc.is_scheduled = 1
|
||||||
|
while frappe.db.exists("Holiday", {"holiday_date": doc.expected_end_date}):
|
||||||
|
print("DEBUG: Expected end date falls on a holiday, extending end date by 1 day")
|
||||||
|
doc.expected_end_date += timedelta(days=1)
|
||||||
|
elif not doc.expected_start_date or not doc.expected_end_date:
|
||||||
|
print("DEBUG: Project missing expected start or end date, marking as unscheduled")
|
||||||
|
doc.is_scheduled = 0
|
||||||
|
event = TaskService.determine_event(doc)
|
||||||
|
if event:
|
||||||
|
TaskService.calculate_and_set_due_dates(
|
||||||
|
[task.task for task in doc.tasks],
|
||||||
|
event,
|
||||||
|
current_triggering_dict=doc.as_dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
def after_save(doc, method):
|
||||||
|
print("DEBUG: After Save Triggered for Project:", doc.name)
|
||||||
|
if doc.project_template == "SNW Install":
|
||||||
|
print("DEBUG: Project template is SNW Install, updating Address Job Status based on Project status")
|
||||||
|
status_mapping = {
|
||||||
|
"Open": "In Progress",
|
||||||
|
"Completed": "Completed",
|
||||||
|
"Closed": "Completed"
|
||||||
|
}
|
||||||
|
new_status = status_mapping.get(doc.status, "In Progress")
|
||||||
|
if frappe.db.get_value("Address", doc.job_address, "job_status") != new_status:
|
||||||
|
print("DEBUG: Updating Address job_status to:", new_status)
|
||||||
|
AddressService.update_value(
|
||||||
|
doc.job_address,
|
||||||
|
"job_status",
|
||||||
|
new_status
|
||||||
|
)
|
||||||
|
|
@ -8,7 +8,8 @@ def before_insert(doc, method):
|
||||||
# Address.onsite_meetings is a child table with two fields: onsite_meeting (Link) and project_template (Link). Iterate through to see if there is already an SNW Install meeting linked.
|
# Address.onsite_meetings is a child table with two fields: onsite_meeting (Link) and project_template (Link). Iterate through to see if there is already an SNW Install meeting linked.
|
||||||
for link in address_doc.onsite_meetings:
|
for link in address_doc.onsite_meetings:
|
||||||
if link.project_template == "SNW Install":
|
if link.project_template == "SNW Install":
|
||||||
raise frappe.ValidationError("An On-Site Meeting with project template 'SNW Install' is already linked to this address.")
|
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.")
|
||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
print("DEBUG: After Insert Triggered for On-Site Meeting")
|
print("DEBUG: After Insert Triggered for On-Site Meeting")
|
||||||
|
|
@ -22,17 +23,19 @@ def after_insert(doc, method):
|
||||||
|
|
||||||
|
|
||||||
def before_save(doc, method):
|
def before_save(doc, method):
|
||||||
|
|
||||||
print("DEBUG: Before Save Triggered for On-Site Meeting")
|
print("DEBUG: Before Save Triggered for On-Site Meeting")
|
||||||
if doc.status != "Scheduled" and doc.start_time and doc.end_time and doc.status != "Completed":
|
if doc.status != "Scheduled" and doc.start_time and doc.end_time and doc.status != "Completed" and doc.status != "Cancelled":
|
||||||
print("DEBUG: Meeting has start and end time, setting status to Scheduled")
|
print("DEBUG: Meeting has start and end time, setting status to Scheduled")
|
||||||
doc.status = "Scheduled"
|
doc.status = "Scheduled"
|
||||||
if doc.project_template == "SNW Install":
|
if doc.project_template == "SNW Install":
|
||||||
print("DEBUG: Project template is SNW Install")
|
print("DEBUG: Project template is SNW Install")
|
||||||
if doc.status == "Completed":
|
if doc.status == "Completed":
|
||||||
print("DEBUG: Meeting marked as Completed, updating Address status")
|
print("DEBUG: Meeting marked as Completed, updating Address status")
|
||||||
current_status = AddressService.get_value(doc.address, "onsite_meeting_scheduled")
|
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
|
||||||
if current_status != doc.status:
|
if doc.status == "Cancelled":
|
||||||
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
|
print("DEBUG: Meeting marked as Cancelled, updating Address status")
|
||||||
|
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Not Started")
|
||||||
|
|
||||||
def validate_address_link(doc, method):
|
def validate_address_link(doc, method):
|
||||||
print("DEBUG: Validating Address link for On-Site Meeting")
|
print("DEBUG: Validating Address link for On-Site Meeting")
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
import frappe
|
import frappe
|
||||||
from custom_ui.services import DbService, AddressService, ClientService
|
from custom_ui.services import DbService, AddressService, ClientService
|
||||||
|
|
||||||
|
|
||||||
|
def on_save(doc, method):
|
||||||
|
print("DEBUG: on_save hook triggered for Sales Order", doc.name)
|
||||||
|
if doc.advance_paid >= doc.grand_total/2:
|
||||||
|
if doc.project and doc.half_down_required:
|
||||||
|
print("DEBUG: Advance payments exceed required threshold of half down, setting project half down paid.")
|
||||||
|
project = frappe.get_doc("Project", doc.project)
|
||||||
|
project.is_half_down_paid = True
|
||||||
|
|
||||||
|
|
||||||
def before_insert(doc, method):
|
def before_insert(doc, method):
|
||||||
print("DEBUG: before_insert hook triggered for Sales Order:", doc.name)
|
print("DEBUG: before_insert hook triggered for Sales Order")
|
||||||
if doc.custom_project_template == "SNW Install":
|
# if doc.custom_project_template == "SNW Install":
|
||||||
print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
|
# print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
|
||||||
address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
# address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
||||||
if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
|
# if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
|
||||||
raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
|
# raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
|
||||||
|
|
||||||
|
|
||||||
def on_submit(doc, method):
|
def on_submit(doc, method):
|
||||||
print("DEBUG: Info from Sales Order")
|
print("DEBUG: Info from Sales Order")
|
||||||
|
|
@ -29,14 +40,16 @@ def on_submit(doc, method):
|
||||||
"custom_warranty_duration_days": 90,
|
"custom_warranty_duration_days": 90,
|
||||||
"customer": doc.customer,
|
"customer": doc.customer,
|
||||||
"job_address": doc.custom_job_address,
|
"job_address": doc.custom_job_address,
|
||||||
"sales_order": doc.name
|
"sales_order": doc.name,
|
||||||
|
"requires_half_payment": doc.requires_half_payment
|
||||||
})
|
})
|
||||||
# attatch the job to the sales_order links
|
# attatch the job to the sales_order links
|
||||||
new_job.insert()
|
new_job.insert()
|
||||||
frappe.db.commit()
|
# frappe.db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("ERROR creating Project from Sales Order:", str(e))
|
print("ERROR creating Project from Sales Order:", str(e))
|
||||||
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
print("DEBUG: after_insert hook triggered for Sales Order:", doc.name)
|
print("DEBUG: after_insert hook triggered for Sales Order:", doc.name)
|
||||||
AddressService.append_link_v2(
|
AddressService.append_link_v2(
|
||||||
|
|
@ -49,41 +62,43 @@ def after_insert(doc, method):
|
||||||
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
|
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_sales_invoice_from_sales_order(doc, method):
|
|
||||||
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()
|
def create_sales_invoice_from_sales_order(doc, method):
|
||||||
invoice.submit()
|
pass
|
||||||
frappe.db.commit()
|
# try:
|
||||||
return invoice
|
# print("DEBUG: after_submit hook triggered for Sales Order:", doc.name)
|
||||||
except Exception as e:
|
# invoice_ammount = doc.grand_total / 2 if doc.requires_half_payment else doc.grand_total
|
||||||
print("ERROR creating Sales Invoice from Sales Order:", str(e))
|
# items = []
|
||||||
frappe.log_error(f"Error creating Sales Invoice from Sales Order {doc.name}: {str(e)}", "Sales Order after_submit Error")
|
# 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")
|
||||||
|
|
|
||||||
31
custom_ui/events/service_appointment.py
Normal file
31
custom_ui/events/service_appointment.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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,7 +1,34 @@
|
||||||
import frappe
|
import frappe
|
||||||
|
from custom_ui.services import AddressService, ClientService, TaskService
|
||||||
|
|
||||||
def before_insert(doc, method):
|
def before_insert(doc, method):
|
||||||
"""Set values before inserting a Task."""
|
"""Set values before inserting a Task."""
|
||||||
|
print("DEBUG: Before Insert Triggered for Task")
|
||||||
project_doc = frappe.get_doc("Project", doc.project)
|
project_doc = frappe.get_doc("Project", doc.project)
|
||||||
if project_doc.custom_installation_address:
|
doc.project_template = project_doc.project_template
|
||||||
doc.custom_property = project_doc.custom_installation_address
|
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())
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1
custom_ui/fixtures/email_template.json
Normal file
1
custom_ui/fixtures/email_template.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
|
|
@ -1,4 +1,20 @@
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Pre-Built Routes",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocField",
|
||||||
|
"field_name": "naming_series",
|
||||||
|
"is_system_generated": 1,
|
||||||
|
"modified": "2026-01-21 10:16:27.072272",
|
||||||
|
"module": null,
|
||||||
|
"name": "Pre-Built Routes-naming_series-options",
|
||||||
|
"property": "options",
|
||||||
|
"property_type": "Text",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "Route - .#####"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Item",
|
"doc_type": "Item",
|
||||||
|
|
@ -7327,22 +7343,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "1"
|
"value": "1"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Contact",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2024-12-23 13:33:08.995392",
|
|
||||||
"module": null,
|
|
||||||
"name": "Contact-main-naming_rule",
|
|
||||||
"property": "naming_rule",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "Set by user"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Contact",
|
"doc_type": "Contact",
|
||||||
|
|
@ -7375,22 +7375,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "creation"
|
"value": "creation"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Contact",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2024-12-23 16:11:50.106128",
|
|
||||||
"module": null,
|
|
||||||
"name": "Contact-main-autoname",
|
|
||||||
"property": "autoname",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "full_name"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Contact",
|
"doc_type": "Contact",
|
||||||
|
|
@ -10975,22 +10959,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "1"
|
"value": "1"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Project",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-03-04 19:46:31.636284",
|
|
||||||
"module": null,
|
|
||||||
"name": "Project-main-autoname",
|
|
||||||
"property": "autoname",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": ".custom_installation_address.-PRO-.#####.-.YYYY."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Payment Entry",
|
"doc_type": "Payment Entry",
|
||||||
|
|
@ -11711,38 +11679,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "1"
|
"value": "1"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Address",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-04-25 12:53:57.474107",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address-main-naming_rule",
|
|
||||||
"property": "naming_rule",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Address",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-04-25 12:53:57.545546",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address-main-autoname",
|
|
||||||
"property": "autoname",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": ""
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Address",
|
"doc_type": "Address",
|
||||||
|
|
@ -12175,22 +12111,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "[\"subject\", \"project\", \"custom_property\", \"issue\", \"type\", \"color\", \"is_group\", \"is_template\", \"column_break0\", \"status\", \"priority\", \"task_weight\", \"parent_task\", \"completed_by\", \"completed_on\", \"custom_foreman\", \"sb_timeline\", \"exp_start_date\", \"expected_time\", \"start\", \"column_break_11\", \"exp_end_date\", \"progress\", \"duration\", \"is_milestone\", \"sb_details\", \"description\", \"sb_depends_on\", \"depends_on\", \"depends_on_tasks\", \"sb_actual\", \"act_start_date\", \"actual_time\", \"column_break_15\", \"act_end_date\", \"sb_costing\", \"total_costing_amount\", \"total_expense_claim\", \"column_break_20\", \"total_billing_amount\", \"sb_more_info\", \"review_date\", \"closing_date\", \"column_break_22\", \"department\", \"company\", \"lft\", \"rgt\", \"old_parent\", \"template_task\"]"
|
"value": "[\"subject\", \"project\", \"custom_property\", \"issue\", \"type\", \"color\", \"is_group\", \"is_template\", \"column_break0\", \"status\", \"priority\", \"task_weight\", \"parent_task\", \"completed_by\", \"completed_on\", \"custom_foreman\", \"sb_timeline\", \"exp_start_date\", \"expected_time\", \"start\", \"column_break_11\", \"exp_end_date\", \"progress\", \"duration\", \"is_milestone\", \"sb_details\", \"description\", \"sb_depends_on\", \"depends_on\", \"depends_on_tasks\", \"sb_actual\", \"act_start_date\", \"actual_time\", \"column_break_15\", \"act_end_date\", \"sb_costing\", \"total_costing_amount\", \"total_expense_claim\", \"column_break_20\", \"total_billing_amount\", \"sb_more_info\", \"review_date\", \"closing_date\", \"column_break_22\", \"department\", \"company\", \"lft\", \"rgt\", \"old_parent\", \"template_task\"]"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Project",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-08-26 18:57:43.989451",
|
|
||||||
"module": null,
|
|
||||||
"name": "Project-main-field_order",
|
|
||||||
"property": "field_order",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"custom_column_break_k7sgq\", \"custom_installation_address\", \"naming_series\", \"project_name\", \"status\", \"custom_warranty_duration_days\", \"custom_warranty_expiration_date\", \"custom_warranty_information\", \"project_type\", \"percent_complete_method\", \"percent_complete\", \"column_break_5\", \"project_template\", \"expected_start_date\", \"expected_end_date\", \"custom_completion_date\", \"priority\", \"custom_foreman\", \"custom_hidden_fields\", \"department\", \"is_active\", \"custom_address\", \"custom_section_break_lgkpd\", \"custom_workflow_related_custom_fields__landry\", \"custom_permit_status\", \"custom_utlity_locate_status\", \"custom_crew_scheduling\", \"customer_details\", \"customer\", \"column_break_14\", \"sales_order\", \"users_section\", \"users\", \"copied_from\", \"section_break0\", \"notes\", \"section_break_18\", \"actual_start_date\", \"actual_time\", \"column_break_20\", \"actual_end_date\", \"project_details\", \"estimated_costing\", \"total_costing_amount\", \"total_expense_claim\", \"total_purchase_cost\", \"company\", \"column_break_28\", \"total_sales_amount\", \"total_billable_amount\", \"total_billed_amount\", \"total_consumed_material_cost\", \"cost_center\", \"margin\", \"gross_margin\", \"column_break_37\", \"per_gross_margin\", \"monitor_progress\", \"collect_progress\", \"holiday_list\", \"frequency\", \"from_time\", \"to_time\", \"first_email\", \"second_email\", \"daily_time_to_send\", \"day_to_send\", \"weekly_time_to_send\", \"column_break_45\", \"message\"]"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Attendance",
|
"doc_type": "Attendance",
|
||||||
|
|
@ -12303,54 +12223,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "[\"section_break_toee\", \"custom_location_of_meeting\", \"contact\", \"custom_phone_number\", \"custom_sms_optin\", \"custom_email_address\", \"custom_column_break_dsqvk\", \"appointment_date\", \"appointment_time\", \"status\", \"custom_internal_company\", \"custom_section_break_gndxh\", \"service_details\", \"custom_assigned_to\", \"auto_repeat\"]"
|
"value": "[\"section_break_toee\", \"custom_location_of_meeting\", \"contact\", \"custom_phone_number\", \"custom_sms_optin\", \"custom_email_address\", \"custom_column_break_dsqvk\", \"appointment_date\", \"appointment_time\", \"status\", \"custom_internal_company\", \"custom_section_break_gndxh\", \"service_details\", \"custom_assigned_to\", \"auto_repeat\"]"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Address",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-11-10 01:29:00.331516",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address-main-field_order",
|
|
||||||
"property": "field_order",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"address_details\", \"custom_column_break_vqa4d\", \"custom_column_break_jw2ty\", \"custom_installationservice_address\", \"custom_billing_address\", \"is_shipping_address\", \"is_primary_address\", \"custom_is_compnay_address\", \"custom_column_break_ky1zo\", \"custom_estimate_sent_status\", \"custom_onsite_meeting_scheduled\", \"custom_job_status\", \"custom_payment_received_status\", \"custom_section_break_fvgdt\", \"address_title\", \"address_type\", \"address_line1\", \"address_line2\", \"custom_linked_city\", \"custom_subdivision\", \"is_your_company_address\", \"custom_column_break_3mo7x\", \"state\", \"city\", \"pincode\", \"county\", \"country\", \"custom_column_break_rrto0\", \"custom_customer_to_bill\", \"custom_contact_name\", \"phone\", \"email_id\", \"fax\", \"tax_category\", \"disabled\", \"custom_section_break_aecpx\", \"column_break0\", \"custom_show_irrigation_district\", \"custom_google_map\", \"custom_latitude\", \"custom_longitude\", \"custom_address_for_coordinates\", \"linked_with\", \"custom_linked_contacts\", \"links\", \"custom_column_break_9cbvb\", \"custom_linked_companies\", \"custom_irrigation\", \"custom_upcoming_services\", \"custom_service_type\", \"custom_service_route\", \"custom_confirmation_status\", \"custom_backflow_test_form_filed\", \"custom_column_break_j79td\", \"custom_technician_assigned\", \"custom_scheduled_date\", \"custom_column_break_sqplk\", \"custom_test_route\", \"custom_tech\", \"custom_column_break_wcs7g\", \"custom_section_break_zruvq\", \"custom_irrigation_district\", \"custom_serial_\", \"custom_makemodel_\", \"custom_column_break_djjw3\", \"custom_backflow_location\", \"custom_shutoff_location\", \"custom_valve_boxes\", \"custom_timer_type_and_location\", \"custom_column_break_slusf\", \"custom_section_break_5d1cf\", \"custom_installed_by_sprinklers_nw\", \"custom_column_break_th7rq\", \"custom_installed_for\", \"custom_install_month\", \"custom_install_year\", \"custom_column_break_4itse\", \"custom_section_break_xfdtv\", \"custom_backflow_test_report\", \"custom_column_break_oxppn\", \"custom_photo_attachment\"]"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Address",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-11-10 01:29:00.632202",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address-main-links_order",
|
|
||||||
"property": "links_order",
|
|
||||||
"property_type": "Small Text",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"21ddd8462e\", \"c26b89d0d3\", \"ee207f2316\"]"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Address",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-11-10 01:29:00.867308",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address-main-states_order",
|
|
||||||
"property": "states_order",
|
|
||||||
"property_type": "Small Text",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"62m56h85vo\", \"62m5uugrvr\", \"62m57bgpkf\", \"62m5fgrjb0\"]"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Payment Entry",
|
"doc_type": "Payment Entry",
|
||||||
|
|
@ -12671,22 +12543,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "ISS-.YYYY.-"
|
"value": "ISS-.YYYY.-"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Contact",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-11-26 03:43:13.493067",
|
|
||||||
"module": null,
|
|
||||||
"name": "Contact-main-field_order",
|
|
||||||
"property": "field_order",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"sb_01\", \"custom_column_break_g4zvy\", \"first_name\", \"custom_column_break_hpz5b\", \"middle_name\", \"custom_column_break_3pehb\", \"last_name\", \"contact_section\", \"links\", \"phone_nos\", \"email_ids\", \"custom_column_break_nfqbi\", \"is_primary_contact\", \"is_billing_contact\", \"custom_service_address\", \"user\", \"unsubscribed\", \"more_info\", \"custom_column_break_sn9hu\", \"full_name\", \"address\", \"company_name\", \"designation\", \"department\", \"image\", \"sb_00\", \"custom_column_break_kmlkz\", \"email_id\", \"mobile_no\", \"phone\", \"status\", \"gender\", \"salutation\", \"contact_details\", \"cb_00\", \"custom_test_label\", \"google_contacts\", \"google_contacts_id\", \"sync_with_google_contacts\", \"cb00\", \"pulled_from_google_contacts\", \"custom_column_break_ejxjz\"]"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Contact",
|
"doc_type": "Contact",
|
||||||
|
|
@ -12735,22 +12591,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "[\"qagt2h4psk\"]"
|
"value": "[\"qagt2h4psk\"]"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Lead",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2026-01-07 04:42:08.600100",
|
|
||||||
"module": null,
|
|
||||||
"name": "Lead-main-field_order",
|
|
||||||
"property": "field_order",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"naming_series\", \"salutation\", \"first_name\", \"middle_name\", \"last_name\", \"custom_customer_name\", \"column_break_1\", \"lead_name\", \"customer_type\", \"job_title\", \"gender\", \"source\", \"col_break123\", \"lead_owner\", \"status\", \"customer\", \"type\", \"request_type\", \"contact_info_tab\", \"email_id\", \"website\", \"column_break_20\", \"mobile_no\", \"whatsapp_no\", \"column_break_16\", \"phone\", \"phone_ext\", \"organization_section\", \"company_name\", \"no_of_employees\", \"column_break_28\", \"annual_revenue\", \"industry\", \"market_segment\", \"column_break_31\", \"territory\", \"fax\", \"address_section\", \"address_html\", \"column_break_38\", \"city\", \"state\", \"country\", \"column_break2\", \"contact_html\", \"qualification_tab\", \"qualification_status\", \"column_break_64\", \"qualified_by\", \"qualified_on\", \"other_info_tab\", \"campaign_name\", \"company\", \"column_break_22\", \"language\", \"image\", \"title\", \"column_break_50\", \"disabled\", \"unsubscribed\", \"blog_subscriber\", \"activities_tab\", \"open_activities_html\", \"all_activities_section\", \"all_activities_html\", \"notes_tab\", \"notes_html\", \"notes\", \"dashboard_tab\"]"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Address",
|
"doc_type": "Address",
|
||||||
|
|
@ -12767,6 +12607,102 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "Billing\nShipping\nOffice\nPersonal\nPlant\nPostal\nShop\nSubsidiary\nWarehouse\nCurrent\nPermanent\nOther\nService"
|
"value": "Billing\nShipping\nOffice\nPersonal\nPlant\nPostal\nShop\nSubsidiary\nWarehouse\nCurrent\nPermanent\nOther\nService"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Lead",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-19 15:35:00.062846",
|
||||||
|
"module": null,
|
||||||
|
"name": "Lead-main-field_order",
|
||||||
|
"property": "field_order",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"naming_series\", \"salutation\", \"first_name\", \"middle_name\", \"last_name\", \"custom_customer_name\", \"column_break_1\", \"lead_name\", \"customer_type\", \"companies\", \"quotations\", \"onsite_meetings\", \"job_title\", \"gender\", \"source\", \"col_break123\", \"lead_owner\", \"status\", \"customer\", \"type\", \"request_type\", \"contact_info_tab\", \"email_id\", \"website\", \"column_break_20\", \"mobile_no\", \"whatsapp_no\", \"column_break_16\", \"phone\", \"phone_ext\", \"organization_section\", \"company_name\", \"no_of_employees\", \"column_break_28\", \"annual_revenue\", \"industry\", \"market_segment\", \"column_break_31\", \"territory\", \"fax\", \"address_section\", \"address_html\", \"column_break_38\", \"city\", \"state\", \"country\", \"column_break2\", \"contact_html\", \"qualification_tab\", \"qualification_status\", \"column_break_64\", \"qualified_by\", \"qualified_on\", \"other_info_tab\", \"campaign_name\", \"company\", \"column_break_22\", \"language\", \"image\", \"title\", \"column_break_50\", \"disabled\", \"unsubscribed\", \"blog_subscriber\", \"activities_tab\", \"open_activities_html\", \"all_activities_section\", \"all_activities_html\", \"notes_tab\", \"notes_html\", \"notes\", \"dashboard_tab\", \"contacts\", \"primary_contact\", \"properties\", \"custom_billing_address\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Address",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-21 03:31:53.715660",
|
||||||
|
"module": null,
|
||||||
|
"name": "Address-main-naming_rule",
|
||||||
|
"property": "naming_rule",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "Expression"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Address",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-21 03:31:53.785594",
|
||||||
|
"module": null,
|
||||||
|
"name": "Address-main-autoname",
|
||||||
|
"property": "autoname",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "format:{full_address)-#-{MM}-{YYYY}-{####}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Address",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-21 03:31:53.827100",
|
||||||
|
"module": null,
|
||||||
|
"name": "Address-main-field_order",
|
||||||
|
"property": "field_order",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"address_details\", \"custom_column_break_vqa4d\", \"custom_column_break_jw2ty\", \"custom_installationservice_address\", \"custom_billing_address\", \"is_shipping_address\", \"is_primary_address\", \"custom_is_compnay_address\", \"custom_column_break_ky1zo\", \"custom_estimate_sent_status\", \"custom_onsite_meeting_scheduled\", \"custom_job_status\", \"custom_payment_received_status\", \"custom_section_break_fvgdt\", \"address_title\", \"primary_contact\", \"address_type\", \"address_line1\", \"address_line2\", \"custom_linked_city\", \"custom_subdivision\", \"is_your_company_address\", \"custom_column_break_3mo7x\", \"state\", \"city\", \"pincode\", \"county\", \"country\", \"full_address\", \"latitude\", \"longitude\", \"onsite_meeting_scheduled\", \"estimate_sent_status\", \"job_status\", \"payment_received_status\", \"custom_column_break_rrto0\", \"custom_customer_to_bill\", \"lead_name\", \"customer_type\", \"customer_name\", \"contacts\", \"companies\", \"quotations\", \"onsite_meetings\", \"projects\", \"sales_orders\", \"tasks\", \"custom_contact_name\", \"phone\", \"email_id\", \"fax\", \"tax_category\", \"disabled\", \"custom_section_break_aecpx\", \"column_break0\", \"custom_show_irrigation_district\", \"custom_google_map\", \"custom_latitude\", \"custom_longitude\", \"custom_address_for_coordinates\", \"linked_with\", \"custom_linked_contacts\", \"links\", \"custom_column_break_9cbvb\", \"custom_linked_companies\", \"custom_irrigation\", \"custom_upcoming_services\", \"custom_service_type\", \"custom_service_route\", \"custom_confirmation_status\", \"custom_backflow_test_form_filed\", \"custom_column_break_j79td\", \"custom_technician_assigned\", \"custom_scheduled_date\", \"custom_column_break_sqplk\", \"custom_test_route\", \"custom_tech\", \"custom_column_break_wcs7g\", \"custom_section_break_zruvq\", \"custom_irrigation_district\", \"custom_serial_\", \"custom_makemodel_\", \"custom_column_break_djjw3\", \"custom_backflow_location\", \"custom_shutoff_location\", \"custom_valve_boxes\", \"custom_timer_type_and_location\", \"custom_column_break_slusf\", \"custom_section_break_5d1cf\", \"custom_installed_by_sprinklers_nw\", \"custom_column_break_th7rq\", \"custom_installed_for\", \"custom_install_month\", \"custom_install_year\", \"custom_column_break_4itse\", \"custom_section_break_xfdtv\", \"custom_backflow_test_report\", \"custom_column_break_oxppn\", \"custom_photo_attachment\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Project Template",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-21 04:34:22.663180",
|
||||||
|
"module": null,
|
||||||
|
"name": "Project Template-main-field_order",
|
||||||
|
"property": "field_order",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"project_type\", \"tasks\", \"company\", \"calendar_color\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Project Template",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocField",
|
||||||
|
"field_name": "calendar_color",
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-21 04:34:22.740335",
|
||||||
|
"module": null,
|
||||||
|
"name": "Project Template-calendar_color-in_list_view",
|
||||||
|
"property": "in_list_view",
|
||||||
|
"property_type": "Check",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "1"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Project",
|
"doc_type": "Project",
|
||||||
|
|
@ -15022,5 +14958,181 @@
|
||||||
"property_type": "Check",
|
"property_type": "Check",
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "1"
|
"value": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Task Type",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-22 06:23:06.078264",
|
||||||
|
"module": null,
|
||||||
|
"name": "Task Type-main-naming_rule",
|
||||||
|
"property": "naming_rule",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "Expression"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Task Type",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-22 06:23:06.134175",
|
||||||
|
"module": null,
|
||||||
|
"name": "Task Type-main-autoname",
|
||||||
|
"property": "autoname",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "format:{title}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Task Type",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocField",
|
||||||
|
"field_name": "task_type_calculate_from",
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-22 09:31:29.877718",
|
||||||
|
"module": null,
|
||||||
|
"name": "Task Type-task_type_calculate_from-mandatory_depends_on",
|
||||||
|
"property": "mandatory_depends_on",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "eval:doc.calculate_from == \"Task\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Lead",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 01:51:02.536818",
|
||||||
|
"module": null,
|
||||||
|
"name": "Lead-main-autoname",
|
||||||
|
"property": "autoname",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "format:{custom_customer_name}-#-{YYYY}-{MM}-{####}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Address",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:35:09.522811",
|
||||||
|
"module": null,
|
||||||
|
"name": "Address-main-links_order",
|
||||||
|
"property": "links_order",
|
||||||
|
"property_type": "Small Text",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"21ddd8462e\", \"c26b89d0d3\", \"ee207f2316\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Address",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:35:09.598292",
|
||||||
|
"module": null,
|
||||||
|
"name": "Address-main-states_order",
|
||||||
|
"property": "states_order",
|
||||||
|
"property_type": "Small Text",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"62m56h85vo\", \"62m5uugrvr\", \"62m57bgpkf\", \"62m5fgrjb0\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Contact",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:40:01.394710",
|
||||||
|
"module": null,
|
||||||
|
"name": "Contact-main-naming_rule",
|
||||||
|
"property": "naming_rule",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "Expression"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Contact",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:40:01.427255",
|
||||||
|
"module": null,
|
||||||
|
"name": "Contact-main-autoname",
|
||||||
|
"property": "autoname",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "format:{full-name}-#-{MM}-{YYYY}-{####}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Contact",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:40:01.458831",
|
||||||
|
"module": null,
|
||||||
|
"name": "Contact-main-field_order",
|
||||||
|
"property": "field_order",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"sb_01\", \"custom_column_break_g4zvy\", \"first_name\", \"custom_column_break_hpz5b\", \"middle_name\", \"custom_column_break_3pehb\", \"last_name\", \"email\", \"customer_type\", \"customer_name\", \"addresses\", \"contact_section\", \"links\", \"phone_nos\", \"email_ids\", \"custom_column_break_nfqbi\", \"is_primary_contact\", \"is_billing_contact\", \"custom_service_address\", \"user\", \"unsubscribed\", \"more_info\", \"custom_column_break_sn9hu\", \"full_name\", \"address\", \"company_name\", \"designation\", \"role\", \"department\", \"image\", \"sb_00\", \"custom_column_break_kmlkz\", \"email_id\", \"mobile_no\", \"phone\", \"status\", \"gender\", \"salutation\", \"contact_details\", \"cb_00\", \"custom_test_label\", \"google_contacts\", \"google_contacts_id\", \"sync_with_google_contacts\", \"cb00\", \"pulled_from_google_contacts\", \"custom_column_break_ejxjz\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Project",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 10:42:06.682515",
|
||||||
|
"module": null,
|
||||||
|
"name": "Project-main-autoname",
|
||||||
|
"property": "autoname",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "format:{project_template}-#-PRO-{#####}-{YYYY}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Project",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 10:42:06.862234",
|
||||||
|
"module": null,
|
||||||
|
"name": "Project-main-field_order",
|
||||||
|
"property": "field_order",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"custom_column_break_k7sgq\", \"custom_installation_address\", \"naming_series\", \"project_name\", \"job_address\", \"status\", \"custom_warranty_duration_days\", \"custom_warranty_expiration_date\", \"custom_warranty_information\", \"project_type\", \"percent_complete_method\", \"percent_complete\", \"column_break_5\", \"project_template\", \"expected_start_date\", \"expected_start_time\", \"expected_end_date\", \"expected_end_time\", \"is_scheduled\", \"invoice_status\", \"custom_completion_date\", \"priority\", \"custom_foreman\", \"custom_hidden_fields\", \"department\", \"service_appointment\", \"tasks\", \"is_active\", \"custom_address\", \"custom_section_break_lgkpd\", \"custom_workflow_related_custom_fields__landry\", \"custom_permit_status\", \"custom_utlity_locate_status\", \"custom_crew_scheduling\", \"customer_details\", \"customer\", \"column_break_14\", \"sales_order\", \"users_section\", \"users\", \"copied_from\", \"section_break0\", \"notes\", \"section_break_18\", \"actual_start_date\", \"actual_start_time\", \"actual_time\", \"column_break_20\", \"actual_end_date\", \"actual_end_time\", \"project_details\", \"estimated_costing\", \"total_costing_amount\", \"total_expense_claim\", \"total_purchase_cost\", \"company\", \"column_break_28\", \"total_sales_amount\", \"total_billable_amount\", \"total_billed_amount\", \"total_consumed_material_cost\", \"cost_center\", \"margin\", \"gross_margin\", \"column_break_37\", \"per_gross_margin\", \"monitor_progress\", \"collect_progress\", \"holiday_list\", \"frequency\", \"from_time\", \"to_time\", \"first_email\", \"second_email\", \"daily_time_to_send\", \"day_to_send\", \"weekly_time_to_send\", \"column_break_45\", \"subject\", \"message\"]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -26,6 +26,10 @@ add_to_apps_screen = [
|
||||||
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
requires = [
|
||||||
|
"holidays==0.89"
|
||||||
|
]
|
||||||
# Apps
|
# Apps
|
||||||
# ------------------
|
# ------------------
|
||||||
|
|
||||||
|
|
@ -33,13 +37,13 @@ add_to_apps_screen = [
|
||||||
|
|
||||||
# Each item in the list will be shown as an app in the apps page
|
# Each item in the list will be shown as an app in the apps page
|
||||||
# add_to_apps_screen = [
|
# add_to_apps_screen = [
|
||||||
# {
|
# {
|
||||||
# "name": "custom_ui",
|
# "name": "custom_ui",
|
||||||
# "logo": "/assets/custom_ui/logo.png",
|
# "logo": "/assets/custom_ui/logo.png",
|
||||||
# "title": "Custom Ui",
|
# "title": "Custom Ui",
|
||||||
# "route": "/custom_ui",
|
# "route": "/custom_ui",
|
||||||
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
||||||
# }
|
# }
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
# Includes in <head>
|
# Includes in <head>
|
||||||
|
|
@ -82,7 +86,7 @@ add_to_apps_screen = [
|
||||||
|
|
||||||
# website user home page (by Role)
|
# website user home page (by Role)
|
||||||
# role_home_page = {
|
# role_home_page = {
|
||||||
# "Role": "home_page"
|
# "Role": "home_page"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Generators
|
# Generators
|
||||||
|
|
@ -96,8 +100,8 @@ add_to_apps_screen = [
|
||||||
|
|
||||||
# add methods and filters to jinja environment
|
# add methods and filters to jinja environment
|
||||||
# jinja = {
|
# jinja = {
|
||||||
# "methods": "custom_ui.utils.jinja_methods",
|
# "methods": "custom_ui.utils.jinja_methods",
|
||||||
# "filters": "custom_ui.utils.jinja_filters"
|
# "filters": "custom_ui.utils.jinja_filters"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
@ -139,11 +143,11 @@ add_to_apps_screen = [
|
||||||
# Permissions evaluated in scripted ways
|
# Permissions evaluated in scripted ways
|
||||||
|
|
||||||
# permission_query_conditions = {
|
# permission_query_conditions = {
|
||||||
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
|
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
# has_permission = {
|
# has_permission = {
|
||||||
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# DocType Class
|
# DocType Class
|
||||||
|
|
@ -151,7 +155,7 @@ add_to_apps_screen = [
|
||||||
# Override standard doctype classes
|
# Override standard doctype classes
|
||||||
|
|
||||||
# override_doctype_class = {
|
# override_doctype_class = {
|
||||||
# "ToDo": "custom_app.overrides.CustomToDo"
|
# "ToDo": "custom_app.overrides.CustomToDo"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Document Events
|
# Document Events
|
||||||
|
|
@ -159,11 +163,11 @@ add_to_apps_screen = [
|
||||||
# Hook on document methods and events
|
# Hook on document methods and events
|
||||||
|
|
||||||
doc_events = {
|
doc_events = {
|
||||||
"On-Site Meeting": {
|
"On-Site Meeting": {
|
||||||
"after_insert": "custom_ui.events.onsite_meeting.after_insert",
|
"after_insert": "custom_ui.events.onsite_meeting.after_insert",
|
||||||
"before_save": "custom_ui.events.onsite_meeting.before_save",
|
"before_save": "custom_ui.events.onsite_meeting.before_save",
|
||||||
"before_insert": "custom_ui.events.onsite_meeting.before_insert"
|
"before_insert": "custom_ui.events.onsite_meeting.before_insert"
|
||||||
},
|
},
|
||||||
"Address": {
|
"Address": {
|
||||||
"before_insert": "custom_ui.events.address.before_insert"
|
"before_insert": "custom_ui.events.address.before_insert"
|
||||||
},
|
},
|
||||||
|
|
@ -181,32 +185,48 @@ doc_events = {
|
||||||
},
|
},
|
||||||
"Project": {
|
"Project": {
|
||||||
"before_insert": "custom_ui.events.jobs.before_insert",
|
"before_insert": "custom_ui.events.jobs.before_insert",
|
||||||
"after_insert": "custom_ui.events.jobs.after_insert"
|
"after_insert": "custom_ui.events.jobs.after_insert",
|
||||||
|
"before_save": "custom_ui.events.jobs.before_save",
|
||||||
|
"on_update": "custom_ui.events.jobs.after_save"
|
||||||
},
|
},
|
||||||
"Task": {
|
"Task": {
|
||||||
"before_insert": "custom_ui.events.task.before_insert"
|
"before_insert": "custom_ui.events.task.before_insert",
|
||||||
|
"after_insert": "custom_ui.events.task.after_insert",
|
||||||
|
"before_save": "custom_ui.events.task.before_save"
|
||||||
|
},
|
||||||
|
"Bid Meeting Note Form": {
|
||||||
|
"after_insert": "custom_ui.events.general.attach_bid_note_form_to_project_template"
|
||||||
|
},
|
||||||
|
"Service Address 2": {
|
||||||
|
"before_save": "custom_ui.events.service_appointment.before_save",
|
||||||
|
"after_insert": "custom_ui.events.service_appointment.after_insert",
|
||||||
|
"on_update": "custom_ui.events.service_appointment.on_update"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
|
{
|
||||||
|
"dt": "Email Template",
|
||||||
|
"filters": [
|
||||||
|
["name", "in", ["Customer Invoice"]]
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"dt": "DocType",
|
"dt": "DocType",
|
||||||
"filters": [
|
"filters": [
|
||||||
["custom", "=", 1]
|
["custom", "=", 1]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"dt": "Custom Field"
|
# These don't have reliable flags → export all
|
||||||
},
|
{"dt": "Custom Field"},
|
||||||
{
|
{"dt": "Property Setter"},
|
||||||
"dt": "Property Setter"
|
{"dt": "Client Script"},
|
||||||
},
|
{"dt": "Server Script"},
|
||||||
{
|
# {"dt": "Report"},
|
||||||
"dt": "Client Script"
|
# {"dt": "Print Format"},
|
||||||
},
|
# {"dt": "Dashboard"},
|
||||||
{
|
# {"dt": "Workspace"},
|
||||||
"dt": "Server Script"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -218,21 +238,21 @@ fixtures = [
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
# scheduler_events = {
|
# scheduler_events = {
|
||||||
# "all": [
|
# "all": [
|
||||||
# "custom_ui.tasks.all"
|
# "custom_ui.tasks.all"
|
||||||
# ],
|
# ],
|
||||||
# "daily": [
|
# "daily": [
|
||||||
# "custom_ui.tasks.daily"
|
# "custom_ui.tasks.daily"
|
||||||
# ],
|
# ],
|
||||||
# "hourly": [
|
# "hourly": [
|
||||||
# "custom_ui.tasks.hourly"
|
# "custom_ui.tasks.hourly"
|
||||||
# ],
|
# ],
|
||||||
# "weekly": [
|
# "weekly": [
|
||||||
# "custom_ui.tasks.weekly"
|
# "custom_ui.tasks.weekly"
|
||||||
# ],
|
# ],
|
||||||
# "monthly": [
|
# "monthly": [
|
||||||
# "custom_ui.tasks.monthly"
|
# "custom_ui.tasks.monthly"
|
||||||
# ],
|
# ],
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
|
|
@ -244,14 +264,14 @@ fixtures = [
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
#
|
#
|
||||||
# override_whitelisted_methods = {
|
# override_whitelisted_methods = {
|
||||||
# "frappe.desk.doctype.event.event.get_events": "custom_ui.event.get_events"
|
# "frappe.desk.doctype.event.event.get_events": "custom_ui.event.get_events"
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
# each overriding function accepts a `data` argument;
|
# each overriding function accepts a `data` argument;
|
||||||
# generated from the base implementation of the doctype dashboard,
|
# generated from the base implementation of the doctype dashboard,
|
||||||
# along with any modifications made in other Frappe apps
|
# along with any modifications made in other Frappe apps
|
||||||
# override_doctype_dashboards = {
|
# override_doctype_dashboards = {
|
||||||
# "Task": "custom_ui.task.get_dashboard_data"
|
# "Task": "custom_ui.task.get_dashboard_data"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# exempt linked doctypes from being automatically cancelled
|
# exempt linked doctypes from being automatically cancelled
|
||||||
|
|
@ -277,37 +297,37 @@ fixtures = [
|
||||||
# --------------------
|
# --------------------
|
||||||
|
|
||||||
# user_data_fields = [
|
# user_data_fields = [
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_1}",
|
# "doctype": "{doctype_1}",
|
||||||
# "filter_by": "{filter_by}",
|
# "filter_by": "{filter_by}",
|
||||||
# "redact_fields": ["{field_1}", "{field_2}"],
|
# "redact_fields": ["{field_1}", "{field_2}"],
|
||||||
# "partial": 1,
|
# "partial": 1,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_2}",
|
# "doctype": "{doctype_2}",
|
||||||
# "filter_by": "{filter_by}",
|
# "filter_by": "{filter_by}",
|
||||||
# "partial": 1,
|
# "partial": 1,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_3}",
|
# "doctype": "{doctype_3}",
|
||||||
# "strict": False,
|
# "strict": False,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_4}"
|
# "doctype": "{doctype_4}"
|
||||||
# }
|
# }
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
# Authentication and authorization
|
# Authentication and authorization
|
||||||
# --------------------------------
|
# --------------------------------
|
||||||
|
|
||||||
# auth_hooks = [
|
# auth_hooks = [
|
||||||
# "custom_ui.auth.validate"
|
# "custom_ui.auth.validate"
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
# Automatically update python controller files with type annotations for this app.
|
# Automatically update python controller files with type annotations for this app.
|
||||||
# export_python_type_annotations = True
|
# export_python_type_annotations = True
|
||||||
|
|
||||||
# default_log_clearing_doctypes = {
|
# default_log_clearing_doctypes = {
|
||||||
# "Logging DocType Name": 30 # days to retain logs
|
# "Logging DocType Name": 30 # days to retain logs
|
||||||
# }
|
# }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
import frappe
|
import frappe
|
||||||
from .utils import create_module
|
from .utils import create_module
|
||||||
|
import holidays
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
def after_install():
|
def after_install():
|
||||||
create_module()
|
create_module()
|
||||||
|
|
@ -17,6 +19,11 @@ def after_install():
|
||||||
frappe.reload_doctype("On-Site Meeting")
|
frappe.reload_doctype("On-Site Meeting")
|
||||||
update_onsite_meeting_fields()
|
update_onsite_meeting_fields()
|
||||||
update_address_fields()
|
update_address_fields()
|
||||||
|
check_and_create_holiday_list()
|
||||||
|
create_project_templates()
|
||||||
|
create_task_types()
|
||||||
|
# create_tasks()
|
||||||
|
create_bid_meeting_note_form_templates()
|
||||||
build_frontend()
|
build_frontend()
|
||||||
|
|
||||||
def after_migrate():
|
def after_migrate():
|
||||||
|
|
@ -30,7 +37,13 @@ def after_migrate():
|
||||||
frappe.clear_cache(doctype=doctype)
|
frappe.clear_cache(doctype=doctype)
|
||||||
frappe.reload_doctype(doctype)
|
frappe.reload_doctype(doctype)
|
||||||
|
|
||||||
update_address_fields()
|
check_and_create_holiday_list()
|
||||||
|
# create_project_templates()
|
||||||
|
create_task_types()
|
||||||
|
# create_tasks()
|
||||||
|
create_bid_meeting_note_form_templates()
|
||||||
|
|
||||||
|
# update_address_fields()
|
||||||
# build_frontend()
|
# build_frontend()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,7 +65,7 @@ def build_frontend():
|
||||||
frappe.log_error(message="No frontend directory found for custom_ui", title="Frontend Build Skipped")
|
frappe.log_error(message="No frontend directory found for custom_ui", title="Frontend Build Skipped")
|
||||||
print(f"⚠️ Frontend directory does not exist. Skipping build. Path was {frontend_path}")
|
print(f"⚠️ Frontend directory does not exist. Skipping build. Path was {frontend_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
dist_path = os.path.join(app_root, "custom_ui", "public", "dist")
|
dist_path = os.path.join(app_root, "custom_ui", "public", "dist")
|
||||||
should_build = True
|
should_build = True
|
||||||
|
|
||||||
|
|
@ -65,10 +78,10 @@ def build_frontend():
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
frappe.log_error(message=str(e), title="Frontend Build Failed")
|
frappe.log_error(message=str(e), title="Frontend Build Failed")
|
||||||
print(f"\n❌ Frontend build failed: {e}\n")
|
print(f"\n❌ Frontend build failed: {e}\n")
|
||||||
|
|
||||||
def add_custom_fields():
|
def add_custom_fields():
|
||||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||||
|
|
||||||
print("\n🔧 Adding custom fields to doctypes...")
|
print("\n🔧 Adding custom fields to doctypes...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -81,7 +94,7 @@ def add_custom_fields():
|
||||||
print(" ✅ Added 'Service' to Address address_type options.")
|
print(" ✅ Added 'Service' to Address address_type options.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ⚠️ Failed to update Address address_type: {e}")
|
print(f" ⚠️ Failed to update Address address_type: {e}")
|
||||||
|
|
||||||
custom_fields = {
|
custom_fields = {
|
||||||
"Customer": [
|
"Customer": [
|
||||||
dict(
|
dict(
|
||||||
|
|
@ -146,6 +159,13 @@ def add_custom_fields():
|
||||||
fieldtype="Link",
|
fieldtype="Link",
|
||||||
options="Contact",
|
options="Contact",
|
||||||
insert_after="contacts"
|
insert_after="contacts"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="tasks",
|
||||||
|
label="Tasks",
|
||||||
|
fieldtype="Table",
|
||||||
|
options="Customer Task Link",
|
||||||
|
insert_after="projects"
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
"Lead": [
|
"Lead": [
|
||||||
|
|
@ -256,7 +276,7 @@ def add_custom_fields():
|
||||||
insert_after="full_address"
|
insert_after="full_address"
|
||||||
),
|
),
|
||||||
dict(
|
dict(
|
||||||
fieldname="longitude",
|
fieldname="longitude",
|
||||||
label="Longitude",
|
label="Longitude",
|
||||||
fieldtype="Float",
|
fieldtype="Float",
|
||||||
precision=8,
|
precision=8,
|
||||||
|
|
@ -266,7 +286,7 @@ def add_custom_fields():
|
||||||
fieldname="onsite_meeting_scheduled",
|
fieldname="onsite_meeting_scheduled",
|
||||||
label="On-Site Meeting Scheduled",
|
label="On-Site Meeting Scheduled",
|
||||||
fieldtype="Select",
|
fieldtype="Select",
|
||||||
options="Not Started\nIn Progress\nCompleted",
|
options="Not Started\nIn Progress\nCompleted",
|
||||||
default="Not Started",
|
default="Not Started",
|
||||||
insert_after="longitude"
|
insert_after="longitude"
|
||||||
),
|
),
|
||||||
|
|
@ -280,7 +300,7 @@ def add_custom_fields():
|
||||||
),
|
),
|
||||||
dict(
|
dict(
|
||||||
fieldname="job_status",
|
fieldname="job_status",
|
||||||
label="Job Status",
|
label="Job Status",
|
||||||
fieldtype="Select",
|
fieldtype="Select",
|
||||||
options="Not Started\nIn Progress\nCompleted",
|
options="Not Started\nIn Progress\nCompleted",
|
||||||
default="Not Started",
|
default="Not Started",
|
||||||
|
|
@ -291,7 +311,7 @@ def add_custom_fields():
|
||||||
label="Payment Received Status",
|
label="Payment Received Status",
|
||||||
fieldtype="Select",
|
fieldtype="Select",
|
||||||
options="Not Started\nIn Progress\nCompleted",
|
options="Not Started\nIn Progress\nCompleted",
|
||||||
default="Not Started",
|
default="Not Started",
|
||||||
insert_after="job_status"
|
insert_after="job_status"
|
||||||
),
|
),
|
||||||
dict(
|
dict(
|
||||||
|
|
@ -327,6 +347,13 @@ def add_custom_fields():
|
||||||
fieldtype="Table",
|
fieldtype="Table",
|
||||||
options="Address Company Link",
|
options="Address Company Link",
|
||||||
insert_after="contacts"
|
insert_after="contacts"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="tasks",
|
||||||
|
label="Tasks",
|
||||||
|
fieldtype="Table",
|
||||||
|
options="Address Task Link",
|
||||||
|
insert_after="projects"
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
"Contact": [
|
"Contact": [
|
||||||
|
|
@ -539,7 +566,60 @@ def add_custom_fields():
|
||||||
options="Customer",
|
options="Customer",
|
||||||
insert_after="job_address",
|
insert_after="job_address",
|
||||||
description="The customer for whom the project is being executed."
|
description="The customer for whom the project is being executed."
|
||||||
)
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="expected_start_time",
|
||||||
|
label="Expected Start Time",
|
||||||
|
fieldtype="Time",
|
||||||
|
insert_after="expected_start_date"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="expected_end_time",
|
||||||
|
label="Expected End Time",
|
||||||
|
fieldtype="Time",
|
||||||
|
insert_after="expected_end_date"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="actual_start_time",
|
||||||
|
label="Actual Start Time",
|
||||||
|
fieldtype="Time",
|
||||||
|
insert_after="actual_start_date"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="actual_end_time",
|
||||||
|
label="Actual End Time",
|
||||||
|
fieldtype="Time",
|
||||||
|
insert_after="actual_end_date"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="is_scheduled",
|
||||||
|
label="Is Scheduled",
|
||||||
|
fieldtype="Check",
|
||||||
|
default=0,
|
||||||
|
insert_after="is_half_down_paid"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="invoice_status",
|
||||||
|
label="Invoice Status",
|
||||||
|
fieldtype="Select",
|
||||||
|
default="Not Ready",
|
||||||
|
options="Not Ready\nReady to Invoice\nInvoice Created\nInvoice Sent",
|
||||||
|
insert_after="is_scheduled"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="requires_half_payment",
|
||||||
|
label="Requires Half Payment",
|
||||||
|
fieldtype="Check",
|
||||||
|
default=0,
|
||||||
|
insert_after="expected_end_time"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="is_half_down_paid",
|
||||||
|
label="Is Half Down Paid",
|
||||||
|
fieldtype="Check",
|
||||||
|
default=0,
|
||||||
|
insert_after="requires_half_payment"
|
||||||
|
),
|
||||||
],
|
],
|
||||||
"Project Template": [
|
"Project Template": [
|
||||||
dict(
|
dict(
|
||||||
|
|
@ -549,18 +629,33 @@ def add_custom_fields():
|
||||||
options="Company",
|
options="Company",
|
||||||
insert_after="project_type",
|
insert_after="project_type",
|
||||||
description="The company associated with this project template."
|
description="The company associated with this project template."
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="calendar_color",
|
||||||
|
label="Calendar Color",
|
||||||
|
fieldtype="Color",
|
||||||
|
insert_after="company"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
"Task": [
|
||||||
|
dict(
|
||||||
|
fieldname="project_template",
|
||||||
|
label="Project Template",
|
||||||
|
fieldtype="Link",
|
||||||
|
options="Project Template",
|
||||||
|
insert_after="project"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
print("🔧 Custom fields to check per doctype:")
|
print("🔧 Custom fields to check per doctype:")
|
||||||
for key, value in custom_fields.items():
|
for key, value in custom_fields.items():
|
||||||
print(f" • {key}: {len(value)} fields")
|
print(f" • {key}: {len(value)} fields")
|
||||||
print(f" Total fields to check: {sum(len(v) for v in custom_fields.values())}\n")
|
print(f" Total fields to check: {sum(len(v) for v in custom_fields.values())}\n")
|
||||||
|
|
||||||
missing_fields = []
|
missing_fields = []
|
||||||
fields_to_update = []
|
fields_to_update = []
|
||||||
|
|
||||||
for doctype, field_options in custom_fields.items():
|
for doctype, field_options in custom_fields.items():
|
||||||
meta = frappe.get_meta(doctype)
|
meta = frappe.get_meta(doctype)
|
||||||
for field_spec in field_options:
|
for field_spec in field_options:
|
||||||
|
|
@ -573,7 +668,7 @@ def add_custom_fields():
|
||||||
if frappe.db.exists("Custom Field", custom_field_name):
|
if frappe.db.exists("Custom Field", custom_field_name):
|
||||||
custom_field_doc = frappe.get_doc("Custom Field", custom_field_name)
|
custom_field_doc = frappe.get_doc("Custom Field", custom_field_name)
|
||||||
needs_update = False
|
needs_update = False
|
||||||
|
|
||||||
# Compare important properties
|
# Compare important properties
|
||||||
for key, desired_value in field_spec.items():
|
for key, desired_value in field_spec.items():
|
||||||
if key == "fieldname":
|
if key == "fieldname":
|
||||||
|
|
@ -582,10 +677,10 @@ def add_custom_fields():
|
||||||
if current_value != desired_value:
|
if current_value != desired_value:
|
||||||
needs_update = True
|
needs_update = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if needs_update:
|
if needs_update:
|
||||||
fields_to_update.append((doctype, fieldname, field_spec))
|
fields_to_update.append((doctype, fieldname, field_spec))
|
||||||
|
|
||||||
if missing_fields:
|
if missing_fields:
|
||||||
print("\n❌ Missing custom fields:")
|
print("\n❌ Missing custom fields:")
|
||||||
for entry in missing_fields:
|
for entry in missing_fields:
|
||||||
|
|
@ -594,36 +689,36 @@ def add_custom_fields():
|
||||||
missing_field_specs = build_missing_field_specs(custom_fields, missing_fields)
|
missing_field_specs = build_missing_field_specs(custom_fields, missing_fields)
|
||||||
create_custom_fields(missing_field_specs)
|
create_custom_fields(missing_field_specs)
|
||||||
print("✅ Missing custom fields created.")
|
print("✅ Missing custom fields created.")
|
||||||
|
|
||||||
if fields_to_update:
|
if fields_to_update:
|
||||||
print("\n🔧 Updating custom fields with mismatched specs:")
|
print("\n🔧 Updating custom fields with mismatched specs:")
|
||||||
for doctype, fieldname, field_spec in fields_to_update:
|
for doctype, fieldname, field_spec in fields_to_update:
|
||||||
print(f" • {doctype}: {fieldname}")
|
print(f" • {doctype}: {fieldname}")
|
||||||
custom_field_name = f"{doctype}-{fieldname}"
|
custom_field_name = f"{doctype}-{fieldname}"
|
||||||
custom_field_doc = frappe.get_doc("Custom Field", custom_field_name)
|
custom_field_doc = frappe.get_doc("Custom Field", custom_field_name)
|
||||||
|
|
||||||
# Update all properties from field_spec
|
# Update all properties from field_spec
|
||||||
for key, value in field_spec.items():
|
for key, value in field_spec.items():
|
||||||
if key != "fieldname":
|
if key != "fieldname":
|
||||||
setattr(custom_field_doc, key, value)
|
setattr(custom_field_doc, key, value)
|
||||||
|
|
||||||
custom_field_doc.save(ignore_permissions=True)
|
custom_field_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
print("✅ Custom fields updated.")
|
print("✅ Custom fields updated.")
|
||||||
|
|
||||||
if not missing_fields and not fields_to_update:
|
if not missing_fields and not fields_to_update:
|
||||||
print("✅ All custom fields verified.")
|
print("✅ All custom fields verified.")
|
||||||
|
|
||||||
|
|
||||||
def update_onsite_meeting_fields():
|
def update_onsite_meeting_fields():
|
||||||
"""Update On-Site Meeting doctype fields to make start_time and end_time optional."""
|
"""Update On-Site Meeting doctype fields to make start_time and end_time optional."""
|
||||||
print("\n🔧 Updating On-Site Meeting doctype fields...")
|
print("\n🔧 Updating On-Site Meeting doctype fields...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the doctype
|
# Get the doctype
|
||||||
doctype = frappe.get_doc("DocType", "On-Site Meeting")
|
doctype = frappe.get_doc("DocType", "On-Site Meeting")
|
||||||
|
|
||||||
# Find and update start_time and end_time fields
|
# Find and update start_time and end_time fields
|
||||||
updated_fields = []
|
updated_fields = []
|
||||||
for field in doctype.fields:
|
for field in doctype.fields:
|
||||||
|
|
@ -631,20 +726,20 @@ def update_onsite_meeting_fields():
|
||||||
if field.reqd == 1:
|
if field.reqd == 1:
|
||||||
field.reqd = 0
|
field.reqd = 0
|
||||||
updated_fields.append(field.fieldname)
|
updated_fields.append(field.fieldname)
|
||||||
|
|
||||||
if updated_fields:
|
if updated_fields:
|
||||||
# Save the doctype
|
# Save the doctype
|
||||||
doctype.save(ignore_permissions=True)
|
doctype.save(ignore_permissions=True)
|
||||||
print(f"✅ Updated fields: {', '.join(updated_fields)} (set to not required)")
|
print(f"✅ Updated fields: {', '.join(updated_fields)} (set to not required)")
|
||||||
else:
|
else:
|
||||||
print("✅ Fields already configured correctly")
|
print("✅ Fields already configured correctly")
|
||||||
|
|
||||||
print("🔧 On-Site Meeting field update complete.\n")
|
print("🔧 On-Site Meeting field update complete.\n")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error updating On-Site Meeting fields: {str(e)}")
|
print(f"❌ Error updating On-Site Meeting fields: {str(e)}")
|
||||||
frappe.log_error(message=str(e), title="On-Site Meeting Field Update Failed")
|
frappe.log_error(message=str(e), title="On-Site Meeting Field Update Failed")
|
||||||
# Don't raise - this is not critical enough to stop migration
|
# Don't raise - this is not critical enough to stop migration
|
||||||
|
|
||||||
def update_address_fields():
|
def update_address_fields():
|
||||||
quotations = frappe.get_all("Quotation", pluck="name")
|
quotations = frappe.get_all("Quotation", pluck="name")
|
||||||
addresses = frappe.get_all("Address", pluck="name")
|
addresses = frappe.get_all("Address", pluck="name")
|
||||||
|
|
@ -664,9 +759,9 @@ def update_address_fields():
|
||||||
combined_doctypes.append({"doctype": "Address", "name": address})
|
combined_doctypes.append({"doctype": "Address", "name": address})
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
combined_doctypes.append({"doctype": "Task", "name": task})
|
combined_doctypes.append({"doctype": "Task", "name": task})
|
||||||
|
|
||||||
print(f"\n📍 Updating field values for {total_addresses} addresses, {total_quotations} quotations, {total_sales_orders} sales orders, and {total_tasks} tasks...")
|
print(f"\n📍 Updating field values for {total_addresses} addresses, {total_quotations} quotations, {total_sales_orders} sales orders, and {total_tasks} tasks...")
|
||||||
|
|
||||||
# Field update counters
|
# Field update counters
|
||||||
field_counters = {
|
field_counters = {
|
||||||
'quotation_addresses_updated': 0,
|
'quotation_addresses_updated': 0,
|
||||||
|
|
@ -687,7 +782,7 @@ def update_address_fields():
|
||||||
'contacts_updated': 0,
|
'contacts_updated': 0,
|
||||||
'tasks_updated': 0
|
'tasks_updated': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onsite_meta = frappe.get_meta("On-Site Meeting")
|
onsite_meta = frappe.get_meta("On-Site Meeting")
|
||||||
onsite_status_field = "custom_status" if onsite_meta.has_field("custom_status") else "status"
|
onsite_status_field = "custom_status" if onsite_meta.has_field("custom_status") else "status"
|
||||||
|
|
||||||
|
|
@ -697,7 +792,7 @@ def update_address_fields():
|
||||||
bar_length = 30
|
bar_length = 30
|
||||||
filled_length = int(bar_length * index // total_doctypes)
|
filled_length = int(bar_length * index // total_doctypes)
|
||||||
bar = '█' * filled_length + '░' * (bar_length - filled_length)
|
bar = '█' * filled_length + '░' * (bar_length - filled_length)
|
||||||
|
|
||||||
# Print a three-line, refreshing progress block without adding new lines each loop
|
# Print a three-line, refreshing progress block without adding new lines each loop
|
||||||
progress_line = f"📊 Progress: [{bar}] {progress_percentage:3d}% ({index}/{total_doctypes})"
|
progress_line = f"📊 Progress: [{bar}] {progress_percentage:3d}% ({index}/{total_doctypes})"
|
||||||
counters_line = f" Fields updated: {field_counters['total_field_updates']} | DocTypes updated: {field_counters['addresses_updated'] + field_counters['quotations_updated'] + field_counters['sales_orders_updated'] + field_counters['customers_updated']}"
|
counters_line = f" Fields updated: {field_counters['total_field_updates']} | DocTypes updated: {field_counters['addresses_updated'] + field_counters['quotations_updated'] + field_counters['sales_orders_updated'] + field_counters['customers_updated']}"
|
||||||
|
|
@ -728,7 +823,7 @@ def update_address_fields():
|
||||||
custom_installation_address = getattr(quotation_doc, 'custom_installation_address', None)
|
custom_installation_address = getattr(quotation_doc, 'custom_installation_address', None)
|
||||||
custom_job_address = getattr(quotation_doc, 'custom_job_address', None)
|
custom_job_address = getattr(quotation_doc, 'custom_job_address', None)
|
||||||
custom_project_template = getattr(quotation_doc, 'custom_project_template', None)
|
custom_project_template = getattr(quotation_doc, 'custom_project_template', None)
|
||||||
|
|
||||||
updates = {}
|
updates = {}
|
||||||
if custom_installation_address and not custom_job_address:
|
if custom_installation_address and not custom_job_address:
|
||||||
updates['custom_job_address'] = custom_installation_address
|
updates['custom_job_address'] = custom_installation_address
|
||||||
|
|
@ -738,15 +833,15 @@ def update_address_fields():
|
||||||
updates['custom_project_template'] = "SNW Install"
|
updates['custom_project_template'] = "SNW Install"
|
||||||
field_counters[f"{dict_field}_project_templates_updated"] += 1
|
field_counters[f"{dict_field}_project_templates_updated"] += 1
|
||||||
field_counters['total_field_updates'] += 1
|
field_counters['total_field_updates'] += 1
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
frappe.db.set_value(doc['doctype'], doc['name'], updates)
|
frappe.db.set_value(doc['doctype'], doc['name'], updates)
|
||||||
field_counters[f"{dict_field}s_updated"] += 1
|
field_counters[f"{dict_field}s_updated"] += 1
|
||||||
|
|
||||||
if doc['doctype'] == "Address":
|
if doc['doctype'] == "Address":
|
||||||
address_doc = frappe.get_doc("Address", doc['name'])
|
address_doc = frappe.get_doc("Address", doc['name'])
|
||||||
updates = {}
|
updates = {}
|
||||||
|
|
||||||
# Use getattr with default values instead of direct attribute access
|
# Use getattr with default values instead of direct attribute access
|
||||||
if not getattr(address_doc, 'full_address', None):
|
if not getattr(address_doc, 'full_address', None):
|
||||||
address_parts_1 = [
|
address_parts_1 = [
|
||||||
|
|
@ -758,57 +853,57 @@ def update_address_fields():
|
||||||
address_doc.state or "",
|
address_doc.state or "",
|
||||||
address_doc.pincode or "",
|
address_doc.pincode or "",
|
||||||
]
|
]
|
||||||
|
|
||||||
full_address = ", ".join([
|
full_address = ", ".join([
|
||||||
" ".join(filter(None, address_parts_1)),
|
" ".join(filter(None, address_parts_1)),
|
||||||
" ".join(filter(None, address_parts_2))
|
" ".join(filter(None, address_parts_2))
|
||||||
]).strip()
|
]).strip()
|
||||||
updates['full_address'] = full_address
|
updates['full_address'] = full_address
|
||||||
field_counters['full_address'] += 1
|
field_counters['full_address'] += 1
|
||||||
field_counters['total_field_updates'] += 1
|
field_counters['total_field_updates'] += 1
|
||||||
|
|
||||||
onsite_meeting = "Not Started"
|
onsite_meeting = "Not Started"
|
||||||
estimate_sent = "Not Started"
|
estimate_sent = "Not Started"
|
||||||
job_status = "Not Started"
|
job_status = "Not Started"
|
||||||
payment_received = "Not Started"
|
payment_received = "Not Started"
|
||||||
|
|
||||||
|
|
||||||
onsite_meetings = frappe.get_all("On-Site Meeting", fields=[onsite_status_field], filters={"address": address_doc.address_title})
|
onsite_meetings = frappe.get_all("On-Site Meeting", fields=[onsite_status_field], filters={"address": address_doc.address_title})
|
||||||
if onsite_meetings and onsite_meetings[0]:
|
if onsite_meetings and onsite_meetings[0]:
|
||||||
status_value = onsite_meetings[0].get(onsite_status_field)
|
status_value = onsite_meetings[0].get(onsite_status_field)
|
||||||
onsite_meeting = "Completed" if status_value == "Completed" else "In Progress"
|
onsite_meeting = "Completed" if status_value == "Completed" else "In Progress"
|
||||||
|
|
||||||
estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus", "custom_response"], filters={"custom_job_address": address_doc.address_title})
|
estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus", "custom_response"], filters={"custom_job_address": address_doc.address_title})
|
||||||
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]:
|
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]:
|
||||||
estimate_sent = "Completed"
|
estimate_sent = "Completed"
|
||||||
elif estimates and estimates[0] and not (estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]):
|
elif estimates and estimates[0] and not (estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]):
|
||||||
estimate_sent = "In Progress"
|
estimate_sent = "In Progress"
|
||||||
|
|
||||||
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address_doc.address_title, "project_template": "SNW Install"})
|
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address_doc.address_title, "project_template": "SNW Install"})
|
||||||
if jobs and jobs[0] and jobs[0]["status"] == "Completed":
|
if jobs and jobs[0] and jobs[0]["status"] == "Completed":
|
||||||
job_status = "Completed"
|
job_status = "Completed"
|
||||||
elif jobs and jobs[0]:
|
elif jobs and jobs[0]:
|
||||||
job_status = "In Progress"
|
job_status = "In Progress"
|
||||||
|
|
||||||
sales_invoices = frappe.get_all("Sales Invoice", fields=["outstanding_amount"], filters={"custom_installation_address": address_doc.address_title})
|
sales_invoices = frappe.get_all("Sales Invoice", fields=["outstanding_amount"], filters={"custom_installation_address": address_doc.address_title})
|
||||||
# payments = frappe.get_all("Payment Entry", filters={"custom_installation_address": address_doc.address_title})
|
# payments = frappe.get_all("Payment Entry", filters={"custom_installation_address": address_doc.address_title})
|
||||||
if sales_invoices and sales_invoices[0] and sales_invoices[0]["outstanding_amount"] == 0:
|
if sales_invoices and sales_invoices[0] and sales_invoices[0]["outstanding_amount"] == 0:
|
||||||
payment_received = "Completed"
|
payment_received = "Completed"
|
||||||
elif sales_invoices and sales_invoices[0]:
|
elif sales_invoices and sales_invoices[0]:
|
||||||
payment_received = "In Progress"
|
payment_received = "In Progress"
|
||||||
|
|
||||||
customer_name = getattr(address_doc, 'custom_customer_to_bill', None)
|
customer_name = getattr(address_doc, 'custom_customer_to_bill', None)
|
||||||
links = address_doc.get("links", [])
|
links = address_doc.get("links", [])
|
||||||
customer_links = [link for link in links if link.link_doctype == "Customer"]
|
customer_links = [link for link in links if link.link_doctype == "Customer"]
|
||||||
needs_link_update = False
|
needs_link_update = False
|
||||||
|
|
||||||
if customer_name and frappe.db.exists("Customer", customer_name):
|
if customer_name and frappe.db.exists("Customer", customer_name):
|
||||||
customer_doc = frappe.get_doc("Customer", customer_name)
|
customer_doc = frappe.get_doc("Customer", customer_name)
|
||||||
|
|
||||||
# Check if address needs link update
|
# Check if address needs link update
|
||||||
if not customer_links:
|
if not customer_links:
|
||||||
needs_link_update = True
|
needs_link_update = True
|
||||||
|
|
||||||
if not address_doc.name in [link.address_name for link in customer_doc.get("custom_select_address", [])]:
|
if not address_doc.name in [link.address_name for link in customer_doc.get("custom_select_address", [])]:
|
||||||
customer_doc.append("custom_select_address", {
|
customer_doc.append("custom_select_address", {
|
||||||
"address_name": address_doc.name
|
"address_name": address_doc.name
|
||||||
|
|
@ -816,7 +911,7 @@ def update_address_fields():
|
||||||
customer_doc.save(ignore_permissions=True)
|
customer_doc.save(ignore_permissions=True)
|
||||||
field_counters['customers_updated'] += 1
|
field_counters['customers_updated'] += 1
|
||||||
field_counters['total_field_updates'] += 1
|
field_counters['total_field_updates'] += 1
|
||||||
|
|
||||||
if getattr(address_doc, 'custom_onsite_meeting_scheduled', None) != onsite_meeting:
|
if getattr(address_doc, 'custom_onsite_meeting_scheduled', None) != onsite_meeting:
|
||||||
updates['custom_onsite_meeting_scheduled'] = onsite_meeting
|
updates['custom_onsite_meeting_scheduled'] = onsite_meeting
|
||||||
field_counters['custom_onsite_meeting_scheduled'] += 1
|
field_counters['custom_onsite_meeting_scheduled'] += 1
|
||||||
|
|
@ -833,11 +928,11 @@ def update_address_fields():
|
||||||
updates['custom_payment_received_status'] = payment_received
|
updates['custom_payment_received_status'] = payment_received
|
||||||
field_counters['custom_payment_received_status'] += 1
|
field_counters['custom_payment_received_status'] += 1
|
||||||
field_counters['total_field_updates'] += 1
|
field_counters['total_field_updates'] += 1
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
frappe.db.set_value("Address", doc['name'], updates)
|
frappe.db.set_value("Address", doc['name'], updates)
|
||||||
field_counters['addresses_updated'] += 1
|
field_counters['addresses_updated'] += 1
|
||||||
|
|
||||||
# Handle address links after db.set_value to avoid timestamp mismatch
|
# Handle address links after db.set_value to avoid timestamp mismatch
|
||||||
if needs_link_update:
|
if needs_link_update:
|
||||||
# Reload the document to get the latest version
|
# Reload the document to get the latest version
|
||||||
|
|
@ -858,13 +953,13 @@ def update_address_fields():
|
||||||
frappe.db.set_value("Task", doc["name"], "custom_property", project_address if project_address else alt_project_address)
|
frappe.db.set_value("Task", doc["name"], "custom_property", project_address if project_address else alt_project_address)
|
||||||
field_counters['tasks_updated'] += 1
|
field_counters['tasks_updated'] += 1
|
||||||
field_counters['total_field_updates'] += 1
|
field_counters['total_field_updates'] += 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Commit every 100 records to avoid long transactions
|
# Commit every 100 records to avoid long transactions
|
||||||
if index % 100 == 0:
|
if index % 100 == 0:
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
|
|
||||||
# Print completion summary
|
# Print completion summary
|
||||||
print(f"\n\n✅ DocType field value update completed!")
|
print(f"\n\n✅ DocType field value update completed!")
|
||||||
print(f"📊 Summary:")
|
print(f"📊 Summary:")
|
||||||
|
|
@ -886,7 +981,7 @@ def update_address_fields():
|
||||||
print(f" • Sales Order Addresses Updated: {field_counters['sales_order_addresses_updated']:,}")
|
print(f" • Sales Order Addresses Updated: {field_counters['sales_order_addresses_updated']:,}")
|
||||||
print(f" • Sales Order Project Templates Updated: {field_counters['sales_order_project_templates_updated']:,}")
|
print(f" • Sales Order Project Templates Updated: {field_counters['sales_order_project_templates_updated']:,}")
|
||||||
print("📍 DocType field value updates complete.\n")
|
print("📍 DocType field value updates complete.\n")
|
||||||
|
|
||||||
def build_missing_field_specs(custom_fields, missing_fields):
|
def build_missing_field_specs(custom_fields, missing_fields):
|
||||||
missing_field_specs = {}
|
missing_field_specs = {}
|
||||||
for entry in missing_fields:
|
for entry in missing_fields:
|
||||||
|
|
@ -896,5 +991,390 @@ def build_missing_field_specs(custom_fields, missing_fields):
|
||||||
if field_spec["fieldname"] == fieldname:
|
if field_spec["fieldname"] == fieldname:
|
||||||
missing_field_specs[doctype].append(field_spec)
|
missing_field_specs[doctype].append(field_spec)
|
||||||
break
|
break
|
||||||
|
|
||||||
return missing_field_specs
|
return missing_field_specs
|
||||||
|
|
||||||
|
def check_and_create_holiday_list(year=2026, country="US", weekly_off="Sunday"):
|
||||||
|
"""Check if Holiday List for the given year exists, if not create it."""
|
||||||
|
print(f"\n🔧 Checking for Holiday List for {country} in {year}...")
|
||||||
|
holiday_list_name = f"{country} Holidays {year}"
|
||||||
|
|
||||||
|
if frappe.db.exists("Holiday List", holiday_list_name):
|
||||||
|
print(f"✅ Holiday List '{holiday_list_name}' already exists.")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(f"❌ Holiday List '{holiday_list_name}' does not exist. Creating...")
|
||||||
|
us_holidays = holidays.US(years=[year])
|
||||||
|
sundays = get_all_sundays(year)
|
||||||
|
hl = frappe.get_doc({
|
||||||
|
"doctype": "Holiday List",
|
||||||
|
"holiday_list_name": holiday_list_name,
|
||||||
|
"country": country,
|
||||||
|
"year": year,
|
||||||
|
"from_date": f"{year}-01-01",
|
||||||
|
"to_date": f"{year}-12-31",
|
||||||
|
"weekly_off": weekly_off,
|
||||||
|
"holidays": [
|
||||||
|
{
|
||||||
|
"holiday_date": holiday_date,
|
||||||
|
"description": holiday_name
|
||||||
|
} for holiday_date, holiday_name in us_holidays.items()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
for sunday in sundays:
|
||||||
|
hl.append("holidays", {
|
||||||
|
"holiday_date": sunday,
|
||||||
|
"description": "Sunday"
|
||||||
|
})
|
||||||
|
hl.insert()
|
||||||
|
# hl.make_holiday_entries()
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f"✅ Holiday List '{holiday_list_name}' created successfully.")
|
||||||
|
|
||||||
|
def get_all_sundays(year):
|
||||||
|
sundays = []
|
||||||
|
d = date(year, 1, 1)
|
||||||
|
|
||||||
|
while d.weekday() != 6:
|
||||||
|
d += timedelta(days=1)
|
||||||
|
|
||||||
|
while d.year == year:
|
||||||
|
sundays.append(d)
|
||||||
|
d += timedelta(days=7)
|
||||||
|
|
||||||
|
return sundays
|
||||||
|
|
||||||
|
def create_task_types():
|
||||||
|
task_types = [
|
||||||
|
{
|
||||||
|
"title": "811/Locate",
|
||||||
|
"name": "811/Locate",
|
||||||
|
"description": "Utility locate request prior to installation start.",
|
||||||
|
"base_date": "Start",
|
||||||
|
"offset_days": 7,
|
||||||
|
"offset_direction": "Before",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"trigger": "Scheduled",
|
||||||
|
"triggering_doctype": "Service Address 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Permit",
|
||||||
|
"name": "Permit",
|
||||||
|
"description": "Permits required prior to installation start.",
|
||||||
|
"base_date": "Start",
|
||||||
|
"offset_days": 7,
|
||||||
|
"offset_direction": "Before",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"trigger": "Scheduled",
|
||||||
|
"triggering_doctype": "Service Address 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "1/2 Down Payment",
|
||||||
|
"name": "1/2 Down Payment",
|
||||||
|
"description": "Collect half down payment on project creation.",
|
||||||
|
"calculate_from": "Project",
|
||||||
|
"trigger": "Created",
|
||||||
|
"triggering_doctype": "Project",
|
||||||
|
"no_due_date": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Machine Staging",
|
||||||
|
"name": "Machine Staging",
|
||||||
|
"description": "Stage machinery one day before installation start.",
|
||||||
|
"base_date": "Start",
|
||||||
|
"offset_days": 1,
|
||||||
|
"offset_direction": "Before",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"triggering_doctype": "Service Address 2",
|
||||||
|
"trigger": "Scheduled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Final Invoice",
|
||||||
|
"name": "Final Invoice",
|
||||||
|
"description": "Send final invoice within 5 days of job completion.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 5,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"triggering_doctype": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Backflow Test",
|
||||||
|
"name": "Backflow Test",
|
||||||
|
"description": "Backflow test after job completion if quoted.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 0,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"triggering_doctype": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Schedule Permit Inspection",
|
||||||
|
"name": "Schedule Permit Inspection",
|
||||||
|
"description": "Schedule permit inspection 5 days after completion.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 5,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
"triggering_doctype": "Service Address 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "15 Day Warranty Follow-Up",
|
||||||
|
"name": "15 Day Warranty Follow-Up",
|
||||||
|
"description": "15-day warranty follow-up after completion.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 15,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
"triggering_doctype": "Service Address 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "30 Day Warranty Check",
|
||||||
|
"name": "30 Day Warranty Check",
|
||||||
|
"description": "30-day warranty check after completion.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 30,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"triggering_doctype": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "30 Day Payment Reminder",
|
||||||
|
"name": "30 Day Payment Reminder",
|
||||||
|
"description": "Payment reminder sent 30 days after completion.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 30,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
"triggering_doctype": "Service Address 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "60 Day Late Payment Notice",
|
||||||
|
"name": "60 Day Late Payment Notice",
|
||||||
|
"description": "Late payment notification at 60 days post completion.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 60,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
"triggering_doctype": "Service Address 2"
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "80 Day Lien Notice",
|
||||||
|
"name": "80 Day Lien Notice",
|
||||||
|
"description": "Lien notice if payment is still late after 80 days.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 80,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
"triggering_doctype": "Service Address 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "365 Day Warranty Call / Walk",
|
||||||
|
"name": "365 Day Warranty Call / Walk",
|
||||||
|
"description": "One-year warranty call or walk-through.",
|
||||||
|
"base_date": "Completion",
|
||||||
|
"offset_days": 365,
|
||||||
|
"offset_direction": "After",
|
||||||
|
"calculate_from": "Service Address 2",
|
||||||
|
"trigger": "Completed",
|
||||||
|
"triggering_doctype": "Service Address 2"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for task_type in task_types:
|
||||||
|
# Idempotency check
|
||||||
|
if frappe.db.exists("Task Type", task_type["name"]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
"doctype": "Task Type",
|
||||||
|
"title": task_type["title"],
|
||||||
|
"name": task_type["name"],
|
||||||
|
"description": task_type["description"],
|
||||||
|
"base_date": task_type.get("base_date"),
|
||||||
|
"offset_days": task_type.get("offset_days", 0),
|
||||||
|
"offset_direction": task_type.get("offset_direction"),
|
||||||
|
"calculate_from": task_type.get("calculate_from", "Service Address 2"),
|
||||||
|
"trigger": task_type["trigger"],
|
||||||
|
"no_due_date": task_type.get("no_due_date", 0),
|
||||||
|
"triggering_doctype": task_type.get("triggering_doctype", "Service Address 2")
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
def create_tasks():
|
||||||
|
print("\n🔧 Creating default Tasks if they do not exist...")
|
||||||
|
default_tasks = [
|
||||||
|
{
|
||||||
|
"task_name": "Initial Consultation",
|
||||||
|
"description": "Conduct an initial consultation with the client to discuss project requirements.",
|
||||||
|
"status": "Open",
|
||||||
|
"priority": "High",
|
||||||
|
"type": "Consultation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"task_name": "Site Survey",
|
||||||
|
"description": "Perform a site survey to assess conditions and gather necessary data.",
|
||||||
|
"status": "Open",
|
||||||
|
"priority": "Medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"task_name": "Design Proposal",
|
||||||
|
"description": "Prepare and present a design proposal based on client needs and site survey findings.",
|
||||||
|
"status": "Open",
|
||||||
|
"priority": "High"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for task in default_tasks:
|
||||||
|
if frappe.db.exists("Task", task["task_name"]):
|
||||||
|
continue
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
"doctype": "Task",
|
||||||
|
"is_template": 1,
|
||||||
|
"task_name": task["task_name"],
|
||||||
|
"description": task["description"],
|
||||||
|
"status": task["status"],
|
||||||
|
"priority": task["priority"],
|
||||||
|
"type": task["type"]
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
def create_project_templates():
|
||||||
|
"""Create default Project Templates if they do not exist."""
|
||||||
|
print("\n🔧 Checking for default Project Templates...")
|
||||||
|
templates = {
|
||||||
|
"snw_templates": [
|
||||||
|
{
|
||||||
|
"name": "SNW Install",
|
||||||
|
"project_type": "Service",
|
||||||
|
"company": "Sprinklers Northwest",
|
||||||
|
"calendar_color": "#FF5733" # Example color
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_bid_meeting_note_form_templates():
|
||||||
|
"""Create Bid Meeting Note Forms if they do not exist."""
|
||||||
|
print("\n🔧 Checking for Bid Meeting Note Forms...")
|
||||||
|
|
||||||
|
forms = {
|
||||||
|
"Sprinklers Northwest": [
|
||||||
|
{
|
||||||
|
"title": "SNW Install Bid Meeting Notes",
|
||||||
|
"description": "Notes form for SNW Install bid meetings.",
|
||||||
|
"project_template": "SNW Install",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"label": "Locate Needed",
|
||||||
|
"type": "Check",
|
||||||
|
"required": 1,
|
||||||
|
"help_text": "Indicate if a locate is needed for this project.",
|
||||||
|
"row": 1,
|
||||||
|
"column": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Permit Needed",
|
||||||
|
"type": "Check",
|
||||||
|
"required": 1,
|
||||||
|
"help_text": "Indicate if a permit is needed for this project.",
|
||||||
|
"row": 1,
|
||||||
|
"column": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Back Flow Test Required",
|
||||||
|
"type": "Check",
|
||||||
|
"required": 1,
|
||||||
|
"help_text": "Indicate if a backflow test is required after installation.",
|
||||||
|
"row": 1,
|
||||||
|
"column": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Machine Access",
|
||||||
|
"type": "Check",
|
||||||
|
"required": 1,
|
||||||
|
"row": 2,
|
||||||
|
"column": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Machines",
|
||||||
|
"type": "Multi-Select",
|
||||||
|
"options": "MT, Skip Steer, Excavator-E-50, Link Belt, Tre?, Forks, Auger, Backhoe, Loader, Duzer",
|
||||||
|
"include_options": 1,
|
||||||
|
"conditional_on_field": "Machine Access",
|
||||||
|
"row": 2,
|
||||||
|
"column": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Materials Required",
|
||||||
|
"type": "Check",
|
||||||
|
"required": 1,
|
||||||
|
"row": 3,
|
||||||
|
"column": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Materials",
|
||||||
|
"type": "Multi-Select w/ Quantity",
|
||||||
|
"doctype_for_select": "Item",
|
||||||
|
"doctype_label_field": "itemName",
|
||||||
|
"conditional_on_field": "Materials Required",
|
||||||
|
"row": 4,
|
||||||
|
"column": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
for company, form_list in forms.items():
|
||||||
|
for form in form_list:
|
||||||
|
# Idempotency check
|
||||||
|
if frappe.db.exists(
|
||||||
|
"Bid Meeting Note Form",
|
||||||
|
{"title": form["title"], "company": company},
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
doc = frappe.new_doc("Bid Meeting Note Form")
|
||||||
|
doc.company = company
|
||||||
|
doc.title = form["title"]
|
||||||
|
doc.description = form.get("description")
|
||||||
|
doc.project_template = form.get("project_template")
|
||||||
|
|
||||||
|
for idx, field in enumerate(form.get("fields", []), start=1):
|
||||||
|
doc.append(
|
||||||
|
"fields",
|
||||||
|
{
|
||||||
|
"label": field["label"],
|
||||||
|
"type": field["type"],
|
||||||
|
"options": field.get("options"),
|
||||||
|
"required": field.get("required", 0),
|
||||||
|
"default_value": field.get("default_value"),
|
||||||
|
"read_only": field.get("read_only", 0),
|
||||||
|
"order": field.get("order", 0),
|
||||||
|
"help_text": field.get("help_text"),
|
||||||
|
"doctype_for_select": field.get("doctype_for_select"),
|
||||||
|
"include_options": field.get("include_options", 0),
|
||||||
|
"conditional_on_field": field.get("conditional_on_field"),
|
||||||
|
"conditional_on_value": field.get("conditional_on_value"),
|
||||||
|
"doctype_label_field": field.get("doctype_label_field"),
|
||||||
|
"row": field.get("row"),
|
||||||
|
"column": field.get("column"),
|
||||||
|
"idx": idx,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,6 @@ from .contact_service import ContactService
|
||||||
from .db_service import DbService
|
from .db_service import DbService
|
||||||
from .client_service import ClientService
|
from .client_service import ClientService
|
||||||
from .estimate_service import EstimateService
|
from .estimate_service import EstimateService
|
||||||
from .onsite_meeting_service import OnSiteMeetingService
|
from .onsite_meeting_service import OnSiteMeetingService
|
||||||
|
from .task_service import TaskService
|
||||||
|
from .service_appointment_service import ServiceAppointmentService
|
||||||
|
|
@ -122,7 +122,6 @@ class ClientService:
|
||||||
print(f"DEBUG: Linked quotation {quotation.get('quotation')} to customer")
|
print(f"DEBUG: Linked quotation {quotation.get('quotation')} to customer")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Failed to link quotation {quotation.get('quotation')}: {str(e)}")
|
print(f"ERROR: Failed to link quotation {quotation.get('quotation')}: {str(e)}")
|
||||||
frappe.log_error(f"Quotation linking error: {str(e)}", "convert_lead_to_customer")
|
|
||||||
|
|
||||||
if update_onsite_meetings:
|
if update_onsite_meetings:
|
||||||
print(f"DEBUG: Updating onsite meetings. Count: {len(lead_doc.get('onsite_meetings', []))}")
|
print(f"DEBUG: Updating onsite meetings. Count: {len(lead_doc.get('onsite_meetings', []))}")
|
||||||
|
|
|
||||||
54
custom_ui/services/service_appointment_service.py
Normal file
54
custom_ui/services/service_appointment_service.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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
|
||||||
171
custom_ui/services/task_service.py
Normal file
171
custom_ui/services/task_service.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
|
@ -5,47 +5,73 @@ import { useErrorStore } from "./stores/errors";
|
||||||
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
||||||
// Proxy method for external API calls
|
// Proxy method for external API calls
|
||||||
const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request";
|
const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request";
|
||||||
|
// On-Site Meeting methods
|
||||||
|
const FRAPPE_GET_INCOMPLETE_BIDS_METHOD = "custom_ui.api.db.on_site_meetings.get_incomplete_bids";
|
||||||
// Estimate methods
|
// Estimate methods
|
||||||
const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate";
|
const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate";
|
||||||
const FRAPPE_GET_ESTIMATES_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data";
|
const FRAPPE_GET_ESTIMATES_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data";
|
||||||
|
const FRAPPE_GET_ESTIMATES_TABLE_DATA_V2_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data_v2";
|
||||||
const FRAPPE_GET_ESTIMATE_BY_ADDRESS_METHOD = "custom_ui.api.db.estimates.get_estimate_from_address";
|
const FRAPPE_GET_ESTIMATE_BY_ADDRESS_METHOD = "custom_ui.api.db.estimates.get_estimate_from_address";
|
||||||
const FRAPPE_SEND_ESTIMATE_EMAIL_METHOD = "custom_ui.api.db.estimates.send_estimate_email";
|
const FRAPPE_SEND_ESTIMATE_EMAIL_METHOD = "custom_ui.api.db.estimates.send_estimate_email";
|
||||||
const FRAPPE_LOCK_ESTIMATE_METHOD = "custom_ui.api.db.estimates.lock_estimate";
|
const FRAPPE_LOCK_ESTIMATE_METHOD = "custom_ui.api.db.estimates.lock_estimate";
|
||||||
const FRAPPE_ESTIMATE_UPDATE_RESPONSE_METHOD = "custom_ui.api.db.estimates.manual_response";
|
const FRAPPE_ESTIMATE_UPDATE_RESPONSE_METHOD = "custom_ui.api.db.estimates.manual_response";
|
||||||
const FRAPPE_GET_ESTIMATE_TEMPLATES_METHOD = "custom_ui.api.db.estimates.get_estimate_templates";
|
const FRAPPE_GET_ESTIMATE_TEMPLATES_METHOD = "custom_ui.api.db.estimates.get_estimate_templates";
|
||||||
const FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD = "custom_ui.api.db.estimates.create_estimate_template";
|
const FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD = "custom_ui.api.db.estimates.create_estimate_template";
|
||||||
|
const FRAPPE_GET_UNAPPROVED_ESTIMATES_COUNT_METHOD = "custom_ui.api.db.estimates.get_unapproved_estimates_count";
|
||||||
|
const FRAPPE_GET_ESTIMATES_HALF_DOWN_COUNT_METHOD = "custom_ui.api.db.estimates.get_estimates_half_down_count";
|
||||||
// Job methods
|
// Job methods
|
||||||
const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job";
|
const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job";
|
||||||
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data";
|
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data";
|
||||||
const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job";
|
const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job";
|
||||||
const FRAPPE_GET_JOB_TASK_LIST_METHOD = "custom_ui.api.db.jobs.get_job_task_table_data";
|
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_INSTALL_PROJECTS_METHOD = "custom_ui.api.db.jobs.get_install_projects";
|
const FRAPPE_GET_INSTALL_PROJECTS_METHOD = "custom_ui.api.db.jobs.get_install_projects";
|
||||||
|
const FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD = "custom_ui.api.db.jobs.get_projects_for_calendar";
|
||||||
const FRAPPE_GET_JOB_TEMPLATES_METHOD = "custom_ui.api.db.jobs.get_job_templates";
|
const FRAPPE_GET_JOB_TEMPLATES_METHOD = "custom_ui.api.db.jobs.get_job_templates";
|
||||||
|
const FRAPPE_UPDATE_JOB_SCHEDULED_DATES_METHOD = "custom_ui.api.db.jobs.update_job_scheduled_dates";
|
||||||
|
const FRAPPE_GET_JOBS_IN_QUEUE_METHOD = "custom_ui.api.db.jobs.get_jobs_in_queue_count";
|
||||||
|
const FRAPPE_GET_JOBS_IN_PROGRESS_METHOD = "custom_ui.api.db.jobs.get_jobs_in_progress_count";
|
||||||
|
const FRAPPE_GET_JOBS_LATE_METHOD = "custom_ui.api.db.jobs.get_jobs_late_count";
|
||||||
|
const FRAPPE_GET_JOBS_TO_INVOICE_METHOD = "custom_ui.api.db.jobs.get_jobs_to_invoice_count";
|
||||||
// Task methods
|
// Task methods
|
||||||
const FRAPPE_GET_TASKS_METHOD = "custom_ui.api.db.tasks.get_tasks_table_data";
|
const FRAPPE_GET_TASKS_METHOD = "custom_ui.api.db.tasks.get_tasks_table_data";
|
||||||
const FRAPPE_GET_TASKS_STATUS_OPTIONS = "custom_ui.api.db.tasks.get_task_status_options";
|
const FRAPPE_GET_TASKS_STATUS_OPTIONS = "custom_ui.api.db.tasks.get_task_status_options";
|
||||||
const FRAPPE_SET_TASK_STATUS_METHOD = "custom_ui.api.db.tasks.set_task_status";
|
const FRAPPE_SET_TASK_STATUS_METHOD = "custom_ui.api.db.tasks.set_task_status";
|
||||||
|
const FRAPPE_GET_TASKS_DUE_METHOD = "custom_ui.api.db.tasks.get_tasks_due";
|
||||||
// Invoice methods
|
// Invoice methods
|
||||||
const FRAPPE_GET_INVOICES_METHOD = "custom_ui.api.db.invoices.get_invoice_table_data";
|
const FRAPPE_GET_INVOICES_METHOD = "custom_ui.api.db.invoices.get_invoice_table_data";
|
||||||
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
|
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
|
||||||
|
const FRAPPE_GET_INVOICES_LATE_METHOD = "custom_ui.api.db.invoices.get_invoices_late_count";
|
||||||
|
const FRAPPE_CREATE_INVOICE_FOR_JOB = "custom_ui.api.db.invoices.create_invoice_for_job";
|
||||||
// Warranty methods
|
// Warranty methods
|
||||||
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
|
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
|
||||||
// On-Site Meeting methods
|
// On-Site Meeting methods
|
||||||
const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD =
|
const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD =
|
||||||
"custom_ui.api.db.bid_meetings.get_week_bid_meetings";
|
"custom_ui.api.db.bid_meetings.get_week_bid_meetings";
|
||||||
|
const FRAPPE_GET_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meeting_note_form";
|
||||||
const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meetings";
|
const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meetings";
|
||||||
|
const FRAPPE_SUBMIT_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.submit_bid_meeting_note_form";
|
||||||
// Address methods
|
// Address methods
|
||||||
const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
|
const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
|
||||||
// Client methods
|
// Client methods
|
||||||
const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client";
|
const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client";
|
||||||
const FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD = "custom_ui.api.db.clients.get_client_status_counts";
|
const FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD = "custom_ui.api.db.clients.get_client_status_counts";
|
||||||
const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data";
|
const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data";
|
||||||
|
const FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD = "custom_ui.api.db.clients.get_clients_table_data_v2";
|
||||||
const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client_v2";
|
const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client_v2";
|
||||||
const FRAPPE_GET_CLIENT_NAMES_METHOD = "custom_ui.api.db.clients.get_client_names";
|
const FRAPPE_GET_CLIENT_NAMES_METHOD = "custom_ui.api.db.clients.get_client_names";
|
||||||
|
// Employee methods
|
||||||
|
const FRAPPE_GET_EMPLOYEES_METHOD = "custom_ui.api.db.employees.get_employees";
|
||||||
|
const FRAPPE_GET_EMPLOYEES_ORGANIZED_METHOD = "custom_ui.api.db.employees.get_employees_organized";
|
||||||
|
// Other methods
|
||||||
|
const FRAPPE_GET_WEEK_HOLIDAYS_METHOD = "custom_ui.api.db.general.get_week_holidays";
|
||||||
|
const FRAPPE_GET_DOC_LIST_METHOD = "custom_ui.api.db.general.get_doc_list";
|
||||||
|
// Service Appointment methods
|
||||||
|
const FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD = "custom_ui.api.db.service_appointments.get_service_appointments";
|
||||||
|
const FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD = "custom_ui.api.db.service_appointments.update_service_appointment_scheduled_dates";
|
||||||
class Api {
|
class Api {
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CORE REQUEST METHOD
|
// CORE REQUEST METHOPD
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
static async request(frappeMethod, args = {}) {
|
static async request(frappeMethod, args = {}) {
|
||||||
|
|
@ -85,6 +111,17 @@ class Api {
|
||||||
return await this.request(FRAPPE_GET_CLIENT_METHOD, { clientName });
|
return await this.request(FRAPPE_GET_CLIENT_METHOD, { clientName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getPaginatedClientDetailsV2(paginationParams = {}, filters = {}, sortings = []) {
|
||||||
|
const { page = 0, pageSize = 10 } = paginationParams;
|
||||||
|
const result = await this.request(FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD, {
|
||||||
|
filters,
|
||||||
|
sortings,
|
||||||
|
page: page + 1,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get paginated client data with filtering and sorting
|
* Get paginated client data with filtering and sorting
|
||||||
* @param {Object} paginationParams - Pagination parameters from store
|
* @param {Object} paginationParams - Pagination parameters from store
|
||||||
|
|
@ -136,9 +173,22 @@ class Api {
|
||||||
// ON-SITE MEETING METHODS
|
// ON-SITE MEETING METHODS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
static async getUnscheduledBidMeetings() {
|
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) {
|
||||||
return await this.request(
|
return await this.request(
|
||||||
"custom_ui.api.db.bid_meetings.get_unscheduled_bid_meetings",
|
"custom_ui.api.db.bid_meetings.get_unscheduled_bid_meetings",
|
||||||
|
{ company }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,8 +196,8 @@ class Api {
|
||||||
return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters });
|
return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters });
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getWeekBidMeetings(weekStart, weekEnd) {
|
static async getWeekBidMeetings(weekStart, weekEnd, company) {
|
||||||
return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd });
|
return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd, company });
|
||||||
}
|
}
|
||||||
|
|
||||||
static async updateBidMeeting(name, data) {
|
static async updateBidMeeting(name, data) {
|
||||||
|
|
@ -169,6 +219,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
|
// ESTIMATE / QUOTATION METHODS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -213,7 +269,12 @@ class Api {
|
||||||
|
|
||||||
console.log("DEBUG: API - Sending estimate options to backend:", page, pageSize, filters, sorting);
|
console.log("DEBUG: API - Sending estimate options to backend:", page, pageSize, filters, sorting);
|
||||||
|
|
||||||
const result = await this.request(FRAPPE_GET_ESTIMATES_METHOD, { 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 });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,6 +303,14 @@ class Api {
|
||||||
return await this.request(FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD, { data });
|
return await this.request(FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD, { data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getUnapprovedEstimatesCount(currentCompany) {
|
||||||
|
return await this.request(FRAPPE_GET_UNAPPROVED_ESTIMATES_COUNT_METHOD, {company: currentCompany});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getEstimatesHalfDownCount(currentCompany) {
|
||||||
|
return await this.request(FRAPPE_GET_ESTIMATES_HALF_DOWN_COUNT_METHOD, {company: currentCompany});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// JOB / PROJECT METHODS
|
// JOB / PROJECT METHODS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -298,6 +367,39 @@ class Api {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getJobsForCalendar(startDate, endDate, company = null, projectTemplates = []) {
|
||||||
|
return await this.request(FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD, { startDate, endDate, company, projectTemplates });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateJobScheduledDates(jobName, newStartDate, newEndDate, foremanName) {
|
||||||
|
return await this.request(FRAPPE_UPDATE_JOB_SCHEDULED_DATES_METHOD, {
|
||||||
|
jobName,
|
||||||
|
newStartDate,
|
||||||
|
newEndDate,
|
||||||
|
foremanName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getJobsInQueueCount(currentCompany) {
|
||||||
|
return await this.request(FRAPPE_GET_JOBS_IN_QUEUE_METHOD, {company: currentCompany});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getJobsInProgressCount(currentCompany) {
|
||||||
|
return await this.request(FRAPPE_GET_JOBS_IN_PROGRESS_METHOD, {company: currentCompany});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getJobsLateCount(currentCompany) {
|
||||||
|
return await this.request(FRAPPE_GET_JOBS_LATE_METHOD, {company: currentCompany});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getJobsToInvoiceCount(currentCompany) {
|
||||||
|
return await this.request(FRAPPE_GET_JOBS_TO_INVOICE_METHOD, {company: currentCompany});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async setJobCompleted(jobName) {
|
||||||
|
return await this.request(FRAPPE_SET_JOB_COMPLETE_METHOD, {jobName: jobName});
|
||||||
|
}
|
||||||
|
|
||||||
static async getJob(jobName) {
|
static async getJob(jobName) {
|
||||||
if (frappe.db.exists("Project", jobName)) {
|
if (frappe.db.exists("Project", jobName)) {
|
||||||
const result = await this.request(FRAPPE_GET_JOB_METHOD, { jobId: jobName })
|
const result = await this.request(FRAPPE_GET_JOB_METHOD, { jobId: jobName })
|
||||||
|
|
@ -344,10 +446,29 @@ class Api {
|
||||||
|
|
||||||
console.log("DEBUG: API - Sending job task options to backend:", options);
|
console.log("DEBUG: API - Sending job task options to backend:", options);
|
||||||
|
|
||||||
const result = await this.request(FRAPPE_GET_JOB_TASK_LIST_METHOD, { options, filters });
|
const result = await this.request(FRAPPE_GET_JOB_TASK_TABLE_DATA_METHOD, { filters, sortings: sorting, page:page+1, pageSize });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE APPOINTMENT METHODS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static async getServiceAppointments(companies = [], filters = {}) {
|
||||||
|
return await this.request(FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD, { companies, filters });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateServiceAppointmentScheduledDates(serviceAppointmentName, startDate, endDate, crewLeadName, startTime = null, endTime = null) {
|
||||||
|
return await this.request(FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD, {
|
||||||
|
serviceAppointmentName,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
crewLeadName,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TASK METHODS
|
// TASK METHODS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -401,6 +522,16 @@ class Api {
|
||||||
return await this.request(FRAPPE_SET_TASK_STATUS_METHOD, { taskName, newStatus });
|
return await this.request(FRAPPE_SET_TASK_STATUS_METHOD, { taskName, newStatus });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getTasksDue(subjectFilter, currentCompany) {
|
||||||
|
const result = await this.request(FRAPPE_GET_TASKS_DUE_METHOD, {subjectFilter, currentCompany});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getTasksCompleted(subjectFilter) {
|
||||||
|
const result = await this.request(FRAPPE_GET_TASKS_COMPLETED_METHOD, {subjectFilter});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// INVOICE / PAYMENT METHODS
|
// INVOICE / PAYMENT METHODS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -437,6 +568,16 @@ class Api {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getInvoicesLateCount(currentCompany) {
|
||||||
|
const result = await this.request(FRAPPE_GET_INVOICES_LATE_METHOD, { company: currentCompany });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createInvoiceForJob(jobName) {
|
||||||
|
const result = await this.request(FRAPPE_CREATE_INVOICE_FOR_JOB, { jobName: jobName });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WARRANTY METHODS
|
// WARRANTY METHODS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -558,6 +699,26 @@ class Api {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EMPLOYEE METHODS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static async getEmployees(company, roles = []) {
|
||||||
|
return await this.request(FRAPPE_GET_EMPLOYEES_METHOD, { company, roles });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getEmployeesOrganized (company, roles = []) {
|
||||||
|
return await this.request(FRAPPE_GET_EMPLOYEES_ORGANIZED_METHOD, { company, roles });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OTHER METHODS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static async getWeekHolidays(startDate) {
|
||||||
|
return await this.request(FRAPPE_GET_WEEK_HOLIDAYS_METHOD, { weekStartDate: startDate });
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// GENERIC DOCTYPE METHODS
|
// GENERIC DOCTYPE METHODS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -572,20 +733,21 @@ class Api {
|
||||||
*/
|
*/
|
||||||
static async getDocsList(
|
static async getDocsList(
|
||||||
doctype,
|
doctype,
|
||||||
fields = [],
|
fields = ["*"],
|
||||||
filters = {},
|
filters = {},
|
||||||
page = 0,
|
pluck = null,
|
||||||
start = 0,
|
|
||||||
pageLength = 0,
|
|
||||||
) {
|
) {
|
||||||
const docs = await frappe.db.get_list(doctype, {
|
const docs = await this.request(
|
||||||
fields,
|
FRAPPE_GET_DOC_LIST_METHOD,
|
||||||
filters,
|
{
|
||||||
start: start,
|
doctype,
|
||||||
limit: pageLength,
|
fields,
|
||||||
});
|
filters,
|
||||||
|
pluck,
|
||||||
|
}
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`DEBUG: API - Fetched ${doctype} list (page ${page + 1}, start ${start}): `,
|
`DEBUG: API - Fetched ${doctype} list: `,
|
||||||
docs,
|
docs,
|
||||||
);
|
);
|
||||||
return docs;
|
return docs;
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,24 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="calendar-navigation">
|
<div class="calendar-navigation">
|
||||||
<Tabs value="0">
|
<Tabs value="0" v-if="companyStore.currentCompany == 'Sprinklers Northwest'">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="0">Bids</Tab>
|
<Tab value="0">Bids</Tab>
|
||||||
<Tab value="1">Install</Tab>
|
<Tab value="1">Projects</Tab>
|
||||||
<Tab value="2">Service</Tab>
|
<Tab value="2">Service</Tab>
|
||||||
<Tab value="3">Lowe Fencing</Tab>
|
|
||||||
<Tab value="4">Daniel's Landscaping</Tab>
|
|
||||||
<Tab value="5">Nuco Yardcare</Tab>
|
|
||||||
<Tab value="6">Warranties</Tab>
|
<Tab value="6">Warranties</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel header="Bids" value="0">
|
<TabPanel header="Bids" value="0">
|
||||||
<ScheduleBid />
|
<ScheduleBid />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel header="Install" value="1">
|
<TabPanel header="Projects" value="1">
|
||||||
<InstallsCalendar />
|
<SNWProjectCalendar />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel header="Service" value="2">
|
<TabPanel header="Service" value="2">
|
||||||
<div class="coming-soon">
|
<div class="coming-soon">
|
||||||
<p>Service feature coming soon!</p>
|
<p>Service feature coming soon!</p>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel header="Lowe Fencing" value="3">
|
|
||||||
<div class="coming-soon">
|
|
||||||
<p>Lowe Fencing calendar coming soon!</p>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel header="Daniel's Landscaping" value="4">
|
|
||||||
<div class="coming-soon">
|
|
||||||
<p>Daniel's Calendar coming soon!</p>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel header="Nuco Yardcare" value="5">
|
|
||||||
<div class="coming-soon">
|
|
||||||
<p>Nuco calendar coming soon!</p>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel header="Warranties" value="6">
|
<TabPanel header="Warranties" value="6">
|
||||||
<div class="coming-soon">
|
<div class="coming-soon">
|
||||||
<p>Warranties Calendar coming soon!</p>
|
<p>Warranties Calendar coming soon!</p>
|
||||||
|
|
@ -44,6 +26,37 @@
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
<Tabs v-else value="0">
|
||||||
|
<TabList>
|
||||||
|
<Tab value="0">Bids</Tab>
|
||||||
|
<Tab value="1">Projects</Tab>
|
||||||
|
<Tab value="2">Service</Tab>
|
||||||
|
<Tab value="3">Warranties</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel header="Bids" value="0">
|
||||||
|
<ScheduleBid />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel header="Projects" value="1">
|
||||||
|
<div class="coming-soon">
|
||||||
|
<p>Calendar Coming Soon!</p>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel header="Service" value="2">
|
||||||
|
<div class="coming-soon">
|
||||||
|
<p>Calendar Coming Soon!</p>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel header="Service" value="2">
|
||||||
|
<div class="coming-soon">
|
||||||
|
<p>Calendar Coming Soon!</p>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel header="Warranties" value="3">
|
||||||
|
<p>Calendar Coming Soon!</p>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -56,15 +69,35 @@ import TabPanel from 'primevue/tabpanel';
|
||||||
import TabPanels from 'primevue/tabpanels';
|
import TabPanels from 'primevue/tabpanels';
|
||||||
import ScheduleBid from '../calendar/bids/ScheduleBid.vue';
|
import ScheduleBid from '../calendar/bids/ScheduleBid.vue';
|
||||||
import JobsCalendar from '../calendar/jobs/JobsCalendar.vue';
|
import JobsCalendar from '../calendar/jobs/JobsCalendar.vue';
|
||||||
import InstallsCalendar from '../calendar/jobs/InstallsCalendar.vue';
|
import SNWProjectCalendar from './jobs/SNWProjectCalendar.vue';
|
||||||
import { useNotificationStore } from '../../stores/notifications-primevue';
|
import { useNotificationStore } from '../../stores/notifications-primevue';
|
||||||
|
import { useCompanyStore } from '../../stores/company';
|
||||||
|
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.calendar-navigation {
|
.calendar-navigation {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-navigation :deep(.p-tabs) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-navigation :deep(.p-tabpanels) {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-navigation :deep(.p-tabpanel) {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coming-soon {
|
.coming-soon {
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,8 @@
|
||||||
)"
|
)"
|
||||||
:key="meeting.id"
|
:key="meeting.id"
|
||||||
class="meeting-event"
|
class="meeting-event"
|
||||||
:class="getMeetingColorClass(meeting)"
|
:class="[getMeetingColorClass(meeting), { 'meeting-completed-locked': meeting.status === 'Completed' }]"
|
||||||
draggable="true"
|
:draggable="meeting.status !== 'Completed'"
|
||||||
@dragstart="handleMeetingDragStart($event, meeting)"
|
@dragstart="handleMeetingDragStart($event, meeting)"
|
||||||
@dragend="handleDragEnd($event)"
|
@dragend="handleDragEnd($event)"
|
||||||
@click.stop="showMeetingDetails(meeting)"
|
@click.stop="showMeetingDetails(meeting)"
|
||||||
|
|
@ -206,6 +206,7 @@
|
||||||
:meeting="selectedMeeting"
|
:meeting="selectedMeeting"
|
||||||
@close="closeMeetingModal"
|
@close="closeMeetingModal"
|
||||||
@meeting-updated="handleMeetingUpdated"
|
@meeting-updated="handleMeetingUpdated"
|
||||||
|
@complete-meeting="openNoteForm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- New Meeting Modal -->
|
<!-- New Meeting Modal -->
|
||||||
|
|
@ -216,6 +217,17 @@
|
||||||
@confirm="handleNewMeetingConfirm"
|
@confirm="handleNewMeetingConfirm"
|
||||||
@cancel="handleNewMeetingCancel"
|
@cancel="handleNewMeetingCancel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Bid Meeting Note Form Modal -->
|
||||||
|
<BidMeetingNoteForm
|
||||||
|
v-if="selectedMeetingForNotes"
|
||||||
|
:visible="showNoteFormModal"
|
||||||
|
@update:visible="showNoteFormModal = $event"
|
||||||
|
:bid-meeting-name="selectedMeetingForNotes.name"
|
||||||
|
:project-template="selectedMeetingForNotes.projectTemplate"
|
||||||
|
@submit="handleNoteFormSubmit"
|
||||||
|
@cancel="handleNoteFormCancel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -224,14 +236,17 @@ import { ref, computed, onMounted, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import BidMeetingModal from "../../modals/BidMeetingModal.vue";
|
import BidMeetingModal from "../../modals/BidMeetingModal.vue";
|
||||||
import MeetingDetailsModal from "../../modals/MeetingDetailsModal.vue";
|
import MeetingDetailsModal from "../../modals/MeetingDetailsModal.vue";
|
||||||
|
import BidMeetingNoteForm from "../../modals/BidMeetingNoteForm.vue";
|
||||||
import { useLoadingStore } from "../../../stores/loading";
|
import { useLoadingStore } from "../../../stores/loading";
|
||||||
import { useNotificationStore } from "../../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../../stores/notifications-primevue";
|
||||||
|
import { useCompanyStore } from "../../../stores/company";
|
||||||
import Api from "../../../api";
|
import Api from "../../../api";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
|
|
||||||
// Query parameters
|
// Query parameters
|
||||||
const isNewMode = computed(() => route.query.new === "true");
|
const isNewMode = computed(() => route.query.new === "true");
|
||||||
|
|
@ -249,6 +264,8 @@ const unscheduledMeetings = ref([]);
|
||||||
const selectedMeeting = ref(null);
|
const selectedMeeting = ref(null);
|
||||||
const showMeetingModal = ref(false);
|
const showMeetingModal = ref(false);
|
||||||
const showNewMeetingModal = ref(false);
|
const showNewMeetingModal = ref(false);
|
||||||
|
const showNoteFormModal = ref(false);
|
||||||
|
const selectedMeetingForNotes = ref(null);
|
||||||
|
|
||||||
// Drag and drop state
|
// Drag and drop state
|
||||||
const isDragOver = ref(false);
|
const isDragOver = ref(false);
|
||||||
|
|
@ -474,6 +491,63 @@ const handleMeetingUpdated = async () => {
|
||||||
await loadUnscheduledMeetings();
|
await loadUnscheduledMeetings();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openNoteForm = (meeting) => {
|
||||||
|
// Verify meeting has required data
|
||||||
|
if (!meeting || !meeting.name) {
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Error",
|
||||||
|
message: "Meeting information is incomplete",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meeting.projectTemplate) {
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Missing Project Template",
|
||||||
|
message: "This meeting does not have a project template assigned. Cannot open note form.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedMeetingForNotes.value = meeting;
|
||||||
|
showNoteFormModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNoteFormSubmit = async () => {
|
||||||
|
// After successful submission, mark the meeting as completed
|
||||||
|
try {
|
||||||
|
loadingStore.setLoading(true);
|
||||||
|
await Api.updateBidMeeting(selectedMeetingForNotes.value.name, {
|
||||||
|
status: "Completed",
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
message: "Meeting marked as completed",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload meetings
|
||||||
|
await handleMeetingUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating meeting status:", error);
|
||||||
|
} finally {
|
||||||
|
loadingStore.setLoading(false);
|
||||||
|
showNoteFormModal.value = false;
|
||||||
|
selectedMeetingForNotes.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNoteFormCancel = () => {
|
||||||
|
showNoteFormModal.value = false;
|
||||||
|
selectedMeetingForNotes.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
const openNewMeetingModal = () => {
|
const openNewMeetingModal = () => {
|
||||||
showNewMeetingModal.value = true;
|
showNewMeetingModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
@ -489,7 +563,16 @@ const handleNewMeetingConfirm = async (meetingData) => {
|
||||||
|
|
||||||
showNewMeetingModal.value = false;
|
showNewMeetingModal.value = false;
|
||||||
|
|
||||||
// Reload unscheduled meetings to show the new one
|
// 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
|
||||||
await loadUnscheduledMeetings();
|
await loadUnscheduledMeetings();
|
||||||
|
|
||||||
notificationStore.addNotification({
|
notificationStore.addNotification({
|
||||||
|
|
@ -534,6 +617,7 @@ const handleDragStart = (event, meeting = null) => {
|
||||||
notes: meeting.notes || "",
|
notes: meeting.notes || "",
|
||||||
assigned_employee: meeting.assigned_employee || "",
|
assigned_employee: meeting.assigned_employee || "",
|
||||||
status: meeting.status,
|
status: meeting.status,
|
||||||
|
projectTemplate: meeting.projectTemplate,
|
||||||
};
|
};
|
||||||
} else if (!draggedMeeting.value) {
|
} else if (!draggedMeeting.value) {
|
||||||
// If no meeting data is set, use query address
|
// If no meeting data is set, use query address
|
||||||
|
|
@ -549,6 +633,12 @@ const handleDragStart = (event, meeting = null) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMeetingDragStart = (event, meeting) => {
|
const handleMeetingDragStart = (event, meeting) => {
|
||||||
|
// Prevent dragging completed meetings
|
||||||
|
if (meeting.status === 'Completed') {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle dragging a scheduled meeting
|
// Handle dragging a scheduled meeting
|
||||||
draggedMeeting.value = {
|
draggedMeeting.value = {
|
||||||
id: meeting.name,
|
id: meeting.name,
|
||||||
|
|
@ -557,6 +647,7 @@ const handleMeetingDragStart = (event, meeting) => {
|
||||||
assigned_employee: meeting.assigned_employee || "",
|
assigned_employee: meeting.assigned_employee || "",
|
||||||
status: meeting.status,
|
status: meeting.status,
|
||||||
isRescheduling: true, // Flag to indicate this is a reschedule
|
isRescheduling: true, // Flag to indicate this is a reschedule
|
||||||
|
projectTemplate: meeting.projectTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store the original meeting data in case drag is cancelled
|
// Store the original meeting data in case drag is cancelled
|
||||||
|
|
@ -667,6 +758,7 @@ const handleDrop = async (event, date, time) => {
|
||||||
notes: droppedMeeting.notes || "",
|
notes: droppedMeeting.notes || "",
|
||||||
assigned_employee: droppedMeeting.assigned_employee || "",
|
assigned_employee: droppedMeeting.assigned_employee || "",
|
||||||
status: "Scheduled",
|
status: "Scheduled",
|
||||||
|
projectTemplate: droppedMeeting.projectTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If this is an existing meeting, update it in the backend
|
// If this is an existing meeting, update it in the backend
|
||||||
|
|
@ -790,6 +882,7 @@ const handleDropToUnscheduled = async (event) => {
|
||||||
notes: droppedMeeting.notes || "",
|
notes: droppedMeeting.notes || "",
|
||||||
status: "Unscheduled",
|
status: "Unscheduled",
|
||||||
assigned_employee: droppedMeeting.assigned_employee || "",
|
assigned_employee: droppedMeeting.assigned_employee || "",
|
||||||
|
projectTemplate: droppedMeeting.projectTemplate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -816,7 +909,7 @@ const handleDropToUnscheduled = async (event) => {
|
||||||
const loadUnscheduledMeetings = async () => {
|
const loadUnscheduledMeetings = async () => {
|
||||||
loadingStore.setLoading(true);
|
loadingStore.setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await Api.getUnscheduledBidMeetings();
|
const result = await Api.getUnscheduledBidMeetings(companyStore.currentCompany);
|
||||||
// Ensure we always have an array
|
// Ensure we always have an array
|
||||||
unscheduledMeetings.value = Array.isArray(result) ? result : [];
|
unscheduledMeetings.value = Array.isArray(result) ? result : [];
|
||||||
console.log("Loaded unscheduled meetings:", unscheduledMeetings.value);
|
console.log("Loaded unscheduled meetings:", unscheduledMeetings.value);
|
||||||
|
|
@ -865,7 +958,7 @@ const loadWeekMeetings = async () => {
|
||||||
|
|
||||||
// Try to get meetings from API
|
// Try to get meetings from API
|
||||||
try {
|
try {
|
||||||
const apiResult = await Api.getWeekBidMeetings(weekStartStr, weekEndStr);
|
const apiResult = await Api.getWeekBidMeetings(weekStartStr, weekEndStr, companyStore.currentCompany);
|
||||||
if (Array.isArray(apiResult)) {
|
if (Array.isArray(apiResult)) {
|
||||||
// Transform the API data to match what the calendar expects
|
// Transform the API data to match what the calendar expects
|
||||||
meetings.value = apiResult
|
meetings.value = apiResult
|
||||||
|
|
@ -1085,6 +1178,15 @@ watch(currentWeekStart, () => {
|
||||||
loadWeekMeetings();
|
loadWeekMeetings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch for company changes
|
||||||
|
watch(
|
||||||
|
() => companyStore.currentCompany,
|
||||||
|
async () => {
|
||||||
|
await loadWeekMeetings();
|
||||||
|
await loadUnscheduledMeetings();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.new,
|
() => route.query.new,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
|
|
@ -1098,9 +1200,10 @@ watch(
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.schedule-bid-container {
|
.schedule-bid-container {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|
@ -1110,6 +1213,7 @@ watch(
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
|
|
@ -1150,9 +1254,9 @@ watch(
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
max-height: calc(100vh - 150px);
|
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
|
|
@ -1182,11 +1286,13 @@ watch(
|
||||||
.sidebar-header h4 {
|
.sidebar-header h4 {
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unscheduled-meetings-list {
|
.unscheduled-meetings-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1461,11 +1567,24 @@ watch(
|
||||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meeting-event.meeting-completed-locked {
|
||||||
|
cursor: default !important;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event.meeting-completed-locked:active {
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
||||||
|
|
||||||
.meeting-event:hover {
|
.meeting-event:hover {
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meeting-event.meeting-completed-locked:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.event-time {
|
.event-time {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
1850
frontend/src/components/calendar/jobs/SNWProjectCalendar.vue
Normal file
1850
frontend/src/components/calendar/jobs/SNWProjectCalendar.vue
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -59,6 +59,15 @@
|
||||||
style="margin-top: 0"
|
style="margin-top: 0"
|
||||||
/>
|
/>
|
||||||
<label :for="`isBilling-${index}`"><i class="pi pi-dollar" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Billing Address</label>
|
<label :for="`isBilling-${index}`"><i class="pi pi-dollar" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Billing Address</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:id="`isService-${index}`"
|
||||||
|
v-model="address.isServiceAddress"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@change="handleServiceChange(index)"
|
||||||
|
style="margin-top: 0; margin-left: 1.5rem;"
|
||||||
|
/>
|
||||||
|
<label :for="`isService-${index}`"><i class="pi pi-truck" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Service Address</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
|
|
@ -120,7 +129,7 @@
|
||||||
<Select
|
<Select
|
||||||
:id="`primaryContact-${index}`"
|
:id="`primaryContact-${index}`"
|
||||||
v-model="address.primaryContact"
|
v-model="address.primaryContact"
|
||||||
:options="contactOptions"
|
:options="address.contacts.map(c => contactOptions.find(opt => opt.value === c))"
|
||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
:disabled="isSubmitting || contactOptions.length === 0"
|
:disabled="isSubmitting || contactOptions.length === 0"
|
||||||
|
|
@ -174,6 +183,7 @@ const localFormData = computed({
|
||||||
addressLine1: "",
|
addressLine1: "",
|
||||||
addressLine2: "",
|
addressLine2: "",
|
||||||
isBillingAddress: true,
|
isBillingAddress: true,
|
||||||
|
isServiceAddress: true,
|
||||||
pincode: "",
|
pincode: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
|
|
@ -205,6 +215,7 @@ onMounted(() => {
|
||||||
addressLine1: "",
|
addressLine1: "",
|
||||||
addressLine2: "",
|
addressLine2: "",
|
||||||
isBillingAddress: true,
|
isBillingAddress: true,
|
||||||
|
isServiceAddress: true,
|
||||||
pincode: "",
|
pincode: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
|
|
@ -221,6 +232,7 @@ const addAddress = () => {
|
||||||
addressLine1: "",
|
addressLine1: "",
|
||||||
addressLine2: "",
|
addressLine2: "",
|
||||||
isBillingAddress: false,
|
isBillingAddress: false,
|
||||||
|
isServiceAddress: true,
|
||||||
pincode: "",
|
pincode: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
|
|
@ -259,6 +271,7 @@ const handleBillingChange = (selectedIndex) => {
|
||||||
localFormData.value.addresses.forEach((addr, idx) => {
|
localFormData.value.addresses.forEach((addr, idx) => {
|
||||||
if (idx !== selectedIndex) {
|
if (idx !== selectedIndex) {
|
||||||
addr.isBillingAddress = false;
|
addr.isBillingAddress = false;
|
||||||
|
addr.isServiceAddress = true; // Ensure service address is true for others
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -279,6 +292,21 @@ const handleBillingChange = (selectedIndex) => {
|
||||||
localFormData.value.addresses[selectedIndex].primaryContact = 0;
|
localFormData.value.addresses[selectedIndex].primaryContact = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
localFormData.value.addresses[selectedIndex].isBillingAddress = true;
|
||||||
|
notificationStore.addInfo("At least one of Billing Address must be selected.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServiceChange = (selectedIndex) => {
|
||||||
|
// If the address does not have billing address selected, ensure that service address is always true
|
||||||
|
if (!localFormData.value.addresses[selectedIndex].isBillingAddress) {
|
||||||
|
localFormData.value.addresses[selectedIndex].isServiceAddress = true;
|
||||||
|
notificationStore.addInfo("Service Address must be selected if not a Billing Address.");
|
||||||
|
}
|
||||||
|
if (!localFormData.value.addresses.some(addr => addr.isServiceAddress)) {
|
||||||
|
localFormData.value.addresses[selectedIndex].isServiceAddress = true;
|
||||||
|
notificationStore.addInfo("At least one Service Address must be selected.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
385
frontend/src/components/clientView/AddressSelector.vue
Normal file
385
frontend/src/components/clientView/AddressSelector.vue
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
<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>
|
||||||
335
frontend/src/components/clientView/GeneralClientInfo.vue
Normal file
335
frontend/src/components/clientView/GeneralClientInfo.vue
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
<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)"
|
:class="getStatusClass(onsiteMeetingStatus)"
|
||||||
@click="handleBidMeetingClick"
|
@click="handleBidMeetingClick"
|
||||||
>
|
>
|
||||||
<span class="status-label">Meeting</span>
|
<span class="status-label">Bid Meeting</span>
|
||||||
<span class="status-badge">{{ onsiteMeetingStatus }}</span>
|
<span class="status-badge">{{ onsiteMeetingStatus }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
:class="getStatusClass(estimateSentStatus)"
|
:class="getStatusClass(estimateSentStatus)"
|
||||||
@click="handleEstimateClick"
|
@click="handleEstimateClick"
|
||||||
>
|
>
|
||||||
<span class="status-label">Estimate</span>
|
<span class="status-label">Estimate Sent</span>
|
||||||
<span class="status-badge">{{ estimateSentStatus }}</span>
|
<span class="status-badge">{{ estimateSentStatus }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -109,8 +109,10 @@ const handleBidMeetingClick = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEstimateClick = () => {
|
const handleEstimateClick = () => {
|
||||||
if (props.estimateSentStatus === "Not Started") {
|
if ((props.estimateSentStatus === "Not Started") && props.onsiteMeetingStatus != "Completed") {
|
||||||
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&template=SNW%20Install`);
|
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)}`);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`);
|
router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
frontend/src/components/clientView/Overview.vue
Normal file
132
frontend/src/components/clientView/Overview.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<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>
|
||||||
622
frontend/src/components/clientView/PropertyDetails.vue
Normal file
622
frontend/src/components/clientView/PropertyDetails.vue
Normal file
|
|
@ -0,0 +1,622 @@
|
||||||
|
<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>
|
||||||
118
frontend/src/components/clientView/QuickActions.vue
Normal file
118
frontend/src/components/clientView/QuickActions.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<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>
|
||||||
89
frontend/src/components/clientView/SpecialModules.vue
Normal file
89
frontend/src/components/clientView/SpecialModules.vue
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<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,7 +529,6 @@ const setMenuRef = (el, label, id) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
const refName = `${label}-${id}`;
|
const refName = `${label}-${id}`;
|
||||||
menuRefs[refName] = el;
|
menuRefs[refName] = el;
|
||||||
console.log("Setting Menu Ref:", refName, el);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -784,7 +783,9 @@ const hasExactlyOneRowSelected = computed(() => {
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const currentFilters = filtersStore.getTableFilters(props.tableName);
|
const currentFilters = filtersStore.getTableFilters(props.tableName);
|
||||||
filterableColumns.value.forEach((col) => {
|
filterableColumns.value.forEach((col) => {
|
||||||
pendingFilters.value[col.fieldName] = currentFilters[col.fieldName]?.value || "";
|
// 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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1074,12 +1075,9 @@ const handleActionClick = (action, rowData = null) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMenu = (event, action, rowData) => {
|
const toggleMenu = (event, action, rowData) => {
|
||||||
console.log("Menu button toggled");
|
|
||||||
const menuKey = `${action.label}-${rowData.id}`;
|
const menuKey = `${action.label}-${rowData.id}`;
|
||||||
console.log("Looking for menu:", menuKey, menuRefs);
|
|
||||||
const menu = menuRefs[menuKey];
|
const menu = menuRefs[menuKey];
|
||||||
if (menu) {
|
if (menu) {
|
||||||
console.log("Found menu, toggling:", menu);
|
|
||||||
menu.toggle(event);
|
menu.toggle(event);
|
||||||
activeMenuKey.value = menuKey;
|
activeMenuKey.value = menuKey;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1088,11 +1086,9 @@ const toggleMenu = (event, action, rowData) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildMenuItems = (menuItems, rowData) => {
|
const buildMenuItems = (menuItems, rowData) => {
|
||||||
console.log("DEBUG: Building menuItems:", menuItems);
|
|
||||||
return menuItems.map(item => ({
|
return menuItems.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
command: () => {
|
command: () => {
|
||||||
console.log("Clicked from Datatable");
|
|
||||||
if (typeof item.command === 'function') {
|
if (typeof item.command === 'function') {
|
||||||
item.command(rowData);
|
item.command(rowData);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="showOverlay"
|
v-if="showOverlay"
|
||||||
class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-[9999] transition-opacity duration-200"
|
class="global-loading-overlay"
|
||||||
:class="{ 'opacity-100': showOverlay, 'opacity-0 pointer-events-none': !showOverlay }"
|
|
||||||
>
|
>
|
||||||
<div class="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full mx-4 text-center">
|
<div class="loading-content">
|
||||||
<div class="mb-4">
|
<div class="spinner-container">
|
||||||
<i class="pi pi-spin pi-spinner text-4xl text-blue-500"></i>
|
<i class="pi pi-spin pi-spinner"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">Loading</h3>
|
<h3 class="loading-title">Loading</h3>
|
||||||
<p class="text-gray-600">{{ loadingMessage }}</p>
|
<p class="loading-message">{{ loadingMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -46,15 +45,51 @@ const loadingMessage = computed(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Additional styling for better visual appearance */
|
.global-loading-overlay {
|
||||||
.bg-opacity-30 {
|
position: fixed;
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Backdrop blur effect for modern browsers */
|
.loading-content {
|
||||||
@supports (backdrop-filter: blur(4px)) {
|
background: white;
|
||||||
.fixed.inset-0 {
|
border-radius: 12px;
|
||||||
backdrop-filter: blur(4px);
|
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;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ const props = defineProps({
|
||||||
const mapElement = ref(null);
|
const mapElement = ref(null);
|
||||||
let map = null;
|
let map = null;
|
||||||
let marker = null;
|
let marker = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
|
||||||
const initializeMap = async () => {
|
const initializeMap = async () => {
|
||||||
if (!mapElement.value) return;
|
if (!mapElement.value) return;
|
||||||
|
|
@ -75,8 +76,12 @@ const initializeMap = async () => {
|
||||||
|
|
||||||
// Only create map if we have valid coordinates
|
// Only create map if we have valid coordinates
|
||||||
if (!isNaN(lat) && !isNaN(lng)) {
|
if (!isNaN(lat) && !isNaN(lng)) {
|
||||||
|
// Wait for next tick to ensure DOM is updated
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
|
// Additional delay to ensure container has proper dimensions
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
map = L.map(mapElement.value, {
|
map = L.map(mapElement.value, {
|
||||||
zoomControl: props.interactive,
|
zoomControl: props.interactive,
|
||||||
|
|
@ -106,6 +111,28 @@ const initializeMap = async () => {
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.openPopup();
|
.openPopup();
|
||||||
|
|
||||||
|
// Ensure map renders correctly - call invalidateSize multiple times
|
||||||
|
const invalidateMap = () => {
|
||||||
|
if (map) {
|
||||||
|
map.invalidateSize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(invalidateMap, 100);
|
||||||
|
setTimeout(invalidateMap, 300);
|
||||||
|
setTimeout(invalidateMap, 500);
|
||||||
|
|
||||||
|
// Set up resize observer to handle container size changes
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (map) {
|
||||||
|
map.invalidateSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(mapElement.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -117,6 +144,16 @@ const updateMap = () => {
|
||||||
// Update map view
|
// Update map view
|
||||||
map.setView([lat, lng], props.zoomLevel);
|
map.setView([lat, lng], props.zoomLevel);
|
||||||
|
|
||||||
|
// Ensure map renders correctly after view change
|
||||||
|
const invalidateMap = () => {
|
||||||
|
if (map) {
|
||||||
|
map.invalidateSize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(invalidateMap, 100);
|
||||||
|
setTimeout(invalidateMap, 300);
|
||||||
|
|
||||||
// Update marker
|
// Update marker
|
||||||
if (marker) {
|
if (marker) {
|
||||||
marker.setLatLng([lat, lng]);
|
marker.setLatLng([lat, lng]);
|
||||||
|
|
@ -164,6 +201,10 @@ onUnmounted(() => {
|
||||||
map = null;
|
map = null;
|
||||||
marker = null;
|
marker = null;
|
||||||
}
|
}
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -178,6 +219,7 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.map {
|
.map {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, nextTick, computed, onUnmounted} from "vue";
|
import { ref, onMounted, watch, nextTick, computed, onUnmounted, toRaw} from "vue";
|
||||||
import { Chart, registerables } from "chart.js";
|
import { Chart, registerables } from "chart.js";
|
||||||
|
|
||||||
// Register Chart.js components
|
// Register Chart.js components
|
||||||
|
|
@ -29,17 +29,13 @@ Chart.register(...registerables);
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: String,
|
title: String,
|
||||||
todoNumber: Number,
|
categories: Object,
|
||||||
completedNumber: Number,
|
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
//Constants
|
|
||||||
const categories = ["To-do", "Completed"];
|
|
||||||
|
|
||||||
//Reactive data
|
//Reactive data
|
||||||
const centerData = ref(null);
|
const centerData = ref(null);
|
||||||
const hoveredSegment = ref(null);
|
const hoveredSegment = ref(null);
|
||||||
|
|
@ -56,21 +52,19 @@ const getHoveredCategoryIndex = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCategoryValue = (categoryIndex) => {
|
const getCategoryValue = (categoryIndex) => {
|
||||||
if (categoryIndex === 0) {
|
return props.categories.data[categoryIndex];
|
||||||
return props.todoNumber
|
|
||||||
} else {
|
|
||||||
return props.completedNumber
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChartData = () => {
|
const getChartData = () => {
|
||||||
|
const categoryData = props.categories.data;
|
||||||
|
const categoryColors = props.categories.colors;
|
||||||
const chartData = {
|
const chartData = {
|
||||||
name: props.title,
|
name: props.title,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "",
|
label: "",
|
||||||
data: [props.todoNumber, props.completedNumber],
|
data: categoryData,
|
||||||
backgroundColor: ["#b22222", "#4caf50"]
|
backgroundColor: categoryColors
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
@ -79,8 +73,11 @@ const getChartData = () => {
|
||||||
|
|
||||||
|
|
||||||
const updateCenterData = () => {
|
const updateCenterData = () => {
|
||||||
const total = props.todoNumber + props.completedNumber;
|
let total = 0;
|
||||||
const todos = props.todoNumber;
|
for (let i=0; i<props.categories.data.length; i++) {
|
||||||
|
total += props.categories.data[i];
|
||||||
|
}
|
||||||
|
const todos = props.categories.data[0];
|
||||||
|
|
||||||
if (todos === 0 && total > 0) {
|
if (todos === 0 && total > 0) {
|
||||||
centerData.value = {
|
centerData.value = {
|
||||||
|
|
@ -107,14 +104,14 @@ const updateCenterData = () => {
|
||||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) + "%" : "0%";
|
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) + "%" : "0%";
|
||||||
|
|
||||||
centerData.value = {
|
centerData.value = {
|
||||||
label: categories[hoveredCategoryIndex],
|
label: props.categories.labels[hoveredCategoryIndex],
|
||||||
value: value,
|
value: value,
|
||||||
percentage: percentage,
|
percentage: percentage,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
centerData.value = {
|
centerData.value = {
|
||||||
label: "To-do",
|
label: props.categories.labels[0],
|
||||||
value: props.todoNumber,
|
value: todos,
|
||||||
percentage: null,
|
percentage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -180,8 +177,6 @@ const getChartOptions = () => {
|
||||||
const createChart = () => {
|
const createChart = () => {
|
||||||
if (!chartCanvas.value || props.loading) return;
|
if (!chartCanvas.value || props.loading) return;
|
||||||
|
|
||||||
console.log(`DEBUG: Creating chart for ${props.title}`);
|
|
||||||
console.log(props);
|
|
||||||
|
|
||||||
const ctx = chartCanvas.value.getContext("2d");
|
const ctx = chartCanvas.value.getContext("2d");
|
||||||
if (chartInstance.value) {
|
if (chartInstance.value) {
|
||||||
|
|
@ -214,9 +209,9 @@ onMounted(() => {
|
||||||
createChart();
|
createChart();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.completedNumber, (newValue) => {
|
watch(() => props.categories, (newValue) => {
|
||||||
updateChart();
|
updateChart();
|
||||||
});
|
}, {deep: true});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
978
frontend/src/components/modals/BidMeetingNoteForm.vue
Normal file
978
frontend/src/components/modals/BidMeetingNoteForm.vue
Normal file
|
|
@ -0,0 +1,978 @@
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:visible="showModal"
|
||||||
|
@update:visible="showModal = $event"
|
||||||
|
:options="modalOptions"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="modal-header">
|
||||||
|
<i class="pi pi-file-edit" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||||
|
{{ formTitle }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="loading-container">
|
||||||
|
<ProgressSpinner style="width: 50px; height: 50px" strokeWidth="4" />
|
||||||
|
<p>Loading form...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="formConfig" class="form-container">
|
||||||
|
<Button @click="debugLog" label="Debug" severity="secondary" size="small" class="debug-button" />
|
||||||
|
<template v-for="row in groupedFields" :key="`row-${row.rowIndex}`">
|
||||||
|
<div class="form-row">
|
||||||
|
<div
|
||||||
|
v-for="field in row.fields"
|
||||||
|
:key="field.name"
|
||||||
|
:class="`form-column-${Math.min(Math.max(field.columns || 12, 1), 12)}`"
|
||||||
|
>
|
||||||
|
<div class="form-field">
|
||||||
|
<!-- Field Label -->
|
||||||
|
<label :for="field.name" class="field-label">
|
||||||
|
{{ field.label }}
|
||||||
|
<span v-if="field.required" class="required-indicator">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Help Text -->
|
||||||
|
<small v-if="field.helpText" class="field-help-text">
|
||||||
|
{{ field.helpText }}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<!-- Data/Text Field -->
|
||||||
|
<template v-if="field.type === 'Data' || field.type === 'Text'">
|
||||||
|
<InputText
|
||||||
|
v-if="field.type === 'Data'"
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
:placeholder="field.label"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
v-else
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
:placeholder="field.label"
|
||||||
|
rows="3"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Check Field -->
|
||||||
|
<template v-else-if="field.type === 'Check'">
|
||||||
|
<div class="checkbox-container">
|
||||||
|
<Checkbox
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:binary="true"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
/>
|
||||||
|
<label :for="field.name" class="checkbox-label">
|
||||||
|
{{ formData[field.name].value ? 'Yes' : 'No' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Date Field -->
|
||||||
|
<template v-else-if="field.type === 'Date'">
|
||||||
|
<DatePicker
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
dateFormat="yy-mm-dd"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Datetime Field -->
|
||||||
|
<template v-else-if="field.type === 'Datetime'">
|
||||||
|
<DatePicker
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
showTime
|
||||||
|
hourFormat="12"
|
||||||
|
dateFormat="yy-mm-dd"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Time Field -->
|
||||||
|
<template v-else-if="field.type === 'Time'">
|
||||||
|
<DatePicker
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
timeOnly
|
||||||
|
hourFormat="12"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Number Field -->
|
||||||
|
<template v-else-if="field.type === 'Number'">
|
||||||
|
<InputNumber
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Select Field -->
|
||||||
|
<template v-else-if="field.type === 'Select'">
|
||||||
|
<div @click="console.log('Select wrapper clicked:', field.name, 'disabled:', field.readOnly || !isFieldVisible(field), 'options:', optionsForFields[field.name])">
|
||||||
|
<Select
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:options="optionsForFields[field.name]"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
:placeholder="`Select ${field.label}`"
|
||||||
|
:optionLabel="'label'"
|
||||||
|
:optionValue="'value'"
|
||||||
|
:editable="false"
|
||||||
|
:showClear="true"
|
||||||
|
:baseZIndex="10000"
|
||||||
|
@click.native="console.log('Select native click:', field.name)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Multi-Select Field -->
|
||||||
|
<template v-else-if="field.type === 'Multi-Select'">
|
||||||
|
<MultiSelect
|
||||||
|
:id="field.name"
|
||||||
|
v-model="formData[field.name].value"
|
||||||
|
:options="optionsForFields[field.name]"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
:placeholder="`Select ${field.label}`"
|
||||||
|
:key="`${field.name}-${field.readOnly || !isFieldVisible(field)}`"
|
||||||
|
:optionLabel="'label'"
|
||||||
|
:optionValue="'value'"
|
||||||
|
:showClear="true"
|
||||||
|
:baseZIndex="9999"
|
||||||
|
display="chip"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Multi-Select w/ Quantity Field -->
|
||||||
|
<template v-else-if="field.type === 'Multi-Select w/ Quantity'">
|
||||||
|
<div class="multi-select-quantity-container">
|
||||||
|
<!-- Item Selector -->
|
||||||
|
<div class="item-selector">
|
||||||
|
<Select
|
||||||
|
v-model="currentItemSelection[field.name]"
|
||||||
|
:options="optionsForFields[field.name]"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
:placeholder="`Add ${field.label}`"
|
||||||
|
:key="`${field.name}-${field.readOnly || !isFieldVisible(field)}`"
|
||||||
|
:optionLabel="'label'"
|
||||||
|
:showClear="true"
|
||||||
|
:baseZIndex="9999"
|
||||||
|
class="w-full"
|
||||||
|
@change="addItemToQuantityList(field)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Items with Quantities -->
|
||||||
|
<div v-if="formData[field.name].value && formData[field.name].value.length > 0" class="selected-items-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in formData[field.name].value"
|
||||||
|
:key="index"
|
||||||
|
class="quantity-item"
|
||||||
|
>
|
||||||
|
<div class="item-name">{{ getOptionLabel(field, item) }}</div>
|
||||||
|
<div class="quantity-controls">
|
||||||
|
<InputNumber
|
||||||
|
v-model="item.quantity"
|
||||||
|
:min="1"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
showButtons
|
||||||
|
buttonLayout="horizontal"
|
||||||
|
:step="1"
|
||||||
|
decrementButtonClass="p-button-secondary"
|
||||||
|
incrementButtonClass="p-button-secondary"
|
||||||
|
incrementButtonIcon="pi pi-plus"
|
||||||
|
decrementButtonIcon="pi pi-minus"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
@click="removeItemFromQuantityList(field, index)"
|
||||||
|
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="error-container">
|
||||||
|
<i class="pi pi-exclamation-triangle" style="font-size: 2rem; color: var(--red-500);"></i>
|
||||||
|
<p>Failed to load form configuration</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, reactive } from "vue";
|
||||||
|
import Modal from "../common/Modal.vue";
|
||||||
|
import InputText from "primevue/inputtext";
|
||||||
|
import Textarea from "primevue/textarea";
|
||||||
|
import Checkbox from "primevue/checkbox";
|
||||||
|
import DatePicker from "primevue/datepicker";
|
||||||
|
import InputNumber from "primevue/inputnumber";
|
||||||
|
import Select from "primevue/select";
|
||||||
|
import MultiSelect from "primevue/multiselect";
|
||||||
|
import Button from "primevue/button";
|
||||||
|
import ProgressSpinner from "primevue/progressspinner";
|
||||||
|
import Api from "../../api";
|
||||||
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
|
||||||
|
const docsForSelectFields = ref({});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
bidMeetingName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
projectTemplate: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:visible", "submit", "cancel"]);
|
||||||
|
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
|
const showModal = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value) => emit("update:visible", value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const formConfig = ref(null);
|
||||||
|
const formData = ref({}); // Will store fieldName: {fieldConfig, value}
|
||||||
|
const currentItemSelection = ref({}); // For tracking current selection in Multi-Select w/ Quantity
|
||||||
|
const doctypeOptions = ref({}); // Cache for doctype options
|
||||||
|
|
||||||
|
const formTitle = computed(() => {
|
||||||
|
return formConfig.value?.title || "Bid Meeting Notes";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include all fields from config plus a general notes field
|
||||||
|
const allFields = computed(() => {
|
||||||
|
if (!formConfig.value) return [];
|
||||||
|
|
||||||
|
const fields = [...(formConfig.value.fields || [])];
|
||||||
|
|
||||||
|
// Always add a general notes field at the end
|
||||||
|
const generalNotesField = {
|
||||||
|
name: 'general_notes',
|
||||||
|
label: 'General Notes',
|
||||||
|
type: 'Text',
|
||||||
|
required: 0,
|
||||||
|
readOnly: 0,
|
||||||
|
helpText: 'Any additional notes or observations from the meeting',
|
||||||
|
row: Math.max(...fields.map(f => f.row || 1), 0) + 1,
|
||||||
|
columns: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.push(generalNotesField);
|
||||||
|
return fields;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group fields by row for grid layout
|
||||||
|
const groupedFields = computed(() => {
|
||||||
|
const groups = {};
|
||||||
|
|
||||||
|
allFields.value.forEach(field => {
|
||||||
|
const rowNum = field.row || 1;
|
||||||
|
if (!groups[rowNum]) {
|
||||||
|
groups[rowNum] = { rowIndex: rowNum, fields: [] };
|
||||||
|
}
|
||||||
|
groups[rowNum].fields.push(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort fields by column and set columns span
|
||||||
|
Object.values(groups).forEach(group => {
|
||||||
|
group.fields.sort((a, b) => (a.column || 0) - (b.column || 0));
|
||||||
|
const numFields = group.fields.length;
|
||||||
|
const span = Math.floor(12 / numFields);
|
||||||
|
group.fields.forEach(field => {
|
||||||
|
field.columns = span;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(groups).sort((a, b) => a.rowIndex - b.rowIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update field value in reactive form data
|
||||||
|
const updateFieldValue = (fieldName, value) => {
|
||||||
|
if (formData.value[fieldName]) {
|
||||||
|
formData.value[fieldName].value = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get CSS class for field column span
|
||||||
|
const getFieldColumnClass = (field) => {
|
||||||
|
const columns = field.columns || 12; // Default to full width if not specified
|
||||||
|
return `form-column-${Math.min(Math.max(columns, 1), 12)}`; // Ensure between 1-12
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalOptions = computed(() => ({
|
||||||
|
maxWidth: "800px",
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: "Submit",
|
||||||
|
cancelButtonText: "Cancel",
|
||||||
|
confirmButtonColor: "primary",
|
||||||
|
zIndex: 1000, // Lower than select baseZIndex
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to find field name by label
|
||||||
|
const findFieldNameByLabel = (label) => {
|
||||||
|
if (!formConfig.value || !formConfig.value.fields) return null;
|
||||||
|
const field = formConfig.value.fields.find(f => f.label === label);
|
||||||
|
return field ? field.name : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDocsForSelectField = async (doctype, fieldName) => {
|
||||||
|
const docs = await Api.getDocsList(doctype);
|
||||||
|
docsForSelectFields[fieldName] = docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a field should be visible based on conditional logic
|
||||||
|
const isFieldVisible = (field) => {
|
||||||
|
if (!field.conditionalOnField) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the actual field name from the label (conditionalOnField contains the label)
|
||||||
|
const dependentFieldName = findFieldNameByLabel(field.conditionalOnField);
|
||||||
|
if (!dependentFieldName) {
|
||||||
|
console.warn(`Could not find field with label: ${field.conditionalOnField}`);
|
||||||
|
return true; // Show field if we can't find the dependency
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependentFieldValue = formData.value[dependentFieldName]?.value;
|
||||||
|
|
||||||
|
console.log(`Checking visibility for ${field.label}:`, {
|
||||||
|
conditionalOnField: field.conditionalOnField,
|
||||||
|
dependentFieldName,
|
||||||
|
dependentFieldValue,
|
||||||
|
conditionalOnValue: field.conditionalOnValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the dependent field is a checkbox, it should be true
|
||||||
|
if (typeof dependentFieldValue === "boolean") {
|
||||||
|
return dependentFieldValue === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If conditional_on_value is specified, check for exact match
|
||||||
|
if (field.conditionalOnValue !== null && field.conditionalOnValue !== undefined) {
|
||||||
|
return dependentFieldValue === field.conditionalOnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, just check if the dependent field has any truthy value
|
||||||
|
return !!dependentFieldValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get options for select/multi-select fields
|
||||||
|
const getFieldOptions = (field) => {
|
||||||
|
// Access reactive data to ensure reactivity
|
||||||
|
const optionsData = docsForSelectFields.value[field.name];
|
||||||
|
|
||||||
|
console.log(`getFieldOptions called for ${field.label}:`, {
|
||||||
|
type: field.type,
|
||||||
|
options: field.options,
|
||||||
|
optionsType: typeof field.options,
|
||||||
|
doctypeForSelect: field.doctypeForSelect,
|
||||||
|
doctypeLabelField: field.doctypeLabelField,
|
||||||
|
hasDoctypeOptions: !!optionsData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If options should be fetched from a doctype
|
||||||
|
if (field.doctypeForSelect && optionsData) {
|
||||||
|
console.log(`Using doctype options for ${field.label}:`, optionsData);
|
||||||
|
return [...optionsData]; // Return a copy to ensure reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
// If options are provided as a string (comma-separated), parse them
|
||||||
|
if (field.options && typeof field.options === "string" && field.options.trim() !== "") {
|
||||||
|
const optionStrings = field.options.split(",").map((opt) => opt.trim()).filter(opt => opt !== "");
|
||||||
|
// Convert to objects for consistency with PrimeVue MultiSelect
|
||||||
|
const options = optionStrings.map((opt) => ({
|
||||||
|
label: opt,
|
||||||
|
value: opt,
|
||||||
|
}));
|
||||||
|
console.log(`Parsed options for ${field.label}:`, options);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`No options found for ${field.label}`);
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get options for select/multi-select fields
|
||||||
|
const optionsForFields = computed(() => {
|
||||||
|
// Ensure reactivity by accessing docsForSelectFields
|
||||||
|
const docsData = docsForSelectFields.value;
|
||||||
|
const opts = {};
|
||||||
|
allFields.value.forEach(field => {
|
||||||
|
const options = getFieldOptions(field);
|
||||||
|
opts[field.name] = options;
|
||||||
|
console.log(`Computed options for ${field.name}:`, options);
|
||||||
|
});
|
||||||
|
console.log('optionsForFields computed:', opts);
|
||||||
|
return opts;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add item to quantity list for Multi-Select w/ Quantity fields
|
||||||
|
const addItemToQuantityList = (field) => {
|
||||||
|
const selectedItem = currentItemSelection.value[field.name];
|
||||||
|
if (!selectedItem) return;
|
||||||
|
|
||||||
|
// selectedItem is now an object with { label, value }
|
||||||
|
const itemValue = selectedItem.value || selectedItem;
|
||||||
|
const itemLabel = selectedItem.label || selectedItem;
|
||||||
|
|
||||||
|
// Initialize array if it doesn't exist
|
||||||
|
const fieldData = formData.value[field.name];
|
||||||
|
if (!fieldData.value) {
|
||||||
|
fieldData.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if item already exists (compare by value)
|
||||||
|
const existingItem = fieldData.value.find((item) => item.item === itemValue);
|
||||||
|
if (existingItem) {
|
||||||
|
// Increment quantity if item already exists
|
||||||
|
existingItem.quantity += 1;
|
||||||
|
} else {
|
||||||
|
// Add new item with quantity 1
|
||||||
|
fieldData.value.push({
|
||||||
|
item: itemValue,
|
||||||
|
label: itemLabel,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
currentItemSelection.value[field.name] = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove item from quantity list
|
||||||
|
const removeItemFromQuantityList = (field, index) => {
|
||||||
|
const fieldData = formData.value[field.name];
|
||||||
|
if (fieldData && fieldData.value) {
|
||||||
|
fieldData.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get option label for display
|
||||||
|
const getOptionLabel = (field, item) => {
|
||||||
|
const options = optionsForFields.value[field.name];
|
||||||
|
const option = options.find(o => o.value === item.item);
|
||||||
|
return option ? option.label : item.label || item.item;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize form data with default values
|
||||||
|
const initializeFormData = () => {
|
||||||
|
if (!formConfig.value) return;
|
||||||
|
|
||||||
|
const data = {};
|
||||||
|
allFields.value.forEach((field) => {
|
||||||
|
// Create reactive object with field config and value
|
||||||
|
data[field.name] = {
|
||||||
|
...field, // Include all field configuration
|
||||||
|
value: null, // Initialize value
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set default value if provided
|
||||||
|
if (field.defaultValue !== null && field.defaultValue !== undefined) {
|
||||||
|
data[field.name].value = field.defaultValue;
|
||||||
|
} else {
|
||||||
|
// Initialize based on field type
|
||||||
|
switch (field.type) {
|
||||||
|
case "Check":
|
||||||
|
data[field.name].value = false;
|
||||||
|
break;
|
||||||
|
case "Multi-Select":
|
||||||
|
data[field.name].value = [];
|
||||||
|
break;
|
||||||
|
case "Multi-Select w/ Quantity":
|
||||||
|
data[field.name].value = [];
|
||||||
|
break;
|
||||||
|
case "Number":
|
||||||
|
data[field.name].value = null;
|
||||||
|
break;
|
||||||
|
case "Select":
|
||||||
|
data[field.name].value = null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
data[field.name].value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
formData.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load form configuration
|
||||||
|
const loadFormConfig = async () => {
|
||||||
|
if (!props.projectTemplate) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const config = await Api.getBidMeetingNoteForm(props.projectTemplate);
|
||||||
|
formConfig.value = config;
|
||||||
|
console.log("Loaded form config:", config);
|
||||||
|
|
||||||
|
// Load doctype options for fields that need them
|
||||||
|
await loadDoctypeOptions();
|
||||||
|
|
||||||
|
// Initialize form data
|
||||||
|
initializeFormData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading form config:", error);
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to load form configuration",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load options for fields that reference doctypes
|
||||||
|
const loadDoctypeOptions = async () => {
|
||||||
|
if (!formConfig.value || !formConfig.value.fields) return;
|
||||||
|
|
||||||
|
const fieldsWithDoctype = formConfig.value.fields.filter(
|
||||||
|
(field) => field.doctypeForSelect && field.doctypeForSelect !== ""
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const field of fieldsWithDoctype) {
|
||||||
|
try {
|
||||||
|
// Use the new API method for fetching docs
|
||||||
|
let docs = await Api.getQuotationItems();
|
||||||
|
|
||||||
|
// Deduplicate by value field
|
||||||
|
const valueField = field.doctypeValueField || 'name';
|
||||||
|
const seen = new Set();
|
||||||
|
docs = docs.filter(doc => {
|
||||||
|
const val = doc[valueField] || doc.name || doc;
|
||||||
|
if (seen.has(val)) return false;
|
||||||
|
seen.add(val);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform docs into options format
|
||||||
|
// Use doctypeLabelField if specified, otherwise default to 'name'
|
||||||
|
const labelField = field.doctypeLabelField || 'name';
|
||||||
|
|
||||||
|
const options = docs.map((doc) => ({
|
||||||
|
label: doc[labelField] || doc.name || doc,
|
||||||
|
value: doc[valueField] || doc.name || doc,
|
||||||
|
}));
|
||||||
|
|
||||||
|
docsForSelectFields.value[field.name] = options;
|
||||||
|
console.log(`Loaded ${options.length} options for ${field.label} from ${field.doctypeForSelect}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading options for ${field.doctypeForSelect}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!formConfig.value) return errors;
|
||||||
|
|
||||||
|
allFields.value.forEach((field) => {
|
||||||
|
// Only validate if field is visible
|
||||||
|
if (!isFieldVisible(field)) return;
|
||||||
|
|
||||||
|
// Skip required validation for checkboxes (they always have a value: true or false)
|
||||||
|
if (field.type === 'Check') return;
|
||||||
|
|
||||||
|
if (field.required) {
|
||||||
|
const value = formData.value[field.name]?.value;
|
||||||
|
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
errors.push(`${field.label} is required`);
|
||||||
|
} else if (Array.isArray(value) && value.length === 0) {
|
||||||
|
errors.push(`${field.label} is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format field data for submission
|
||||||
|
const formatFieldData = (field) => {
|
||||||
|
const value = formData.value[field.name]?.value;
|
||||||
|
|
||||||
|
// Include the entire field configuration
|
||||||
|
const fieldData = {
|
||||||
|
...field, // Include all field properties
|
||||||
|
value: value, // Override with current value
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle options: include unless fetched from doctype
|
||||||
|
if (field.doctypeForSelect) {
|
||||||
|
// Remove options if they were fetched from doctype
|
||||||
|
delete fieldData.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For fields with include_options flag, include the selected options
|
||||||
|
if (field.includeOptions) {
|
||||||
|
if (field.type === "Multi-Select") {
|
||||||
|
fieldData.selectedOptions = value || [];
|
||||||
|
} else if (field.type === "Multi-Select w/ Quantity") {
|
||||||
|
fieldData.items = value || [];
|
||||||
|
} else if (field.type === "Select") {
|
||||||
|
fieldData.selectedOption = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates as strings
|
||||||
|
if (field.type === "Date" || field.type === "Datetime" || field.type === "Time") {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
if (field.type === "Date") {
|
||||||
|
fieldData.value = value.toISOString().split("T")[0];
|
||||||
|
} else if (field.type === "Datetime") {
|
||||||
|
fieldData.value = value.toISOString();
|
||||||
|
} else if (field.type === "Time") {
|
||||||
|
fieldData.value = value.toTimeString().split(" ")[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validate form
|
||||||
|
const errors = validateForm();
|
||||||
|
if (errors.length > 0) {
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Validation Error",
|
||||||
|
message: errors.join(", "),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingStore.setLoading(true);
|
||||||
|
|
||||||
|
// Format data for submission
|
||||||
|
const submissionData = {
|
||||||
|
bidMeeting: props.bidMeetingName,
|
||||||
|
projectTemplate: props.projectTemplate,
|
||||||
|
formName: formConfig.value?.name || formConfig.value?.title,
|
||||||
|
formTemplate: formConfig.value?.name || formConfig.value?.title,
|
||||||
|
fields: allFields.value
|
||||||
|
.filter((field) => isFieldVisible(field))
|
||||||
|
.map((field) => formatFieldData(field)),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Submitting form data:", submissionData);
|
||||||
|
|
||||||
|
// Submit to API
|
||||||
|
await Api.submitBidMeetingNoteForm(submissionData);
|
||||||
|
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
message: "Bid meeting notes submitted successfully",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
emit("submit", submissionData);
|
||||||
|
showModal.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting form:", error);
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to submit form. Please try again.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingStore.setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit("cancel");
|
||||||
|
showModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug function to log current form data and select options
|
||||||
|
const debugLog = () => {
|
||||||
|
console.log("=== FORM DEBUG ===");
|
||||||
|
|
||||||
|
const debugData = {};
|
||||||
|
allFields.value.forEach(field => {
|
||||||
|
const fieldValue = formData.value[field.name]?.value;
|
||||||
|
const fieldOptions = optionsForFields.value[field.name];
|
||||||
|
const isVisible = isFieldVisible(field);
|
||||||
|
const isDisabled = field.readOnly || !isVisible;
|
||||||
|
|
||||||
|
debugData[field.name] = {
|
||||||
|
label: field.label,
|
||||||
|
type: field.type,
|
||||||
|
value: fieldValue,
|
||||||
|
isVisible,
|
||||||
|
isDisabled,
|
||||||
|
...(field.type.includes('Select') ? {
|
||||||
|
options: fieldOptions,
|
||||||
|
optionsCount: fieldOptions?.length || 0
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Current Form Data:", debugData);
|
||||||
|
console.log("==================");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for modal visibility changes
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
loadFormConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load form config on mount if modal is visible
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.visible) {
|
||||||
|
loadFormConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-button {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid Layout Styles */
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-column-1 { grid-column: span 1; }
|
||||||
|
.form-column-2 { grid-column: span 2; }
|
||||||
|
.form-column-3 { grid-column: span 3; }
|
||||||
|
.form-column-4 { grid-column: span 4; }
|
||||||
|
.form-column-5 { grid-column: span 5; }
|
||||||
|
.form-column-6 { grid-column: span 6; }
|
||||||
|
.form-column-7 { grid-column: span 7; }
|
||||||
|
.form-column-8 { grid-column: span 8; }
|
||||||
|
.form-column-9 { grid-column: span 9; }
|
||||||
|
.form-column-10 { grid-column: span 10; }
|
||||||
|
.form-column-11 { grid-column: span 11; }
|
||||||
|
.form-column-12 { grid-column: span 12; }
|
||||||
|
|
||||||
|
.form-field-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field :deep(.p-select) {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field :deep(.p-multiselect) {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-indicator {
|
||||||
|
color: var(--red-500);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help-text {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container :deep(.p-checkbox) {
|
||||||
|
width: 1.25rem !important;
|
||||||
|
height: 1.25rem !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container :deep(.p-checkbox .p-checkbox-box) {
|
||||||
|
width: 1.25rem !important;
|
||||||
|
height: 1.25rem !important;
|
||||||
|
border: 1px solid var(--surface-border) !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container :deep(.p-checkbox .p-checkbox-input) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container :deep(.p-checkbox .p-checkbox-box .p-checkbox-icon) {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-quantity-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-selector {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-items-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--surface-50);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--surface-0);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-container {
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-controls {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
566
frontend/src/components/modals/BidMeetingNotes.vue
Normal file
566
frontend/src/components/modals/BidMeetingNotes.vue
Normal file
|
|
@ -0,0 +1,566 @@
|
||||||
|
<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>
|
||||||
341
frontend/src/components/modals/JobDetailsModal.vue
Normal file
341
frontend/src/components/modals/JobDetailsModal.vue
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
<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,11 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
|
<div>
|
||||||
<template #title>
|
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
|
||||||
<div class="modal-header">
|
<template #title>
|
||||||
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
<div class="modal-header">
|
||||||
Meeting Details
|
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||||
</div>
|
Meeting Details
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
<div v-if="meeting" class="meeting-details">
|
<div v-if="meeting" class="meeting-details">
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<div class="status-section">
|
<div class="status-section">
|
||||||
|
|
@ -129,14 +130,25 @@
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="meeting.status !== 'Completed' && meeting.status !== 'Unscheduled'"
|
v-if="meeting.status === 'Scheduled'"
|
||||||
@click="handleMarkComplete"
|
@click="handleMarkComplete"
|
||||||
color="success"
|
color="success"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
:loading="isUpdating"
|
:loading="isUpdating"
|
||||||
>
|
>
|
||||||
<v-icon left>mdi-check</v-icon>
|
<v-icon left>mdi-file-edit</v-icon>
|
||||||
Mark as Completed
|
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-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|
@ -148,15 +160,98 @@
|
||||||
<v-icon left>mdi-file-document-outline</v-icon>
|
<v-icon left>mdi-file-document-outline</v-icon>
|
||||||
Create Estimate
|
Create Estimate
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="meeting.status !== 'Completed'"
|
||||||
|
@click="showCancelWarning = true"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<v-icon left>mdi-cancel</v-icon>
|
||||||
|
Cancel Meeting
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Bid Notes Modal -->
|
||||||
|
<Modal
|
||||||
|
:visible="showBidNotesModal"
|
||||||
|
@update:visible="showBidNotesModal = $event"
|
||||||
|
:options="bidNotesModalOptions"
|
||||||
|
@confirm="handleCloseBidNotes"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="modal-header">
|
||||||
|
<i class="pi pi-file-edit" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||||
|
Bid Meeting Notes
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<BidMeetingNotes v-if="bidNoteData" :bid-note="bidNoteData" />
|
||||||
|
<div v-else-if="bidNotesError" class="error-message">
|
||||||
|
<i class="pi pi-exclamation-circle"></i>
|
||||||
|
<span>{{ bidNotesError }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="loading-message">
|
||||||
|
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||||
|
<span>Loading bid notes...</span>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Cancel Meeting Warning Dialog -->
|
||||||
|
<v-dialog v-model="showCancelWarning" max-width="500px">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h5 text-error">
|
||||||
|
<v-icon color="error" class="mr-2">mdi-alert</v-icon>
|
||||||
|
Cancel Bid Meeting?
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="pt-4">
|
||||||
|
<p class="text-body-1 mb-3">
|
||||||
|
<strong>Warning:</strong> This will permanently cancel this bid meeting.
|
||||||
|
</p>
|
||||||
|
<template v-if="meeting?.status === 'Scheduled'">
|
||||||
|
<p class="text-body-2 mb-3">
|
||||||
|
If you want to:
|
||||||
|
</p>
|
||||||
|
<ul class="text-body-2 mb-3">
|
||||||
|
<li><strong>Reschedule:</strong> Drag and drop the meeting to a different time slot</li>
|
||||||
|
<li><strong>Unschedule:</strong> Drag the meeting back to the unscheduled section</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-body-2 mb-2">
|
||||||
|
<strong>Note:</strong> Cancelling permanently marks the meeting as cancelled, which is different from rescheduling or unscheduling it.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p class="text-body-1 font-weight-bold">
|
||||||
|
Are you sure you want to proceed with canceling this meeting?
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="showCancelWarning = false"
|
||||||
|
>
|
||||||
|
No, Keep Meeting
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="elevated"
|
||||||
|
@click="handleCancelMeeting"
|
||||||
|
:loading="isCanceling"
|
||||||
|
>
|
||||||
|
Yes, Cancel Meeting
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import Modal from "../common/Modal.vue";
|
import Modal from "../common/Modal.vue";
|
||||||
|
import BidMeetingNotes from "./BidMeetingNotes.vue";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
|
||||||
|
|
@ -176,10 +271,16 @@ const props = defineProps({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits(["update:visible", "close", "meetingUpdated"]);
|
const emit = defineEmits(["update:visible", "close", "meetingUpdated", "completeMeeting"]);
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const isUpdating = ref(false);
|
const isUpdating = ref(false);
|
||||||
|
const showBidNotesModal = ref(false);
|
||||||
|
const bidNoteData = ref(null);
|
||||||
|
const loadingBidNotes = ref(false);
|
||||||
|
const bidNotesError = ref(null);
|
||||||
|
const showCancelWarning = ref(false);
|
||||||
|
const isCanceling = ref(false);
|
||||||
|
|
||||||
const showModal = computed({
|
const showModal = computed({
|
||||||
get() {
|
get() {
|
||||||
|
|
@ -198,6 +299,13 @@ const modalOptions = computed(() => ({
|
||||||
confirmButtonColor: "primary",
|
confirmButtonColor: "primary",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const bidNotesModalOptions = computed(() => ({
|
||||||
|
maxWidth: "1000px",
|
||||||
|
showCancelButton: false,
|
||||||
|
confirmButtonText: "Close",
|
||||||
|
confirmButtonColor: "primary",
|
||||||
|
}));
|
||||||
|
|
||||||
// Computed properties for data extraction
|
// Computed properties for data extraction
|
||||||
const customerName = computed(() => {
|
const customerName = computed(() => {
|
||||||
if (props.meeting?.address?.customerName) {
|
if (props.meeting?.address?.customerName) {
|
||||||
|
|
@ -269,34 +377,20 @@ const handleClose = () => {
|
||||||
const handleMarkComplete = async () => {
|
const handleMarkComplete = async () => {
|
||||||
if (!props.meeting?.name) return;
|
if (!props.meeting?.name) return;
|
||||||
|
|
||||||
try {
|
// Check if meeting has a project template
|
||||||
isUpdating.value = true;
|
if (!props.meeting.projectTemplate) {
|
||||||
|
|
||||||
await Api.updateBidMeeting(props.meeting.name, {
|
|
||||||
status: "Completed",
|
|
||||||
});
|
|
||||||
|
|
||||||
notificationStore.addNotification({
|
notificationStore.addNotification({
|
||||||
type: "success",
|
type: "warning",
|
||||||
title: "Meeting Completed",
|
title: "Missing Project Template",
|
||||||
message: "The meeting has been marked as completed.",
|
message: "This meeting requires a project template to create notes.",
|
||||||
duration: 4000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit event to refresh the calendar
|
|
||||||
emit("meetingUpdated");
|
|
||||||
handleClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error marking meeting as complete:", error);
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: "error",
|
|
||||||
title: "Error",
|
|
||||||
message: "Failed to update meeting status.",
|
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
} finally {
|
return;
|
||||||
isUpdating.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open the note form modal
|
||||||
|
emit("completeMeeting", props.meeting);
|
||||||
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateEstimate = () => {
|
const handleCreateEstimate = () => {
|
||||||
|
|
@ -313,7 +407,7 @@ const handleCreateEstimate = () => {
|
||||||
new: "true",
|
new: "true",
|
||||||
address: addressText,
|
address: addressText,
|
||||||
"from-meeting": fromMeeting,
|
"from-meeting": fromMeeting,
|
||||||
template: template,
|
"project-template": template,
|
||||||
contact: contactName,
|
contact: contactName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -336,6 +430,81 @@ const formatDateTime = (dateString) => {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewBidNotes = async () => {
|
||||||
|
if (!props.meeting?.bidNotes) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingBidNotes.value = true;
|
||||||
|
bidNotesError.value = null;
|
||||||
|
bidNoteData.value = null;
|
||||||
|
|
||||||
|
// Fetch the bid meeting note
|
||||||
|
const noteData = await Api.getBidMeetingNote(props.meeting.bidNotes);
|
||||||
|
|
||||||
|
if (!noteData) {
|
||||||
|
throw new Error("Failed to load bid notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
bidNoteData.value = noteData;
|
||||||
|
showBidNotesModal.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading bid notes:", error);
|
||||||
|
bidNotesError.value = error.message || "Failed to load bid notes";
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to load bid notes. Please try again.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingBidNotes.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseBidNotes = () => {
|
||||||
|
showBidNotesModal.value = false;
|
||||||
|
bidNoteData.value = null;
|
||||||
|
bidNotesError.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelMeeting = async () => {
|
||||||
|
if (!props.meeting?.name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isCanceling.value = true;
|
||||||
|
|
||||||
|
// Update the meeting status to Cancelled
|
||||||
|
await Api.updateBidMeeting(props.meeting.name, {
|
||||||
|
status: "Cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
|
showCancelWarning.value = false;
|
||||||
|
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Meeting Cancelled",
|
||||||
|
message: "The bid meeting has been cancelled successfully.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit meeting updated event to refresh the calendar
|
||||||
|
emit("meetingUpdated");
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
handleClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error canceling meeting:", error);
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to cancel meeting. Please try again.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isCanceling.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -377,5 +546,24 @@ const formatDateTime = (dateString) => {
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 2px solid #e0e0e0;
|
border-top: 2px solid #e0e0e0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message,
|
||||||
|
.loading-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message i {
|
||||||
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,7 @@ import CalendarNavigation from '@/components/calendar/CalendarNavigation.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:deep(.calendar-navigation) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,79 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="client-page">
|
<div class="client-page">
|
||||||
<!-- Client Header -->
|
<!-- Client Header -->
|
||||||
<TopBar :selectedAddressIdx="selectedAddressIdx" :client="client" :nextVisitDate="nextVisitDate" v-if="client.customerName" @update:selectedAddressIdx="selectedAddressIdx = $event" />
|
<GeneralClientInfo
|
||||||
|
v-if="client.customerName"
|
||||||
|
:client-data="client"
|
||||||
|
/>
|
||||||
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
|
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
|
||||||
|
|
||||||
<Tabs value="0">
|
<!-- 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">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="0">Overview</Tab>
|
<Tab value="0">Overview</Tab>
|
||||||
<Tab value="1">Projects <span class="tab-info-alert">1</span></Tab>
|
<Tab value="1">Projects</Tab>
|
||||||
<Tab value="2">Financials</Tab>
|
<Tab value="2">Financials</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
|
<!-- Overview Tab -->
|
||||||
<TabPanel value="0">
|
<TabPanel value="0">
|
||||||
<Overview
|
<Overview
|
||||||
:client-data="client"
|
:selected-address="selectedAddressData"
|
||||||
:selected-address="selectedAddress"
|
:all-contacts="client.contacts"
|
||||||
|
:edit-mode="editMode"
|
||||||
:is-new="isNew"
|
:is-new="isNew"
|
||||||
|
:full-address="fullAddress"
|
||||||
|
:client="client"
|
||||||
|
@edit-mode-enabled="enableEditMode"
|
||||||
|
@update:address-contacts="handleAddressContactsUpdate"
|
||||||
|
@update:primary-contact="handlePrimaryContactUpdate"
|
||||||
|
@update:client="handleClientUpdate"
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Projects Tab -->
|
||||||
<TabPanel value="1">
|
<TabPanel value="1">
|
||||||
<div id="projects-tab"><h3>Project Status</h3></div>
|
<div class="coming-soon-section">
|
||||||
|
<i class="pi pi-wrench"></i>
|
||||||
|
<h3>Projects</h3>
|
||||||
|
<p>Section coming soon</p>
|
||||||
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Financials Tab -->
|
||||||
<TabPanel value="2">
|
<TabPanel value="2">
|
||||||
<div id="financials-tab"><h3>Accounting</h3></div>
|
<div class="coming-soon-section">
|
||||||
|
<i class="pi pi-dollar"></i>
|
||||||
|
<h3>Financials</h3>
|
||||||
|
<p>Section coming soon</p>
|
||||||
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Form Actions (for edit mode or new client) -->
|
||||||
|
<div class="form-actions" v-if="editMode || isNew">
|
||||||
|
<Button
|
||||||
|
@click="handleCancel"
|
||||||
|
label="Cancel"
|
||||||
|
severity="secondary"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="handleSubmit"
|
||||||
|
:label="isNew ? 'Create Client' : 'Save Changes'"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -35,21 +83,25 @@ import TabList from "primevue/tablist";
|
||||||
import Tab from "primevue/tab";
|
import Tab from "primevue/tab";
|
||||||
import TabPanels from "primevue/tabpanels";
|
import TabPanels from "primevue/tabpanels";
|
||||||
import TabPanel from "primevue/tabpanel";
|
import TabPanel from "primevue/tabpanel";
|
||||||
|
import Button from "primevue/button";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
import { useCompanyStore } from "../../stores/company";
|
||||||
import DataUtils from "../../utils";
|
import DataUtils from "../../utils";
|
||||||
import Overview from "../clientSubPages/Overview.vue";
|
import AddressSelector from "../clientView/AddressSelector.vue";
|
||||||
import ProjectStatus from "../clientSubPages/ProjectStatus.vue";
|
import GeneralClientInfo from "../clientView/GeneralClientInfo.vue";
|
||||||
import TopBar from "../clientView/TopBar.vue";
|
|
||||||
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
|
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
|
||||||
|
import Overview from "../clientView/Overview.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
|
|
||||||
const address = route.query.address || null;
|
const address = (route.query.address || '').trim();
|
||||||
const clientName = route.query.client || null;
|
const clientName = route.query.client || null;
|
||||||
const isNew = computed(() => route.query.new === "true" || false);
|
const isNew = computed(() => route.query.new === "true" || false);
|
||||||
|
|
||||||
|
|
@ -66,13 +118,17 @@ const selectedAddressObject = computed(() =>
|
||||||
);
|
);
|
||||||
const addresses = computed(() => {
|
const addresses = computed(() => {
|
||||||
if (client.value && client.value.addresses) {
|
if (client.value && client.value.addresses) {
|
||||||
return client.value.addresses.map((addr) => DataUtils.calculateFullAddress(addr));
|
return client.value.addresses.map((addr) => DataUtils.calculateFullAddress(addr).trim());
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextVisitDate = ref(null); // Placeholder, update as needed
|
const nextVisitDate = ref(null); // Placeholder, update as needed
|
||||||
|
|
||||||
|
// Tab and edit state
|
||||||
|
const editMode = ref(false);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
const selectedAddressIdx = computed({
|
const selectedAddressIdx = computed({
|
||||||
get: () => addresses.value.indexOf(selectedAddress.value),
|
get: () => addresses.value.indexOf(selectedAddress.value),
|
||||||
set: (idx) => {
|
set: (idx) => {
|
||||||
|
|
@ -82,6 +138,22 @@ const selectedAddressIdx = computed({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Find the address data object that matches the selected address string
|
||||||
|
const selectedAddressData = computed(() => {
|
||||||
|
if (!client.value?.addresses || !selectedAddress.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return client.value.addresses.find(
|
||||||
|
(addr) => DataUtils.calculateFullAddress(addr).trim() === selectedAddress.value.trim()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate full address for display
|
||||||
|
const fullAddress = computed(() => {
|
||||||
|
if (!selectedAddressData.value) return "N/A";
|
||||||
|
return DataUtils.calculateFullAddress(selectedAddressData.value);
|
||||||
|
});
|
||||||
|
|
||||||
const getClientNames = async (type) => {
|
const getClientNames = async (type) => {
|
||||||
loadingStore.setLoading(true);
|
loadingStore.setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -102,10 +174,11 @@ const getClient = async (name) => {
|
||||||
// Set initial selected address if provided in route or use first address
|
// Set initial selected address if provided in route or use first address
|
||||||
if (address && client.value.addresses) {
|
if (address && client.value.addresses) {
|
||||||
const fullAddresses = client.value.addresses.map((addr) =>
|
const fullAddresses = client.value.addresses.map((addr) =>
|
||||||
DataUtils.calculateFullAddress(addr),
|
DataUtils.calculateFullAddress(addr).trim(),
|
||||||
);
|
);
|
||||||
if (fullAddresses.includes(address)) {
|
const trimmedAddress = address.trim();
|
||||||
selectedAddress.value = address;
|
if (fullAddresses.includes(trimmedAddress)) {
|
||||||
|
selectedAddress.value = trimmedAddress;
|
||||||
} else if (fullAddresses.length > 0) {
|
} else if (fullAddresses.length > 0) {
|
||||||
selectedAddress.value = fullAddresses[0];
|
selectedAddress.value = fullAddresses[0];
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +196,16 @@ const getClient = async (name) => {
|
||||||
} else if (selectedAddress.value) {
|
} else if (selectedAddress.value) {
|
||||||
// geocode.value = await Api.getGeocode(selectedAddress.value);
|
// geocode.value = await Api.getGeocode(selectedAddress.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if client is associated with current company
|
||||||
|
if (companyStore.currentCompany && client.value.companies) {
|
||||||
|
const clientHasCompany = client.value.companies.some(company => company.company === companyStore.currentCompany);
|
||||||
|
if (!clientHasCompany) {
|
||||||
|
notificationStore.addWarning(
|
||||||
|
`The selected company is not linked to this client.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching client data in Client.vue: ", error.message || error);
|
console.error("Error fetching client data in Client.vue: ", error.message || error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -173,6 +256,100 @@ watch(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => companyStore.currentCompany,
|
||||||
|
(newCompany) => {
|
||||||
|
console.log("############# Company changed to:", newCompany);
|
||||||
|
if (!newCompany || !client.value.customerName) return;
|
||||||
|
|
||||||
|
// Check if client is associated with the company
|
||||||
|
let clientHasCompany = false;
|
||||||
|
if (client.value.companies) {
|
||||||
|
clientHasCompany = client.value.companies.some(company => company.company === newCompany);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if selected address is associated with the company
|
||||||
|
let addressHasCompany = false;
|
||||||
|
if (selectedAddressData.value?.companies) {
|
||||||
|
addressHasCompany = selectedAddressData.value.companies.some(company => company.company === newCompany);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show warnings for missing associations
|
||||||
|
if (!clientHasCompany) {
|
||||||
|
notificationStore.addWarning(
|
||||||
|
`The selected company is not linked to this client.`,
|
||||||
|
);
|
||||||
|
} else if (!addressHasCompany) {
|
||||||
|
notificationStore.addWarning(
|
||||||
|
`The selected company is not linked to this address.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle address change
|
||||||
|
const handleAddressChange = (newIdx) => {
|
||||||
|
selectedAddressIdx.value = newIdx;
|
||||||
|
// TODO: Update route query with new address
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable edit mode
|
||||||
|
const enableEditMode = () => {
|
||||||
|
editMode.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel edit or new
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isNew.value) {
|
||||||
|
// For new client, clear the form data
|
||||||
|
client.value = {};
|
||||||
|
} else {
|
||||||
|
editMode.value = false;
|
||||||
|
// Restore original data if editing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle save edit or create new
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
if (isNew.value) {
|
||||||
|
const createdClient = await Api.createClient(client.value);
|
||||||
|
console.log("Created client:", createdClient);
|
||||||
|
notificationStore.addSuccess("Client created successfully!");
|
||||||
|
stripped_name = createdClient.customerName.split("-#-")[0].trim();
|
||||||
|
// Navigate to the created client
|
||||||
|
router.push('/client?client=' + encodeURIComponent(stripped_name));
|
||||||
|
} else {
|
||||||
|
// TODO: Implement save logic
|
||||||
|
notificationStore.addSuccess("Changes saved successfully!");
|
||||||
|
editMode.value = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting:", error);
|
||||||
|
notificationStore.addError(isNew.value ? "Failed to create client" : "Failed to save changes");
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle address contacts update
|
||||||
|
const handleAddressContactsUpdate = (contactNames) => {
|
||||||
|
console.log("Address contacts updated:", contactNames);
|
||||||
|
// TODO: Store this for saving
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle primary contact update
|
||||||
|
const handlePrimaryContactUpdate = (contactName) => {
|
||||||
|
console.log("Primary contact updated:", contactName);
|
||||||
|
// TODO: Store this for saving
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle client update from forms
|
||||||
|
const handleClientUpdate = (newClientData) => {
|
||||||
|
client.value = { ...client.value, ...newClientData };
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
.tab-info-alert {
|
.tab-info-alert {
|
||||||
|
|
@ -184,4 +361,63 @@ watch(
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-tabs {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-section i {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-section p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-page {
|
||||||
|
padding-bottom: 5rem; /* Add padding to prevent content from being hidden behind fixed buttons */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,12 @@ import { useFiltersStore } from "../../stores/filters";
|
||||||
import { useModalStore } from "../../stores/modal";
|
import { useModalStore } from "../../stores/modal";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
import { useCompanyStore } from "../../stores/company";
|
||||||
import TodoChart from "../common/TodoChart.vue";
|
import TodoChart from "../common/TodoChart.vue";
|
||||||
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
|
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
|
||||||
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
|
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
|
||||||
WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
|
WateringSoil, Soil, Truck, SoilAlt,
|
||||||
|
Filter} from "@iconoir/vue";
|
||||||
|
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
|
|
@ -41,6 +43,7 @@ const filtersStore = useFiltersStore();
|
||||||
const modalStore = useModalStore();
|
const modalStore = useModalStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
|
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
const totalRecords = ref(0);
|
const totalRecords = ref(0);
|
||||||
|
|
@ -50,10 +53,26 @@ const currentWeekParams = ref({});
|
||||||
const chartLoading = ref(true); // Start with loading state
|
const chartLoading = ref(true); // Start with loading state
|
||||||
|
|
||||||
const lookup = route.query.lookup;
|
const lookup = route.query.lookup;
|
||||||
|
const lastLazyLoadEvent = ref(null);
|
||||||
|
|
||||||
|
// Watch for company changes to reload data
|
||||||
|
watch(
|
||||||
|
() => companyStore.currentCompany,
|
||||||
|
async () => {
|
||||||
|
console.log("Company changed, reloading client data...");
|
||||||
|
if (lastLazyLoadEvent.value) {
|
||||||
|
await handleLazyLoad(lastLazyLoadEvent.value);
|
||||||
|
}
|
||||||
|
// Also refresh status counts
|
||||||
|
await refreshStatusCounts();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Computed property to get current filters for the chart
|
// Computed property to get current filters for the chart
|
||||||
const currentFilters = computed(() => {
|
const currentFilters = computed(() => {
|
||||||
return filtersStore.getTableFilters("clients");
|
filters = { ...filtersStore.getTableFilters("clients"),
|
||||||
|
company: { value: companyStore.currentCompany, matchMode: FilterMatchMode.CONTAINS}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle week change from chart
|
// Handle week change from chart
|
||||||
|
|
@ -220,6 +239,7 @@ const tableActions = [
|
||||||
// Handle lazy loading events from DataTable
|
// Handle lazy loading events from DataTable
|
||||||
const handleLazyLoad = async (event) => {
|
const handleLazyLoad = async (event) => {
|
||||||
console.log("Clients page - handling lazy load:", event);
|
console.log("Clients page - handling lazy load:", event);
|
||||||
|
lastLazyLoadEvent.value = event;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
@ -263,8 +283,9 @@ const handleLazyLoad = async (event) => {
|
||||||
filters,
|
filters,
|
||||||
sortingArray,
|
sortingArray,
|
||||||
});
|
});
|
||||||
|
filters["company"] = { value: companyStore.currentCompany, matchMode: FilterMatchMode.CONTAINS};
|
||||||
|
|
||||||
const result = await Api.getPaginatedClientDetails(
|
const result = await Api.getPaginatedClientDetailsV2(
|
||||||
paginationParams,
|
paginationParams,
|
||||||
filters,
|
filters,
|
||||||
sortingArray,
|
sortingArray,
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,34 @@
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Bid Meeting Notes Side Tab -->
|
||||||
|
<Button v-if="bidMeeting?.bidNotes" class="bid-notes-side-tab" @click="onTabClick">
|
||||||
|
<div class="tab-content">
|
||||||
|
<i class="pi pi-file-edit"></i>
|
||||||
|
<span class="tab-text">Bid Notes</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Bid Meeting Notes Drawer -->
|
||||||
|
<Drawer
|
||||||
|
:visible="showDrawer"
|
||||||
|
@update:visible="showDrawer = $event"
|
||||||
|
position="right"
|
||||||
|
:style="{ width: '1200px' }"
|
||||||
|
@hide="showDrawer = false"
|
||||||
|
class="bid-notes-drawer"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="drawer-header">
|
||||||
|
<h3>Bid Meeting Notes</h3>
|
||||||
|
<Button icon="pi pi-times" @click="showDrawer = false" text rounded />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="drawer-content">
|
||||||
|
<BidMeetingNotes v-if="bidMeeting?.bidNotes" :bid-note="bidMeeting.bidNotes" />
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -391,11 +419,13 @@ import Modal from "../common/Modal.vue";
|
||||||
import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
|
import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
|
||||||
import DataTable from "../common/DataTable.vue";
|
import DataTable from "../common/DataTable.vue";
|
||||||
import DocHistory from "../common/DocHistory.vue";
|
import DocHistory from "../common/DocHistory.vue";
|
||||||
|
import BidMeetingNotes from "../modals/BidMeetingNotes.vue";
|
||||||
import InputText from "primevue/inputtext";
|
import InputText from "primevue/inputtext";
|
||||||
import InputNumber from "primevue/inputnumber";
|
import InputNumber from "primevue/inputnumber";
|
||||||
import Button from "primevue/button";
|
import Button from "primevue/button";
|
||||||
import Select from "primevue/select";
|
import Select from "primevue/select";
|
||||||
import Tooltip from "primevue/tooltip";
|
import Tooltip from "primevue/tooltip";
|
||||||
|
import Drawer from "primevue/drawer";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import DataUtils from "../../utils";
|
import DataUtils from "../../utils";
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
|
@ -414,6 +444,7 @@ const nameQuery = computed(() => route.query.name || "");
|
||||||
const templateQuery = computed(() => route.query.template || "");
|
const templateQuery = computed(() => route.query.template || "");
|
||||||
const fromMeetingQuery = computed(() => route.query["from-meeting"] || "");
|
const fromMeetingQuery = computed(() => route.query["from-meeting"] || "");
|
||||||
const contactQuery = computed(() => route.query.contact || "");
|
const contactQuery = computed(() => route.query.contact || "");
|
||||||
|
const projectTemplateQuery = computed(() => route.query["project-template"] || "");
|
||||||
const isNew = computed(() => route.query.new === "true");
|
const isNew = computed(() => route.query.new === "true");
|
||||||
|
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
|
|
@ -427,7 +458,7 @@ const formData = reactive({
|
||||||
estimateName: null,
|
estimateName: null,
|
||||||
requiresHalfPayment: false,
|
requiresHalfPayment: false,
|
||||||
projectTemplate: null,
|
projectTemplate: null,
|
||||||
fromMeeting: null,
|
fromOnsiteMeeting: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedAddress = ref(null);
|
const selectedAddress = ref(null);
|
||||||
|
|
@ -452,8 +483,10 @@ const showResponseModal = ref(false);
|
||||||
const showSaveTemplateModal = ref(false);
|
const showSaveTemplateModal = ref(false);
|
||||||
const addressSearchResults = ref([]);
|
const addressSearchResults = ref([]);
|
||||||
const itemSearchTerm = ref("");
|
const itemSearchTerm = ref("");
|
||||||
|
const showDrawer = ref(false);
|
||||||
|
|
||||||
const estimate = ref(null);
|
const estimate = ref(null);
|
||||||
|
const bidMeeting = ref(null);
|
||||||
|
|
||||||
// Computed property to determine if fields are editable
|
// Computed property to determine if fields are editable
|
||||||
const isEditable = computed(() => {
|
const isEditable = computed(() => {
|
||||||
|
|
@ -725,7 +758,7 @@ const saveDraft = async () => {
|
||||||
estimateName: formData.estimateName,
|
estimateName: formData.estimateName,
|
||||||
requiresHalfPayment: formData.requiresHalfPayment,
|
requiresHalfPayment: formData.requiresHalfPayment,
|
||||||
projectTemplate: formData.projectTemplate,
|
projectTemplate: formData.projectTemplate,
|
||||||
fromMeeting: formData.fromMeeting,
|
fromOnsiteMeeting: formData.fromOnsiteMeeting,
|
||||||
company: company.currentCompany
|
company: company.currentCompany
|
||||||
};
|
};
|
||||||
estimate.value = await Api.createEstimate(data);
|
estimate.value = await Api.createEstimate(data);
|
||||||
|
|
@ -802,6 +835,15 @@ const toggleDiscountType = (item, type) => {
|
||||||
item.discountType = type;
|
item.discountType = type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onTabClick = () => {
|
||||||
|
console.log('Bid notes tab clicked');
|
||||||
|
console.log('Current showDrawer value:', showDrawer.value);
|
||||||
|
console.log('bidMeeting:', bidMeeting.value);
|
||||||
|
console.log('bidMeeting?.bidNotes:', bidMeeting.value?.bidNotes);
|
||||||
|
showDrawer.value = true;
|
||||||
|
console.log('Set showDrawer to true');
|
||||||
|
};
|
||||||
|
|
||||||
const tableActions = [
|
const tableActions = [
|
||||||
{
|
{
|
||||||
label: "Add Selected Items",
|
label: "Add Selected Items",
|
||||||
|
|
@ -882,6 +924,8 @@ watch(
|
||||||
const newIsNew = newQuery.new === "true";
|
const newIsNew = newQuery.new === "true";
|
||||||
const newAddressQuery = newQuery.address;
|
const newAddressQuery = newQuery.address;
|
||||||
const newNameQuery = newQuery.name;
|
const newNameQuery = newQuery.name;
|
||||||
|
const newFromMeetingQuery = newQuery["from-meeting"];
|
||||||
|
const newProjectTemplateQuery = newQuery["project-template"];
|
||||||
|
|
||||||
if (newAddressQuery && newIsNew) {
|
if (newAddressQuery && newIsNew) {
|
||||||
// Creating new estimate - pre-fill address
|
// Creating new estimate - pre-fill address
|
||||||
|
|
@ -925,7 +969,15 @@ watch(
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false;
|
formData.requiresHalfPayment = Boolean(estimate.value.requiresHalfPayment || estimate.value.custom_requires_half_payment);
|
||||||
|
// If estimate has fromOnsiteMeeting, fetch bid meeting
|
||||||
|
if (estimate.value.fromOnsiteMeeting) {
|
||||||
|
try {
|
||||||
|
bidMeeting.value = await Api.getBidMeeting(estimate.value.fromOnsiteMeeting);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bid meeting for existing estimate:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading estimate:", error);
|
console.error("Error loading estimate:", error);
|
||||||
|
|
@ -935,6 +987,35 @@ watch(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle from-meeting for new estimates
|
||||||
|
if (newFromMeetingQuery && newIsNew) {
|
||||||
|
formData.fromOnsiteMeeting = newFromMeetingQuery;
|
||||||
|
try {
|
||||||
|
bidMeeting.value = await Api.getBidMeeting(newFromMeetingQuery);
|
||||||
|
if (bidMeeting.value?.bidNotes?.quantities) {
|
||||||
|
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
|
||||||
|
const item = quotationItems.value.find(i => i.itemCode === q.item);
|
||||||
|
return {
|
||||||
|
itemCode: q.item,
|
||||||
|
itemName: item?.itemName || q.item,
|
||||||
|
qty: q.quantity,
|
||||||
|
standardRate: item?.standardRate || 0,
|
||||||
|
discountAmount: null,
|
||||||
|
discountPercentage: null,
|
||||||
|
discountType: 'currency'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bid meeting:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle project-template
|
||||||
|
if (newProjectTemplateQuery) {
|
||||||
|
formData.projectTemplate = newProjectTemplateQuery;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
|
|
@ -954,10 +1035,36 @@ onMounted(async () => {
|
||||||
|
|
||||||
// Handle from-meeting query parameter
|
// Handle from-meeting query parameter
|
||||||
if (fromMeetingQuery.value) {
|
if (fromMeetingQuery.value) {
|
||||||
formData.fromMeeting = fromMeetingQuery.value;
|
formData.fromOnsiteMeeting = fromMeetingQuery.value;
|
||||||
|
// Fetch the bid meeting to check for bidNotes
|
||||||
|
try {
|
||||||
|
bidMeeting.value = await Api.getBidMeeting(fromMeetingQuery.value);
|
||||||
|
// If new estimate and bid notes have quantities, set default items
|
||||||
|
if (isNew.value && bidMeeting.value?.bidNotes?.quantities) {
|
||||||
|
selectedItems.value = bidMeeting.value.bidNotes.quantities.map(q => {
|
||||||
|
const item = quotationItems.value.find(i => i.itemCode === q.item);
|
||||||
|
return {
|
||||||
|
itemCode: q.item,
|
||||||
|
itemName: item?.itemName || q.item,
|
||||||
|
qty: q.quantity,
|
||||||
|
standardRate: item?.standardRate || 0,
|
||||||
|
discountAmount: null,
|
||||||
|
discountPercentage: null,
|
||||||
|
discountType: 'currency'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bid meeting:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle project-template query parameter
|
||||||
|
if (projectTemplateQuery.value) {
|
||||||
|
formData.projectTemplate = projectTemplateQuery.value;
|
||||||
|
}
|
||||||
|
|
||||||
if (addressQuery.value && isNew.value) {
|
if (addressQuery.value && isNew.value) {
|
||||||
// Creating new estimate - pre-fill address
|
// Creating new estimate - pre-fill address
|
||||||
await selectAddress(addressQuery.value);
|
await selectAddress(addressQuery.value);
|
||||||
|
|
@ -1006,7 +1113,15 @@ onMounted(async () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false;
|
formData.requiresHalfPayment = Boolean(estimate.value.requiresHalfPayment || estimate.value.custom_requires_half_payment);
|
||||||
|
// If estimate has fromOnsiteMeeting, fetch bid meeting
|
||||||
|
if (estimate.value.fromOnsiteMeeting) {
|
||||||
|
try {
|
||||||
|
bidMeeting.value = await Api.getBidMeeting(estimate.value.fromOnsiteMeeting);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bid meeting for existing estimate:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
estimateResponse.value = estimate.value.customResponse;
|
estimateResponse.value = estimate.value.customResponse;
|
||||||
estimateResponseSelection.value = estimate.value.customResponse;
|
estimateResponseSelection.value = estimate.value.customResponse;
|
||||||
}
|
}
|
||||||
|
|
@ -1034,6 +1149,46 @@ onMounted(async () => {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bid-notes-side-tab {
|
||||||
|
position: fixed;
|
||||||
|
right: -90px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background-color: #2196f3;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 8px;
|
||||||
|
width: 110px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: upright;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-notes-side-tab:hover {
|
||||||
|
right: -80px;
|
||||||
|
background-color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content i {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.address-section,
|
.address-section,
|
||||||
.contact-section,
|
.contact-section,
|
||||||
.project-template-section,
|
.project-template-section,
|
||||||
|
|
@ -1365,5 +1520,29 @@ onMounted(async () => {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bid-notes-drawer {
|
||||||
|
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-content {
|
||||||
|
padding: 1rem;
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<parameter name="filePath"></parameter>
|
<parameter name="filePath"></parameter>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Incomplete Bids"
|
title="Incomplete Bids"
|
||||||
:todoNumber="bidsTodoNumber"
|
:categories="chartData.bids"
|
||||||
:completedNumber="bidsCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -38,8 +37,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Unapproved Estimates"
|
title="Unapproved Estimates"
|
||||||
:todoNumber="estimatesTodoNumber"
|
:categories="chartData.estimates"
|
||||||
:completedNumber="estimatesCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -61,8 +59,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Half Down Payments"
|
title="Half Down Payments"
|
||||||
:todoNumber="halfDownTodoNumber"
|
:categories="chartData.halfDown"
|
||||||
:completedNumber="halfDownCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -84,8 +81,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Jobs In Queue"
|
title="Jobs In Queue"
|
||||||
:todoNumber="jobQueueTodoNumber"
|
:categories="chartData.jobsInQueue"
|
||||||
:completedNumber="jobQueueCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -132,13 +128,15 @@ import Card from "../common/Card.vue";
|
||||||
import DataTable from "../common/DataTable.vue";
|
import DataTable from "../common/DataTable.vue";
|
||||||
import TodoChart from "../common/TodoChart.vue";
|
import TodoChart from "../common/TodoChart.vue";
|
||||||
import { Edit, ChatBubbleQuestion, CreditCard, Hammer } from "@iconoir/vue";
|
import { Edit, ChatBubbleQuestion, CreditCard, Hammer } from "@iconoir/vue";
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
import { usePaginationStore } from "../../stores/pagination";
|
import { usePaginationStore } from "../../stores/pagination";
|
||||||
import { useFiltersStore } from "../../stores/filters";
|
import { useFiltersStore } from "../../stores/filters";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
import { useCompanyStore } from "../../stores/company";
|
||||||
|
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
const paginationStore = usePaginationStore();
|
const paginationStore = usePaginationStore();
|
||||||
const filtersStore = useFiltersStore();
|
const filtersStore = useFiltersStore();
|
||||||
|
|
@ -148,15 +146,25 @@ const tableData = ref([]);
|
||||||
const totalRecords = ref(0);
|
const totalRecords = ref(0);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const showSubmitEstimateModal = ref(true);
|
const showSubmitEstimateModal = ref(true);
|
||||||
|
const chartData = ref({
|
||||||
|
bids: {labels: ["Unscheduled"], data: [0], colors: ['red']},
|
||||||
|
estimates: {labels: ["Draft", "Submitted"], data: [0, 0], colors: ['orange', 'blue']},
|
||||||
|
halfDown: {labels: ["Unpaid"], data: [0], colors: ['red']},
|
||||||
|
jobsInQueue: {labels: ["Queued"], data: [0], colors: ['blue']}
|
||||||
|
});
|
||||||
|
|
||||||
//Junk
|
//Junk
|
||||||
const filteredItems= []
|
const filteredItems= []
|
||||||
// End junk
|
// End junk
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ label: "Estimate Address", fieldName: "address", type: "text", sortable: true, filterable: true },
|
{ label: "Estimate Address", fieldName: "address", type: "link", sortable: true, filterable:true,
|
||||||
|
onLinkClick: (link, rowData) => handlePropertyClick(link, rowData),
|
||||||
|
},
|
||||||
//{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
|
//{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
|
||||||
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
|
{ label: "Customer", fieldName: "customer", type: "link", sortable: true, filterable: true,
|
||||||
|
onLinkClick: (link, rowData) => handleCustomerClick(link, rowData),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Status",
|
label: "Status",
|
||||||
fieldName: "status",
|
fieldName: "status",
|
||||||
|
|
@ -194,6 +202,19 @@ const closeSubmitEstimateModal = () => {
|
||||||
showSubmitEstimateModal.value = false;
|
showSubmitEstimateModal.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCustomerClick = (link, rowData) => {
|
||||||
|
console.log("DEBUG: Customer Link Clicked.");
|
||||||
|
const client = encodeURIComponent(rowData.customer);
|
||||||
|
const address = encodeURIComponent(rowData.address);
|
||||||
|
router.push(`/client?client=${client}&address=${address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePropertyClick = (link, rowData) => {
|
||||||
|
console.log("DEBUG: Property Link Clicked.");
|
||||||
|
const client = encodeURIComponent(rowData.customer);
|
||||||
|
const address = encodeURIComponent(rowData.address);
|
||||||
|
router.push(`/property?client=${client}&address=${address}`);
|
||||||
|
}
|
||||||
|
|
||||||
const handleLazyLoad = async (event) => {
|
const handleLazyLoad = async (event) => {
|
||||||
console.log("Estimates page - handling lazy load:", event);
|
console.log("Estimates page - handling lazy load:", event);
|
||||||
|
|
@ -214,7 +235,7 @@ const handleLazyLoad = async (event) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get filters (convert PrimeVue format to API format)
|
// Get filters (convert PrimeVue format to API format)
|
||||||
const filters = {};
|
const filters = {company: companyStore.currentCompany};
|
||||||
if (event.filters) {
|
if (event.filters) {
|
||||||
Object.keys(event.filters).forEach((key) => {
|
Object.keys(event.filters).forEach((key) => {
|
||||||
if (key !== "global" && event.filters[key] && event.filters[key].value) {
|
if (key !== "global" && event.filters[key] && event.filters[key].value) {
|
||||||
|
|
@ -260,6 +281,15 @@ const handleLazyLoad = async (event) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const loadChartData = async () => {
|
||||||
|
chartData.value.bids.data = await Api.getIncompleteBidsCount(companyStore.currentCompany);
|
||||||
|
chartData.value.estimates.data = await Api.getUnapprovedEstimatesCount(companyStore.currentCompany);
|
||||||
|
chartData.value.halfDown.data = await Api.getEstimatesHalfDownCount(companyStore.currentCompany);
|
||||||
|
chartData.value.jobsInQueue.data = await Api.getJobsInQueueCount(companyStore.currentCompany);
|
||||||
|
};
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Initialize pagination and filters
|
// Initialize pagination and filters
|
||||||
|
|
@ -282,6 +312,20 @@ onMounted(async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// watch the company store and refetch data when it changes
|
||||||
|
watch(
|
||||||
|
() => companyStore.currentCompany,
|
||||||
|
async (newCompany, oldCompany) => {
|
||||||
|
console.log("Company changed from", oldCompany, "to", newCompany, "- refetching estimates data.");
|
||||||
|
await handleLazyLoad({
|
||||||
|
page: paginationStore.getTablePagination("estimates").page,
|
||||||
|
rows: paginationStore.getTablePagination("estimates").rows,
|
||||||
|
first: paginationStore.getTablePagination("estimates").first,
|
||||||
|
filters: filtersStore.getTableFilters("estimates"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
.widgets-grid {
|
.widgets-grid {
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,10 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Locates"
|
title="Locates"
|
||||||
:todoNumber="locatesTodoNumber"
|
:categories="chartData.locates"
|
||||||
:completedNumber="locatesCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button" @click="navigateTo('/jobs')">
|
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Locate')">
|
||||||
View Locates
|
View Locates
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,11 +36,10 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Permits"
|
title="Permits"
|
||||||
:todoNumber="permitsTodoNumber"
|
:categories="chartData.permits"
|
||||||
:completedNumber="permitsCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button" @click="navigateTo('/jobs')">
|
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Permit(s)')">
|
||||||
View Permits
|
View Permits
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,11 +57,10 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Permit Finalization"
|
title="Permit Finalization"
|
||||||
:todoNumber="permitFinalizationsTodoNumber"
|
:categories="chartData.permitFinalizations"
|
||||||
:completedNumber="permitFinalizationsCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button" @click="navigateTo('/jobs')">
|
<button class="sidebar-button" @click="navigateTo('/tasks?subject=Close-out')">
|
||||||
View Finalizations
|
View Finalizations
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,11 +78,10 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Warranty Claims"
|
title="Warranty Claims"
|
||||||
:todoNumber="warrantyTodoNumber"
|
:categories="chartData.warranties"
|
||||||
:completedNumber="warrantyCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button" @click="navigateTo('/jobs')">
|
<button class="sidebar-button" @click="navigateTo('/warranties')">
|
||||||
View Warranties
|
View Warranties
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,8 +99,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Incomplete Bids"
|
title="Incomplete Bids"
|
||||||
:todoNumber="bidsTodoNumber"
|
:categories="chartData.bids"
|
||||||
:completedNumber="bidsCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -126,8 +121,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Unapproved Estimates"
|
title="Unapproved Estimates"
|
||||||
:todoNumber="estimatesTodoNumber"
|
:categories="chartData.estimates"
|
||||||
:completedNumber="estimatesCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -149,12 +143,11 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Half Down Payments"
|
title="Half Down Payments"
|
||||||
:todoNumber="halfDownTodoNumber"
|
:categories="chartData.halfDown"
|
||||||
:completedNumber="halfDownCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
@click="navigateTo('/jobs')">
|
@click="navigateTo('/invoices')">
|
||||||
Half Down Payments
|
Half Down Payments
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -172,12 +165,11 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="15 Day Follow Ups"
|
title="15 Day Follow Ups"
|
||||||
:todoNumber="fifteenDayTodoNumber"
|
:categories="chartData.fifteenDayFollowups"
|
||||||
:completedNumber="fifteenDayCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
@click="navigateTo('/calendar')">
|
@click="navigateTo('/tasks?subject=15-Day')">
|
||||||
View Follow Ups
|
View Follow Ups
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,8 +187,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Late Balances"
|
title="Late Balances"
|
||||||
:todoNumber="balancesTodoNumber"
|
:categories="chartData.lateBalances"
|
||||||
:completedNumber="balancesCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -218,12 +209,11 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Backflow Tests"
|
title="Backflow Tests"
|
||||||
:todoNumber="backflowsTodoNumber"
|
:categories="chartData.backflows"
|
||||||
:completedNumber="backflowsCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
@click="navigateTo('/jobs')">
|
@click="navigateTo('/tasks?subject=backflow')">
|
||||||
Late Balances
|
Late Balances
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,12 +231,11 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Curbing"
|
title="Curbing"
|
||||||
:todoNumber="curbingTodoNumber"
|
:categories="chartData.curbing"
|
||||||
:completedNumber="curbingCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
@click="navigateTo('/jobs')">
|
@click="navigateTo('/tasks?subject=Curbing')">
|
||||||
Curbing
|
Curbing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,12 +253,11 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Hydroseeding"
|
title="Hydroseeding"
|
||||||
:todoNumber="hydroseedingTodoNumber"
|
:categories="chartData.hydroseed"
|
||||||
:completedNumber="hydroseedingCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
@click="navigateTo('/jobs')">
|
@click="navigateTo('/tasks?subject=Hydroseed')">
|
||||||
Hydroseeding
|
Hydroseeding
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,12 +275,11 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Machines"
|
title="Machines"
|
||||||
:todoNumber="machinesTodoNumber"
|
:categories="chartData.machines"
|
||||||
:completedNumber="machinesCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
@click="navigateTo('/jobs')">
|
@click="navigateTo('/tasks?subject=machines')">
|
||||||
Machines
|
Machines
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -310,12 +297,11 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Deliveries"
|
title="Deliveries"
|
||||||
:todoNumber="deliveriesTodoNumber"
|
:categories="chartData.deliveries"
|
||||||
:completedNumber="delivieriesCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
@click="navigateTo('/jobs')">
|
@click="navigateTo('/tasks?subject=machines')">
|
||||||
Deliveries
|
Deliveries
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -326,7 +312,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
//import Card from "primevue/card";
|
//import Card from "primevue/card";
|
||||||
import Card from "../common/Card.vue";
|
import Card from "../common/Card.vue";
|
||||||
|
|
@ -334,26 +320,48 @@ import Tag from "primevue/tag";
|
||||||
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
|
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
|
||||||
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
|
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
|
||||||
WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
|
WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
|
||||||
|
import Api from "../../api.js";
|
||||||
import DataUtils from "../../utils.js";
|
import DataUtils from "../../utils.js";
|
||||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
import { useCompanyStore } from "../../stores/company.js";
|
||||||
//import SimpleChart from "../common/SimpleChart.vue";
|
//import SimpleChart from "../common/SimpleChart.vue";
|
||||||
import TodoChart from "../common/TodoChart.vue";
|
import TodoChart from "../common/TodoChart.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const defaultColors = ['blue', 'green', 'red'];
|
||||||
|
|
||||||
// Dummy data from utils
|
// Dummy data from utils
|
||||||
const clientData = ref(DataUtils.dummyClientData);
|
const clientData = ref(DataUtils.dummyClientData);
|
||||||
const jobData = ref(DataUtils.dummyJobData);
|
const jobData = ref(DataUtils.dummyJobData);
|
||||||
const locatesTodoNumber = ref(45);
|
const locatesTodoNumber = ref(0);
|
||||||
const locatesCompletedNumber = ref(5);
|
const locatesCompletedNumber = ref(0);
|
||||||
const permitsTodoNumber = ref(24);
|
const permitsTodoNumber = ref(0);
|
||||||
const permitsCompletedNumber = ref(7);
|
const permitsCompletedNumber = ref(0);
|
||||||
const permitFinalizationsTodoNumber = ref(35);
|
const permitFinalizationsTodoNumber = ref(35);
|
||||||
const permitFinalizationsCompletedNumber = ref(2);
|
const permitFinalizationsCompletedNumber = ref(2);
|
||||||
const warrantyTodoNumber = ref(0);
|
const warrantyTodoNumber = ref(0);
|
||||||
const warrantyCompletedNumber = ref(10);
|
const warrantyCompletedNumber = ref(10);
|
||||||
|
|
||||||
|
const chartData = ref({
|
||||||
|
locates: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
permits: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
curbing: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
hydroseed: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
permitFinalizations: {labels: ["Todo", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
warranties: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
fifteenDayFollowups: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
lateBalances: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
backflows: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
machines: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
deliveries: {labels: ["To-do", "Completed", "Overdue"], data: [0, 0, 0], colors: defaultColors},
|
||||||
|
bids: {labels: ["Unscheduled"], data: [0], colors: ['red']},
|
||||||
|
estimates: {labels: ["Draft", "Submitted"], data: [0, 0], colors: ['orange', 'blue']},
|
||||||
|
halfDown: {labels: ["Unpaid"], data: [0], colors: ['red']},
|
||||||
|
});
|
||||||
|
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
|
|
||||||
// Computed values for dashboard metrics
|
// Computed values for dashboard metrics
|
||||||
const totalRevenue = computed(() => "$47,250");
|
const totalRevenue = computed(() => "$47,250");
|
||||||
|
|
@ -365,9 +373,31 @@ const avgResponseTime = computed(() => 2.3);
|
||||||
const navigateTo = (path) => {
|
const navigateTo = (path) => {
|
||||||
router.push(path);
|
router.push(path);
|
||||||
};
|
};
|
||||||
onMounted(() => {
|
|
||||||
|
const loadChartData = async() => {
|
||||||
|
chartData.value.locates.data = await Api.getTasksDue("Locate", companyStore.currentCompany);
|
||||||
|
chartData.value.permits.data = await Api.getTasksDue("Permit(s)", companyStore.currentCompany);
|
||||||
|
chartData.value.curbing.data = await Api.getTasksDue("Curbing", companyStore.currentCompany);
|
||||||
|
chartData.value.hydroseed.data = await Api.getTasksDue("Hydroseed", companyStore.currentCompany);
|
||||||
|
chartData.value.permitFinalizations.data = await Api.getTasksDue("Permit Close-out", companyStore.currentCompany);
|
||||||
|
chartData.value.warranties.data = await Api.getTasksDue("Warranty", companyStore.currentCompany);
|
||||||
|
chartData.value.fifteenDayFollowups.data = await Api.getTasksDue("15-Day QA", companyStore.currentCompany);
|
||||||
|
chartData.value.backflows.data = await Api.getTasksDue("Backflow", companyStore.currentCompany);
|
||||||
|
//Uncomment below when we can check if half-down payments have/can been paid
|
||||||
|
//chartData.value.estimates.data = await Api.getEstimatesHalfDownCount();
|
||||||
|
chartData.value.bids.data = await Api.getIncompleteBidsCount(companyStore.currentCompany);
|
||||||
|
chartData.value.estimates.data = await Api.getUnapprovedEstimatesCount(companyStore.currentCompany);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async() => {
|
||||||
notifications.addWarning("Dashboard metrics are based on dummy data for demonstration purposes. UPDATES COMING SOON!");
|
notifications.addWarning("Dashboard metrics are based on dummy data for demonstration purposes. UPDATES COMING SOON!");
|
||||||
|
await loadChartData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
|
||||||
|
await loadChartData();
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Ready To Invoice"
|
title="Ready To Invoice"
|
||||||
:todoNumber="invoiceTodoNumber"
|
:categories="chartData.jobsToInvoice"
|
||||||
:completedNumber="invoiceCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -38,8 +37,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Late Balances"
|
title="Late Balances"
|
||||||
:todoNumber="balancesTodoNumber"
|
:categories="chartData.invoicesLate"
|
||||||
:completedNumber="balancesCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -65,21 +63,28 @@
|
||||||
import Card from "../common/Card.vue";
|
import Card from "../common/Card.vue";
|
||||||
import DataTable from "../common/DataTable.vue";
|
import DataTable from "../common/DataTable.vue";
|
||||||
import TodoChart from "../common/TodoChart.vue";
|
import TodoChart from "../common/TodoChart.vue";
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
import { usePaginationStore } from "../../stores/pagination";
|
import { usePaginationStore } from "../../stores/pagination";
|
||||||
import { useFiltersStore } from "../../stores/filters";
|
import { useFiltersStore } from "../../stores/filters";
|
||||||
|
import { useCompanyStore } from "../../stores/company.js";
|
||||||
import { CardNoAccess, CalendarCheck } from "@iconoir/vue";
|
import { CardNoAccess, CalendarCheck } from "@iconoir/vue";
|
||||||
|
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
const paginationStore = usePaginationStore();
|
const paginationStore = usePaginationStore();
|
||||||
const filtersStore = useFiltersStore();
|
const filtersStore = useFiltersStore();
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
|
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
const totalRecords = ref(0);
|
const totalRecords = ref(0);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const chartData = ref({
|
||||||
|
jobsToInvoice: {labels: ["Ready To Invoice"], data: [0], colors: ['green']},
|
||||||
|
invoicesLate: {labels: ["Due", "30 Days", "60 Days", "80 Days"], data: [0, 0, 0, 0], colors: ["blue", "yellow", "orange", "red"]}
|
||||||
|
})
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ label: "Customer Address", fieldName: "address", type: "text", sortable: true },
|
{ label: "Customer Address", fieldName: "address", type: "text", sortable: true },
|
||||||
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
|
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
|
||||||
|
|
@ -168,6 +173,12 @@ const handleLazyLoad = async (event) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load Chart Data
|
||||||
|
const loadChartData = async () => {
|
||||||
|
chartData.value.jobsToInvoice.data = await Api.getJobsToInvoiceCount(companyStore.currentCompany);
|
||||||
|
chartData.value.invoicesLate.data = await Api.getInvoicesLateCount(companyStore.currentCompany);
|
||||||
|
};
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Initialize pagination and filters
|
// Initialize pagination and filters
|
||||||
|
|
@ -188,6 +199,12 @@ onMounted(async () => {
|
||||||
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
||||||
filters: initialFilters,
|
filters: initialFilters,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await loadChartData();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
|
||||||
|
await loadChartData();
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
{{ job.customInstallationAddress || "" }}
|
{{ job.jobAddress["fullAddress"] || "" }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -32,6 +32,26 @@
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="job-info">
|
||||||
|
<Card>
|
||||||
|
<template #header>
|
||||||
|
<div class="widget-header">
|
||||||
|
<h3>Job Status</h3>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="widget-content">
|
||||||
|
Job is {{ job.status }}.
|
||||||
|
<button
|
||||||
|
class="sidebar-button"
|
||||||
|
@click="createInvoiceForJob()"
|
||||||
|
>
|
||||||
|
Create Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|
@ -66,7 +86,7 @@ const notifications = useNotificationStore();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const jobIdQuery = computed(() => route.query.jobId || "");
|
const jobIdQuery = computed(() => route.query.name || "");
|
||||||
const isNew = computed(() => route.query.new === "true");
|
const isNew = computed(() => route.query.new === "true");
|
||||||
|
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
|
|
@ -124,6 +144,11 @@ const tableActions = computed(() => [
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const createInvoiceForJob = async () => {
|
||||||
|
console.log(job);
|
||||||
|
await Api.createInvoiceForJob(job.value.name);
|
||||||
|
}
|
||||||
|
|
||||||
const handleLazyLoad = async (event) => {
|
const handleLazyLoad = async (event) => {
|
||||||
console.log("Task list on Job Page - handling lazy load:", event);
|
console.log("Task list on Job Page - handling lazy load:", event);
|
||||||
try {
|
try {
|
||||||
|
|
@ -232,7 +257,7 @@ const handleLazyLoad = async (event) => {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.log("DEBUG: Query params:", route.query);
|
console.log("DEBUG: Query params:", route.query);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const optionsResult = await Api.getTaskStatusOptions();
|
const optionsResult = await Api.getTaskStatusOptions();
|
||||||
if (optionsResult && optionsResult.length > 0) {
|
if (optionsResult && optionsResult.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Jobs In Queue"
|
title="Jobs In Queue"
|
||||||
:todoNumber="jobQueueTodoNumber"
|
:categories="chartData.jobsInQueue"
|
||||||
:completedNumber="jobQueueCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -38,8 +37,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Jobs in Progress"
|
title="Jobs in Progress"
|
||||||
:todoNumber="progressTodoNumber"
|
:categories="chartData.jobsInProgress"
|
||||||
:completedNumber="progressCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -61,8 +59,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Late Jobs"
|
title="Late Jobs"
|
||||||
:todoNumber="lateTodoNumber"
|
:categories="chartData.jobsLate"
|
||||||
:completedNumber="lateCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -84,8 +81,7 @@
|
||||||
<div class="widget-content">
|
<div class="widget-content">
|
||||||
<TodoChart
|
<TodoChart
|
||||||
title="Ready To Invoice"
|
title="Ready To Invoice"
|
||||||
:todoNumber="invoiceTodoNumber"
|
:categories="chartData.jobsToInvoice"
|
||||||
:completedNumber="invoiceCompletedNumber"
|
|
||||||
>
|
>
|
||||||
</TodoChart>
|
</TodoChart>
|
||||||
<button class="sidebar-button"
|
<button class="sidebar-button"
|
||||||
|
|
@ -112,29 +108,39 @@
|
||||||
import Card from "../common/Card.vue";
|
import Card from "../common/Card.vue";
|
||||||
import DataTable from "../common/DataTable.vue";
|
import DataTable from "../common/DataTable.vue";
|
||||||
import TodoChart from "../common/TodoChart.vue";
|
import TodoChart from "../common/TodoChart.vue";
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
import { usePaginationStore } from "../../stores/pagination";
|
import { usePaginationStore } from "../../stores/pagination";
|
||||||
import { useFiltersStore } from "../../stores/filters";
|
import { useFiltersStore } from "../../stores/filters";
|
||||||
|
import { useCompanyStore } from "../../stores/company.js";
|
||||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
import { Alarm, CalendarCheck, Hammer } from "@iconoir/vue";
|
import { Alarm, CalendarCheck, Hammer } from "@iconoir/vue";
|
||||||
|
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
const paginationStore = usePaginationStore();
|
const paginationStore = usePaginationStore();
|
||||||
const filtersStore = useFiltersStore();
|
const filtersStore = useFiltersStore();
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
|
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
const totalRecords = ref(0);
|
const totalRecords = ref(0);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const chartData = ref({
|
||||||
|
jobsInQueue: {labels: ["Queued"], data: [0], colors: ['blue']},
|
||||||
|
jobsInProgress: {labels: ["In Progress"], data: [0], colors: ['blue']},
|
||||||
|
jobsLate: {labels: ["Late"], data: [0], colors: ['red']},
|
||||||
|
jobsToInvoice: {labels: ["Ready To Invoice"], data: [0], colors: ['green']},
|
||||||
|
})
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ label: "Job ID", fieldName: "name", type: "text", sortable: true, filterable: true },
|
{ label: "Job ID", fieldName: "name", type: "text", sortable: true, filterable: true },
|
||||||
{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
|
{ label: "Address", fieldName: "jobAddress", type: "text", sortable: true },
|
||||||
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
|
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
|
||||||
{ label: "Overall Status", fieldName: "status", type: "status", sortable: true },
|
{ label: "Overall Status", fieldName: "status", type: "status", sortable: true },
|
||||||
|
{ label: "Invoice Status", fieldName: "invoiceStatus", type: "text", sortable: true },
|
||||||
{ label: "Progress", fieldName: "percentComplete", type: "text", sortable: true }
|
{ label: "Progress", fieldName: "percentComplete", type: "text", sortable: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -257,7 +263,14 @@ const handleLazyLoad = async (event) => {
|
||||||
|
|
||||||
const handleRowClick = (event) => {
|
const handleRowClick = (event) => {
|
||||||
const rowData = event.data;
|
const rowData = event.data;
|
||||||
router.push(`/job?jobId=${rowData.name}`);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
|
|
@ -280,7 +293,16 @@ onMounted(async () => {
|
||||||
sortField: initialSorting.field || initialPagination.sortField,
|
sortField: initialSorting.field || initialPagination.sortField,
|
||||||
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chart Data
|
||||||
|
await loadChartData();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
|
||||||
|
await loadChartData();
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
.widgets-grid {
|
.widgets-grid {
|
||||||
|
|
|
||||||
|
|
@ -21,18 +21,22 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import DataTable from "../common/DataTable.vue";
|
import DataTable from "../common/DataTable.vue";
|
||||||
import { ref, onMounted, watch, computed } from "vue";
|
import { ref, onMounted, watch, computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
import { usePaginationStore } from "../../stores/pagination";
|
import { usePaginationStore } from "../../stores/pagination";
|
||||||
import { useFiltersStore } from "../../stores/filters";
|
import { useFiltersStore } from "../../stores/filters";
|
||||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
import { FilterMatchMode } from "@primevue/core";
|
||||||
|
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
const paginationStore = usePaginationStore();
|
const paginationStore = usePaginationStore();
|
||||||
const filtersStore = useFiltersStore();
|
const filtersStore = useFiltersStore();
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const subject = route.query.subject;
|
||||||
|
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
const totalRecords = ref(0);
|
const totalRecords = ref(0);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
@ -46,13 +50,18 @@ const statusOptions = ref([
|
||||||
"Cancelled",
|
"Cancelled",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
subject: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||||
|
};
|
||||||
|
|
||||||
// Computed property to get current filters for the chart
|
// Computed property to get current filters for the chart
|
||||||
const currentFilters = computed(() => {
|
const currentFilters = computed(() => {
|
||||||
return filtersStore.getTableFilters("tasks");
|
return filtersStore.getTableFilters("tasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ label: "Task", fieldName: "subject", type: "text", sortable: true, filterable: true },
|
{ label: "Task", fieldName: "subject", type: "text", sortable: true, filterable: true,
|
||||||
|
filterInputID: "subjectFilterId", defaultValue: subject || null },
|
||||||
{ label: "Job", fieldName: "project", type: "link", sortable: true,
|
{ label: "Job", fieldName: "project", type: "link", sortable: true,
|
||||||
onLinkClick: (link, rowData) => handleProjectClick(link, rowData)
|
onLinkClick: (link, rowData) => handleProjectClick(link, rowData)
|
||||||
},
|
},
|
||||||
|
|
@ -92,7 +101,7 @@ const tableActions = [
|
||||||
try {
|
try {
|
||||||
// Uncomment when API is ready
|
// Uncomment when API is ready
|
||||||
await Api.setTaskStatus(rowData.id, option);
|
await Api.setTaskStatus(rowData.id, option);
|
||||||
|
|
||||||
// Find and update the row in the table data
|
// Find and update the row in the table data
|
||||||
const rowIndex = tableData.value.findIndex(row => row.id === rowData.id);
|
const rowIndex = tableData.value.findIndex(row => row.id === rowData.id);
|
||||||
if (rowIndex >= 0) {
|
if (rowIndex >= 0) {
|
||||||
|
|
@ -211,7 +220,6 @@ watch(showCompleted, () => {
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
notifications.addWarning("Tasks page coming soon");
|
|
||||||
// Initialize pagination and filters
|
// Initialize pagination and filters
|
||||||
paginationStore.initializeTablePagination("tasks", { rows: 10 });
|
paginationStore.initializeTablePagination("tasks", { rows: 10 });
|
||||||
filtersStore.initializeTableFilters("tasks", columns);
|
filtersStore.initializeTableFilters("tasks", columns);
|
||||||
|
|
@ -222,6 +230,11 @@ onMounted(async () => {
|
||||||
const initialFilters = filtersStore.getTableFilters("tasks");
|
const initialFilters = filtersStore.getTableFilters("tasks");
|
||||||
const initialSorting = filtersStore.getTableSorting("tasks");
|
const initialSorting = filtersStore.getTableSorting("tasks");
|
||||||
|
|
||||||
|
if (subject) {
|
||||||
|
console.log("Setting subject filter from query param:", subject);
|
||||||
|
initialFilters.subject.value = subject;
|
||||||
|
}
|
||||||
|
|
||||||
const optionsResult = await Api.getTaskStatusOptions();
|
const optionsResult = await Api.getTaskStatusOptions();
|
||||||
statusOptions.value = optionsResult;
|
statusOptions.value = optionsResult;
|
||||||
console.log("DEBUG: Loaded Status options: ", statusOptions.value)
|
console.log("DEBUG: Loaded Status options: ", statusOptions.value)
|
||||||
|
|
@ -234,6 +247,8 @@ onMounted(async () => {
|
||||||
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
sortOrder: initialSorting.order || initialPagination.sortOrder,
|
||||||
filters: initialFilters,
|
filters: initialFilters,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notifications.addWarning("Tasks page coming soon");
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
|
|
|
||||||
90
templates/downpayment/downpayment.html
Normal file
90
templates/downpayment/downpayment.html
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Down Payment Required</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.payment-details {
|
||||||
|
background-color: #ecf0f1;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.payment-details h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #3498db;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Thank You for Confirming Your Quote</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Dear Valued Customer,</p>
|
||||||
|
<p>Thank you for accepting our quote for services at {{ company_name }}. We're excited to work with you and appreciate your trust in our team.</p>
|
||||||
|
<p>To proceed with scheduling your service, a half down payment is required. This helps us secure the necessary resources and ensures everything is prepared for your appointment.</p>
|
||||||
|
<div class="payment-details">
|
||||||
|
<h2>Payment Details</h2>
|
||||||
|
<p><strong>Sales Order Number:</strong> {{ sales_order_number }}</p>
|
||||||
|
<p><strong>Down Payment Amount:</strong> ${{ total_amount }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Please click the button below to make your secure payment through our payment processor:</p>
|
||||||
|
<a href="https://yourdomain.com/downpayment?so={{ sales_order_number }}&amount={{ total_amount }}" class="cta-button">Make Payment</a>
|
||||||
|
<p>If you have any questions or need assistance, feel free to contact us. We're here to help!</p>
|
||||||
|
<p>Best regards,<br>The Team at {{ company_name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated email. Please do not reply directly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue