lots of updates
This commit is contained in:
parent
02c48e6108
commit
8ed083fce1
14 changed files with 730 additions and 83 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import frappe, json
|
import frappe, json
|
||||||
from frappe.utils.pdf import get_pdf
|
from frappe.utils.pdf import get_pdf
|
||||||
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
# ===============================================================================
|
# ===============================================================================
|
||||||
# ESTIMATES & INVOICES API METHODS
|
# ESTIMATES & INVOICES API METHODS
|
||||||
|
|
@ -152,7 +153,6 @@ def send_estimate_email(estimate_name):
|
||||||
quotation.custom_sent = 1
|
quotation.custom_sent = 1
|
||||||
quotation.save()
|
quotation.save()
|
||||||
updated_quotation = frappe.get_doc("Quotation", estimate_name)
|
updated_quotation = frappe.get_doc("Quotation", estimate_name)
|
||||||
print("DEBUG: Quotation submitted successfully.")
|
|
||||||
return build_success_response(updated_quotation.as_dict())
|
return build_success_response(updated_quotation.as_dict())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"DEBUG: Error in send_estimate_email: {str(e)}")
|
print(f"DEBUG: Error in send_estimate_email: {str(e)}")
|
||||||
|
|
@ -161,16 +161,35 @@ def send_estimate_email(estimate_name):
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def update_response(name, response):
|
def update_response(name, response):
|
||||||
"""Update the response for a given estimate."""
|
"""Update the response for a given estimate."""
|
||||||
estimate = frappe.get_doc("Quotation", name)
|
print("DEBUG: RESPONSE RECEIVED:", name, response)
|
||||||
accepted = True if response == "Accepted" else False
|
try:
|
||||||
new_status = "Estimate Accepted" if accepted else "Lost"
|
if not frappe.db.exists("Quotation", name):
|
||||||
|
raise Exception("Estimate not found.")
|
||||||
|
estimate = frappe.get_doc("Quotation", name)
|
||||||
|
accepted = True if response == "Accepted" else False
|
||||||
|
new_status = "Estimate Accepted" if accepted else "Lost"
|
||||||
|
|
||||||
estimate.custom_response = response
|
estimate.custom_response = response
|
||||||
estimate.custom_current_status = new_status
|
estimate.custom_current_status = new_status
|
||||||
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
|
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
|
||||||
estimate.flags.ignore_permissions = True
|
estimate.flags.ignore_permissions = True
|
||||||
estimate.save()
|
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
|
||||||
frappe.db.commit()
|
# estimate.save()
|
||||||
|
estimate.submit()
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
if accepted:
|
||||||
|
template = "custom_ui/templates/estimates/accepted.html"
|
||||||
|
elif response == "Requested call":
|
||||||
|
template = "custom_ui/templates/estimates/request-call.html"
|
||||||
|
else:
|
||||||
|
template = "custom_ui/templates/estimates/rejected.html"
|
||||||
|
html = frappe.render_template(template, {"doc": estimate})
|
||||||
|
return Response(html, mimetype="text/html")
|
||||||
|
except Exception as e:
|
||||||
|
template = "custom_ui/templates/estimates/error.html"
|
||||||
|
html = frappe.render_template(template, {"error_message": str(e)})
|
||||||
|
return Response(html, mimetype="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -212,6 +231,7 @@ def upsert_estimate(data):
|
||||||
print("DEBUG: Retrieved address name:", data.get("address_name"))
|
print("DEBUG: Retrieved address name:", data.get("address_name"))
|
||||||
new_estimate = frappe.get_doc({
|
new_estimate = frappe.get_doc({
|
||||||
"doctype": "Quotation",
|
"doctype": "Quotation",
|
||||||
|
"custom_requires_half_payment": data.get("requires_half_payment", 0),
|
||||||
"custom_installation_address": data.get("address_name"),
|
"custom_installation_address": data.get("address_name"),
|
||||||
"custom_current_status": "Draft",
|
"custom_current_status": "Draft",
|
||||||
"contact_email": data.get("contact_email"),
|
"contact_email": data.get("contact_email"),
|
||||||
|
|
|
||||||
|
|
@ -118,9 +118,16 @@ def update_onsite_meeting(name, data):
|
||||||
try:
|
try:
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
data = json.loads(data)
|
data = json.loads(data)
|
||||||
data = {**defualts, **data}
|
|
||||||
|
# Ensure we always have the expected keys so fields can be cleared
|
||||||
|
data = {**defualts, **(data or {})}
|
||||||
meeting = frappe.get_doc("On-Site Meeting", name)
|
meeting = frappe.get_doc("On-Site Meeting", name)
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
|
# Allow explicitly clearing date/time and assignment fields
|
||||||
|
if key in ["start_time", "end_time", "assigned_employee", "completed_by"] and value is None:
|
||||||
|
meeting.set(key, None)
|
||||||
|
continue
|
||||||
|
|
||||||
if value is not None:
|
if value is not None:
|
||||||
if key == "address":
|
if key == "address":
|
||||||
value = frappe.db.get_value("Address", {"full_address": value}, "name")
|
value = frappe.db.get_value("Address", {"full_address": value}, "name")
|
||||||
|
|
@ -128,6 +135,7 @@ def update_onsite_meeting(name, data):
|
||||||
value = frappe.db.get_value("Employee", {"employee_name": value}, "name")
|
value = frappe.db.get_value("Employee", {"employee_name": value}, "name")
|
||||||
meeting.set(key, value)
|
meeting.set(key, value)
|
||||||
meeting.save()
|
meeting.save()
|
||||||
|
frappe.db.commit()
|
||||||
return build_success_response(meeting.as_dict())
|
return build_success_response(meeting.as_dict())
|
||||||
except frappe.DoesNotExistError:
|
except frappe.DoesNotExistError:
|
||||||
return build_error_response(f"On-Site Meeting '{name}' does not exist.", 404)
|
return build_error_response(f"On-Site Meeting '{name}' does not exist.", 404)
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,25 @@ def after_insert(doc, method):
|
||||||
frappe.log_error(f"Error in estimate after_insert: {str(e)}", "Estimate Hook Error")
|
frappe.log_error(f"Error in estimate after_insert: {str(e)}", "Estimate Hook Error")
|
||||||
|
|
||||||
def after_save(doc, method):
|
def after_save(doc, method):
|
||||||
if not doc.custom_sent or not doc.custom_response:
|
print("DEBUG: after_save hook triggered for Quotation:", doc.name)
|
||||||
return
|
if doc.custom_sent and doc.custom_response:
|
||||||
print("DEBUG: Quotation has been sent, updating Address status")
|
print("DEBUG: Quotation has been sent, updating Address status")
|
||||||
address_doc = frappe.get_doc("Address", doc.custom_installation_address)
|
address_doc = frappe.get_doc("Address", doc.custom_installation_address)
|
||||||
address_doc.custom_estimate_sent_status = "Completed"
|
address_doc.custom_estimate_sent_status = "Completed"
|
||||||
address_doc.save()
|
address_doc.save()
|
||||||
|
|
||||||
|
def after_submit(doc, method):
|
||||||
|
print("DEBUG: on_submit hook triggered for Quotation:", doc.name)
|
||||||
|
if doc.custom_current_status == "Estimate Accepted":
|
||||||
|
print("DEBUG: Creating Sales Order from accepted Estimate")
|
||||||
|
address_doc = frappe.get_doc("Address", doc.custom_installation_address)
|
||||||
|
address_doc.custom_estimate_sent_status = "Completed"
|
||||||
|
address_doc.save()
|
||||||
|
try:
|
||||||
|
new_sales_order = make_sales_order(doc.name)
|
||||||
|
new_sales_order.custom_requires_half_payment = doc.requires_half_payment
|
||||||
|
new_sales_order.insert()
|
||||||
|
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
|
||||||
|
except Exception as e:
|
||||||
|
print("ERROR creating Sales Order from Estimate:", str(e))
|
||||||
|
frappe.log_error(f"Error creating Sales Order from Estimate {doc.name}: {str(e)}", "Estimate on_submit Error")
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,7 @@ def after_save(doc, method):
|
||||||
address_doc = frappe.get_doc("Address", doc.address)
|
address_doc = frappe.get_doc("Address", doc.address)
|
||||||
address_doc.custom_onsite_meeting_scheduled = "Completed"
|
address_doc.custom_onsite_meeting_scheduled = "Completed"
|
||||||
address_doc.save()
|
address_doc.save()
|
||||||
|
return
|
||||||
|
if doc.status != "Scheduled" and doc.start_time and doc.end_time:
|
||||||
|
doc.status = "Scheduled"
|
||||||
|
doc.save()
|
||||||
6
custom_ui/events/sales_order.py
Normal file
6
custom_ui/events/sales_order.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
def after_insert(doc, method):
|
||||||
|
print(doc.as_dict())
|
||||||
|
# Create Invoice and Project from Sales Order
|
||||||
|
|
||||||
|
|
@ -161,14 +161,18 @@ add_to_apps_screen = [
|
||||||
doc_events = {
|
doc_events = {
|
||||||
"On-Site Meeting": {
|
"On-Site Meeting": {
|
||||||
"after_insert": "custom_ui.events.onsite_meeting.after_insert",
|
"after_insert": "custom_ui.events.onsite_meeting.after_insert",
|
||||||
"after_save": "custom_ui.events.onsite_meeting.after_save"
|
"on_update": "custom_ui.events.onsite_meeting.after_save"
|
||||||
},
|
},
|
||||||
"Address": {
|
"Address": {
|
||||||
"after_insert": "custom_ui.events.address.after_insert"
|
"after_insert": "custom_ui.events.address.after_insert"
|
||||||
},
|
},
|
||||||
"Quotation": {
|
"Quotation": {
|
||||||
"after_insert": "custom_ui.events.estimate.after_insert",
|
"after_insert": "custom_ui.events.estimate.after_insert",
|
||||||
"after_save": "custom_ui.events.estimate.after_save"
|
"on_update": "custom_ui.events.estimate.after_save",
|
||||||
|
"after_submit": "custom_ui.events.estimate.after_submit"
|
||||||
|
},
|
||||||
|
"Sales Order": {
|
||||||
|
"after_insert": "custom_ui.events.sales_order.after_insert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,15 @@ def add_custom_fields():
|
||||||
default=0,
|
default=0,
|
||||||
insert_after="custom_installation_address"
|
insert_after="custom_installation_address"
|
||||||
)
|
)
|
||||||
|
],
|
||||||
|
"Sales Order": [
|
||||||
|
dict(
|
||||||
|
fieldname="requires_half_payment",
|
||||||
|
label="Requires Half Payment",
|
||||||
|
fieldtype="Check",
|
||||||
|
default=0,
|
||||||
|
insert_after="custom_installation_address"
|
||||||
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,19 +248,47 @@ def update_address_fields():
|
||||||
|
|
||||||
print(f"\n📍 Updating fields for {total_addresses} addresses...")
|
print(f"\n📍 Updating fields for {total_addresses} addresses...")
|
||||||
|
|
||||||
# Verify custom fields exist by checking the meta
|
# Verify custom fields exist by checking the meta for every doctype that was customized
|
||||||
address_meta = frappe.get_meta("Address")
|
def has_any_field(meta, candidates):
|
||||||
required_fields = ['full_address', 'custom_onsite_meeting_scheduled',
|
return any(meta.has_field(f) for f in candidates)
|
||||||
'custom_estimate_sent_status', 'custom_job_status',
|
|
||||||
'custom_payment_received_status']
|
custom_field_expectations = {
|
||||||
|
"Address": [
|
||||||
|
["full_address"],
|
||||||
|
["custom_onsite_meeting_scheduled", "onsite_meeting_scheduled"],
|
||||||
|
["custom_estimate_sent_status", "estimate_sent_status"],
|
||||||
|
["custom_job_status", "job_status"],
|
||||||
|
["custom_payment_received_status", "payment_received_status"]
|
||||||
|
],
|
||||||
|
"Contact": [
|
||||||
|
["custom_role", "role"],
|
||||||
|
["custom_email", "email"]
|
||||||
|
],
|
||||||
|
"On-Site Meeting": [
|
||||||
|
["custom_notes", "notes"],
|
||||||
|
["custom_assigned_employee", "assigned_employee"],
|
||||||
|
["custom_status", "status"],
|
||||||
|
["custom_completed_by", "completed_by"]
|
||||||
|
],
|
||||||
|
"Quotation": [
|
||||||
|
["custom_requires_half_payment", "requires_half_payment"]
|
||||||
|
],
|
||||||
|
"Sales Order": [
|
||||||
|
["custom_requires_half_payment", "requires_half_payment"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
missing_fields = []
|
missing_fields = []
|
||||||
for field in required_fields:
|
for doctype, field_options in custom_field_expectations.items():
|
||||||
if not address_meta.has_field(field):
|
meta = frappe.get_meta(doctype)
|
||||||
missing_fields.append(field)
|
for candidates in field_options:
|
||||||
|
if not has_any_field(meta, candidates):
|
||||||
|
missing_fields.append(f"{doctype}: {'/'.join(candidates)}")
|
||||||
|
|
||||||
if missing_fields:
|
if missing_fields:
|
||||||
print(f"\n❌ Missing custom fields: {', '.join(missing_fields)}")
|
print("\n❌ Missing custom fields:")
|
||||||
|
for entry in missing_fields:
|
||||||
|
print(f" • {entry}")
|
||||||
print(" Custom fields creation may have failed. Skipping address updates.")
|
print(" Custom fields creation may have failed. Skipping address updates.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -260,8 +297,6 @@ def update_address_fields():
|
||||||
# Field update counters
|
# Field update counters
|
||||||
field_counters = {
|
field_counters = {
|
||||||
'full_address': 0,
|
'full_address': 0,
|
||||||
'latitude': 0,
|
|
||||||
'longitude': 0,
|
|
||||||
'custom_onsite_meeting_scheduled': 0,
|
'custom_onsite_meeting_scheduled': 0,
|
||||||
'custom_estimate_sent_status': 0,
|
'custom_estimate_sent_status': 0,
|
||||||
'custom_job_status': 0,
|
'custom_job_status': 0,
|
||||||
|
|
@ -270,6 +305,9 @@ def update_address_fields():
|
||||||
total_field_updates = 0
|
total_field_updates = 0
|
||||||
addresses_updated = 0
|
addresses_updated = 0
|
||||||
|
|
||||||
|
onsite_meta = frappe.get_meta("On-Site Meeting")
|
||||||
|
onsite_status_field = "custom_status" if onsite_meta.has_field("custom_status") else "status"
|
||||||
|
|
||||||
for index, name in enumerate(addresses, 1):
|
for index, name in enumerate(addresses, 1):
|
||||||
# Calculate progress
|
# Calculate progress
|
||||||
progress_percentage = int((index / total_addresses) * 100)
|
progress_percentage = int((index / total_addresses) * 100)
|
||||||
|
|
@ -277,13 +315,25 @@ def update_address_fields():
|
||||||
filled_length = int(bar_length * index // total_addresses)
|
filled_length = int(bar_length * index // total_addresses)
|
||||||
bar = '█' * filled_length + '░' * (bar_length - filled_length)
|
bar = '█' * filled_length + '░' * (bar_length - filled_length)
|
||||||
|
|
||||||
# Print progress bar with field update count
|
# Print a three-line, refreshing progress block to avoid terminal wrap
|
||||||
print(f"\r📊 Progress: [{bar}] {progress_percentage:3d}% ({index}/{total_addresses}) | Fields Updated: {total_field_updates} - Processing: {name[:25]}...", end='', flush=True)
|
progress_line = f"📊 Progress: [{bar}] {progress_percentage:3d}% ({index}/{total_addresses})"
|
||||||
|
counters_line = f" Fields updated: {total_field_updates} | Addresses updated: {addresses_updated}"
|
||||||
|
detail_line = f" Processing: {name[:40]}..."
|
||||||
|
|
||||||
|
if index == 1:
|
||||||
|
# Save cursor position at the start of the progress block
|
||||||
|
print("\033[s", end='')
|
||||||
|
else:
|
||||||
|
# Restore to the saved cursor position to rewrite the three-line block
|
||||||
|
print("\033[u", end='')
|
||||||
|
|
||||||
|
print(f"\r\033[K{progress_line}")
|
||||||
|
print(f"\r\033[K{counters_line}")
|
||||||
|
print(f"\r\033[K{detail_line}", end='' if index != total_addresses else '\n', flush=True)
|
||||||
|
|
||||||
should_update = False
|
should_update = False
|
||||||
address = frappe.get_doc("Address", name)
|
address = frappe.get_doc("Address", name)
|
||||||
current_address_updates = 0
|
current_address_updates = 0
|
||||||
current_address_updates = 0
|
|
||||||
|
|
||||||
# Use getattr with default values instead of direct attribute access
|
# Use getattr with default values instead of direct attribute access
|
||||||
if not getattr(address, 'full_address', None):
|
if not getattr(address, 'full_address', None):
|
||||||
|
|
@ -310,14 +360,15 @@ def update_address_fields():
|
||||||
job_status = "Not Started"
|
job_status = "Not Started"
|
||||||
payment_received = "Not Started"
|
payment_received = "Not Started"
|
||||||
|
|
||||||
onsite_meetings = frappe.get_all("On-Site Meeting", fields=["docstatus"],filters={"address": address.address_title})
|
onsite_meetings = frappe.get_all("On-Site Meeting", fields=[onsite_status_field], filters={"address": address.address_title})
|
||||||
if onsite_meetings and onsite_meetings[0]:
|
if onsite_meetings and onsite_meetings[0]:
|
||||||
onsite_meeting = "Completed" if onsite_meetings[0]["docstatus"] == 1 else "In Progress"
|
status_value = onsite_meetings[0].get(onsite_status_field)
|
||||||
|
onsite_meeting = "Completed" if status_value == "Completed" else "In Progress"
|
||||||
|
|
||||||
estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus"], filters={"custom_installation_address": address.address_title})
|
estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus", "custom_response"], filters={"custom_installation_address": address.address_title})
|
||||||
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["docstatus"] == 1:
|
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]:
|
||||||
estimate_sent = "Completed"
|
estimate_sent = "Completed"
|
||||||
elif estimates and estimates[0] and estimates[0]["docstatus"] != 1:
|
elif estimates and estimates[0] and not (estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]):
|
||||||
estimate_sent = "In Progress"
|
estimate_sent = "In Progress"
|
||||||
|
|
||||||
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address.address_title, "project_template": "SNW Install"})
|
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address.address_title, "project_template": "SNW Install"})
|
||||||
|
|
@ -367,8 +418,6 @@ def update_address_fields():
|
||||||
print(f" • Total field updates: {total_field_updates:,}")
|
print(f" • Total field updates: {total_field_updates:,}")
|
||||||
print(f"\n📝 Field-specific updates:")
|
print(f"\n📝 Field-specific updates:")
|
||||||
print(f" • Full Address: {field_counters['full_address']:,}")
|
print(f" • Full Address: {field_counters['full_address']:,}")
|
||||||
print(f" • Latitude: {field_counters['latitude']:,}")
|
|
||||||
print(f" • Longitude: {field_counters['longitude']:,}")
|
|
||||||
print(f" • On-Site Meeting Status: {field_counters['custom_onsite_meeting_scheduled']:,}")
|
print(f" • On-Site Meeting Status: {field_counters['custom_onsite_meeting_scheduled']:,}")
|
||||||
print(f" • Estimate Sent Status: {field_counters['custom_estimate_sent_status']:,}")
|
print(f" • Estimate Sent Status: {field_counters['custom_estimate_sent_status']:,}")
|
||||||
print(f" • Job Status: {field_counters['custom_job_status']:,}")
|
print(f" • Job Status: {field_counters['custom_job_status']:,}")
|
||||||
|
|
|
||||||
86
custom_ui/templates/estimates/accepted.html
Normal file
86
custom_ui/templates/estimates/accepted.html
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Quotation Accepted</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: white;
|
||||||
|
padding: 50px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
|
||||||
|
opacity: 0.1;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1976d2;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.checkmark {
|
||||||
|
font-size: 3em;
|
||||||
|
color: #ff9800;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: bounce 1s ease-in-out;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #424242;
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
color: #2196f3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 20%, 50%, 80%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="checkmark">✓</div>
|
||||||
|
<h1>Thank You, {{ doc.party_name or doc.customer }}!</h1>
|
||||||
|
<p>You <span class="highlight">accepted the Quote</span>! You will receive a payment link shortly.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
custom_ui/templates/estimates/error.html
Normal file
74
custom_ui/templates/estimates/error.html
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Error</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: white;
|
||||||
|
padding: 50px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
|
||||||
|
opacity: 0.1;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1976d2;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 3em;
|
||||||
|
color: #ff9800;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #424242;
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
color: #2196f3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">⚠️</div>
|
||||||
|
<h1>Oops! Something went wrong.</h1>
|
||||||
|
<p>We're sorry, but an error occurred. Please try again later or contact support if the problem persists.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
custom_ui/templates/estimates/rejected.html
Normal file
87
custom_ui/templates/estimates/rejected.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Quotation Rejected</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: white;
|
||||||
|
padding: 50px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
|
||||||
|
opacity: 0.1;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1976d2;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 3em;
|
||||||
|
color: #ff9800;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #424242;
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
color: #2196f3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.contact-info {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-left: 4px solid #1976d2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">📞</div>
|
||||||
|
<h1>We're Sorry, {{ doc.party_name or doc.customer }}</h1>
|
||||||
|
<p>We understand that our quote didn't meet your needs this time. We'd still love to discuss how we can help with your project!</p>
|
||||||
|
<p>Please don't hesitate to reach out:</p>
|
||||||
|
<div class="contact-info">
|
||||||
|
<p><strong>Phone:</strong> [Your Company Phone Number]</p>
|
||||||
|
<p><strong>Email:</strong> [Your Company Email]</p>
|
||||||
|
<p><strong>Website:</strong> [Your Company Website]</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
86
custom_ui/templates/estimates/request-call.html
Normal file
86
custom_ui/templates/estimates/request-call.html
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Call Requested</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: white;
|
||||||
|
padding: 50px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
|
||||||
|
opacity: 0.1;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1976d2;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 3em;
|
||||||
|
color: #ff9800;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: bounce 1s ease-in-out;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #424242;
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
color: #2196f3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 20%, 50%, 80%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">📞</div>
|
||||||
|
<h1>Thank You, {{ doc.party_name or doc.customer }}!</h1>
|
||||||
|
<p>Thank you for your response! Someone from our team will <span class="highlight">reach out to you soon</span> to discuss your project.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -216,8 +216,8 @@
|
||||||
<DataTable
|
<DataTable
|
||||||
ref="dataTableRef"
|
ref="dataTableRef"
|
||||||
:value="data"
|
:value="data"
|
||||||
:rowsPerPageOptions="[5, 10, 20, 50]"
|
:rowsPerPageOptions="rowsPerPageOptions"
|
||||||
:paginator="true"
|
:paginator="paginator"
|
||||||
:rows="currentRows"
|
:rows="currentRows"
|
||||||
:first="currentFirst"
|
:first="currentFirst"
|
||||||
:lazy="lazy"
|
:lazy="lazy"
|
||||||
|
|
@ -230,7 +230,7 @@
|
||||||
filterDisplay="none"
|
filterDisplay="none"
|
||||||
v-model:filters="filterRef"
|
v-model:filters="filterRef"
|
||||||
scrollable
|
scrollable
|
||||||
scrollHeight="70vh"
|
:scrollHeight="scrollHeight"
|
||||||
v-model:selection="selectedRows"
|
v-model:selection="selectedRows"
|
||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
metaKeySelection="true"
|
metaKeySelection="true"
|
||||||
|
|
@ -398,6 +398,19 @@ const props = defineProps({
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
// Pagination control
|
||||||
|
paginator: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
rowsPerPageOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [5, 10, 20, 50],
|
||||||
|
},
|
||||||
filters: {
|
filters: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
|
@ -424,6 +437,10 @@ const props = defineProps({
|
||||||
type: String,
|
type: String,
|
||||||
default: "pi pi-spinner pi-spin",
|
default: "pi pi-spinner pi-spin",
|
||||||
},
|
},
|
||||||
|
scrollHeight: {
|
||||||
|
type: String,
|
||||||
|
default: "70vh",
|
||||||
|
},
|
||||||
// Auto-connect to global loading store
|
// Auto-connect to global loading store
|
||||||
useGlobalLoading: {
|
useGlobalLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|
@ -479,15 +496,18 @@ const loading = computed(() => {
|
||||||
|
|
||||||
// Get current rows per page from pagination store or default
|
// Get current rows per page from pagination store or default
|
||||||
const currentRows = computed(() => {
|
const currentRows = computed(() => {
|
||||||
|
if (!props.paginator) {
|
||||||
|
return props.data?.length || 0;
|
||||||
|
}
|
||||||
if (props.lazy) {
|
if (props.lazy) {
|
||||||
return paginationStore.getTablePagination(props.tableName).rows;
|
return paginationStore.getTablePagination(props.tableName).rows;
|
||||||
}
|
}
|
||||||
return 10; // Default for non-lazy tables
|
return props.rows || 10; // Default for non-lazy tables
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current first index for pagination synchronization
|
// Get current first index for pagination synchronization
|
||||||
const currentFirst = computed(() => {
|
const currentFirst = computed(() => {
|
||||||
if (props.lazy) {
|
if (props.lazy && props.paginator) {
|
||||||
return paginationStore.getTablePagination(props.tableName).first;
|
return paginationStore.getTablePagination(props.tableName).first;
|
||||||
}
|
}
|
||||||
return currentPageState.value.first;
|
return currentPageState.value.first;
|
||||||
|
|
@ -497,7 +517,7 @@ const currentFirst = computed(() => {
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
filtersStore.initializeTableFilters(props.tableName, props.columns);
|
filtersStore.initializeTableFilters(props.tableName, props.columns);
|
||||||
filtersStore.initializeTableSorting(props.tableName);
|
filtersStore.initializeTableSorting(props.tableName);
|
||||||
if (props.lazy) {
|
if (props.lazy && props.paginator) {
|
||||||
paginationStore.initializeTablePagination(props.tableName, {
|
paginationStore.initializeTablePagination(props.tableName, {
|
||||||
rows: 10,
|
rows: 10,
|
||||||
totalRecords: props.totalRecords,
|
totalRecords: props.totalRecords,
|
||||||
|
|
@ -532,7 +552,7 @@ const filterRef = computed({
|
||||||
watch(
|
watch(
|
||||||
() => props.totalRecords,
|
() => props.totalRecords,
|
||||||
(newTotal) => {
|
(newTotal) => {
|
||||||
if (props.lazy && newTotal !== undefined) {
|
if (props.lazy && props.paginator && newTotal !== undefined) {
|
||||||
// Force reactivity update for page controls
|
// Force reactivity update for page controls
|
||||||
selectedPageJump.value = "";
|
selectedPageJump.value = "";
|
||||||
}
|
}
|
||||||
|
|
@ -603,6 +623,9 @@ const hasFilterChanges = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
const totalPages = computed(() => {
|
||||||
|
if (!props.paginator) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (props.lazy) {
|
if (props.lazy) {
|
||||||
return paginationStore.getTotalPages(props.tableName);
|
return paginationStore.getTotalPages(props.tableName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="estimate-page">
|
<div class="estimate-page">
|
||||||
<h2>{{ isNew ? 'Create Estimate' : 'View Estimate' }}</h2>
|
<h2>{{ isNew ? 'Create Estimate' : 'View Estimate' }}</h2>
|
||||||
|
<div v-if="!isNew && estimate" class="page-actions">
|
||||||
|
<Button label="Duplicate" icon="pi pi-copy" @click="duplicateEstimate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Address Section -->
|
<!-- Address Section -->
|
||||||
<div class="address-section">
|
<div class="address-section">
|
||||||
|
|
@ -89,11 +92,17 @@
|
||||||
<Button
|
<Button
|
||||||
label="Save Draft"
|
label="Save Draft"
|
||||||
@click="saveDraft"
|
@click="saveDraft"
|
||||||
:disabled="selectedItems.length === 0"
|
:disabled="selectedItems.length === 0 || estimate?.customSent === 1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="estimate">
|
<div v-if="estimate">
|
||||||
<Button label="Send Estimate" @click="showConfirmationModal = true" :disabled="estimate.docstatus !== 0"/>
|
<Button label="Send Estimate" @click="showConfirmationModal = true" :disabled="estimate.customSent === 1"/>
|
||||||
|
</div>
|
||||||
|
<div v-if="estimate && estimate.customSent === 1" class="response-status">
|
||||||
|
<h4>Customer Response:</h4>
|
||||||
|
<span :class="getResponseClass(estimate.customResponse)">
|
||||||
|
{{ getResponseText(estimate.customResponse) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -131,7 +140,7 @@
|
||||||
:options="{ showActions: false }"
|
:options="{ showActions: false }"
|
||||||
>
|
>
|
||||||
<template #title>Add Item</template>
|
<template #title>Add Item</template>
|
||||||
<div class="modal-content">
|
<div class="modal-content items-modal-content">
|
||||||
<div class="search-section">
|
<div class="search-section">
|
||||||
<label for="item-search" class="field-label">Search Items</label>
|
<label for="item-search" class="field-label">Search Items</label>
|
||||||
<InputText
|
<InputText
|
||||||
|
|
@ -152,7 +161,7 @@
|
||||||
:tableActions="tableActions"
|
:tableActions="tableActions"
|
||||||
selectable
|
selectable
|
||||||
:paginator="false"
|
:paginator="false"
|
||||||
:rows="filteredItems.length"
|
:scrollHeight="'55vh'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -223,10 +232,12 @@ const router = useRouter();
|
||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
const addressQuery = route.query.address;
|
const addressQuery = computed(() => route.query.address || "");
|
||||||
const isNew = route.query.new === "true" ? true : false;
|
const isNew = computed(() => route.query.new === "true");
|
||||||
|
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
|
const isDuplicating = ref(false);
|
||||||
|
const duplicatedItems = ref([]);
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
address: "",
|
address: "",
|
||||||
|
|
@ -252,10 +263,10 @@ const estimate = ref(null);
|
||||||
|
|
||||||
// Computed property to determine if fields are editable
|
// Computed property to determine if fields are editable
|
||||||
const isEditable = computed(() => {
|
const isEditable = computed(() => {
|
||||||
if (isNew) return true;
|
if (isNew.value) return true;
|
||||||
if (!estimate.value) return false;
|
if (!estimate.value) return false;
|
||||||
// If docstatus is 0 (draft), allow editing of contact and items
|
// If docstatus is 0 (draft), allow editing of contact and items
|
||||||
return estimate.value.docstatus === 0;
|
return estimate.value.customSent === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const itemColumns = [
|
const itemColumns = [
|
||||||
|
|
@ -370,6 +381,31 @@ const saveDraft = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const duplicateEstimate = () => {
|
||||||
|
if (!estimate.value) return;
|
||||||
|
|
||||||
|
// Preserve current items/quantities for the new estimate
|
||||||
|
duplicatedItems.value = (selectedItems.value || []).map((item) => ({ ...item }));
|
||||||
|
isDuplicating.value = true;
|
||||||
|
|
||||||
|
// Navigate to new estimate mode without address/contact in query params
|
||||||
|
router.push({ path: "/estimate", query: { new: "true" } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResponseClass = (response) => {
|
||||||
|
if (response === "Accepted") return "response-accepted";
|
||||||
|
if (response === "Rejected") return "response-rejected";
|
||||||
|
if (response === "Requested help") return "response-requested-help";
|
||||||
|
return "response-no-response";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResponseText = (response) => {
|
||||||
|
if (response === "Accepted") return "Accepted";
|
||||||
|
if (response === "Rejected") return "Rejected";
|
||||||
|
if (response === "Requested help") return "Requested Help";
|
||||||
|
return "No response yet";
|
||||||
|
};
|
||||||
|
|
||||||
const confirmAndSendEstimate = async () => {
|
const confirmAndSendEstimate = async () => {
|
||||||
loadingStore.setLoading(true, "Sending estimate...");
|
loadingStore.setLoading(true, "Sending estimate...");
|
||||||
const updatedEstimate = await Api.sendEstimateEmail(estimate.value.name);
|
const updatedEstimate = await Api.sendEstimateEmail(estimate.value.name);
|
||||||
|
|
@ -424,7 +460,12 @@ watch(
|
||||||
async (newQuery, oldQuery) => {
|
async (newQuery, oldQuery) => {
|
||||||
// If 'new' param or address changed, reload component state
|
// If 'new' param or address changed, reload component state
|
||||||
if (newQuery.new !== oldQuery.new || newQuery.address !== oldQuery.address) {
|
if (newQuery.new !== oldQuery.new || newQuery.address !== oldQuery.address) {
|
||||||
// Reset all state
|
const duplicating = isDuplicating.value;
|
||||||
|
const preservedItems = duplicating
|
||||||
|
? (duplicatedItems.value || []).map((item) => ({ ...item }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Reset all state, but keep items if duplicating
|
||||||
formData.address = "";
|
formData.address = "";
|
||||||
formData.addressName = "";
|
formData.addressName = "";
|
||||||
formData.contact = "";
|
formData.contact = "";
|
||||||
|
|
@ -433,8 +474,15 @@ watch(
|
||||||
selectedContact.value = null;
|
selectedContact.value = null;
|
||||||
contacts.value = [];
|
contacts.value = [];
|
||||||
contactOptions.value = [];
|
contactOptions.value = [];
|
||||||
selectedItems.value = [];
|
|
||||||
estimate.value = null;
|
estimate.value = null;
|
||||||
|
selectedItems.value = preservedItems;
|
||||||
|
|
||||||
|
// Clear duplication state once applied
|
||||||
|
if (duplicating) {
|
||||||
|
isDuplicating.value = false;
|
||||||
|
duplicatedItems.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reload data based on new query params
|
// Reload data based on new query params
|
||||||
const newIsNew = newQuery.new === "true";
|
const newIsNew = newQuery.new === "true";
|
||||||
|
|
@ -487,20 +535,20 @@ onMounted(async () => {
|
||||||
console.error("Error loading quotation items:", error);
|
console.error("Error loading quotation items:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addressQuery && isNew) {
|
if (addressQuery.value && isNew.value) {
|
||||||
// Creating new estimate - pre-fill address
|
// Creating new estimate - pre-fill address
|
||||||
await selectAddress(addressQuery);
|
await selectAddress(addressQuery.value);
|
||||||
} else if (addressQuery && !isNew) {
|
} else if (addressQuery.value && !isNew.value) {
|
||||||
// Viewing existing estimate - load and populate all fields
|
// Viewing existing estimate - load and populate all fields
|
||||||
try {
|
try {
|
||||||
estimate.value = await Api.getEstimateFromAddress(addressQuery);
|
estimate.value = await Api.getEstimateFromAddress(addressQuery.value);
|
||||||
console.log("DEBUG: Loaded estimate:", estimate.value);
|
console.log("DEBUG: Loaded estimate:", estimate.value);
|
||||||
|
|
||||||
if (estimate.value) {
|
if (estimate.value) {
|
||||||
// Set the estimate name for upserting
|
// Set the estimate name for upserting
|
||||||
formData.estimateName = estimate.value.name;
|
formData.estimateName = estimate.value.name;
|
||||||
|
|
||||||
await selectAddress(addressQuery);
|
await selectAddress(addressQuery.value);
|
||||||
// Set the contact from the estimate
|
// Set the contact from the estimate
|
||||||
formData.contact = estimate.value.partyName;
|
formData.contact = estimate.value.partyName;
|
||||||
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.partyName) || null;
|
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.partyName) || null;
|
||||||
|
|
@ -537,6 +585,12 @@ onMounted(async () => {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.address-section,
|
.address-section,
|
||||||
.contact-section {
|
.contact-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
|
@ -607,6 +661,14 @@ onMounted(async () => {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.items-modal-content {
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.search-section {
|
.search-section {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -654,6 +716,40 @@ onMounted(async () => {
|
||||||
color: #856404;
|
color: #856404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.response-status {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-status h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-accepted {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-rejected {
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-requested-help {
|
||||||
|
color: #ffc107;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-no-response {
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.address-search-results {
|
.address-search-results {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,14 @@
|
||||||
density="compact"
|
density="compact"
|
||||||
></v-btn>
|
></v-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isSidebarCollapsed" class="unscheduled-meetings-list">
|
<div
|
||||||
|
v-if="!isSidebarCollapsed"
|
||||||
|
class="unscheduled-meetings-list"
|
||||||
|
:class="{ 'drag-over-unscheduled': isUnscheduledDragOver }"
|
||||||
|
@dragover="handleUnscheduledDragOver"
|
||||||
|
@dragleave="handleUnscheduledDragLeave"
|
||||||
|
@drop="handleDropToUnscheduled"
|
||||||
|
>
|
||||||
<div v-if="unscheduledMeetings.length === 0" class="empty-state">
|
<div v-if="unscheduledMeetings.length === 0" class="empty-state">
|
||||||
<v-icon size="large" color="grey-lighten-1">mdi-calendar-check</v-icon>
|
<v-icon size="large" color="grey-lighten-1">mdi-calendar-check</v-icon>
|
||||||
<p>No unscheduled meetings</p>
|
<p>No unscheduled meetings</p>
|
||||||
|
|
@ -239,6 +246,7 @@ const isDragOver = ref(false);
|
||||||
const dragOverSlot = ref(null);
|
const dragOverSlot = ref(null);
|
||||||
const draggedMeeting = ref(null);
|
const draggedMeeting = ref(null);
|
||||||
const originalMeetingForReschedule = ref(null); // Store original meeting data for reschedules
|
const originalMeetingForReschedule = ref(null); // Store original meeting data for reschedules
|
||||||
|
const isUnscheduledDragOver = ref(false);
|
||||||
|
|
||||||
// Sidebar state
|
// Sidebar state
|
||||||
const isSidebarCollapsed = ref(false);
|
const isSidebarCollapsed = ref(false);
|
||||||
|
|
@ -431,6 +439,12 @@ const formatDateForUrl = (date) => {
|
||||||
return date.toISOString().split("T")[0];
|
return date.toISOString().split("T")[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAddressText = (address) => {
|
||||||
|
if (!address) return "";
|
||||||
|
if (typeof address === "string") return address;
|
||||||
|
return address.full_address || address.fullAddress || address.name || "";
|
||||||
|
};
|
||||||
|
|
||||||
const selectTimeSlot = (date, time) => {
|
const selectTimeSlot = (date, time) => {
|
||||||
console.log("Selected time slot:", date, time);
|
console.log("Selected time slot:", date, time);
|
||||||
};
|
};
|
||||||
|
|
@ -537,17 +551,21 @@ const handleMeetingDragStart = (event, meeting) => {
|
||||||
console.log("Rescheduling meeting:", draggedMeeting.value);
|
console.log("Rescheduling meeting:", draggedMeeting.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetDragState = () => {
|
||||||
|
isDragOver.value = false;
|
||||||
|
dragOverSlot.value = null;
|
||||||
|
isUnscheduledDragOver.value = false;
|
||||||
|
draggedMeeting.value = null;
|
||||||
|
originalMeetingForReschedule.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event) => {
|
const handleDragEnd = (event) => {
|
||||||
// If drag was cancelled (not dropped successfully), restore the original meeting
|
// If drag was cancelled (not dropped successfully), restore the original meeting
|
||||||
if (originalMeetingForReschedule.value && draggedMeeting.value?.isRescheduling) {
|
if (originalMeetingForReschedule.value && draggedMeeting.value?.isRescheduling) {
|
||||||
// Meeting wasn't successfully dropped, so it's still in the array
|
// Meeting wasn't successfully dropped, so it's still in the array
|
||||||
console.log("Drag cancelled, meeting remains in original position");
|
console.log("Drag cancelled, meeting remains in original position");
|
||||||
}
|
}
|
||||||
|
resetDragState();
|
||||||
draggedMeeting.value = null;
|
|
||||||
originalMeetingForReschedule.value = null;
|
|
||||||
isDragOver.value = false;
|
|
||||||
dragOverSlot.value = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (event, date, time) => {
|
const handleDragOver = (event, date, time) => {
|
||||||
|
|
@ -611,10 +629,7 @@ const handleDrop = async (event, date, time) => {
|
||||||
if (droppedMeeting.isRescheduling) {
|
if (droppedMeeting.isRescheduling) {
|
||||||
await loadWeekMeetings();
|
await loadWeekMeetings();
|
||||||
}
|
}
|
||||||
isDragOver.value = false;
|
resetDragState();
|
||||||
dragOverSlot.value = null;
|
|
||||||
draggedMeeting.value = null;
|
|
||||||
originalMeetingForReschedule.value = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -710,10 +725,77 @@ const handleDrop = async (event, date, time) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset drag state
|
// Reset drag state
|
||||||
isDragOver.value = false;
|
resetDragState();
|
||||||
dragOverSlot.value = null;
|
};
|
||||||
draggedMeeting.value = null;
|
|
||||||
originalMeetingForReschedule.value = null;
|
const handleUnscheduledDragOver = (event) => {
|
||||||
|
if (!draggedMeeting.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
isUnscheduledDragOver.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnscheduledDragLeave = () => {
|
||||||
|
isUnscheduledDragOver.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDropToUnscheduled = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!draggedMeeting.value) return;
|
||||||
|
|
||||||
|
const droppedMeeting = { ...draggedMeeting.value };
|
||||||
|
|
||||||
|
// Only act when moving a scheduled meeting back to unscheduled
|
||||||
|
if (!droppedMeeting.isRescheduling || !droppedMeeting.id) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingStore.setLoading(true);
|
||||||
|
|
||||||
|
await Api.updateOnSiteMeeting(droppedMeeting.id, {
|
||||||
|
status: "Unscheduled",
|
||||||
|
start_time: null,
|
||||||
|
end_time: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from the scheduled list
|
||||||
|
meetings.value = meetings.value.filter(
|
||||||
|
(meeting) => meeting.name !== droppedMeeting.id && meeting.id !== droppedMeeting.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to unscheduled list if not already present
|
||||||
|
const exists = unscheduledMeetings.value.some((m) => m.name === droppedMeeting.id);
|
||||||
|
if (!exists) {
|
||||||
|
unscheduledMeetings.value.unshift({
|
||||||
|
name: droppedMeeting.id,
|
||||||
|
address: getAddressText(droppedMeeting.address),
|
||||||
|
notes: droppedMeeting.notes || "",
|
||||||
|
status: "Unscheduled",
|
||||||
|
assigned_employee: droppedMeeting.assigned_employee || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Meeting Unscheduled",
|
||||||
|
message: "Meeting moved back to the unscheduled list",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error unscheduling meeting:", error);
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: "error",
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to move meeting to unscheduled",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingStore.setLoading(false);
|
||||||
|
resetDragState();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUnscheduledMeetings = async () => {
|
const loadUnscheduledMeetings = async () => {
|
||||||
|
|
@ -1001,6 +1083,12 @@ watch(
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unscheduled-meetings-list.drag-over-unscheduled {
|
||||||
|
border: 2px dashed #4caf50;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #f0fff3;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue