Big update
This commit is contained in:
parent
124b8775fb
commit
b400be3f1a
29 changed files with 31703 additions and 2443 deletions
|
|
@ -11,6 +11,7 @@ def get_week_bid_meetings(week_start, week_end, company):
|
||||||
"On-Site Meeting",
|
"On-Site Meeting",
|
||||||
fields=["*"],
|
fields=["*"],
|
||||||
filters=[
|
filters=[
|
||||||
|
["status", "!=", "Cancelled"],
|
||||||
["start_time", ">=", week_start],
|
["start_time", ">=", week_start],
|
||||||
["start_time", "<=", week_end],
|
["start_time", "<=", week_end],
|
||||||
["company", "=", company]
|
["company", "=", company]
|
||||||
|
|
@ -169,6 +170,9 @@ def get_bid_meeting(name):
|
||||||
if meeting_dict.get("contact"):
|
if meeting_dict.get("contact"):
|
||||||
contact_doc = ContactService.get_or_throw(meeting_dict["contact"])
|
contact_doc = ContactService.get_or_throw(meeting_dict["contact"])
|
||||||
meeting_dict["contact"] = contact_doc.as_dict()
|
meeting_dict["contact"] = contact_doc.as_dict()
|
||||||
|
if meeting_dict.get("bid_notes"):
|
||||||
|
bid_meeting_note_doc = frappe.get_doc("Bid Meeting Note", meeting_dict["bid_notes"])
|
||||||
|
meeting_dict["bid_notes"] = bid_meeting_note_doc.as_dict()
|
||||||
|
|
||||||
return build_success_response(meeting_dict)
|
return build_success_response(meeting_dict)
|
||||||
except frappe.DoesNotExistError:
|
except frappe.DoesNotExistError:
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ def get_doc_list(doctype, fields=["*"], filters={}, pluck=None):
|
||||||
fields=fields,
|
fields=fields,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
order_by="creation desc",
|
order_by="creation desc",
|
||||||
pluck=pluck
|
# pluck=pluck
|
||||||
)
|
)
|
||||||
print(f"DEBUG: Retrieved documents for {doctype} with filters {filters}: {docs}")
|
print(f"DEBUG: Retrieved documents for {doctype} with filters {filters}: {docs}")
|
||||||
return build_success_response(docs)
|
return build_success_response(docs)
|
||||||
|
|
|
||||||
6
custom_ui/api/payments.py
Normal file
6
custom_ui/api/payments.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
def start_payment(invoice_name: str):
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
@ -83,5 +83,5 @@ def on_update_after_submit(doc, method):
|
||||||
print("DEBUG: Submitting Sales Order")
|
print("DEBUG: Submitting Sales Order")
|
||||||
# new_sales_order.customer_address = backup
|
# new_sales_order.customer_address = backup
|
||||||
new_sales_order.submit()
|
new_sales_order.submit()
|
||||||
frappe.db.commit()
|
# frappe.db.commit()
|
||||||
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
|
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import frappe
|
import frappe
|
||||||
from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, TaskService
|
from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, TaskService
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import traceback
|
||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
print("DEBUG: After Insert Triggered for Project")
|
print("DEBUG: After Insert Triggered for Project")
|
||||||
|
|
@ -15,26 +16,41 @@ def after_insert(doc, method):
|
||||||
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
|
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
|
||||||
)
|
)
|
||||||
if doc.project_template == "SNW Install":
|
if doc.project_template == "SNW Install":
|
||||||
print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
|
print("DEBUG: Project template is SNW Install, creating Service Appointment")
|
||||||
AddressService.update_value(
|
# AddressService.update_value(
|
||||||
doc.job_address,
|
# doc.job_address,
|
||||||
"job_status",
|
# "job_status",
|
||||||
"In Progress"
|
# "In Progress"
|
||||||
)
|
# )
|
||||||
service_apt = ServiceAppointmentService.create({
|
try:
|
||||||
"project": doc.name,
|
service_apt = ServiceAppointmentService.create({
|
||||||
"customer": doc.customer,
|
"project": doc.name,
|
||||||
"address": doc.job_address,
|
"customer": doc.customer,
|
||||||
"company": doc.company,
|
"service_address": doc.job_address,
|
||||||
"project_template": doc.project_template
|
"company": doc.company,
|
||||||
})
|
"project_template": doc.project_template
|
||||||
frappe.db.set_value("Project", doc.name, "service_appointment", service_apt.name)
|
})
|
||||||
|
doc.service_appointment = service_apt.name
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
print("DEBUG: Created Service Appointment:", service_apt.name)
|
||||||
|
except Exception as e:
|
||||||
|
print("ERROR: Failed to create Service Appointment for Project:", e)
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise e
|
||||||
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.name)]
|
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.name)]
|
||||||
for task_name in task_names:
|
for task_name in task_names:
|
||||||
doc.append("tasks", {
|
doc.append("tasks", {
|
||||||
"task": task_name
|
"task": task_name
|
||||||
})
|
})
|
||||||
TaskService.calculate_and_set_due_dates(task_names, "Created")
|
AddressService.append_link_v2(
|
||||||
|
doc.job_address, "tasks", {"task": task_name}
|
||||||
|
)
|
||||||
|
ClientService.append_link_v2(
|
||||||
|
doc.customer, "tasks", {"task": task_name}
|
||||||
|
)
|
||||||
|
if task_names:
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
TaskService.calculate_and_set_due_dates(task_names, "Created", "Project")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,7 +76,8 @@ def after_save(doc, method):
|
||||||
if event:
|
if event:
|
||||||
TaskService.calculate_and_set_due_dates(
|
TaskService.calculate_and_set_due_dates(
|
||||||
[task.task for task in doc.tasks],
|
[task.task for task in doc.tasks],
|
||||||
event
|
event,
|
||||||
|
"Project"
|
||||||
)
|
)
|
||||||
if doc.project_template == "SNW Install":
|
if doc.project_template == "SNW Install":
|
||||||
print("DEBUG: Project template is SNW Install, updating Address Job Status based on Project status")
|
print("DEBUG: Project template is SNW Install, updating Address Job Status based on Project status")
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ def before_insert(doc, method):
|
||||||
# Address.onsite_meetings is a child table with two fields: onsite_meeting (Link) and project_template (Link). Iterate through to see if there is already an SNW Install meeting linked.
|
# Address.onsite_meetings is a child table with two fields: onsite_meeting (Link) and project_template (Link). Iterate through to see if there is already an SNW Install meeting linked.
|
||||||
for link in address_doc.onsite_meetings:
|
for link in address_doc.onsite_meetings:
|
||||||
if link.project_template == "SNW Install":
|
if link.project_template == "SNW Install":
|
||||||
raise frappe.ValidationError("An On-Site Meeting with project template 'SNW Install' is already linked to this address.")
|
if frappe.db.get_value("On-Site Meeting", link.onsite_meeting, "status") != "Cancelled":
|
||||||
|
raise frappe.ValidationError("An On-Site Meeting with project template 'SNW Install' is already linked to this address.")
|
||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
print("DEBUG: After Insert Triggered for On-Site Meeting")
|
print("DEBUG: After Insert Triggered for On-Site Meeting")
|
||||||
|
|
@ -22,17 +23,19 @@ def after_insert(doc, method):
|
||||||
|
|
||||||
|
|
||||||
def before_save(doc, method):
|
def before_save(doc, method):
|
||||||
|
|
||||||
print("DEBUG: Before Save Triggered for On-Site Meeting")
|
print("DEBUG: Before Save Triggered for On-Site Meeting")
|
||||||
if doc.status != "Scheduled" and doc.start_time and doc.end_time and doc.status != "Completed":
|
if doc.status != "Scheduled" and doc.start_time and doc.end_time and doc.status != "Completed" and doc.status != "Cancelled":
|
||||||
print("DEBUG: Meeting has start and end time, setting status to Scheduled")
|
print("DEBUG: Meeting has start and end time, setting status to Scheduled")
|
||||||
doc.status = "Scheduled"
|
doc.status = "Scheduled"
|
||||||
if doc.project_template == "SNW Install":
|
if doc.project_template == "SNW Install":
|
||||||
print("DEBUG: Project template is SNW Install")
|
print("DEBUG: Project template is SNW Install")
|
||||||
if doc.status == "Completed":
|
if doc.status == "Completed":
|
||||||
print("DEBUG: Meeting marked as Completed, updating Address status")
|
print("DEBUG: Meeting marked as Completed, updating Address status")
|
||||||
current_status = AddressService.get_value(doc.address, "onsite_meeting_scheduled")
|
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
|
||||||
if current_status != doc.status:
|
if doc.status == "Cancelled":
|
||||||
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
|
print("DEBUG: Meeting marked as Cancelled, updating Address status")
|
||||||
|
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Not Started")
|
||||||
|
|
||||||
def validate_address_link(doc, method):
|
def validate_address_link(doc, method):
|
||||||
print("DEBUG: Validating Address link for On-Site Meeting")
|
print("DEBUG: Validating Address link for On-Site Meeting")
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import frappe
|
||||||
from custom_ui.services import DbService, AddressService, ClientService
|
from custom_ui.services import DbService, AddressService, ClientService
|
||||||
|
|
||||||
def before_insert(doc, method):
|
def before_insert(doc, method):
|
||||||
print("DEBUG: before_insert hook triggered for Sales Order:", doc.name)
|
print("DEBUG: before_insert hook triggered for Sales Order")
|
||||||
if doc.custom_project_template == "SNW Install":
|
# if doc.custom_project_template == "SNW Install":
|
||||||
print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
|
# print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
|
||||||
address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
# address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
||||||
if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
|
# if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
|
||||||
raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
|
# raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
|
||||||
|
|
||||||
def on_submit(doc, method):
|
def on_submit(doc, method):
|
||||||
print("DEBUG: Info from Sales Order")
|
print("DEBUG: Info from Sales Order")
|
||||||
|
|
@ -33,7 +33,7 @@ def on_submit(doc, method):
|
||||||
})
|
})
|
||||||
# attatch the job to the sales_order links
|
# attatch the job to the sales_order links
|
||||||
new_job.insert()
|
new_job.insert()
|
||||||
frappe.db.commit()
|
# frappe.db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("ERROR creating Project from Sales Order:", str(e))
|
print("ERROR creating Project from Sales Order:", str(e))
|
||||||
|
|
||||||
|
|
@ -50,40 +50,41 @@ def after_insert(doc, method):
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_sales_invoice_from_sales_order(doc, method):
|
def create_sales_invoice_from_sales_order(doc, method):
|
||||||
try:
|
pass
|
||||||
print("DEBUG: after_submit hook triggered for Sales Order:", doc.name)
|
# try:
|
||||||
invoice_ammount = doc.grand_total / 2 if doc.requires_half_payment else doc.grand_total
|
# print("DEBUG: after_submit hook triggered for Sales Order:", doc.name)
|
||||||
items = []
|
# invoice_ammount = doc.grand_total / 2 if doc.requires_half_payment else doc.grand_total
|
||||||
for so_item in doc.items:
|
# items = []
|
||||||
# proportionally reduce rate if half-payment
|
# for so_item in doc.items:
|
||||||
rate = so_item.rate / 2 if doc.requires_half_payment else so_item.rate
|
# # proportionally reduce rate if half-payment
|
||||||
qty = so_item.qty # usually full qty, but depends on half-payment rules
|
# rate = so_item.rate / 2 if doc.requires_half_payment else so_item.rate
|
||||||
items.append({
|
# qty = so_item.qty # usually full qty, but depends on half-payment rules
|
||||||
"item_code": so_item.item_code,
|
# items.append({
|
||||||
"qty": qty,
|
# "item_code": so_item.item_code,
|
||||||
"rate": rate,
|
# "qty": qty,
|
||||||
"income_account": so_item.income_account,
|
# "rate": rate,
|
||||||
"cost_center": so_item.cost_center,
|
# "income_account": so_item.income_account,
|
||||||
"so_detail": so_item.name # links item to Sales Order
|
# "cost_center": so_item.cost_center,
|
||||||
})
|
# "so_detail": so_item.name # links item to Sales Order
|
||||||
invoice = frappe.get_doc({
|
# })
|
||||||
"doctype": "Sales Invoice",
|
# invoice = frappe.get_doc({
|
||||||
"customer": doc.customer,
|
# "doctype": "Sales Invoice",
|
||||||
"company": doc.company,
|
# "customer": doc.customer,
|
||||||
"posting_date": frappe.utils.nowdate(),
|
# "company": doc.company,
|
||||||
"due_date": frappe.utils.nowdate(), # or calculate from payment terms
|
# "posting_date": frappe.utils.nowdate(),
|
||||||
"currency": doc.currency,
|
# "due_date": frappe.utils.nowdate(), # or calculate from payment terms
|
||||||
"update_stock": 0,
|
# "currency": doc.currency,
|
||||||
"items": items,
|
# "update_stock": 0,
|
||||||
"sales_order": doc.name, # link invoice to Sales Order
|
# "items": items,
|
||||||
"ignore_pricing_rule": 1,
|
# "sales_order": doc.name, # link invoice to Sales Order
|
||||||
"payment_schedule": doc.payment_schedule if not half_payment else [] # optional
|
# "ignore_pricing_rule": 1,
|
||||||
})
|
# "payment_schedule": doc.payment_schedule if not half_payment else [] # optional
|
||||||
|
# })
|
||||||
|
|
||||||
invoice.insert()
|
# invoice.insert()
|
||||||
invoice.submit()
|
# invoice.submit()
|
||||||
frappe.db.commit()
|
# frappe.db.commit()
|
||||||
return invoice
|
# return invoice
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
print("ERROR creating Sales Invoice from Sales Order:", str(e))
|
# print("ERROR creating Sales Invoice from Sales Order:", str(e))
|
||||||
frappe.log_error(f"Error creating Sales Invoice from Sales Order {doc.name}: {str(e)}", "Sales Order after_submit Error")
|
# frappe.log_error(f"Error creating Sales Invoice from Sales Order {doc.name}: {str(e)}", "Sales Order after_submit Error")
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ def on_update(doc, method):
|
||||||
if event:
|
if event:
|
||||||
tasks = TaskService.get_tasks_by_project(doc.project)
|
tasks = TaskService.get_tasks_by_project(doc.project)
|
||||||
task_names = [task.name for task in tasks]
|
task_names = [task.name for task in tasks]
|
||||||
TaskService.calculate_and_set_due_dates(task_names=task_names, event=event)
|
TaskService.calculate_and_set_due_dates(task_names=task_names, event=event, triggering_doctype="Service Address 2")
|
||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
print("DEBUG: After Insert Triggered for Service Appointment")
|
print("DEBUG: After Insert Triggered for Service Appointment")
|
||||||
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
|
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")
|
TaskService.calculate_and_set_due_dates(task_names=task_names, event="Created", triggering_doctype="Service Address 2")
|
||||||
|
|
||||||
|
|
@ -6,6 +6,7 @@ def before_insert(doc, method):
|
||||||
print("DEBUG: Before Insert Triggered for Task")
|
print("DEBUG: Before Insert Triggered for Task")
|
||||||
project_doc = frappe.get_doc("Project", doc.project)
|
project_doc = frappe.get_doc("Project", doc.project)
|
||||||
doc.project_template = project_doc.project_template
|
doc.project_template = project_doc.project_template
|
||||||
|
doc.customer = project_doc.customer
|
||||||
if project_doc.job_address:
|
if project_doc.job_address:
|
||||||
doc.custom_property = project_doc.job_address
|
doc.custom_property = project_doc.job_address
|
||||||
|
|
||||||
|
|
@ -22,12 +23,12 @@ def after_insert(doc, method):
|
||||||
doc.customer, "tasks", {"task": doc.name, "project_template": doc.project_template }
|
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)]
|
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
|
||||||
TaskService.calculate_and_set_due_dates(task_names, "Created")
|
TaskService.calculate_and_set_due_dates(task_names, "Created", "Task")
|
||||||
|
|
||||||
def after_save(doc, method):
|
def after_save(doc, method):
|
||||||
print("DEBUG: After Save Triggered for Task:", doc.name)
|
print("DEBUG: After Save Triggered for Task:", doc.name)
|
||||||
event = TaskService.determine_event(doc)
|
event = TaskService.determine_event(doc)
|
||||||
if event:
|
if event:
|
||||||
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
|
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
|
||||||
TaskService.calculate_and_set_due_dates(task_names, event)
|
TaskService.calculate_and_set_due_dates(task_names, event, "Task")
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1 @@
|
||||||
[
|
[]
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Email Template",
|
|
||||||
"modified": "2026-01-24 07:18:15.939258",
|
|
||||||
"name": "Customer Invoice",
|
|
||||||
"response": "<div class=\"ql-editor read-mode\"><p>-- Copywriting goes here --</p><p>-- Customized Payment Link goes here --</p><p>-- In the meantime --</p><p>Invoice number: {{ name }}</p><p>Amount: {{ grand_total }}</p><p>https://sprinklersnorthwest.com/product/bill-pay/</p></div>",
|
|
||||||
"response_html": null,
|
|
||||||
"subject": "Your Invoice is Ready",
|
|
||||||
"use_html": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -7343,22 +7343,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "1"
|
"value": "1"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Contact",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2024-12-23 13:33:08.995392",
|
|
||||||
"module": null,
|
|
||||||
"name": "Contact-main-naming_rule",
|
|
||||||
"property": "naming_rule",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "Set by user"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Contact",
|
"doc_type": "Contact",
|
||||||
|
|
@ -7391,22 +7375,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "creation"
|
"value": "creation"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Contact",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2024-12-23 16:11:50.106128",
|
|
||||||
"module": null,
|
|
||||||
"name": "Contact-main-autoname",
|
|
||||||
"property": "autoname",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "full_name"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Contact",
|
"doc_type": "Contact",
|
||||||
|
|
@ -12575,22 +12543,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "ISS-.YYYY.-"
|
"value": "ISS-.YYYY.-"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Contact",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2025-11-26 03:43:13.493067",
|
|
||||||
"module": null,
|
|
||||||
"name": "Contact-main-field_order",
|
|
||||||
"property": "field_order",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"sb_01\", \"custom_column_break_g4zvy\", \"first_name\", \"custom_column_break_hpz5b\", \"middle_name\", \"custom_column_break_3pehb\", \"last_name\", \"contact_section\", \"links\", \"phone_nos\", \"email_ids\", \"custom_column_break_nfqbi\", \"is_primary_contact\", \"is_billing_contact\", \"custom_service_address\", \"user\", \"unsubscribed\", \"more_info\", \"custom_column_break_sn9hu\", \"full_name\", \"address\", \"company_name\", \"designation\", \"department\", \"image\", \"sb_00\", \"custom_column_break_kmlkz\", \"email_id\", \"mobile_no\", \"phone\", \"status\", \"gender\", \"salutation\", \"contact_details\", \"cb_00\", \"custom_test_label\", \"google_contacts\", \"google_contacts_id\", \"sync_with_google_contacts\", \"cb00\", \"pulled_from_google_contacts\", \"custom_column_break_ejxjz\"]"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Contact",
|
"doc_type": "Contact",
|
||||||
|
|
@ -12671,54 +12623,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "[\"naming_series\", \"salutation\", \"first_name\", \"middle_name\", \"last_name\", \"custom_customer_name\", \"column_break_1\", \"lead_name\", \"customer_type\", \"companies\", \"quotations\", \"onsite_meetings\", \"job_title\", \"gender\", \"source\", \"col_break123\", \"lead_owner\", \"status\", \"customer\", \"type\", \"request_type\", \"contact_info_tab\", \"email_id\", \"website\", \"column_break_20\", \"mobile_no\", \"whatsapp_no\", \"column_break_16\", \"phone\", \"phone_ext\", \"organization_section\", \"company_name\", \"no_of_employees\", \"column_break_28\", \"annual_revenue\", \"industry\", \"market_segment\", \"column_break_31\", \"territory\", \"fax\", \"address_section\", \"address_html\", \"column_break_38\", \"city\", \"state\", \"country\", \"column_break2\", \"contact_html\", \"qualification_tab\", \"qualification_status\", \"column_break_64\", \"qualified_by\", \"qualified_on\", \"other_info_tab\", \"campaign_name\", \"company\", \"column_break_22\", \"language\", \"image\", \"title\", \"column_break_50\", \"disabled\", \"unsubscribed\", \"blog_subscriber\", \"activities_tab\", \"open_activities_html\", \"all_activities_section\", \"all_activities_html\", \"notes_tab\", \"notes_html\", \"notes\", \"dashboard_tab\", \"contacts\", \"primary_contact\", \"properties\", \"custom_billing_address\"]"
|
"value": "[\"naming_series\", \"salutation\", \"first_name\", \"middle_name\", \"last_name\", \"custom_customer_name\", \"column_break_1\", \"lead_name\", \"customer_type\", \"companies\", \"quotations\", \"onsite_meetings\", \"job_title\", \"gender\", \"source\", \"col_break123\", \"lead_owner\", \"status\", \"customer\", \"type\", \"request_type\", \"contact_info_tab\", \"email_id\", \"website\", \"column_break_20\", \"mobile_no\", \"whatsapp_no\", \"column_break_16\", \"phone\", \"phone_ext\", \"organization_section\", \"company_name\", \"no_of_employees\", \"column_break_28\", \"annual_revenue\", \"industry\", \"market_segment\", \"column_break_31\", \"territory\", \"fax\", \"address_section\", \"address_html\", \"column_break_38\", \"city\", \"state\", \"country\", \"column_break2\", \"contact_html\", \"qualification_tab\", \"qualification_status\", \"column_break_64\", \"qualified_by\", \"qualified_on\", \"other_info_tab\", \"campaign_name\", \"company\", \"column_break_22\", \"language\", \"image\", \"title\", \"column_break_50\", \"disabled\", \"unsubscribed\", \"blog_subscriber\", \"activities_tab\", \"open_activities_html\", \"all_activities_section\", \"all_activities_html\", \"notes_tab\", \"notes_html\", \"notes\", \"dashboard_tab\", \"contacts\", \"primary_contact\", \"properties\", \"custom_billing_address\"]"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Lead",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2026-01-19 16:24:58.030843",
|
|
||||||
"module": null,
|
|
||||||
"name": "Lead-main-autoname",
|
|
||||||
"property": "autoname",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "format:{lead_name}-#-{YYYY}-{MM}-{####}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Project",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2026-01-19 17:52:08.608481",
|
|
||||||
"module": null,
|
|
||||||
"name": "Project-main-autoname",
|
|
||||||
"property": "autoname",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "format:{job_address}-{project_template}-#-PRO-{#####}-{YYYY}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Project",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2026-01-19 17:52:08.652993",
|
|
||||||
"module": null,
|
|
||||||
"name": "Project-main-field_order",
|
|
||||||
"property": "field_order",
|
|
||||||
"property_type": "Data",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"custom_column_break_k7sgq\", \"custom_installation_address\", \"naming_series\", \"project_name\", \"job_address\", \"status\", \"custom_warranty_duration_days\", \"custom_warranty_expiration_date\", \"custom_warranty_information\", \"project_type\", \"percent_complete_method\", \"percent_complete\", \"column_break_5\", \"project_template\", \"expected_start_date\", \"expected_end_date\", \"custom_completion_date\", \"priority\", \"custom_foreman\", \"custom_hidden_fields\", \"department\", \"is_active\", \"custom_address\", \"custom_section_break_lgkpd\", \"custom_workflow_related_custom_fields__landry\", \"custom_permit_status\", \"custom_utlity_locate_status\", \"custom_crew_scheduling\", \"customer_details\", \"customer\", \"column_break_14\", \"sales_order\", \"users_section\", \"users\", \"copied_from\", \"section_break0\", \"notes\", \"section_break_18\", \"actual_start_date\", \"actual_time\", \"column_break_20\", \"actual_end_date\", \"project_details\", \"estimated_costing\", \"total_costing_amount\", \"total_expense_claim\", \"total_purchase_cost\", \"company\", \"column_break_28\", \"total_sales_amount\", \"total_billable_amount\", \"total_billed_amount\", \"total_consumed_material_cost\", \"cost_center\", \"margin\", \"gross_margin\", \"column_break_37\", \"per_gross_margin\", \"monitor_progress\", \"collect_progress\", \"holiday_list\", \"frequency\", \"from_time\", \"to_time\", \"first_email\", \"second_email\", \"daily_time_to_send\", \"day_to_send\", \"weekly_time_to_send\", \"column_break_45\", \"subject\", \"message\"]"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Address",
|
"doc_type": "Address",
|
||||||
|
|
@ -12767,38 +12671,6 @@
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "[\"address_details\", \"custom_column_break_vqa4d\", \"custom_column_break_jw2ty\", \"custom_installationservice_address\", \"custom_billing_address\", \"is_shipping_address\", \"is_primary_address\", \"custom_is_compnay_address\", \"custom_column_break_ky1zo\", \"custom_estimate_sent_status\", \"custom_onsite_meeting_scheduled\", \"custom_job_status\", \"custom_payment_received_status\", \"custom_section_break_fvgdt\", \"address_title\", \"primary_contact\", \"address_type\", \"address_line1\", \"address_line2\", \"custom_linked_city\", \"custom_subdivision\", \"is_your_company_address\", \"custom_column_break_3mo7x\", \"state\", \"city\", \"pincode\", \"county\", \"country\", \"full_address\", \"latitude\", \"longitude\", \"onsite_meeting_scheduled\", \"estimate_sent_status\", \"job_status\", \"payment_received_status\", \"custom_column_break_rrto0\", \"custom_customer_to_bill\", \"lead_name\", \"customer_type\", \"customer_name\", \"contacts\", \"companies\", \"quotations\", \"onsite_meetings\", \"projects\", \"sales_orders\", \"tasks\", \"custom_contact_name\", \"phone\", \"email_id\", \"fax\", \"tax_category\", \"disabled\", \"custom_section_break_aecpx\", \"column_break0\", \"custom_show_irrigation_district\", \"custom_google_map\", \"custom_latitude\", \"custom_longitude\", \"custom_address_for_coordinates\", \"linked_with\", \"custom_linked_contacts\", \"links\", \"custom_column_break_9cbvb\", \"custom_linked_companies\", \"custom_irrigation\", \"custom_upcoming_services\", \"custom_service_type\", \"custom_service_route\", \"custom_confirmation_status\", \"custom_backflow_test_form_filed\", \"custom_column_break_j79td\", \"custom_technician_assigned\", \"custom_scheduled_date\", \"custom_column_break_sqplk\", \"custom_test_route\", \"custom_tech\", \"custom_column_break_wcs7g\", \"custom_section_break_zruvq\", \"custom_irrigation_district\", \"custom_serial_\", \"custom_makemodel_\", \"custom_column_break_djjw3\", \"custom_backflow_location\", \"custom_shutoff_location\", \"custom_valve_boxes\", \"custom_timer_type_and_location\", \"custom_column_break_slusf\", \"custom_section_break_5d1cf\", \"custom_installed_by_sprinklers_nw\", \"custom_column_break_th7rq\", \"custom_installed_for\", \"custom_install_month\", \"custom_install_year\", \"custom_column_break_4itse\", \"custom_section_break_xfdtv\", \"custom_backflow_test_report\", \"custom_column_break_oxppn\", \"custom_photo_attachment\"]"
|
"value": "[\"address_details\", \"custom_column_break_vqa4d\", \"custom_column_break_jw2ty\", \"custom_installationservice_address\", \"custom_billing_address\", \"is_shipping_address\", \"is_primary_address\", \"custom_is_compnay_address\", \"custom_column_break_ky1zo\", \"custom_estimate_sent_status\", \"custom_onsite_meeting_scheduled\", \"custom_job_status\", \"custom_payment_received_status\", \"custom_section_break_fvgdt\", \"address_title\", \"primary_contact\", \"address_type\", \"address_line1\", \"address_line2\", \"custom_linked_city\", \"custom_subdivision\", \"is_your_company_address\", \"custom_column_break_3mo7x\", \"state\", \"city\", \"pincode\", \"county\", \"country\", \"full_address\", \"latitude\", \"longitude\", \"onsite_meeting_scheduled\", \"estimate_sent_status\", \"job_status\", \"payment_received_status\", \"custom_column_break_rrto0\", \"custom_customer_to_bill\", \"lead_name\", \"customer_type\", \"customer_name\", \"contacts\", \"companies\", \"quotations\", \"onsite_meetings\", \"projects\", \"sales_orders\", \"tasks\", \"custom_contact_name\", \"phone\", \"email_id\", \"fax\", \"tax_category\", \"disabled\", \"custom_section_break_aecpx\", \"column_break0\", \"custom_show_irrigation_district\", \"custom_google_map\", \"custom_latitude\", \"custom_longitude\", \"custom_address_for_coordinates\", \"linked_with\", \"custom_linked_contacts\", \"links\", \"custom_column_break_9cbvb\", \"custom_linked_companies\", \"custom_irrigation\", \"custom_upcoming_services\", \"custom_service_type\", \"custom_service_route\", \"custom_confirmation_status\", \"custom_backflow_test_form_filed\", \"custom_column_break_j79td\", \"custom_technician_assigned\", \"custom_scheduled_date\", \"custom_column_break_sqplk\", \"custom_test_route\", \"custom_tech\", \"custom_column_break_wcs7g\", \"custom_section_break_zruvq\", \"custom_irrigation_district\", \"custom_serial_\", \"custom_makemodel_\", \"custom_column_break_djjw3\", \"custom_backflow_location\", \"custom_shutoff_location\", \"custom_valve_boxes\", \"custom_timer_type_and_location\", \"custom_column_break_slusf\", \"custom_section_break_5d1cf\", \"custom_installed_by_sprinklers_nw\", \"custom_column_break_th7rq\", \"custom_installed_for\", \"custom_install_month\", \"custom_install_year\", \"custom_column_break_4itse\", \"custom_section_break_xfdtv\", \"custom_backflow_test_report\", \"custom_column_break_oxppn\", \"custom_photo_attachment\"]"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Address",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2026-01-21 03:31:53.933678",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address-main-links_order",
|
|
||||||
"property": "links_order",
|
|
||||||
"property_type": "Small Text",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"21ddd8462e\", \"c26b89d0d3\", \"ee207f2316\"]"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default_value": null,
|
|
||||||
"doc_type": "Address",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocType",
|
|
||||||
"field_name": null,
|
|
||||||
"is_system_generated": 0,
|
|
||||||
"modified": "2026-01-21 03:31:53.991156",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address-main-states_order",
|
|
||||||
"property": "states_order",
|
|
||||||
"property_type": "Small Text",
|
|
||||||
"row_name": null,
|
|
||||||
"value": "[\"62m56h85vo\", \"62m5uugrvr\", \"62m57bgpkf\", \"62m5fgrjb0\"]"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"doc_type": "Project Template",
|
"doc_type": "Project Template",
|
||||||
|
|
@ -15134,5 +15006,133 @@
|
||||||
"property_type": "Data",
|
"property_type": "Data",
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "eval:doc.calculate_from == \"Task\""
|
"value": "eval:doc.calculate_from == \"Task\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Lead",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 01:51:02.536818",
|
||||||
|
"module": null,
|
||||||
|
"name": "Lead-main-autoname",
|
||||||
|
"property": "autoname",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "format:{custom_customer_name}-#-{YYYY}-{MM}-{####}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Address",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:35:09.522811",
|
||||||
|
"module": null,
|
||||||
|
"name": "Address-main-links_order",
|
||||||
|
"property": "links_order",
|
||||||
|
"property_type": "Small Text",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"21ddd8462e\", \"c26b89d0d3\", \"ee207f2316\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Address",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:35:09.598292",
|
||||||
|
"module": null,
|
||||||
|
"name": "Address-main-states_order",
|
||||||
|
"property": "states_order",
|
||||||
|
"property_type": "Small Text",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"62m56h85vo\", \"62m5uugrvr\", \"62m57bgpkf\", \"62m5fgrjb0\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Contact",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:40:01.394710",
|
||||||
|
"module": null,
|
||||||
|
"name": "Contact-main-naming_rule",
|
||||||
|
"property": "naming_rule",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "Expression"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Contact",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:40:01.427255",
|
||||||
|
"module": null,
|
||||||
|
"name": "Contact-main-autoname",
|
||||||
|
"property": "autoname",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "format:{full-name}-#-{MM}-{YYYY}-{####}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Contact",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 02:40:01.458831",
|
||||||
|
"module": null,
|
||||||
|
"name": "Contact-main-field_order",
|
||||||
|
"property": "field_order",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"sb_01\", \"custom_column_break_g4zvy\", \"first_name\", \"custom_column_break_hpz5b\", \"middle_name\", \"custom_column_break_3pehb\", \"last_name\", \"email\", \"customer_type\", \"customer_name\", \"addresses\", \"contact_section\", \"links\", \"phone_nos\", \"email_ids\", \"custom_column_break_nfqbi\", \"is_primary_contact\", \"is_billing_contact\", \"custom_service_address\", \"user\", \"unsubscribed\", \"more_info\", \"custom_column_break_sn9hu\", \"full_name\", \"address\", \"company_name\", \"designation\", \"role\", \"department\", \"image\", \"sb_00\", \"custom_column_break_kmlkz\", \"email_id\", \"mobile_no\", \"phone\", \"status\", \"gender\", \"salutation\", \"contact_details\", \"cb_00\", \"custom_test_label\", \"google_contacts\", \"google_contacts_id\", \"sync_with_google_contacts\", \"cb00\", \"pulled_from_google_contacts\", \"custom_column_break_ejxjz\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Project",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 10:42:06.682515",
|
||||||
|
"module": null,
|
||||||
|
"name": "Project-main-autoname",
|
||||||
|
"property": "autoname",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "format:{project_template}-#-PRO-{#####}-{YYYY}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default_value": null,
|
||||||
|
"doc_type": "Project",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"field_name": null,
|
||||||
|
"is_system_generated": 0,
|
||||||
|
"modified": "2026-01-26 10:42:06.862234",
|
||||||
|
"module": null,
|
||||||
|
"name": "Project-main-field_order",
|
||||||
|
"property": "field_order",
|
||||||
|
"property_type": "Data",
|
||||||
|
"row_name": null,
|
||||||
|
"value": "[\"custom_column_break_k7sgq\", \"custom_installation_address\", \"naming_series\", \"project_name\", \"job_address\", \"status\", \"custom_warranty_duration_days\", \"custom_warranty_expiration_date\", \"custom_warranty_information\", \"project_type\", \"percent_complete_method\", \"percent_complete\", \"column_break_5\", \"project_template\", \"expected_start_date\", \"expected_start_time\", \"expected_end_date\", \"expected_end_time\", \"is_scheduled\", \"invoice_status\", \"custom_completion_date\", \"priority\", \"custom_foreman\", \"custom_hidden_fields\", \"department\", \"service_appointment\", \"tasks\", \"is_active\", \"custom_address\", \"custom_section_break_lgkpd\", \"custom_workflow_related_custom_fields__landry\", \"custom_permit_status\", \"custom_utlity_locate_status\", \"custom_crew_scheduling\", \"customer_details\", \"customer\", \"column_break_14\", \"sales_order\", \"users_section\", \"users\", \"copied_from\", \"section_break0\", \"notes\", \"section_break_18\", \"actual_start_date\", \"actual_start_time\", \"actual_time\", \"column_break_20\", \"actual_end_date\", \"actual_end_time\", \"project_details\", \"estimated_costing\", \"total_costing_amount\", \"total_expense_claim\", \"total_purchase_cost\", \"company\", \"column_break_28\", \"total_sales_amount\", \"total_billable_amount\", \"total_billed_amount\", \"total_consumed_material_cost\", \"cost_center\", \"margin\", \"gross_margin\", \"column_break_37\", \"per_gross_margin\", \"monitor_progress\", \"collect_progress\", \"holiday_list\", \"frequency\", \"from_time\", \"to_time\", \"first_email\", \"second_email\", \"daily_time_to_send\", \"day_to_send\", \"weekly_time_to_send\", \"column_break_45\", \"subject\", \"message\"]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -8,7 +8,7 @@ class ServiceAppointmentService:
|
||||||
"""Create a new Service Appointment document."""
|
"""Create a new Service Appointment document."""
|
||||||
print("DEBUG: Creating Service Appointment with data:", data)
|
print("DEBUG: Creating Service Appointment with data:", data)
|
||||||
service_appointment_doc = frappe.get_doc({
|
service_appointment_doc = frappe.get_doc({
|
||||||
"doctype": "Service Appointment",
|
"doctype": "Service Address 2",
|
||||||
**data
|
**data
|
||||||
})
|
})
|
||||||
service_appointment_doc.insert()
|
service_appointment_doc.insert()
|
||||||
|
|
@ -19,7 +19,7 @@ class ServiceAppointmentService:
|
||||||
def get_full_dict(service_appointment_name: str) -> dict:
|
def get_full_dict(service_appointment_name: str) -> dict:
|
||||||
"""Retrieve a Service Appointment document as a full dictionary."""
|
"""Retrieve a Service Appointment document as a full dictionary."""
|
||||||
print(f"DEBUG: Retrieving Service Appointment document with name: {service_appointment_name}")
|
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 = frappe.get_doc("Service Address 2", service_appointment_name).as_dict()
|
||||||
service_appointment["service_address"] = AddressService.get_or_throw(service_appointment["service_address"]).as_dict()
|
service_appointment["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["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()
|
service_appointment["project"] = DbService.get_doc_or_throw("Project", service_appointment["project"]).as_dict()
|
||||||
|
|
@ -30,7 +30,7 @@ class ServiceAppointmentService:
|
||||||
def update_scheduled_dates(service_appointment_name: str, start_date, end_date, start_time=None, end_time=None):
|
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."""
|
"""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}")
|
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 = DbService.get_or_throw("Service Address 2", service_appointment_name)
|
||||||
service_appointment.expected_start_date = start_date
|
service_appointment.expected_start_date = start_date
|
||||||
service_appointment.expected_end_date = end_date
|
service_appointment.expected_end_date = end_date
|
||||||
if start_time:
|
if start_time:
|
||||||
|
|
@ -45,7 +45,7 @@ class ServiceAppointmentService:
|
||||||
def update_field(service_appointment_name: str, updates: list[tuple[str, any]]):
|
def update_field(service_appointment_name: str, updates: list[tuple[str, any]]):
|
||||||
"""Update specific fields of a Service Appointment."""
|
"""Update specific fields of a Service Appointment."""
|
||||||
print(f"DEBUG: Updating fields for Service Appointment {service_appointment_name} with updates: {updates}")
|
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)
|
service_appointment = DbService.get_or_throw("Service Address 2", service_appointment_name)
|
||||||
for field, value in updates:
|
for field, value in updates:
|
||||||
setattr(service_appointment, field, value)
|
setattr(service_appointment, field, value)
|
||||||
service_appointment.save()
|
service_appointment.save()
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ class TaskService:
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def calculate_and_set_due_dates(task_names: list[str], event: str):
|
def calculate_and_set_due_dates(task_names: list[str], event: str, triggering_doctype: str):
|
||||||
"""Calculate the due date for a list of tasks based on their expected end dates."""
|
"""Calculate the due date for a list of tasks based on their expected end dates."""
|
||||||
for task_name in task_names:
|
for task_name in task_names:
|
||||||
TaskService.check_and_update_task_due_date(task_name, event)
|
TaskService.check_and_update_task_due_date(task_name, event, triggering_doctype)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -19,9 +19,15 @@ class TaskService:
|
||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_and_update_task_due_date(task_name: str, event: str):
|
def check_and_update_task_due_date(task_name: str, event: str, triggering_doctype: str):
|
||||||
"""Determine the triggering configuration for a given task."""
|
"""Determine the triggering configuration for a given task."""
|
||||||
task_type_doc = TaskService.get_task_type_doc(task_name)
|
task_type_doc = TaskService.get_task_type_doc(task_name)
|
||||||
|
if task_type_doc.no_due_date:
|
||||||
|
print(f"DEBUG: Task {task_name} is marked as no due date, skipping calculation.")
|
||||||
|
return
|
||||||
|
if task_type_doc.triggering_doctype != triggering_doctype:
|
||||||
|
print(f"DEBUG: Task {task_name} triggering doctype {task_type_doc.triggering_doctype} does not match triggering doctype {triggering_doctype}, skipping calculation.")
|
||||||
|
return
|
||||||
if task_type_doc.trigger != event:
|
if task_type_doc.trigger != event:
|
||||||
print(f"DEBUG: Task {task_name} trigger {task_type_doc.trigger} does not match event {event}, skipping calculation.")
|
print(f"DEBUG: Task {task_name} trigger {task_type_doc.trigger} does not match event {event}, skipping calculation.")
|
||||||
return
|
return
|
||||||
|
|
@ -31,9 +37,9 @@ class TaskService:
|
||||||
if task_type_doc.no_due_date:
|
if task_type_doc.no_due_date:
|
||||||
print(f"DEBUG: Task {task_name} is marked as no due date, skipping calculation.")
|
print(f"DEBUG: Task {task_name} is marked as no due date, skipping calculation.")
|
||||||
return
|
return
|
||||||
calculated_from = task_type_doc.calculated_from
|
calculate_from = task_type_doc.calculate_from
|
||||||
trigger = task_type_doc.trigger
|
trigger = task_type_doc.trigger
|
||||||
print(f"DEBUG: Calculating triggering data for Task {task_name} from {calculated_from} on trigger {trigger}")
|
print(f"DEBUG: Calculating triggering data for Task {task_name} from {calculate_from} on trigger {trigger}")
|
||||||
|
|
||||||
|
|
||||||
triggering_doc_dict = TaskService.get_triggering_doc_dict(task_name=task_name, task_type_doc=task_type_doc)
|
triggering_doc_dict = TaskService.get_triggering_doc_dict(task_name=task_name, task_type_doc=task_type_doc)
|
||||||
|
|
@ -59,7 +65,7 @@ class TaskService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_task_type_doc(task_name: str):
|
def get_task_type_doc(task_name: str):
|
||||||
task_type_name = frappe.get_value("Task", task_name, "task_type")
|
task_type_name = frappe.get_value("Task", task_name, "type")
|
||||||
return frappe.get_doc("Task Type", task_type_name)
|
return frappe.get_doc("Task Type", task_type_name)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -99,12 +105,12 @@ class TaskService:
|
||||||
def get_triggering_doc_dict(task_name: str, task_type_doc) -> dict | None:
|
def get_triggering_doc_dict(task_name: str, task_type_doc) -> dict | None:
|
||||||
project_name = frappe.get_value("Task", task_name, "project")
|
project_name = frappe.get_value("Task", task_name, "project")
|
||||||
dict = None
|
dict = None
|
||||||
if task_type_doc.calculated_from == "Project":
|
if task_type_doc.calculate_from == "Project":
|
||||||
dict = frappe.get_doc("Project", project_name).to_dict()
|
dict = frappe.get_doc("Project", project_name).to_dict()
|
||||||
if task_type_doc.calculated_from == "Service Appointment":
|
if task_type_doc.calculate_from == "Service Address 2":
|
||||||
service_name = frappe.get_value("Project", project_name, "service_appointment")
|
service_name = frappe.get_value("Project", project_name, "service_appointment")
|
||||||
dict = frappe.get_doc("Service Appointment", service_name).to_dict()
|
dict = frappe.get_doc("Service Address 2", service_name).to_dict()
|
||||||
if task_type_doc.calculated_from == "Task":
|
if task_type_doc.calculate_from == "Task":
|
||||||
project_doc = frappe.get_doc("Project", project_name)
|
project_doc = frappe.get_doc("Project", project_name)
|
||||||
for task in project_doc.tasks:
|
for task in project_doc.tasks:
|
||||||
if task.task_type == task_type_doc.task_type_calculate_from:
|
if task.task_type == task_type_doc.task_type_calculate_from:
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,8 @@
|
||||||
)"
|
)"
|
||||||
:key="meeting.id"
|
:key="meeting.id"
|
||||||
class="meeting-event"
|
class="meeting-event"
|
||||||
:class="getMeetingColorClass(meeting)"
|
:class="[getMeetingColorClass(meeting), { 'meeting-completed-locked': meeting.status === 'Completed' }]"
|
||||||
draggable="true"
|
:draggable="meeting.status !== 'Completed'"
|
||||||
@dragstart="handleMeetingDragStart($event, meeting)"
|
@dragstart="handleMeetingDragStart($event, meeting)"
|
||||||
@dragend="handleDragEnd($event)"
|
@dragend="handleDragEnd($event)"
|
||||||
@click.stop="showMeetingDetails(meeting)"
|
@click.stop="showMeetingDetails(meeting)"
|
||||||
|
|
@ -633,6 +633,12 @@ const handleDragStart = (event, meeting = null) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMeetingDragStart = (event, meeting) => {
|
const handleMeetingDragStart = (event, meeting) => {
|
||||||
|
// Prevent dragging completed meetings
|
||||||
|
if (meeting.status === 'Completed') {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle dragging a scheduled meeting
|
// Handle dragging a scheduled meeting
|
||||||
draggedMeeting.value = {
|
draggedMeeting.value = {
|
||||||
id: meeting.name,
|
id: meeting.name,
|
||||||
|
|
@ -1561,11 +1567,24 @@ watch(
|
||||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meeting-event.meeting-completed-locked {
|
||||||
|
cursor: default !important;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-event.meeting-completed-locked:active {
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
||||||
|
|
||||||
.meeting-event:hover {
|
.meeting-event:hover {
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meeting-event.meeting-completed-locked:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.event-time {
|
.event-time {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
362
frontend/src/components/clientView/AddressSelector.vue
Normal file
362
frontend/src/components/clientView/AddressSelector.vue
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="addresses.length > 1" class="address-selector">
|
||||||
|
<div class="selector-header">
|
||||||
|
<h4>Select Address</h4>
|
||||||
|
<Button
|
||||||
|
@click="showAddAddressModal = true"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
label="Add An Address"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedAddressIndex"
|
||||||
|
:options="addressOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
placeholder="Select an address"
|
||||||
|
class="w-full address-dropdown"
|
||||||
|
@change="handleAddressChange"
|
||||||
|
>
|
||||||
|
<template #value="slotProps">
|
||||||
|
<div v-if="slotProps.value !== null && slotProps.value !== undefined" class="dropdown-value">
|
||||||
|
<span class="address-title">{{ addresses[slotProps.value]?.addressTitle || 'Unnamed Address' }}</span>
|
||||||
|
<div class="address-badges">
|
||||||
|
<Badge
|
||||||
|
v-if="addresses[slotProps.value]?.isPrimaryAddress && !addresses[slotProps.value]?.isServiceAddress"
|
||||||
|
value="Billing Only"
|
||||||
|
severity="info"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="addresses[slotProps.value]?.isPrimaryAddress && addresses[slotProps.value]?.isServiceAddress"
|
||||||
|
value="Billing & Service"
|
||||||
|
severity="success"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="!addresses[slotProps.value]?.isPrimaryAddress && addresses[slotProps.value]?.isServiceAddress"
|
||||||
|
value="Service"
|
||||||
|
severity="secondary"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="addresses[slotProps.value]?.isServiceAddress"
|
||||||
|
:value="`${addresses[slotProps.value]?.projects?.length || 0} Projects`"
|
||||||
|
severity="contrast"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #option="slotProps">
|
||||||
|
<div class="dropdown-option">
|
||||||
|
<span class="option-title">{{ slotProps.option.addressTitle || 'Unnamed Address' }}</span>
|
||||||
|
<div class="option-badges">
|
||||||
|
<Badge
|
||||||
|
v-if="slotProps.option.isPrimaryAddress && !slotProps.option.isServiceAddress"
|
||||||
|
value="Billing Only"
|
||||||
|
severity="info"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="slotProps.option.isPrimaryAddress && slotProps.option.isServiceAddress"
|
||||||
|
value="Billing & Service"
|
||||||
|
severity="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="!slotProps.option.isPrimaryAddress && slotProps.option.isServiceAddress"
|
||||||
|
value="Service"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="slotProps.option.isServiceAddress"
|
||||||
|
:value="`${slotProps.option.projectCount} Projects`"
|
||||||
|
severity="contrast"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
<!-- Selected Address Info -->
|
||||||
|
<div v-if="selectedAddress" class="selected-address-info">
|
||||||
|
<div class="address-status">
|
||||||
|
<Badge
|
||||||
|
v-if="selectedAddress.isPrimaryAddress && !selectedAddress.isServiceAddress"
|
||||||
|
value="Billing Only Address"
|
||||||
|
severity="info"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="selectedAddress.isPrimaryAddress && selectedAddress.isServiceAddress"
|
||||||
|
value="Billing & Service Address"
|
||||||
|
severity="success"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="!selectedAddress.isPrimaryAddress && selectedAddress.isServiceAddress"
|
||||||
|
value="Service Address"
|
||||||
|
severity="secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Address Details -->
|
||||||
|
<div v-if="selectedAddress.isServiceAddress" class="service-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<i class="pi pi-briefcase"></i>
|
||||||
|
<span>{{ selectedAddress.projects?.length || 0 }} Projects</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<i class="pi pi-calendar"></i>
|
||||||
|
<span>{{ selectedAddress.onsiteMeetings?.length || 0 }} Bid Meetings</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="primaryContact" class="detail-item primary-contact">
|
||||||
|
<i class="pi pi-user"></i>
|
||||||
|
<div class="contact-info">
|
||||||
|
<span class="contact-name">{{ primaryContactName }}</span>
|
||||||
|
<span class="contact-detail">{{ primaryContactEmail }}</span>
|
||||||
|
<span class="contact-detail">{{ primaryContactPhone }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Address Modal -->
|
||||||
|
<Dialog
|
||||||
|
:visible="showAddAddressModal"
|
||||||
|
@update:visible="showAddAddressModal = $event"
|
||||||
|
header="Add Address"
|
||||||
|
:modal="true"
|
||||||
|
:closable="true"
|
||||||
|
class="add-address-dialog"
|
||||||
|
>
|
||||||
|
<div class="coming-soon">
|
||||||
|
<i class="pi pi-hourglass"></i>
|
||||||
|
<p>Feature coming soon</p>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
label="Close"
|
||||||
|
severity="secondary"
|
||||||
|
@click="showAddAddressModal = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import Badge from "primevue/badge";
|
||||||
|
import Button from "primevue/button";
|
||||||
|
import Dialog from "primevue/dialog";
|
||||||
|
import Dropdown from "primevue/dropdown";
|
||||||
|
import DataUtils from "../../utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
addresses: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
selectedAddressIdx: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
contacts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:selectedAddressIdx"]);
|
||||||
|
|
||||||
|
const showAddAddressModal = ref(false);
|
||||||
|
const selectedAddressIndex = ref(props.selectedAddressIdx);
|
||||||
|
|
||||||
|
// Watch for external changes to selectedAddressIdx
|
||||||
|
watch(() => props.selectedAddressIdx, (newVal) => {
|
||||||
|
selectedAddressIndex.value = newVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selected address object
|
||||||
|
const selectedAddress = computed(() => {
|
||||||
|
if (selectedAddressIndex.value >= 0 && selectedAddressIndex.value < props.addresses.length) {
|
||||||
|
return props.addresses[selectedAddressIndex.value];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Address options for dropdown
|
||||||
|
const addressOptions = computed(() => {
|
||||||
|
return props.addresses.map((addr, idx) => ({
|
||||||
|
label: addr.addressTitle || DataUtils.calculateFullAddress(addr),
|
||||||
|
value: idx,
|
||||||
|
addressTitle: addr.addressTitle || 'Unnamed Address',
|
||||||
|
isPrimaryAddress: addr.isPrimaryAddress,
|
||||||
|
isServiceAddress: addr.isServiceAddress,
|
||||||
|
projectCount: addr.projects?.length || 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Primary contact for selected address
|
||||||
|
const primaryContact = computed(() => {
|
||||||
|
if (!selectedAddress.value?.primaryContact || !props.contacts) return null;
|
||||||
|
return props.contacts.find(c => c.name === selectedAddress.value.primaryContact);
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryContactName = computed(() => {
|
||||||
|
if (!primaryContact.value) return "N/A";
|
||||||
|
return primaryContact.value.fullName || primaryContact.value.name || "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryContactEmail = computed(() => {
|
||||||
|
if (!primaryContact.value) return "N/A";
|
||||||
|
return primaryContact.value.emailId || primaryContact.value.customEmail || "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryContactPhone = computed(() => {
|
||||||
|
if (!primaryContact.value) return "N/A";
|
||||||
|
return primaryContact.value.phone || primaryContact.value.mobileNo || "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle address change
|
||||||
|
const handleAddressChange = () => {
|
||||||
|
emit("update:selectedAddressIdx", selectedAddressIndex.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.address-selector {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-dropdown {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-value,
|
||||||
|
.dropdown-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-title,
|
||||||
|
.option-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-badges,
|
||||||
|
.option-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-address-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface-ground);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-status {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.primary-contact {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-detail {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon i {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
272
frontend/src/components/clientView/GeneralClientInfo.vue
Normal file
272
frontend/src/components/clientView/GeneralClientInfo.vue
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
<template>
|
||||||
|
<div class="general-client-info">
|
||||||
|
<div class="info-grid">
|
||||||
|
<!-- Lead Badge -->
|
||||||
|
<div v-if="isLead" class="lead-badge-container">
|
||||||
|
<Badge value="LEAD" severity="warn" size="large" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Name (only show for Company type) -->
|
||||||
|
<div v-if="clientData.customerType === 'Company'" class="info-section">
|
||||||
|
<label>Company Name</label>
|
||||||
|
<span class="info-value large">{{ displayClientName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Type -->
|
||||||
|
<div class="info-section">
|
||||||
|
<label>Client Type</label>
|
||||||
|
<span class="info-value">{{ clientData.customerType || "N/A" }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Associated Companies -->
|
||||||
|
<div v-if="associatedCompanies.length > 0" class="info-section">
|
||||||
|
<label>Associated Companies</label>
|
||||||
|
<div class="companies-list">
|
||||||
|
<Tag
|
||||||
|
v-for="company in associatedCompanies"
|
||||||
|
:key="company"
|
||||||
|
:value="company"
|
||||||
|
severity="info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Billing Address -->
|
||||||
|
<div v-if="billingAddress" class="info-section">
|
||||||
|
<label>Billing Address</label>
|
||||||
|
<span class="info-value">{{ billingAddress }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Primary Contact Info -->
|
||||||
|
<div v-if="primaryContact" class="info-section primary-contact">
|
||||||
|
<label>{{ clientData.customerType === 'Individual' ? 'Contact Information' : 'Primary Contact' }}</label>
|
||||||
|
<div class="contact-details">
|
||||||
|
<div class="contact-item">
|
||||||
|
<i class="pi pi-user"></i>
|
||||||
|
<span>{{ primaryContact.fullName || primaryContact.name || "N/A" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-item">
|
||||||
|
<i class="pi pi-envelope"></i>
|
||||||
|
<span>{{ primaryContact.emailId || primaryContact.customEmail || "N/A" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-item">
|
||||||
|
<i class="pi pi-phone"></i>
|
||||||
|
<span>{{ primaryContact.phone || primaryContact.mobileNo || "N/A" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<div class="info-section stats">
|
||||||
|
<label>Overview</label>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<i class="pi pi-map-marker"></i>
|
||||||
|
<span class="stat-value">{{ addressCount }}</span>
|
||||||
|
<span class="stat-label">Addresses</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<i class="pi pi-users"></i>
|
||||||
|
<span class="stat-value">{{ contactCount }}</span>
|
||||||
|
<span class="stat-label">Contacts</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<i class="pi pi-briefcase"></i>
|
||||||
|
<span class="stat-value">{{ projectCount }}</span>
|
||||||
|
<span class="stat-label">Projects</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Creation Date -->
|
||||||
|
<div class="info-section">
|
||||||
|
<label>Created</label>
|
||||||
|
<span class="info-value">{{ formattedCreationDate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import Badge from "primevue/badge";
|
||||||
|
import Tag from "primevue/tag";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
clientData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if client is a Lead
|
||||||
|
const isLead = computed(() => props.clientData.doctype === "Lead");
|
||||||
|
|
||||||
|
// Strip "-#-" from client name
|
||||||
|
const displayClientName = computed(() => {
|
||||||
|
if (!props.clientData.customerName) return "N/A";
|
||||||
|
return props.clientData.customerName.split("-#-")[0].trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get associated companies
|
||||||
|
const associatedCompanies = computed(() => {
|
||||||
|
if (!props.clientData.companies || !Array.isArray(props.clientData.companies)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return props.clientData.companies.map(c => c.company).filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strip "-#-" from billing address
|
||||||
|
const billingAddress = computed(() => {
|
||||||
|
if (!props.clientData.customBillingAddress) return null;
|
||||||
|
return props.clientData.customBillingAddress.split("-#-")[0].trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get primary contact
|
||||||
|
const primaryContact = computed(() => {
|
||||||
|
if (!props.clientData.contacts || !props.clientData.primaryContact) return null;
|
||||||
|
return props.clientData.contacts.find(
|
||||||
|
c => c.name === props.clientData.primaryContact
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Counts
|
||||||
|
const addressCount = computed(() => props.clientData.addresses?.length || 0);
|
||||||
|
const contactCount = computed(() => props.clientData.contacts?.length || 0);
|
||||||
|
const projectCount = computed(() => props.clientData.jobs?.length || 0);
|
||||||
|
|
||||||
|
// Format creation date
|
||||||
|
const formattedCreationDate = computed(() => {
|
||||||
|
if (!props.clientData.creation) return "N/A";
|
||||||
|
const date = new Date(props.clientData.creation);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.general-client-info {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-badge-container {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.large {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.companies-list {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-contact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-details {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: var(--surface-ground);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item i {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: var(--surface-ground);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item i {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
132
frontend/src/components/clientView/Overview.vue
Normal file
132
frontend/src/components/clientView/Overview.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<template>
|
||||||
|
<div class="overview-content">
|
||||||
|
<!-- New Client Forms -->
|
||||||
|
<div v-if="isNew" class="new-client-forms">
|
||||||
|
<ClientInformationForm
|
||||||
|
:form-data="client"
|
||||||
|
:is-submitting="false"
|
||||||
|
:is-edit-mode="false"
|
||||||
|
@update:formData="handleFormDataUpdate"
|
||||||
|
@newClientToggle="handleNewClientToggle"
|
||||||
|
@customerSelected="handleCustomerSelected"
|
||||||
|
/>
|
||||||
|
<ContactInformationForm
|
||||||
|
:form-data="client"
|
||||||
|
:is-submitting="false"
|
||||||
|
:is-edit-mode="false"
|
||||||
|
@update:formData="handleFormDataUpdate"
|
||||||
|
/>
|
||||||
|
<AddressInformationForm
|
||||||
|
:form-data="client"
|
||||||
|
:is-submitting="false"
|
||||||
|
:is-edit-mode="false"
|
||||||
|
@update:formData="handleFormDataUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions (only in non-edit mode) -->
|
||||||
|
<QuickActions
|
||||||
|
v-if="!editMode && !isNew"
|
||||||
|
:full-address="fullAddress"
|
||||||
|
@edit-mode-enabled="handleEditModeEnabled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Special Modules Section -->
|
||||||
|
<SpecialModules
|
||||||
|
v-if="!isNew && !editMode"
|
||||||
|
:selected-address="selectedAddress"
|
||||||
|
:full-address="fullAddress"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Property Details -->
|
||||||
|
<PropertyDetails
|
||||||
|
v-if="!isNew && selectedAddress"
|
||||||
|
:address-data="selectedAddress"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:edit-mode="editMode"
|
||||||
|
@update:address-contacts="handleAddressContactsUpdate"
|
||||||
|
@update:primary-contact="handlePrimaryContactUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import QuickActions from "./QuickActions.vue";
|
||||||
|
import SpecialModules from "./SpecialModules.vue";
|
||||||
|
import PropertyDetails from "./PropertyDetails.vue";
|
||||||
|
import ClientInformationForm from "../clientSubPages/ClientInformationForm.vue";
|
||||||
|
import ContactInformationForm from "../clientSubPages/ContactInformationForm.vue";
|
||||||
|
import AddressInformationForm from "../clientSubPages/AddressInformationForm.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selectedAddress: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
allContacts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
editMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isNew: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
fullAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"edit-mode-enabled",
|
||||||
|
"update:address-contacts",
|
||||||
|
"update:primary-contact",
|
||||||
|
"update:client",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleEditModeEnabled = () => {
|
||||||
|
emit("edit-mode-enabled");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressContactsUpdate = (contactNames) => {
|
||||||
|
emit("update:address-contacts", contactNames);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrimaryContactUpdate = (contactName) => {
|
||||||
|
emit("update:primary-contact", contactName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormDataUpdate = (newFormData) => {
|
||||||
|
emit("update:client", newFormData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewClientToggle = (isNewClient) => {
|
||||||
|
// Handle if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomerSelected = (customer) => {
|
||||||
|
// Handle if needed
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overview-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-client-forms {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
617
frontend/src/components/clientView/PropertyDetails.vue
Normal file
617
frontend/src/components/clientView/PropertyDetails.vue
Normal file
|
|
@ -0,0 +1,617 @@
|
||||||
|
<template>
|
||||||
|
<div class="property-details">
|
||||||
|
<h3>Property Details</h3>
|
||||||
|
|
||||||
|
<div class="details-grid">
|
||||||
|
<!-- Address Information -->
|
||||||
|
<div class="detail-section full-width">
|
||||||
|
<div class="section-header">
|
||||||
|
<i class="pi pi-map-marker"></i>
|
||||||
|
<h4>Address</h4>
|
||||||
|
</div>
|
||||||
|
<div class="address-info">
|
||||||
|
<p class="full-address">{{ fullAddress }}</p>
|
||||||
|
<div class="address-badges">
|
||||||
|
<Badge
|
||||||
|
v-if="addressData.isPrimaryAddress && !addressData.isServiceAddress"
|
||||||
|
value="Billing Only"
|
||||||
|
severity="info"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="addressData.isPrimaryAddress && addressData.isServiceAddress"
|
||||||
|
value="Billing & Service"
|
||||||
|
severity="success"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
v-if="!addressData.isPrimaryAddress && addressData.isServiceAddress"
|
||||||
|
value="Service Address"
|
||||||
|
severity="secondary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts Section -->
|
||||||
|
<div class="detail-section full-width">
|
||||||
|
<div class="section-header">
|
||||||
|
<i class="pi pi-users"></i>
|
||||||
|
<h4>Contacts</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Mode -->
|
||||||
|
<div v-if="!editMode" class="contacts-display">
|
||||||
|
<template v-if="addressContacts.length > 0">
|
||||||
|
<!-- Primary Contact -->
|
||||||
|
<div v-if="primaryContact" class="contact-card primary">
|
||||||
|
<div class="contact-badge">
|
||||||
|
<Badge value="Primary" severity="success" />
|
||||||
|
</div>
|
||||||
|
<div class="contact-info">
|
||||||
|
<h5>{{ primaryContactName }}</h5>
|
||||||
|
<div class="contact-details">
|
||||||
|
<div class="contact-detail">
|
||||||
|
<i class="pi pi-envelope"></i>
|
||||||
|
<span>{{ primaryContactEmail }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-detail">
|
||||||
|
<i class="pi pi-phone"></i>
|
||||||
|
<span>{{ primaryContactPhone }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="primaryContact.role" class="contact-detail">
|
||||||
|
<i class="pi pi-briefcase"></i>
|
||||||
|
<span>{{ primaryContact.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Other Contacts -->
|
||||||
|
<div v-if="otherContacts.length > 0" class="other-contacts">
|
||||||
|
<h6>Other Contacts</h6>
|
||||||
|
<div class="contacts-grid">
|
||||||
|
<div
|
||||||
|
v-for="contact in otherContacts"
|
||||||
|
:key="contact.name"
|
||||||
|
class="contact-card small"
|
||||||
|
>
|
||||||
|
<div class="contact-info-compact">
|
||||||
|
<span class="contact-name">{{ getContactName(contact) }}</span>
|
||||||
|
<span class="contact-email">{{ getContactEmail(contact) }}</span>
|
||||||
|
<span class="contact-phone">{{ getContactPhone(contact) }}</span>
|
||||||
|
<span v-if="contact.role" class="contact-role">{{ contact.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="pi pi-user-minus"></i>
|
||||||
|
<p>No contacts associated with this address</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Mode -->
|
||||||
|
<div v-else class="contacts-edit">
|
||||||
|
<div class="edit-instructions">
|
||||||
|
<i class="pi pi-info-circle"></i>
|
||||||
|
<span>Select contacts to associate with this address. One must be marked as primary.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contacts-list">
|
||||||
|
<div
|
||||||
|
v-for="contact in allContacts"
|
||||||
|
:key="contact.name"
|
||||||
|
class="contact-checkbox-item"
|
||||||
|
:class="{ 'is-selected': isContactSelected(contact) }"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:model-value="isContactSelected(contact)"
|
||||||
|
:binary="true"
|
||||||
|
@update:model-value="toggleContact(contact)"
|
||||||
|
:input-id="`contact-${contact.name}`"
|
||||||
|
/>
|
||||||
|
<label :for="`contact-${contact.name}`" class="contact-label">
|
||||||
|
<div class="contact-info-inline">
|
||||||
|
<span class="contact-name">{{ getContactName(contact) }}</span>
|
||||||
|
<span class="contact-email">{{ getContactEmail(contact) }}</span>
|
||||||
|
<span class="contact-phone">{{ getContactPhone(contact) }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div v-if="isContactSelected(contact)" class="primary-checkbox">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="isPrimaryContact(contact)"
|
||||||
|
:binary="true"
|
||||||
|
@update:model-value="setPrimaryContact(contact)"
|
||||||
|
:input-id="`primary-${contact.name}`"
|
||||||
|
/>
|
||||||
|
<label :for="`primary-${contact.name}`">Primary</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Companies Section -->
|
||||||
|
<div class="detail-section full-width">
|
||||||
|
<div class="section-header">
|
||||||
|
<i class="pi pi-building"></i>
|
||||||
|
<h4>Associated Companies</h4>
|
||||||
|
</div>
|
||||||
|
<div v-if="associatedCompanies.length > 0" class="companies-list">
|
||||||
|
<div
|
||||||
|
v-for="company in associatedCompanies"
|
||||||
|
:key="company"
|
||||||
|
class="company-item"
|
||||||
|
>
|
||||||
|
<i class="pi pi-building"></i>
|
||||||
|
<span>{{ company }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<i class="pi pi-building"></i>
|
||||||
|
<p>No companies associated with this address</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Section -->
|
||||||
|
<div class="detail-section full-width">
|
||||||
|
<div class="section-header">
|
||||||
|
<i class="pi pi-map"></i>
|
||||||
|
<h4>Location</h4>
|
||||||
|
</div>
|
||||||
|
<LeafletMap
|
||||||
|
:latitude="latitude"
|
||||||
|
:longitude="longitude"
|
||||||
|
:address-title="addressData.addressTitle || 'Property Location'"
|
||||||
|
map-height="350px"
|
||||||
|
:zoom-level="16"
|
||||||
|
/>
|
||||||
|
<div v-if="latitude && longitude" class="coordinates-info">
|
||||||
|
<small>
|
||||||
|
<strong>Coordinates:</strong>
|
||||||
|
{{ parseFloat(latitude).toFixed(6) }}, {{ parseFloat(longitude).toFixed(6) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import Badge from "primevue/badge";
|
||||||
|
import Checkbox from "primevue/checkbox";
|
||||||
|
import LeafletMap from "../common/LeafletMap.vue";
|
||||||
|
import DataUtils from "../../utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
addressData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
allContacts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
editMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:addressContacts", "update:primaryContact"]);
|
||||||
|
|
||||||
|
// Local state for editing
|
||||||
|
const selectedContactNames = ref([]);
|
||||||
|
const selectedPrimaryContactName = ref(null);
|
||||||
|
|
||||||
|
// Initialize from props when edit mode is enabled
|
||||||
|
watch(() => props.editMode, (isEditMode) => {
|
||||||
|
if (isEditMode) {
|
||||||
|
// Initialize selected contacts from address
|
||||||
|
selectedContactNames.value = (props.addressData.contacts || [])
|
||||||
|
.map(c => c.contact)
|
||||||
|
.filter(Boolean);
|
||||||
|
selectedPrimaryContactName.value = props.addressData.primaryContact || null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Full address
|
||||||
|
const fullAddress = computed(() => {
|
||||||
|
return DataUtils.calculateFullAddress(props.addressData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get contacts associated with this address
|
||||||
|
const addressContacts = computed(() => {
|
||||||
|
if (!props.addressData.contacts || !props.allContacts) return [];
|
||||||
|
|
||||||
|
const addressContactNames = props.addressData.contacts.map(c => c.contact);
|
||||||
|
return props.allContacts.filter(c => addressContactNames.includes(c.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Primary contact
|
||||||
|
const primaryContact = computed(() => {
|
||||||
|
if (!props.addressData.primaryContact || !props.allContacts) return null;
|
||||||
|
return props.allContacts.find(c => c.name === props.addressData.primaryContact);
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryContactName = computed(() => {
|
||||||
|
if (!primaryContact.value) return "N/A";
|
||||||
|
return primaryContact.value.fullName || primaryContact.value.name || "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryContactEmail = computed(() => {
|
||||||
|
if (!primaryContact.value) return "N/A";
|
||||||
|
return primaryContact.value.emailId || primaryContact.value.customEmail || "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryContactPhone = computed(() => {
|
||||||
|
if (!primaryContact.value) return "N/A";
|
||||||
|
return primaryContact.value.phone || primaryContact.value.mobileNo || "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other contacts (non-primary)
|
||||||
|
const otherContacts = computed(() => {
|
||||||
|
return addressContacts.value.filter(c => c.name !== props.addressData.primaryContact);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map coordinates
|
||||||
|
const latitude = computed(() => {
|
||||||
|
return props.addressData.customLatitude || props.addressData.latitude || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const longitude = computed(() => {
|
||||||
|
return props.addressData.customLongitude || props.addressData.longitude || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Associated companies
|
||||||
|
const associatedCompanies = computed(() => {
|
||||||
|
if (!props.addressData.companies) return [];
|
||||||
|
return props.addressData.companies.map(company => company.company).filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions for contact display
|
||||||
|
const getContactName = (contact) => {
|
||||||
|
return contact.fullName || contact.name || "N/A";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContactEmail = (contact) => {
|
||||||
|
return contact.emailId || contact.customEmail || "N/A";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContactPhone = (contact) => {
|
||||||
|
return contact.phone || contact.mobileNo || "N/A";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit mode functions
|
||||||
|
const isContactSelected = (contact) => {
|
||||||
|
return selectedContactNames.value.includes(contact.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPrimaryContact = (contact) => {
|
||||||
|
return selectedPrimaryContactName.value === contact.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleContact = (contact) => {
|
||||||
|
const index = selectedContactNames.value.indexOf(contact.name);
|
||||||
|
if (index > -1) {
|
||||||
|
// Removing contact
|
||||||
|
selectedContactNames.value.splice(index, 1);
|
||||||
|
// If this was the primary contact, clear it
|
||||||
|
if (selectedPrimaryContactName.value === contact.name) {
|
||||||
|
selectedPrimaryContactName.value = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Adding contact
|
||||||
|
selectedContactNames.value.push(contact.name);
|
||||||
|
}
|
||||||
|
emitChanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPrimaryContact = (contact) => {
|
||||||
|
if (isContactSelected(contact)) {
|
||||||
|
selectedPrimaryContactName.value = contact.name;
|
||||||
|
emitChanges();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitChanges = () => {
|
||||||
|
emit("update:addressContacts", selectedContactNames.value);
|
||||||
|
emit("update:primaryContact", selectedPrimaryContactName.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.property-details {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-details > h3 {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
background: var(--surface-ground);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 2px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-address {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contacts Display Mode */
|
||||||
|
.contacts-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-card.primary {
|
||||||
|
border: 2px solid var(--green-500);
|
||||||
|
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-badge {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info h5 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-detail i {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
min-width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-detail span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Other Contacts */
|
||||||
|
.other-contacts h6 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-card.small {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-compact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-compact .contact-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-compact .contact-email,
|
||||||
|
.contact-info-compact .contact-phone,
|
||||||
|
.contact-info-compact .contact-role {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contacts Edit Mode */
|
||||||
|
.contacts-edit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-instructions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--blue-50);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--blue-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-instructions i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--blue-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-instructions span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--blue-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--surface-border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-checkbox-item.is-selected {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: var(--primary-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-label {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-inline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-inline .contact-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info-inline .contact-email,
|
||||||
|
.contact-info-inline .contact-phone {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--green-50);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--green-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-checkbox label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--green-700);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Companies */
|
||||||
|
.companies-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-item i {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
min-width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-item span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map */
|
||||||
|
.coordinates-info {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
frontend/src/components/clientView/QuickActions.vue
Normal file
118
frontend/src/components/clientView/QuickActions.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<template>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<Button
|
||||||
|
@click="handleEdit"
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
label="Edit Information"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="handleCreateEstimate"
|
||||||
|
icon="pi pi-file-edit"
|
||||||
|
label="Create Estimate"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="handleCreateBidMeeting"
|
||||||
|
icon="pi pi-calendar-plus"
|
||||||
|
label="Create Bid Meeting"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edit Confirmation Dialog -->
|
||||||
|
<Dialog
|
||||||
|
:visible="showEditConfirmDialog"
|
||||||
|
@update:visible="showEditConfirmDialog = $event"
|
||||||
|
header="Confirm Edit"
|
||||||
|
:modal="true"
|
||||||
|
:closable="false"
|
||||||
|
class="confirm-dialog"
|
||||||
|
>
|
||||||
|
<p>Are you sure you want to edit this client information? This will enable editing mode.</p>
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
severity="secondary"
|
||||||
|
@click="showEditConfirmDialog = false"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Yes, Edit"
|
||||||
|
@click="confirmEdit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import Button from "primevue/button";
|
||||||
|
import Dialog from "primevue/dialog";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import DataUtils from "../../utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fullAddress: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["edit-mode-enabled"]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const showEditConfirmDialog = ref(false);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
showEditConfirmDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmEdit = () => {
|
||||||
|
showEditConfirmDialog.value = false;
|
||||||
|
emit("edit-mode-enabled");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateEstimate = () => {
|
||||||
|
router.push({
|
||||||
|
path: "/estimate",
|
||||||
|
query: {
|
||||||
|
new: "true",
|
||||||
|
address: props.fullAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateBidMeeting = () => {
|
||||||
|
router.push({
|
||||||
|
path: "/calendar",
|
||||||
|
query: {
|
||||||
|
tab: "bids",
|
||||||
|
new: "true",
|
||||||
|
address: props.fullAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog :deep(.p-dialog-footer) {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
89
frontend/src/components/clientView/SpecialModules.vue
Normal file
89
frontend/src/components/clientView/SpecialModules.vue
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="shouldDisplayModule" class="special-modules">
|
||||||
|
<!-- SNW Install Module -->
|
||||||
|
<InstallStatus
|
||||||
|
v-if="currentCompany === 'Sprinklers Northwest'"
|
||||||
|
:onsite-meeting-status="snwInstallData.onsiteMeetingStatus"
|
||||||
|
:estimate-sent-status="snwInstallData.estimateSentStatus"
|
||||||
|
:job-status="snwInstallData.jobStatus"
|
||||||
|
:payment-status="snwInstallData.paymentStatus"
|
||||||
|
:full-address="fullAddress"
|
||||||
|
:bid-meeting="snwInstallData.onsiteMeeting"
|
||||||
|
:estimate="snwInstallData.estimate"
|
||||||
|
:job="snwInstallData.job"
|
||||||
|
:payment="snwInstallData.payment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useCompanyStore } from "../../stores/company";
|
||||||
|
import InstallStatus from "./InstallStatus.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selectedAddress: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
fullAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
|
|
||||||
|
const currentCompany = computed(() => companyStore.currentCompany);
|
||||||
|
|
||||||
|
// Check if we should display any module
|
||||||
|
const shouldDisplayModule = computed(() => {
|
||||||
|
return currentCompany.value === "Sprinklers Northwest";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed data for SNW Install status
|
||||||
|
const snwInstallData = computed(() => {
|
||||||
|
if (!props.selectedAddress) {
|
||||||
|
return {
|
||||||
|
onsiteMeetingStatus: "Not Started",
|
||||||
|
estimateSentStatus: "Not Started",
|
||||||
|
jobStatus: "Not Started",
|
||||||
|
paymentStatus: "Not Started",
|
||||||
|
onsiteMeeting: "",
|
||||||
|
estimate: "",
|
||||||
|
job: "",
|
||||||
|
payment: "dummy-payment-string",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const addr = props.selectedAddress;
|
||||||
|
|
||||||
|
// Filter for SNW Install template
|
||||||
|
const snwBidMeeting = addr.onsiteMeetings?.find(
|
||||||
|
(m) => m.projectTemplate === "SNW Install" && m.status !== "Cancelled"
|
||||||
|
);
|
||||||
|
const snwEstimate = addr.quotations?.find(
|
||||||
|
(q) => q.projectTemplate === "SNW Install" && q.status !== "Cancelled"
|
||||||
|
);
|
||||||
|
const snwJob = addr.projects?.find(
|
||||||
|
(p) => p.projectTemplate === "SNW Install" && p.status !== "Cancelled"
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onsiteMeetingStatus: addr.onsiteMeetingScheduled || "Not Started",
|
||||||
|
estimateSentStatus: addr.estimateSentStatus || "Not Started",
|
||||||
|
jobStatus: addr.jobStatus || "Not Started",
|
||||||
|
paymentStatus: addr.paymentReceivedStatus || "Not Started",
|
||||||
|
onsiteMeeting: snwBidMeeting?.onsiteMeeting || "",
|
||||||
|
estimate: snwEstimate?.quotation || "",
|
||||||
|
job: snwJob?.project || "",
|
||||||
|
payment: "dummy-payment-string",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.special-modules {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -59,6 +59,7 @@ const props = defineProps({
|
||||||
const mapElement = ref(null);
|
const mapElement = ref(null);
|
||||||
let map = null;
|
let map = null;
|
||||||
let marker = null;
|
let marker = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
|
||||||
const initializeMap = async () => {
|
const initializeMap = async () => {
|
||||||
if (!mapElement.value) return;
|
if (!mapElement.value) return;
|
||||||
|
|
@ -75,8 +76,12 @@ const initializeMap = async () => {
|
||||||
|
|
||||||
// Only create map if we have valid coordinates
|
// Only create map if we have valid coordinates
|
||||||
if (!isNaN(lat) && !isNaN(lng)) {
|
if (!isNaN(lat) && !isNaN(lng)) {
|
||||||
|
// Wait for next tick to ensure DOM is updated
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
|
// Additional delay to ensure container has proper dimensions
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
map = L.map(mapElement.value, {
|
map = L.map(mapElement.value, {
|
||||||
zoomControl: props.interactive,
|
zoomControl: props.interactive,
|
||||||
|
|
@ -106,6 +111,28 @@ const initializeMap = async () => {
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.openPopup();
|
.openPopup();
|
||||||
|
|
||||||
|
// Ensure map renders correctly - call invalidateSize multiple times
|
||||||
|
const invalidateMap = () => {
|
||||||
|
if (map) {
|
||||||
|
map.invalidateSize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(invalidateMap, 100);
|
||||||
|
setTimeout(invalidateMap, 300);
|
||||||
|
setTimeout(invalidateMap, 500);
|
||||||
|
|
||||||
|
// Set up resize observer to handle container size changes
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (map) {
|
||||||
|
map.invalidateSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(mapElement.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -117,6 +144,16 @@ const updateMap = () => {
|
||||||
// Update map view
|
// Update map view
|
||||||
map.setView([lat, lng], props.zoomLevel);
|
map.setView([lat, lng], props.zoomLevel);
|
||||||
|
|
||||||
|
// Ensure map renders correctly after view change
|
||||||
|
const invalidateMap = () => {
|
||||||
|
if (map) {
|
||||||
|
map.invalidateSize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(invalidateMap, 100);
|
||||||
|
setTimeout(invalidateMap, 300);
|
||||||
|
|
||||||
// Update marker
|
// Update marker
|
||||||
if (marker) {
|
if (marker) {
|
||||||
marker.setLatLng([lat, lng]);
|
marker.setLatLng([lat, lng]);
|
||||||
|
|
@ -164,6 +201,10 @@ onUnmounted(() => {
|
||||||
map = null;
|
map = null;
|
||||||
marker = null;
|
marker = null;
|
||||||
}
|
}
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -178,6 +219,7 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.map {
|
.map {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -573,7 +573,7 @@ const loadDoctypeOptions = async () => {
|
||||||
for (const field of fieldsWithDoctype) {
|
for (const field of fieldsWithDoctype) {
|
||||||
try {
|
try {
|
||||||
// Use the new API method for fetching docs
|
// Use the new API method for fetching docs
|
||||||
let docs = await Api.getEstimateItems();
|
let docs = await Api.getQuotationItems();
|
||||||
|
|
||||||
// Deduplicate by value field
|
// Deduplicate by value field
|
||||||
const valueField = field.doctypeValueField || 'name';
|
const valueField = field.doctypeValueField || 'name';
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="bidNote" class="notes-content">
|
<div v-else-if="bidNote" class="notes-content">
|
||||||
<!-- Header Information -->
|
|
||||||
<div class="notes-header">
|
|
||||||
<div class="header-info">
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Meeting:</span>
|
|
||||||
<span class="value">{{ bidNote.bidMeeting }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item" v-if="bidNote.formTemplate">
|
|
||||||
<span class="label">Template:</span>
|
|
||||||
<span class="value">{{ bidNote.formTemplate }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Created By:</span>
|
|
||||||
<span class="value">{{ bidNote.owner }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Last Modified:</span>
|
|
||||||
<span class="value">{{ formatDate(bidNote.modified) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- General Notes (if exists) -->
|
<!-- General Notes (if exists) -->
|
||||||
<div v-if="bidNote.notes" class="general-notes">
|
<div v-if="bidNote.notes" class="general-notes">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
|
|
@ -107,6 +85,20 @@
|
||||||
<i class="pi pi-inbox"></i>
|
<i class="pi pi-inbox"></i>
|
||||||
<p>No fields to display</p>
|
<p>No fields to display</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Header Information (moved to bottom) -->
|
||||||
|
<div class="notes-header">
|
||||||
|
<div class="header-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">Created By:</span>
|
||||||
|
<span class="value">{{ bidNote.owner }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">Submitted on:</span>
|
||||||
|
<span class="value">{{ formatDate(bidNote.creation) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -216,46 +208,83 @@ const getItemLabel = (field, item) => {
|
||||||
return item.item || 'Unknown Item';
|
return item.item || 'Unknown Item';
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDoctypeData = async (field, itemId) => {
|
|
||||||
if (!field.valueDoctype || !itemId) return null;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cacheKey = `${field.valueDoctype}:${itemId}`;
|
|
||||||
if (doctypeCache.value[cacheKey]) {
|
|
||||||
return doctypeCache.value[cacheKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the getDetailedDoc method from the API
|
|
||||||
const data = await Api.getDocsList(field.valueDoctype, ["*"], { "item_group": "SWN-S" });
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
doctypeCache.value[cacheKey] = data;
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching doctype ${field.valueDoctype}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadDoctypeLabels = async () => {
|
const loadDoctypeLabels = async () => {
|
||||||
if (!props.bidNote?.fields) return;
|
if (!props.bidNote?.fields) return;
|
||||||
|
|
||||||
|
// Check if there are quantities to fetch
|
||||||
|
if (!props.bidNote.quantities || props.bidNote.quantities.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Find all Multi-Select w/ Quantity fields that have valueDoctype
|
// Find all Multi-Select w/ Quantity fields that have valueDoctype
|
||||||
const quantityFields = props.bidNote.fields.filter(
|
const quantityFields = props.bidNote.fields.filter(
|
||||||
f => f.type === 'Multi-Select w/ Quantity' && f.valueDoctype
|
f => f.type === 'Multi-Select w/ Quantity' && f.valueDoctype
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (quantityFields.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each field type (valueDoctype), collect all item IDs and fetch them in batch
|
||||||
for (const field of quantityFields) {
|
for (const field of quantityFields) {
|
||||||
const items = getParsedMultiSelectQty(field.value);
|
const items = getParsedMultiSelectQty(field.value);
|
||||||
for (const item of items) {
|
if (items.length === 0) continue;
|
||||||
if (item.item && !item.fetchedLabel) {
|
|
||||||
const data = await fetchDoctypeData(field, item.item);
|
// Collect all item IDs for this field
|
||||||
if (data && field.doctypeLabelField) {
|
const itemIds = items.map(item => item.item).filter(Boolean);
|
||||||
// Add the fetched label to the item
|
if (itemIds.length === 0) continue;
|
||||||
item.fetchedLabel = data[field.doctypeLabelField] || item.item;
|
|
||||||
|
// Check which items are not already cached
|
||||||
|
const uncachedItemIds = itemIds.filter(itemId => {
|
||||||
|
const cacheKey = `${field.valueDoctype}:${itemId}`;
|
||||||
|
return !doctypeCache.value[cacheKey];
|
||||||
|
});
|
||||||
|
|
||||||
|
// If all items are cached, skip the API call
|
||||||
|
if (uncachedItemIds.length === 0) {
|
||||||
|
// Just map the cached data to the items
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.item) {
|
||||||
|
const cacheKey = `${field.valueDoctype}:${item.item}`;
|
||||||
|
const cachedData = doctypeCache.value[cacheKey];
|
||||||
|
if (cachedData && field.doctypeLabelField) {
|
||||||
|
item.fetchedLabel = cachedData[field.doctypeLabelField] || item.item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build filter to fetch all uncached items at once
|
||||||
|
const filters = {
|
||||||
|
name: ['in', uncachedItemIds]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch all items in one API call
|
||||||
|
const fetchedItems = await Api.getDocsList(field.valueDoctype, ["*"], filters);
|
||||||
|
|
||||||
|
// Cache the fetched items
|
||||||
|
if (Array.isArray(fetchedItems)) {
|
||||||
|
fetchedItems.forEach(docData => {
|
||||||
|
const cacheKey = `${field.valueDoctype}:${docData.name}`;
|
||||||
|
doctypeCache.value[cacheKey] = docData;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now map the labels to all items (including previously cached ones)
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.item) {
|
||||||
|
const cacheKey = `${field.valueDoctype}:${item.item}`;
|
||||||
|
const cachedData = doctypeCache.value[cacheKey];
|
||||||
|
if (cachedData && field.doctypeLabelField) {
|
||||||
|
item.fetchedLabel = cachedData[field.doctypeLabelField] || item.item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching doctype ${field.valueDoctype}:`, error);
|
||||||
|
// On error, items will just show their IDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -321,32 +350,37 @@ onMounted(async () => {
|
||||||
.notes-header {
|
.notes-header {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 20px;
|
padding: 12px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-info {
|
.header-info {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item .label {
|
.info-item .label {
|
||||||
font-size: 0.85em;
|
font-size: 0.75em;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item .value {
|
.info-item .value {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.general-notes {
|
.general-notes {
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="meeting.status !== 'Completed' && meeting.status !== 'Unscheduled'"
|
v-if="meeting.status === 'Scheduled'"
|
||||||
@click="handleMarkComplete"
|
@click="handleMarkComplete"
|
||||||
color="success"
|
color="success"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
|
|
@ -160,6 +160,16 @@
|
||||||
<v-icon left>mdi-file-document-outline</v-icon>
|
<v-icon left>mdi-file-document-outline</v-icon>
|
||||||
Create Estimate
|
Create Estimate
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="meeting.status !== 'Completed'"
|
||||||
|
@click="showCancelWarning = true"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<v-icon left>mdi-cancel</v-icon>
|
||||||
|
Cancel Meeting
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -187,6 +197,53 @@
|
||||||
<span>Loading bid notes...</span>
|
<span>Loading bid notes...</span>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Cancel Meeting Warning Dialog -->
|
||||||
|
<v-dialog v-model="showCancelWarning" max-width="500px">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h5 text-error">
|
||||||
|
<v-icon color="error" class="mr-2">mdi-alert</v-icon>
|
||||||
|
Cancel Bid Meeting?
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="pt-4">
|
||||||
|
<p class="text-body-1 mb-3">
|
||||||
|
<strong>Warning:</strong> This will permanently cancel this bid meeting.
|
||||||
|
</p>
|
||||||
|
<template v-if="meeting?.status === 'Scheduled'">
|
||||||
|
<p class="text-body-2 mb-3">
|
||||||
|
If you want to:
|
||||||
|
</p>
|
||||||
|
<ul class="text-body-2 mb-3">
|
||||||
|
<li><strong>Reschedule:</strong> Drag and drop the meeting to a different time slot</li>
|
||||||
|
<li><strong>Unschedule:</strong> Drag the meeting back to the unscheduled section</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-body-2 mb-2">
|
||||||
|
<strong>Note:</strong> Cancelling permanently marks the meeting as cancelled, which is different from rescheduling or unscheduling it.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p class="text-body-1 font-weight-bold">
|
||||||
|
Are you sure you want to proceed with canceling this meeting?
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="showCancelWarning = false"
|
||||||
|
>
|
||||||
|
No, Keep Meeting
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="elevated"
|
||||||
|
@click="handleCancelMeeting"
|
||||||
|
:loading="isCanceling"
|
||||||
|
>
|
||||||
|
Yes, Cancel Meeting
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -222,6 +279,8 @@ const showBidNotesModal = ref(false);
|
||||||
const bidNoteData = ref(null);
|
const bidNoteData = ref(null);
|
||||||
const loadingBidNotes = ref(false);
|
const loadingBidNotes = ref(false);
|
||||||
const bidNotesError = ref(null);
|
const bidNotesError = ref(null);
|
||||||
|
const showCancelWarning = ref(false);
|
||||||
|
const isCanceling = ref(false);
|
||||||
|
|
||||||
const showModal = computed({
|
const showModal = computed({
|
||||||
get() {
|
get() {
|
||||||
|
|
@ -408,6 +467,44 @@ const handleCloseBidNotes = () => {
|
||||||
bidNoteData.value = null;
|
bidNoteData.value = null;
|
||||||
bidNotesError.value = null;
|
bidNotesError.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelMeeting = async () => {
|
||||||
|
if (!props.meeting?.name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isCanceling.value = true;
|
||||||
|
|
||||||
|
// Update the meeting status to Cancelled
|
||||||
|
await Api.updateBidMeeting(props.meeting.name, {
|
||||||
|
status: "Cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
|
showCancelWarning.value = false;
|
||||||
|
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Meeting Cancelled",
|
||||||
|
message: "The bid meeting has been cancelled successfully.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit meeting updated event to refresh the calendar
|
||||||
|
emit("meetingUpdated");
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
handleClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error canceling meeting:", error);
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to cancel meeting. Please try again.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isCanceling.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,79 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="client-page">
|
<div class="client-page">
|
||||||
<!-- Client Header -->
|
<!-- Client Header -->
|
||||||
<TopBar :selectedAddressIdx="selectedAddressIdx" :client="client" :nextVisitDate="nextVisitDate" v-if="client.customerName" @update:selectedAddressIdx="selectedAddressIdx = $event" />
|
<GeneralClientInfo
|
||||||
|
v-if="client.customerName"
|
||||||
|
:client-data="client"
|
||||||
|
/>
|
||||||
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
|
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
|
||||||
|
|
||||||
<Tabs value="0">
|
<!-- Address Selector (only shows if multiple addresses) -->
|
||||||
|
<AddressSelector
|
||||||
|
v-if="!isNew && client.addresses && client.addresses.length > 1"
|
||||||
|
:addresses="client.addresses"
|
||||||
|
:selected-address-idx="selectedAddressIdx"
|
||||||
|
:contacts="client.contacts"
|
||||||
|
@update:selected-address-idx="handleAddressChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main Content Tabs -->
|
||||||
|
<Tabs value="0" class="overview-tabs">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="0">Overview</Tab>
|
<Tab value="0">Overview</Tab>
|
||||||
<Tab value="1">Projects <span class="tab-info-alert">1</span></Tab>
|
<Tab value="1">Projects</Tab>
|
||||||
<Tab value="2">Financials</Tab>
|
<Tab value="2">Financials</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
|
<!-- Overview Tab -->
|
||||||
<TabPanel value="0">
|
<TabPanel value="0">
|
||||||
<Overview
|
<Overview
|
||||||
:client-data="client"
|
:selected-address="selectedAddressData"
|
||||||
:selected-address="selectedAddress"
|
:all-contacts="client.contacts"
|
||||||
|
:edit-mode="editMode"
|
||||||
:is-new="isNew"
|
:is-new="isNew"
|
||||||
|
:full-address="fullAddress"
|
||||||
|
:client="client"
|
||||||
|
@edit-mode-enabled="enableEditMode"
|
||||||
|
@update:address-contacts="handleAddressContactsUpdate"
|
||||||
|
@update:primary-contact="handlePrimaryContactUpdate"
|
||||||
|
@update:client="handleClientUpdate"
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Projects Tab -->
|
||||||
<TabPanel value="1">
|
<TabPanel value="1">
|
||||||
<div id="projects-tab"><h3>Project Status</h3></div>
|
<div class="coming-soon-section">
|
||||||
|
<i class="pi pi-wrench"></i>
|
||||||
|
<h3>Projects</h3>
|
||||||
|
<p>Section coming soon</p>
|
||||||
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Financials Tab -->
|
||||||
<TabPanel value="2">
|
<TabPanel value="2">
|
||||||
<div id="financials-tab"><h3>Accounting</h3></div>
|
<div class="coming-soon-section">
|
||||||
|
<i class="pi pi-dollar"></i>
|
||||||
|
<h3>Financials</h3>
|
||||||
|
<p>Section coming soon</p>
|
||||||
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Form Actions (for edit mode or new client) -->
|
||||||
|
<div class="form-actions" v-if="editMode || isNew">
|
||||||
|
<Button
|
||||||
|
@click="handleCancel"
|
||||||
|
label="Cancel"
|
||||||
|
severity="secondary"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="handleSubmit"
|
||||||
|
:label="isNew ? 'Create Client' : 'Save Changes'"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -35,19 +83,23 @@ import TabList from "primevue/tablist";
|
||||||
import Tab from "primevue/tab";
|
import Tab from "primevue/tab";
|
||||||
import TabPanels from "primevue/tabpanels";
|
import TabPanels from "primevue/tabpanels";
|
||||||
import TabPanel from "primevue/tabpanel";
|
import TabPanel from "primevue/tabpanel";
|
||||||
|
import Button from "primevue/button";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
import { useCompanyStore } from "../../stores/company";
|
||||||
import DataUtils from "../../utils";
|
import DataUtils from "../../utils";
|
||||||
import Overview from "../clientSubPages/Overview.vue";
|
import AddressSelector from "../clientView/AddressSelector.vue";
|
||||||
import ProjectStatus from "../clientSubPages/ProjectStatus.vue";
|
import GeneralClientInfo from "../clientView/GeneralClientInfo.vue";
|
||||||
import TopBar from "../clientView/TopBar.vue";
|
|
||||||
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
|
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
|
||||||
|
import Overview from "../clientView/Overview.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
const companyStore = useCompanyStore();
|
||||||
|
|
||||||
const address = route.query.address || null;
|
const address = route.query.address || null;
|
||||||
const clientName = route.query.client || null;
|
const clientName = route.query.client || null;
|
||||||
|
|
@ -73,6 +125,10 @@ const addresses = computed(() => {
|
||||||
|
|
||||||
const nextVisitDate = ref(null); // Placeholder, update as needed
|
const nextVisitDate = ref(null); // Placeholder, update as needed
|
||||||
|
|
||||||
|
// Tab and edit state
|
||||||
|
const editMode = ref(false);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
const selectedAddressIdx = computed({
|
const selectedAddressIdx = computed({
|
||||||
get: () => addresses.value.indexOf(selectedAddress.value),
|
get: () => addresses.value.indexOf(selectedAddress.value),
|
||||||
set: (idx) => {
|
set: (idx) => {
|
||||||
|
|
@ -82,6 +138,22 @@ const selectedAddressIdx = computed({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Find the address data object that matches the selected address string
|
||||||
|
const selectedAddressData = computed(() => {
|
||||||
|
if (!client.value?.addresses || !selectedAddress.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return client.value.addresses.find(
|
||||||
|
(addr) => DataUtils.calculateFullAddress(addr) === selectedAddress.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate full address for display
|
||||||
|
const fullAddress = computed(() => {
|
||||||
|
if (!selectedAddressData.value) return "N/A";
|
||||||
|
return DataUtils.calculateFullAddress(selectedAddressData.value);
|
||||||
|
});
|
||||||
|
|
||||||
const getClientNames = async (type) => {
|
const getClientNames = async (type) => {
|
||||||
loadingStore.setLoading(true);
|
loadingStore.setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -173,6 +245,88 @@ watch(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => companyStore.currentCompany,
|
||||||
|
(newCompany) => {
|
||||||
|
console.log("############# Company changed to:", newCompany);
|
||||||
|
let companyIsPresent = false
|
||||||
|
for (company of selectedAddressData.value.companies || []) {
|
||||||
|
console.log("Checking address company:", company);
|
||||||
|
if (company.company === newCompany) {
|
||||||
|
companyIsPresent = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!companyIsPresent) {
|
||||||
|
notificationStore.addWarning(
|
||||||
|
`The selected company is not linked to this address.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle address change
|
||||||
|
const handleAddressChange = (newIdx) => {
|
||||||
|
selectedAddressIdx.value = newIdx;
|
||||||
|
// TODO: Update route query with new address
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable edit mode
|
||||||
|
const enableEditMode = () => {
|
||||||
|
editMode.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancel edit or new
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isNew.value) {
|
||||||
|
// For new client, clear the form data
|
||||||
|
client.value = {};
|
||||||
|
} else {
|
||||||
|
editMode.value = false;
|
||||||
|
// Restore original data if editing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle save edit or create new
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
if (isNew.value) {
|
||||||
|
const createdClient = await Api.createClient(client.value);
|
||||||
|
console.log("Created client:", createdClient);
|
||||||
|
notificationStore.addSuccess("Client created successfully!");
|
||||||
|
// Navigate to the created client
|
||||||
|
window.location.hash = '#/client?client=' + encodeURIComponent(createdClient.name || createdClient.customerName);
|
||||||
|
} else {
|
||||||
|
// TODO: Implement save logic
|
||||||
|
notificationStore.addSuccess("Changes saved successfully!");
|
||||||
|
editMode.value = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting:", error);
|
||||||
|
notificationStore.addError(isNew.value ? "Failed to create client" : "Failed to save changes");
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle address contacts update
|
||||||
|
const handleAddressContactsUpdate = (contactNames) => {
|
||||||
|
console.log("Address contacts updated:", contactNames);
|
||||||
|
// TODO: Store this for saving
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle primary contact update
|
||||||
|
const handlePrimaryContactUpdate = (contactName) => {
|
||||||
|
console.log("Primary contact updated:", contactName);
|
||||||
|
// TODO: Store this for saving
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle client update from forms
|
||||||
|
const handleClientUpdate = (newClientData) => {
|
||||||
|
client.value = { ...client.value, ...newClientData };
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
.tab-info-alert {
|
.tab-info-alert {
|
||||||
|
|
@ -184,4 +338,63 @@ watch(
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-tabs {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-section i {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon-section p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-page {
|
||||||
|
padding-bottom: 5rem; /* Add padding to prevent content from being hidden behind fixed buttons */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,34 @@
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Bid Meeting Notes Side Tab -->
|
||||||
|
<Button v-if="bidMeeting?.bidNotes" class="bid-notes-side-tab" @click="onTabClick">
|
||||||
|
<div class="tab-content">
|
||||||
|
<i class="pi pi-file-edit"></i>
|
||||||
|
<span class="tab-text">Bid Notes</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Bid Meeting Notes Drawer -->
|
||||||
|
<Drawer
|
||||||
|
:visible="showDrawer"
|
||||||
|
@update:visible="showDrawer = $event"
|
||||||
|
position="right"
|
||||||
|
:style="{ width: '1200px' }"
|
||||||
|
@hide="showDrawer = false"
|
||||||
|
class="bid-notes-drawer"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="drawer-header">
|
||||||
|
<h3>Bid Meeting Notes</h3>
|
||||||
|
<Button icon="pi pi-times" @click="showDrawer = false" text rounded />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="drawer-content">
|
||||||
|
<BidMeetingNotes v-if="bidMeeting?.bidNotes" :bid-note="bidMeeting.bidNotes" />
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -391,11 +419,13 @@ import Modal from "../common/Modal.vue";
|
||||||
import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
|
import SaveTemplateModal from "../modals/SaveTemplateModal.vue";
|
||||||
import DataTable from "../common/DataTable.vue";
|
import DataTable from "../common/DataTable.vue";
|
||||||
import DocHistory from "../common/DocHistory.vue";
|
import DocHistory from "../common/DocHistory.vue";
|
||||||
|
import BidMeetingNotes from "../modals/BidMeetingNotes.vue";
|
||||||
import InputText from "primevue/inputtext";
|
import InputText from "primevue/inputtext";
|
||||||
import InputNumber from "primevue/inputnumber";
|
import InputNumber from "primevue/inputnumber";
|
||||||
import Button from "primevue/button";
|
import Button from "primevue/button";
|
||||||
import Select from "primevue/select";
|
import Select from "primevue/select";
|
||||||
import Tooltip from "primevue/tooltip";
|
import Tooltip from "primevue/tooltip";
|
||||||
|
import Drawer from "primevue/drawer";
|
||||||
import Api from "../../api";
|
import Api from "../../api";
|
||||||
import DataUtils from "../../utils";
|
import DataUtils from "../../utils";
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
import { useLoadingStore } from "../../stores/loading";
|
||||||
|
|
@ -452,8 +482,10 @@ const showResponseModal = ref(false);
|
||||||
const showSaveTemplateModal = ref(false);
|
const showSaveTemplateModal = ref(false);
|
||||||
const addressSearchResults = ref([]);
|
const addressSearchResults = ref([]);
|
||||||
const itemSearchTerm = ref("");
|
const itemSearchTerm = ref("");
|
||||||
|
const showDrawer = ref(false);
|
||||||
|
|
||||||
const estimate = ref(null);
|
const estimate = ref(null);
|
||||||
|
const bidMeeting = ref(null);
|
||||||
|
|
||||||
// Computed property to determine if fields are editable
|
// Computed property to determine if fields are editable
|
||||||
const isEditable = computed(() => {
|
const isEditable = computed(() => {
|
||||||
|
|
@ -802,6 +834,15 @@ const toggleDiscountType = (item, type) => {
|
||||||
item.discountType = type;
|
item.discountType = type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onTabClick = () => {
|
||||||
|
console.log('Bid notes tab clicked');
|
||||||
|
console.log('Current showDrawer value:', showDrawer.value);
|
||||||
|
console.log('bidMeeting:', bidMeeting.value);
|
||||||
|
console.log('bidMeeting?.bidNotes:', bidMeeting.value?.bidNotes);
|
||||||
|
showDrawer.value = true;
|
||||||
|
console.log('Set showDrawer to true');
|
||||||
|
};
|
||||||
|
|
||||||
const tableActions = [
|
const tableActions = [
|
||||||
{
|
{
|
||||||
label: "Add Selected Items",
|
label: "Add Selected Items",
|
||||||
|
|
@ -955,6 +996,12 @@ onMounted(async () => {
|
||||||
// Handle from-meeting query parameter
|
// Handle from-meeting query parameter
|
||||||
if (fromMeetingQuery.value) {
|
if (fromMeetingQuery.value) {
|
||||||
formData.fromMeeting = fromMeetingQuery.value;
|
formData.fromMeeting = fromMeetingQuery.value;
|
||||||
|
// Fetch the bid meeting to check for bidNotes
|
||||||
|
try {
|
||||||
|
bidMeeting.value = await Api.getBidMeeting(fromMeetingQuery.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bid meeting:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1034,6 +1081,46 @@ onMounted(async () => {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bid-notes-side-tab {
|
||||||
|
position: fixed;
|
||||||
|
right: -90px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background-color: #2196f3;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 8px;
|
||||||
|
width: 110px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: upright;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-notes-side-tab:hover {
|
||||||
|
right: -80px;
|
||||||
|
background-color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content i {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.address-section,
|
.address-section,
|
||||||
.contact-section,
|
.contact-section,
|
||||||
.project-template-section,
|
.project-template-section,
|
||||||
|
|
@ -1365,5 +1452,29 @@ onMounted(async () => {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bid-notes-drawer {
|
||||||
|
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-content {
|
||||||
|
padding: 1rem;
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<parameter name="filePath"></parameter>
|
<parameter name="filePath"></parameter>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue