update with main
This commit is contained in:
parent
5e192a61e1
commit
ba3e2a4d8e
29 changed files with 51749 additions and 139 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
# ===============================================================================
|
||||
|
|
|
|||
40
custom_ui/api/db/service_appointments.py
Normal file
40
custom_ui/api/db/service_appointments.py
Normal 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)
|
||||
6
custom_ui/events/general.py
Normal file
6
custom_ui/events/general.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import frappe
|
||||
|
||||
def attach_bid_note_form_to_project_template(doc, method):
|
||||
"""Attatch Bid Meeting Note Form to Project Template on insert."""
|
||||
print("DEBUG: Attaching Bid Meeting Note Form to Project Template")
|
||||
frappe.set_value("Project Template", doc.project_template, "bid_meeting_note_form", doc.name)
|
||||
|
|
@ -1,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 = {
|
||||
|
|
|
|||
17
custom_ui/events/service_appointment.py
Normal file
17
custom_ui/events/service_appointment.py
Normal 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")
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
519
custom_ui/fixtures/client_script.json
Normal file
519
custom_ui/fixtures/client_script.json
Normal 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"
|
||||
}
|
||||
]
|
||||
1515
custom_ui/fixtures/dashboard.json
Normal file
1515
custom_ui/fixtures/dashboard.json
Normal file
File diff suppressed because it is too large
Load diff
1192
custom_ui/fixtures/print_format.json
Normal file
1192
custom_ui/fixtures/print_format.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
9974
custom_ui/fixtures/report.json
Normal file
9974
custom_ui/fixtures/report.json
Normal file
File diff suppressed because it is too large
Load diff
515
custom_ui/fixtures/server_script.json
Normal file
515
custom_ui/fixtures/server_script.json
Normal file
File diff suppressed because one or more lines are too long
21119
custom_ui/fixtures/workspace.json
Normal file
21119
custom_ui/fixtures/workspace.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
# ---------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
53
custom_ui/services/service_appointment_service.py
Normal file
53
custom_ui/services/service_appointment_service.py
Normal 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
|
||||
143
custom_ui/services/task_service.py
Normal file
143
custom_ui/services/task_service.py
Normal 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
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
978
frontend/src/components/modals/BidMeetingNoteForm.vue
Normal file
978
frontend/src/components/modals/BidMeetingNoteForm.vue
Normal file
|
|
@ -0,0 +1,978 @@
|
|||
<template>
|
||||
<Modal
|
||||
:visible="showModal"
|
||||
@update:visible="showModal = $event"
|
||||
:options="modalOptions"
|
||||
@confirm="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template #title>
|
||||
<div class="modal-header">
|
||||
<i class="pi pi-file-edit" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||
{{ formTitle }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<ProgressSpinner style="width: 50px; height: 50px" strokeWidth="4" />
|
||||
<p>Loading form...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="formConfig" class="form-container">
|
||||
<Button @click="debugLog" label="Debug" severity="secondary" size="small" class="debug-button" />
|
||||
<template v-for="row in groupedFields" :key="`row-${row.rowIndex}`">
|
||||
<div class="form-row">
|
||||
<div
|
||||
v-for="field in row.fields"
|
||||
:key="field.name"
|
||||
:class="`form-column-${Math.min(Math.max(field.columns || 12, 1), 12)}`"
|
||||
>
|
||||
<div class="form-field">
|
||||
<!-- Field Label -->
|
||||
<label :for="field.name" class="field-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required-indicator">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Help Text -->
|
||||
<small v-if="field.helpText" class="field-help-text">
|
||||
{{ field.helpText }}
|
||||
</small>
|
||||
|
||||
<!-- Data/Text Field -->
|
||||
<template v-if="field.type === 'Data' || field.type === 'Text'">
|
||||
<InputText
|
||||
v-if="field.type === 'Data'"
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
:placeholder="field.label"
|
||||
class="w-full"
|
||||
/>
|
||||
<Textarea
|
||||
v-else
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
:placeholder="field.label"
|
||||
rows="3"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Check Field -->
|
||||
<template v-else-if="field.type === 'Check'">
|
||||
<div class="checkbox-container">
|
||||
<Checkbox
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:binary="true"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
/>
|
||||
<label :for="field.name" class="checkbox-label">
|
||||
{{ formData[field.name].value ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Date Field -->
|
||||
<template v-else-if="field.type === 'Date'">
|
||||
<DatePicker
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
dateFormat="yy-mm-dd"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Datetime Field -->
|
||||
<template v-else-if="field.type === 'Datetime'">
|
||||
<DatePicker
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
showTime
|
||||
hourFormat="12"
|
||||
dateFormat="yy-mm-dd"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Time Field -->
|
||||
<template v-else-if="field.type === 'Time'">
|
||||
<DatePicker
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
timeOnly
|
||||
hourFormat="12"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Number Field -->
|
||||
<template v-else-if="field.type === 'Number'">
|
||||
<InputNumber
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Select Field -->
|
||||
<template v-else-if="field.type === 'Select'">
|
||||
<div @click="console.log('Select wrapper clicked:', field.name, 'disabled:', field.readOnly || !isFieldVisible(field), 'options:', optionsForFields[field.name])">
|
||||
<Select
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:options="optionsForFields[field.name]"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
:placeholder="`Select ${field.label}`"
|
||||
:optionLabel="'label'"
|
||||
:optionValue="'value'"
|
||||
:editable="false"
|
||||
:showClear="true"
|
||||
:baseZIndex="10000"
|
||||
@click.native="console.log('Select native click:', field.name)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Multi-Select Field -->
|
||||
<template v-else-if="field.type === 'Multi-Select'">
|
||||
<MultiSelect
|
||||
:id="field.name"
|
||||
v-model="formData[field.name].value"
|
||||
:options="optionsForFields[field.name]"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
:placeholder="`Select ${field.label}`"
|
||||
:key="`${field.name}-${field.readOnly || !isFieldVisible(field)}`"
|
||||
:optionLabel="'label'"
|
||||
:optionValue="'value'"
|
||||
:showClear="true"
|
||||
:baseZIndex="9999"
|
||||
display="chip"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Multi-Select w/ Quantity Field -->
|
||||
<template v-else-if="field.type === 'Multi-Select w/ Quantity'">
|
||||
<div class="multi-select-quantity-container">
|
||||
<!-- Item Selector -->
|
||||
<div class="item-selector">
|
||||
<Select
|
||||
v-model="currentItemSelection[field.name]"
|
||||
:options="optionsForFields[field.name]"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
:placeholder="`Add ${field.label}`"
|
||||
:key="`${field.name}-${field.readOnly || !isFieldVisible(field)}`"
|
||||
:optionLabel="'label'"
|
||||
:showClear="true"
|
||||
:baseZIndex="9999"
|
||||
class="w-full"
|
||||
@change="addItemToQuantityList(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selected Items with Quantities -->
|
||||
<div v-if="formData[field.name].value && formData[field.name].value.length > 0" class="selected-items-list">
|
||||
<div
|
||||
v-for="(item, index) in formData[field.name].value"
|
||||
:key="index"
|
||||
class="quantity-item"
|
||||
>
|
||||
<div class="item-name">{{ getOptionLabel(field, item) }}</div>
|
||||
<div class="quantity-controls">
|
||||
<InputNumber
|
||||
v-model="item.quantity"
|
||||
:min="1"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
showButtons
|
||||
buttonLayout="horizontal"
|
||||
:step="1"
|
||||
decrementButtonClass="p-button-secondary"
|
||||
incrementButtonClass="p-button-secondary"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
@click="removeItemFromQuantityList(field, index)"
|
||||
:disabled="field.readOnly || !isFieldVisible(field)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-container">
|
||||
<i class="pi pi-exclamation-triangle" style="font-size: 2rem; color: var(--red-500);"></i>
|
||||
<p>Failed to load form configuration</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, reactive } from "vue";
|
||||
import Modal from "../common/Modal.vue";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Textarea from "primevue/textarea";
|
||||
import Checkbox from "primevue/checkbox";
|
||||
import DatePicker from "primevue/datepicker";
|
||||
import InputNumber from "primevue/inputnumber";
|
||||
import Select from "primevue/select";
|
||||
import MultiSelect from "primevue/multiselect";
|
||||
import Button from "primevue/button";
|
||||
import ProgressSpinner from "primevue/progressspinner";
|
||||
import Api from "../../api";
|
||||
import { useLoadingStore } from "../../stores/loading";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
|
||||
const docsForSelectFields = ref({});
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
bidMeetingName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
projectTemplate: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "submit", "cancel"]);
|
||||
|
||||
const loadingStore = useLoadingStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const showModal = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit("update:visible", value),
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
const formConfig = ref(null);
|
||||
const formData = ref({}); // Will store fieldName: {fieldConfig, value}
|
||||
const currentItemSelection = ref({}); // For tracking current selection in Multi-Select w/ Quantity
|
||||
const doctypeOptions = ref({}); // Cache for doctype options
|
||||
|
||||
const formTitle = computed(() => {
|
||||
return formConfig.value?.title || "Bid Meeting Notes";
|
||||
});
|
||||
|
||||
// Include all fields from config plus a general notes field
|
||||
const allFields = computed(() => {
|
||||
if (!formConfig.value) return [];
|
||||
|
||||
const fields = [...(formConfig.value.fields || [])];
|
||||
|
||||
// Always add a general notes field at the end
|
||||
const generalNotesField = {
|
||||
name: 'general_notes',
|
||||
label: 'General Notes',
|
||||
type: 'Text',
|
||||
required: 0,
|
||||
readOnly: 0,
|
||||
helpText: 'Any additional notes or observations from the meeting',
|
||||
row: Math.max(...fields.map(f => f.row || 1), 0) + 1,
|
||||
columns: 12,
|
||||
};
|
||||
|
||||
fields.push(generalNotesField);
|
||||
return fields;
|
||||
});
|
||||
|
||||
// Group fields by row for grid layout
|
||||
const groupedFields = computed(() => {
|
||||
const groups = {};
|
||||
|
||||
allFields.value.forEach(field => {
|
||||
const rowNum = field.row || 1;
|
||||
if (!groups[rowNum]) {
|
||||
groups[rowNum] = { rowIndex: rowNum, fields: [] };
|
||||
}
|
||||
groups[rowNum].fields.push(field);
|
||||
});
|
||||
|
||||
// Sort fields by column and set columns span
|
||||
Object.values(groups).forEach(group => {
|
||||
group.fields.sort((a, b) => (a.column || 0) - (b.column || 0));
|
||||
const numFields = group.fields.length;
|
||||
const span = Math.floor(12 / numFields);
|
||||
group.fields.forEach(field => {
|
||||
field.columns = span;
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(groups).sort((a, b) => a.rowIndex - b.rowIndex);
|
||||
});
|
||||
|
||||
// Update field value in reactive form data
|
||||
const updateFieldValue = (fieldName, value) => {
|
||||
if (formData.value[fieldName]) {
|
||||
formData.value[fieldName].value = value;
|
||||
}
|
||||
};
|
||||
|
||||
// Get CSS class for field column span
|
||||
const getFieldColumnClass = (field) => {
|
||||
const columns = field.columns || 12; // Default to full width if not specified
|
||||
return `form-column-${Math.min(Math.max(columns, 1), 12)}`; // Ensure between 1-12
|
||||
};
|
||||
|
||||
const modalOptions = computed(() => ({
|
||||
maxWidth: "800px",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Submit",
|
||||
cancelButtonText: "Cancel",
|
||||
confirmButtonColor: "primary",
|
||||
zIndex: 1000, // Lower than select baseZIndex
|
||||
}));
|
||||
|
||||
// Helper to find field name by label
|
||||
const findFieldNameByLabel = (label) => {
|
||||
if (!formConfig.value || !formConfig.value.fields) return null;
|
||||
const field = formConfig.value.fields.find(f => f.label === label);
|
||||
return field ? field.name : null;
|
||||
};
|
||||
|
||||
const fetchDocsForSelectField = async (doctype, fieldName) => {
|
||||
const docs = await Api.getDocsList(doctype);
|
||||
docsForSelectFields[fieldName] = docs;
|
||||
}
|
||||
|
||||
// Check if a field should be visible based on conditional logic
|
||||
const isFieldVisible = (field) => {
|
||||
if (!field.conditionalOnField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find the actual field name from the label (conditionalOnField contains the label)
|
||||
const dependentFieldName = findFieldNameByLabel(field.conditionalOnField);
|
||||
if (!dependentFieldName) {
|
||||
console.warn(`Could not find field with label: ${field.conditionalOnField}`);
|
||||
return true; // Show field if we can't find the dependency
|
||||
}
|
||||
|
||||
const dependentFieldValue = formData.value[dependentFieldName]?.value;
|
||||
|
||||
console.log(`Checking visibility for ${field.label}:`, {
|
||||
conditionalOnField: field.conditionalOnField,
|
||||
dependentFieldName,
|
||||
dependentFieldValue,
|
||||
conditionalOnValue: field.conditionalOnValue,
|
||||
});
|
||||
|
||||
// If the dependent field is a checkbox, it should be true
|
||||
if (typeof dependentFieldValue === "boolean") {
|
||||
return dependentFieldValue === true;
|
||||
}
|
||||
|
||||
// If conditional_on_value is specified, check for exact match
|
||||
if (field.conditionalOnValue !== null && field.conditionalOnValue !== undefined) {
|
||||
return dependentFieldValue === field.conditionalOnValue;
|
||||
}
|
||||
|
||||
// Otherwise, just check if the dependent field has any truthy value
|
||||
return !!dependentFieldValue;
|
||||
};
|
||||
|
||||
// Get options for select/multi-select fields
|
||||
const getFieldOptions = (field) => {
|
||||
// Access reactive data to ensure reactivity
|
||||
const optionsData = docsForSelectFields.value[field.name];
|
||||
|
||||
console.log(`getFieldOptions called for ${field.label}:`, {
|
||||
type: field.type,
|
||||
options: field.options,
|
||||
optionsType: typeof field.options,
|
||||
doctypeForSelect: field.doctypeForSelect,
|
||||
doctypeLabelField: field.doctypeLabelField,
|
||||
hasDoctypeOptions: !!optionsData,
|
||||
});
|
||||
|
||||
// If options should be fetched from a doctype
|
||||
if (field.doctypeForSelect && optionsData) {
|
||||
console.log(`Using doctype options for ${field.label}:`, optionsData);
|
||||
return [...optionsData]; // Return a copy to ensure reactivity
|
||||
}
|
||||
|
||||
// If options are provided as a string (comma-separated), parse them
|
||||
if (field.options && typeof field.options === "string" && field.options.trim() !== "") {
|
||||
const optionStrings = field.options.split(",").map((opt) => opt.trim()).filter(opt => opt !== "");
|
||||
// Convert to objects for consistency with PrimeVue MultiSelect
|
||||
const options = optionStrings.map((opt) => ({
|
||||
label: opt,
|
||||
value: opt,
|
||||
}));
|
||||
console.log(`Parsed options for ${field.label}:`, options);
|
||||
return options;
|
||||
}
|
||||
|
||||
console.warn(`No options found for ${field.label}`);
|
||||
return [];
|
||||
};
|
||||
|
||||
// Get options for select/multi-select fields
|
||||
const optionsForFields = computed(() => {
|
||||
// Ensure reactivity by accessing docsForSelectFields
|
||||
const docsData = docsForSelectFields.value;
|
||||
const opts = {};
|
||||
allFields.value.forEach(field => {
|
||||
const options = getFieldOptions(field);
|
||||
opts[field.name] = options;
|
||||
console.log(`Computed options for ${field.name}:`, options);
|
||||
});
|
||||
console.log('optionsForFields computed:', opts);
|
||||
return opts;
|
||||
});
|
||||
|
||||
// Add item to quantity list for Multi-Select w/ Quantity fields
|
||||
const addItemToQuantityList = (field) => {
|
||||
const selectedItem = currentItemSelection.value[field.name];
|
||||
if (!selectedItem) return;
|
||||
|
||||
// selectedItem is now an object with { label, value }
|
||||
const itemValue = selectedItem.value || selectedItem;
|
||||
const itemLabel = selectedItem.label || selectedItem;
|
||||
|
||||
// Initialize array if it doesn't exist
|
||||
const fieldData = formData.value[field.name];
|
||||
if (!fieldData.value) {
|
||||
fieldData.value = [];
|
||||
}
|
||||
|
||||
// Check if item already exists (compare by value)
|
||||
const existingItem = fieldData.value.find((item) => item.item === itemValue);
|
||||
if (existingItem) {
|
||||
// Increment quantity if item already exists
|
||||
existingItem.quantity += 1;
|
||||
} else {
|
||||
// Add new item with quantity 1
|
||||
fieldData.value.push({
|
||||
item: itemValue,
|
||||
label: itemLabel,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
currentItemSelection.value[field.name] = null;
|
||||
};
|
||||
|
||||
// Remove item from quantity list
|
||||
const removeItemFromQuantityList = (field, index) => {
|
||||
const fieldData = formData.value[field.name];
|
||||
if (fieldData && fieldData.value) {
|
||||
fieldData.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Get option label for display
|
||||
const getOptionLabel = (field, item) => {
|
||||
const options = optionsForFields.value[field.name];
|
||||
const option = options.find(o => o.value === item.item);
|
||||
return option ? option.label : item.label || item.item;
|
||||
};
|
||||
|
||||
// Initialize form data with default values
|
||||
const initializeFormData = () => {
|
||||
if (!formConfig.value) return;
|
||||
|
||||
const data = {};
|
||||
allFields.value.forEach((field) => {
|
||||
// Create reactive object with field config and value
|
||||
data[field.name] = {
|
||||
...field, // Include all field configuration
|
||||
value: null, // Initialize value
|
||||
};
|
||||
|
||||
// Set default value if provided
|
||||
if (field.defaultValue !== null && field.defaultValue !== undefined) {
|
||||
data[field.name].value = field.defaultValue;
|
||||
} else {
|
||||
// Initialize based on field type
|
||||
switch (field.type) {
|
||||
case "Check":
|
||||
data[field.name].value = false;
|
||||
break;
|
||||
case "Multi-Select":
|
||||
data[field.name].value = [];
|
||||
break;
|
||||
case "Multi-Select w/ Quantity":
|
||||
data[field.name].value = [];
|
||||
break;
|
||||
case "Number":
|
||||
data[field.name].value = null;
|
||||
break;
|
||||
case "Select":
|
||||
data[field.name].value = null;
|
||||
break;
|
||||
default:
|
||||
data[field.name].value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
formData.value = data;
|
||||
};
|
||||
|
||||
// Load form configuration
|
||||
const loadFormConfig = async () => {
|
||||
if (!props.projectTemplate) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const config = await Api.getBidMeetingNoteForm(props.projectTemplate);
|
||||
formConfig.value = config;
|
||||
console.log("Loaded form config:", config);
|
||||
|
||||
// Load doctype options for fields that need them
|
||||
await loadDoctypeOptions();
|
||||
|
||||
// Initialize form data
|
||||
initializeFormData();
|
||||
} catch (error) {
|
||||
console.error("Error loading form config:", error);
|
||||
notificationStore.addNotification({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Failed to load form configuration",
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load options for fields that reference doctypes
|
||||
const loadDoctypeOptions = async () => {
|
||||
if (!formConfig.value || !formConfig.value.fields) return;
|
||||
|
||||
const fieldsWithDoctype = formConfig.value.fields.filter(
|
||||
(field) => field.doctypeForSelect && field.doctypeForSelect !== ""
|
||||
);
|
||||
|
||||
for (const field of fieldsWithDoctype) {
|
||||
try {
|
||||
// Use the new API method for fetching docs
|
||||
let docs = await Api.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>
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue