update with main

This commit is contained in:
Casey 2026-01-24 07:25:21 -06:00
parent 5e192a61e1
commit ba3e2a4d8e
29 changed files with 51749 additions and 139 deletions

View file

@ -26,6 +26,18 @@ def get_week_bid_meetings(week_start, week_end, company):
except Exception as e:
frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_bid_meeting_note_form(project_template):
bid_meeting_note_form_name = frappe.db.get_value("Project Template", project_template, "bid_meeting_note_form")
if not bid_meeting_note_form_name:
return build_error_response(f"No Bid Meeting Note Form configured for Project Template '{project_template}'", 404)
try:
note_form = frappe.get_doc("Bid Meeting Note Form", bid_meeting_note_form_name)
return build_success_response(note_form.as_dict())
except Exception as e:
frappe.log_error(message=str(e), title="Get Bid Meeting Note Form Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_bid_meetings(fields=["*"], filters={}, company=None):
@ -75,6 +87,61 @@ def get_unscheduled_bid_meetings(company):
frappe.log_error(message=str(e), title="Get Unscheduled On-Site Meetings Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def submit_bid_meeting_note_form(bid_meeting, project_template, fields, form_template):
"""Submit Bid Meeting Note Form data for a specific On-Site Meeting."""
if isinstance(fields, str):
fields = json.loads(fields)
try:
print(f"DEBUG: Submitting Bid Meeting Note Form for meeting='{bid_meeting}' from template='{form_template}' with fields='{fields}'")
meeting = DbService.get_or_throw("On-Site Meeting", bid_meeting)
# Update fields on the meeting
meeting_note_field_docs = [{
"label": field.get("label"),
"type": field.get("type"),
"value": json.dumps(field.get("value")) if isinstance(field.get("value"), (list, dict)) else field.get("value"),
"row": field.get("row"),
"column": field.get("column"),
"value_doctype": field.get("doctype_for_select"),
"available_options": field.get("options"),
"include_available_options": field.get("include_available_options", False),
"conditional_on_field": field.get("conditional_on_field"),
"conditional_on_value": field.get("conditional_on_value"),
"doctype_label_field": field.get("doctype_label_field")
} for field in fields]
new_bid_meeting_note_doc = frappe.get_doc({
"doctype": "Bid Meeting Note",
"bid_meeting": bid_meeting,
"project_template": project_template,
"form_template": form_template,
"fields": meeting_note_field_docs
})
new_bid_meeting_note_doc.insert(ignore_permissions=True)
for field_row, field in zip(new_bid_meeting_note_doc.fields, fields):
if not isinstance(field.get("value"), list):
continue
for item in field["value"]:
if not isinstance(item, dict):
continue
new_bid_meeting_note_doc.append("quantities", {
"meeting_note_field": field_row.name,
"item": item.get("item"),
"quantity": item.get("quantity")
})
new_bid_meeting_note_doc.save(ignore_permissions=True)
meeting.bid_notes = new_bid_meeting_note_doc.name
meeting.status = "Completed"
meeting.save()
frappe.db.commit()
return build_success_response(meeting.as_dict())
except frappe.DoesNotExistError:
return build_error_response(f"On-Site Meeting '{bid_meeting}' does not exist.", 404)
except Exception as e:
frappe.log_error(message=str(e), title="Submit Bid Meeting Note Form Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_bid_meeting(name):

View file

@ -14,8 +14,34 @@ from custom_ui.services import DbService, ClientService, AddressService, Contact
def get_estimate_table_data_v2(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated estimate table data with filtering and sorting."""
print("DEBUG: Raw estimate options received:", filters, sortings, page, page_size)
filters, sortings, page, page_size = DbUtils.process_query_conditions(filters, sortings, page, page_size)
filters, sortings, page, page_size = DbUtils.process_datatable_request(filters, sortings, page, page_size)
sortings = "modified desc" if not sortings else sortings
count = frappe.db.count("Quotation", filters=filters)
print(f"DEBUG: Number of estimates returned: {count}")
estimate_names = frappe.db.get_all(
"Quotation",
filters=filters,
pluck="name",
limit=page_size,
start=(page) * page_size,
order_by=sortings
)
estimates = [frappe.get_doc("Quotation", name).as_dict() for name in estimate_names]
tableRows = []
for estimate in estimates:
tableRow = {
"id": estimate["name"],
"address": frappe.db.get_value("Address", estimate.get("custom_job_address"), "full_address"),
# strip "-#-" from actual_customer_name and anything that comes after it
"customer": estimate.get("actual_customer_name").split("-#-")[0] if estimate.get("actual_customer_name") else estimate.get("customer_name") if estimate.get("customer_name") else "",
"status": estimate.get("custom_current_status", ""),
"order_type": estimate.get("order_type", ""),
}
tableRows.append(tableRow)
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(table_data_dict)
@frappe.whitelist()
def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):

View file

@ -1,6 +1,7 @@
import frappe
from custom_ui.db_utils import build_history_entries, build_success_response, build_error_response
from datetime import datetime, timedelta
import json
def get_doc_history(doctype, docname):
"""Get the history of changes for a specific document."""
@ -76,4 +77,24 @@ def get_week_holidays(week_start_date: str):
)
print(f"DEBUG: Retrieved holidays from {start_date} to {end_date}: {holidays}")
return build_success_response(holidays)
return build_success_response(holidays)
@frappe.whitelist()
def get_doc_list(doctype, fields=["*"], filters={}, pluck=None):
"""Get list of documents for a given doctype with specified fields and filters."""
if isinstance(fields, str):
fields = json.loads(fields)
if isinstance(filters, str):
filters = json.loads(filters)
try:
docs = frappe.get_all(
doctype,
fields=fields,
filters=filters,
order_by="creation desc",
pluck=pluck
)
print(f"DEBUG: Retrieved documents for {doctype} with filters {filters}: {docs}")
return build_success_response(docs)
except Exception as e:
return build_error_response(str(e), 500)

View file

@ -1,6 +1,6 @@
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 custom_ui.services import AddressService, ClientService, ServiceAppointmentService
from frappe.utils import getdate
# ===============================================================================

View file

@ -0,0 +1,40 @@
import frappe, json
from custom_ui.db_utils import build_success_response, build_error_response
from custom_ui.services import ServiceAppointmentService
@frappe.whitelist()
def get_service_appointments(companies, filters={}):
"""Get Service Appointments for given companies."""
try:
if isinstance(companies, str):
companies = json.loads(companies)
if isinstance(filters, str):
filters = json.loads(filters)
filters["company"] = ["in", companies]
service_appointment_names = frappe.get_all(
"Service Appointment",
filters=filters,
pluck="name"
)
service_appointments = [
ServiceAppointmentService.get_full_dict(name)
for name in service_appointment_names
]
return build_success_response(service_appointments)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def update_service_appointment_scheduled_dates(service_appointment_name: str, start_date, end_date, crew_lead_name, start_time=None, end_time=None):
"""Update scheduled dates for a Service Appointment."""
try:
updated_service_appointment = ServiceAppointmentService.update_scheduled_dates(
service_appointment_name,
start_date,
end_date,
start_time,
end_time
)
return build_success_response(updated_service_appointment.as_dict())
except Exception as e:
return build_error_response(str(e), 500)

View file

@ -0,0 +1,6 @@
import frappe
def attach_bid_note_form_to_project_template(doc, method):
"""Attatch Bid Meeting Note Form to Project Template on insert."""
print("DEBUG: Attaching Bid Meeting Note Form to Project Template")
frappe.set_value("Project Template", doc.project_template, "bid_meeting_note_form", doc.name)

View file

@ -1,5 +1,5 @@
import frappe
from custom_ui.services import AddressService, ClientService
from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, TaskService
from datetime import timedelta
def after_insert(doc, method):
@ -21,6 +21,21 @@ def after_insert(doc, method):
"job_status",
"In Progress"
)
service_apt = ServiceAppointmentService.create({
"project": doc.name,
"customer": doc.customer,
"address": doc.job_address,
"company": doc.company,
"project_template": doc.project_template
})
frappe.db.set_value("Project", doc.name, "service_appointment", service_apt.name)
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.name)]
for task_name in task_names:
doc.append("tasks", {
"task": task_name
})
TaskService.calculate_and_set_due_dates(task_names, "Created")
def before_insert(doc, method):
@ -41,6 +56,12 @@ def before_save(doc, method):
def after_save(doc, method):
print("DEBUG: After Save Triggered for Project:", doc.name)
event = TaskService.determine_event(doc)
if event:
TaskService.calculate_and_set_due_dates(
[task.task for task in doc.tasks],
event
)
if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install, updating Address Job Status based on Project status")
status_mapping = {

View file

@ -0,0 +1,17 @@
import frappe
from custom_ui.services import TaskService
def on_update(doc, method):
print("DEBUG: On Update Triggered for Service Appointment")
event = TaskService.determine_event(doc)
if event:
tasks = TaskService.get_tasks_by_project(doc.project)
task_names = [task.name for task in tasks]
TaskService.calculate_and_set_due_dates(task_names=task_names, event=event)
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Service Appointment")
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.calculate_and_set_due_dates(task_names=task_names, event="Created")

View file

@ -1,5 +1,5 @@
import frappe
from custom_ui.services import AddressService, ClientService
from custom_ui.services import AddressService, ClientService, TaskService
def before_insert(doc, method):
"""Set values before inserting a Task."""
@ -18,4 +18,16 @@ def after_insert(doc, method):
AddressService.append_link_v2(
doc.custom_property, "links", {"link_doctype": "Task", "link_name": doc.name}
)
ClientService.append_link_v2(
doc.customer, "tasks", {"task": doc.name, "project_template": doc.project_template }
)
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.calculate_and_set_due_dates(task_names, "Created")
def after_save(doc, method):
print("DEBUG: After Save Triggered for Task:", doc.name)
event = TaskService.determine_event(doc)
if event:
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.calculate_and_set_due_dates(task_names, event)

View file

@ -0,0 +1,519 @@
[
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Opportunity",
"enabled": 1,
"modified": "2024-06-28 04:58:27.527685",
"module": null,
"name": "Set Item Price",
"script": "frappe.ui.form.on('Opportunity Item', {\r\n item_code: function(frm, cdt, cdn) {\r\n var row = locals[cdt][cdn];\r\n if (row.item_code) {\r\n frappe.call({\r\n method: 'frappe.client.get_value',\r\n args: {\r\n 'doctype': 'Item Price',\r\n 'filters': {\r\n 'item_code': row.item_code,\r\n 'selling': 1\r\n },\r\n 'fieldname': ['price_list_rate']\r\n },\r\n callback: function(r) {\r\n if (r.message) {\r\n frappe.model.set_value(cdt, cdn, 'rate', r.message.price_list_rate);\r\n frappe.model.set_value(cdt, cdn, 'amount', row.qty * r.message.price_list_rate);\r\n }\r\n }\r\n });\r\n }\r\n },\r\n qty: function(frm, cdt, cdn) {\r\n var row = locals[cdt][cdn];\r\n if (row.qty && row.rate) {\r\n frappe.model.set_value(cdt, cdn, 'amount', row.qty * row.rate);\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "SNW Jobs",
"enabled": 1,
"modified": "2024-07-18 02:56:08.345504",
"module": null,
"name": "SNW Jobs - auto populate days",
"script": "frappe.ui.form.on('SNW Jobs', {\n refresh(frm) {\n // your code here (if needed)\n },\n start_date(frm) {\n calculate_total_days(frm);\n },\n end_date(frm) {\n calculate_total_days(frm);\n }\n});\n\nfunction calculate_total_days(frm) {\n if (frm.doc.start_date && frm.doc.end_date) {\n const startDate = new Date(frm.doc.start_date);\n const endDate = new Date(frm.doc.end_date);\n const timeDiff = endDate - startDate;\n const dayDiff = timeDiff / (1000 * 3600 * 24);\n frm.set_value('number_of_days', dayDiff);\n }\n}",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "SNW Jobs",
"enabled": 1,
"modified": "2024-07-18 03:04:59.760299",
"module": null,
"name": "SNW Jobs - Calculate Balance",
"script": "frappe.ui.form.on('SNW Jobs', {\r\n refresh(frm) {\r\n // Your code here (if needed)\r\n },\r\n total_expected_price(frm) {\r\n calculate_balance(frm);\r\n },\r\n paid(frm) {\r\n calculate_balance(frm);\r\n }\r\n});\r\n\r\nfunction calculate_balance(frm) {\r\n if (frm.doc.total_expected_price != null && frm.doc.paid != null) {\r\n const balance = frm.doc.total_expected_price - frm.doc.paid;\r\n frm.set_value('balance', balance);\r\n }\r\n}\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "SNW Jobs",
"enabled": 1,
"modified": "2024-07-18 03:12:21.825901",
"module": null,
"name": "SNW Jobs - pull crew lead employees",
"script": "frappe.ui.form.on('SNW Jobs', {\n\trefresh(frm) {\n\t\t// your code here\n\t},\n\t onload(frm) {\n frm.set_query('crew_leader', function() {\n return {\n filters: {\n designation: 'Crew Lead'\n }\n };\n });\n }\n})",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Follow Up Checklist",
"enabled": 1,
"modified": "2024-09-16 05:58:34.623817",
"module": null,
"name": "auto populate follow up form",
"script": "frappe.ui.form.on('Follow Up Checklist', {\n customer:function(frm) {\n\t\t // Fetch customer details like phone and email\n frappe.db.get_value(\"Address\", {\"customer\": frm.doc.customer, \"customer_primary_address\": 1}, \"address_line1\", function(r) {\n if(r && r.address_line1) {\n frm.set_value(\"address\", r.address_line1); // Set the address field\n }\n });\n }\n});",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Quotation",
"enabled": 1,
"modified": "2025-04-24 05:36:48.042696",
"module": null,
"name": "Item Markup Script",
"script": "frappe.ui.form.on('Quotation Item', {\r\n item_code: function(frm, cdt, cdn) {\r\n let row = locals[cdt][cdn];\r\n if (row.item_code) {\r\n // Fetch the item price from the Item Price List\r\n frappe.call({\r\n method: 'frappe.client.get_list',\r\n args: {\r\n doctype: 'Item Price',\r\n filters: {\r\n item_code: row.item_code,\r\n price_list: frm.doc.selling_price_list // Assuming the price list is set in the Quotation\r\n },\r\n fields: ['price_list_rate']\r\n },\r\n callback: function(response) {\r\n if (response.message && response.message.length > 0) {\r\n // Get the price from the Item Price List\r\n let base_rate = response.message[0].price_list_rate || 0;\r\n\r\n // Fetch the markup percentage from the Item master\r\n frappe.call({\r\n method: 'frappe.client.get',\r\n args: {\r\n doctype: 'Item',\r\n name: row.item_code\r\n },\r\n callback: function(r) {\r\n if (r.message) {\r\n // Fetch the markup percentage from the Item master\r\n let markup = r.message.custom_markup_percentage || 0; // Default to 0% if not set\r\n \r\n // Calculate the new rate with markup\r\n let new_rate = base_rate + (base_rate * (markup / 100));\r\n frappe.model.set_value(cdt, cdn, 'rate', new_rate);\r\n \r\n // Refresh the items table to show the updated rate\r\n frm.refresh_field('items');\r\n }\r\n }\r\n });\r\n }\r\n }\r\n });\r\n }\r\n }\r\n});\r\n\r\n// Optional: Recalculate all items on form refresh or load\r\nfrappe.ui.form.on('Quotation', {\r\n refresh: function(frm) {\r\n frm.doc.items.forEach(function(item) {\r\n //frappe.model.trigger('item_code', item.name);\r\n });\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Quotation",
"enabled": 1,
"modified": "2025-01-08 05:04:26.743210",
"module": null,
"name": "Quotation - Set Same Valid Until Date",
"script": "frappe.ui.form.on(\"Quotation\", {\n onload: function(frm) {\n frm.set_value('valid_till', '2025-12-31');\n }\n});",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Note",
"enabled": 1,
"modified": "2024-10-24 23:43:27.548340",
"module": null,
"name": "Open Note in Edit Mode",
"script": "frappe.ui.form.on('Note', {\r\n onload_post_render: function(frm) {\r\n // Check if this is a new document or in read mode, then switch to edit mode\r\n if (frm.is_new() || frm.doc.__unsaved) {\r\n frm.page.set_primary_action(__('Save'), () => frm.save());\r\n frm.page.wrapper.find('.btn-primary').removeClass('hidden');\r\n frm.enable_save();\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 1,
"modified": "2024-12-05 11:55:14.179233",
"module": null,
"name": "Address Doctype Linked City Auto Populate",
"script": "frappe.ui.form.on('Address', {\r\n custom_linked_city: function (frm) {\r\n if (frm.doc.custom_linked_city) {\r\n frappe.db.get_doc('City', frm.doc.custom_linked_city).then(city_doc => {\r\n frm.set_value('city', city_doc.city_name); // Sync to mandatory City field\r\n frm.set_value('state', city_doc.state); // Populate State\r\n if (city_doc.zip_code) {\r\n frm.set_value('zip_code', city_doc.zip_code); // Populate Zip Code\r\n }\r\n });\r\n }\r\n },\r\n city: function (frm) {\r\n // Optionally, sync back to custom_linked_city when the mandatory City field is used\r\n if (!frm.doc.custom_linked_city && frm.doc.city) {\r\n frappe.msgprint(__('Consider selecting a linked city for better accuracy.'));\r\n }\r\n }\r\n});",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 1,
"modified": "2024-12-06 05:15:31.487671",
"module": null,
"name": "Address - Irrigation District Visibility Condition",
"script": "frappe.ui.form.on('Address', {\r\n refresh: function(frm) {\r\n let show_irrigation_district = false;\r\n\r\n // Check if 'is_shipping_address' is checked\r\n if (frm.doc.is_shipping_address) {\r\n // Required companies list\r\n const required_companies = ['Sprinklers Northwest', 'Nuco Yard Care'];\r\n\r\n // Check child table rows\r\n if (frm.doc.custom_linked_companies) {\r\n show_irrigation_district = frm.doc.custom_linked_companies.some(row =>\r\n required_companies.includes(row.company)\r\n );\r\n }\r\n }\r\n\r\n // Show or hide the custom_irrigation_district field\r\n frm.set_df_property('custom_irrigation_district', 'hidden', !show_irrigation_district);\r\n },\r\n\r\n is_service_address: function(frm) {\r\n // Re-run visibility logic when 'is_service_address' changes\r\n frm.trigger('refresh');\r\n },\r\n\r\n custom_linked_companies: function(frm) {\r\n // Re-run visibility logic when the child table is updated\r\n frm.trigger('refresh');\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 1,
"modified": "2024-12-06 05:27:19.840296",
"module": null,
"name": "Address - Installed By Snw Visibility Condition",
"script": "frappe.ui.form.on('Address', {\r\n refresh: function(frm) {\r\n frm.trigger('toggle_irrigation_fields');\r\n },\r\n\r\n custom_installed_by_sprinklers_nw: function(frm) {\r\n frm.trigger('toggle_irrigation_fields');\r\n },\r\n\r\n toggle_irrigation_fields: function(frm) {\r\n const is_installed = frm.doc.custom_installed_by_sprinklers_nw;\r\n\r\n // Toggle visibility for irrigation-related fields\r\n frm.set_df_property('custom_install_month', 'hidden', !is_installed);\r\n frm.set_df_property('custom_install_year', 'hidden', !is_installed);\r\n frm.set_df_property('custom_backflow_test_report', 'hidden', !is_installed);\r\n frm.set_df_property('custom_photo_attachment', 'hidden', !is_installed);\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 0,
"modified": "2024-12-13 07:14:26.487456",
"module": null,
"name": "Address - Google Map Display",
"script": "frappe.ui.form.on('Address', {\r\n refresh: function(frm) {\r\n // Render Google Map if address fields are available and saved\r\n if (!frm.is_new() && frm.doc.address_line1 && frm.doc.city && frm.doc.country) {\r\n frm.trigger('render_google_map');\r\n }\r\n },\r\n\r\n render_google_map: function(frm) {\r\n // Construct the full address string\r\n const address = [\r\n frm.doc.address_line1,\r\n frm.doc.address_line2 || '', // Optional\r\n frm.doc.city,\r\n frm.doc.state || '', // Optional\r\n frm.doc.pincode || '', // Optional\r\n frm.doc.country\r\n ].filter(Boolean).join(', '); // Remove empty fields\r\n\r\n // Replace with your Google Maps API Key\r\n const apiKey = 'AIzaSyB2uNXSQpMp-lGJHIWFpzloWxs76zjkU8Y';\r\n\r\n // Generate the embed URL\r\n const mapUrl = `https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${encodeURIComponent(address)}`;\r\n\r\n // Render the iframe in the HTML field\r\n frm.fields_dict.custom_google_map.$wrapper.html(`\r\n <iframe \r\n width=\"100%\" \r\n height=\"400\" \r\n frameborder=\"0\" \r\n style=\"border:0\" \r\n src=\"${mapUrl}\" \r\n allowfullscreen>\r\n </iframe>\r\n `);\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 0,
"modified": "2025-05-15 09:00:39.086280",
"module": null,
"name": "Address - Auto-Filling Mutiple Contact Email and Phone",
"script": "frappe.ui.form.on('Address Contact Role', {\r\n contact: function(frm, cdt, cdn) {\r\n let row = frappe.get_doc(cdt, cdn);\r\n\r\n if (row.contact) {\r\n // Fetch email and phone from the selected Contact\r\n frappe.db.get_doc('Contact', row.contact).then(contact => {\r\n if (contact) {\r\n // Pull primary email and phone from the Contact\r\n row.email = contact.email_id || '';\r\n row.phone = contact.phone || contact.mobile_no || ''; // Prefer phone, fallback to mobile_no\r\n frm.refresh_field('custom_linked_contacts');\r\n }\r\n });\r\n } else {\r\n // Clear fields if no Contact selected\r\n row.email = '';\r\n row.phone = '';\r\n frm.refresh_field('custom_linked_contacts');\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Locate Log",
"enabled": 1,
"modified": "2024-12-11 12:11:35.789512",
"module": null,
"name": "Locate Button",
"script": "frappe.ui.form.on('Locate Log', {\r\n refresh: function(frm) {\r\n // Add the custom button \"Mark as Located\"\r\n if (frm.doc.status !== 'Completed') {\r\n frm.add_custom_button(__('Mark as Located'), function() {\r\n frm.trigger('mark_as_located');\r\n });\r\n }\r\n },\r\n mark_as_located: function(frm) {\r\n // Check if Dig Ticket # is provided\r\n if (!frm.doc.dig_ticket_number) {\r\n frappe.msgprint(__('Please enter a Dig Ticket #.'));\r\n return;\r\n }\r\n\r\n // Trigger the backend method to update the Job Queue and Locate Log\r\n frappe.call({\r\n method: 'my_app.my_module.api.update_locate_log_and_job_queue',\r\n args: {\r\n locate_log: frm.doc.name,\r\n dig_ticket_number: frm.doc.dig_ticket_number\r\n },\r\n callback: function(r) {\r\n if (r.message) {\r\n frappe.msgprint(__('Locate marked as completed.'));\r\n frm.reload_doc(); // Reload the form to reflect changes\r\n }\r\n }\r\n });\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pre-Built Routes",
"enabled": 0,
"modified": "2024-12-11 13:24:02.894613",
"module": null,
"name": "Real-Time Updates for Address -Route Connection",
"script": "frappe.ui.form.on('Assigned Address', {\r\n address_name: function (frm, cdt, cdn) {\r\n const row = locals[cdt][cdn];\r\n\r\n // Ensure the address_name field is set\r\n if (row.address_name) {\r\n // Fetch the custom_confirmation_status from the Address Doctype\r\n frappe.db.get_value('Address', row.address_name, 'custom_confirmation_status', (value) => {\r\n if (value && value.custom_confirmation_status) {\r\n // Set the status in the child table to match the Address confirmation status\r\n frappe.model.set_value(cdt, cdn, 'status', value.custom_confirmation_status);\r\n } else {\r\n // Default to No Response if Address confirmation status is not set\r\n frappe.model.set_value(cdt, cdn, 'status', 'No Response');\r\n }\r\n });\r\n } else {\r\n // If no address_name is set, default status to No Response\r\n frappe.model.set_value(cdt, cdn, 'status', 'No Response');\r\n }\r\n }\r\n});\r\n\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 0,
"modified": "2024-12-11 13:09:36.908372",
"module": null,
"name": "Real Time Updates for Address Doctype Connection to Pre-Built",
"script": "frappe.ui.form.on('Address', {\r\n custom_confirmation_status: function (frm) {\r\n // Check if the Service Route is set\r\n if (!frm.doc.custom_service_route) {\r\n frappe.msgprint(__('Service Route is not assigned for this address.'));\r\n return;\r\n }\r\n\r\n // Retrieve the Assigned Address row where this address is linked\r\n frappe.call({\r\n method: 'frappe.client.get_value',\r\n args: {\r\n doctype: 'Assigned Address',\r\n filters: { address_name: frm.doc.name },\r\n fieldname: 'name'\r\n },\r\n callback: function (response) {\r\n if (response.message) {\r\n const assigned_row_name = response.message.name;\r\n // Update the Status in the Assigned Address row\r\n frappe.call({\r\n method: 'frappe.client.set_value',\r\n args: {\r\n doctype: 'Assigned Address',\r\n name: assigned_row_name,\r\n fieldname: 'status',\r\n value: frm.doc.custom_confirmation_status\r\n },\r\n callback: function (response) {\r\n if (!response.exc) {\r\n frappe.msgprint(__('Status updated in Pre-Built Route.'));\r\n }\r\n }\r\n });\r\n } else {\r\n frappe.msgprint(__('This address is not linked to any Pre-Built Route.'));\r\n }\r\n }\r\n });\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pre-Built Routes",
"enabled": 1,
"modified": "2024-12-13 09:14:52.635425",
"module": null,
"name": "Color Code Route Child Table",
"script": "frappe.ui.form.on('Pre-Built Routes', {\r\n refresh: function (frm) {\r\n // Loop through the rows in the Assigned Addresses table\r\n frm.fields_dict['assigned_addresses'].grid.wrapper.find('.grid-row').each(function () {\r\n const row = $(this);\r\n const doc = row.data('doc'); // Get the child table row data\r\n\r\n if (doc && doc.status) {\r\n // Apply color based on the Status field\r\n switch (doc.status) {\r\n case 'Confirmed':\r\n row.css('background-color', '#D4EDDA'); // Green for Scheduled\r\n break;\r\n case 'Reschedule':\r\n row.css('background-color', '#FFF3CD'); // Yellow for Completed\r\n break;\r\n case 'Declined':\r\n row.css('background-color', '#F8D7DA'); // Red for Pending Reschedule\r\n break;\r\n default:\r\n row.css('background-color', '#E2E0E0',); // Reset to default if no match\r\n break;\r\n }\r\n }\r\n });\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pre-Built Routes",
"enabled": 1,
"modified": "2024-12-11 13:25:17.161414",
"module": null,
"name": "Dynamically Update Confirmation Status on Pre-Built Route",
"script": "frappe.ui.form.on('Assigned Address', {\r\n address_name: function (frm, cdt, cdn) {\r\n const row = locals[cdt][cdn];\r\n\r\n // Ensure the address_name field is set\r\n if (row.address_name) {\r\n // Fetch the custom_confirmation_status from the Address Doctype\r\n frappe.db.get_value('Address', row.address_name, 'custom_confirmation_status', (value) => {\r\n if (value && value.custom_confirmation_status) {\r\n // Set the status in the child table to match the Address confirmation status\r\n frappe.model.set_value(cdt, cdn, 'status', value.custom_confirmation_status);\r\n } else {\r\n // Default to No Response if Address confirmation status is not set\r\n frappe.model.set_value(cdt, cdn, 'status', 'No Response');\r\n }\r\n });\r\n } else {\r\n // If no address_name is set, default status to No Response\r\n frappe.model.set_value(cdt, cdn, 'status', 'No Response');\r\n }\r\n }\r\n});",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pre-Built Routes",
"enabled": 0,
"modified": "2024-12-13 09:03:06.173211",
"module": null,
"name": "Route Map",
"script": "frappe.ui.form.on('Pre-Built Routes', {\r\n refresh: function (frm) {\r\n // Check if Google Maps API is loaded\r\n if (!window.google || !window.google.maps) {\r\n frappe.msgprint(__('Google Maps API is not loaded. Attempting to load...'));\r\n\r\n // Dynamically load the Google Maps API\r\n const script = document.createElement('script');\r\n script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyB2uNXSQpMp-lGJHIWFpzloWxs76zjkU8Y`;\r\n script.async = true;\r\n script.defer = true;\r\n\r\n // Call renderMap after the API is loaded\r\n script.onload = function () {\r\n console.log(\"Google Maps API loaded successfully.\");\r\n renderMap(frm); // Render the map after API loads\r\n };\r\n\r\n // Handle API load failure\r\n script.onerror = function () {\r\n frappe.msgprint(__('Failed to load Google Maps API. Please check your API key.'));\r\n };\r\n\r\n document.head.appendChild(script);\r\n } else {\r\n // If API is already loaded, render the map immediately\r\n renderMap(frm);\r\n }\r\n }\r\n});\r\n\r\nfunction renderMap(frm) {\r\n // Ensure the form has assigned addresses\r\n const addresses = frm.doc.assigned_addresses || [];\r\n if (!addresses.length) {\r\n frappe.msgprint(__('No addresses found for this route.'));\r\n return;\r\n }\r\n\r\n // Prepare points for the map\r\n const points = addresses.map((row) => ({\r\n lat: parseFloat(row.latitude || 0),\r\n lng: parseFloat(row.longitude || 0),\r\n status: row.status || 'No Response',\r\n title: row.address_name || 'Unknown Address'\r\n }));\r\n\r\n console.log(\"Map points:\", points); // Debug the points array\r\n\r\n // Define marker colors based on status\r\n const statusColors = {\r\n 'Confirmed': 'green',\r\n 'Reschedule': 'yellow',\r\n 'Decline': 'red',\r\n 'No Response': 'white'\r\n };\r\n\r\n // Initialize map container\r\n const mapContainer = frm.fields_dict['route_map'].wrapper;\r\n mapContainer.innerHTML = '<div id=\"route-map\" style=\"height: 400px;\"></div>';\r\n const map = new google.maps.Map(document.getElementById('route-map'), {\r\n zoom: 10,\r\n mapTypeId: 'roadmap'\r\n });\r\n\r\n // Fit map bounds to all points\r\n const bounds = new google.maps.LatLngBounds();\r\n\r\n // Add markers for each point\r\n points.forEach((point) => {\r\n if (point.lat && point.lng) {\r\n const marker = new google.maps.Marker({\r\n position: { lat: point.lat, lng: point.lng },\r\n map: map,\r\n title: point.title,\r\n icon: {\r\n path: google.maps.SymbolPath.CIRCLE,\r\n scale: 8,\r\n fillColor: statusColors[point.status],\r\n fillOpacity: 1,\r\n strokeWeight: 0\r\n }\r\n });\r\n bounds.extend(marker.getPosition());\r\n }\r\n });\r\n\r\n // Adjust map view to fit all markers\r\n if (!bounds.isEmpty()) {\r\n map.fitBounds(bounds);\r\n } else {\r\n frappe.msgprint(__('No valid points to display on the map.'));\r\n }\r\n}\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 1,
"modified": "2025-05-15 12:34:32.006687",
"module": null,
"name": "Test - Update Address Map Display with lat/long",
"script": "frappe.ui.form.on('Address', {\r\n refresh: function(frm) {\r\n frm.trigger('render_google_map');\r\n },\r\n\r\n render_google_map: function(frm) {\r\n if (!frm.is_new() && frm.doc.address_line1 && frm.doc.city && frm.doc.country) {\r\n // Render Google Map if address fields are available and saved\r\n\r\n const address = [\r\n frm.doc.address_line1,\r\n frm.doc.address_line2 || '',\r\n frm.doc.city,\r\n frm.doc.state || '',\r\n frm.doc.pincode || '',\r\n frm.doc.country\r\n ].filter(Boolean).join(', ');\r\n \r\n const apiKey = 'AIzaSyCd3ALZe6wjt3xnc7X_rRItfKAEJugfuZ4';\r\n const mapUrl = `https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${encodeURIComponent(address)}`;\r\n \r\n frm.fields_dict.custom_google_map.$wrapper.html(`\r\n <iframe \r\n width=\"100%\" \r\n height=\"400\" \r\n frameborder=\"0\" \r\n style=\"border:0\" \r\n src=\"${mapUrl}\" \r\n allowfullscreen>\r\n </iframe>\r\n `);\r\n } else {\r\n frm.fields_dict.custom_google_map.$wrapper.html('');\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pre-Built Routes",
"enabled": 1,
"modified": "2024-12-13 08:47:49.392360",
"module": null,
"name": "Test - Pull The lat/long into Assigned Address",
"script": "frappe.ui.form.on('Assigned Address', {\r\n address_name: function(frm, cdt, cdn) {\r\n let row = locals[cdt][cdn];\r\n if (row.address_name) {\r\n frappe.db.get_value('Address', row.address_name, ['custom_latitude', 'custom_longitude'], (value) => {\r\n if (value) {\r\n frappe.model.set_value(cdt, cdn, 'latitude', value.custom_latitude);\r\n frappe.model.set_value(cdt, cdn, 'longitude', value.custom_longitude);\r\n } else {\r\n frappe.msgprint(__('Could not fetch coordinates for the selected address.'));\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pre-Built Routes",
"enabled": 1,
"modified": "2024-12-13 09:07:36.356251",
"module": null,
"name": "Test - Render color coded map",
"script": "frappe.ui.form.on('Pre-Built Routes', {\r\n refresh: function(frm) {\r\n // Dynamically load Google Maps API if not already loaded\r\n if (!window.google || !window.google.maps) {\r\n const apiKey = 'AIzaSyCd3ALZe6wjt3xnc7X_rRItfKAEJugfuZ4'; // Replace with your actual API key\r\n const script = document.createElement('script');\r\n script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`;\r\n script.async = true;\r\n script.defer = true;\r\n script.onload = function () {\r\n console.log(\"Google Maps API loaded successfully.\");\r\n renderMap(frm); // Render map after API is loaded\r\n };\r\n script.onerror = function () {\r\n frappe.msgprint(__('Failed to load Google Maps API. Please check your API key.'));\r\n };\r\n document.head.appendChild(script);\r\n return;\r\n }\r\n\r\n // If API is already loaded, render the map immediately\r\n renderMap(frm);\r\n }\r\n});\r\n\r\nfunction renderMap(frm) {\r\n const addresses = frm.doc.assigned_addresses || [];\r\n if (!addresses.length) {\r\n frappe.msgprint(__('No addresses to display on the map.'));\r\n return;\r\n }\r\n\r\n // Prepare points for the map\r\n const points = addresses.map(row => ({\r\n lat: parseFloat(row.latitude || 0),\r\n lng: parseFloat(row.longitude || 0),\r\n status: row.status || 'No Response',\r\n title: row.address_name || 'Unknown Address'\r\n }));\r\n\r\n // Define marker colors based on status\r\n const statusColors = {\r\n 'Confirmed': 'green',\r\n 'Reschedule': 'yellow',\r\n 'Declined': 'red',\r\n 'No Response': 'gray'\r\n };\r\n\r\n const mapContainer = frm.fields_dict['route_map'].wrapper;\r\n mapContainer.innerHTML = '<div id=\"route-map\" style=\"height: 400px;\"></div>';\r\n const map = new google.maps.Map(document.getElementById('route-map'), {\r\n zoom: 10,\r\n mapTypeId: 'roadmap'\r\n });\r\n\r\n const bounds = new google.maps.LatLngBounds();\r\n\r\n // Add markers for each point\r\n points.forEach(point => {\r\n if (point.lat && point.lng) {\r\n const marker = new google.maps.Marker({\r\n position: { lat: point.lat, lng: point.lng },\r\n map: map,\r\n title: point.title,\r\n icon: {\r\n path: google.maps.SymbolPath.CIRCLE,\r\n scale: 8,\r\n fillColor: statusColors[point.status],\r\n fillOpacity: 1,\r\n strokeWeight: 0\r\n }\r\n });\r\n bounds.extend(marker.getPosition());\r\n }\r\n });\r\n\r\n if (!bounds.isEmpty()) {\r\n map.fitBounds(bounds);\r\n }\r\n}\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Locate Log",
"enabled": 1,
"modified": "2024-12-16 14:47:43.738700",
"module": null,
"name": "calculated start date",
"script": "frappe.ui.form.on('Your DocType', {\r\n date_located: function(frm) {\r\n if (frm.doc.date_located) {\r\n var input_date = frm.doc.date_located;\r\n var calculated_date = addBusinessDays(input_date, 2);\r\n frm.set_value('legal_start_date', calculated_date);\r\n }\r\n }\r\n});\r\n\r\n// Function to calculate date after a specified number of business days\r\nfunction addBusinessDays(startDate, numBusinessDays) {\r\n var date = new Date(startDate);\r\n var daysAdded = 0;\r\n \r\n while (daysAdded < numBusinessDays) {\r\n date.setDate(date.getDate() + 1);\r\n // Check if the new date is a weekend (Saturday = 6, Sunday = 0)\r\n if (date.getDay() !== 0 && date.getDay() !== 6) {\r\n daysAdded++;\r\n }\r\n }\r\n \r\n // Return the calculated date in the correct format for ERPNext\r\n return frappe.datetime.add_days(date, 0);\r\n}\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Locate Log",
"enabled": 1,
"modified": "2024-12-16 14:47:43.716572",
"module": null,
"name": "calculated start date-list",
"script": "frappe.ui.form.on('Your DocType', {\r\n date_located: function(frm) {\r\n if (frm.doc.date_located) {\r\n var input_date = frm.doc.date_located;\r\n var calculated_date = addBusinessDays(input_date, 2);\r\n frm.set_value('legal_start_date', calculated_date);\r\n }\r\n }\r\n});\r\n\r\n// Function to calculate date after a specified number of business days\r\nfunction addBusinessDays(startDate, numBusinessDays) {\r\n var date = new Date(startDate);\r\n var daysAdded = 0;\r\n \r\n while (daysAdded < numBusinessDays) {\r\n date.setDate(date.getDate() + 1);\r\n // Check if the new date is a weekend (Saturday = 6, Sunday = 0)\r\n if (date.getDay() !== 0 && date.getDay() !== 6) {\r\n daysAdded++;\r\n }\r\n }\r\n \r\n // Return the calculated date in the correct format for ERPNext\r\n return frappe.datetime.add_days(date, 0);\r\n}\r\n",
"view": "List"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Locate Log",
"enabled": 1,
"modified": "2024-12-16 14:47:18.813864",
"module": null,
"name": "expiration date",
"script": "frappe.ui.form.on('Your DocType', {\r\n // Trigger when the \"Marked as Located\" button is clicked\r\n refresh: function(frm) {\r\n frm.add_custom_button(__('Marked as Located'), function() {\r\n update_expiration_date(frm);\r\n });\r\n }\r\n});\r\n\r\n// Function to update the expiration_date based on legal_start_date and state\r\nfunction update_expiration_date(frm) {\r\n if (frm.doc.legal_start_date && frm.doc.state) {\r\n var legalStartDate = frm.doc.legal_start_date;\r\n var expirationDate = null;\r\n \r\n // Check the state and calculate expiration date accordingly\r\n if (frm.doc.state === \"ID\") {\r\n expirationDate = frappe.datetime.add_days(legalStartDate, 28); // 28 days for ID\r\n } else if (frm.doc.state === \"WA\") {\r\n expirationDate = frappe.datetime.add_days(legalStartDate, 45); // 45 days for WA\r\n }\r\n\r\n // Set the calculated expiration date if a valid state is selected\r\n if (expirationDate) {\r\n frm.set_value('expiration_date', expirationDate);\r\n frappe.msgprint(__('Expiration Date has been calculated and updated.'));\r\n } else {\r\n frappe.msgprint(__('Please make sure both legal start date and state are filled.'));\r\n }\r\n } else {\r\n frappe.msgprint(__('Please make sure both legal start date and state are filled.'));\r\n }\r\n}\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Locate Log",
"enabled": 1,
"modified": "2024-12-16 14:53:13.214793",
"module": null,
"name": "expiration date -list",
"script": "frappe.ui.form.on('Your DocType', {\r\n date_located: function(frm) {\r\n if (frm.doc.date_located) {\r\n var input_date = frm.doc.date_located;\r\n var calculated_date = addBusinessDays(input_date, 30);\r\n frm.set_value('expiration_date', calculated_date);\r\n }\r\n }\r\n});\r\n\r\n// Function to calculate date after a specified number of business days\r\nfunction addBusinessDays(startDate, numBusinessDays) {\r\n var date = new Date(startDate);\r\n var daysAdded = 0;\r\n \r\n while (daysAdded < numBusinessDays) {\r\n date.setDate(date.getDate() + 1);\r\n // Check if the new date is a weekend (Saturday = 6, Sunday = 0)\r\n if (date.getDay() !== 0 && date.getDay() !== 6) {\r\n daysAdded++;\r\n }\r\n }\r\n \r\n // Return the calculated date in the correct format for ERPNext\r\n return frappe.datetime.add_days(date, 0);\r\n}\r\n",
"view": "List"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Quotation",
"enabled": 0,
"modified": "2025-01-24 13:24:25.882893",
"module": null,
"name": "Auto populate address from customer - test",
"script": "frappe.ui.form.on('Quotation', {\r\n customer: function(frm) {\r\n // Trigger when the customer field is set or changed\r\n if (frm.doc.customer) {\r\n frappe.call({\r\n method: 'frappe.client.get_value',\r\n args: {\r\n doctype: 'Customer',\r\n filters: { name: frm.doc.customer },\r\n fieldname: 'address_html'\r\n },\r\n callback: function(response) {\r\n if (response.message && response.message.address_html) {\r\n // Set the customer_address field in the Quotation\r\n frm.set_value('customer_address', response.message.address_html);\r\n } else {\r\n frappe.msgprint(__('No address found for the selected customer.'));\r\n }\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 0,
"modified": "2025-01-24 14:05:52.607856",
"module": "Brotherton SOP",
"name": "address concatenate",
"script": "frappe.ui.form.on('<Address>', {\n validate: function(frm) {\n // Concatenate the fields\n frm.set_value('address_line1', \n (frm.doc.custom_street_number || '') + ', ' + \n (frm.doc.custom_directional || '') + ', ' + \n (frm.doc.custom_street_name || '') + ', ' + \n (frm.doc.custom_street_suffix || '')\n );\n }\n});",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 1,
"modified": "2025-05-15 12:30:18.280633",
"module": null,
"name": "Address Validation",
"script": "const renameInProgress = new Set();\r\nfunction rename(frm) {\r\n const newName = frm.doc.address_line1;\r\n if (!renameInProgress.has(newName)) {\r\n renameInProgress.add(newName);\r\n return frappe.call({\r\n method: 'frappe.rename_doc',\r\n freeze: true,\r\n \t\tfreeze_message: \"Updating name...\",\r\n args: {\r\n doctype: 'Address',\r\n old: frm.doc.name,\r\n new: newName,\r\n merge: 0\r\n },\r\n \r\n // From https://github.com/frappe/frappe/blob/f708acb59e3cdc9ec1a91bcdfc0f36d6d012cbf5/frappe/public/js/frappe/model/model.js#L787\r\n callback: function (r, rt) {\r\n \t\t\tif (!r.exc) {\r\n \t\t\t\t$(document).trigger(\"rename\", [\r\n \t\t\t\t\t'Address',\r\n \t\t\t\t\tfrm.doc.name,\r\n \t\t\t\t\tr.message || newName,\r\n \t\t\t\t]);\r\n \t\t\t}\r\n \t\t\trenameInProgress.delete(newName);\r\n \t\t},\r\n })\r\n }\r\n}\r\n\r\nfrappe.ui.form.on('Address', {\r\n // Trigger validation and formatting on refresh\r\n refresh: function (frm) {\r\n // Trigger field validation when the form is refreshed\r\n frm.trigger('format_address_fields');\r\n \r\n // Rename to remove appended type, if needed\r\n if (!frm.is_new() && frm.doc.name !== frm.doc.address_line1 && frm.doc.address_type === \"Other\") {\r\n // Trial and error, seems like 1 second is needed to not cause issues\r\n setTimeout(() => rename(frm), 1000);\r\n }\r\n },\r\n\r\n // Watch for changes in address_line1 and address_line2 fields\r\n address_line1: function (frm) {\r\n frm.trigger('format_address_fields');\r\n if (frm.doc.address_line1 && frm.doc.address_title !== frm.doc.address_line1) {\r\n frm.set_value('address_title', frm.doc.address_line1);\r\n }\r\n },\r\n address_line2: function (frm) {\r\n frm.trigger('format_address_fields');\r\n },\r\n\r\n // Format and validate address fields\r\n format_address_fields: function (frm) {\r\n // Helper function to capitalize text and remove punctuation\r\n function format_text(field) {\r\n let value = frm.doc[field] || '';\r\n // Remove punctuation and capitalize the text\r\n let formatted_value = value\r\n .replace(/[.,!?;:']/g, '') // Remove punctuation\r\n .toUpperCase(); // Capitalize text\r\n return formatted_value;\r\n }\r\n\r\n // Format address_line1 and address_line2\r\n const formatted_line1 = format_text('address_line1');\r\n const formatted_line2 = format_text('address_line2');\r\n\r\n // Set the formatted values back to the form\r\n if (formatted_line1 !== frm.doc.address_line1) {\r\n frm.set_value('address_line1', formatted_line1);\r\n }\r\n if (formatted_line2 !== frm.doc.address_line2) {\r\n frm.set_value('address_line2', formatted_line2);\r\n }\r\n\r\n // Validate if punctuation exists (after formatting)\r\n if (/[.,!?;:']/.test(frm.doc.address_line1) || /[.,!?;:']/.test(frm.doc.address_line2)) {\r\n frappe.msgprint(__('Punctuation is not allowed in address fields.'));\r\n }\r\n },\r\n\r\n // Before saving the document, validate the fields\r\n validate: function (frm) {\r\n const invalidFields = [];\r\n\r\n // Check if punctuation still exists in address fields\r\n ['address_line1', 'address_line2'].forEach(field => {\r\n if (/[.,!?;:']/.test(frm.doc[field])) {\r\n invalidFields.push(field);\r\n }\r\n });\r\n\r\n // If invalid fields exist, stop the save process and alert the user\r\n if (invalidFields.length > 0) {\r\n frappe.msgprint(__('Punctuation is not allowed in address fields: ') + invalidFields.join(', '));\r\n frappe.validated = false; // Prevent saving\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "QB Export",
"enabled": 1,
"modified": "2025-02-04 03:12:39.473331",
"module": null,
"name": "QB Export",
"script": "frappe.ui.form.on('QB Export', {\n\tonload(frm) {\n\t if (!frm.doc.start_date) frm.doc.start_date = new Date(new Date().getFullYear(), new Date().getMonth()-1)\n if (!frm.doc.end_date) frm.doc.end_date = new Date(new Date().getFullYear(), new Date().getMonth(), 0)\n }\n})",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Customer",
"enabled": 1,
"modified": "2025-02-05 05:28:54.588303",
"module": null,
"name": "Customer - Allow Multiple Addresses To Be Asssigned",
"script": "frappe.ui.form.on('Custom Customer Address Link', {\r\n address_name: function(frm, cdt, cdn) {\r\n let row = locals[cdt][cdn];\r\n \r\n if (row.address_name) {\r\n frappe.call({\r\n method: \"frappe.client.get\",\r\n args: {\r\n doctype: \"Address\",\r\n name: row.address_name\r\n },\r\n callback: function(r) {\r\n if (r.message) {\r\n frappe.model.set_value(cdt, cdn, \"address_line1\", r.message.address_line1);\r\n frappe.model.set_value(cdt, cdn, \"city\", r.message.city);\r\n frappe.model.set_value(cdt, cdn, \"state\", r.message.state);\r\n frappe.model.set_value(cdt, cdn, \"pincode\", r.message.pincode);\r\n frappe.model.set_value(cdt, cdn, \"address_type\", r.message.address_type);\r\n }\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Sales Order",
"enabled": 1,
"modified": "2025-03-05 05:13:03.150519",
"module": null,
"name": "Carry Over Installation Address",
"script": "frappe.ui.form.on(\"Sales Order\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.quotation) {\r\n frappe.db.get_value(\"Quotation\", frm.doc.quotation, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n \r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Sales Invoice",
"enabled": 1,
"modified": "2025-03-05 05:13:42.768423",
"module": null,
"name": "Quotation to Sales Invoice Carry Over",
"script": "frappe.ui.form.on(\"Sales Invoice\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.quotation) {\r\n frappe.db.get_value(\"Quotation\", frm.doc.quotation, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Work Order",
"enabled": 1,
"modified": "2025-03-05 07:09:42.680637",
"module": null,
"name": "Sales Order to Work Order Carry Over",
"script": "frappe.ui.form.on(\"Work Order\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.sales_order) {\r\n frappe.db.get_value(\"Sales Order\", frm.doc.sales_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Delivery Note",
"enabled": 1,
"modified": "2025-03-05 05:15:06.408083",
"module": null,
"name": "Sales Order to Delivery Note Carry Over",
"script": "frappe.ui.form.on(\"Delivery Note\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.against_sales_order) {\r\n frappe.db.get_value(\"Sales Order\", frm.doc.against_sales_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Payment Entry",
"enabled": 1,
"modified": "2025-04-25 04:17:25.626671",
"module": null,
"name": "Sales Invoice to Payment Entry Carry Over",
"script": "frappe.ui.form.on(\"Payment Entry\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.reference_doctype && frm.doc.reference_name) {\r\n let source_doctype = frm.doc.reference_doctype;\r\n let source_name = frm.doc.reference_name;\r\n\r\n frappe.db.get_value(\"Sales Invoice\", frm.doc.sales_invoice, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Job Card",
"enabled": 1,
"modified": "2025-03-05 05:22:37.566028",
"module": null,
"name": "Work Order to Job Card Carry Over",
"script": "frappe.ui.form.on(\"Job Card\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.work_order) {\r\n frappe.db.get_value(\"Work Order\", frm.doc.work_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_jobsite\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Project",
"enabled": 1,
"modified": "2025-03-05 05:19:29.828616",
"module": null,
"name": "Sales Order to Project Carry Over",
"script": "frappe.ui.form.on(\"Project\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.sales_order) {\r\n frappe.db.get_value(\"Sales Order\", frm.doc.sales_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 1,
"modified": "2025-09-02 11:38:09.302073",
"module": null,
"name": "Buttons on Address Doctype",
"script": "frappe.ui.form.on(\"Address\", {\r\n refresh: function (frm) {\r\n if (!frm.is_new()) {\r\n frm.add_custom_button(__('Schedule On-Site Meeting'), function () {\r\n sessionStorage.setItem('on-site-meeting-address', frm.doc.name);\r\n frappe.set_route(\"List\", \"On-Site Meeting\", \"Calendar\", \"On-Site Meeting Calendar\");\r\n }, __(\"Create\"));\r\n \r\n // Add button to create a Sales Order\r\n frm.add_custom_button(__('Create Sales Order'), function () {\r\n frappe.new_doc('Sales Order', {\r\n custom_installation_address: frm.doc.name,\r\n customer: frm.doc.custom_customer_to_bill\r\n });\r\n }, __(\"Create\"));\r\n \r\n // Add button to create a Quotation\r\n frm.add_custom_button(__('Create Quotation'), function () {\r\n frappe.new_doc('Quotation', {\r\n custom_installation_address: frm.doc.name,\r\n quotation_to: 'Customer',\r\n party_name: frm.doc.custom_customer_to_bill\r\n });\r\n }, __(\"Create\"));\r\n // Add button to create a new Service Appointment\r\n frm.add_custom_button(__('Create Service Appointment'), function() {\r\n frappe.new_doc('Service Appointment', {\r\n //custom_location_of_meeting: frm.doc.name,\r\n contact: frm.doc.custom_customer_to_bill\r\n });\r\n }, __(\"Create\"));\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "On-Site Meeting",
"enabled": 1,
"modified": "2025-04-17 12:19:42.335880",
"module": null,
"name": "On-Site Meeting - autofill address from session Enabled",
"script": "frappe.ui.form.on('On-Site Meeting', {\n\tonload(frm) {\n\t if (sessionStorage.getItem('on-site-meeting-address')) {\n\t frm.set_value('address', sessionStorage.getItem('on-site-meeting-address'));\n\t console.log('start_time', frm.doc.start_time)\n\t if (frm.doc.start_time) {\n\t frm.save();\n\t }\n\t }\n\t},\n\tvalidate(frm) {\n\t let end = new Date(frm.doc.start_time + \" GMT\");\n\t end.setHours(end.getHours() + 1)\n\t frm.set_value('end_time', end.toISOString().replace('T', ' ').split('.')[0]);\n\t},\n\tafter_save(frm) {\n\t console.log('on submit')\n\t if (frm.doc.address === sessionStorage.getItem('on-site-meeting-address')) {\n\t frappe.set_route('Form', 'Address', frm.doc.address);\n\t }\n\t sessionStorage.removeItem('on-site-meeting-address');\n\t},\n\tafter_cancel(frm) {\n\t sessionStorage.removeItem('on-site-meeting-address');\n\t},\n\tafter_discard(frm) {\n\t sessionStorage.removeItem('on-site-meeting-address');\n\t},\n})",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Quotation",
"enabled": 1,
"modified": "2025-05-08 13:56:46.345762",
"module": null,
"name": "Quick-send quote button (SNW)",
"script": "frappe.ui.form.on('Quotation', {\n\trefresh(frm) {\n\t if (frm.doc.status == \"Open\" && frm.doc.company == \"Sprinklers Northwest\") {\n \t\tfrm.add_custom_button(__('Send Email'), async function () {\n \t\t const party = frm.doc.quotation_to && frm.doc.party_name ? await frappe.db.get_doc(frm.doc.quotation_to, frm.doc.party_name) : null;\n \t\t const address = frm.doc.custom_installation_address ? await frappe.db.get_doc('Address', frm.doc.custom_installation_address) : null;\n \t\t \n \t\t let email = null;\n \t\t if (party && party.email_id) {\n \t\t email = party.email_id;\n \t\t } else if (party && party.email_ids && party.email_ids.length) {\n \t\t const primary = party.email_ids.find(email => email.is_primary);\n \t\t if (primary) email = primary.email_id;\n \t\t else email = party.email_ids[0].email_id;\n \t\t } else if (address && address.email_id) {\n \t\t email = address.email_id;\n \t\t }\n \t\t \n \t\t if (!email) {\n \t\t frappe.msgprint(\"No email on customer or address\");\n \t\t return;\n \t\t }\n \t\t \n frappe.confirm(`Send quote to ${frm.doc.party_name} (${email})?`,\n async () => {\n const { message: { subject, message } } = await frappe.call({\n method: \"frappe.email.doctype.email_template.email_template.get_email_template\",\n type: \"POST\",\n args: {\n template_name: 'Quote with Actions - SNW',\n doc: frm.doc\n }\n });\n \n await frappe.call({\n method: \"frappe.core.doctype.communication.email.make\",\n type: 'POST',\n args: {\n recipients: email, \n subject,\n content: message,\n doctype: 'Quotation',\n name: frm.doc.name,\n send_email: 1,\n send_me_a_copy: 0,\n print_format: 'SNW Quotations',\n email_template: 'Quote with Actions - SNW',\n read_receipt: 1,\n print_letterhead: 1\n }\n });\n \n frappe.msgprint(\"Email Queued\");\n },\n null\n );\n });\n\t }\n\t}\n})",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Payment Entry",
"enabled": 1,
"modified": "2025-05-02 11:03:34.086145",
"module": null,
"name": "Installation Address Carry Over from Sales Order to Payment Address",
"script": "frappe.ui.form.on(\"Payment Entry\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.against_sales_order) {\r\n frappe.db.get_value(\"Sales Order\", frm.doc.against_sales_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n\r\n\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Payment Entry",
"enabled": 1,
"modified": "2025-05-08 13:20:53.255294",
"module": null,
"name": "Fetch billing customer from installation address",
"script": "frappe.ui.form.on('Payment Entry', {\n\tcustom_installation_address(frm) {\n\t if (frm.doc.custom_installation_address) {\n\t frappe.db.get_doc('Address', frm.doc.custom_installation_address).then(doc => {\n frm.set_value('party_type', 'Customer')\n frm.set_value('party', doc.custom_customer_to_bill)\n })\n\t }\n\t}\n})",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pre-Built Routes",
"enabled": 1,
"modified": "2025-06-17 02:12:53.095227",
"module": null,
"name": "Filter Route Technicians by Role",
"script": "frappe.ui.form.on('Pre-Built Routes', {\r\n onload: function(frm) {\r\n\r\n // Filter Crew Leader: Only show employees with designation = \"Crew Lead\"\r\n frm.set_query('crew_leader', () => {\r\n return {\r\n filters: {\r\n designation: 'Crew Lead',\r\n status: 'Active'\r\n }\r\n };\r\n });\r\n\r\n // Filter Technicians: Only show active employees who are NOT crew leads\r\n frm.fields_dict['assigned_technicians'].grid.get_field('employee').get_query = function() {\r\n return {\r\n filters: [\r\n ['designation', '!=', 'Crew Lead'],\r\n ['status', '=', 'Active']\r\n ]\r\n };\r\n };\r\n\r\n },\r\n\r\n // Optional: Prevent assigning crew leader as a technician too\r\n validate: function(frm) {\r\n let crew_leader = frm.doc.crew_leader;\r\n let techs = frm.doc.assigned_technicians || [];\r\n\r\n let duplicate = techs.find(row => row.employee === crew_leader);\r\n if (duplicate) {\r\n frappe.msgprint(__('Crew Leader cannot be listed as a Technician.'));\r\n frappe.validated = false;\r\n }\r\n }\r\n});\r\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pre-Built Routes",
"enabled": 1,
"modified": "2025-06-17 02:20:56.937981",
"module": null,
"name": "Auto-Fetch Relevant Sales Order",
"script": "// Trigger this logic whenever the address_name field is changed\nfrappe.ui.form.on('Assigned Address', {\n address_name: function(frm, cdt, cdn) {\n let row = locals[cdt][cdn];\n// Exit early if no address is selected\n if (!row.address_name) return;\n // Call the server to fetch all Sales Orders with this installation address\n frappe.call({\n method: \"frappe.client.get_list\",\n args: {\n doctype: \"Sales Order\",\n filters: {\n custom_installation_address: row.address_name\n },\n fields: [\"name\", \"customer\", \"transaction_date\", \"status\", \"grand_total\"]\n },\n callback: function(response) {\n const orders = response.message;\n // Case: No Sales Orders found\n if (!orders || orders.length === 0) {\n frappe.msgprint(\"No Sales Orders found for this address.\");\n frappe.model.set_value(cdt, cdn, \"linked_sales_order\", \"\");\n return;\n }\n // Case: Exactly one Sales Order found — auto-select it\n if (orders.length === 1) {\n frappe.model.set_value(cdt, cdn, \"linked_sales_order\", orders[0].name);\n return;\n }\n\n // Case: Multiple Sales Orders found — show a dialog to select one\n\n // Create a user-friendly list of options for the select field\n const options = orders.map(order => {\n return {\n label: `${order.name} | ${order.customer} | ${frappe.datetime.str_to_user(order.transaction_date)} | ${order.status} | $${order.grand_total}`,\n value: order.name\n };\n });\n // Define and show a custom dialog for selecting the appropriate Sales Order\n const dialog = new frappe.ui.Dialog({\n title: \"Select Sales Order for \" + row.address_name,\n fields: [\n {\n fieldname: 'selected_so',\n label: 'Sales Order',\n fieldtype: 'Select',\n options: options,\n reqd: 1\n }\n ],\n primary_action_label: 'Select',\n primary_action(values) {\n frappe.model.set_value(cdt, cdn, \"linked_sales_order\", values.selected_so);\n dialog.hide();\n }\n });\n\n dialog.show();\n }\n });\n }\n});\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Project",
"enabled": 1,
"modified": "2025-08-29 17:31:31.663751",
"module": null,
"name": "Project Warranty Countdown",
"script": "frappe.ui.form.on('Project', {\n\trefresh(frm) {\n\t\tif (frm.doc.status == \"Completed\" && frm.doc.project_template == \"SNW Install\") {\n\t\t let message;\n\t\t const days = Math.abs(frappe.datetime.get_day_diff(frm.doc.custom_warranty_expiration_date, frm.doc.custom_completion_date));\n\t\t let dayMessage = days == 1 ? \"day\" : \"days\";\n\t\t if (frappe.datetime.get_today() <= frm.doc.custom_warranty_expiration_date) {\n\t\t message = `Warranty is valid for ${days} more ${dayMessage}`;\n\t\t } else {\n\t\t message = `Warranty has expired ${days} ${dayMessage} ago.`;\n\t\t }\n\t\t frm.set_value(\"custom_warranty_information\", message);\n\t\t}\n\t}\n});\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Address",
"enabled": 1,
"modified": "2025-05-15 09:21:20.813661",
"module": null,
"name": "Address Error Fix",
"script": "frappe.ui.form.on('Address', {\r\n validate: function (frm) {\r\n // Default value for the custom field (if it doesn't exist in Address DocType)\r\n if (!frm.doc.hasOwnProperty('custom_is_your_company_address')) {\r\n frm.doc.custom_is_your_company_address = false;\r\n }\r\n\r\n // Custom validation logic\r\n // if (frm.doc.custom_is_your_company_address && !frm.doc.some_other_field) {\r\n // frappe.throw(__('Please ensure that the required fields are filled out for Company Address.'));\r\n // }\r\n }\r\n});\r\n",
"view": "Form"
}
]

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -192,6 +192,9 @@ doc_events = {
"Task": {
"before_insert": "custom_ui.events.task.before_insert",
"after_insert": "custom_ui.events.task.after_insert"
},
"Bid Meeting Note Form": {
"after_insert": "custom_ui.events.general.attach_bid_note_form_to_project_template"
}
}
@ -205,79 +208,26 @@ fixtures = [
{
"dt": "DocType",
"filters": [
["name", "in", [
"Quotation Template",
"Quotation Template Item",
"Customer Company Link",
"Customer Address Link",
"Customer Contact Link",
# New link doctypes
"Customer Project Link",
"Customer Quotation Link",
"Customer Sales Order Link",
"Customer On-Site Meeting Link",
"Lead Address Link",
"Lead Contact Link",
"Lead Companies Link",
"Lead Quotation Link",
"Lead On-Site Meeting Link",
"Address Project Link",
"Address Quotation Link",
"Address On-Site Meeting Link",
"Address Sales Order Link",
"Address Contact Link",
"Address Company Link",
"Contact Address Link",
"Address Task Link",
"Customer Task Link"
]]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Quotation"],
["fieldname", "in", [
"custom_quotation_template",
"custom_project_template"
]]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Sales Order"],
["fieldname", "=", "custom_project_template"]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Lead"],
["fieldname", "=", "custom_customer_name"]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Project Template"],
["fieldname", "=", "company"]
]
},
{
"dt": "Property Setter",
"filters": [
["doc_type", "=", "Lead"],
["doc_type", "=", "Project"],
["doc_type", "=", "Address"]
]
}
["custom", "=", 1]
]
},
# These don't have reliable flags → export all
{"dt": "Custom Field"},
{"dt": "Property Setter"},
{"dt": "Client Script"},
{"dt": "Server Script"},
{"dt": "Report"},
{"dt": "Print Format"},
{"dt": "Dashboard"},
{"dt": "Workspace"},
]
# Scheduled Tasks
# ---------------

View file

@ -19,6 +19,8 @@ def after_install():
frappe.reload_doctype("On-Site Meeting")
update_onsite_meeting_fields()
update_address_fields()
check_and_create_holiday_list()
create_snw_install_task_types()
build_frontend()
def after_migrate():
@ -33,6 +35,7 @@ def after_migrate():
frappe.reload_doctype(doctype)
check_and_create_holiday_list()
create_snw_install_task_types()
# update_address_fields()
# build_frontend()
@ -1020,3 +1023,143 @@ def get_all_sundays(year):
d += timedelta(days=7)
return sundays
def create_snw_install_task_types():
task_types = [
{
"title": "Locate",
"description": "Utility locate request prior to installation start.",
"base_date": "Start",
"offset_days": 7,
"offset_direction": "Before",
"calculate_from": "Service Appointment",
"trigger": "Scheduled",
},
{
"title": "Permit",
"description": "Permits required prior to installation start.",
"base_date": "Start",
"offset_days": 7,
"offset_direction": "Before",
"calculate_from": "Service Appointment",
"trigger": "Scheduled",
},
{
"title": "1/2 Down Payment",
"description": "Collect half down payment on project creation.",
"calculate_from": "Project",
"trigger": "Created",
"no_due_date": 1,
},
{
"title": "Machine Staging",
"description": "Stage machinery one day before installation start.",
"base_date": "Start",
"offset_days": 1,
"offset_direction": "Before",
"calculate_from": "Service Appointment",
"trigger": "Scheduled",
},
{
"title": "Final Invoice",
"description": "Send final invoice within 5 days of job completion.",
"base_date": "Completion",
"offset_days": 5,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
{
"title": "Backflow Test",
"description": "Backflow test after job completion if quoted.",
"base_date": "Completion",
"offset_days": 0,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
{
"title": "Schedule Permit Inspection",
"description": "Schedule permit inspection 5 days after completion.",
"base_date": "Completion",
"offset_days": 5,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
{
"title": "15 Day Warranty Follow-Up",
"description": "15-day warranty follow-up after completion.",
"base_date": "Completion",
"offset_days": 15,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
{
"title": "30 Day Warranty Check",
"description": "30-day warranty check after completion.",
"base_date": "Completion",
"offset_days": 30,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
{
"title": "30 Day Payment Reminder",
"description": "Payment reminder sent 30 days after completion.",
"base_date": "Completion",
"offset_days": 30,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
{
"title": "60 Day Late Payment Notice",
"description": "Late payment notification at 60 days post completion.",
"base_date": "Completion",
"offset_days": 60,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
{
"title": "80 Day Lien Notice",
"description": "Lien notice if payment is still late after 80 days.",
"base_date": "Completion",
"offset_days": 80,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
{
"title": "365 Day Warranty Call / Walk",
"description": "One-year warranty call or walk-through.",
"base_date": "Completion",
"offset_days": 365,
"offset_direction": "After",
"calculate_from": "Service Appointment",
"trigger": "Completed",
},
]
for task_type in task_types:
# Idempotency check
if frappe.db.exists("Task Type", task_type["title"]):
continue
doc = frappe.get_doc({
"doctype": "Task Type",
"title": task_type["title"],
"description": task_type["description"],
"base_date": task_type.get("base_date"),
"offset_days": task_type.get("offset_days", 0),
"offset_direction": task_type.get("offset_direction"),
"calculate_from": task_type.get("calculate_from", "Service Appointment"),
"trigger": task_type["trigger"],
"no_due_date": task_type.get("no_due_date", 0),
})
doc.insert(ignore_permissions=True)
frappe.db.commit()

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ const FRAPPE_GET_INCOMPLETE_BIDS_METHOD = "custom_ui.api.db.on_site_meetings.get
// Estimate methods
const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate";
const FRAPPE_GET_ESTIMATES_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data";
const FRAPPE_GET_ESTIMATES_TABLE_DATA_V2_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data_v2";
const FRAPPE_GET_ESTIMATE_BY_ADDRESS_METHOD = "custom_ui.api.db.estimates.get_estimate_from_address";
const FRAPPE_SEND_ESTIMATE_EMAIL_METHOD = "custom_ui.api.db.estimates.send_estimate_email";
const FRAPPE_LOCK_ESTIMATE_METHOD = "custom_ui.api.db.estimates.lock_estimate";
@ -47,7 +48,9 @@ const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warra
// On-Site Meeting methods
const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD =
"custom_ui.api.db.bid_meetings.get_week_bid_meetings";
const FRAPPE_GET_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meeting_note_form";
const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meetings";
const FRAPPE_SUBMIT_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.submit_bid_meeting_note_form";
// Address methods
const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
// Client methods
@ -62,6 +65,10 @@ const FRAPPE_GET_EMPLOYEES_METHOD = "custom_ui.api.db.employees.get_employees";
const FRAPPE_GET_EMPLOYEES_ORGANIZED_METHOD = "custom_ui.api.db.employees.get_employees_organized";
// Other methods
const FRAPPE_GET_WEEK_HOLIDAYS_METHOD = "custom_ui.api.db.general.get_week_holidays";
const FRAPPE_GET_DOC_LIST_METHOD = "custom_ui.api.db.general.get_doc_list";
// Service Appointment methods
const FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD = "custom_ui.api.db.service_appointments.get_service_appointments";
const FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD = "custom_ui.api.db.service_appointments.update_service_appointment_scheduled_dates";
class Api {
// ============================================================================
// CORE REQUEST METHOPD
@ -166,6 +173,18 @@ class Api {
// ON-SITE MEETING METHODS
// ============================================================================
static async getBidMeetingNoteForm(projectTemplate) {
return await this.request(FRAPPE_GET_BID_MEETING_NOTE_FORM_METHOD, { projectTemplate });
}
static async submitBidMeetingNoteForm(data) {
return await this.request(FRAPPE_SUBMIT_BID_MEETING_NOTE_FORM_METHOD, {
bidMeeting: data.bidMeeting,
projectTemplate: data.projectTemplate,
formTemplate: data.formTemplate,
fields: data.fields});
}
static async getUnscheduledBidMeetings(company) {
return await this.request(
"custom_ui.api.db.bid_meetings.get_unscheduled_bid_meetings",
@ -244,7 +263,7 @@ class Api {
console.log("DEBUG: API - Sending estimate options to backend:", page, pageSize, filters, sorting);
const result = await this.request(FRAPPE_GET_ESTIMATES_METHOD, { page, pageSize, filters, sorting});
const result = await this.request(FRAPPE_GET_ESTIMATES_TABLE_DATA_V2_METHOD, { page, pageSize, filters, sorting});
return result;
}
@ -425,6 +444,25 @@ class Api {
return result;
}
// ============================================================================
// SERVICE APPOINTMENT METHODS
// ============================================================================
static async getServiceAppointments(companies = [], filters = {}) {
return await this.request(FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD, { companies, filters });
}
static async updateServiceAppointmentScheduledDates(serviceAppointmentName, startDate, endDate, crewLeadName, startTime = null, endTime = null) {
return await this.request(FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD, {
serviceAppointmentName,
startDate,
endDate,
crewLeadName,
startTime,
endTime
})
}
// ============================================================================
// TASK METHODS
// ============================================================================
@ -689,20 +727,21 @@ class Api {
*/
static async getDocsList(
doctype,
fields = [],
fields = ["*"],
filters = {},
page = 0,
start = 0,
pageLength = 0,
pluck = null,
) {
const docs = await frappe.db.get_list(doctype, {
fields,
filters,
start: start,
limit: pageLength,
});
const docs = await this.request(
FRAPPE_GET_DOC_LIST_METHOD,
{
doctype,
fields,
filters,
pluck,
}
);
console.log(
`DEBUG: API - Fetched ${doctype} list (page ${page + 1}, start ${start}): `,
`DEBUG: API - Fetched ${doctype} list: `,
docs,
);
return docs;

View file

@ -26,9 +26,37 @@
</TabPanel>
</TabPanels>
</Tabs>
<div v-else class="coming-soon">
<p>Calendar feature coming soon!</p>
</div>
<Tabs v-else value="0">
<TabList>
<Tab value="0">Bids</Tab>
<Tab value="1">Projects</Tab>
<Tab value="2">Service</Tab>
<Tab value="3">Warranties</Tab>
</TabList>
<TabPanels>
<TabPanel header="Bids" value="0">
<ScheduleBid />
</TabPanel>
<TabPanel header="Projects" value="1">
<div class="coming-soon">
<p>Calendar Coming Soon!</p>
</div>
</TabPanel>
<TabPanel header="Service" value="2">
<div class="coming-soon">
<p>Calendar Coming Soon!</p>
</div>
</TabPanel>
<TabPanel header="Service" value="2">
<div class="coming-soon">
<p>Calendar Coming Soon!</p>
</div>
</TabPanel>
<TabPanel header="Warranties" value="3">
<p>Calendar Coming Soon!</p>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>

View file

@ -206,6 +206,7 @@
:meeting="selectedMeeting"
@close="closeMeetingModal"
@meeting-updated="handleMeetingUpdated"
@complete-meeting="openNoteForm"
/>
<!-- New Meeting Modal -->
@ -216,6 +217,17 @@
@confirm="handleNewMeetingConfirm"
@cancel="handleNewMeetingCancel"
/>
<!-- Bid Meeting Note Form Modal -->
<BidMeetingNoteForm
v-if="selectedMeetingForNotes"
:visible="showNoteFormModal"
@update:visible="showNoteFormModal = $event"
:bid-meeting-name="selectedMeetingForNotes.name"
:project-template="selectedMeetingForNotes.projectTemplate"
@submit="handleNoteFormSubmit"
@cancel="handleNoteFormCancel"
/>
</div>
</template>
@ -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,
});
}

View file

@ -306,7 +306,7 @@
<!-- Event Details Modal -->
<JobDetailsModal
v-model:visible="eventDialog"
v-model="eventDialog"
:job="selectedEvent"
:foremen="foremen"
@close="eventDialog = false"
@ -807,7 +807,8 @@ const toggleTemplate = (templateName) => {
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();
});
</script>

View file

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

View file

@ -171,7 +171,7 @@ import { ref, computed } from "vue";
// Props
const props = defineProps({
visible: {
modelValue: {
type: Boolean,
default: false,
},
@ -186,15 +186,15 @@ const props = defineProps({
});
// Emits
const emit = defineEmits(["update:visible", "close"]);
const emit = defineEmits(["update:modelValue", "close"]);
// Computed
const showModal = computed({
get() {
return props.visible;
return props.modelValue;
},
set(value) {
emit("update:visible", value);
emit("update:modelValue", value);
},
});

View file

@ -135,8 +135,8 @@
variant="elevated"
:loading="isUpdating"
>
<v-icon left>mdi-check</v-icon>
Mark as Completed
<v-icon left>mdi-file-edit</v-icon>
Create Notes and Complete
</v-btn>
<v-btn
@ -176,7 +176,7 @@ const props = defineProps({
});
// Emits
const emit = defineEmits(["update:visible", "close", "meetingUpdated"]);
const emit = defineEmits(["update:visible", "close", "meetingUpdated", "completeMeeting"]);
// Local state
const isUpdating = ref(false);
@ -269,34 +269,20 @@ const handleClose = () => {
const handleMarkComplete = async () => {
if (!props.meeting?.name) return;
try {
isUpdating.value = true;
await Api.updateBidMeeting(props.meeting.name, {
status: "Completed",
});
// Check if meeting has a project template
if (!props.meeting.projectTemplate) {
notificationStore.addNotification({
type: "success",
title: "Meeting Completed",
message: "The meeting has been marked as completed.",
duration: 4000,
});
// Emit event to refresh the calendar
emit("meetingUpdated");
handleClose();
} catch (error) {
console.error("Error marking meeting as complete:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to update meeting status.",
type: "warning",
title: "Missing Project Template",
message: "This meeting requires a project template to create notes.",
duration: 5000,
});
} finally {
isUpdating.value = false;
return;
}
// Open the note form modal
emit("completeMeeting", props.meeting);
handleClose();
};
const handleCreateEstimate = () => {

View file

@ -129,13 +129,16 @@ import DataTable from "../common/DataTable.vue";
import TodoChart from "../common/TodoChart.vue";
import { Edit, ChatBubbleQuestion, CreditCard, Hammer } from "@iconoir/vue";
import { ref, onMounted, watch } from "vue";
import { ref, onMounted, watch } from "vue";
import Api from "../../api";
import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters";
import { useCompanyStore } from "../../stores/company.js";
import { useRouter } from "vue-router";
import { useCompanyStore } from "../../stores/company";
const companyStore = useCompanyStore();
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
@ -235,7 +238,7 @@ const handleLazyLoad = async (event) => {
};
// Get filters (convert PrimeVue format to API format)
const filters = {};
const filters = {company: companyStore.currentCompany};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) {
@ -281,6 +284,8 @@ const handleLazyLoad = async (event) => {
}
};
const loadChartData = async () => {
chartData.value.bids.data = await Api.getIncompleteBidsCount(companyStore.currentCompany);
chartData.value.estimates.data = await Api.getUnapprovedEstimatesCount(companyStore.currentCompany);
@ -308,14 +313,21 @@ onMounted(async () => {
sortOrder: initialSorting.order || initialPagination.sortOrder,
filters: initialFilters,
});
// Chart Data
await loadChartData();
});
watch(() => companyStore.currentCompany, async (newCompany, oldCompany) => {
await loadChartData();
});
// watch the company store and refetch data when it changes
watch(
() => companyStore.currentCompany,
async (newCompany, oldCompany) => {
console.log("Company changed from", oldCompany, "to", newCompany, "- refetching estimates data.");
await handleLazyLoad({
page: paginationStore.getTablePagination("estimates").page,
rows: paginationStore.getTablePagination("estimates").rows,
first: paginationStore.getTablePagination("estimates").first,
filters: filtersStore.getTableFilters("estimates"),
});
}
);
</script>
<style lang="css">