This commit is contained in:
Casey 2026-02-18 06:56:19 -06:00
parent e2746b83bb
commit 1610905a43
16 changed files with 3303 additions and 2521 deletions

View File

@ -9,6 +9,101 @@ from custom_ui.services import AddressService, ContactService, ClientService
# CLIENT MANAGEMENT API METHODS
# ===============================================================================
@frappe.whitelist()
def add_addresses_contacts(client_name, company_name, addresses=[], contacts=[]):
if isinstance(addresses, str):
addresses = json.loads(addresses)
if isinstance(contacts, str):
contacts = json.loads(contacts)
print(f"DEBUG: add_addresses_contacts called with client_name: {client_name}, addresses: {addresses}, contacts: {contacts}")
try:
client_doc = ClientService.get_client_or_throw(client_name)
if contacts:
contact_docs = [frappe.get_doc("Contact", contact.contact) for contact in client_doc.contacts]
for contact in contacts:
contact_doc = None
if frappe.db.exists("Contact", {"email_id": contact.get("email")}):
contact_doc = frappe.get_doc("Contact", {"email_id": contact.get("email")})
else:
contact_doc = ContactService.create({
"first_name": contact.get("first_name"),
"last_name": contact.get("last_name"),
"email_id": contact.get("email"),
"role": contact.get("role"),
"phone": contact.get("phone"),
"custom_email": contact.get("email"),
"is_primary_contact": 0,
"customer_type": client_doc.doctype,
"customer_name": client_doc.name,
"email_ids": [{
"email": contact.get("email"),
"is_primary": 1
}],
"phone_nos": [{
"phone": contact.get("phone"),
"is_primary_phone": 1,
"is_primary_mobile_no": 1
}]
})
contact_doc.insert()
ClientService.append_link_v2(client_doc.name, "contacts", {"contact": contact_doc.name})
ContactService.link_contact_to_customer(contact_doc, client_doc.doctype, client_doc.name)
contact_docs.append(contact_doc)
address_docs = [frappe.get_doc("Address", link.address) for link in client_doc.properties]
for address in addresses:
address_doc = None
if frappe.db.exists("Address", {
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
"city": address.get("city"),
# "state": address.get("state"),
"pincode": address.get("pincode")
}):
address_doc = frappe.get_doc("Address", {
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
"city": address.get("city"),
# "state": address.get("state"),
"pincode": address.get("pincode")
})
else:
address_doc = AddressService.create({
"address_title": AddressService.build_address_title(customer_name=client_name, address_data=address),
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
"city": address.get("city"),
"state": address.get("state"),
"pincode": address.get("pincode"),
"country": "United States",
"address_type": "Service",
"custom_billing_address": 0,
"is_primary_address": 0,
"is_service_address": 1,
"customer_type": client_doc.doctype,
"customer_name": client_doc.name
})
address_doc.insert()
if company_name not in [company.company for company in address_doc.companies]:
address_doc.append("companies", {"company": company_name})
address_doc.save(ignore_permissions=True)
AddressService.link_address_to_customer(address_doc, client_doc.doctype, client_doc.name)
for contact_to_link_idx in address.get("contacts", []):
contact_doc = contact_docs[contact_to_link_idx]
AddressService.link_address_to_contact(address_doc, contact_doc.name)
ContactService.link_contact_to_address(contact_doc, address_doc.name)
primary_contact = contact_docs[address.get("primary_contact", 0)]
AddressService.set_primary_contact(address_doc.name, primary_contact.name)
ClientService.append_link_v2(client_doc.name, "properties", {"address": address_doc.name})
address_docs.append(address_doc)
return build_success_response({
"contacts": [contact.as_dict() for contact in contact_docs],
"addresses": [address.as_dict() for address in address_docs],
"message": "Addresses and contacts added successfully."
})
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def check_client_exists(client_name):
"""Check if a client exists as either a Customer or a Lead.

View File

@ -61,7 +61,7 @@ def get_unscheduled_service_appointments(companies):
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):
def update_service_appointment_scheduled_dates(service_appointment_name: str, start_date, end_date, crew_lead_name, skipped_days=[], 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:
@ -71,7 +71,8 @@ def update_service_appointment_scheduled_dates(service_appointment_name: str, st
start_date,
end_date,
start_time,
end_time
end_time,
skip_days=skipped_days
)
return build_success_response(updated_service_appointment.as_dict())
except Exception as e:

View File

@ -61,7 +61,7 @@
"expenses_included_in_valuation": "Expenses Included In Valuation - VS",
"fax": null,
"is_group": 0,
"modified": "2026-02-15 00:56:31.933618",
"modified": "2026-02-17 03:05:48.497419",
"monthly_sales_target": 0.0,
"name": "Veritas Stone",
"old_parent": "",
@ -149,7 +149,7 @@
"expenses_included_in_valuation": "Expenses Included In Valuation - DL",
"fax": null,
"is_group": 0,
"modified": "2026-02-15 00:56:31.935944",
"modified": "2026-02-17 03:05:48.503038",
"monthly_sales_target": 0.0,
"name": "Daniels Landscape Supplies",
"old_parent": "",
@ -237,7 +237,7 @@
"expenses_included_in_valuation": "Expenses Included In Valuation - SD",
"fax": null,
"is_group": 0,
"modified": "2026-02-15 00:56:31.938072",
"modified": "2026-02-17 03:05:48.505280",
"monthly_sales_target": 0.0,
"name": "sprinklersnorthwest (Demo)",
"old_parent": "",
@ -325,7 +325,7 @@
"expenses_included_in_valuation": "Expenses Included In Valuation - NYC",
"fax": null,
"is_group": 0,
"modified": "2026-02-15 00:56:31.942129",
"modified": "2026-02-17 03:05:48.509153",
"monthly_sales_target": 0.0,
"name": "Nuco Yard Care",
"old_parent": "",
@ -413,7 +413,7 @@
"expenses_included_in_valuation": "Expenses Included In Valuation - LF",
"fax": null,
"is_group": 0,
"modified": "2026-02-15 00:56:31.940118",
"modified": "2026-02-17 03:05:48.507129",
"monthly_sales_target": 0.0,
"name": "Lowe Fencing",
"old_parent": "",
@ -501,7 +501,7 @@
"expenses_included_in_valuation": "Expenses Included In Valuation - S",
"fax": null,
"is_group": 0,
"modified": "2026-02-15 00:56:31.946270",
"modified": "2026-02-17 03:05:48.512257",
"monthly_sales_target": 0.0,
"name": "Sprinklers Northwest",
"old_parent": "",

File diff suppressed because it is too large Load Diff

View File

@ -33179,5 +33179,351 @@
"track_views": 0,
"translated_doctype": 0,
"website_search_field": null
},
{
"_assign": null,
"_comments": null,
"_last_update": null,
"_liked_by": null,
"_user_tags": null,
"actions": [],
"allow_auto_repeat": 0,
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 1,
"app": null,
"autoname": null,
"beta": 0,
"color": null,
"colour": null,
"custom": 1,
"default_email_template": null,
"default_print_format": null,
"default_view": null,
"description": null,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"documentation": null,
"editable_grid": 1,
"email_append_to": 0,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": "Customer",
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "customer_type",
"fieldtype": "Select",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Customer Type",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Customer\nLead",
"parent": "Address Customer Link",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "customer_name",
"fieldtype": "Dynamic Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Customer Name",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "customer_type",
"parent": "Address Customer Link",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "relation",
"fieldtype": "Select",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Relation",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Owner\nBuilder",
"parent": "Address Customer Link",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": "1",
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "is_active",
"fieldtype": "Check",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Active",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Address Customer Link",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
}
],
"force_re_route_to_default_view": 0,
"grid_page_length": 50,
"has_web_view": 0,
"hide_toolbar": 0,
"icon": null,
"image_field": null,
"in_create": 0,
"index_web_pages_for_search": 1,
"is_calendar_and_gantt": 0,
"is_published_field": null,
"is_submittable": 0,
"is_tree": 0,
"is_virtual": 0,
"issingle": 0,
"istable": 1,
"links": [],
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": null,
"modified": "2026-02-17 08:14:21.379886",
"module": "Custom UI",
"name": "Address Customer Link",
"naming_rule": "",
"nsm_parent_field": null,
"parent_node": null,
"permissions": [],
"print_outline": null,
"protect_attached_files": 0,
"queue_in_background": 0,
"quick_entry": 0,
"read_only": 0,
"recipient_account_field": null,
"restrict_to_domain": null,
"route": null,
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"search_fields": null,
"sender_field": null,
"sender_name_field": null,
"show_name_in_global_search": 0,
"show_preview_popup": 0,
"show_title_field_in_link": 0,
"smallicon": null,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"subject": null,
"subject_field": null,
"tag_fields": null,
"timeline_field": null,
"title_field": null,
"track_changes": 0,
"track_seen": 0,
"track_views": 0,
"translated_doctype": 0,
"website_search_field": null
}
]

View File

@ -1,13 +1,13 @@
[
{
"bid_meeting_note_form": "SNW Install Bid Meeting Notes",
"calendar_color": "#CB2929",
"calendar_color": "#c1dec5",
"company": "Sprinklers Northwest",
"custom__complete_method": "Task Weight",
"docstatus": 0,
"doctype": "Project Template",
"item_groups": "SNW-I, SNW-S, SNW-LS",
"modified": "2026-02-15 01:31:39.325004",
"modified": "2026-02-16 03:59:53.719382",
"name": "SNW Install",
"project_type": "External",
"tasks": [

View File

@ -95,22 +95,6 @@
"row_name": null,
"value": "[\"weight\", \"description\", \"base_date\", \"offset_days\", \"skip_weekends\", \"skip_holidays\", \"logic_key\", \"offset_direction\", \"title\", \"days\", \"calculate_from\", \"trigger\", \"task_type_calculate_from\", \"work_type\", \"no_due_date\", \"triggering_doctype\", \"custom_completion_trigger\", \"custom_completion_trigger_doctype\", \"target_percent\"]"
},
{
"default_value": null,
"doc_type": "Contact",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "email_id",
"is_system_generated": 0,
"modified": "2025-01-06 14:08:26.445944",
"module": null,
"name": "Contact-email_id-options",
"property": "options",
"property_type": "Text",
"row_name": null,
"value": "email_id"
},
{
"default_value": null,
"doc_type": "Pre-Built Routes",
@ -10415,22 +10399,6 @@
"row_name": null,
"value": "None"
},
{
"default_value": null,
"doc_type": "Customer",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-02-05 06:03:40.318065",
"module": null,
"name": "Customer-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"basic_info\", \"naming_series\", \"salutation\", \"customer_name\", \"customer_type\", \"customer_group\", \"custom_appointment_date\", \"column_break0\", \"territory\", \"gender\", \"lead_name\", \"opportunity_name\", \"account_manager\", \"image\", \"defaults_tab\", \"default_currency\", \"default_bank_account\", \"column_break_14\", \"default_price_list\", \"custom_previous_year_price\", \"internal_customer_section\", \"is_internal_customer\", \"represents_company\", \"column_break_70\", \"companies\", \"more_info\", \"market_segment\", \"industry\", \"customer_pos_id\", \"website\", \"language\", \"column_break_45\", \"customer_details\", \"contact_and_address_tab\", \"address_contacts\", \"address_html\", \"column_break1\", \"contact_html\", \"custom_related_addresses\", \"custom_select_address\", \"custom_associated_contacts\", \"custom_add_contacts\", \"custom_primary_billing_and_contact_details\", \"custom_billing_address\", \"custom_column_break_q0puw\", \"custom_billing_contact\", \"primary_address_and_contact_detail\", \"column_break_26\", \"primary_address\", \"customer_primary_address\", \"column_break_nwor\", \"customer_primary_contact\", \"mobile_no\", \"email_id\", \"tax_tab\", \"taxation_section\", \"tax_id\", \"column_break_21\", \"tax_category\", \"tax_withholding_category\", \"accounting_tab\", \"credit_limit_section\", \"payment_terms\", \"credit_limits\", \"default_receivable_accounts\", \"accounts\", \"loyalty_points_tab\", \"loyalty_program\", \"column_break_54\", \"loyalty_program_tier\", \"sales_team_tab\", \"sales_team\", \"sales_team_section\", \"default_sales_partner\", \"column_break_66\", \"default_commission_rate\", \"settings_tab\", \"so_required\", \"dn_required\", \"exempt_from_sales_tax\", \"column_break_53\", \"is_frozen\", \"disabled\", \"portal_users_tab\", \"portal_users\", \"dashboard_tab\"]"
},
{
"default_value": null,
"doc_type": "SMS Log",
@ -12751,22 +12719,6 @@
"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",
@ -15119,38 +15071,6 @@
"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",
@ -15167,22 +15087,6 @@
"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",
@ -15262,5 +15166,117 @@
"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\", \"requires_half_payment\", \"is_half_down_paid\", \"is_scheduled\", \"invoice_status\", \"custom_completion_date\", \"priority\", \"custom_foreman\", \"custom_hidden_fields\", \"department\", \"service_appointment\", \"tasks\", \"ready_to_schedule\", \"is_active\", \"custom_address\", \"custom_section_break_lgkpd\", \"custom_workflow_related_custom_fields__landry\", \"custom_permit_status\", \"custom_utlity_locate_status\", \"custom_crew_scheduling\", \"customer_details\", \"customer\", \"column_break_14\", \"sales_order\", \"users_section\", \"users\", \"copied_from\", \"section_break0\", \"notes\", \"section_break_18\", \"actual_start_date\", \"actual_start_time\", \"actual_time\", \"column_break_20\", \"actual_end_date\", \"actual_end_time\", \"project_details\", \"estimated_costing\", \"total_costing_amount\", \"total_expense_claim\", \"total_purchase_cost\", \"company\", \"column_break_28\", \"total_sales_amount\", \"total_billable_amount\", \"total_billed_amount\", \"total_consumed_material_cost\", \"cost_center\", \"margin\", \"gross_margin\", \"column_break_37\", \"per_gross_margin\", \"monitor_progress\", \"collect_progress\", \"holiday_list\", \"frequency\", \"from_time\", \"to_time\", \"first_email\", \"second_email\", \"daily_time_to_send\", \"day_to_send\", \"weekly_time_to_send\", \"column_break_45\", \"subject\", \"message\"]"
},
{
"default_value": null,
"doc_type": "Customer",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-02-17 03:51:08.179106",
"module": null,
"name": "Customer-main-naming_rule",
"property": "naming_rule",
"property_type": "Data",
"row_name": null,
"value": "Expression"
},
{
"default_value": null,
"doc_type": "Customer",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-02-17 03:51:08.313125",
"module": null,
"name": "Customer-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": "format:{customer_name}-CUST-{MM}-{YYYY}-{#####}"
},
{
"default_value": null,
"doc_type": "Customer",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-02-17 03:51:08.370590",
"module": null,
"name": "Customer-main-field_order",
"property": "field_order",
"property_type": "Data",
"row_name": null,
"value": "[\"basic_info\", \"naming_series\", \"salutation\", \"customer_name\", \"from_lead\", \"properties\", \"contacts\", \"primary_contact\", \"customer_type\", \"customer_group\", \"custom_appointment_date\", \"column_break0\", \"territory\", \"gender\", \"lead_name\", \"opportunity_name\", \"prospect_name\", \"account_manager\", \"image\", \"defaults_tab\", \"default_currency\", \"default_bank_account\", \"column_break_14\", \"default_price_list\", \"custom_previous_year_price\", \"internal_customer_section\", \"is_internal_customer\", \"represents_company\", \"column_break_70\", \"companies\", \"quotations\", \"onsite_meetings\", \"projects\", \"tasks\", \"sales_orders\", \"more_info\", \"market_segment\", \"industry\", \"customer_pos_id\", \"website\", \"language\", \"column_break_45\", \"customer_details\", \"contact_and_address_tab\", \"address_contacts\", \"address_html\", \"column_break1\", \"contact_html\", \"custom_related_addresses\", \"custom_select_address\", \"custom_associated_contacts\", \"custom_add_contacts\", \"custom_primary_billing_and_contact_details\", \"custom_billing_address\", \"custom_column_break_q0puw\", \"custom_billing_contact\", \"primary_address_and_contact_detail\", \"column_break_26\", \"primary_address\", \"customer_primary_address\", \"column_break_nwor\", \"customer_primary_contact\", \"mobile_no\", \"email_id\", \"first_name\", \"last_name\", \"tax_tab\", \"taxation_section\", \"tax_id\", \"column_break_21\", \"tax_category\", \"tax_withholding_category\", \"accounting_tab\", \"credit_limit_section\", \"payment_terms\", \"credit_limits\", \"default_receivable_accounts\", \"accounts\", \"loyalty_points_tab\", \"loyalty_program\", \"column_break_54\", \"loyalty_program_tier\", \"sales_team_tab\", \"sales_team\", \"sales_team_section\", \"default_sales_partner\", \"column_break_66\", \"default_commission_rate\", \"settings_tab\", \"so_required\", \"dn_required\", \"exempt_from_sales_tax\", \"column_break_53\", \"is_frozen\", \"disabled\", \"portal_users_tab\", \"portal_users\", \"dashboard_tab\"]"
},
{
"default_value": null,
"doc_type": "Contact",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2026-02-17 03:53:14.660505",
"module": null,
"name": "Contact-main-autoname",
"property": "autoname",
"property_type": "Data",
"row_name": null,
"value": "format:{full_name}-CONT-{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-02-17 08:16:54.459462",
"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\", \"is_service_address\", \"customers\", \"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": "2026-02-17 08:16:54.562064",
"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-02-17 08:16:54.619557",
"module": null,
"name": "Address-main-states_order",
"property": "states_order",
"property_type": "Small Text",
"row_name": null,
"value": "[\"62m56h85vo\", \"62m5uugrvr\", \"62m57bgpkf\", \"62m5fgrjb0\"]"
}
]

View File

@ -324,31 +324,6 @@
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Start",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Permits required prior to installation start.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-02-08 01:48:15.012387",
"name": "Permit",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Permit",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
"weight": 7.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
@ -424,6 +399,31 @@
"weight": 25.0,
"work_type": "Labor"
},
{
"base_date": "Start",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Permits required prior to installation start.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-02-08 01:48:15.012387",
"name": "Permit",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Permit",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
"weight": 7.0,
"work_type": "Admin"
},
{
"base_date": "Project Start",
"calculate_from": "Service Appointment",

View File

@ -0,0 +1,20 @@
import frappe
class DBRestoreService:
@staticmethod
def massage_customer_address_contact_links():
"""Fixes the links between Customer, Address, and Contacts from legacy data that may have been imported without proper linking."""
# use emojis in print statments to make it more fun and visually distinct in the logs
print("DEBUG: 🛠️ Starting to massage customer, address, and contact links")
all_addresses = frappe.get_all("Address", pluck="name")
print(f"DEBUG: Found {len(all_addresses)} addresses to process")
all_customers = frappe.get_all("Customer", pluck="name")
print(f"DEBUG: Found {len(all_customers)} customers to process")
all_leads = frappe.get_all("Lead", pluck="name")
print(f"DEBUG: Found {len(all_leads)} leads to process")
all_contacts = frappe.get_all("Contact", pluck="name")
print(f"DEBUG: Found {len(all_contacts)} contacts to process")
# query all customer doctypes that don't have an empty array for custom_select_address. This field is a child table so get_all cannot be used. We need to use a custom sql query
# the child table is a doctype called "Custom "
# print(f"DEBUG: Found {len(customers_with_addresses)} customers with addresses to process")

View File

@ -24,6 +24,7 @@ class ServiceAppointmentService:
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()
service_appointment["color"] = frappe.get_value("Project Template", service_appointment["project_template"], "calendar_color")
return service_appointment
@ -43,20 +44,21 @@ class ServiceAppointmentService:
if end_time:
service_appointment.expected_end_time = end_time
if skip_days:
current_skip_days
print(f"DEBUG: Updating skip days for Service Appointment {service_appointment_name}. Current skip days: {[skip_day.date for skip_day in service_appointment.skip_days]}, New skip days: {skip_days}")
# Compare skip_days with the current skip_days and remove/add as needed
current_skip_days = set([skip_day.date for skip_day in service_appointment.skip_days])
new_skip_days = set(skip_days)
current_skip_days = [skip_day.date for skip_day in service_appointment.skip_days]
# Remove skip days that are no longer needed
for skip_day in current_skip_days - new_skip_days:
skip_day_doc = service_appointment.skip_days.find(lambda d: d.date == skip_day)
if skip_day_doc:
service_appointment.skip_days.remove(skip_day_doc.name)
print(f"DEBUG: Removed skip day {skip_day} from Service Appointment {service_appointment_name}")
for skip_day in current_skip_days:
if skip_day not in skip_days:
skip_day_doc = service_appointment.skip_days.find(lambda d: d.date == skip_day)
if skip_day_doc:
service_appointment.skip_days.remove(skip_day_doc.name)
print(f"DEBUG: Removed skip day {skip_day} from Service Appointment {service_appointment_name}")
# Add new skip days
for skip_day in new_skip_days - current_skip_days:
service_appointment.append("skip_days", {"date": skip_day})
print(f"DEBUG: Added new skip day {skip_day} for Service Appointment {service_appointment_name}")
for skip_day in skip_days:
if skip_day not in current_skip_days:
service_appointment.append("skip_days", {"date": skip_day["date"]})
print(f"DEBUG: Added new skip day {skip_day} for Service Appointment {service_appointment_name}")
service_appointment.save()
print(f"DEBUG: Updated scheduled dates for Service Appointment {service_appointment_name}")
return service_appointment

View File

@ -68,6 +68,7 @@ const FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD = "custom_ui.api.db.clients.get_cli
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_CHECK_CLIENT_EXISTS_METHOD = "custom_ui.api.db.clients.check_client_exists";
const FRAPPE_ADD_ADDRESSES_CONTACTS_METHOD = "custom_ui.api.db.clients.add_addresses_contacts";
// 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";
@ -81,7 +82,7 @@ const FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD = "custom_ui.api.
const FRAPPE_UPDATE_SERVICE_APPOINTMENT_STATUS_METHOD = "custom_ui.api.db.service_appointments.update_service_appointment_status";
class Api {
// ============================================================================
// CORE REQUEST METHOPD
// CORE REQUEST METHOD
// ============================================================================
static async request(frappeMethod, args = {}) {
@ -183,6 +184,10 @@ class Api {
return result;
}
static async addAddressesAndContacts(clientName, companyName, addresses = [], contacts = []) {
return await this.request(FRAPPE_ADD_ADDRESSES_CONTACTS_METHOD, { clientName, companyName, addresses, contacts });
}
// ============================================================================
// ON-SITE MEETING METHODS
// ============================================================================

View File

@ -57,7 +57,9 @@
<v-card-title class="text-subtitle-1 py-2">
Select Project Templates
</v-card-title>
<v-divider></v-divider>
if (isHoliday(date)) {
classes.push('holiday');
}
<v-card-text class="pa-2">
<v-list density="compact">
<v-list-item @click="toggleAllTemplates">
@ -203,7 +205,7 @@
<div class="calendar-section">
<div class="weekly-calendar">
<!-- Days Header -->
<div class="calendar-header-row" :style="{ gridTemplateColumns: `150px repeat(7, 1fr)` }">
<div class="calendar-header-row" :style="{ gridTemplateColumns: `150px repeat(7, 140px)` }">
<div class="crew-column-header">Crew</div>
<div
v-for="day in weekDays"
@ -218,7 +220,7 @@
<!-- Foremen Grid -->
<div class="calendar-grid">
<div v-for="foreman in visibleForemen" :key="foreman.name" class="foreman-row" :style="{ gridTemplateColumns: `150px repeat(7, 1fr)` }">
<div v-for="foreman in visibleForemen" :key="foreman.name" class="foreman-row" :style="{ gridTemplateColumns: `150px repeat(7, 140px)` }">
<!-- Foreman/Crew Column -->
<div class="crew-column">
<div class="crew-name">{{ foreman.employeeName }}</div>
@ -230,67 +232,65 @@
v-for="day in weekDays"
:key="`${foreman.name}-${day.date}`"
class="day-cell"
:class="{
'today': isToday(day.date),
'holiday': isHoliday(day.date),
'sunday': isSunday(day.date),
'drag-over': isDragOver && dragOverCell?.foremanId === foreman.name && dragOverCell?.date === day.date,
'has-skipped-jobs': getSkippedJobsForCell(foreman.name, day.date).length > 0,
}"
:class="getCellStyling(foreman.name, day.date).classes"
:style="Object.assign({}, getCellStyling(foreman.name, day.date).data.backgroundColor ? { background: getCellStyling(foreman.name, day.date).data.backgroundColor } : {}, getCellStyling(foreman.name, day.date).data.rightBorder ? { borderRight: '2px solid #333' } : {})"
:draggable="getCellStyling(foreman.name, day.date).data.jobs.length > 0"
@dragstart="handleCellDragStart($event, foreman.name, day.date)"
@dragover="handleDragOver($event, foreman.name, day.date)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, foreman.name, day.date)"
@click="skipMode ? handleSkipDayClick(foreman.name, day.date) : null"
@click="skipMode ? handleSkipDayClick(foreman.name, day.date) : handleCellClick(foreman.name, day.date, $event)"
@mousedown="(event) => handleCellMouseDown(event, foreman.name, day.date)"
>
<!-- Jobs in this day -->
<div
v-for="job in getJobsForCell(foreman.name, day.date)"
:key="job.name"
class="calendar-job"
:style="getJobStyle(job, day.date)"
:draggable="job.status === 'Scheduled'"
@click.stop="skipMode ? handleSkipDayClick(foreman.name, day.date, job) : showEventDetails({ event: job })"
@dragstart="job.status === 'Scheduled' ? handleDragStart(job, $event) : null"
@dragend="handleDragEnd"
@mousedown="(job.status === 'Scheduled' || job.status === 'Started') ? startResize($event, job, day.date) : null"
@drop.stop.prevent="handleScheduledDrop(job, $event, foreman.name, day.date)"
@dragover.stop.prevent="handleScheduledDragOver(job, $event, foreman.name, day.date)"
>
<v-icon v-if="jobStartsBeforeWeek(job)" size="small" class="spans-arrow-left">mdi-arrow-left</v-icon>
<div class="job-content">
<div class="job-title">{{ job.projectTemplate }}</div>
<div class="job-address">{{ job.serviceAddress.fullAddress }}</div>
</div>
<v-icon v-if="jobSpansToNextWeek(job)" size="small" class="spans-arrow-right">mdi-arrow-right</v-icon>
<div class="resize-handle"></div>
</div>
<!-- Skipped days -->
<div
v-for="job in getSkippedJobsForCell(foreman.name, day.date)"
:key="`skip-${job.name}`"
class="skipped-day"
:class="getPriorityClass(job.priority)"
>
<span>Skipped</span>
<button
class="remove-skip-btn"
@click.stop="handleRemoveSkip(job, day.date)"
title="Remove skipped day"
>
<v-icon size="small">mdi-close</v-icon>
</button>
</div>
<!-- Job content for scheduled cells -->
<template v-if="getCellStyling(foreman.name, day.date).data.jobs.length > 0 && !getCellStyling(foreman.name, day.date).data.isSkipped">
<div class="job-content">
<!-- Start icon/component -->
<span v-if="getCellStyling(foreman.name, day.date).data.showStartIcon" class="job-start-icon">
<v-icon size="small">mdi-play-circle</v-icon>
</span>
<!-- End icon/component -->
<span v-if="getCellStyling(foreman.name, day.date).data.showEndIcon" class="job-end-icon">
<v-icon size="small">mdi-stop-circle</v-icon>
</span>
<!-- Address info only if showAddress -->
<div v-if="getCellStyling(foreman.name, day.date).data.showAddress" class="job-address">
{{ getCellStyling(foreman.name, day.date).data.jobs[0].serviceAddress.fullAddress }}
</div>
<div class="job-title">{{ getCellStyling(foreman.name, day.date).data.jobs[0].projectTemplate }}</div>
</div>
<!-- Resize handle for job end cells -->
<div
v-if="getCellStyling(foreman.name, day.date).data.showResizeBar"
class="resize-handle"
@mousedown.stop="(event) => startResize(event, getCellStyling(foreman.name, day.date).data.jobs[0], day.date)"
></div>
<!-- Arrows for jobs spanning weeks -->
<v-icon
v-if="jobStartsBeforeWeek(getCellStyling(foreman.name, day.date).data.jobs[0])"
size="small"
class="spans-arrow-left"
>mdi-arrow-left</v-icon>
<v-icon
v-if="jobSpansToNextWeek(getCellStyling(foreman.name, day.date).data.jobs[0])"
size="small"
class="spans-arrow-right"
>mdi-arrow-right</v-icon>
</template>
<!-- Holiday connector line for split jobs -->
<template v-if="isHoliday(day.date)">
<div
v-for="job in getJobsWithConnector(foreman.name, day.date)"
:key="`connector-${job.name}`"
class="holiday-connector"
:class="getPriorityClass(job.priority)"
></div>
</template>
<!-- Skipped day content -->
<template v-if="getCellStyling(foreman.name, day.date).data.isSkipped">
<span>Skipped</span>
<button
class="remove-skip-btn"
@click.stop="handleRemoveSkip(getCellStyling(foreman.name, day.date).data.skipJob, day.date)"
title="Remove skipped day"
>
<v-icon size="small">mdi-close</v-icon>
</button>
</template>
</div>
</div>
</div>
@ -716,24 +716,120 @@ const formatDuration = (minutes) => {
return `${hours}h ${mins}m`;
};
// Get jobs for a specific foreman and date
const getJobsForCell = (foremanId, date) => {
// Don't render jobs on Sunday or holidays
if (isSunday(date) || isHoliday(date)) return [];
// Get cell styling classes and data for a specific foreman and date
const getCellStyling = (foreman, date) => {
const classes = [];
const data = { jobs: [], isSkipped: false, skipJob: null };
return scheduledServices.value.filter((job) => {
if (job.foreman !== foremanId) return false;
const jobStart = job.expectedStartDate;
const jobEnd = job.expectedEndDate || job.expectedStartDate;
// Check if this date falls within the job's date range
// AND that it's a valid segment start date
// AND not in skipDays
const segments = getJobSegments(job);
const isSkipped = job.skipDays && job.skipDays.some(skip => skip.date === date);
return segments.some(seg => seg.start === date) && !isSkipped;
// Check if holiday
if (isHoliday(date)) {
classes.push('holiday');
}
// Check if Sunday
if (isSunday(date)) {
classes.push('sunday');
}
// Check if today
if (isToday(date)) {
classes.push('today');
}
// Get jobs for this cell
const jobs = scheduledServices.value.filter(job => {
if (job.foreman !== foreman) return false;
const start = job.expectedStartDate;
const end = job.expectedEndDate || start;
return date >= start && date <= end;
});
data.jobs = jobs;
// Check for skipped days
const skippedJobs = jobs.filter(job => {
return job.skipDays && job.skipDays.some(skip => skip.date === date);
});
if (skippedJobs.length > 0) {
data.isSkipped = true;
data.skipJob = skippedJobs[0]; // Take first one
classes.push('skipped');
// Add background color for skipped cell
data.backgroundColor = (skippedJobs[0].color || '#2196f3') + '40';
return { classes, data };
}
// If no jobs, return basic styling
if (jobs.length === 0) {
classes.push('empty');
return { classes, data };
}
// Cell is part of a scheduled job - determine styling
const jobForStyling = jobs[0];
const startDate = jobForStyling.expectedStartDate;
const endDate = jobForStyling.expectedEndDate || startDate;
const isStart = date === startDate;
const isEnd = date === endDate;
// Add priority class
classes.push(getPriorityClass(jobForStyling.priority));
// Add job color for all scheduled days
data.backgroundColor = jobForStyling.color || '#2196f3';
// Border styling for start/end
if (isStart) {
classes.push('job-start');
data.showStartIcon = true;
data.showAddress = true;
} else {
classes.push('job-continuation');
// Check if previous day was skipped
const prevDate = addDays(date, -1);
const prevSkipped = jobForStyling.skipDays && jobForStyling.skipDays.some(skip => skip.date === prevDate);
if (prevSkipped) {
classes.push('after-skip');
}
// Check if previous dates are on previous week
const weekStart = parseLocalDate(weekStartDate.value);
const cellDate = parseLocalDate(date);
if (cellDate.getDay() === 1 && cellDate.getTime() > weekStart.getTime()) { // Monday and not first Monday
classes.push('after-week-break');
}
}
// Right side styling
if (isEnd) {
classes.push('job-end');
data.showEndIcon = true;
// Only add right border for end cell
data.rightBorder = true;
} else {
data.rightBorder = false;
}
// Add job-end class on every update
classes.push('job-end');
// Check if next day is skipped
const nextDate = addDays(date, 1);
const nextSkipped = jobForStyling.skipDays && jobForStyling.skipDays.some(skip => skip.date === nextDate);
if (nextSkipped) {
classes.push('before-skip');
}
// Check if job continues to next week
const weekEnd = parseLocalDate(addDays(weekStartDate.value, 6));
const endDateObj = parseLocalDate(endDate);
if (endDateObj > weekEnd) {
classes.push('continues-next-week');
}
// Only show resize bar on end cell
data.showResizeBar = isEnd;
return { classes, data };
};
// Get skipped jobs for a specific foreman and date
@ -788,15 +884,17 @@ const getJobStyle = (job, currentDate) => {
? 'calc(100% - 8px)' // Single day: full width minus padding
: `calc(${visualDays * 100}% + ${(visualDays - 1)}px)`; // Multi-day: span cells accounting for borders
// Get color from project template
let backgroundColor = '#2196f3'; // Default color
if (job.projectTemplate) {
const template = projectTemplates.value.find(t => t.name === job.projectTemplate);
if (template && template.calendarColor) {
backgroundColor = template.calendarColor;
}
// Use job.color for cell background if available
let backgroundColor = job.color || '#2196f3';
// Use higher opacity for skip days
if (job.skipDays && job.skipDays.some(skip => skip.date === currentDate)) {
backgroundColor = backgroundColor.replace(/\)$/,", 0.25)") || backgroundColor + '40';
}
const darkerColor = darkenColor(backgroundColor, 20);
return {
width: widthCalc,
zIndex: 10,
background: backgroundColor
};
return {
width: widthCalc,
@ -909,14 +1007,40 @@ const onDateSelected = (date) => {
}
};
const showEventDetails = (event) => {
// Don't open modal if we just finished resizing
if (justFinishedResize.value) {
justFinishedResize.value = false;
return;
const handleCellDragStart = (event, foremanId, date) => {
const cellData = getCellStyling(foremanId, date);
if (cellData.data.jobs.length > 0 && !cellData.data.isSkipped) {
const job = cellData.data.jobs[0];
if (job.status === 'Scheduled') {
handleDragStart(job, event);
} else {
// Prevent dragging if not scheduled
event.preventDefault();
}
} else {
// Prevent dragging skipped cells or empty cells
event.preventDefault();
}
};
const handleCellMouseDown = (event, foremanId, date) => {
const cellData = getCellStyling(foremanId, date);
if (cellData.data.jobs.length > 0 && !cellData.data.isSkipped) {
const job = cellData.data.jobs[0];
if (job.status === 'Scheduled' || job.status === 'Started') {
// Check if click is on resize handle area (right edge)
const target = event.target;
const rect = target.closest('.day-cell').getBoundingClientRect();
const clickX = event.clientX;
const isNearRightEdge = clickX > rect.right - 20; // 20px from right edge
if (isNearRightEdge && cellData.classes.includes('job-end')) {
event.preventDefault(); // Only prevent default for resize
startResize(event, job, date);
}
// If not near right edge, allow normal drag behavior
}
}
selectedEvent.value = event.event;
eventDialog.value = true;
};
const scheduleService = (service) => {
@ -933,20 +1057,6 @@ const handleDragStart = (service, event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", service.name);
// Get the dimensions of the dragged element
const dragElement = event.target;
const rect = dragElement.getBoundingClientRect();
// Set the drag image offset to center the element horizontally and position cursor at top
const offsetX = rect.width / 2;
const offsetY = 10;
try {
event.dataTransfer.setDragImage(dragElement, offsetX, offsetY);
} catch (e) {
console.log("Could not set custom drag image");
}
// Add visual feedback
event.target.style.opacity = '0.5';
console.log("Drag started for service:", service.projectTemplate);
@ -1315,46 +1425,12 @@ const handleUnscheduledDrop = async (event) => {
// Allow moving scheduled jobs between days/crews if status is 'Scheduled'
const handleScheduledDragOver = (job, event, foremanId, date) => {
if (!draggedService.value) return;
// Only allow if dragging a scheduled job and target is a valid cell
if (draggedService.value.status === 'Scheduled' && job.status === 'Scheduled') {
event.dataTransfer.dropEffect = 'move';
}
// This is now handled by the main handleDragOver function
};
const handleScheduledDrop = async (job, event, foremanId, date) => {
if (!draggedService.value) return;
// Only allow if dragging a scheduled job and target is a valid cell
if (draggedService.value.status !== 'Scheduled') return;
// Prevent dropping on same cell
if (draggedService.value.name === job.name && draggedService.value.foreman === foremanId && draggedService.value.expectedStartDate === date) return;
// Prevent dropping on Sunday or holidays
if (isSunday(date) || isHoliday(date)) return;
// Update job's foreman and date
const serviceIndex = scheduledServices.value.findIndex(s => s.name === draggedService.value.name);
if (serviceIndex !== -1) {
try {
await Api.updateServiceAppointmentScheduledDates(
draggedService.value.name,
date,
draggedService.value.expectedEndDate,
foremanId
);
scheduledServices.value[serviceIndex] = {
...scheduledServices.value[serviceIndex],
expectedStartDate: date,
foreman: foremanId
};
notifications.addSuccess('Job moved successfully!');
} catch (error) {
notifications.addError('Failed to move job');
}
}
isDragOver.value = false;
dragOverCell.value = null;
draggedService.value = null;
// Allow moving scheduled jobs between days/crews if status is 'Scheduled'
const handleScheduledDrop = async (event, foremanId, date) => {
// This is now handled by the main handleDrop function
};
// Resize functionality
@ -1366,28 +1442,28 @@ const startResize = (event, job, date) => {
const target = event.target;
const rect = target.getBoundingClientRect();
const clickX = event.clientX;
// Check if click is on the resize handle or near right edge
const isNearRightEdge = clickX > rect.right - 10;
const isResizeHandle = target.classList.contains('resize-handle');
// Allow repeated extension: remove restriction on job.expectedEndDate
// (no early return)
if (!isNearRightEdge && !isResizeHandle) return;
event.stopPropagation();
event.preventDefault();
// Set flag immediately to prevent modal from opening
justFinishedResize.value = true;
resizingJob.value = job;
resizeStartX.value = event.clientX;
resizeStartDate.value = date;
originalEndDate.value = job.expectedEndDate || job.expectedStartDate;
// Add global mouse move and mouse up listeners
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
// Add visual feedback
document.body.style.cursor = 'ew-resize';
};
@ -1727,7 +1803,7 @@ onMounted(async () => {
}
.weekly-calendar {
min-width: 1000px;
min-width: 1130px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
@ -1812,15 +1888,20 @@ onMounted(async () => {
}
.day-cell {
border-right: 1px solid var(--surface-border);
border: 1px solid #e0e0e0;
position: relative;
cursor: pointer;
transition: background-color 0.2s;
min-height: 80px;
max-height: 80px;
padding: 4px;
overflow: visible; /* Allow multi-day jobs to overflow */
width: 100%; /* Ensure cell doesn't expand */
overflow: hidden; /* Prevent content from expanding cell */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-shrink: 0; /* Prevent shrinking */
box-sizing: border-box; /* Include padding in width calculation */
}
.day-cell:hover {
@ -1837,67 +1918,148 @@ onMounted(async () => {
box-sizing: border-box;
}
.calendar-job {
position: absolute;
left: 4px;
top: 4px;
background: linear-gradient(135deg, #2196f3, #1976d2);
color: white;
border-radius: 4px;
padding: 8px 10px;
font-size: 0.85em;
cursor: pointer;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
display: flex;
align-items: center;
min-height: 68px;
height: calc(100% - 8px);
max-width: none; /* Allow spanning */
}
.calendar-job:hover {
transform: scale(1.02);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.calendar-job:hover .resize-handle {
opacity: 1;
}
.calendar-job[draggable="true"] {
.day-cell[draggable="true"] {
cursor: grab;
}
.calendar-job[draggable="true"]:active {
.day-cell[draggable="true"]:active {
cursor: grabbing;
}
/* Job styling based on priority */
.day-cell.priority-urgent {
background: linear-gradient(135deg, #f44336, #d32f2f);
color: white;
}
.day-cell.priority-high {
background: linear-gradient(135deg, #ff9800, #f57c00);
color: white;
}
.day-cell.priority-medium {
background: linear-gradient(135deg, #2196f3, #1976d2);
color: white;
}
.day-cell.priority-low {
background: linear-gradient(135deg, #4caf50, #388e3c);
color: white;
}
/* Job border styling - only for cells that contain jobs */
.day-cell.priority-urgent:not(.empty):not(.skipped),
.day-cell.priority-high:not(.empty):not(.skipped),
.day-cell.priority-medium:not(.empty):not(.skipped),
.day-cell.priority-low:not(.empty):not(.skipped) {
border-top: 3px solid currentColor;
border-bottom: 3px solid currentColor;
}
/* Job start/end border styling */
.day-cell.job-start:not(.skipped) {
border-left: 4px solid currentColor;
}
.day-cell.job-end:not(.skipped) {
border-right: 4px solid currentColor;
}
/* Skip day styling */
.day-cell.skipped {
border: 2px dotted #ff0000;
background: rgba(255, 0, 0, 0.1);
color: #ff0000;
font-weight: 500;
justify-content: center;
align-items: center;
}
/* Special week break borders */
.day-cell.after-week-break:not(.skipped) {
border-left: 4px double currentColor;
}
.day-cell.continues-next-week:not(.skipped) {
border-right: 4px double currentColor;
}
/* Skip day adjacent styling */
.day-cell.after-skip:not(.skipped) {
border-left: 2px dashed currentColor;
}
.day-cell.before-skip:not(.skipped) {
border-right: 2px dashed currentColor;
}
/* Holiday and Sunday styling */
.day-cell.holiday {
background: repeating-linear-gradient(
45deg,
rgba(255, 193, 7, 0.15),
rgba(255, 193, 7, 0.15) 10px,
rgba(255, 193, 7, 0.05) 10px,
rgba(255, 193, 7, 0.05) 20px
);
border-left: 3px solid #ffc107;
border-right: 3px solid #ffc107;
}
.day-cell.sunday {
background-color: rgba(200, 200, 200, 0.1); /* light gray for Sunday */
}
/* Skipped styling */
.day-cell.skipped {
background: rgba(255, 0, 0, 0.1);
border-top: 2px dotted #ff0000;
border-bottom: 2px dotted #ff0000;
color: #ff0000;
font-weight: 500;
justify-content: center;
align-items: center;
}
/* Empty cell styling */
.day-cell.empty {
background-color: transparent;
}
.job-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
width: 100%;
padding: 2px;
box-sizing: border-box;
overflow: hidden;
}
.job-title {
font-weight: 600;
font-size: 0.85em;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
font-size: 0.95em;
max-width: 100%;
box-sizing: border-box;
}
.job-address {
font-size: 0.8em;
font-size: 0.75em;
opacity: 0.9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
max-width: 100%;
line-height: 1.2;
box-sizing: border-box;
}
.resize-handle {
@ -1921,17 +2083,15 @@ onMounted(async () => {
.spans-arrow-left {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
left: 2px;
top: 2px;
color: rgba(255, 255, 255, 0.8);
}
.spans-arrow-right {
position: absolute;
right: 25px; /* Position before resize handle */
top: 50%;
transform: translateY(-50%);
right: 2px;
top: 2px;
color: rgba(255, 255, 255, 0.8);
}

View File

@ -174,6 +174,10 @@ const props = defineProps({
type: Array,
default: () => [],
},
contactOptions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:formData"]);
@ -205,7 +209,7 @@ const localFormData = computed({
const contactOptions = computed(() => {
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
return [];
return props.contactOptions;
}
return localFormData.value.contacts.map((contact, index) => ({
label: `${contact.firstName || ""} ${contact.lastName || ""}`.trim() || `Contact ${index + 1}`,
@ -231,28 +235,6 @@ onMounted(() => {
];
}
});
const addAddress = () => {
localFormData.value.addresses.push({
addressLine1: "",
addressLine2: "",
isBillingAddress: false,
isServiceAddress: true,
pincode: "",
city: "",
state: "",
contacts: [],
primaryContact: null,
zipcodeLookupDisabled: true,
});
};
const removeAddress = (index) => {
if (localFormData.value.addresses.length > 1) {
localFormData.value.addresses.splice(index, 1);
}
};
const formatAddressLine = (index, field, event) => {
const value = event.target.value;
if (!value) return;

View File

@ -149,24 +149,24 @@ const props = defineProps({
const emit = defineEmits(["update:formData"]);
const localFormData = computed({
get: () => {
if (!props.formData.contacts || props.formData.contacts.length === 0) {
props.formData.contacts = [
{
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: true,
},
];
}
return props.formData;
},
get: () => props.formData,
set: (value) => emit("update:formData", value),
});
// Ensure at least one contact always exists
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
localFormData.value.contacts = [
{
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: true,
},
];
}
const roleOptions = ref([
{ label: "Owner", value: "Owner" },
{ label: "Property Manager", value: "Property Manager" },

View File

@ -0,0 +1,99 @@
<template>
<Dialog :visible="visible" @update:visible="val => emit('update:visible', val)" modal :closable="false" :style="{ width: '700px', maxWidth: '95vw' }">
<template #header>
<span class="modal-title">Add Contact/Address</span>
</template>
<div class="modal-body">
<ContactInformationForm
:formData="contactFormData.value"
@update:formData="val => contactFormData.value = val"
:isSubmitting="isSubmitting"
:existingContacts="existingContacts"
/>
<AddressInformationForm
:formData="addressFormData.value"
@update:formData="val => addressFormData.value = val"
:isSubmitting="isSubmitting"
:contactOptions="allContactOptions"
:existingAddresses="existingAddresses"
/>
</div>
<template #footer>
<Button label="Cancel" @click="close" severity="secondary" />
<Button label="Create" @click="create" severity="primary" :loading="isSubmitting" />
</template>
</Dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import ContactInformationForm from '../clientSubPages/ContactInformationForm.vue';
import AddressInformationForm from '../clientSubPages/AddressInformationForm.vue';
const props = defineProps({
visible: Boolean,
clientContacts: { type: Array, default: () => [] },
existingContacts: { type: Array, default: () => [] },
existingAddresses: { type: Array, default: () => [] },
isSubmitting: { type: Boolean, default: false },
});
const emit = defineEmits(['update:visible', 'created']);
const contactFormData = ref({ contacts: [] });
const addressFormData = ref({ addresses: [], contacts: [] });
// Keep addressFormData.contacts in sync with new contacts
watch(
() => contactFormData.value.contacts,
(newContacts) => {
addressFormData.value.contacts = newContacts || [];
},
{ deep: true }
);
// All contact options = clientContacts + new contacts
const allContactOptions = computed(() => {
const clientOpts = (props.clientContacts || []).map((c, idx) => ({
label: `${c.firstName || ''} ${c.lastName || ''}`.trim() || `Contact ${idx + 1}`,
value: `client-${idx}`,
...c,
}));
const newOpts = (contactFormData.value.contacts || []).map((c, idx) => ({
label: `${c.firstName || ''} ${c.lastName || ''}`.trim() || `Contact ${idx + 1}`,
value: `new-${idx}`,
...c,
}));
return [...clientOpts, ...newOpts];
});
function close() {
emit('update:visible', false);
}
function create() {
// Dummy create handler
console.log('Create clicked', {
contacts: contactFormData.value.contacts,
addresses: addressFormData.value.addresses,
});
emit('created', {
contacts: contactFormData.value.contacts,
addresses: addressFormData.value.addresses,
});
close();
}
</script>
<style scoped>
.modal-title {
font-size: 1.2rem;
font-weight: 600;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 0.5rem 0;
}
</style>

View File

@ -1,116 +1,123 @@
<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 class="general-client-info">
<div class="info-grid">
<!-- Add Contact/Address Button (always visible) -->
<div class="lead-badge-container">
<template v-if="isLead">
<Badge value="LEAD" severity="warn" size="large" />
</template>
<div class="action-buttons">
<Button size="small" variant="outlined" color="primary" @click="openAddModal">
<v-icon left size="small">mdi-account-plus</v-icon>
Add Contact/Address
</Button>
</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>
<!-- 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>
</div>
<!-- Creation Date -->
<div class="info-section">
<label>Created</label>
<span class="info-value">{{ formattedCreationDate }}</span>
<!-- 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>
<AddContactAddressModal
:visible="showAddModal"
@update:visible="showAddModal = $event"
:clientContacts="clientData.contacts || []"
:existingContacts="clientData.contacts?.map(c => c.fullName || c.name) || []"
:existingAddresses="clientData.addresses?.map(a => a.addressLine1) || []"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import Badge from "primevue/badge";
import Tag from "primevue/tag";
import AddContactAddressModal from './AddContactAddressModal.vue';
import Button from 'primevue/button';
import { ref } from 'vue';
const showAddModal = ref(false);
const openAddModal = () => {
showAddModal.value = true;
};
const props = defineProps({
clientData: {
@ -166,16 +173,8 @@ const formattedCreationDate = computed(() => {
});
});
// 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>