From ddf758f4b6dfea4a8bee8d9c4f645f8b4d335103 Mon Sep 17 00:00:00 2001 From: rocketdebris Date: Mon, 19 Jan 2026 13:21:28 -0500 Subject: [PATCH 01/40] Removed unneeded debug console logs. --- frontend/src/components/common/DataTable.vue | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 4a5713b..1a1720e 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -529,7 +529,6 @@ const setMenuRef = (el, label, id) => { if (el) { const refName = `${label}-${id}`; menuRefs[refName] = el; - console.log("Setting Menu Ref:", refName, el); } } @@ -1074,12 +1073,9 @@ const handleActionClick = (action, rowData = null) => { }; const toggleMenu = (event, action, rowData) => { - console.log("Menu button toggled"); const menuKey = `${action.label}-${rowData.id}`; - console.log("Looking for menu:", menuKey, menuRefs); const menu = menuRefs[menuKey]; if (menu) { - console.log("Found menu, toggling:", menu); menu.toggle(event); activeMenuKey.value = menuKey; } else { @@ -1088,11 +1084,9 @@ const toggleMenu = (event, action, rowData) => { }; const buildMenuItems = (menuItems, rowData) => { - console.log("DEBUG: Building menuItems:", menuItems); return menuItems.map(item => ({ ...item, command: () => { - console.log("Clicked from Datatable"); if (typeof item.command === 'function') { item.command(rowData); } From 6ae6ae6812d3fefc1374fc6f034d1c50be4ca4bb Mon Sep 17 00:00:00 2001 From: rocketdebris Date: Mon, 19 Jan 2026 15:38:47 -0500 Subject: [PATCH 02/40] Added routing to filter the datatable based on the subject line of the task. --- frontend/src/components/pages/Tasks.vue | 27 ++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/pages/Tasks.vue b/frontend/src/components/pages/Tasks.vue index 1e9515e..0e7a9ec 100644 --- a/frontend/src/components/pages/Tasks.vue +++ b/frontend/src/components/pages/Tasks.vue @@ -21,18 +21,22 @@ From 7710a7c8fed93bbea03c9ee7a81a91b3cd06f16f Mon Sep 17 00:00:00 2001 From: Casey Date: Tue, 20 Jan 2026 00:58:03 -0600 Subject: [PATCH 05/40] updates for company effects --- custom_ui/api/db/bid_meetings.py | 11 +- custom_ui/api/db/clients.py | 77 +++- custom_ui/api/db/jobs.py | 69 ++-- custom_ui/db_utils.py | 4 + custom_ui/events/jobs.py | 25 +- custom_ui/events/task.py | 21 +- custom_ui/fixtures/doctype.json | 382 +++++++++++++++--- custom_ui/fixtures/property_setter.json | 1 + custom_ui/hooks.py | 17 +- custom_ui/install.py | 56 ++- frontend/src/api.js | 29 +- .../calendar/CalendarNavigation.vue | 40 +- .../components/calendar/bids/ScheduleBid.vue | 25 +- ...tallsCalendar.vue => ProjectsCalendar.vue} | 244 ++++++++++- .../components/clientSubPages/Overview.vue | 9 +- frontend/src/components/common/DataTable.vue | 4 +- .../common/GlobalLoadingOverlay.vue | 65 ++- frontend/src/components/pages/Calendar.vue | 3 + frontend/src/components/pages/Clients.vue | 27 +- frontend/src/components/pages/Job.vue | 2 +- frontend/src/components/pages/Jobs.vue | 2 +- frontend/src/components/pages/Tasks.vue | 14 +- 22 files changed, 941 insertions(+), 186 deletions(-) create mode 100644 custom_ui/fixtures/property_setter.json rename frontend/src/components/calendar/jobs/{InstallsCalendar.vue => ProjectsCalendar.vue} (80%) diff --git a/custom_ui/api/db/bid_meetings.py b/custom_ui/api/db/bid_meetings.py index bc73060..3be0432 100644 --- a/custom_ui/api/db/bid_meetings.py +++ b/custom_ui/api/db/bid_meetings.py @@ -4,7 +4,7 @@ from custom_ui.db_utils import build_error_response, build_success_response, pro from custom_ui.services import DbService, ClientService, AddressService, ContactService @frappe.whitelist() -def get_week_bid_meetings(week_start, week_end): +def get_week_bid_meetings(week_start, week_end, company): """Get On-Site Meetings scheduled within a specific week.""" try: meetings = frappe.db.get_all( @@ -12,7 +12,8 @@ def get_week_bid_meetings(week_start, week_end): fields=["*"], filters=[ ["start_time", ">=", week_start], - ["start_time", "<=", week_end] + ["start_time", "<=", week_end], + ["company", "=", company] ], order_by="start_time asc" ) @@ -27,7 +28,7 @@ def get_week_bid_meetings(week_start, week_end): return build_error_response(str(e), 500) @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.""" try: print("DEBUG: Raw bid meeting options received:", filters) @@ -53,13 +54,13 @@ def get_bid_meetings(fields=["*"], filters={}): @frappe.whitelist() -def get_unscheduled_bid_meetings(): +def get_unscheduled_bid_meetings(company): """Get On-Site Meetings that are unscheduled.""" try: meetings = frappe.db.get_all( "On-Site Meeting", fields=["*"], - filters={"status": "Unscheduled"}, + filters={"status": "Unscheduled", "company": company}, order_by="creation desc" ) for meeting in meetings: diff --git a/custom_ui/api/db/clients.py b/custom_ui/api/db/clients.py index 0edb984..b306528 100644 --- a/custom_ui/api/db/clients.py +++ b/custom_ui/api/db/clients.py @@ -1,5 +1,5 @@ import frappe, json -from custom_ui.db_utils import build_error_response, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, map_lead_client, build_address_title +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 custom_ui.api.db.addresses import address_exists from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links @@ -167,6 +167,81 @@ def get_client_v2(client_name): return build_error_response(str(ve), 400) except Exception as e: return build_error_response(str(e), 500) + + + +@frappe.whitelist() +def get_clients_table_data_v2(filters={}, sortings=[], page=1, page_size=10): + """Get paginated client table data with filtering and sorting support.""" + try: + filters = json.loads(filters) if isinstance(filters, str) else filters + sortings = json.loads(sortings) if isinstance(sortings, str) else sortings + page = int(page) + page_size = int(page_size) + print("DEBUG: Raw client table query received:", { + "filters": filters, + "sortings": sortings, + "page": page, + "page_size": page_size + }) + where_clauses = [] + values = [] + if filters.get("company"): + where_clauses.append("c.company = %s") + values.append(filters["company"]["value"]) + + if filters.get("address"): + where_clauses.append("a.full_address LIKE %s") + values.append(f"%{filters['address']['value']}%") + + if filters.get("customer_name"): + where_clauses.append("a.customer_name LIKE %s") + values.append(f"%{filters['customer_name']['value']}%") + + where_sql = "" + if where_clauses: + where_sql = "WHERE " + " AND ".join(where_clauses) + + offset = (page - 1) * page_size + + address_names = frappe.db.sql(f""" + SELECT DISTINCT a.name + FROM `tabAddress` a + LEFT JOIN `tabAddress Company Link` c ON c.parent = a.name + {where_sql} + ORDER BY a.modified DESC + LIMIT %s OFFSET %s + """, values + [page_size, offset], as_dict=True) + print("DEBUG: Address names retrieved:", address_names) + + count = frappe.db.sql(f""" + SELECT COUNT(DISTINCT a.name) as count + FROM `tabAddress` a + LEFT JOIN `tabAddress Company Link` c ON c.parent = a.name + {where_sql} + """, values, as_dict=True)[0]["count"] + tableRows = [] + for address_name in address_names: + address = AddressService.get_or_throw(address_name["name"]) + tableRow = {} + tableRow["id"] = address.name + tableRow["address"] = address.full_address + tableRow["client_type"] = address.customer_type + tableRow["customer_name"] = normalize_name(address.customer_name, "-#-") + tableRow["companies"] = ", ".join([link.company for link in address.get("companies", [])]) + tableRows.append(tableRow) + + table_data = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size) + + return build_success_response(table_data) + except frappe.ValidationError as ve: + return build_error_response(str(ve), 400) + except Exception as e: + print("ERROR in get_clients_table_data_v2:", str(e)) + return build_error_response(str(e), 500) + + + @frappe.whitelist() diff --git a/custom_ui/api/db/jobs.py b/custom_ui/api/db/jobs.py index 9b7b419..5efe3a0 100644 --- a/custom_ui/api/db/jobs.py +++ b/custom_ui/api/db/jobs.py @@ -50,6 +50,8 @@ def get_job(job_id=""): project = project.as_dict() project["job_address"] = address_doc project["client"] = ClientService.get_client_or_throw(project.customer) + task_names = frappe.get_all("Task", filters={"project": job_id}) + project["tasks"] = [frappe.get_doc("Task", task_name).as_dict() for task_name in task_names] return build_success_response(project) except Exception as e: return build_error_response(str(e), 500) @@ -167,55 +169,32 @@ def upsert_job(data): return {"status": "error", "message": str(e)} @frappe.whitelist() -def get_install_projects(start_date=None, end_date=None): +def get_projects_for_calendar(date, company=None, project_templates=[]): """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("šŸ“…šŸ“…šŸ“…", date, "company:", company, "project_templates:", project_templates, "type:", type(project_templates)) 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 + filters["expected_start_date"] = date # If date range provided, we could filter, but for now let's fetch all open/active ones # or maybe filter by status not Closed/Completed if we want active ones. # The user said "unscheduled" are those with status "Open" (and no date). + # extend filters into unscheduled_filters - projects = frappe.get_all("Project", fields=["*"], filters=filters) - - calendar_events = [] - for project in projects: - # Determine status - status = "unscheduled" - if project.get("expected_start_date"): - status = "scheduled" - - # Map to calendar event format - event = { - "id": project.name, - "serviceType": project.project_name, # Using project name as service type/title - "customer": project.customer, - "status": status, - "scheduledDate": project.expected_start_date, - "scheduledTime": "08:00", # Default time if not specified? Project doesn't seem to have time. - "duration": 480, # Default 8 hours? - "foreman": project.get("custom_install_crew"), - "crew": [], # Need to map crew - "estimatedCost": project.estimated_costing, - "priority": project.priority.lower() if project.priority else "medium", - "notes": project.notes, - "address": project.custom_installation_address - } - - calendar_events.append(event) - - return {"status": "success", "data": calendar_events} + project_names = frappe.get_all("Project", pluck="name", filters=filters) + print("DEBUG: Found scheduled project names:", project_names) + unscheduled_project_names = frappe.get_all("Project", pluck="name", filters=unscheduled_filters) + print("DEBUG: Found unscheduled project names:", unscheduled_project_names) + projects = [frappe.get_doc("Project", name).as_dict() for name in project_names] + unscheduled_projects = [frappe.get_doc("Project", name).as_dict() for name in unscheduled_project_names] + return build_success_response({ "projects": projects, "unscheduled_projects": unscheduled_projects }) except Exception as e: - return {"status": "error", "message": str(e)} - -@frappe.whitelist() -def get_project_templates_for_company(company_name): - """Get project templates for a specific company.""" - try: - templates = frappe.get_all( - "Project Template", - fields=["*"], - filters={"company": company_name} - ) - return build_success_response(templates) - except Exception as e: - return build_error_response(str(e), 500), + return build_error_response(str(e), 500) \ No newline at end of file diff --git a/custom_ui/db_utils.py b/custom_ui/db_utils.py index 3c577a8..0bf9915 100644 --- a/custom_ui/db_utils.py +++ b/custom_ui/db_utils.py @@ -229,3 +229,7 @@ def build_history_entries(comments, versions): # Sort by timestamp descending history.sort(key=lambda x: x["timestamp"], reverse=True) return history + +def normalize_name(name: str, split_target: str = "_") -> str: + """Normalize a name by splitting off anything after and including the split_target.""" + return name.split(split_target)[0] if split_target in name else name diff --git a/custom_ui/events/jobs.py b/custom_ui/events/jobs.py index aa80dc0..7801611 100644 --- a/custom_ui/events/jobs.py +++ b/custom_ui/events/jobs.py @@ -25,4 +25,27 @@ def after_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 - pass \ No newline at end of file + 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: + doc.is_scheduled = 1 + else: + doc.is_scheduled = 0 + +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") + AddressService.update_value( + doc.job_address, + "job_status", + new_status + ) \ No newline at end of file diff --git a/custom_ui/events/task.py b/custom_ui/events/task.py index 2e32836..fe226a5 100644 --- a/custom_ui/events/task.py +++ b/custom_ui/events/task.py @@ -1,7 +1,24 @@ import frappe +from custom_ui.services import AddressService, ClientService def before_insert(doc, method): """Set values before inserting a Task.""" + print("DEBUG: Before Insert Triggered for Task") project_doc = frappe.get_doc("Project", doc.project) - if project_doc.custom_installation_address: - doc.custom_property = project_doc.custom_installation_address \ No newline at end of file + doc.project_template = project_doc.project_template + 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 } + ) + \ No newline at end of file diff --git a/custom_ui/fixtures/doctype.json b/custom_ui/fixtures/doctype.json index 67f0e8c..d2859ca 100644 --- a/custom_ui/fixtures/doctype.json +++ b/custom_ui/fixtures/doctype.json @@ -44,7 +44,7 @@ "documentation_url": null, "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "company", + "fieldname": "task", "fieldtype": "Link", "hidden": 0, "hide_border": 0, @@ -58,7 +58,7 @@ "in_preview": 0, "in_standard_filter": 0, "is_virtual": 0, - "label": "Company", + "label": "Task", "length": 0, "link_filters": null, "make_attachment_public": 0, @@ -68,8 +68,8 @@ "non_negative": 0, "oldfieldname": null, "oldfieldtype": null, - "options": "Company", - "parent": "Lead Company Link", + "options": "Task", + "parent": "Customer Task Link", "parentfield": "fields", "parenttype": "DocType", "permlevel": 0, @@ -93,6 +93,70 @@ "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": "project_template", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "is_virtual": 0, + "label": "Project Template", + "length": 0, + "link_filters": null, + "make_attachment_public": 0, + "mandatory_depends_on": null, + "max_height": null, + "no_copy": 0, + "non_negative": 0, + "oldfieldname": null, + "oldfieldtype": null, + "options": "Project Template", + "parent": "Customer Task 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, @@ -114,10 +178,228 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "5f481f64a0f53ad40b09d8b5694265c1", - "modified": "2026-01-15 00:40:39.197431", - "module": "Custom", - "name": "Lead Company Link", + "migration_hash": null, + "modified": "2026-01-19 18:10:16.782664", + "module": "Custom UI", + "name": "Customer Task 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 + }, + { + "_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": null, + "depends_on": null, + "description": null, + "documentation_url": null, + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "task", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "is_virtual": 0, + "label": "Task", + "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": "Task", + "parent": "Address Task 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": "project_template", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "is_virtual": 0, + "label": "Project Template", + "length": 0, + "link_filters": null, + "make_attachment_public": 0, + "mandatory_depends_on": null, + "max_height": null, + "no_copy": 0, + "non_negative": 0, + "oldfieldname": null, + "oldfieldtype": null, + "options": "Project Template", + "parent": "Address Task 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-01-19 18:10:02.359022", + "module": "Custom UI", + "name": "Address Task Link", "naming_rule": "", "nsm_parent_field": null, "parent_node": null, @@ -268,8 +550,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.521684", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.097017", "module": "Custom", "name": "Lead Companies Link", "naming_rule": "", @@ -486,8 +768,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.576521", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.150584", "module": "Custom", "name": "Address Project Link", "naming_rule": "", @@ -704,8 +986,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.628136", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.203403", "module": "Custom", "name": "Address Quotation Link", "naming_rule": "", @@ -922,8 +1204,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.681893", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.255846", "module": "Custom", "name": "Address On-Site Meeting Link", "naming_rule": "", @@ -1140,8 +1422,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.737017", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.309600", "module": "Custom", "name": "Address Sales Order Link", "naming_rule": "", @@ -1294,8 +1576,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.787995", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.361237", "module": "Custom", "name": "Contact Address Link", "naming_rule": "", @@ -1448,8 +1730,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.837721", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.412683", "module": "Custom", "name": "Lead On-Site Meeting Link", "naming_rule": "", @@ -2050,8 +2332,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.906370", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.483924", "module": "Selling", "name": "Quotation Template", "naming_rule": "", @@ -2548,8 +2830,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:34.977831", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.558008", "module": "Selling", "name": "Quotation Template Item", "naming_rule": "", @@ -2702,8 +2984,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.031029", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.609372", "module": "Custom UI", "name": "Customer Company Link", "naming_rule": "", @@ -2856,8 +3138,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.084461", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.660893", "module": "Custom UI", "name": "Customer Address Link", "naming_rule": "", @@ -3010,8 +3292,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.135851", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.712878", "module": "Custom UI", "name": "Customer Contact Link", "naming_rule": "", @@ -3164,8 +3446,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.184768", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.765849", "module": "Custom", "name": "Address Contact Link", "naming_rule": "", @@ -3318,8 +3600,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.236428", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.818352", "module": "Custom", "name": "Customer On-Site Meeting Link", "naming_rule": "", @@ -3472,8 +3754,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.287145", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.870984", "module": "Custom", "name": "Customer Project Link", "naming_rule": "", @@ -3626,8 +3908,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.338967", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.922695", "module": "Custom", "name": "Customer Quotation Link", "naming_rule": "", @@ -3780,8 +4062,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.388711", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:17.975165", "module": "Custom", "name": "Customer Sales Order Link", "naming_rule": "", @@ -3934,8 +4216,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.441876", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:18.027046", "module": "Custom", "name": "Lead Address Link", "naming_rule": "", @@ -4088,8 +4370,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.492936", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:18.078476", "module": "Custom", "name": "Lead Contact Link", "naming_rule": "", @@ -4242,8 +4524,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.545465", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:18.170095", "module": "Custom", "name": "Lead Quotation Link", "naming_rule": "", @@ -4396,8 +4678,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "7c3c71cf20b258daa783e541cb045a4b", - "modified": "2026-01-16 04:11:35.604415", + "migration_hash": "0df0ede31f640435231ba887f40eca91", + "modified": "2026-01-19 20:52:18.238066", "module": "Custom", "name": "Address Company Link", "naming_rule": "", diff --git a/custom_ui/fixtures/property_setter.json b/custom_ui/fixtures/property_setter.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/custom_ui/fixtures/property_setter.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/custom_ui/hooks.py b/custom_ui/hooks.py index 1759776..e7ec24b 100644 --- a/custom_ui/hooks.py +++ b/custom_ui/hooks.py @@ -181,10 +181,13 @@ doc_events = { }, "Project": { "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": { - "before_insert": "custom_ui.events.task.before_insert" + "before_insert": "custom_ui.events.task.before_insert", + "after_insert": "custom_ui.events.task.after_insert" } } @@ -216,6 +219,8 @@ fixtures = [ "Address Contact Link", "Address Company Link", "Contact Address Link", + "Address Task Link", + "Customer Task Link" ]] ] }, @@ -249,7 +254,15 @@ fixtures = [ ["dt", "=", "Project Template"], ["fieldname", "=", "company"] ] + }, + { + "dt": "Property Setter", + "filters": [ + ["doc_type", "=", "Lead"], + ["doc_type", "=", "Project"] + ] } + ] diff --git a/custom_ui/install.py b/custom_ui/install.py index 014cf70..0453f49 100644 --- a/custom_ui/install.py +++ b/custom_ui/install.py @@ -30,7 +30,7 @@ def after_migrate(): frappe.clear_cache(doctype=doctype) frappe.reload_doctype(doctype) - update_address_fields() + # update_address_fields() # build_frontend() @@ -146,6 +146,13 @@ def add_custom_fields(): fieldtype="Link", options="Contact", insert_after="contacts" + ), + dict( + fieldname="tasks", + label="Tasks", + fieldtype="Table", + options="Customer Task Link", + insert_after="projects" ) ], "Lead": [ @@ -327,6 +334,13 @@ def add_custom_fields(): fieldtype="Table", options="Address Company Link", insert_after="contacts" + ), + dict( + fieldname="tasks", + label="Tasks", + fieldtype="Table", + options="Address Task Link", + insert_after="projects" ) ], "Contact": [ @@ -539,6 +553,37 @@ def add_custom_fields(): options="Customer", insert_after="job_address", 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="expected_end_time" ) ], "Project Template": [ @@ -550,6 +595,15 @@ def add_custom_fields(): insert_after="project_type", description="The company associated with this project template." ) + ], + "Task": [ + dict( + fieldname="project_template", + label="Project Template", + fieldtype="Link", + options="Project Template", + insert_after="project" + ) ] } diff --git a/frontend/src/api.js b/frontend/src/api.js index 936cae9..3663e52 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -18,8 +18,10 @@ const FRAPPE_CREATE_ESTIMATE_TEMPLATE_METHOD = "custom_ui.api.db.estimates.creat const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job"; const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data"; const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job"; -const FRAPPE_GET_JOB_TASK_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_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"; // Task methods const FRAPPE_GET_TASKS_METHOD = "custom_ui.api.db.tasks.get_tasks_table_data"; @@ -42,6 +44,7 @@ const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses"; const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client"; const FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD = "custom_ui.api.db.clients.get_client_status_counts"; const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data"; +const FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD = "custom_ui.api.db.clients.get_clients_table_data_v2"; const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client_v2"; const FRAPPE_GET_CLIENT_NAMES_METHOD = "custom_ui.api.db.clients.get_client_names"; @@ -87,6 +90,17 @@ class Api { return await this.request(FRAPPE_GET_CLIENT_METHOD, { clientName }); } + static async getPaginatedClientDetailsV2(paginationParams = {}, filters = {}, sortings = []) { + const { page = 0, pageSize = 10 } = paginationParams; + const result = await this.request(FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD, { + filters, + sortings, + page: page + 1, + pageSize, + }); + return result; + } + /** * Get paginated client data with filtering and sorting * @param {Object} paginationParams - Pagination parameters from store @@ -138,9 +152,10 @@ class Api { // ON-SITE MEETING METHODS // ============================================================================ - static async getUnscheduledBidMeetings() { + static async getUnscheduledBidMeetings(company) { return await this.request( "custom_ui.api.db.bid_meetings.get_unscheduled_bid_meetings", + { company } ); } @@ -148,8 +163,8 @@ class Api { return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters }); } - static async getWeekBidMeetings(weekStart, weekEnd) { - return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd }); + static async getWeekBidMeetings(weekStart, weekEnd, company) { + return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd, company }); } static async updateBidMeeting(name, data) { @@ -300,6 +315,10 @@ class Api { return result; } + static async getJobsForCalendar(date, company = null, projectTemplates = []) { + return await this.request(FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD, { date, company, projectTemplates }); + } + static async getJob(jobName) { if (frappe.db.exists("Project", jobName)) { const result = await this.request(FRAPPE_GET_JOB_METHOD, { jobId: jobName }) @@ -346,7 +365,7 @@ class Api { 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; } diff --git a/frontend/src/components/calendar/CalendarNavigation.vue b/frontend/src/components/calendar/CalendarNavigation.vue index 6d8993a..d16cee5 100644 --- a/frontend/src/components/calendar/CalendarNavigation.vue +++ b/frontend/src/components/calendar/CalendarNavigation.vue @@ -3,11 +3,8 @@ Bids - Install + Projects Service - Lowe Fencing - Daniel's Landscaping - Nuco Yardcare Warranties @@ -22,21 +19,6 @@

Service feature coming soon!

- -
-

Lowe Fencing calendar coming soon!

-
-
- -
-

Daniel's Calendar coming soon!

-
-
- -
-

Nuco calendar coming soon!

-
-

Warranties Calendar coming soon!

@@ -56,7 +38,7 @@ import TabPanel from 'primevue/tabpanel'; import TabPanels from 'primevue/tabpanels'; import ScheduleBid from '../calendar/bids/ScheduleBid.vue'; import JobsCalendar from '../calendar/jobs/JobsCalendar.vue'; -import InstallsCalendar from '../calendar/jobs/InstallsCalendar.vue'; +import InstallsCalendar from './jobs/ProjectsCalendar.vue'; import { useNotificationStore } from '../../stores/notifications-primevue'; const notifications = useNotificationStore(); @@ -65,6 +47,24 @@ const notifications = useNotificationStore(); diff --git a/frontend/src/components/pages/Calendar.vue b/frontend/src/components/pages/Calendar.vue index f9371c8..895bfbb 100644 --- a/frontend/src/components/pages/Calendar.vue +++ b/frontend/src/components/pages/Calendar.vue @@ -7,4 +7,7 @@ import CalendarNavigation from '@/components/calendar/CalendarNavigation.vue' diff --git a/frontend/src/components/pages/Clients.vue b/frontend/src/components/pages/Clients.vue index 815836f..d5cc7f4 100644 --- a/frontend/src/components/pages/Clients.vue +++ b/frontend/src/components/pages/Clients.vue @@ -29,10 +29,12 @@ import { useFiltersStore } from "../../stores/filters"; import { useModalStore } from "../../stores/modal"; import { useRouter, useRoute } from "vue-router"; import { useNotificationStore } from "../../stores/notifications-primevue"; +import { useCompanyStore } from "../../stores/company"; import TodoChart from "../common/TodoChart.vue"; import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch, ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit, -WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue"; +WateringSoil, Soil, Truck, SoilAlt, +Filter} from "@iconoir/vue"; const notifications = useNotificationStore(); const loadingStore = useLoadingStore(); @@ -41,6 +43,7 @@ const filtersStore = useFiltersStore(); const modalStore = useModalStore(); const router = useRouter(); const route = useRoute(); +const companyStore = useCompanyStore(); const tableData = ref([]); const totalRecords = ref(0); @@ -50,10 +53,26 @@ const currentWeekParams = ref({}); const chartLoading = ref(true); // Start with loading state const lookup = route.query.lookup; +const lastLazyLoadEvent = ref(null); + +// Watch for company changes to reload data +watch( + () => companyStore.currentCompany, + async () => { + console.log("Company changed, reloading client data..."); + if (lastLazyLoadEvent.value) { + await handleLazyLoad(lastLazyLoadEvent.value); + } + // Also refresh status counts + await refreshStatusCounts(); + } +); // Computed property to get current filters for the chart const currentFilters = computed(() => { - return filtersStore.getTableFilters("clients"); + filters = { ...filtersStore.getTableFilters("clients"), + company: { value: companyStore.currentCompany, matchMode: FilterMatchMode.CONTAINS} + }; }); // Handle week change from chart @@ -220,6 +239,7 @@ const tableActions = [ // Handle lazy loading events from DataTable const handleLazyLoad = async (event) => { console.log("Clients page - handling lazy load:", event); + lastLazyLoadEvent.value = event; try { isLoading.value = true; @@ -263,8 +283,9 @@ const handleLazyLoad = async (event) => { filters, sortingArray, }); + filters["company"] = { value: companyStore.currentCompany, matchMode: FilterMatchMode.CONTAINS}; - const result = await Api.getPaginatedClientDetails( + const result = await Api.getPaginatedClientDetailsV2( paginationParams, filters, sortingArray, diff --git a/frontend/src/components/pages/Job.vue b/frontend/src/components/pages/Job.vue index 65e33a1..5b500a6 100644 --- a/frontend/src/components/pages/Job.vue +++ b/frontend/src/components/pages/Job.vue @@ -66,7 +66,7 @@ const notifications = useNotificationStore(); const route = useRoute(); -const jobIdQuery = computed(() => route.query.jobId || ""); +const jobIdQuery = computed(() => route.query.name || ""); const isNew = computed(() => route.query.new === "true"); const tableData = ref([]); diff --git a/frontend/src/components/pages/Jobs.vue b/frontend/src/components/pages/Jobs.vue index 9d116ef..879691e 100644 --- a/frontend/src/components/pages/Jobs.vue +++ b/frontend/src/components/pages/Jobs.vue @@ -257,7 +257,7 @@ const handleLazyLoad = async (event) => { const handleRowClick = (event) => { const rowData = event.data; - router.push(`/job?jobId=${rowData.name}`); + router.push(`/job?name=${rowData.name}`); } // Load initial data diff --git a/frontend/src/components/pages/Tasks.vue b/frontend/src/components/pages/Tasks.vue index 0e7a9ec..cd18d34 100644 --- a/frontend/src/components/pages/Tasks.vue +++ b/frontend/src/components/pages/Tasks.vue @@ -61,7 +61,7 @@ const currentFilters = computed(() => { const columns = [ { label: "Task", fieldName: "subject", type: "text", sortable: true, filterable: true, - filterInputID: "subjectFilterId" }, + filterInputID: "subjectFilterId", defaultValue: subject || null }, { label: "Job", fieldName: "project", type: "link", sortable: true, onLinkClick: (link, rowData) => handleProjectClick(link, rowData) }, @@ -220,11 +220,6 @@ watch(showCompleted, () => { // Load initial data onMounted(async () => { - if (subject) { - const inputElement = document.getElementById(`filter-subject`); - inputElement.text = subject; - } - notifications.addWarning("Tasks page coming soon"); // Initialize pagination and filters paginationStore.initializeTablePagination("tasks", { rows: 10 }); filtersStore.initializeTableFilters("tasks", columns); @@ -236,11 +231,8 @@ onMounted(async () => { const initialSorting = filtersStore.getTableSorting("tasks"); if (subject) { - console.log(subject); - console.log(initialFilters); - console.log(initialFilters.subject); + console.log("Setting subject filter from query param:", subject); initialFilters.subject.value = subject; - //initialFilters = {...initialFilters, subject: {value: subject, match_mode: "contains"}}; } const optionsResult = await Api.getTaskStatusOptions(); @@ -255,6 +247,8 @@ onMounted(async () => { sortOrder: initialSorting.order || initialPagination.sortOrder, filters: initialFilters, }); + + notifications.addWarning("Tasks page coming soon"); }); \ No newline at end of file From e67805c01fd9d07216e9744566a44550de26f05e Mon Sep 17 00:00:00 2001 From: Casey Date: Wed, 21 Jan 2026 08:44:20 -0600 Subject: [PATCH 09/40] update calendar --- custom_ui/api/db/estimates.py | 5 +- custom_ui/api/db/jobs.py | 33 +- custom_ui/events/jobs.py | 21 +- custom_ui/fixtures/doctype.json | 92 +-- custom_ui/hooks.py | 3 +- custom_ui/install.py | 6 + frontend/src/api.js | 14 +- .../calendar/jobs/SNWProjectCalendar.vue | 698 +++++++++++------- .../src/components/modals/JobDetailsModal.vue | 299 ++++++++ frontend/src/components/pages/Estimate.vue | 2 +- 10 files changed, 848 insertions(+), 325 deletions(-) create mode 100644 frontend/src/components/modals/JobDetailsModal.vue diff --git a/custom_ui/api/db/estimates.py b/custom_ui/api/db/estimates.py index dcfc579..25149e3 100644 --- a/custom_ui/api/db/estimates.py +++ b/custom_ui/api/db/estimates.py @@ -404,7 +404,7 @@ def upsert_estimate(data): # estimate.custom_job_address = data.get("address_name") # estimate.party_name = data.get("customer") # estimate.contact_person = data.get("contact_name") - estimate.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_quotation_template = data.get("quotation_template", None) # estimate.company = data.get("company") @@ -427,6 +427,7 @@ def upsert_estimate(data): }) estimate.save() + frappe.db.commit() estimate_dict = estimate.as_dict() estimate_dict["history"] = get_doc_history("Quotation", estimate_name) print(f"DEBUG: Estimate updated: {estimate.name}") @@ -444,7 +445,7 @@ def upsert_estimate(data): # print("DEBUG: No billing address found for client:", client_doc.name) new_estimate = frappe.get_doc({ "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_current_status": "Draft", "contact_email": data.get("contact_email"), diff --git a/custom_ui/api/db/jobs.py b/custom_ui/api/db/jobs.py index 5fa2475..082733e 100644 --- a/custom_ui/api/db/jobs.py +++ b/custom_ui/api/db/jobs.py @@ -1,6 +1,7 @@ import frappe, json from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response from custom_ui.services import AddressService, ClientService +from frappe.utils import getdate # =============================================================================== # JOB MANAGEMENT API METHODS @@ -177,21 +178,26 @@ def upsert_job(data): return {"status": "error", "message": str(e)} @frappe.whitelist() -def get_projects_for_calendar(date, company=None, project_templates=[]): +def get_projects_for_calendar(start_date, end_date, company=None, project_templates=[]): """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("šŸ“…šŸ“…šŸ“…", date, "company:", company, "project_templates:", project_templates, "type:", type(project_templates)) + print("šŸ“…šŸ“…šŸ“…", start_date, end_date, " company:", company, "project_templates:", project_templates, "type:", type(project_templates)) try: - filters = {"company": company} if company else {} + 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 - filters["expected_start_date"] = date + + # 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 # 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). @@ -206,3 +212,22 @@ def get_projects_for_calendar(date, company=None, project_templates=[]): return build_success_response({ "projects": projects, "unscheduled_projects": unscheduled_projects }) except Exception as e: return build_error_response(str(e), 500) + +@frappe.whitelist() +def update_job_scheduled_dates(job_name: str, new_start_date: str = None, new_end_date: str = None, foreman_name: str = None): + """Update job (project) schedule dates.""" + print("DEBUG: Updating job schedule:", job_name, new_start_date, new_end_date, foreman_name) + try: + project = frappe.get_doc("Project", job_name) + project.expected_start_date = getdate(new_start_date) if new_start_date else None + project.expected_end_date = getdate(new_end_date) if new_end_date else None + if new_start_date and new_end_date: + project.is_scheduled = 1 + else: + project.is_scheduled = 0 + if foreman_name: + project.custom_foreman = foreman_name + project.save() + return build_success_response(project.as_dict()) + except Exception as e: + return build_error_response(str(e), 500) \ No newline at end of file diff --git a/custom_ui/events/jobs.py b/custom_ui/events/jobs.py index 7801611..13b328f 100644 --- a/custom_ui/events/jobs.py +++ b/custom_ui/events/jobs.py @@ -1,6 +1,6 @@ import frappe from custom_ui.services import AddressService, ClientService - +from datetime import timedelta def after_insert(doc, method): print("DEBUG: After Insert Triggered for Project") @@ -30,8 +30,13 @@ def before_insert(doc, method): 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 - else: + 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 def after_save(doc, method): @@ -44,8 +49,10 @@ def after_save(doc, method): "Closed": "Completed" } new_status = status_mapping.get(doc.status, "In Progress") - AddressService.update_value( - doc.job_address, - "job_status", - new_status - ) \ No newline at end of file + 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 + ) \ No newline at end of file diff --git a/custom_ui/fixtures/doctype.json b/custom_ui/fixtures/doctype.json index d2859ca..db8c42d 100644 --- a/custom_ui/fixtures/doctype.json +++ b/custom_ui/fixtures/doctype.json @@ -178,8 +178,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": null, - "modified": "2026-01-19 18:10:16.782664", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:39.681180", "module": "Custom UI", "name": "Customer Task Link", "naming_rule": "", @@ -396,8 +396,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": null, - "modified": "2026-01-19 18:10:02.359022", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:39.752798", "module": "Custom UI", "name": "Address Task Link", "naming_rule": "", @@ -550,8 +550,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.097017", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:39.827207", "module": "Custom", "name": "Lead Companies Link", "naming_rule": "", @@ -768,8 +768,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.150584", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:39.914772", "module": "Custom", "name": "Address Project Link", "naming_rule": "", @@ -986,8 +986,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.203403", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.049882", "module": "Custom", "name": "Address Quotation Link", "naming_rule": "", @@ -1204,8 +1204,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.255846", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.145083", "module": "Custom", "name": "Address On-Site Meeting Link", "naming_rule": "", @@ -1422,8 +1422,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.309600", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.270888", "module": "Custom", "name": "Address Sales Order Link", "naming_rule": "", @@ -1576,8 +1576,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.361237", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.346187", "module": "Custom", "name": "Contact Address Link", "naming_rule": "", @@ -1730,8 +1730,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.412683", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.418889", "module": "Custom", "name": "Lead On-Site Meeting Link", "naming_rule": "", @@ -2332,8 +2332,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.483924", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.507246", "module": "Selling", "name": "Quotation Template", "naming_rule": "", @@ -2830,8 +2830,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.558008", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.595751", "module": "Selling", "name": "Quotation Template Item", "naming_rule": "", @@ -2984,8 +2984,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.609372", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.676141", "module": "Custom UI", "name": "Customer Company Link", "naming_rule": "", @@ -3138,8 +3138,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.660893", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.749303", "module": "Custom UI", "name": "Customer Address Link", "naming_rule": "", @@ -3292,8 +3292,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.712878", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.836296", "module": "Custom UI", "name": "Customer Contact Link", "naming_rule": "", @@ -3446,8 +3446,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.765849", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.900156", "module": "Custom", "name": "Address Contact Link", "naming_rule": "", @@ -3600,8 +3600,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.818352", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:40.986399", "module": "Custom", "name": "Customer On-Site Meeting Link", "naming_rule": "", @@ -3754,8 +3754,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.870984", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:41.054749", "module": "Custom", "name": "Customer Project Link", "naming_rule": "", @@ -3908,8 +3908,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.922695", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:41.114144", "module": "Custom", "name": "Customer Quotation Link", "naming_rule": "", @@ -4062,8 +4062,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:17.975165", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:41.170115", "module": "Custom", "name": "Customer Sales Order Link", "naming_rule": "", @@ -4216,8 +4216,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:18.027046", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:41.226997", "module": "Custom", "name": "Lead Address Link", "naming_rule": "", @@ -4370,8 +4370,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:18.078476", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:41.283969", "module": "Custom", "name": "Lead Contact Link", "naming_rule": "", @@ -4524,8 +4524,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:18.170095", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:41.344731", "module": "Custom", "name": "Lead Quotation Link", "naming_rule": "", @@ -4678,8 +4678,8 @@ "make_attachments_public": 0, "max_attachments": 0, "menu_index": null, - "migration_hash": "0df0ede31f640435231ba887f40eca91", - "modified": "2026-01-19 20:52:18.238066", + "migration_hash": "9792f2dfc070efd283d24244c612c2d4", + "modified": "2026-01-20 12:56:41.401007", "module": "Custom", "name": "Address Company Link", "naming_rule": "", diff --git a/custom_ui/hooks.py b/custom_ui/hooks.py index 04541cf..0f5911b 100644 --- a/custom_ui/hooks.py +++ b/custom_ui/hooks.py @@ -263,7 +263,8 @@ fixtures = [ "dt": "Property Setter", "filters": [ ["doc_type", "=", "Lead"], - ["doc_type", "=", "Project"] + ["doc_type", "=", "Project"], + ["doc_type", "=", "Address"] ] } diff --git a/custom_ui/install.py b/custom_ui/install.py index d264307..ab74967 100644 --- a/custom_ui/install.py +++ b/custom_ui/install.py @@ -598,6 +598,12 @@ def add_custom_fields(): options="Company", insert_after="project_type", description="The company associated with this project template." + ), + dict( + fieldname="calendar_color", + label="Calendar Color", + fieldtype="Color", + insert_after="company" ) ], "Task": [ diff --git a/frontend/src/api.js b/frontend/src/api.js index 18c1ae0..86e37f1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -23,6 +23,7 @@ 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_JOBS_FOR_CALENDAR_METHOD = "custom_ui.api.db.jobs.get_projects_for_calendar"; const FRAPPE_GET_JOB_TEMPLATES_METHOD = "custom_ui.api.db.jobs.get_job_templates"; +const FRAPPE_UPDATE_JOB_SCHEDULED_DATES_METHOD = "custom_ui.api.db.jobs.update_job_scheduled_dates"; // Task methods const FRAPPE_GET_TASKS_METHOD = "custom_ui.api.db.tasks.get_tasks_table_data"; const FRAPPE_GET_TASKS_STATUS_OPTIONS = "custom_ui.api.db.tasks.get_task_status_options"; @@ -319,8 +320,17 @@ class Api { return result; } - static async getJobsForCalendar(date, company = null, projectTemplates = []) { - return await this.request(FRAPPE_GET_JOBS_FOR_CALENDAR_METHOD, { date, company, projectTemplates }); + 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 getJob(jobName) { diff --git a/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue b/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue index 152938a..9e24b16 100644 --- a/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue +++ b/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue @@ -23,9 +23,9 @@ variant="outlined" size="small" > - This Week + + This Week + -
-
{{ job.serviceType }}
-
{{ job.customer }}
-
- mdi-arrow-right -
+ :style="getJobStyle(job, day.date)" + draggable="true" + @click.stop="showEventDetails({ event: job })" + @dragstart="handleDragStart(job, $event)" + @dragend="handleDragEnd" + @mousedown="startResize($event, job, day.date)" + > + mdi-arrow-left +
+
{{ job.projectTemplate || job.serviceType }}
+
{{ stripAddress(job.address || job.jobAddress) }}
+ mdi-arrow-right +
+
+ + + @@ -261,41 +268,32 @@ @dragend="handleDragEnd" > -
- - {{ service.priority.toUpperCase() }} - - ${{ service.estimatedCost.toLocaleString() }} -
+
{{ service.projectTemplate || service.serviceType }}
+
+ mdi-map-marker + {{ stripAddress(service.address || service.jobAddress) }} +
+
+ mdi-account + {{ service.customer }} +
-
{{ service.serviceType }}
-
{{ service.customer }}
+
+ {{ service.notes }} +
-
-
- mdi-clock - {{ formatDuration(service.duration) }} -
-
- -
- {{ service.notes }} -
- - - mdi-calendar-plus - Schedule - -
+ + mdi-calendar-plus + Schedule + + @@ -306,67 +304,17 @@ - - - - - {{ selectedEvent.title }} - - {{ selectedEvent.priority.toUpperCase() }} - - + + - -
-
- mdi-account - Customer: {{ selectedEvent.customer }} -
-
- mdi-map-marker - Address: {{ selectedEvent.address }} -
-
- mdi-wrench - Service Type: {{ selectedEvent.serviceType }} -
-
- mdi-calendar - Date: {{ selectedEvent.scheduledDate }} -
-
- mdi-clock - Time: {{ selectedEvent.scheduledTime }} ({{ - formatDuration(selectedEvent.duration) - }}) -
-
- mdi-account-hard-hat - Crew: {{ selectedEvent.foreman || "Not assigned" }} -
-
- mdi-currency-usd - Estimated Cost: ${{ - selectedEvent.estimatedCost.toLocaleString() - }} -
-
- mdi-note-text - Notes: {{ selectedEvent.notes }} -
-
-
- - - - Close - -
-
- - -
- {{ tooltip }} + +
+ āžœ Extend to next week
@@ -376,6 +324,7 @@ import { ref, onMounted, computed, watch } from "vue"; import Api from "../../../api"; import { useNotificationStore } from "../../../stores/notifications-primevue"; import { useCompanyStore } from "../../../stores/company"; +import JobDetailsModal from "../../modals/JobDetailsModal.vue"; const notifications = useNotificationStore(); const companyStore = useCompanyStore(); @@ -393,10 +342,8 @@ const draggedService = ref(null); const isDragOver = ref(false); const dragOverCell = ref(null); -// Tooltip state -const tooltip = ref(''); -const tooltipVisible = ref(false); -const tooltipPosition = ref({ x: 0, y: 0 }); +// Extend to next week popup state +const showExtendToNextWeekPopup = ref(false); // Resize state const resizingJob = ref(null); @@ -495,7 +442,13 @@ function daysBetween(startDate, endDate) { // Helper function to check if date is a holiday function isHoliday(dateStr) { - return holidays.value.some(holiday => holiday.date === dateStr); + return holidays.value.some(holiday => holiday.holidayDate === dateStr); +} + +// Helper function to get holiday description for a date +function getHolidayDescription(dateStr) { + const holiday = holidays.value.find(h => h.holidayDate === dateStr); + return holiday ? holiday.description : null; } // Helper function to check if date is Sunday @@ -532,6 +485,90 @@ function jobSpansToNextWeek(job) { return end > weekEnd; } +// Helper function to check if job starts before current week +function jobStartsBeforeWeek(job) { + const startDate = job.expectedStartDate || job.scheduledDate; + const start = parseLocalDate(startDate); + const weekStart = parseLocalDate(weekStartDate.value); + return start < weekStart; +} + +// Helper function to strip address after "-#-" +function stripAddress(address) { + if (!address) return ''; + const index = address.indexOf('-#-'); + return index > -1 ? address.substring(0, index).trim() : address; +} + +// Helper function to get crew name from foreman ID +function getCrewName(foremanId) { + if (!foremanId) return 'Not assigned'; + const foreman = foremen.value.find(f => f.name === foremanId); + if (!foreman) return foremanId; + return foreman.customCrew ? `${foreman.employeeName} (Crew ${foreman.customCrew})` : foreman.employeeName; +} + +// Helper function to get all holidays in a date range +function getHolidaysInRange(startDate, endDate) { + const start = parseLocalDate(startDate); + const end = parseLocalDate(endDate); + const holidaysInRange = []; + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const dateStr = toLocalDateString(d); + if (isHoliday(dateStr)) { + holidaysInRange.push(dateStr); + } + } + return holidaysInRange; +} + +// Helper function to calculate job segments (parts between holidays/Sundays) +function getJobSegments(job) { + const startDate = job.scheduledDate; + const endDate = job.scheduledEndDate || job.scheduledDate; + const weekEndDate = addDays(weekStartDate.value, 6); // Saturday + + const segments = []; + let segmentStart = startDate; + let currentDate = startDate; + + const start = parseLocalDate(startDate); + const end = parseLocalDate(endDate); + const weekEnd = parseLocalDate(weekEndDate); + + // Don't render segments beyond the current week's Saturday + const effectiveEnd = end <= weekEnd ? end : weekEnd; + + for (let d = new Date(start); d <= effectiveEnd; d.setDate(d.getDate() + 1)) { + const dateStr = toLocalDateString(d); + + // If we hit a holiday or Sunday, close the current segment + if (isHoliday(dateStr) || isSunday(dateStr)) { + // Close previous segment if it exists + if (segmentStart !== null) { + const prevDate = toLocalDateString(new Date(d.getTime() - 86400000)); // Previous day + if (parseLocalDate(prevDate) >= parseLocalDate(startDate)) { + segments.push({ start: segmentStart, end: prevDate }); + } + segmentStart = null; + } + } else { + // Start a new segment if we don't have one + if (segmentStart === null) { + segmentStart = dateStr; + } + } + } + + // Close the final segment if there's an open one + if (segmentStart !== null) { + const finalEnd = toLocalDateString(effectiveEnd); + segments.push({ start: segmentStart, end: finalEnd }); + } + + return segments; +} + // Computed properties const weekDisplayText = computed(() => { const startDate = parseLocalDate(weekStartDate.value); @@ -612,8 +649,8 @@ const formatDuration = (minutes) => { // Get jobs for a specific foreman and date const getJobsForCell = (foremanId, date) => { - // Don't render jobs on Sunday - if (isSunday(date)) return []; + // Don't render jobs on Sunday or holidays + if (isSunday(date) || isHoliday(date)) return []; return scheduledServices.value.filter((job) => { if (job.foreman !== foremanId) return false; @@ -622,7 +659,24 @@ const getJobsForCell = (foremanId, date) => { const jobEnd = job.scheduledEndDate || job.scheduledDate; // Check if this date falls within the job's date range - return date >= jobStart && date <= jobEnd; + // AND that it's a valid segment start date + const segments = getJobSegments(job); + return segments.some(seg => seg.start === date); + }); +}; + +// Get jobs that need a connector line on this holiday +const getJobsWithConnector = (foremanId, date) => { + if (!isHoliday(date)) return []; + + return scheduledServices.value.filter((job) => { + if (job.foreman !== foremanId) return false; + + const jobStart = job.scheduledDate; + const jobEnd = job.scheduledEndDate || job.scheduledDate; + + // Check if this holiday date falls within the job's range + return date > jobStart && date < jobEnd; }); }; @@ -630,27 +684,65 @@ const getJobsForCell = (foremanId, date) => { const getJobStyle = (job, currentDate) => { const jobStart = job.scheduledDate; const jobEnd = job.scheduledEndDate || job.scheduledDate; + const segments = getJobSegments(job); - // Calculate how many days this job spans - const duration = daysBetween(jobStart, jobEnd) + 1; + // Find which segment (if any) should be rendered at currentDate + const segmentIndex = segments.findIndex(seg => seg.start === currentDate); - // Only render the job on its starting day - if (currentDate !== jobStart) { + if (segmentIndex === -1) { return { display: 'none' }; } + const segment = segments[segmentIndex]; + + // Calculate visual duration for this segment + let visualDays = daysBetween(segment.start, segment.end) + 1; + // For multi-day jobs, calculate width to span cells - // Each additional day adds 100% + 1px for the border - const widthCalc = duration === 1 + const widthCalc = visualDays === 1 ? 'calc(100% - 8px)' // Single day: full width minus padding - : `calc(${duration * 100}% + ${(duration - 1)}px)`; // Multi-day: span cells accounting for borders + : `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; + } + } + const darkerColor = darkenColor(backgroundColor, 20); return { width: widthCalc, zIndex: 10, + background: `linear-gradient(135deg, ${backgroundColor}, ${darkerColor})` }; }; +// Helper function to darken a color +function darkenColor(color, percent) { + if (!color || color === 'transparent') return '#1976d2'; + + // Convert hex to RGB + let r, g, b; + if (color.startsWith('#')) { + const hex = color.replace('#', ''); + r = parseInt(hex.substring(0, 2), 16); + g = parseInt(hex.substring(2, 4), 16); + b = parseInt(hex.substring(4, 6), 16); + } else { + return color; // Return original if not hex + } + + // Darken + r = Math.max(0, Math.floor(r * (1 - percent / 100))); + g = Math.max(0, Math.floor(g * (1 - percent / 100))); + b = Math.max(0, Math.floor(b * (1 - percent / 100))); + + return `rgb(${r}, ${g}, ${b})`; +} + // Calendar navigation methods const previousWeek = () => { const date = parseLocalDate(weekStartDate.value); @@ -776,7 +868,6 @@ const handleDragEnd = (event) => { draggedService.value = null; isDragOver.value = false; dragOverCell.value = null; - tooltipVisible.value = false; }; const handleDragOver = (event, foremanId, date) => { @@ -824,7 +915,6 @@ const handleDragLeave = (event) => { ) { isDragOver.value = false; dragOverCell.value = null; - tooltipVisible.value = false; } }, 10); }; @@ -844,6 +934,16 @@ const handleDrop = async (event, foremanId, date) => { return; } + // Prevent dropping on holidays + if (isHoliday(date)) { + const holidayDesc = getHolidayDescription(date); + notifications.addError(`Cannot schedule jobs on ${holidayDesc || 'a holiday'}. Please select a different date.`); + isDragOver.value = false; + dragOverCell.value = null; + draggedService.value = null; + return; + } + // Get foreman details const foreman = foremen.value.find(f => f.name === foremanId); if (!foreman) return; @@ -858,7 +958,6 @@ const handleDrop = async (event, foremanId, date) => { isDragOver.value = false; dragOverCell.value = null; draggedService.value = null; - tooltipVisible.value = false; return; } @@ -867,20 +966,26 @@ const handleDrop = async (event, foremanId, date) => { if (serviceIndex !== -1) { const wasScheduled = services.value[serviceIndex].isScheduled; - services.value[serviceIndex] = { - ...services.value[serviceIndex], - isScheduled: true, - scheduledDate: date, - scheduledEndDate: endDate, - foreman: foreman.name - }; const action = wasScheduled ? 'Moved' : 'Scheduled'; console.log(`${action} ${draggedService.value.serviceType} for ${foreman.employeeName} on ${date} to ${endDate}`); - + // Call API to persist changes (placeholder for now) try { - notifications.addWarning("API update feature coming soon!"); + await Api.updateJobScheduledDates( + draggedService.value.id, + date, + endDate, + foreman.name + ) + services.value[serviceIndex] = { + ...services.value[serviceIndex], + isScheduled: true, + scheduledDate: date, + scheduledEndDate: endDate, + foreman: foreman.name + }; + notifications.addSuccess("Job scheduled successfully!"); // Future implementation: // await Api.updateJobSchedule({ // id: draggedService.value.id, @@ -899,7 +1004,6 @@ const handleDrop = async (event, foremanId, date) => { isDragOver.value = false; dragOverCell.value = null; draggedService.value = null; - tooltipVisible.value = false; }; // Handle dropping scheduled items back to unscheduled @@ -934,7 +1038,13 @@ const handleUnscheduledDrop = async (event) => { // Call API to persist changes (placeholder for now) try { - notifications.addWarning("API update feature coming soon!"); + await Api.updateJobScheduledDates( + draggedService.value.id, + null, + null, + null + ) + notifications.addSuccess("Job unscheduled successfully!"); // Future implementation: // await Api.updateJobSchedule({ // id: draggedService.value.id, @@ -1001,20 +1111,45 @@ const handleResize = (event) => { // Calculate how many days to extend with better snapping (0.4 threshold instead of 0.5) const daysToAdd = Math.floor(deltaX / cellWidth + 0.4); - // Calculate new end date based on current job duration + adjustment - const jobStartDate = resizingJob.value.scheduledDate; - const currentEndDate = originalEndDate.value || jobStartDate; - const currentDuration = daysBetween(jobStartDate, currentEndDate); // Days from start to current end - const totalDays = currentDuration + daysToAdd; + // Calculate new end date based on current end date + adjustment + const currentEndDate = originalEndDate.value; + const currentEndDateObj = parseLocalDate(currentEndDate); - // Minimum is same day (0 days difference) - if (totalDays >= 0) { - let newEndDate = addDays(jobStartDate, totalDays); - let extendsOverSunday = false; - - // Check if the new end date or any date in between is Sunday - for (let i = 0; i <= totalDays; i++) { - const checkDate = addDays(jobStartDate, i); + // Calculate proposed end date by adding days to the CURRENT end date + let proposedEndDate = addDays(currentEndDate, daysToAdd); + + // Don't allow shrinking before the current end date (minimum stay at current) + if (daysToAdd < 0) { + proposedEndDate = currentEndDate; + } + + let newEndDate = proposedEndDate; + let extendsOverSunday = false; + let hasHolidayBlock = false; + const weekEndDate = addDays(weekStartDate.value, 6); // Saturday + const proposedDate = parseLocalDate(proposedEndDate); + const saturdayDate = parseLocalDate(weekEndDate); + + // Check for holidays in the EXTENSION range only (from current end to proposed end) + const holidaysInRange = getHolidaysInRange(currentEndDate, proposedEndDate); + if (holidaysInRange.length > 0) { + hasHolidayBlock = true; + // Allow the extension - visual split will be handled by getJobSegments + newEndDate = proposedEndDate; + } + + // Check if the proposed end date extends past Saturday or lands on Sunday + if (proposedDate > saturdayDate || isSunday(proposedEndDate)) { + extendsOverSunday = true; + // Set logical end date to next Monday, but visually cap at Saturday + newEndDate = getNextMonday(weekEndDate); + } else if (!hasHolidayBlock && daysToAdd > 0) { + // Check if any date in the EXTENSION range is Sunday (only if no holiday block) + // Only check from current end to proposed end + const startCheck = parseLocalDate(currentEndDate); + const endCheck = parseLocalDate(proposedEndDate); + for (let d = new Date(startCheck.getTime() + 86400000); d <= endCheck; d.setDate(d.getDate() + 1)) { + const checkDate = toLocalDateString(d); if (isSunday(checkDate)) { extendsOverSunday = true; // Extend to next Monday @@ -1022,85 +1157,67 @@ const handleResize = (event) => { break; } } - - // Show tooltip if extending over Sunday - if (extendsOverSunday) { - tooltip.value = 'Extend to next Monday'; - tooltipVisible.value = true; - tooltipPosition.value = { x: event.clientX, y: event.clientY }; - } else { - tooltipVisible.value = false; - } - - const serviceIndex = services.value.findIndex(s => s.id === resizingJob.value.id); - if (serviceIndex !== -1) { - services.value[serviceIndex] = { - ...services.value[serviceIndex], - scheduledEndDate: newEndDate - }; - } + } + + // Show popup if extending over Sunday + showExtendToNextWeekPopup.value = extendsOverSunday; + + const serviceIndex = services.value.findIndex(s => s.id === resizingJob.value.id); + if (serviceIndex !== -1) { + services.value[serviceIndex] = { + ...services.value[serviceIndex], + scheduledEndDate: newEndDate + }; } }; const stopResize = async () => { if (!resizingJob.value) return; - // Hide tooltip - tooltipVisible.value = false; + // Hide popup + showExtendToNextWeekPopup.value = false; const job = resizingJob.value; - const newEndDate = job.scheduledEndDate || job.scheduledDate; - // Check for holidays in the range (excluding the extended-to-Monday case) - const jobStartDate = job.scheduledDate; - const weekEnd = addDays(weekStartDate.value, 6); // Saturday + // Find the updated job in services array (because resizingJob.value is a stale reference) + const serviceIndex = services.value.findIndex(s => s.id === job.id); + if (serviceIndex === -1) return; - // Only check for holidays in the current week range - const endDateToCheck = parseLocalDate(newEndDate) <= parseLocalDate(weekEnd) ? newEndDate : weekEnd; - - if (hasHolidayInRange(jobStartDate, endDateToCheck)) { - notifications.addError("Cannot extend job onto a holiday. Resize cancelled."); - // Revert the change - const serviceIndex = services.value.findIndex(s => s.id === job.id); - if (serviceIndex !== -1) { - services.value[serviceIndex] = { - ...services.value[serviceIndex], - scheduledEndDate: originalEndDate.value - }; - } - // Clean up - document.removeEventListener('mousemove', handleResize); - document.removeEventListener('mouseup', stopResize); - document.body.style.cursor = 'default'; - setTimeout(() => { - justFinishedResize.value = false; - }, 150); - resizingJob.value = null; - resizeStartX.value = 0; - resizeStartDate.value = null; - originalEndDate.value = null; - return; - } + const updatedJob = services.value[serviceIndex]; + const newEndDate = updatedJob.scheduledEndDate || updatedJob.scheduledDate; + console.log(`Proposed new end date for job ${job.serviceType}: ${newEndDate}`); // Only update if end date changed if (newEndDate !== originalEndDate.value) { + const weekEnd = addDays(weekStartDate.value, 6); // Saturday const extendsToNextWeek = parseLocalDate(newEndDate) > parseLocalDate(weekEnd); + if (extendsToNextWeek) { notifications.addInfo(`Job extended to next Monday (${formatDate(newEndDate)})`); } - console.log(`Extended job ${job.serviceType} to ${newEndDate}`); - // Call API to persist changes (placeholder for now) + console.log(`Extended job ${job.serviceType} from ${originalEndDate.value} to ${newEndDate}`); + + // Call API to persist changes try { - notifications.addWarning("API update feature coming soon!"); - // Future implementation: - // await Api.updateJobSchedule({ - // id: job.id, - // expectedEndDate: newEndDate - // }); + await Api.updateJobScheduledDates( + job.id, + job.scheduledDate, + newEndDate, + job.foreman + ); + notifications.addSuccess("Job end date updated successfully!"); } catch (error) { console.error("Error updating job end date:", error); - notifications.addError("Failed to update job"); + notifications.addError("Failed to update job end date"); + // Revert on error + const serviceIndex = services.value.findIndex(s => s.id === job.id); + if (serviceIndex !== -1) { + services.value[serviceIndex] = { + ...services.value[serviceIndex], + scheduledEndDate: originalEndDate.value + }; + } } } @@ -1127,7 +1244,7 @@ const fetchProjects = async () => { const startDate = weekStartDate.value; const endDate = addDays(startDate, 6); - const data = await Api.getJobsForCalendar(startDate, companyStore.currentCompany, selectedProjectTemplates.value); + const data = await Api.getJobsForCalendar(startDate, endDate, companyStore.currentCompany, selectedProjectTemplates.value); // Transform the API response into the format the component expects const transformedServices = []; @@ -1136,6 +1253,7 @@ const fetchProjects = async () => { if (data.projects && Array.isArray(data.projects)) { data.projects.forEach(project => { transformedServices.push({ + ...project, // Include all fields from API id: project.name, title: project.projectName || project.jobAddress || 'Unnamed Project', serviceType: project.projectName || project.jobAddress || 'Install Project', @@ -1147,7 +1265,10 @@ const fetchProjects = async () => { priority: (project.priority || 'Medium').toLowerCase(), estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0, notes: project.notes || '', - isScheduled: true + isScheduled: true, + projectTemplate: project.projectTemplate, + calendarColor: project.calendarColor, + jobAddress: project.jobAddress }); }); } @@ -1156,6 +1277,7 @@ const fetchProjects = async () => { if (data.unscheduledProjects && Array.isArray(data.unscheduledProjects)) { data.unscheduledProjects.forEach(project => { transformedServices.push({ + ...project, // Include all fields from API id: project.name, title: project.projectName || project.jobAddress || 'Unnamed Project', serviceType: project.projectName || project.jobAddress || 'Install Project', @@ -1167,7 +1289,10 @@ const fetchProjects = async () => { priority: (project.priority || 'Medium').toLowerCase(), estimatedCost: project.totalSalesAmount || project.estimatedCosting || 0, notes: project.notes || '', - isScheduled: false + isScheduled: false, + projectTemplate: project.projectTemplate, + calendarColor: project.calendarColor, + jobAddress: project.jobAddress }); }); } @@ -1419,15 +1544,16 @@ onMounted(async () => { background: linear-gradient(135deg, #2196f3, #1976d2); color: white; border-radius: 4px; - padding: 6px 8px; - font-size: 0.8em; + 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: 40px; + min-height: 68px; + height: calc(100% - 8px); max-width: none; /* Allow spanning */ } @@ -1451,6 +1577,9 @@ onMounted(async () => { .job-content { flex: 1; overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; } .job-title { @@ -1458,15 +1587,17 @@ onMounted(async () => { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - margin-bottom: 2px; + margin-bottom: 4px; + font-size: 0.95em; } -.job-customer { - font-size: 0.75em; +.job-address { + font-size: 0.8em; opacity: 0.9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + line-height: 1.3; } .resize-handle { @@ -1488,20 +1619,20 @@ onMounted(async () => { border-left: 3px solid rgba(255, 255, 255, 0.9); } -.calendar-job.priority-urgent { - background: linear-gradient(135deg, #f44336, #d32f2f); +.spans-arrow-left { + position: absolute; + left: 5px; + top: 50%; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.8); } -.calendar-job.priority-high { - background: linear-gradient(135deg, #ff9800, #f57c00); -} - -.calendar-job.priority-medium { - background: linear-gradient(135deg, #2196f3, #1976d2); -} - -.calendar-job.priority-low { - background: linear-gradient(135deg, #4caf50, #388e3c); +.spans-arrow-right { + position: absolute; + right: 25px; /* Position before resize handle */ + top: 50%; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.8); } .unscheduled-section { @@ -1612,43 +1743,86 @@ onMounted(async () => { color: #666; } -.event-details { - display: flex; - flex-direction: column; - gap: 12px; -} - -.detail-row { - display: flex; - align-items: center; - gap: 8px; -} - .day-cell.holiday { - background-color: rgba(255, 193, 7, 0.1); /* yellow tint for holidays */ + 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 */ } -.tooltip { - position: fixed; - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 8px 12px; - border-radius: 4px; - font-size: 0.875em; +.holiday-connector { + position: absolute; + left: 4px; + right: 4px; + top: 50%; + height: 3px; + transform: translateY(-50%); + border-top: 3px dotted currentColor; + opacity: 0.6; pointer-events: none; - z-index: 1000; - white-space: nowrap; + z-index: 5; } -.spans-arrow { - position: absolute; - right: 25px; /* Position before resize handle */ - top: 50%; - transform: translateY(-50%); - color: rgba(255, 255, 255, 0.8); +.holiday-connector.priority-urgent { + color: #f44336; +} + +.holiday-connector.priority-high { + color: #ff9800; +} + +.holiday-connector.priority-medium { + color: #2196f3; +} + +.holiday-connector.priority-low { + color: #4caf50; +} + +.extend-popup { + position: fixed; + top: 100px; + left: 50%; + transform: translateX(-50%); + background: rgba(33, 150, 243, 0.95); + color: white; + padding: 12px 20px; + border-radius: 8px; + font-size: 1em; + font-weight: 600; + pointer-events: none; + z-index: 10000; + white-space: nowrap; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3); + border: 2px solid rgba(255, 255, 255, 0.3); + animation: slideDown 0.2s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-10px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.service-address { + font-size: 0.85em; + color: #555; + display: flex; + align-items: center; + line-height: 1.4; } \ No newline at end of file diff --git a/frontend/src/components/modals/JobDetailsModal.vue b/frontend/src/components/modals/JobDetailsModal.vue new file mode 100644 index 0000000..4c33beb --- /dev/null +++ b/frontend/src/components/modals/JobDetailsModal.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/frontend/src/components/pages/Estimate.vue b/frontend/src/components/pages/Estimate.vue index e2e6a7a..75d0d50 100644 --- a/frontend/src/components/pages/Estimate.vue +++ b/frontend/src/components/pages/Estimate.vue @@ -1006,7 +1006,7 @@ onMounted(async () => { }; }); } - formData.requiresHalfPayment = estimate.value.custom_requires_half_payment || false; + formData.requiresHalfPayment = estimate.value.requiresHalfPayment || false; estimateResponse.value = estimate.value.customResponse; estimateResponseSelection.value = estimate.value.customResponse; } From c0b1f3f37af3619913d5327fd4b4d300a1abab3f Mon Sep 17 00:00:00 2001 From: rocketdebris Date: Thu, 22 Jan 2026 10:46:11 -0500 Subject: [PATCH 10/40] Added sales order items as tasks to the project. --- custom_ui/api/db/jobs.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/custom_ui/api/db/jobs.py b/custom_ui/api/db/jobs.py index 082733e..16bd48a 100644 --- a/custom_ui/api/db/jobs.py +++ b/custom_ui/api/db/jobs.py @@ -25,7 +25,7 @@ def create_job_from_sales_order(sales_order_name): try: sales_order = frappe.get_doc("Sales Order", sales_order_name) project_template = frappe.get_doc("Project Template", "SNW Install") - new_job = frappe.get_doc({ + new_project = frappe.get_doc({ "doctype": "Project", "custom_address": sales_order.custom_job_address, # "custom_installation_address": sales_order.custom_installation_address, @@ -35,13 +35,22 @@ def create_job_from_sales_order(sales_order_name): "sales_order": sales_order, "custom_company": sales_order.company }) - new_job.insert() + new_project.insert() + for sales_order_item in sales_order.items: + new_task = frappe.get_doc({ + "doctype": "Task", + "project": new_project.name, + "company": sales_order.company, + "custom_property": sales_order.custom_job_address, + "subject": sales_order_item.description, + }) + new_task.insert() # Iterate through new tasks (if any) and set customer, address # job_tasks = frappe.get_all("Task", filters={"Project": new_job.name}) # for task in job_tasks: # task.custom_property = new_job.job_address # task.save() - return build_success_response(new_job.as_dict()) + return build_success_response(new_project.as_dict()) except Exception as e: return build_error_response(str(e), 500) From f386edf769aa1f4804741901c0d5ec3de4564384 Mon Sep 17 00:00:00 2001 From: rocketdebris Date: Thu, 22 Jan 2026 10:46:49 -0500 Subject: [PATCH 11/40] Updated the TodoChart to have arbitrary categories, labels, data, and colors. --- frontend/src/components/common/TodoChart.vue | 36 +++++---- frontend/src/components/pages/Home.vue | 78 ++++++++++---------- 2 files changed, 55 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/common/TodoChart.vue b/frontend/src/components/common/TodoChart.vue index 8f07255..b55b655 100644 --- a/frontend/src/components/common/TodoChart.vue +++ b/frontend/src/components/common/TodoChart.vue @@ -21,7 +21,7 @@ diff --git a/frontend/src/components/pages/Home.vue b/frontend/src/components/pages/Home.vue index d1ecda7..d5e0621 100644 --- a/frontend/src/components/pages/Home.vue +++ b/frontend/src/components/pages/Home.vue @@ -15,8 +15,7 @@
@@ -224,6 +236,7 @@ import { ref, computed, onMounted, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import BidMeetingModal from "../../modals/BidMeetingModal.vue"; import MeetingDetailsModal from "../../modals/MeetingDetailsModal.vue"; +import BidMeetingNoteForm from "../../modals/BidMeetingNoteForm.vue"; import { useLoadingStore } from "../../../stores/loading"; import { useNotificationStore } from "../../../stores/notifications-primevue"; import { useCompanyStore } from "../../../stores/company"; @@ -251,6 +264,8 @@ const unscheduledMeetings = ref([]); const selectedMeeting = ref(null); const showMeetingModal = ref(false); const showNewMeetingModal = ref(false); +const showNoteFormModal = ref(false); +const selectedMeetingForNotes = ref(null); // Drag and drop state const isDragOver = ref(false); @@ -476,6 +491,63 @@ const handleMeetingUpdated = async () => { await loadUnscheduledMeetings(); }; +const openNoteForm = (meeting) => { + // Verify meeting has required data + if (!meeting || !meeting.name) { + notificationStore.addNotification({ + type: "error", + title: "Error", + message: "Meeting information is incomplete", + duration: 5000, + }); + return; + } + + if (!meeting.projectTemplate) { + notificationStore.addNotification({ + type: "error", + title: "Missing Project Template", + message: "This meeting does not have a project template assigned. Cannot open note form.", + duration: 5000, + }); + return; + } + + selectedMeetingForNotes.value = meeting; + showNoteFormModal.value = true; +}; + +const handleNoteFormSubmit = async () => { + // After successful submission, mark the meeting as completed + try { + loadingStore.setLoading(true); + await Api.updateBidMeeting(selectedMeetingForNotes.value.name, { + status: "Completed", + }); + + notificationStore.addNotification({ + type: "success", + title: "Success", + message: "Meeting marked as completed", + duration: 5000, + }); + + // Reload meetings + await handleMeetingUpdated(); + } catch (error) { + console.error("Error updating meeting status:", error); + } finally { + loadingStore.setLoading(false); + showNoteFormModal.value = false; + selectedMeetingForNotes.value = null; + } +}; + +const handleNoteFormCancel = () => { + showNoteFormModal.value = false; + selectedMeetingForNotes.value = null; +}; + const openNewMeetingModal = () => { showNewMeetingModal.value = true; }; @@ -491,7 +563,16 @@ const handleNewMeetingConfirm = async (meetingData) => { 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(); notificationStore.addNotification({ @@ -536,6 +617,7 @@ const handleDragStart = (event, meeting = null) => { notes: meeting.notes || "", assigned_employee: meeting.assigned_employee || "", status: meeting.status, + projectTemplate: meeting.projectTemplate, }; } else if (!draggedMeeting.value) { // If no meeting data is set, use query address @@ -559,6 +641,7 @@ const handleMeetingDragStart = (event, meeting) => { assigned_employee: meeting.assigned_employee || "", status: meeting.status, isRescheduling: true, // Flag to indicate this is a reschedule + projectTemplate: meeting.projectTemplate, }; // Store the original meeting data in case drag is cancelled @@ -669,6 +752,7 @@ const handleDrop = async (event, date, time) => { notes: droppedMeeting.notes || "", assigned_employee: droppedMeeting.assigned_employee || "", status: "Scheduled", + projectTemplate: droppedMeeting.projectTemplate, }; // If this is an existing meeting, update it in the backend @@ -792,6 +876,7 @@ const handleDropToUnscheduled = async (event) => { notes: droppedMeeting.notes || "", status: "Unscheduled", assigned_employee: droppedMeeting.assigned_employee || "", + projectTemplate: droppedMeeting.projectTemplate, }); } diff --git a/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue b/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue index 9e24b16..6a93558 100644 --- a/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue +++ b/frontend/src/components/calendar/jobs/SNWProjectCalendar.vue @@ -306,7 +306,7 @@ { const applyTemplateFilter = async () => { showTemplateMenu.value = false; - await fetchProjects(currentDate.value); + // await fetchProjects(currentDate.value); + await fetchServiceAppointments(); }; // Date picker methods @@ -1238,14 +1239,20 @@ const stopResize = async () => { originalEndDate.value = null; }; -const fetchProjects = async () => { +const fetchServiceAppointments = async (currentDate) => { try { // Calculate date range for the week const startDate = weekStartDate.value; const endDate = addDays(startDate, 6); - const data = await Api.getJobsForCalendar(startDate, endDate, companyStore.currentCompany, selectedProjectTemplates.value); - + // const data = await Api.getJobsForCalendar(startDate, endDate, companyStore.currentCompany, selectedProjectTemplates.value); + const data = await Api.getServiceAppointments( + [companyStore.currentCompany], + { + "expectedStartDate": ["<=", endDate], + "expectedEndDate": [">=", startDate] + } + ); // Transform the API response into the format the component expects const transformedServices = []; @@ -1350,21 +1357,24 @@ const fetchHolidays = async () => { } watch(weekStartDate, async () => { - await fetchProjects(); + // await fetchProjects(); + await fetchServiceAppointments(); await fetchHolidays(); }); watch(companyStore, async () => { await fetchForemen(); await fetchProjectTemplates(); - await fetchProjects(); + // await fetchProjects(); + await fetchServiceAppointments(); }, { deep: true }); // Lifecycle onMounted(async () => { await fetchForemen(); await fetchProjectTemplates(); - await fetchProjects(); + // await fetchProjects(); + await fetchServiceAppointments(); await fetchHolidays(); }); diff --git a/frontend/src/components/modals/BidMeetingNoteForm.vue b/frontend/src/components/modals/BidMeetingNoteForm.vue new file mode 100644 index 0000000..d97d32f --- /dev/null +++ b/frontend/src/components/modals/BidMeetingNoteForm.vue @@ -0,0 +1,978 @@ +