Compare commits
No commits in common. "main" and "ben-demo" have entirely different histories.
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,8 +5,6 @@
|
|||||||
tags
|
tags
|
||||||
node_modules
|
node_modules
|
||||||
__pycache__
|
__pycache__
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
|
|
||||||
*dist/
|
*dist/
|
||||||
.vscode/
|
.vscode/
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 496 KiB |
@ -1,136 +0,0 @@
|
|||||||
import frappe
|
|
||||||
import json
|
|
||||||
from custom_ui.db_utils import build_error_response, build_success_response
|
|
||||||
from custom_ui.services import ClientService, AddressService, ContactService
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_address_by_full_address(full_address):
|
|
||||||
"""Get address by full_address, including associated contacts."""
|
|
||||||
print(f"DEBUG: get_address_by_full_address called with full_address: {full_address}")
|
|
||||||
try:
|
|
||||||
address = AddressService.get_address_by_full_address(full_address)
|
|
||||||
return build_success_response(AddressService.build_full_dict(address))
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_address(address_name):
|
|
||||||
"""Get a specific address by name."""
|
|
||||||
try:
|
|
||||||
address = AddressService.get_or_throw(address_name)
|
|
||||||
return build_success_response(address.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
# @frappe.whitelist() #### DEPRECATED FUNCTION
|
|
||||||
# def get_contacts_for_address(address_name):
|
|
||||||
# """Get contacts linked to a specific address."""
|
|
||||||
# try:
|
|
||||||
# address = AddressService.get_or_throw(address_name)
|
|
||||||
# contacts = []
|
|
||||||
# for contact_link in address.custom_linked_contacts:
|
|
||||||
# contact = frappe.get_doc("Contact", contact_link.contact)
|
|
||||||
# contacts.append(contact.as_dict())
|
|
||||||
# return build_success_response(contacts)
|
|
||||||
# except Exception as e:
|
|
||||||
# return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def create_address(address_data, company, customer_name):
|
|
||||||
"""Create a new address."""
|
|
||||||
print(f"DEBUG: create_address called with address_data: {address_data}, company: {company}, customer_name: {customer_name}")
|
|
||||||
if isinstance(address_data, str):
|
|
||||||
address_data = json.loads(address_data)
|
|
||||||
customer_doctype = ClientService.get_client_doctype(customer_name)
|
|
||||||
address_data["customer_name"] = customer_name
|
|
||||||
address_data["customer_type"] = customer_doctype
|
|
||||||
address_data["address_title"] = AddressService.build_address_title(customer_name, address_data)
|
|
||||||
address_data["address_type"] = "Service"
|
|
||||||
address_data["custom_billing_address"] = 0
|
|
||||||
address_data["is_service_address"] = 1
|
|
||||||
address_data["country"] = "United States"
|
|
||||||
address_data["companies"] = [{ "company": company }]
|
|
||||||
print(f"DEBUG: Final address_data before creation: {address_data}")
|
|
||||||
try:
|
|
||||||
address_doc = AddressService.create_address(address_data)
|
|
||||||
for contact in address_data.get("contacts", []):
|
|
||||||
AddressService.link_address_to_contact(address_doc, contact)
|
|
||||||
contact_doc = ContactService.get_or_throw(contact)
|
|
||||||
ContactService.link_contact_to_address(contact_doc, address_doc)
|
|
||||||
ClientService.append_link_v2(customer_name, "properties", {"address": address_doc.name})
|
|
||||||
return build_success_response(address_doc.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_addresses(fields=["*"], filters={}):
|
|
||||||
"""Get addresses with optional filtering."""
|
|
||||||
if isinstance(fields, str):
|
|
||||||
fields = json.loads(fields)
|
|
||||||
if isinstance(filters, str):
|
|
||||||
filters = json.loads(filters)
|
|
||||||
if fields[0] != "*" and len(fields) == 1:
|
|
||||||
pluck = fields[0]
|
|
||||||
fields = None
|
|
||||||
print(f"Getting addresses with fields: {fields} and filters: {filters} and pluck: {pluck}")
|
|
||||||
try:
|
|
||||||
addresses = frappe.get_all(
|
|
||||||
"Address",
|
|
||||||
fields=fields,
|
|
||||||
filters=filters,
|
|
||||||
order_by="address_line1 desc",
|
|
||||||
pluck=pluck
|
|
||||||
)
|
|
||||||
return build_success_response(addresses)
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Get Addresses Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
def update_address(address_data):
|
|
||||||
"""Update an existing address."""
|
|
||||||
if isinstance(address_data, str):
|
|
||||||
address_data = json.loads(address_data)
|
|
||||||
address_doc = check_and_get_address_by_name(address_data.get("name"))
|
|
||||||
for key, value in address_data.items():
|
|
||||||
setattr(address_doc, key, value)
|
|
||||||
address_doc.save(ignore_permissions=True)
|
|
||||||
return address_doc
|
|
||||||
|
|
||||||
def address_exists(address_line1, address_line2, city, state, pincode):
|
|
||||||
"""Check if an address with the given details already exists."""
|
|
||||||
filters = {
|
|
||||||
"address_line1": address_line1,
|
|
||||||
"address_line2": address_line2,
|
|
||||||
"city": city,
|
|
||||||
"state": state,
|
|
||||||
"pincode": pincode
|
|
||||||
}
|
|
||||||
return frappe.db.exists("Address", filters) is not None
|
|
||||||
|
|
||||||
def calculate_address_title(customer_name, address_data):
|
|
||||||
return f"{customer_name} - {address_data.get('address_line1', '')}, {address_data.get('city', '')} - {address_data.get('type', '')}"
|
|
||||||
|
|
||||||
|
|
||||||
def create_address_links(address_doc, client_doc, contact_docs):
|
|
||||||
print("#####DEBUG: Linking customer to address.")
|
|
||||||
print("#####DEBUG: Client Doc:", client_doc.as_dict(), "Address Doc:", address_doc.as_dict(), "Contact Docs:", [c.as_dict() for c in contact_docs])
|
|
||||||
address_doc.append("links", {
|
|
||||||
"link_doctype": client_doc.doctype,
|
|
||||||
"link_name": client_doc.name
|
|
||||||
})
|
|
||||||
setattr(address_doc, "custom_customer_to_bill" if client_doc.doctype == "Customer" else "lead_name", client_doc.name)
|
|
||||||
# Address -> Contact
|
|
||||||
print("#####DEBUG: Linking contacts to address.")
|
|
||||||
address_doc.custom_contact = next((c.name for c in contact_docs if c.is_primary_contact), contact_docs[0].name)
|
|
||||||
for contact_doc in contact_docs:
|
|
||||||
address_doc.append("custom_linked_contacts", {
|
|
||||||
"contact": contact_doc.name,
|
|
||||||
"email": contact_doc.email_id,
|
|
||||||
"phone": contact_doc.phone,
|
|
||||||
"role": contact_doc.role
|
|
||||||
})
|
|
||||||
address_doc.append("links", {
|
|
||||||
"link_doctype": "Contact",
|
|
||||||
"link_name": contact_doc.name
|
|
||||||
})
|
|
||||||
address_doc.save(ignore_permissions=True)
|
|
||||||
@ -1,255 +0,0 @@
|
|||||||
import frappe
|
|
||||||
import json
|
|
||||||
from custom_ui.db_utils import build_error_response, build_success_response, process_filters, process_sorting
|
|
||||||
from custom_ui.services import DbService, ClientService, AddressService, ContactService
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_week_bid_meetings(week_start, week_end, company):
|
|
||||||
"""Get On-Site Meetings scheduled within a specific week."""
|
|
||||||
try:
|
|
||||||
meetings = frappe.db.get_all(
|
|
||||||
"On-Site Meeting",
|
|
||||||
fields=["*"],
|
|
||||||
filters=[
|
|
||||||
["status", "!=", "Cancelled"],
|
|
||||||
["start_time", ">=", week_start],
|
|
||||||
["start_time", "<=", week_end],
|
|
||||||
["company", "=", company]
|
|
||||||
],
|
|
||||||
order_by="start_time asc"
|
|
||||||
)
|
|
||||||
for meeting in meetings:
|
|
||||||
address_doc = AddressService.get_or_throw(meeting["address"])
|
|
||||||
meeting["address"] = address_doc.as_dict()
|
|
||||||
contact_doc = ContactService.get_or_throw(meeting["contact"]) if meeting.get("contact") else None
|
|
||||||
meeting["contact"] = contact_doc.as_dict() if contact_doc else None
|
|
||||||
return build_success_response(meetings)
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_bid_meeting_note_form(project_template):
|
|
||||||
bid_meeting_note_form_name = frappe.db.get_value("Project Template", project_template, "bid_meeting_note_form")
|
|
||||||
if not bid_meeting_note_form_name:
|
|
||||||
return build_error_response(f"No Bid Meeting Note Form configured for Project Template '{project_template}'", 404)
|
|
||||||
try:
|
|
||||||
note_form = frappe.get_doc("Bid Meeting Note Form", bid_meeting_note_form_name)
|
|
||||||
return build_success_response(note_form.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Get Bid Meeting Note Form Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_bid_meetings(fields=["*"], filters={}, company=None):
|
|
||||||
"""Get paginated On-Site Meetings with filtering and sorting support."""
|
|
||||||
try:
|
|
||||||
print("DEBUG: Raw bid meeting options received:", filters)
|
|
||||||
|
|
||||||
processed_filters = process_filters(filters)
|
|
||||||
|
|
||||||
meetings = frappe.db.get_all(
|
|
||||||
"On-Site Meeting",
|
|
||||||
fields=fields,
|
|
||||||
filters=processed_filters,
|
|
||||||
order_by="creation desc"
|
|
||||||
)
|
|
||||||
for meeting in meetings:
|
|
||||||
address_doc = frappe.get_doc("Address", meeting["address"])
|
|
||||||
meeting["address"] = address_doc.as_dict()
|
|
||||||
|
|
||||||
return build_success_response(
|
|
||||||
meetings
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Get On-Site Meetings Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_bid_meeting_note(name):
|
|
||||||
"""Get a specific Bid Meeting Note by name."""
|
|
||||||
try:
|
|
||||||
note = frappe.get_doc("Bid Meeting Note", name)
|
|
||||||
return build_success_response(note.as_dict())
|
|
||||||
except frappe.DoesNotExistError:
|
|
||||||
return build_error_response(f"Bid Meeting Note '{name}' does not exist.", 404)
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Get Bid Meeting Note Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_unscheduled_bid_meetings(company):
|
|
||||||
"""Get On-Site Meetings that are unscheduled."""
|
|
||||||
try:
|
|
||||||
meetings = frappe.db.get_all(
|
|
||||||
"On-Site Meeting",
|
|
||||||
fields=["*"],
|
|
||||||
filters={"status": "Unscheduled", "company": company},
|
|
||||||
order_by="creation desc"
|
|
||||||
)
|
|
||||||
for meeting in meetings:
|
|
||||||
address_doc = AddressService.get_or_throw(meeting["address"])
|
|
||||||
meeting["address"] = address_doc.as_dict()
|
|
||||||
# client_doc = ClientService.get_client_doctype(meeting["party_name"])
|
|
||||||
# meeting["client"] = client_doc.as_dict() if client_doc else None
|
|
||||||
contact_doc = ContactService.get_or_throw(meeting["contact"]) if meeting.get("contact") else None
|
|
||||||
meeting["contact"] = contact_doc.as_dict() if contact_doc else None
|
|
||||||
return build_success_response(meetings)
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Get Unscheduled On-Site Meetings Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def submit_bid_meeting_note_form(bid_meeting, project_template, fields, form_template):
|
|
||||||
"""Submit Bid Meeting Note Form data for a specific On-Site Meeting."""
|
|
||||||
if isinstance(fields, str):
|
|
||||||
fields = json.loads(fields)
|
|
||||||
try:
|
|
||||||
print(f"DEBUG: Submitting Bid Meeting Note Form for meeting='{bid_meeting}' from template='{form_template}' with fields='{fields}'")
|
|
||||||
|
|
||||||
meeting = DbService.get_or_throw("On-Site Meeting", bid_meeting)
|
|
||||||
|
|
||||||
# Update fields on the meeting
|
|
||||||
meeting_note_field_docs = [{
|
|
||||||
"label": field.get("label"),
|
|
||||||
"type": field.get("type"),
|
|
||||||
"value": json.dumps(field.get("value")) if isinstance(field.get("value"), (list, dict)) else field.get("value"),
|
|
||||||
"row": field.get("row"),
|
|
||||||
"column": field.get("column"),
|
|
||||||
"value_doctype": field.get("doctype_for_select"),
|
|
||||||
"available_options": field.get("options"),
|
|
||||||
"include_available_options": field.get("include_available_options", False),
|
|
||||||
"conditional_on_field": field.get("conditional_on_field"),
|
|
||||||
"conditional_on_value": field.get("conditional_on_value"),
|
|
||||||
"doctype_label_field": field.get("doctype_label_field")
|
|
||||||
} for field in fields]
|
|
||||||
new_bid_meeting_note_doc = frappe.get_doc({
|
|
||||||
"doctype": "Bid Meeting Note",
|
|
||||||
"bid_meeting": bid_meeting,
|
|
||||||
"project_template": project_template,
|
|
||||||
"form_template": form_template,
|
|
||||||
"fields": meeting_note_field_docs
|
|
||||||
})
|
|
||||||
new_bid_meeting_note_doc.insert(ignore_permissions=True)
|
|
||||||
for field_row, field in zip(new_bid_meeting_note_doc.fields, fields):
|
|
||||||
print(f"DEBUG: {field_row.label} - {field.get('label')}")
|
|
||||||
if not isinstance(field.get("value"), list):
|
|
||||||
continue
|
|
||||||
for item in field["value"]:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
new_bid_meeting_note_doc.append("quantities", {
|
|
||||||
"meeting_note_field": field_row.name,
|
|
||||||
"item": item.get("item"),
|
|
||||||
"quantity": item.get("quantity")
|
|
||||||
})
|
|
||||||
new_bid_meeting_note_doc.save(ignore_permissions=True)
|
|
||||||
meeting.bid_notes = new_bid_meeting_note_doc.name
|
|
||||||
meeting.status = "Completed"
|
|
||||||
meeting.save()
|
|
||||||
frappe.db.commit()
|
|
||||||
|
|
||||||
return build_success_response(meeting.as_dict())
|
|
||||||
except frappe.DoesNotExistError:
|
|
||||||
return build_error_response(f"On-Site Meeting '{bid_meeting}' does not exist.", 404)
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Submit Bid Meeting Note Form Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_bid_meeting(name):
|
|
||||||
"""Get a specific On-Site Meeting by name."""
|
|
||||||
try:
|
|
||||||
meeting = frappe.get_doc("On-Site Meeting", name)
|
|
||||||
meeting_dict = meeting.as_dict()
|
|
||||||
|
|
||||||
# Get the full address data
|
|
||||||
if meeting_dict.get("address"):
|
|
||||||
address_doc = AddressService.get_or_throw(meeting_dict["address"])
|
|
||||||
meeting_dict["address"] = address_doc.as_dict()
|
|
||||||
if meeting_dict.get("contact"):
|
|
||||||
contact_doc = ContactService.get_or_throw(meeting_dict["contact"])
|
|
||||||
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)
|
|
||||||
except frappe.DoesNotExistError:
|
|
||||||
return build_error_response(f"On-Site Meeting '{name}' does not exist.", 404)
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Get On-Site Meeting Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def create_bid_meeting(data):
|
|
||||||
"""Create a new On-Site Meeting with Unscheduled status."""
|
|
||||||
if isinstance(data, str):
|
|
||||||
data = json.loads(data)
|
|
||||||
try:
|
|
||||||
print(f"DEBUG: Creating meeting with data='{data}'")
|
|
||||||
|
|
||||||
address_doc = DbService.get_or_throw("Address", data.get("address"))
|
|
||||||
|
|
||||||
# Create the meeting with Unscheduled status
|
|
||||||
meeting = frappe.get_doc({
|
|
||||||
"doctype": "On-Site Meeting",
|
|
||||||
"address": address_doc.name,
|
|
||||||
"notes": data.get("notes") or "",
|
|
||||||
"status": "Unscheduled",
|
|
||||||
"company": data.get("company"),
|
|
||||||
"contact": data.get("contact"),
|
|
||||||
"party_type": address_doc.customer_type,
|
|
||||||
"party_name": address_doc.customer_name,
|
|
||||||
"project_template": data.get("project_template")
|
|
||||||
})
|
|
||||||
meeting.insert(ignore_permissions=True)
|
|
||||||
# ClientService.append_link(address_doc.customer_name, "onsite_meetings", "onsite_meeting", meeting.name)
|
|
||||||
# AddressService.append_link(address_doc.name, "onsite_meetings", "onsite_meeting", meeting.name)
|
|
||||||
meeting.flags.ignore_permissions = True
|
|
||||||
frappe.db.commit()
|
|
||||||
|
|
||||||
# Clear any auto-generated messages from Frappe
|
|
||||||
frappe.local.message_log = []
|
|
||||||
|
|
||||||
print(f"DEBUG: Meeting created successfully: {meeting.name}")
|
|
||||||
|
|
||||||
return build_success_response(meeting.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(message=str(e), title="Create On-Site Meeting Failed")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def update_bid_meeting(name, data):
|
|
||||||
"""Update an existing On-Site Meeting."""
|
|
||||||
try:
|
|
||||||
if isinstance(data, str):
|
|
||||||
data = json.loads(data)
|
|
||||||
|
|
||||||
meeting = frappe.get_doc("On-Site Meeting", name)
|
|
||||||
|
|
||||||
# Only update fields that are explicitly provided in the data
|
|
||||||
for key, value in data.items():
|
|
||||||
print(f"DEBUG: Updating field '{key}' to value '{value}'")
|
|
||||||
if key == "address" and value is not None:
|
|
||||||
# Convert full address to address name
|
|
||||||
value = frappe.db.get_value("Address", {"full_address": value}, "name")
|
|
||||||
meeting.set(key, value)
|
|
||||||
elif key in ["assigned_employee", "completed_by"] and value is not None:
|
|
||||||
# Convert employee name to employee ID
|
|
||||||
value = frappe.db.get_value("Employee", {"employee_name": value}, "name")
|
|
||||||
meeting.set(key, value)
|
|
||||||
else:
|
|
||||||
# For all other fields, set the value as-is (including None to clear fields)
|
|
||||||
meeting.set(key, value)
|
|
||||||
print(f"DEBUG: Field '{key}' updated to '{meeting.get(key)}'")
|
|
||||||
meeting.save()
|
|
||||||
frappe.db.commit()
|
|
||||||
|
|
||||||
return build_success_response(meeting.as_dict())
|
|
||||||
except frappe.DoesNotExistError:
|
|
||||||
return build_error_response(f"On-Site Meeting '{name}' does not exist.", 404)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@ -1,560 +0,0 @@
|
|||||||
import frappe, json
|
|
||||||
from custom_ui.db_utils import build_error_response, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, map_lead_client, build_address_title, normalize_name
|
|
||||||
from erpnext.crm.doctype.lead.lead import make_customer
|
|
||||||
from custom_ui.api.db.addresses import address_exists
|
|
||||||
from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links
|
|
||||||
from custom_ui.services import AddressService, ContactService, ClientService
|
|
||||||
|
|
||||||
# ===============================================================================
|
|
||||||
# CLIENT MANAGEMENT API METHODS
|
|
||||||
# ===============================================================================
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_client_status_counts(weekly=False, week_start_date=None, week_end_date=None):
|
|
||||||
"""Get counts of clients by status categories with optional weekly filtering."""
|
|
||||||
# Build base filters for date range if weekly filtering is enabled
|
|
||||||
try:
|
|
||||||
base_filters = {}
|
|
||||||
if weekly and week_start_date and week_end_date:
|
|
||||||
# Assuming you have a date field to filter by - adjust the field name as needed
|
|
||||||
# Common options: creation, modified, custom_date_field, etc.
|
|
||||||
base_filters["creation"] = ["between", [week_start_date, week_end_date]]
|
|
||||||
|
|
||||||
# Helper function to merge base filters with status filters
|
|
||||||
def get_filters(status_field, status_value):
|
|
||||||
filters = {status_field: status_value}
|
|
||||||
filters.update(base_filters)
|
|
||||||
return filters
|
|
||||||
|
|
||||||
onsite_meeting_scheduled_status_counts = {
|
|
||||||
"label": "On-Site Meeting Scheduled",
|
|
||||||
"not_started": frappe.db.count("Address", filters=get_filters("custom_onsite_meeting_scheduled", "Not Started")),
|
|
||||||
"in_progress": frappe.db.count("Address", filters=get_filters("custom_onsite_meeting_scheduled", "In Progress")),
|
|
||||||
"completed": frappe.db.count("Address", filters=get_filters("custom_onsite_meeting_scheduled", "Completed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
estimate_sent_status_counts = {
|
|
||||||
"label": "Estimate Sent",
|
|
||||||
"not_started": frappe.db.count("Address", filters=get_filters("custom_estimate_sent_status", "Not Started")),
|
|
||||||
"in_progress": frappe.db.count("Address", filters=get_filters("custom_estimate_sent_status", "In Progress")),
|
|
||||||
"completed": frappe.db.count("Address", filters=get_filters("custom_estimate_sent_status", "Completed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
job_status_counts = {
|
|
||||||
"label": "Job Status",
|
|
||||||
"not_started": frappe.db.count("Address", filters=get_filters("custom_job_status", "Not Started")),
|
|
||||||
"in_progress": frappe.db.count("Address", filters=get_filters("custom_job_status", "In Progress")),
|
|
||||||
"completed": frappe.db.count("Address", filters=get_filters("custom_job_status", "Completed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
payment_received_status_counts = {
|
|
||||||
"label": "Payment Received",
|
|
||||||
"not_started": frappe.db.count("Address", filters=get_filters("custom_payment_received_status", "Not Started")),
|
|
||||||
"in_progress": frappe.db.count("Address", filters=get_filters("custom_payment_received_status", "In Progress")),
|
|
||||||
"completed": frappe.db.count("Address", filters=get_filters("custom_payment_received_status", "Completed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
status_dicts = [
|
|
||||||
onsite_meeting_scheduled_status_counts,
|
|
||||||
estimate_sent_status_counts,
|
|
||||||
job_status_counts,
|
|
||||||
payment_received_status_counts
|
|
||||||
]
|
|
||||||
|
|
||||||
categories = []
|
|
||||||
for status_dict in status_dicts:
|
|
||||||
category = {
|
|
||||||
"label": status_dict["label"],
|
|
||||||
"statuses": [
|
|
||||||
{
|
|
||||||
"color": "red",
|
|
||||||
"label": "Not Started",
|
|
||||||
"count": status_dict["not_started"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"color": "yellow",
|
|
||||||
"label": "In Progress",
|
|
||||||
"count": status_dict["in_progress"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"color": "green",
|
|
||||||
"label": "Completed",
|
|
||||||
"count": status_dict["completed"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
categories.append(category)
|
|
||||||
|
|
||||||
return build_success_response(categories)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_client(client_name):
|
|
||||||
"""Get detailed information for a specific client including address, customer, and projects."""
|
|
||||||
print("DEBUG: get_client called with client_name:", client_name)
|
|
||||||
try:
|
|
||||||
clientData = {"addresses": [], "contacts": [], "jobs": [], "sales_invoices": [], "payment_entries": [], "tasks": []}
|
|
||||||
customer = check_and_get_client_doc(client_name)
|
|
||||||
if not customer:
|
|
||||||
return build_error_response(f"Client with name '{client_name}' does not exist.", 404)
|
|
||||||
print("DEBUG: Retrieved customer/lead document:", customer.as_dict())
|
|
||||||
clientData = {**clientData, **customer.as_dict()}
|
|
||||||
if customer.doctype == "Lead":
|
|
||||||
clientData.update(map_lead_client(clientData))
|
|
||||||
links = []
|
|
||||||
if customer.doctype == "Customer":
|
|
||||||
for address_link in customer.custom_select_address:
|
|
||||||
address_doc = frappe.get_doc("Address", address_link.address_name)
|
|
||||||
clientData["addresses"].append(address_doc.as_dict())
|
|
||||||
for contact_link in customer.custom_add_contacts:
|
|
||||||
contact_doc = frappe.get_doc("Contact", contact_link.contact)
|
|
||||||
clientData["contacts"].append(contact_doc.as_dict())
|
|
||||||
|
|
||||||
else:
|
|
||||||
links = frappe.get_all(
|
|
||||||
"Dynamic Link",
|
|
||||||
filters={
|
|
||||||
"link_doctype": "Lead",
|
|
||||||
"link_name": customer.name,
|
|
||||||
"parenttype": ["in", ["Address", "Contact"]],
|
|
||||||
},
|
|
||||||
fields=[
|
|
||||||
"parenttype as link_doctype",
|
|
||||||
"parent as link_name",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
print("DEBUG: Retrieved links from lead:", links)
|
|
||||||
for link in links:
|
|
||||||
print("DEBUG: Processing link:", link)
|
|
||||||
linked_doc = frappe.get_doc(link["link_doctype"], link["link_name"])
|
|
||||||
if link["link_doctype"] == "Contact":
|
|
||||||
clientData["contacts"].append(linked_doc.as_dict())
|
|
||||||
elif link["link_doctype"] == "Address":
|
|
||||||
clientData["addresses"].append(linked_doc.as_dict())
|
|
||||||
|
|
||||||
# TODO: Continue getting other linked docs like jobs, invoices, etc.
|
|
||||||
print("DEBUG: Final client data prepared:", clientData)
|
|
||||||
return build_success_response(clientData)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_client_v2(client_name):
|
|
||||||
"""Get detailed information for a specific client including address, customer, and projects."""
|
|
||||||
print("DEBUG: get_client_v2 called with client_name:", client_name)
|
|
||||||
try:
|
|
||||||
clientData = {"addresses": [], "jobs": [], "payment_entries": [], "tasks": []}
|
|
||||||
customer = check_and_get_client_doc(client_name)
|
|
||||||
if not customer:
|
|
||||||
return build_error_response(f"Client with name '{client_name}' does not exist.", 404)
|
|
||||||
print("DEBUG: Retrieved customer/lead document:", customer.as_dict())
|
|
||||||
clientData = {**clientData, **customer.as_dict()}
|
|
||||||
clientData["contacts"] = [ContactService.get_or_throw(link.contact) for link in clientData["contacts"]]
|
|
||||||
clientData["addresses"] = [AddressService.get_or_throw(link.address) for link in clientData["properties"]]
|
|
||||||
if clientData["doctype"] == "Lead":
|
|
||||||
clientData["customer_name"] = customer.custom_customer_name
|
|
||||||
|
|
||||||
# TODO: Continue getting other linked docs like jobs, invoices, etc.
|
|
||||||
print("DEBUG: Final client data prepared:", clientData)
|
|
||||||
return build_success_response(clientData)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_clients_table_data_v2(filters={}, sortings=[], page=1, page_size=10):
|
|
||||||
"""Get paginated client table data with filtering and sorting support."""
|
|
||||||
try:
|
|
||||||
filters = json.loads(filters) if isinstance(filters, str) else filters
|
|
||||||
sortings = json.loads(sortings) if isinstance(sortings, str) else sortings
|
|
||||||
page = int(page)
|
|
||||||
page_size = int(page_size)
|
|
||||||
print("DEBUG: Raw client table query received:", {
|
|
||||||
"filters": filters,
|
|
||||||
"sortings": sortings,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size
|
|
||||||
})
|
|
||||||
where_clauses = []
|
|
||||||
values = []
|
|
||||||
if filters.get("company"):
|
|
||||||
where_clauses.append("c.company = %s")
|
|
||||||
values.append(filters["company"]["value"])
|
|
||||||
|
|
||||||
if filters.get("address"):
|
|
||||||
where_clauses.append("a.full_address LIKE %s")
|
|
||||||
values.append(f"%{filters['address']['value']}%")
|
|
||||||
|
|
||||||
if filters.get("customer_name"):
|
|
||||||
where_clauses.append("a.customer_name LIKE %s")
|
|
||||||
values.append(f"%{filters['customer_name']['value']}%")
|
|
||||||
|
|
||||||
where_sql = ""
|
|
||||||
if where_clauses:
|
|
||||||
where_sql = "WHERE " + " AND ".join(where_clauses)
|
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
|
|
||||||
address_names = frappe.db.sql(f"""
|
|
||||||
SELECT DISTINCT a.name
|
|
||||||
FROM `tabAddress` a
|
|
||||||
LEFT JOIN `tabAddress Company Link` c ON c.parent = a.name
|
|
||||||
{where_sql}
|
|
||||||
ORDER BY a.modified DESC
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
""", values + [page_size, offset], as_dict=True)
|
|
||||||
print("DEBUG: Address names retrieved:", address_names)
|
|
||||||
|
|
||||||
count = frappe.db.sql(f"""
|
|
||||||
SELECT COUNT(DISTINCT a.name) as count
|
|
||||||
FROM `tabAddress` a
|
|
||||||
LEFT JOIN `tabAddress Company Link` c ON c.parent = a.name
|
|
||||||
{where_sql}
|
|
||||||
""", values, as_dict=True)[0]["count"]
|
|
||||||
tableRows = []
|
|
||||||
for address_name in address_names:
|
|
||||||
address = AddressService.get_or_throw(address_name["name"])
|
|
||||||
tableRow = {}
|
|
||||||
tableRow["id"] = address.name
|
|
||||||
tableRow["address"] = address.full_address
|
|
||||||
tableRow["client_type"] = address.customer_type
|
|
||||||
tableRow["customer_name"] = normalize_name(address.customer_name, "-#-")
|
|
||||||
tableRow["companies"] = ", ".join([link.company for link in address.get("companies", [])])
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
|
|
||||||
table_data = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
|
|
||||||
return build_success_response(table_data)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
print("ERROR in get_clients_table_data_v2:", str(e))
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_clients_table_data(filters={}, sortings=[], page=1, page_size=10):
|
|
||||||
"""Get paginated client table data with filtering and sorting support."""
|
|
||||||
try:
|
|
||||||
|
|
||||||
print("DEBUG: Raw client table query received:", {
|
|
||||||
"filters": filters,
|
|
||||||
"sortings": sortings,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size
|
|
||||||
})
|
|
||||||
|
|
||||||
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
|
|
||||||
print("DEBUG: Processed filters:", processed_filters)
|
|
||||||
print("DEBUG: Processed sortings:", processed_sortings)
|
|
||||||
# Handle count with proper OR filter support
|
|
||||||
if is_or:
|
|
||||||
count = frappe.db.sql(*get_count_or_filters("Address", processed_filters))[0][0]
|
|
||||||
else:
|
|
||||||
count = frappe.db.count("Address", filters=processed_filters)
|
|
||||||
|
|
||||||
print("DEBUG: Count of addresses matching filters:", count)
|
|
||||||
|
|
||||||
address_names = frappe.db.get_all(
|
|
||||||
"Address",
|
|
||||||
fields=["name"],
|
|
||||||
filters=processed_filters if not is_or else None,
|
|
||||||
or_filters=processed_filters if is_or else None,
|
|
||||||
limit=page_size,
|
|
||||||
start=(page - 1) * page_size,
|
|
||||||
order_by=processed_sortings
|
|
||||||
)
|
|
||||||
|
|
||||||
addresses = [frappe.get_doc("Address", addr["name"]).as_dict() for addr in address_names]
|
|
||||||
tableRows = []
|
|
||||||
for address in addresses:
|
|
||||||
is_lead = address.customer_type == "Lead"
|
|
||||||
print("##########IS LEAD:", is_lead)
|
|
||||||
tableRow = {}
|
|
||||||
links = address.links
|
|
||||||
# customer_links = [link for link in links if link.link_doctype == "Customer"] if links else None
|
|
||||||
customer_name = address.get("customer_name")
|
|
||||||
# if not customer_links:
|
|
||||||
# customer_links = [link for link in links if link.link_doctype == "Lead"] if links else None
|
|
||||||
# is_lead = True if customer_links else False
|
|
||||||
# if not customer_name and not customer_links:
|
|
||||||
# customer_name = frappe.get_value("Lead", address.get("customer_name"), "custom_customer_name")
|
|
||||||
if is_lead:
|
|
||||||
# print("DEBUG: No customer to bill. Customer links found:", customer_links)
|
|
||||||
customer_name = frappe.get_value("Lead", address.get("customer_name"), "custom_customer_name")
|
|
||||||
tableRow["id"] = address["name"]
|
|
||||||
tableRow["customer_name"] = customer_name
|
|
||||||
tableRow["address"] = (
|
|
||||||
f"{address['address_line1']}"
|
|
||||||
f"{' ' + address['address_line2'] if address['address_line2'] else ''} "
|
|
||||||
f"{address['city']}, {address['state']} {address['pincode']}"
|
|
||||||
)
|
|
||||||
print("########IS LEAD @TABLE ROW:", is_lead)
|
|
||||||
tableRow["client_type"] = "Lead" if is_lead else "Customer"
|
|
||||||
# tableRow["appointment_scheduled_status"] = address.custom_onsite_meeting_scheduled
|
|
||||||
# tableRow["estimate_sent_status"] = address.custom_estimate_sent_status
|
|
||||||
# tableRow["job_status"] = address.custom_job_status
|
|
||||||
tableRow["payment_received_status"] = address.custom_payment_received_status
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
tableDataDict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
return build_success_response(tableDataDict)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def update_client_info(client_name, data):
|
|
||||||
"""Update client information for a given client."""
|
|
||||||
try:
|
|
||||||
data = json.loads(data)
|
|
||||||
print("DEBUG: update_client_info called with client_name:", client_name, "and data:", data)
|
|
||||||
client_doc = check_and_get_client_doc(client_name)
|
|
||||||
if not client_doc:
|
|
||||||
return build_error_response(f"Client with name '{client_name}' does not exist.", 404)
|
|
||||||
address_updates = data.get("addresses", [])
|
|
||||||
contact_updates = data.get("contacts", [])
|
|
||||||
customer_updates = data.get("customer", {})
|
|
||||||
# Update addresses
|
|
||||||
if address_updates:
|
|
||||||
for addr_data in address_updates:
|
|
||||||
update_address(addr_data)
|
|
||||||
# Update contacts
|
|
||||||
if contact_updates:
|
|
||||||
for contact_data in contact_updates:
|
|
||||||
update_contact(contact_data)
|
|
||||||
# Update customer/lead
|
|
||||||
if customer_updates:
|
|
||||||
for field, value in customer_updates.items():
|
|
||||||
if hasattr(client_doc, field):
|
|
||||||
setattr(client_doc, field, value)
|
|
||||||
client_doc.save(ignore_permissions=True)
|
|
||||||
frappe.local.message_log = []
|
|
||||||
return get_client(client_name)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def upsert_client(data):
|
|
||||||
"""Create a client (customer and address)."""
|
|
||||||
try:
|
|
||||||
data = json.loads(data)
|
|
||||||
print("#####DEBUG: Create client data received:", data)
|
|
||||||
|
|
||||||
customer_name = data.get("customer_name")
|
|
||||||
contacts = data.get("contacts", [])
|
|
||||||
addresses = data.get("addresses", [])
|
|
||||||
|
|
||||||
# Check for existing address
|
|
||||||
client_doc = check_and_get_client_doc(customer_name)
|
|
||||||
if client_doc:
|
|
||||||
return build_error_response(f"Client with name '{customer_name}' already exists.", 400)
|
|
||||||
for address in addresses:
|
|
||||||
if address_exists(
|
|
||||||
address.get("address_line1"),
|
|
||||||
address.get("address_line2"),
|
|
||||||
address.get("city"),
|
|
||||||
address.get("state"),
|
|
||||||
address.get("pincode")
|
|
||||||
):
|
|
||||||
return build_error_response("This address already exists. Please use a different address or search for the address to find the associated client.", 400)
|
|
||||||
|
|
||||||
# Handle customer creation/update
|
|
||||||
|
|
||||||
print("#####DEBUG: Creating new lead.")
|
|
||||||
customer_type = data.get("customer_type", "Individual")
|
|
||||||
primary_contact = find_primary_contact_or_throw(contacts)
|
|
||||||
lead_data = {
|
|
||||||
"first_name": primary_contact.get("first_name"),
|
|
||||||
"last_name": primary_contact.get("last_name"),
|
|
||||||
"email_id": primary_contact.get("email"),
|
|
||||||
"phone": primary_contact.get("phone_number"),
|
|
||||||
"custom_customer_name": customer_name,
|
|
||||||
"customer_type": customer_type,
|
|
||||||
"companies": [{ "company": data.get("company_name")
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
if customer_type == "Company":
|
|
||||||
lead_data["company_name"] = data.get("customer_name")
|
|
||||||
client_doc = create_lead(lead_data)
|
|
||||||
print(f"#####DEBUG: {client_doc.doctype}:", client_doc.as_dict())
|
|
||||||
|
|
||||||
#Handle contact creation
|
|
||||||
contact_docs = []
|
|
||||||
for contact_data in contacts:
|
|
||||||
if isinstance(contact_data, str):
|
|
||||||
contact_data = json.loads(contact_data)
|
|
||||||
print("#####DEBUG: Processing contact data:", contact_data)
|
|
||||||
contact_doc = check_and_get_contact(
|
|
||||||
contact_data.get("first_name"),
|
|
||||||
contact_data.get("last_name"),
|
|
||||||
contact_data.get("email"),
|
|
||||||
contact_data.get("phone_number")
|
|
||||||
)
|
|
||||||
if not contact_doc:
|
|
||||||
print("#####DEBUG: No existing contact found. Creating new contact.")
|
|
||||||
contact_doc = ContactService.create({
|
|
||||||
"first_name": contact_data.get("first_name"),
|
|
||||||
"last_name": contact_data.get("last_name"),
|
|
||||||
"role": contact_data.get("contact_role", "Other"),
|
|
||||||
"custom_email": contact_data.get("email"),
|
|
||||||
"is_primary_contact": 1 if contact_data.get("is_primary", False) else 0,
|
|
||||||
"customer_type": "Lead",
|
|
||||||
"customer_name": client_doc.name,
|
|
||||||
"email_ids": [{
|
|
||||||
"email_id": contact_data.get("email"),
|
|
||||||
"is_primary": 1
|
|
||||||
}],
|
|
||||||
"phone_nos": [{
|
|
||||||
"phone": contact_data.get("phone_number"),
|
|
||||||
"is_primary_mobile_no": 1,
|
|
||||||
"is_primary_phone": 1
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
ContactService.link_contact_to_customer(contact_doc, "Lead", client_doc.name)
|
|
||||||
contact_docs.append(contact_doc)
|
|
||||||
|
|
||||||
# Link all contacts to client after creating them
|
|
||||||
client_doc.reload()
|
|
||||||
for idx, contact_data in enumerate(contacts):
|
|
||||||
if isinstance(contact_data, str):
|
|
||||||
contact_data = json.loads(contact_data)
|
|
||||||
contact_doc = contact_docs[idx]
|
|
||||||
client_doc.append("contacts", {
|
|
||||||
"contact": contact_doc.name
|
|
||||||
})
|
|
||||||
if contact_data.get("is_primary", False):
|
|
||||||
client_doc.primary_contact = contact_doc.name
|
|
||||||
client_doc.save(ignore_permissions=True)
|
|
||||||
|
|
||||||
# Handle address creation
|
|
||||||
address_docs = []
|
|
||||||
for address in addresses:
|
|
||||||
is_billing = True if address.get("is_billing_address") else False
|
|
||||||
is_service = True if address.get("is_service_address") else False
|
|
||||||
print("#####DEBUG: Creating address with data:", address)
|
|
||||||
address_doc = AddressService.create_address({
|
|
||||||
"address_title": AddressService.build_address_title(customer_name, address),
|
|
||||||
"address_line1": address.get("address_line1"),
|
|
||||||
"address_line2": address.get("address_line2"),
|
|
||||||
"address_type": "Billing" if is_billing else "Service",
|
|
||||||
"custom_billing_address": is_billing,
|
|
||||||
"is_service_address": is_service,
|
|
||||||
"is_primary_address": is_billing,
|
|
||||||
"city": address.get("city"),
|
|
||||||
"state": address.get("state"),
|
|
||||||
"country": "United States",
|
|
||||||
"pincode": address.get("pincode"),
|
|
||||||
"customer_type": "Lead",
|
|
||||||
"customer_name": client_doc.name,
|
|
||||||
"companies": [{ "company": data.get("company_name") }]
|
|
||||||
})
|
|
||||||
AddressService.link_address_to_customer(address_doc, "Lead", client_doc.name)
|
|
||||||
address_doc.reload()
|
|
||||||
if is_billing:
|
|
||||||
client_doc.custom_billing_address = address_doc.name
|
|
||||||
client_doc.save(ignore_permissions=True)
|
|
||||||
for contact_to_link_idx in address.get("contacts", []):
|
|
||||||
contact_doc = contact_docs[contact_to_link_idx]
|
|
||||||
AddressService.link_address_to_contact(address_doc, contact_doc.name)
|
|
||||||
address_doc.reload()
|
|
||||||
ContactService.link_contact_to_address(contact_doc, address_doc.name)
|
|
||||||
primary_contact = contact_docs[address.get("primary_contact)", 0)]
|
|
||||||
AddressService.set_primary_contact(address_doc.name, primary_contact.name)
|
|
||||||
address_docs.append(address_doc)
|
|
||||||
|
|
||||||
# Link all addresses to client after creating them
|
|
||||||
client_doc.reload()
|
|
||||||
for address_doc in address_docs:
|
|
||||||
client_doc.append("properties", {
|
|
||||||
"address": address_doc.name
|
|
||||||
})
|
|
||||||
client_doc.save(ignore_permissions=True)
|
|
||||||
client_dict = client_doc.as_dict()
|
|
||||||
client_dict["contacts"] = [contact.as_dict() for contact in contact_docs]
|
|
||||||
client_dict["addresses"] = [address.as_dict() for address in address_docs]
|
|
||||||
|
|
||||||
frappe.local.message_log = []
|
|
||||||
return build_success_response(client_dict)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_client_names(search_term):
|
|
||||||
"""Search for client names matching the search term."""
|
|
||||||
try:
|
|
||||||
search_pattern = f"%{search_term}%"
|
|
||||||
client_names = frappe.db.get_all(
|
|
||||||
"Customer",
|
|
||||||
filters={"customer_name": ["like", search_pattern]},
|
|
||||||
pluck="name")
|
|
||||||
return build_success_response(client_names)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
def check_if_customer(client_name):
|
|
||||||
"""Check if the given client name corresponds to a Customer."""
|
|
||||||
return frappe.db.exists("Customer", client_name) is not None
|
|
||||||
|
|
||||||
def check_and_get_client_doc(client_name):
|
|
||||||
"""Check if a client exists as Customer or Lead and return the document."""
|
|
||||||
print("DEBUG: Checking for existing client with name:", client_name)
|
|
||||||
customer = None
|
|
||||||
if check_if_customer(client_name):
|
|
||||||
print("DEBUG: Client found as Customer.")
|
|
||||||
customer = frappe.get_doc("Customer", client_name)
|
|
||||||
else:
|
|
||||||
print("DEBUG: Client not found as Customer. Checking Lead.")
|
|
||||||
lead_name = frappe.db.get_all("Lead", pluck="name", filters={"company_name": client_name})
|
|
||||||
if not lead_name:
|
|
||||||
lead_name = frappe.db.get_all("Lead", pluck="name", filters={"lead_name": client_name})
|
|
||||||
if not lead_name:
|
|
||||||
lead_name = frappe.db.get_all("Lead", pluck="name", filters={"custom_customer_name": client_name})
|
|
||||||
if lead_name:
|
|
||||||
print("DEBUG: Client found as Lead.")
|
|
||||||
customer = frappe.get_doc("Lead", lead_name[0])
|
|
||||||
return customer
|
|
||||||
|
|
||||||
def convert_lead_to_customer(lead_name):
|
|
||||||
lead = frappe.get_doc("Lead", lead_name)
|
|
||||||
customer = make_customer(lead)
|
|
||||||
customer.insert(ignore_permissions=True)
|
|
||||||
|
|
||||||
|
|
||||||
def create_lead(lead_data):
|
|
||||||
lead = frappe.get_doc({
|
|
||||||
"doctype": "Lead",
|
|
||||||
**lead_data
|
|
||||||
})
|
|
||||||
lead.insert(ignore_permissions=True)
|
|
||||||
return lead
|
|
||||||
|
|
||||||
def get_customer_or_lead(client_name):
|
|
||||||
if check_if_customer(client_name):
|
|
||||||
return frappe.get_doc("Customer", client_name)
|
|
||||||
else:
|
|
||||||
lead_name = frappe.db.get_all("Lead", pluck="name", filters={"lead_name": client_name})[0]
|
|
||||||
return frappe.get_doc("Lead", lead_name)
|
|
||||||
|
|
||||||
def find_primary_contact_or_throw(contacts):
|
|
||||||
for contact in contacts:
|
|
||||||
if contact.get("is_primary"):
|
|
||||||
print("#####DEBUG: Primary contact found:", contact)
|
|
||||||
return contact
|
|
||||||
raise ValueError("No primary contact found in contacts list.")
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
def existing_contact_name(first_name: str, last_name: str, email: str, phone: str) -> str:
|
|
||||||
"""Check if a contact exists based on provided details."""
|
|
||||||
filters = {
|
|
||||||
"first_name": first_name,
|
|
||||||
"last_name": last_name,
|
|
||||||
"email_id": email,
|
|
||||||
"phone": phone
|
|
||||||
}
|
|
||||||
existing_contacts = frappe.db.get_all("Contact", pluck="name", filters=filters)
|
|
||||||
return existing_contacts[0] if existing_contacts else None
|
|
||||||
|
|
||||||
def get_contact(contact_name: str):
|
|
||||||
"""Retrieve a contact document by name."""
|
|
||||||
contact = frappe.get_doc("Contact", contact_name)
|
|
||||||
print("Retrieved existing contact:", contact.as_dict())
|
|
||||||
return contact
|
|
||||||
|
|
||||||
def check_and_get_contact(first_name: str, last_name: str, email: str, phone: str):
|
|
||||||
"""Check if a contact exists and return the contact document if found."""
|
|
||||||
contact_name = existing_contact_name(first_name, last_name, email, phone)
|
|
||||||
if contact_name:
|
|
||||||
return get_contact(contact_name)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def check_and_get_contact_by_name(contact_name: str):
|
|
||||||
"""Check if a contact exists by name and return the contact document if found."""
|
|
||||||
if frappe.db.exists("Contact", contact_name):
|
|
||||||
return get_contact(contact_name)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def create_contact(contact_data: dict):
|
|
||||||
"""Create a new contact."""
|
|
||||||
contact = frappe.get_doc({
|
|
||||||
"doctype": "Contact",
|
|
||||||
**contact_data
|
|
||||||
})
|
|
||||||
contact.insert(ignore_permissions=True)
|
|
||||||
print("Created new contact:", contact.as_dict())
|
|
||||||
return contact
|
|
||||||
|
|
||||||
def create_contact_links(contact_docs, client_doc, address_doc):
|
|
||||||
print("#####DEBUG: Linking contacts to client and address.")
|
|
||||||
for contact_doc in contact_docs:
|
|
||||||
contact_doc.address = address_doc.name
|
|
||||||
contact_doc.append("links", {
|
|
||||||
"link_doctype": client_doc.doctype,
|
|
||||||
"link_name": client_doc.name
|
|
||||||
})
|
|
||||||
contact_doc.append("links", {
|
|
||||||
"link_doctype": "Address",
|
|
||||||
"link_name": address_doc.name
|
|
||||||
})
|
|
||||||
contact_doc.custom_customer = client_doc.name
|
|
||||||
contact_doc.save(ignore_permissions=True)
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.db_utils import build_success_response, build_error_response
|
|
||||||
# ===============================================================================
|
|
||||||
# CUSTOMER API METHODS
|
|
||||||
# ===============================================================================
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_customer_details(customer_name):
|
|
||||||
try:
|
|
||||||
customer = frappe.get_doc("Customer", customer_name)
|
|
||||||
return build_success_response(customer)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_client_names(type):
|
|
||||||
"""Get a list of client names. Maps to value/label pairs for select fields."""
|
|
||||||
try:
|
|
||||||
customer_names = frappe.db.sql("""
|
|
||||||
SELECT
|
|
||||||
customer_name AS label,
|
|
||||||
name AS value
|
|
||||||
FROM
|
|
||||||
`tabCustomer`
|
|
||||||
WHERE
|
|
||||||
customer_type = %s
|
|
||||||
""", (type,), as_dict=True)
|
|
||||||
return build_success_response(customer_names)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import frappe, json
|
|
||||||
from custom_ui.db_utils import build_success_response, build_error_response
|
|
||||||
# ===============================================================================
|
|
||||||
# EMPLOYEE API METHODS
|
|
||||||
# ===============================================================================
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_employees(company: str, roles=[]):
|
|
||||||
"""Get a list of employees for a given company. Can be filtered by role."""
|
|
||||||
roles = json.loads(roles) if isinstance(roles, str) else roles
|
|
||||||
filters = {"company": company}
|
|
||||||
if roles:
|
|
||||||
filters["designation"] = ["in", roles]
|
|
||||||
try:
|
|
||||||
employee_names = frappe.get_all(
|
|
||||||
"Employee",
|
|
||||||
filters=filters,
|
|
||||||
pluck="name"
|
|
||||||
)
|
|
||||||
employees = [frappe.get_doc("Employee", name).as_dict() for name in employee_names]
|
|
||||||
return build_success_response(employees)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_employees_organized(company: str, roles=[]):
|
|
||||||
"""Get all employees for a company organized by designation."""
|
|
||||||
roles = json.loads(roles) if isinstance(roles, str) else roles
|
|
||||||
try:
|
|
||||||
filters = {"company": company}
|
|
||||||
if roles:
|
|
||||||
filters["designation"] = ["in", roles]
|
|
||||||
employee_names = frappe.get_all(
|
|
||||||
"Employee",
|
|
||||||
filters=filters,
|
|
||||||
pluck="name"
|
|
||||||
)
|
|
||||||
employees = [frappe.get_doc("Employee", name).as_dict() for name in employee_names]
|
|
||||||
|
|
||||||
organized = {}
|
|
||||||
for emp in employees:
|
|
||||||
designation = emp.get("designation", "Unassigned")
|
|
||||||
if designation not in organized:
|
|
||||||
organized[designation] = []
|
|
||||||
organized[designation].append(emp)
|
|
||||||
|
|
||||||
return build_success_response(organized)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
@ -1,581 +0,0 @@
|
|||||||
import frappe, json
|
|
||||||
from frappe.utils.pdf import get_pdf
|
|
||||||
from custom_ui.api.db.general import get_doc_history
|
|
||||||
from custom_ui.db_utils import DbUtils, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
|
||||||
from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
|
|
||||||
from custom_ui.services import ItemService, DbService, ClientService, AddressService, ContactService, EstimateService, ItemService
|
|
||||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
|
||||||
|
|
||||||
# ===============================================================================
|
|
||||||
# ESTIMATES & INVOICES API METHODS
|
|
||||||
# ===============================================================================
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_estimate_table_data_v2(filters={}, sortings=[], page=1, page_size=10):
|
|
||||||
"""Get paginated estimate table data with filtering and sorting."""
|
|
||||||
print("DEBUG: Raw estimate options received:", filters, sortings, page, page_size)
|
|
||||||
filters, sortings, page, page_size = DbUtils.process_datatable_request(filters, sortings, page, page_size)
|
|
||||||
sortings = "modified desc" if not sortings else sortings
|
|
||||||
count = frappe.db.count("Quotation", filters=filters)
|
|
||||||
print(f"DEBUG: Number of estimates returned: {count}")
|
|
||||||
estimate_names = frappe.db.get_all(
|
|
||||||
"Quotation",
|
|
||||||
filters=filters,
|
|
||||||
pluck="name",
|
|
||||||
limit=page_size,
|
|
||||||
start=(page) * page_size,
|
|
||||||
order_by=sortings
|
|
||||||
)
|
|
||||||
|
|
||||||
estimates = [frappe.get_doc("Quotation", name).as_dict() for name in estimate_names]
|
|
||||||
tableRows = []
|
|
||||||
for estimate in estimates:
|
|
||||||
tableRow = {
|
|
||||||
"id": estimate["name"],
|
|
||||||
"address": frappe.db.get_value("Address", estimate.get("custom_job_address"), "full_address"),
|
|
||||||
# strip "-#-" from actual_customer_name and anything that comes after it
|
|
||||||
"customer": estimate.get("actual_customer_name").split("-#-")[0] if estimate.get("actual_customer_name") else estimate.get("customer_name") if estimate.get("customer_name") else "",
|
|
||||||
"status": estimate.get("custom_current_status", ""),
|
|
||||||
"order_type": estimate.get("order_type", ""),
|
|
||||||
}
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
return build_success_response(table_data_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
|
|
||||||
"""Get paginated estimate table data with filtering and sorting support."""
|
|
||||||
print("DEBUG: Raw estimate options received:", filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
if is_or:
|
|
||||||
count = frappe.db.sql(*get_count_or_filters("Quotation", processed_filters))[0][0]
|
|
||||||
else:
|
|
||||||
count = frappe.db.count("Quotation", filters=processed_filters)
|
|
||||||
|
|
||||||
print(f"DEBUG: Number of estimates returned: {count}")
|
|
||||||
|
|
||||||
estimates = frappe.db.get_all(
|
|
||||||
"Quotation",
|
|
||||||
fields=["*"],
|
|
||||||
filters=processed_filters if not is_or else None,
|
|
||||||
or_filters=processed_filters if is_or else None,
|
|
||||||
limit=page_size,
|
|
||||||
start=page * page_size,
|
|
||||||
order_by=processed_sortings
|
|
||||||
)
|
|
||||||
|
|
||||||
tableRows = []
|
|
||||||
for estimate in estimates:
|
|
||||||
full_address = frappe.db.get_value("Address", estimate.get("custom_job_address"), "full_address")
|
|
||||||
tableRow = {}
|
|
||||||
tableRow["id"] = estimate["name"]
|
|
||||||
tableRow["address"] = full_address
|
|
||||||
tableRow["quotation_to"] = estimate.get("quotation_to", "")
|
|
||||||
tableRow["customer"] = estimate.get("party_name", "")
|
|
||||||
tableRow["status"] = estimate.get("custom_current_status", "")
|
|
||||||
tableRow["date"] = estimate.get("transaction_date", "")
|
|
||||||
tableRow["order_type"] = estimate.get("order_type", "")
|
|
||||||
tableRow["items"] = estimate.get("items", "")
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
|
|
||||||
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
return build_success_response(table_data_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_quotation_items(project_template:str = None):
|
|
||||||
"""Get all available quotation items."""
|
|
||||||
try:
|
|
||||||
filters = EstimateService.map_project_template_to_filter(project_template)
|
|
||||||
items = frappe.get_all("Item", fields=["item_code", "item_group"], filters=filters)
|
|
||||||
grouped_item_dicts = {}
|
|
||||||
for item in items:
|
|
||||||
item_dict = ItemService.get_full_dict(item.item_code)
|
|
||||||
if item_dict["bom"]:
|
|
||||||
if "Packages" not in grouped_item_dicts:
|
|
||||||
grouped_item_dicts["Packages"] = {}
|
|
||||||
if item.item_group not in grouped_item_dicts["Packages"]:
|
|
||||||
grouped_item_dicts["Packages"][item.item_group] = []
|
|
||||||
grouped_item_dicts["Packages"][item.item_group].append(item_dict)
|
|
||||||
else:
|
|
||||||
if item.item_group not in grouped_item_dicts:
|
|
||||||
grouped_item_dicts[item.item_group] = []
|
|
||||||
grouped_item_dicts[item.item_group].append(item_dict)
|
|
||||||
return build_success_response(grouped_item_dicts)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_estimate(estimate_name):
|
|
||||||
"""Get detailed information for a specific estimate."""
|
|
||||||
try:
|
|
||||||
estimate = frappe.get_doc("Quotation", estimate_name)
|
|
||||||
est_dict = estimate.as_dict()
|
|
||||||
|
|
||||||
address_name = estimate.custom_job_address or estimate.customer_address
|
|
||||||
if address_name:
|
|
||||||
# Fetch Address Doc
|
|
||||||
address_doc = frappe.get_doc("Address", address_name).as_dict()
|
|
||||||
est_dict["full_address"] = address_doc.get("full_address")
|
|
||||||
|
|
||||||
# Logic from get_address_by_full_address to populate customer and contacts
|
|
||||||
customer_exists = frappe.db.exists("Customer", address_doc.get("custom_customer_to_bill"))
|
|
||||||
doctype = "Customer" if customer_exists else "Lead"
|
|
||||||
name = ""
|
|
||||||
if doctype == "Customer":
|
|
||||||
name = address_doc.get("custom_customer_to_bill")
|
|
||||||
else:
|
|
||||||
lead_links = address_doc.get("links", [])
|
|
||||||
lead_name = [link.link_name for link in lead_links if link.link_doctype == "Lead"]
|
|
||||||
name = lead_name[0] if lead_name else ""
|
|
||||||
|
|
||||||
if name:
|
|
||||||
address_doc["customer"] = frappe.get_doc(doctype, name).as_dict()
|
|
||||||
|
|
||||||
contacts = []
|
|
||||||
if address_doc.get("custom_linked_contacts"):
|
|
||||||
for contact_link in address_doc.get("custom_linked_contacts"):
|
|
||||||
contact_doc = frappe.get_doc("Contact", contact_link.contact)
|
|
||||||
contacts.append(contact_doc.as_dict())
|
|
||||||
address_doc["contacts"] = contacts
|
|
||||||
|
|
||||||
est_dict["address_details"] = address_doc
|
|
||||||
|
|
||||||
est_dict["history"] = get_doc_history("Quotation", estimate_name)
|
|
||||||
est_dict["items"] = [ItemService.get_full_dict(item.item_code) for item in estimate.items]
|
|
||||||
|
|
||||||
return build_success_response(est_dict)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_estimate_items():
|
|
||||||
items = frappe.db.get_all("Quotation Item", fields=["*"])
|
|
||||||
return build_success_response(items)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_estimate_from_address(full_address):
|
|
||||||
address_name = frappe.db.get_value("Address", {"full_address": full_address}, "name")
|
|
||||||
quotation_name = frappe.db.get_value("Quotation", {"custom_job_address": address_name}, "name")
|
|
||||||
quotation_doc = frappe.get_doc("Quotation", quotation_name)
|
|
||||||
return build_success_response(quotation_doc.as_dict())
|
|
||||||
# quotation = frappe.db.sql("""
|
|
||||||
# SELECT q.name, q.custom_job_address
|
|
||||||
# FROM `tabQuotation` q
|
|
||||||
# JOIN `tabAddress` a
|
|
||||||
# ON q.custom_job_address = a.name
|
|
||||||
# WHERE a.full_address =%s
|
|
||||||
# """, (full_address,), as_dict=True)
|
|
||||||
# if quotation:
|
|
||||||
# return build_success_response(quotation)
|
|
||||||
# else:
|
|
||||||
# return build_error_response("No quotation found for the given address.", 404)
|
|
||||||
|
|
||||||
# @frappe.whitelist()
|
|
||||||
# def send_estimate_email(estimate_name):
|
|
||||||
# print("DEBUG: Queuing email send job for estimate:", estimate_name)
|
|
||||||
# frappe.enqueue(
|
|
||||||
# "custom_ui.api.db.estimates.send_estimate_email_job",
|
|
||||||
# estimate_name=estimate_name,
|
|
||||||
# queue="long", # or "default"
|
|
||||||
# timeout=600,
|
|
||||||
# )
|
|
||||||
# return build_success_response("Email queued for sending.")
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def send_estimate_email(estimate_name):
|
|
||||||
# def send_estimate_email_job(estimate_name):
|
|
||||||
try:
|
|
||||||
print("DEBUG: Sending estimate email for:", estimate_name)
|
|
||||||
quotation = frappe.get_doc("Quotation", estimate_name)
|
|
||||||
|
|
||||||
# Get recipient email
|
|
||||||
if not DbService.exists("Contact", quotation.contact_person):
|
|
||||||
return build_error_response("No email found for the customer.", 400)
|
|
||||||
party = ContactService.get_or_throw(quotation.contact_person)
|
|
||||||
|
|
||||||
email = quotation.contact_email or None
|
|
||||||
if not email:
|
|
||||||
if (getattr(party, 'email_id', None)):
|
|
||||||
email = party.email_id
|
|
||||||
elif (getattr(party, 'email_ids', None) and len(party.email_ids) > 0):
|
|
||||||
primary = next((e for e in party.email_ids if e.is_primary), None)
|
|
||||||
email = primary.email_id if primary else party.email_ids[0].email_id
|
|
||||||
|
|
||||||
if not email and quotation.custom_job_address:
|
|
||||||
address = frappe.get_doc("Address", quotation.custom_job_address)
|
|
||||||
email = getattr(address, 'email_id', None)
|
|
||||||
if not email:
|
|
||||||
return build_error_response("No email found for the customer or address.", 400)
|
|
||||||
|
|
||||||
# Get customer name
|
|
||||||
customer_name = party.first_name or party.name or "Valued Customer"
|
|
||||||
if party.last_name:
|
|
||||||
customer_name = f"{party.first_name} {party.last_name}"
|
|
||||||
|
|
||||||
# Get full address
|
|
||||||
full_address = "Address not specified"
|
|
||||||
if quotation.custom_job_address:
|
|
||||||
address_doc = frappe.get_doc("Address", quotation.custom_job_address)
|
|
||||||
full_address = address_doc.full_address or address_doc.address_line1 or "Address not specified"
|
|
||||||
|
|
||||||
# Format price
|
|
||||||
price = frappe.utils.fmt_money(quotation.grand_total, currency=quotation.currency)
|
|
||||||
|
|
||||||
# Get additional notes
|
|
||||||
additional = quotation.terms or ""
|
|
||||||
|
|
||||||
# Get company phone
|
|
||||||
company_phone = ""
|
|
||||||
if quotation.company:
|
|
||||||
company_doc = frappe.get_doc("Company", quotation.company)
|
|
||||||
company_phone = getattr(company_doc, 'phone_no', '') or getattr(company_doc, 'phone', '')
|
|
||||||
|
|
||||||
# Get base URL
|
|
||||||
base_url = frappe.utils.get_url()
|
|
||||||
|
|
||||||
# Get letterhead image
|
|
||||||
letterhead_image = ""
|
|
||||||
if quotation.letter_head:
|
|
||||||
letterhead_doc = frappe.get_doc("Letter Head", quotation.letter_head)
|
|
||||||
if letterhead_doc.image:
|
|
||||||
letterhead_image = frappe.utils.get_url() + letterhead_doc.image
|
|
||||||
|
|
||||||
# Prepare template context
|
|
||||||
template_context = {
|
|
||||||
"company": quotation.company,
|
|
||||||
"customer_name": customer_name,
|
|
||||||
"price": price,
|
|
||||||
"address": full_address,
|
|
||||||
"additional": additional,
|
|
||||||
"company_phone": company_phone,
|
|
||||||
"base_url": base_url,
|
|
||||||
"estimate_name": quotation.name,
|
|
||||||
"letterhead_image": letterhead_image
|
|
||||||
}
|
|
||||||
|
|
||||||
# Render the email template
|
|
||||||
template_path = "custom_ui/templates/emails/general_estimation.html"
|
|
||||||
message = frappe.render_template(template_path, template_context)
|
|
||||||
subject = f"Estimate from {quotation.company} - {quotation.name}"
|
|
||||||
|
|
||||||
print("DEBUG: Subject:", subject)
|
|
||||||
print("DEBUG: Sending email to:", email)
|
|
||||||
|
|
||||||
# Generate PDF attachment
|
|
||||||
html = frappe.get_print("Quotation", quotation.name, print_format="Quotation - SNW - Standard", letterhead=True)
|
|
||||||
print("DEBUG: Generated HTML for PDF.")
|
|
||||||
pdf = get_pdf(html)
|
|
||||||
print("DEBUG: Generated PDF for email attachment.")
|
|
||||||
|
|
||||||
# Send email
|
|
||||||
frappe.sendmail(
|
|
||||||
recipients=email,
|
|
||||||
subject=subject,
|
|
||||||
message=message,
|
|
||||||
doctype="Quotation",
|
|
||||||
name=quotation.name,
|
|
||||||
read_receipt=1,
|
|
||||||
print_letterhead=1,
|
|
||||||
attachments=[{"fname": f"{quotation.name}.pdf", "fcontent": pdf}]
|
|
||||||
)
|
|
||||||
print(f"DEBUG: Email sent to {email} successfully.")
|
|
||||||
|
|
||||||
# Update quotation status
|
|
||||||
quotation.custom_current_status = "Submitted"
|
|
||||||
quotation.custom_sent = 1
|
|
||||||
quotation.save()
|
|
||||||
quotation.submit()
|
|
||||||
frappe.db.commit()
|
|
||||||
|
|
||||||
updated_quotation = frappe.get_doc("Quotation", estimate_name)
|
|
||||||
return build_success_response(updated_quotation.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
print(f"DEBUG: Error in send_estimate_email: {str(e)}")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def manual_response(name, response):
|
|
||||||
"""Update the response for an estimate in the UI."""
|
|
||||||
print("DEBUG: RESPONSE_RECEIVED:", name, response)
|
|
||||||
try:
|
|
||||||
if not frappe.db.exists("Quotation", name):
|
|
||||||
raise Exception("Estimate not found.")
|
|
||||||
estimate = frappe.get_doc("Quotation", name)
|
|
||||||
if estimate.docstatus != 1:
|
|
||||||
raise Exception("Estimate must be submitted to update response.")
|
|
||||||
accepted = True if response == "Accepted" else False
|
|
||||||
new_status = "Estimate Accepted" if accepted else "Lost"
|
|
||||||
|
|
||||||
estimate.custom_response = response
|
|
||||||
estimate.custom_current_status = new_status
|
|
||||||
# estimate.custom_current_status = new_status
|
|
||||||
# estimate.status = "Ordered" if accepted else "Closed"
|
|
||||||
estimate.flags.ignore_permissions = True
|
|
||||||
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
|
|
||||||
estimate.save()
|
|
||||||
return build_success_response(estimate.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_estimate_templates(company):
|
|
||||||
"""Get available estimate templates."""
|
|
||||||
filters = {"is_active": 1}
|
|
||||||
if company:
|
|
||||||
filters["company"] = company
|
|
||||||
try:
|
|
||||||
print("DEBUG: Fetching estimate templates for company:", company)
|
|
||||||
templates = frappe.get_all("Quotation Template", fields=["*"], filters=filters)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
if not templates:
|
|
||||||
print("DEBUG: No templates found.")
|
|
||||||
return build_success_response(result)
|
|
||||||
print(f"DEBUG: Found {len(templates)} templates.")
|
|
||||||
for template in templates:
|
|
||||||
print("DEBUG: Processing template:", template)
|
|
||||||
items = frappe.get_all("Quotation Template Item",
|
|
||||||
fields=["item_code", "item_name", "description", "quantity", "discount_percentage", "rate"],
|
|
||||||
filters={"parent": template.name},
|
|
||||||
order_by="idx")
|
|
||||||
|
|
||||||
# Map fields to camelCase as requested
|
|
||||||
mapped_items = []
|
|
||||||
for item in items:
|
|
||||||
mapped_items.append({
|
|
||||||
"item_code": item.item_code,
|
|
||||||
"item_name": item.item_name,
|
|
||||||
"description": item.description,
|
|
||||||
"quantity": item.quantity,
|
|
||||||
"discount_percentage": item.discount_percentage,
|
|
||||||
"rate": item.rate
|
|
||||||
})
|
|
||||||
|
|
||||||
result.append({
|
|
||||||
"name": template.name,
|
|
||||||
"template_name": template.template_name,
|
|
||||||
"active": template.is_active,
|
|
||||||
"description": template.description,
|
|
||||||
"items": mapped_items,
|
|
||||||
"project_template": template.project_template,
|
|
||||||
})
|
|
||||||
|
|
||||||
return build_success_response(result)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def create_estimate_template(data):
|
|
||||||
"""Create a new estimate template."""
|
|
||||||
try:
|
|
||||||
print("DEBUG: Creating estimate template with data:", data)
|
|
||||||
data = json.loads(data) if isinstance(data, str) else data
|
|
||||||
|
|
||||||
doc_data = {
|
|
||||||
"doctype": "Quotation Template",
|
|
||||||
"is_active": 1,
|
|
||||||
"description": data.get("description"),
|
|
||||||
"company": data.get("company"),
|
|
||||||
"items": [],
|
|
||||||
"template_name": data.get("template_name"),
|
|
||||||
"custom_project_template": data.get("project_template", ""),
|
|
||||||
"source_quotation": data.get("source_quotation", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
new_template = frappe.get_doc(doc_data)
|
|
||||||
|
|
||||||
for item in data.get("items", []):
|
|
||||||
new_template.append("items", {
|
|
||||||
"item_code": item.get("item_code"),
|
|
||||||
"item_name": item.get("item_name"),
|
|
||||||
"description": item.get("description"),
|
|
||||||
"qty": item.get("qty") or item.get("quantity"),
|
|
||||||
"rate": item.get("standard_rate") or item.get("rate"),
|
|
||||||
"discount_percentage": item.get("discount_percentage")
|
|
||||||
})
|
|
||||||
|
|
||||||
new_template.insert()
|
|
||||||
return build_success_response(new_template.name)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
# @frappe.whitelist()
|
|
||||||
# def create_template(data):
|
|
||||||
# """Create a new estimate template."""
|
|
||||||
# try:
|
|
||||||
# data = json.loads(data) if isinstance(data, str) else data
|
|
||||||
# print("DEBUG: Creating estimate template with data:", data)
|
|
||||||
|
|
||||||
# new_template = frappe.get_doc({
|
|
||||||
# "doctype": "Quotation Template",
|
|
||||||
# "template_name": data.get("templateName"),
|
|
||||||
# "is_active": data.get("active", 1),
|
|
||||||
# "description": data.get("description", ""),
|
|
||||||
# "company": data.get("company", ""),
|
|
||||||
# "source_quotation": data.get("source_quotation", "")
|
|
||||||
# })
|
|
||||||
|
|
||||||
# for item in data.get("items", []):
|
|
||||||
# item = json.loads(item) if isinstance(item, str) else item
|
|
||||||
# new_template.append("items", {
|
|
||||||
# "item_code": item.get("itemCode"),
|
|
||||||
# "item_name": item.get("itemName"),
|
|
||||||
# "description": item.get("description"),
|
|
||||||
# "qty": item.get("quantity"),
|
|
||||||
# "discount_percentage": item.get("discountPercentage"),
|
|
||||||
# "rate": item.get("rate")
|
|
||||||
# })
|
|
||||||
|
|
||||||
# new_template.insert()
|
|
||||||
# print("DEBUG: New estimate template created with name:", new_template.name)
|
|
||||||
# return build_success_response(new_template.as_dict())
|
|
||||||
# except Exception as e:
|
|
||||||
# return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def upsert_estimate(data):
|
|
||||||
"""Create or update an estimate."""
|
|
||||||
try:
|
|
||||||
data = json.loads(data) if isinstance(data, str) else data
|
|
||||||
print("DEBUG: Upsert estimate data:", data)
|
|
||||||
address_doc = AddressService.get_or_throw(data.get("address_name"))
|
|
||||||
estimate_name = data.get("estimate_name")
|
|
||||||
project_template = data.get("project_template", None)
|
|
||||||
|
|
||||||
# If estimate_name exists, update existing estimate
|
|
||||||
if estimate_name:
|
|
||||||
print(f"DEBUG: Updating existing estimate: {estimate_name}")
|
|
||||||
estimate = frappe.get_doc("Quotation", estimate_name)
|
|
||||||
|
|
||||||
# Update fields
|
|
||||||
# estimate.custom_installation_address = data.get("address")
|
|
||||||
# estimate.custom_job_address = data.get("address_name")
|
|
||||||
# estimate.party_name = data.get("customer")
|
|
||||||
# estimate.contact_person = data.get("contact_name")
|
|
||||||
estimate.requires_half_payment = data.get("requires_half_payment", 0)
|
|
||||||
estimate.custom_project_template = project_template
|
|
||||||
estimate.custom_quotation_template = data.get("quotation_template", None)
|
|
||||||
# estimate.company = data.get("company")
|
|
||||||
# estimate.contact_email = data.get("contact_email")
|
|
||||||
# estimate.quotation_to = client_doctype
|
|
||||||
# estimate.customer_name = data.get("customer")
|
|
||||||
# estimate.customer_address = data.get("address_name")
|
|
||||||
# estimate.letter_head = data.get("company")
|
|
||||||
# estimate.from_onsite_meeting = data.get("onsite_meeting", None)
|
|
||||||
|
|
||||||
# Clear existing items and add new ones
|
|
||||||
estimate.items = []
|
|
||||||
for item in data.get("items", []):
|
|
||||||
item = json.loads(item) if isinstance(item, str) else item
|
|
||||||
estimate.append("items", {
|
|
||||||
"item_code": item.get("item_code"),
|
|
||||||
"qty": item.get("qty"),
|
|
||||||
"rate": item.get("rate"),
|
|
||||||
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
|
|
||||||
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
estimate.save()
|
|
||||||
frappe.db.commit()
|
|
||||||
estimate_dict = estimate.as_dict()
|
|
||||||
estimate_dict["history"] = get_doc_history("Quotation", estimate_name)
|
|
||||||
print(f"DEBUG: Estimate updated: {estimate.name}")
|
|
||||||
return build_success_response(estimate_dict)
|
|
||||||
|
|
||||||
# Otherwise, create new estimate
|
|
||||||
else:
|
|
||||||
print("DEBUG: Creating new estimate")
|
|
||||||
print("DEBUG: Retrieved address name:", data.get("address_name"))
|
|
||||||
client_doc = ClientService.get_client_or_throw(address_doc.customer_name)
|
|
||||||
# billing_address = next((addr for addr in address_doc if addr.address_type == "Billing"), None)
|
|
||||||
# if billing_address:
|
|
||||||
# print("DEBUG: Found billing address:", billing_address.name)
|
|
||||||
# else:
|
|
||||||
# print("DEBUG: No billing address found for client:", client_doc.name)
|
|
||||||
new_estimate = frappe.get_doc({
|
|
||||||
"doctype": "Quotation",
|
|
||||||
"requires_half_payment": data.get("requires_half_payment", 0),
|
|
||||||
"custom_job_address": data.get("address_name"),
|
|
||||||
"custom_current_status": "Draft",
|
|
||||||
"contact_email": data.get("contact_email"),
|
|
||||||
"party_name": data.get("contact_name"),
|
|
||||||
"quotation_to": "Contact",
|
|
||||||
"company": data.get("company"),
|
|
||||||
"actual_customer_name": client_doc.name,
|
|
||||||
"customer_type": address_doc.customer_type,
|
|
||||||
"customer_address": client_doc.custom_billing_address,
|
|
||||||
"contact_person": data.get("contact_name"),
|
|
||||||
"letter_head": data.get("company"),
|
|
||||||
"custom_project_template": data.get("project_template", None),
|
|
||||||
"custom_quotation_template": data.get("quotation_template", None),
|
|
||||||
"from_onsite_meeting": data.get("from_onsite_meeting", None)
|
|
||||||
})
|
|
||||||
for item in data.get("items", []):
|
|
||||||
item = json.loads(item) if isinstance(item, str) else item
|
|
||||||
new_estimate.append("items", {
|
|
||||||
"item_code": item.get("item_code"),
|
|
||||||
"qty": item.get("qty"),
|
|
||||||
"rate": item.get("rate"),
|
|
||||||
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
|
|
||||||
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
|
|
||||||
})
|
|
||||||
# Iterate through every field and print it out, I need to see if there is any field that is a Dynamic link saying Customer
|
|
||||||
for fieldname, value in new_estimate.as_dict().items():
|
|
||||||
print(f"DEBUG: Field '{fieldname}': {value}")
|
|
||||||
new_estimate.insert()
|
|
||||||
# AddressService.append_link(data.get("address_name"), "quotations", "quotation", new_estimate.name)
|
|
||||||
# ClientService.append_link(data.get("customer"), "quotations", "quotation", new_estimate.name)
|
|
||||||
print("DEBUG: New estimate created with name:", new_estimate.name)
|
|
||||||
dict = new_estimate.as_dict()
|
|
||||||
dict["items"] = [ItemService.get_full_dict(item.item_code) for item in new_estimate.items]
|
|
||||||
return build_success_response(dict)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"DEBUG: Error in upsert_estimate: {str(e)}")
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_unapproved_estimates_count(company):
|
|
||||||
"""Get the number of unapproved estimates."""
|
|
||||||
try:
|
|
||||||
draft_filters = {'status': "Draft", "company": company}
|
|
||||||
submitted_filters = {'status': "Submitted", "company": company}
|
|
||||||
draft_count = frappe.db.count("Quotation", filters=draft_filters)
|
|
||||||
submitted_count = frappe.db.count("Quotation", filters=submitted_filters)
|
|
||||||
return build_success_response([draft_count, submitted_count])
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_estimates_half_down_count(company):
|
|
||||||
"""Get the number unpaid half-down estimates."""
|
|
||||||
try:
|
|
||||||
filters = {'requires_half_payment': True, 'company': company}
|
|
||||||
count = frappe.db.count("Quotation", filters=filters)
|
|
||||||
return build_success_response([count])
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
def get_estimate_history(estimate_name):
|
|
||||||
"""Get the history of changes for a specific estimate."""
|
|
||||||
pass
|
|
||||||
# return history
|
|
||||||
|
|
||||||
# @frappe.whitelist()
|
|
||||||
# def get_estimate_counts():
|
|
||||||
# """Get specific counts of estimates based on their status."""
|
|
||||||
# try:
|
|
||||||
# counts = {
|
|
||||||
# "total_estimates": frappe.db.count("Quotation"),
|
|
||||||
# "ready_to_"
|
|
||||||
# }
|
|
||||||
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.db_utils import build_history_entries, build_success_response, build_error_response
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import json
|
|
||||||
|
|
||||||
def get_doc_history(doctype, docname):
|
|
||||||
"""Get the history of changes for a specific document."""
|
|
||||||
# Fetch comments
|
|
||||||
comments = frappe.get_all(
|
|
||||||
"Comment",
|
|
||||||
filters={
|
|
||||||
"reference_doctype": doctype,
|
|
||||||
"reference_name": docname
|
|
||||||
},
|
|
||||||
fields=["*"],
|
|
||||||
order_by="creation desc"
|
|
||||||
)
|
|
||||||
versions = frappe.get_all(
|
|
||||||
"Version",
|
|
||||||
filters={"docname": docname, "ref_doctype": doctype},
|
|
||||||
fields=["*"],
|
|
||||||
order_by="creation desc"
|
|
||||||
)
|
|
||||||
history_entries = build_history_entries(comments, versions)
|
|
||||||
print(f"DEBUG: Retrieved history for {doctype} {docname}: {history_entries}")
|
|
||||||
return history_entries
|
|
||||||
|
|
||||||
def get_docs_history(doctypes_with_names):
|
|
||||||
"""Get history for multiple documents."""
|
|
||||||
all_history = {}
|
|
||||||
for doctype, docname in doctypes_with_names:
|
|
||||||
history = get_doc_history(doctype, docname)
|
|
||||||
all_history[f"{doctype}:{docname}"] = history
|
|
||||||
return all_history
|
|
||||||
|
|
||||||
def search_any_field(doctype, text):
|
|
||||||
meta = frappe.get_meta(doctype)
|
|
||||||
like = f"%{text}%"
|
|
||||||
|
|
||||||
conditions = []
|
|
||||||
|
|
||||||
# 1️⃣ Explicitly include `name`
|
|
||||||
conditions.append("`name` LIKE %s")
|
|
||||||
|
|
||||||
# 2️⃣ Include searchable DocFields
|
|
||||||
for field in meta.fields:
|
|
||||||
if field.fieldtype in ("Data", "Small Text", "Text", "Link"):
|
|
||||||
conditions.append(f"`{field.fieldname}` LIKE %s")
|
|
||||||
|
|
||||||
query = f"""
|
|
||||||
SELECT name
|
|
||||||
FROM `tab{doctype}`
|
|
||||||
WHERE {" OR ".join(conditions)}
|
|
||||||
LIMIT 20
|
|
||||||
"""
|
|
||||||
|
|
||||||
return frappe.db.sql(
|
|
||||||
query,
|
|
||||||
[like] * len(conditions),
|
|
||||||
as_dict=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_week_holidays(week_start_date: str):
|
|
||||||
"""Get holidays within a week starting from the given date."""
|
|
||||||
|
|
||||||
start_date = datetime.strptime(week_start_date, "%Y-%m-%d").date()
|
|
||||||
end_date = start_date + timedelta(days=6)
|
|
||||||
|
|
||||||
holidays = frappe.get_all(
|
|
||||||
"Holiday",
|
|
||||||
filters={
|
|
||||||
"holiday_date": ["between", (start_date, end_date)]
|
|
||||||
},
|
|
||||||
fields=["holiday_date", "description"],
|
|
||||||
order_by="holiday_date asc"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"DEBUG: Retrieved holidays from {start_date} to {end_date}: {holidays}")
|
|
||||||
return build_success_response(holidays)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_doc_list(doctype, fields=["*"], filters={}, pluck=None):
|
|
||||||
"""Get list of documents for a given doctype with specified fields and filters."""
|
|
||||||
if isinstance(fields, str):
|
|
||||||
fields = json.loads(fields)
|
|
||||||
if isinstance(filters, str):
|
|
||||||
filters = json.loads(filters)
|
|
||||||
try:
|
|
||||||
docs = frappe.get_all(
|
|
||||||
doctype,
|
|
||||||
fields=fields,
|
|
||||||
filters=filters,
|
|
||||||
order_by="creation desc",
|
|
||||||
# pluck=pluck
|
|
||||||
)
|
|
||||||
print(f"DEBUG: Retrieved documents for {doctype} with filters {filters}: {docs}")
|
|
||||||
return build_success_response(docs)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
import frappe, json
|
|
||||||
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
|
||||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
|
||||||
|
|
||||||
# ===============================================================================
|
|
||||||
# INVOICES API METHODS
|
|
||||||
# ===============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def create_invoice_for_job(job_name):
|
|
||||||
"""Create the invoice from a sales order of a job."""
|
|
||||||
try:
|
|
||||||
project = frappe.get_doc("Project", job_name)
|
|
||||||
sales_order = project.sales_order
|
|
||||||
invoice = make_sales_invoice(sales_order)
|
|
||||||
invoice.save()
|
|
||||||
return build_success_response(invoice.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_invoices_late_count():
|
|
||||||
"""Return Due, 30-day late, 90-day late, and Lien-worthy late accounts."""
|
|
||||||
try:
|
|
||||||
dummy_result = [10, 4, 5, 1]
|
|
||||||
print("DEBUG: DUMMY RESULT:", dummy_result)
|
|
||||||
return build_success_response(dummy_result)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10):
|
|
||||||
"""Get paginated invoice table data with filtering and sorting support."""
|
|
||||||
print("DEBUG: Raw invoice options received:", filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
if is_or:
|
|
||||||
count = frappe.db.sql(*get_count_or_filters("Sales Invoice", processed_filters))[0][0]
|
|
||||||
else:
|
|
||||||
count = frappe.db.count("Sales Invoice", filters=processed_filters)
|
|
||||||
|
|
||||||
print(f"DEBUG: Number of invoices returned: {count}")
|
|
||||||
|
|
||||||
invoices = frappe.db.get_all(
|
|
||||||
"Sales Invoice",
|
|
||||||
fields=["*"],
|
|
||||||
filters=processed_filters if not is_or else None,
|
|
||||||
or_filters=processed_filters if is_or else None,
|
|
||||||
limit=page_size,
|
|
||||||
start=(page - 1) * page_size,
|
|
||||||
order_by=processed_sortings
|
|
||||||
)
|
|
||||||
|
|
||||||
tableRows = []
|
|
||||||
for invoice in invoices:
|
|
||||||
tableRow = {}
|
|
||||||
tableRow["id"] = invoice["name"]
|
|
||||||
tableRow["address"] = invoice.get("custom_installation_address", "")
|
|
||||||
tableRow["customer"] = invoice.get("customer", "")
|
|
||||||
tableRow["grand_total"] = f"${invoice.get('grand_total', '')}0"
|
|
||||||
tableRow["status"] = invoice.get("status", "")
|
|
||||||
tableRow["items"] = invoice.get("items", "")
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
|
|
||||||
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
return build_success_response(table_data_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_invoice(invoice_name):
|
|
||||||
"""Get detailed information for a specific invoice."""
|
|
||||||
try:
|
|
||||||
invoice = frappe.get_doc("Sales Invoice", invoice_name)
|
|
||||||
return build_success_response(invoice.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_invoice_items():
|
|
||||||
items = frappe.db.get_all("Sales Invoice Item", fields=["*"])
|
|
||||||
return build_success_response(items)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_invoice_from_address(full_address):
|
|
||||||
invoice = frappe.db.sql("""
|
|
||||||
SELECT i.name, i.custom_installation_address
|
|
||||||
FROM `tabSalesInvoice` i
|
|
||||||
JOIN `tabAddress` a
|
|
||||||
ON i.custom_installation_address = a.name
|
|
||||||
WHERE a.full_address =%s
|
|
||||||
""", (full_address,), as_dict=True)
|
|
||||||
if invoice:
|
|
||||||
return build_success_response(invoice)
|
|
||||||
else:
|
|
||||||
return build_error_response("No invoice found for the given address.", 404)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def upsert_invoice(data):
|
|
||||||
"""Create or update an invoice."""
|
|
||||||
print("DOIFJSEOFJISLFK")
|
|
||||||
try:
|
|
||||||
data = json.loads(data) if isinstance(data, str) else data
|
|
||||||
print("DEBUG: Retrieved address name:", data.get("address_name"))
|
|
||||||
new_invoice = frappe.get_doc({
|
|
||||||
"doctype": "Sales Invoice",
|
|
||||||
"custom_installation_address": data.get("address_name"),
|
|
||||||
"contact_email": data.get("contact_email"),
|
|
||||||
"party_name": data.get("contact_name"),
|
|
||||||
"customer_name": data.get("customer_name"),
|
|
||||||
})
|
|
||||||
for item in data.get("items", []):
|
|
||||||
item = json.loads(item) if isinstance(item, str) else item
|
|
||||||
new_invoice.append("items", {
|
|
||||||
"item_code": item.get("item_code"),
|
|
||||||
"qty": item.get("qty"),
|
|
||||||
})
|
|
||||||
new_invoice.insert()
|
|
||||||
print("DEBUG: New invoice created with name:", new_invoice.name)
|
|
||||||
return build_success_response(new_invoice.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
import frappe
|
|
||||||
import json
|
|
||||||
from custom_ui.models import PackageCreationData
|
|
||||||
from custom_ui.services import ProjectService, ItemService
|
|
||||||
from custom_ui.db_utils import build_error_response, build_success_response
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_by_project_template(project_template: str) -> dict:
|
|
||||||
"""Retrieve items associated with a given project template."""
|
|
||||||
print(f"DEBUG: Getting items for Project Template {project_template}")
|
|
||||||
item_groups = ProjectService.get_project_item_groups(project_template)
|
|
||||||
items = ItemService.get_items_by_groups(item_groups)
|
|
||||||
print(f"DEBUG: Retrieved {len(items)} items for Project Template {project_template}")
|
|
||||||
categorized_items = ItemService.build_category_dict(items)
|
|
||||||
return build_success_response(categorized_items)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def save_as_package_item(data):
|
|
||||||
"""Save a new Package Item based on the provided data."""
|
|
||||||
from custom_ui.models import BOMItem
|
|
||||||
data = json.loads(data)
|
|
||||||
print(f"DEBUG: Saving Package Item with data: {data}")
|
|
||||||
# Map 'category' to 'item_group' for the model
|
|
||||||
data['item_group'] = data.pop('category')
|
|
||||||
# Convert items dictionaries to BOMItem instances
|
|
||||||
data['items'] = [
|
|
||||||
BOMItem(
|
|
||||||
item_code=item['item_code'],
|
|
||||||
qty=item['qty'],
|
|
||||||
uom=item['uom']
|
|
||||||
) for item in data['items']
|
|
||||||
]
|
|
||||||
data = PackageCreationData(**data)
|
|
||||||
item = frappe.get_doc({
|
|
||||||
"doctype": "Item",
|
|
||||||
"item_code": ItemService.build_item_code(data.code_prefix, data.package_name),
|
|
||||||
"item_name": data.package_name,
|
|
||||||
"is_stock_item": 0,
|
|
||||||
"item_group": data.item_group,
|
|
||||||
"description": data.description,
|
|
||||||
"standard_rate": data.rate or 0.0,
|
|
||||||
"company": data.company,
|
|
||||||
"has_variants": 0,
|
|
||||||
"stock_uom": "Nos",
|
|
||||||
"is_sales_item": 1,
|
|
||||||
"is_purchase_item": 0,
|
|
||||||
"is_pro_applicable": 0,
|
|
||||||
"is_fixed_asset": 0,
|
|
||||||
"is_service_item": 0
|
|
||||||
}).insert()
|
|
||||||
bom = frappe.get_doc({
|
|
||||||
"doctype": "BOM",
|
|
||||||
"item": item.name,
|
|
||||||
"uom": "Nos",
|
|
||||||
"is_active": 1,
|
|
||||||
"is_default": 1,
|
|
||||||
"items": [{
|
|
||||||
"item_code": bom_item.item_code,
|
|
||||||
"qty": bom_item.qty,
|
|
||||||
"uom": bom_item.uom
|
|
||||||
} for bom_item in data.items]
|
|
||||||
}).insert()
|
|
||||||
bom.submit()
|
|
||||||
item.reload() # Refresh to get latest version after BOM submission
|
|
||||||
item.default_bom = bom.name
|
|
||||||
item.save()
|
|
||||||
print(f"DEBUG: Created Package Item with name: {item.name}")
|
|
||||||
item_dict = item.as_dict()
|
|
||||||
item_dict["bom"] = ItemService.get_full_bom_dict(item.item_code) # Attach BOM details to the item dict
|
|
||||||
return build_success_response(item_dict)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_item_categories():
|
|
||||||
"""Retrieve all item groups for categorization."""
|
|
||||||
print("DEBUG: Getting item categories")
|
|
||||||
item_groups = frappe.get_all("Item Group", pluck="name")
|
|
||||||
print(f"DEBUG: Retrieved item categories: {item_groups}")
|
|
||||||
return build_success_response(item_groups)
|
|
||||||
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
import frappe, json
|
|
||||||
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response, process_sorting
|
|
||||||
from custom_ui.services import AddressService, ClientService, ServiceAppointmentService, ProjectService
|
|
||||||
from frappe.utils import getdate
|
|
||||||
|
|
||||||
# ===============================================================================
|
|
||||||
# JOB MANAGEMENT API METHODS
|
|
||||||
# ===============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_jobs_in_queue_count(company):
|
|
||||||
try:
|
|
||||||
filters = {
|
|
||||||
'company': company,
|
|
||||||
'is_scheduled': True,
|
|
||||||
}
|
|
||||||
count = frappe.db.count("Project", filters=filters)
|
|
||||||
return build_success_response([count])
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_jobs_in_progress_count(company):
|
|
||||||
try:
|
|
||||||
today = getdate()
|
|
||||||
filters = {
|
|
||||||
'company': company,
|
|
||||||
'invoice_status': 'Not Ready',
|
|
||||||
'expected_start_date': ['<=', today],
|
|
||||||
'expected_end_date': ['>=', today],
|
|
||||||
}
|
|
||||||
count = frappe.db.count("Project", filters=filters)
|
|
||||||
return build_success_response([count])
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_jobs_late_count(company):
|
|
||||||
try:
|
|
||||||
today = getdate()
|
|
||||||
filters = {
|
|
||||||
'company': company,
|
|
||||||
'invoice_status': 'Not Ready',
|
|
||||||
'expected_end_date': ['<', today]
|
|
||||||
}
|
|
||||||
count = frappe.db.count("Project", filters=filters)
|
|
||||||
return build_success_response([count])
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_jobs_to_invoice_count(company):
|
|
||||||
try:
|
|
||||||
filters = {
|
|
||||||
'company': company,
|
|
||||||
'invoice_status': 'Ready to Invoice',
|
|
||||||
}
|
|
||||||
count = frappe.db.count("Project", filters=filters)
|
|
||||||
return build_success_response([count])
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_job_templates(company=None):
|
|
||||||
"""Get list of job (project) templates."""
|
|
||||||
filters = {}
|
|
||||||
if company:
|
|
||||||
filters["company"] = company
|
|
||||||
try:
|
|
||||||
templates = frappe.get_all("Project Template", fields=["*"], filters=filters)
|
|
||||||
return build_success_response(templates)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def create_job_from_sales_order(sales_order_name):
|
|
||||||
"""Create a Job (Project) from a given Sales Order"""
|
|
||||||
try:
|
|
||||||
sales_order = frappe.get_doc("Sales Order", sales_order_name)
|
|
||||||
project_template = frappe.get_doc("Project Template", "SNW Install")
|
|
||||||
new_project = frappe.get_doc({
|
|
||||||
"doctype": "Project",
|
|
||||||
"custom_address": sales_order.custom_job_address,
|
|
||||||
# "custom_installation_address": sales_order.custom_installation_address,
|
|
||||||
"project_name": sales_order.custom_job_address,
|
|
||||||
"project_template": project_template,
|
|
||||||
"custom_warranty_duration_days": 90,
|
|
||||||
"sales_order": sales_order,
|
|
||||||
"custom_company": sales_order.company
|
|
||||||
})
|
|
||||||
new_project.insert()
|
|
||||||
for sales_order_item in sales_order.items:
|
|
||||||
new_task = frappe.get_doc({
|
|
||||||
"doctype": "Task",
|
|
||||||
"project": new_project.name,
|
|
||||||
"company": sales_order.company,
|
|
||||||
"custom_property": sales_order.custom_job_address,
|
|
||||||
"subject": sales_order_item.description,
|
|
||||||
})
|
|
||||||
new_task.insert()
|
|
||||||
# Iterate through new tasks (if any) and set customer, address
|
|
||||||
# job_tasks = frappe.get_all("Task", filters={"Project": new_job.name})
|
|
||||||
# for task in job_tasks:
|
|
||||||
# task.custom_property = new_job.job_address
|
|
||||||
# task.save()
|
|
||||||
return build_success_response(new_project.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_job(job_id=""):
|
|
||||||
"""Get particular Job from DB"""
|
|
||||||
print("DEBUG: Loading Job from database:", job_id)
|
|
||||||
try:
|
|
||||||
project = ProjectService.get_full_project_details(job_id)
|
|
||||||
return build_success_response(project)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_job_task_table_data(filters={}, sortings={}, page=1, page_size=10):
|
|
||||||
"""Get paginated job tasks table data with filtering and sorting support."""
|
|
||||||
print("DEBUG: raw task options received:", filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
print("DEBUG: Processed Filters:", processed_filters)
|
|
||||||
|
|
||||||
if is_or:
|
|
||||||
count = frappe.db.sql(*get_count_or_filters("Task", filters))[0][0]
|
|
||||||
else:
|
|
||||||
count = frappe.db.count("Task", filters=filters)
|
|
||||||
|
|
||||||
print(f"DEBUG: Number of tasks returned: {count}")
|
|
||||||
|
|
||||||
tasks = frappe.db.get_all(
|
|
||||||
"Task",
|
|
||||||
fields=["*"],
|
|
||||||
filters=filters,
|
|
||||||
limit=page_size,
|
|
||||||
start=(page - 1) * page_size,
|
|
||||||
order_by=processed_sortings
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
tableRows = []
|
|
||||||
for task in tasks:
|
|
||||||
address_name = frappe.get_value("Project", task.project, "job_address")
|
|
||||||
full_address = frappe.get_value("Address", address_name, "full_address")
|
|
||||||
tableRow = {}
|
|
||||||
tableRow["id"] = task["name"]
|
|
||||||
tableRow["subject"] = task["subject"]
|
|
||||||
tableRow["address"] = full_address
|
|
||||||
tableRow["status"] = task.get("status", "")
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
|
|
||||||
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
return build_success_response(table_data_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_job_task_list(job_id=""):
|
|
||||||
if job_id:
|
|
||||||
try:
|
|
||||||
tasks = frappe.get_all('Task', filters={"project": job_id})
|
|
||||||
task_docs = {task_id: frappe.get_doc(task_id) for task_id in tasks}
|
|
||||||
return build_success_response(task_docs)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_jobs_table_data(filters={}, sortings=[], page=1, page_size=10):
|
|
||||||
"""Get paginated job table data with filtering and sorting support."""
|
|
||||||
print("DEBUG: Raw job options received:", filters, sortings, page, page_size)
|
|
||||||
filters = json.loads(filters) if isinstance(filters, str) else filters
|
|
||||||
sortings = json.loads(sortings) if isinstance(sortings, str) else sortings
|
|
||||||
|
|
||||||
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
# Handle count with proper OR filter support
|
|
||||||
if is_or:
|
|
||||||
count = frappe.db.sql(*get_count_or_filters("Project", processed_filters))[0][0]
|
|
||||||
else:
|
|
||||||
count = frappe.db.count("Project", filters=processed_filters)
|
|
||||||
|
|
||||||
projects = frappe.db.get_all(
|
|
||||||
"Project",
|
|
||||||
fields=["*"],
|
|
||||||
filters=processed_filters if not is_or else None,
|
|
||||||
or_filters=processed_filters if is_or else None,
|
|
||||||
limit=page_size,
|
|
||||||
start=page * page_size,
|
|
||||||
order_by=processed_sortings
|
|
||||||
)
|
|
||||||
|
|
||||||
tableRows = []
|
|
||||||
for project in projects:
|
|
||||||
tableRow = {}
|
|
||||||
tableRow["id"] = project["name"]
|
|
||||||
tableRow["name"] = project["name"]
|
|
||||||
tableRow["job_address"] = project["job_address"]
|
|
||||||
tableRow["customer"] = project.get("customer", "")
|
|
||||||
tableRow["status"] = project.get("status", "")
|
|
||||||
tableRow["invoice_status"] = project.get("invoice_status")
|
|
||||||
tableRow["percent_complete"] = project.get("percent_complete", 0)
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
|
|
||||||
data_table_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
return build_success_response(data_table_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def upsert_job(data):
|
|
||||||
"""Create or update a job (project)."""
|
|
||||||
try:
|
|
||||||
if isinstance(data, str):
|
|
||||||
data = json.loads(data)
|
|
||||||
|
|
||||||
project_id = data.get("id")
|
|
||||||
if not project_id:
|
|
||||||
return {"status": "error", "message": "Project ID is required"}
|
|
||||||
|
|
||||||
project = frappe.get_doc("Project", project_id)
|
|
||||||
|
|
||||||
if "scheduledDate" in data:
|
|
||||||
project.expected_start_date = data["scheduledDate"]
|
|
||||||
|
|
||||||
if "foreman" in data:
|
|
||||||
project.custom_install_crew = data["foreman"]
|
|
||||||
|
|
||||||
project.save()
|
|
||||||
return {"status": "success", "data": project.as_dict()}
|
|
||||||
except Exception as e:
|
|
||||||
return {"status": "error", "message": str(e)}
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_projects_for_calendar(start_date, end_date, company=None, project_templates=[]):
|
|
||||||
"""Get install projects for the calendar."""
|
|
||||||
# Parse project_templates if it's a JSON string
|
|
||||||
if isinstance(project_templates, str):
|
|
||||||
project_templates = json.loads(project_templates)
|
|
||||||
|
|
||||||
# put some emojis in the print to make it stand out
|
|
||||||
print("📅📅📅", start_date, end_date, " company:", company, "project_templates:", project_templates, "type:", type(project_templates))
|
|
||||||
try:
|
|
||||||
filters = {
|
|
||||||
"company": company
|
|
||||||
} if company else {}
|
|
||||||
if project_templates and len(project_templates) > 0:
|
|
||||||
filters["project_template"] = ["in", project_templates]
|
|
||||||
unscheduled_filters = filters.copy()
|
|
||||||
unscheduled_filters["is_scheduled"] = 0
|
|
||||||
|
|
||||||
# add to filter for if expected_start_date is between start_date and end_date OR expected_end_date is between start_date and end_date
|
|
||||||
filters["expected_start_date"] = ["<=", getdate(end_date)]
|
|
||||||
filters["expected_end_date"] = [">=", getdate(start_date)]
|
|
||||||
# If date range provided, we could filter, but for now let's fetch all open/active ones
|
|
||||||
# or maybe filter by status not Closed/Completed if we want active ones.
|
|
||||||
# The user said "unscheduled" are those with status "Open" (and no date).
|
|
||||||
# extend filters into unscheduled_filters
|
|
||||||
|
|
||||||
project_names = frappe.get_all("Project", pluck="name", filters=filters)
|
|
||||||
print("DEBUG: Found scheduled project names:", project_names)
|
|
||||||
unscheduled_project_names = frappe.get_all("Project", pluck="name", filters=unscheduled_filters)
|
|
||||||
print("DEBUG: Found unscheduled project names:", unscheduled_project_names)
|
|
||||||
projects = [frappe.get_doc("Project", name).as_dict() for name in project_names]
|
|
||||||
unscheduled_projects = [frappe.get_doc("Project", name).as_dict() for name in unscheduled_project_names]
|
|
||||||
return build_success_response({ "projects": projects, "unscheduled_projects": unscheduled_projects })
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def update_job_scheduled_dates(job_name: str, new_start_date: str = None, new_end_date: str = None, foreman_name: str = None):
|
|
||||||
"""Update job (project) schedule dates."""
|
|
||||||
print("DEBUG: Updating job schedule:", job_name, new_start_date, new_end_date, foreman_name)
|
|
||||||
try:
|
|
||||||
project = frappe.get_doc("Project", job_name)
|
|
||||||
project.expected_start_date = getdate(new_start_date) if new_start_date else None
|
|
||||||
project.expected_end_date = getdate(new_end_date) if new_end_date else None
|
|
||||||
if new_start_date and new_end_date:
|
|
||||||
project.is_scheduled = 1
|
|
||||||
else:
|
|
||||||
project.is_scheduled = 0
|
|
||||||
if foreman_name:
|
|
||||||
project.custom_foreman = foreman_name
|
|
||||||
project.save()
|
|
||||||
return build_success_response(project.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.db_utils import build_success_response, build_error_response
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_incomplete_bids(company):
|
|
||||||
print("Getting Incomplete Bids")
|
|
||||||
try:
|
|
||||||
filters = {'status': "Unscheduled", 'company': company}
|
|
||||||
count = frappe.db.count("On-Site Meeting", filters=filters)
|
|
||||||
print("Incomplete Bids:", count)
|
|
||||||
return build_success_response([count])
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.db_utils import build_success_response, build_error_response
|
|
||||||
from erpnext.selling.doctype.quotation.quotation import make_sales_order
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def create_sales_order_from_estimate(estimate_name):
|
|
||||||
"""Create a Sales Order from a given Estimate (Quotation)."""
|
|
||||||
try:
|
|
||||||
estimate = frappe.get_doc("Quotation", estimate_name)
|
|
||||||
if estimate.custom_current_status != "Estimate Accepted":
|
|
||||||
raise Exception("Estimate must be accepted to create a Sales Order.")
|
|
||||||
new_sales_order = make_sales_order(estimate_name)
|
|
||||||
new_sales_order.custom_requires_half_payment = estimate.requires_half_payment
|
|
||||||
new_sales_order.insert()
|
|
||||||
return build_success_response(new_sales_order.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import frappe, json
|
|
||||||
from custom_ui.db_utils import build_success_response, build_error_response
|
|
||||||
from custom_ui.services import ServiceAppointmentService
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_service_appointment(service_appointment_name):
|
|
||||||
"""Get a single Service Appointment by name."""
|
|
||||||
try:
|
|
||||||
service_appointment = ServiceAppointmentService.get_full_dict(service_appointment_name)
|
|
||||||
return build_success_response(service_appointment)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_service_appointments(companies, filters={}):
|
|
||||||
"""Get Service Appointments for given companies."""
|
|
||||||
try:
|
|
||||||
if isinstance(companies, str):
|
|
||||||
companies = json.loads(companies)
|
|
||||||
if isinstance(filters, str):
|
|
||||||
filters = json.loads(filters)
|
|
||||||
filters["company"] = ["in", companies]
|
|
||||||
service_appointment_names = frappe.get_all(
|
|
||||||
"Service Address 2",
|
|
||||||
filters=filters,
|
|
||||||
pluck="name"
|
|
||||||
)
|
|
||||||
service_appointments = [
|
|
||||||
ServiceAppointmentService.get_full_dict(name)
|
|
||||||
for name in service_appointment_names
|
|
||||||
]
|
|
||||||
|
|
||||||
"is_half_down_paid"
|
|
||||||
|
|
||||||
return build_success_response(service_appointments)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_unscheduled_service_appointments(companies):
|
|
||||||
"""Get unscheduled Service Appointments for given companies."""
|
|
||||||
try:
|
|
||||||
if isinstance(companies, str):
|
|
||||||
companies = json.loads(companies)
|
|
||||||
filters = {
|
|
||||||
"company": ["in", companies],
|
|
||||||
"expected_start_date": None,
|
|
||||||
"status": "Open"
|
|
||||||
}
|
|
||||||
service_appointment_names = frappe.get_all(
|
|
||||||
"Service Address 2",
|
|
||||||
filters=filters,
|
|
||||||
pluck="name"
|
|
||||||
)
|
|
||||||
service_appointments = [
|
|
||||||
ServiceAppointmentService.get_full_dict(name)
|
|
||||||
for name in service_appointment_names
|
|
||||||
]
|
|
||||||
return build_success_response(service_appointments)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def update_service_appointment_scheduled_dates(service_appointment_name: str, start_date, end_date, crew_lead_name, start_time=None, end_time=None):
|
|
||||||
"""Update scheduled dates for a Service Appointment."""
|
|
||||||
print(f"DEBUG: Updating scheduled dates for Service Appointment {service_appointment_name} to start: {start_date}, end: {end_date}, crew lead: {crew_lead_name}, start time: {start_time}, end time: {end_time}")
|
|
||||||
try:
|
|
||||||
updated_service_appointment = ServiceAppointmentService.update_scheduled_dates(
|
|
||||||
service_appointment_name,
|
|
||||||
crew_lead_name,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
start_time,
|
|
||||||
end_time
|
|
||||||
)
|
|
||||||
return build_success_response(updated_service_appointment.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def update_service_appointment_status(service_appointment_name: str, new_status: str):
|
|
||||||
"""Update status for a Service Appointment."""
|
|
||||||
print(f"DEBUG: Updating status for Service Appointment {service_appointment_name} to new status: {new_status}")
|
|
||||||
try:
|
|
||||||
updated_service_appointment = ServiceAppointmentService.update_status(
|
|
||||||
service_appointment_name,
|
|
||||||
new_status
|
|
||||||
)
|
|
||||||
return build_success_response(updated_service_appointment.as_dict())
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import frappe
|
|
||||||
import datetime
|
|
||||||
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
|
|
||||||
from custom_ui.services import DbService
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def set_task_status(task_name, new_status):
|
|
||||||
"""Set the status of a specific task."""
|
|
||||||
try:
|
|
||||||
task = DbService.get_or_throw("Task", task_name)
|
|
||||||
task.status = new_status
|
|
||||||
task.save()
|
|
||||||
return build_success_response(f"Task {task_name} status updated to {new_status}.")
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_job_task_list(job_id=""):
|
|
||||||
if job_id:
|
|
||||||
try:
|
|
||||||
tasks = frappe.get_all('Task', filters={"project": job_id})
|
|
||||||
task_docs = {task_id: frappe.get_doc(task_id) for task_id in tasks}
|
|
||||||
return build_success_response(task_docs)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_task_status_options():
|
|
||||||
print("DEBUG: Getting task status options")
|
|
||||||
try:
|
|
||||||
task_doctype = frappe.get_doc("DocType", "Task")
|
|
||||||
status_index = 0
|
|
||||||
for i, field in enumerate(task_doctype.fields):
|
|
||||||
if field.fieldname == "status":
|
|
||||||
status_index = i
|
|
||||||
break
|
|
||||||
options = task_doctype.fields[status_index].options.split()
|
|
||||||
print("DEBUG: Task Status options retreived", options)
|
|
||||||
return build_success_response(options)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_tasks_due(subject_filter, current_company):
|
|
||||||
"""Return the number of items due today of the type of subject_filter"""
|
|
||||||
try:
|
|
||||||
today = datetime.date.today()
|
|
||||||
due_filters = {
|
|
||||||
'subject': ['like', f'%{subject_filter}%'],
|
|
||||||
'status': ['not in', ["Template", "Completed", "Cancelled"]],
|
|
||||||
'company': current_company,
|
|
||||||
'exp_end_date': today,
|
|
||||||
# Add due date filter here
|
|
||||||
}
|
|
||||||
completed_filters = {
|
|
||||||
'subject': ['like', f'%{subject_filter}%'],
|
|
||||||
'status': ['not in', ["Template", "Cancelled"]],
|
|
||||||
'company': current_company,
|
|
||||||
'exp_end_date': today,
|
|
||||||
# Add due date filter here
|
|
||||||
}
|
|
||||||
overdue_filters = {
|
|
||||||
'subject': ['like', f'%{subject_filter}%'],
|
|
||||||
'status': ['not in', ["Template", "Completed", "Cancelled"]],
|
|
||||||
'company': current_company,
|
|
||||||
'exp_end_date': ["<", today]
|
|
||||||
# Add overdue date filtering here
|
|
||||||
}
|
|
||||||
due_count = frappe.db.count("Task", filters=due_filters)
|
|
||||||
completed_count = frappe.db.count("Task", filters=completed_filters)
|
|
||||||
overdue_count = frappe.db.count("Task", filters=overdue_filters)
|
|
||||||
return build_success_response([due_count, completed_count, overdue_count])
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_tasks_table_data(filters={}, sortings=[], page=1, page_size=10):
|
|
||||||
"""Get paginated task table data with filtering and sorting support."""
|
|
||||||
try:
|
|
||||||
print("DEBUG: Raw task options received:", filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
processed_filters['status'] = ["not in", ["Template", "Completed", "Cancelled"]]
|
|
||||||
|
|
||||||
print("Processed filters:", processed_filters)
|
|
||||||
|
|
||||||
count = frappe.db.count("Task", filters=processed_filters)
|
|
||||||
|
|
||||||
tasks = frappe.db.get_all(
|
|
||||||
"Task",
|
|
||||||
fields=["*"],
|
|
||||||
filters=processed_filters,
|
|
||||||
limit=page_size,
|
|
||||||
start=(page-1) * page_size,
|
|
||||||
order_by=processed_sortings
|
|
||||||
)
|
|
||||||
|
|
||||||
print("TASKS?", tasks, page, page_size)
|
|
||||||
|
|
||||||
tableRows = []
|
|
||||||
for task in tasks:
|
|
||||||
tableRow = {}
|
|
||||||
tableRow["id"] = task["name"]
|
|
||||||
tableRow["subject"] = task["subject"]
|
|
||||||
tableRow["project"] = task["project"]
|
|
||||||
tableRow["address"] = task.get("custom_property", "")
|
|
||||||
tableRow["status"] = task.get("status", "")
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
|
|
||||||
data_table_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
return build_success_response(data_table_dict)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
import frappe, json, re
|
|
||||||
from datetime import datetime, date
|
|
||||||
from custom_ui.db_utils import build_error_response, build_success_response, process_query_conditions, build_datatable_dict, get_count_or_filters
|
|
||||||
|
|
||||||
# ===============================================================================
|
|
||||||
# WARRANTY MANAGEMENT API METHODS
|
|
||||||
# ===============================================================================
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_warranty_claims(filters={}, sortings=[], page=1, page_size=10):
|
|
||||||
"""Get paginated warranty claims table data with filtering and sorting support."""
|
|
||||||
try:
|
|
||||||
print("DEBUG: Raw warranty options received:", filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
|
|
||||||
|
|
||||||
# Handle count with proper OR filter support
|
|
||||||
if is_or:
|
|
||||||
count = frappe.db.sql(*get_count_or_filters("Warranty Claim", processed_filters))[0][0]
|
|
||||||
else:
|
|
||||||
count = frappe.db.count("Warranty Claim", filters=processed_filters)
|
|
||||||
|
|
||||||
warranty_claims = frappe.db.get_all(
|
|
||||||
"Warranty Claim",
|
|
||||||
fields=["*"],
|
|
||||||
filters=processed_filters if not is_or else None,
|
|
||||||
or_filters=processed_filters if is_or else None,
|
|
||||||
limit=page_size,
|
|
||||||
start=(page - 1) * page_size,
|
|
||||||
order_by=processed_sortings
|
|
||||||
)
|
|
||||||
|
|
||||||
tableRows = []
|
|
||||||
for warranty in warranty_claims:
|
|
||||||
tableRow = {}
|
|
||||||
tableRow["id"] = warranty["name"]
|
|
||||||
tableRow["warrantyId"] = warranty["name"]
|
|
||||||
tableRow["customer"] = warranty.get("customer_name", "")
|
|
||||||
tableRow["serviceAddress"] = warranty.get("service_address", warranty.get("address_display", ""))
|
|
||||||
|
|
||||||
# Extract a brief description from the complaint HTML
|
|
||||||
complaint_text = warranty.get("complaint", "")
|
|
||||||
if complaint_text:
|
|
||||||
# Simple HTML stripping for display - take first 100 chars
|
|
||||||
clean_text = re.sub('<.*?>', '', complaint_text)
|
|
||||||
clean_text = clean_text.strip()
|
|
||||||
if len(clean_text) > 100:
|
|
||||||
clean_text = clean_text[:100] + "..."
|
|
||||||
tableRow["issueDescription"] = clean_text
|
|
||||||
else:
|
|
||||||
tableRow["issueDescription"] = ""
|
|
||||||
|
|
||||||
tableRow["status"] = warranty.get("status", "")
|
|
||||||
tableRow["complaintDate"] = warranty.get("complaint_date", "")
|
|
||||||
tableRow["complaintRaisedBy"] = warranty.get("complaint_raised_by", "")
|
|
||||||
tableRow["fromCompany"] = warranty.get("from_company", "")
|
|
||||||
tableRow["territory"] = warranty.get("territory", "")
|
|
||||||
tableRow["resolutionDate"] = warranty.get("resolution_date", "")
|
|
||||||
tableRow["warrantyStatus"] = warranty.get("warranty_amc_status", "")
|
|
||||||
|
|
||||||
# Add priority based on status and date (can be customized)
|
|
||||||
if warranty.get("status") == "Open":
|
|
||||||
# Calculate priority based on complaint date
|
|
||||||
if warranty.get("complaint_date"):
|
|
||||||
complaint_date = warranty.get("complaint_date")
|
|
||||||
if isinstance(complaint_date, str):
|
|
||||||
complaint_date = datetime.strptime(complaint_date, "%Y-%m-%d").date()
|
|
||||||
elif isinstance(complaint_date, datetime):
|
|
||||||
complaint_date = complaint_date.date()
|
|
||||||
|
|
||||||
days_old = (date.today() - complaint_date).days
|
|
||||||
if days_old > 7:
|
|
||||||
tableRow["priority"] = "High"
|
|
||||||
elif days_old > 3:
|
|
||||||
tableRow["priority"] = "Medium"
|
|
||||||
else:
|
|
||||||
tableRow["priority"] = "Low"
|
|
||||||
else:
|
|
||||||
tableRow["priority"] = "Medium"
|
|
||||||
else:
|
|
||||||
tableRow["priority"] = "Low"
|
|
||||||
tableRows.append(tableRow)
|
|
||||||
|
|
||||||
tableDataDict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
|
|
||||||
return build_success_response(tableDataDict)
|
|
||||||
except frappe.ValidationError as ve:
|
|
||||||
return build_error_response(str(ve), 400)
|
|
||||||
except Exception as e:
|
|
||||||
return build_error_response(str(e), 500)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def upsert_warranty(data):
|
|
||||||
"""Create or update a warranty claim."""
|
|
||||||
# TODO: Implement warranty creation/update logic
|
|
||||||
pass
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import frappe
|
|
||||||
import requests
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
from custom_ui.db_utils import build_success_response, build_error_response
|
|
||||||
import logging
|
|
||||||
|
|
||||||
allowed_hosts = ["api.zippopotam.us", "nominatim.openstreetmap.org"] # Update this list with trusted domains as needed
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def request(url, method="GET", data=None, headers=None):
|
|
||||||
"""
|
|
||||||
Generic proxy for external API requests.
|
|
||||||
WARNING: Only allow requests to trusted domains.
|
|
||||||
"""
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
if parsed_url.hostname not in allowed_hosts:
|
|
||||||
frappe.throw(f"Requests to {parsed_url.hostname} are not allowed.", frappe.PermissionError)
|
|
||||||
try:
|
|
||||||
resp = requests.request(
|
|
||||||
method=method.upper(),
|
|
||||||
url=url,
|
|
||||||
json=frappe.parse_json(data) if data else None,
|
|
||||||
headers=frappe.parse_json(headers) if headers else None,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
try:
|
|
||||||
return build_success_response(resp.json())
|
|
||||||
except ValueError:
|
|
||||||
return build_success_response({"text": resp.text})
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
frappe.log_error(message=str(e), title="Proxy Request Failed")
|
|
||||||
frappe.throw("Failed to fetch data from external API.")
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from werkzeug.wrappers import Response
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def update_response(name, response):
|
|
||||||
"""Update the response for a given estimate."""
|
|
||||||
print("DEBUG: RESPONSE RECEIVED:", name, response)
|
|
||||||
try:
|
|
||||||
if not frappe.db.exists("Quotation", name):
|
|
||||||
raise Exception("Estimate not found.")
|
|
||||||
estimate = frappe.get_doc("Quotation", name)
|
|
||||||
if estimate.docstatus != 1:
|
|
||||||
raise Exception("Estimate must be submitted to update response.")
|
|
||||||
accepted = True if response == "Accepted" else False
|
|
||||||
new_status = "Estimate Accepted" if accepted else "Lost"
|
|
||||||
|
|
||||||
estimate.custom_response = response
|
|
||||||
estimate.custom_current_status = new_status
|
|
||||||
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
|
|
||||||
# estimate.status = "Ordered" if accepted else "Closed"
|
|
||||||
estimate.flags.ignore_permissions = True
|
|
||||||
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
|
|
||||||
estimate.save()
|
|
||||||
|
|
||||||
if accepted:
|
|
||||||
template = "custom_ui/templates/estimates/accepted.html"
|
|
||||||
# if check_if_customer(estimate.party_name):
|
|
||||||
# print("DEBUG: Party is already a customer:", estimate.party_name)
|
|
||||||
# else:
|
|
||||||
# print("DEBUG: Converting lead to customer for party:", estimate.party_name)
|
|
||||||
# convert_lead_to_customer(estimate.party_name)
|
|
||||||
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})
|
|
||||||
frappe.db.commit()
|
|
||||||
return Response(html, mimetype="text/html")
|
|
||||||
except Exception as e:
|
|
||||||
template = "custom_ui/templates/estimates/error.html"
|
|
||||||
html = frappe.render_template(template, {"error": str(e)})
|
|
||||||
return Response(html, mimetype="text/html")
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import frappe
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from frappe.utils.data import flt
|
|
||||||
from custom_ui.services import DbService, StripeService, PaymentService
|
|
||||||
from custom_ui.models import PaymentData
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def half_down_stripe_payment(sales_order):
|
|
||||||
"""Public endpoint for initiating a half-down advance payment for a sales order."""
|
|
||||||
if not DbService.exists("Sales Order", sales_order):
|
|
||||||
frappe.throw("Sales Order does not exist.")
|
|
||||||
so = DbService.get_or_throw("Sales Order", sales_order)
|
|
||||||
if not so.requires_half_payment:
|
|
||||||
frappe.throw("This sales order does not require a half-down payment.")
|
|
||||||
if so.docstatus != 1:
|
|
||||||
frappe.throw("Sales Order must be submitted to proceed with payment.")
|
|
||||||
if so.custom_halfdown_is_paid or so.advance_paid >= so.custom_halfdown_amount:
|
|
||||||
frappe.throw("Half-down payment has already been made for this sales order.")
|
|
||||||
stripe_session = StripeService.create_checkout_session(
|
|
||||||
company=so.company,
|
|
||||||
amount=so.custom_halfdown_amount,
|
|
||||||
service=so.custom_project_template,
|
|
||||||
order_num=so.name,
|
|
||||||
for_advance_payment=True
|
|
||||||
)
|
|
||||||
frappe.local.response["type"] = "redirect"
|
|
||||||
frappe.local.response["location"] = stripe_session.url
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def invoice_stripe_payment(sales_invoice):
|
|
||||||
"""Public endpoint for initiating a full payment for a sales invoice."""
|
|
||||||
if not DbService.exists("Sales Invoice", sales_invoice):
|
|
||||||
frappe.throw("Sales Invoice does not exist.")
|
|
||||||
si = DbService.get_or_throw("Sales Invoice", sales_invoice)
|
|
||||||
if si.docstatus != 1:
|
|
||||||
frappe.throw("Sales Invoice must be submitted to proceed with payment.")
|
|
||||||
if si.outstanding_amount <= 0:
|
|
||||||
frappe.throw("This invoice has already been paid.")
|
|
||||||
stripe_session = StripeService.create_checkout_session(
|
|
||||||
company=si.company,
|
|
||||||
amount=si.outstanding_amount,
|
|
||||||
service=si.project_template,
|
|
||||||
order_num=si.name,
|
|
||||||
for_advance_payment=False
|
|
||||||
)
|
|
||||||
frappe.local.response["type"] = "redirect"
|
|
||||||
frappe.local.response["location"] = stripe_session.url
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def stripe_webhook():
|
|
||||||
"""Endpoint to handle Stripe webhooks."""
|
|
||||||
payload = frappe.request.get_data()
|
|
||||||
sig_header = frappe.request.headers.get('Stripe-Signature')
|
|
||||||
session, metadata = StripeService.get_session_and_metadata(payload, sig_header)
|
|
||||||
|
|
||||||
# Validate required metadata
|
|
||||||
if not metadata.get("company"):
|
|
||||||
raise frappe.ValidationError("Missing required metadata key: company")
|
|
||||||
if not metadata.get("payment_type"):
|
|
||||||
raise frappe.ValidationError("Missing required metadata key: payment_type")
|
|
||||||
|
|
||||||
# Determine reference document based on payment type
|
|
||||||
payment_type = metadata.get("payment_type")
|
|
||||||
reference_doctype = None
|
|
||||||
reference_doc_name = None
|
|
||||||
|
|
||||||
if payment_type == "advance":
|
|
||||||
reference_doctype = "Sales Order"
|
|
||||||
reference_doc_name = metadata.get("sales_order")
|
|
||||||
if not reference_doc_name:
|
|
||||||
raise frappe.ValidationError("Missing sales_order in metadata for advance payment")
|
|
||||||
elif payment_type == "full":
|
|
||||||
reference_doctype = "Sales Invoice"
|
|
||||||
reference_doc_name = metadata.get("sales_invoice")
|
|
||||||
if not reference_doc_name:
|
|
||||||
raise frappe.ValidationError("Missing sales_invoice in metadata for full payment")
|
|
||||||
else:
|
|
||||||
raise frappe.ValidationError(f"Invalid payment type in metadata: {payment_type}")
|
|
||||||
|
|
||||||
# Check if payment already exists
|
|
||||||
if DbService.exists("Payment Entry", {"reference_no": session.id}):
|
|
||||||
raise frappe.ValidationError("Payment Entry already exists for this session.")
|
|
||||||
|
|
||||||
amount_paid = flt(session.amount_total) / 100
|
|
||||||
|
|
||||||
# Convert Unix timestamp to date string (YYYY-MM-DD)
|
|
||||||
reference_date = datetime.fromtimestamp(session.created).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Set Administrator context to create Payment Entry
|
|
||||||
frappe.set_user("Administrator")
|
|
||||||
|
|
||||||
try:
|
|
||||||
pe = PaymentService.create_payment_entry(
|
|
||||||
data=PaymentData(
|
|
||||||
mode_of_payment="Stripe",
|
|
||||||
reference_no=session.id,
|
|
||||||
reference_date=reference_date,
|
|
||||||
received_amount=amount_paid,
|
|
||||||
company=metadata.get("company"),
|
|
||||||
reference_doc_name=reference_doc_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pe.flags.ignore_permissions = True
|
|
||||||
pe.submit()
|
|
||||||
frappe.db.commit()
|
|
||||||
return "Payment Entry created and submitted successfully."
|
|
||||||
finally:
|
|
||||||
# Reset to Guest user
|
|
||||||
frappe.set_user("Guest")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -3,51 +3,6 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import frappe
|
import frappe
|
||||||
from custom_ui.utils import create_module
|
from custom_ui.utils import create_module
|
||||||
from custom_ui.api.db.general import search_any_field
|
|
||||||
|
|
||||||
@click.command("update-data")
|
|
||||||
@click.option("--site", default=None, help="Site to update data for")
|
|
||||||
def update_data(site):
|
|
||||||
address_names = frappe.get_all("Address", pluck="name")
|
|
||||||
total_addresses = len(address_names)
|
|
||||||
updated_addresses = 0
|
|
||||||
updated_contacts = 0
|
|
||||||
updated_customers = 0
|
|
||||||
total_updated_fields = 0
|
|
||||||
skipped = 0
|
|
||||||
for address_name in address_names:
|
|
||||||
should_update = False
|
|
||||||
address = frappe.get_doc("Address", address_name)
|
|
||||||
customer_name = address.custom_customer_to_bill
|
|
||||||
customer_links = [link for link in address.get("links", []) if link.link_doctype == "Customer"]
|
|
||||||
# lead_links = [link for link in address.get("links", []) if link.link_doctype == "Lead"]
|
|
||||||
contact_links = [link for link in address.get("links", []) if link.link_doctype == "Contact"] + address.get("custom_linked_contacts", [])
|
|
||||||
|
|
||||||
if frappe.db.exists("Customer", customer_name):
|
|
||||||
customer = frappe.get_doc("Customer", customer_name)
|
|
||||||
else:
|
|
||||||
lead_names = frappe.get_all("Lead", filters={"lead_name": customer_name}, pluck="name")
|
|
||||||
customer_name = lead_names[0] if lead_names else None
|
|
||||||
customer = frappe.get_doc("Lead", customer_name) if customer_name else None
|
|
||||||
if not customer_links and customer and customer.doctype == "Customer":
|
|
||||||
address.append("links", {
|
|
||||||
"link_doctype": customer.doctype,
|
|
||||||
"link_name": customer.name
|
|
||||||
})
|
|
||||||
updated_addresses += 1
|
|
||||||
should_update = True
|
|
||||||
elif not lead_links and customer and customer.doctype == "Lead":
|
|
||||||
address.append("links", {
|
|
||||||
"link_doctype": customer.doctype,
|
|
||||||
"link_name": customer.name
|
|
||||||
})
|
|
||||||
updated_addresses += 1
|
|
||||||
should_update = True
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("build-frontend")
|
@click.command("build-frontend")
|
||||||
@click.option("--site", default=None, help="Site to build frontend for")
|
@click.option("--site", default=None, help="Site to build frontend for")
|
||||||
@ -74,7 +29,6 @@ def build_frontend(site):
|
|||||||
click.echo("\n✅ Frontend build completed successfully.\n")
|
click.echo("\n✅ Frontend build completed successfully.\n")
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
click.echo(f"\n❌ Frontend build failed: {e}\n")
|
click.echo(f"\n❌ Frontend build failed: {e}\n")
|
||||||
exit(1)
|
|
||||||
else:
|
else:
|
||||||
frappe.log_error(message="No frontend directory found for custom_ui", title="Frontend Build Skipped")
|
frappe.log_error(message="No frontend directory found for custom_ui", title="Frontend Build Skipped")
|
||||||
click.echo(f"\n⚠️ Frontend directory does not exist. Skipping build. Path was {frontend_path}\n")
|
click.echo(f"\n⚠️ Frontend directory does not exist. Skipping build. Path was {frontend_path}\n")
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
<div id="custom-ui-app"></div>
|
<div id="custom-ui-app"></div>
|
||||||
<span id="test-footer">THIS IS A TEST</span>
|
|
||||||
{% if bundle_path %}
|
{% if bundle_path %}
|
||||||
<script type="module" src="{{ bundle_path }}"></script>
|
<script type="module" src="{{ bundle_path }}"></script>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@ -1,47 +1,20 @@
|
|||||||
frappe.pages["custom_ui"].on_page_load = async (wrapper) => {
|
frappe.pages["custom_ui"].on_page_load = async (wrapper) => {
|
||||||
// Create root div for spa if it doesn't exist
|
$(wrapper).html('<div id="custom-ui-app"></div>');
|
||||||
const appRootId = "custom-ui-app";
|
console.log("App root div created");
|
||||||
if (!document.getElementById(appRootId)) {
|
manifest = await fetch("/assets/custom_ui/dist/.vite/manifest.json").then((res) => res.json());
|
||||||
$(wrapper).html('<div id="custom-ui-app"></div>');
|
console.log("Fetched manifest:", manifest);
|
||||||
console.log("App root div created");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to load the manifest file
|
const script = document.createElement("script");
|
||||||
try {
|
script.src = "/assets/custom_ui/dist/" + manifest["src/main.js"]["file"];
|
||||||
// Cache busting by appending a timestamp
|
script.type = "module";
|
||||||
const manifestUrl = `/assets/custom_ui/dist/.vite/manifest.json?v=${Date.now()}`;
|
document.body.appendChild(script);
|
||||||
manifest = await fetch(manifestUrl).then((res) => res.json());
|
console.log("Appended script:", script.src);
|
||||||
console.log("Fetched manifest:", manifest);
|
|
||||||
|
|
||||||
// Check existence of old script and link elements and remove them
|
const link = document.createElement("link");
|
||||||
const existingScript = document.getElementById("custom-ui-main-js");
|
link.rel = "stylesheet";
|
||||||
if (existingScript) existingScript.remove();
|
link.href = "/assets/custom_ui/dist/" + manifest["src/main.js"]["css"][0];
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
const existingLink = document.getElementById("custom-ui-main-css");
|
console.log("Custom UI stylesheet loaded:", link.href);
|
||||||
if (existingLink) existingLink.remove();
|
console.log("Custom UI script loaded:", script.src);
|
||||||
|
|
||||||
// Append new script and link elements
|
|
||||||
const cssHref = manifest["src/main.js"]["css"]?.[0];
|
|
||||||
if (cssHref) {
|
|
||||||
const link = document.createElement("link");
|
|
||||||
link.id = "custom-ui-main-css";
|
|
||||||
link.rel = "stylesheet";
|
|
||||||
link.href = `/assets/custom_ui/dist/${cssHref}`;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
console.log("Appended stylesheet:", link.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsFile = manifest["src/main.js"]["file"];
|
|
||||||
if (jsFile) {
|
|
||||||
const script = document.createElement("script");
|
|
||||||
script.id = "custom-ui-main-js";
|
|
||||||
script.type = "module";
|
|
||||||
script.src = `/assets/custom_ui/dist/${jsFile}`;
|
|
||||||
document.body.appendChild(script);
|
|
||||||
console.log("Appended script:", script.src);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading manifest or app resources:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,248 +0,0 @@
|
|||||||
import json
|
|
||||||
from frappe.utils import strip_html
|
|
||||||
|
|
||||||
def map_field_name(frontend_field):
|
|
||||||
field_mapping = {
|
|
||||||
"customer_name": "custom_customer_to_bill",
|
|
||||||
"address": "address_line1",
|
|
||||||
"appointment_scheduled_status": "custom_onsite_meeting_scheduled",
|
|
||||||
"estimate_sent_status": "custom_estimate_sent_status",
|
|
||||||
"payment_received_status": "custom_payment_received_status",
|
|
||||||
"job_status": "custom_job_status",
|
|
||||||
"installation_address": "custom_installation_address",
|
|
||||||
"warranty_id": "name",
|
|
||||||
"customer": "customer",
|
|
||||||
"fromCompany": "from_company",
|
|
||||||
"warranty_status": "warranty_amc_status"
|
|
||||||
}
|
|
||||||
return field_mapping.get(frontend_field, frontend_field)
|
|
||||||
|
|
||||||
def process_filters(filters):
|
|
||||||
processed_filters = {}
|
|
||||||
if filters:
|
|
||||||
filters = json.loads(filters) if isinstance(filters, str) else filters
|
|
||||||
for field_name, filter_obj in filters.items():
|
|
||||||
if isinstance(filter_obj, dict) and "value" in filter_obj:
|
|
||||||
if filter_obj["value"] is not None and filter_obj["value"] != "":
|
|
||||||
# Map frontend field names to backend field names
|
|
||||||
address_fields = ["address_line1", "address_line2", "city", "state", "pincode"] if field_name == "address" else []
|
|
||||||
|
|
||||||
mapped_field_name = map_field_name(field_name)
|
|
||||||
|
|
||||||
# Handle different match modes
|
|
||||||
match_mode = filter_obj.get("match_mode", "contains")
|
|
||||||
if isinstance(match_mode, str):
|
|
||||||
match_mode = match_mode.lower()
|
|
||||||
# Special handling for address to search accross multiple fields
|
|
||||||
if address_fields:
|
|
||||||
address_filters = []
|
|
||||||
for addr_field in address_fields:
|
|
||||||
if match_mode in ("contains", "contains"):
|
|
||||||
address_filters.append([addr_field, "like", f"%{filter_obj['value']}%"])
|
|
||||||
elif match_mode in ("startswith", "starts_with"):
|
|
||||||
address_filters.append([addr_field, "like", f"{filter_obj['value']}%"])
|
|
||||||
elif match_mode in ("endswith", "ends_with"):
|
|
||||||
address_filters.append([addr_field, "like", f"%{filter_obj['value']}"])
|
|
||||||
elif match_mode in ("equals", "equals"):
|
|
||||||
address_filters.append([addr_field, "=", filter_obj["value"]])
|
|
||||||
else:
|
|
||||||
address_filters.append([addr_field, "like", f"%{filter_obj['value']}%"])
|
|
||||||
processed_filters = address_filters
|
|
||||||
|
|
||||||
continue # Skip the rest of the loop for address field
|
|
||||||
customer_name_fields = ["custom_customer_to_bill", "lead_name"] if field_name == "customer_name" else []
|
|
||||||
if customer_name_fields:
|
|
||||||
customer_name_filters = []
|
|
||||||
for cust_field in customer_name_fields:
|
|
||||||
if match_mode in ("contains", "contains"):
|
|
||||||
customer_name_filters.append([cust_field, "like", f"%{filter_obj['value']}%"])
|
|
||||||
elif match_mode in ("startswith", "starts_with"):
|
|
||||||
customer_name_filters.append([cust_field, "like", f"{filter_obj['value']}%"])
|
|
||||||
elif match_mode in ("endswith", "ends_with"):
|
|
||||||
customer_name_filters.append([cust_field, "like", f"%{filter_obj['value']}"])
|
|
||||||
elif match_mode in ("equals", "equals"):
|
|
||||||
customer_name_filters.append([cust_field, "=", filter_obj["value"]])
|
|
||||||
else:
|
|
||||||
customer_name_filters.append([cust_field, "like", f"%{filter_obj['value']}%"])
|
|
||||||
processed_filters = customer_name_filters
|
|
||||||
|
|
||||||
continue # Skip the rest of the loop for customer_name field
|
|
||||||
if match_mode in ("contains", "contains"):
|
|
||||||
processed_filters[mapped_field_name] = ["like", f"%{filter_obj['value']}%"]
|
|
||||||
elif match_mode in ("startswith", "starts_with"):
|
|
||||||
processed_filters[mapped_field_name] = ["like", f"{filter_obj['value']}%"]
|
|
||||||
elif match_mode in ("endswith", "ends_with"):
|
|
||||||
processed_filters[mapped_field_name] = ["like", f"%{filter_obj['value']}"]
|
|
||||||
elif match_mode in ("equals", "equals"):
|
|
||||||
processed_filters[mapped_field_name] = filter_obj["value"]
|
|
||||||
else:
|
|
||||||
# Default to contains
|
|
||||||
processed_filters[mapped_field_name] = ["like", f"%{filter_obj['value']}%"]
|
|
||||||
print("DEBUG: Processed filters:", processed_filters)
|
|
||||||
return processed_filters
|
|
||||||
|
|
||||||
def process_sorting(sortings):
|
|
||||||
sortings = json.loads(sortings) if isinstance(sortings, str) else sortings
|
|
||||||
order_by = ""
|
|
||||||
print("DEBUG: Original sorting:", sortings)
|
|
||||||
if sortings and len(sortings) > 0:
|
|
||||||
for sorting in sortings:
|
|
||||||
mapped_field = map_field_name(sorting[0].strip())
|
|
||||||
sort_direction = sorting[1].strip().lower()
|
|
||||||
order_by += f"{mapped_field} {sort_direction}, "
|
|
||||||
order_by = order_by.rstrip(", ")
|
|
||||||
else:
|
|
||||||
order_by = "modified desc"
|
|
||||||
print("DEBUG: Processed sorting:", order_by)
|
|
||||||
return order_by
|
|
||||||
|
|
||||||
|
|
||||||
def process_query_conditions(filters, sortings, page, page_size):
|
|
||||||
processed_filters = process_filters(filters)
|
|
||||||
processed_sortings = process_sorting(sortings)
|
|
||||||
is_or_filters = isinstance(processed_filters, list)
|
|
||||||
page_int = int(page)
|
|
||||||
page_size_int = int(page_size)
|
|
||||||
return processed_filters, processed_sortings, is_or_filters, page_int, page_size_int
|
|
||||||
|
|
||||||
|
|
||||||
def build_datatable_dict(data, count, page, page_size):
|
|
||||||
return {
|
|
||||||
"pagination": {
|
|
||||||
"total": count,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
"total_pages": (count + page_size - 1) // page_size
|
|
||||||
},
|
|
||||||
"data": data
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_count_or_filters(doctype, or_filters):
|
|
||||||
where_clauses = []
|
|
||||||
values = []
|
|
||||||
for field, operator, val in or_filters:
|
|
||||||
if operator.lower() == "like":
|
|
||||||
where_clauses.append(f"`{field}` LIKE %s")
|
|
||||||
else:
|
|
||||||
where_clauses.append(f"`{field}` {operator} %s")
|
|
||||||
values.append(val)
|
|
||||||
where_sql = " OR ".join(where_clauses)
|
|
||||||
sql = f"SELECT COUNT(*) FROM `tab{doctype}` WHERE {where_sql}"
|
|
||||||
return sql, values
|
|
||||||
|
|
||||||
def build_error_response(message, status_code=400):
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": message,
|
|
||||||
"status_code": status_code
|
|
||||||
}
|
|
||||||
|
|
||||||
def build_success_response(data):
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"data": data
|
|
||||||
}
|
|
||||||
|
|
||||||
def build_full_address(doc):
|
|
||||||
first_parts = [
|
|
||||||
doc.address_line1,
|
|
||||||
doc.address_line2,
|
|
||||||
doc.city
|
|
||||||
]
|
|
||||||
second_parts = [
|
|
||||||
doc.state,
|
|
||||||
doc.pincode
|
|
||||||
]
|
|
||||||
first = " ".join([p for p in first_parts if p])
|
|
||||||
second = " ".join([p for p in second_parts if p])
|
|
||||||
if first and second:
|
|
||||||
return f"{first}, {second}"
|
|
||||||
return first or second or ""
|
|
||||||
|
|
||||||
def build_address_title(customer_name, address_data):
|
|
||||||
title_parts = [customer_name]
|
|
||||||
if address_data.get("address_line1"):
|
|
||||||
title_parts.append(address_data["address_line1"])
|
|
||||||
if address_data.get("type"):
|
|
||||||
title_parts.append(address_data["type"])
|
|
||||||
return " - ".join(title_parts)
|
|
||||||
|
|
||||||
def map_lead_client(client_data):
|
|
||||||
mappings = {
|
|
||||||
"custom_customer_name": "customer_name",
|
|
||||||
"customer_type": "customer_type",
|
|
||||||
"territory": "territory",
|
|
||||||
"company_name": "company"
|
|
||||||
}
|
|
||||||
for lead_field, client_field in mappings.items():
|
|
||||||
if lead_field in client_data:
|
|
||||||
print(f"DEBUG: Mapping field {lead_field} to {client_field} with value {client_data[lead_field]}")
|
|
||||||
client_data[client_field] = client_data[lead_field]
|
|
||||||
client_data["customer_group"] = ""
|
|
||||||
print("####DEBUG: Mapped client data:", client_data)
|
|
||||||
return client_data
|
|
||||||
|
|
||||||
def map_lead_update(client_data):
|
|
||||||
mappings = {
|
|
||||||
"customer_name": "lead_name",
|
|
||||||
"customer_type": "customer_type",
|
|
||||||
"territory": "territory",
|
|
||||||
"company": "company_name"
|
|
||||||
}
|
|
||||||
for client_field, lead_field in mappings.items():
|
|
||||||
if client_field in client_data:
|
|
||||||
print(f"DEBUG: Mapping field {client_field} to {lead_field} with value {client_data[client_field]}")
|
|
||||||
client_data[lead_field] = client_data[client_field]
|
|
||||||
return client_data
|
|
||||||
|
|
||||||
def map_comment_to_history_entry(comment):
|
|
||||||
return {
|
|
||||||
"type": comment.get("comment_type", "Comment"),
|
|
||||||
"user": comment.get("owner"),
|
|
||||||
"timestamp": comment.get("creation"),
|
|
||||||
"message": strip_html(comment.get("content", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
def map_version_data_to_history_entry(changed_data, creation, owner):
|
|
||||||
field, old, new = changed_data
|
|
||||||
return {
|
|
||||||
"type": "Field Change",
|
|
||||||
"timestamp": creation,
|
|
||||||
"user": owner,
|
|
||||||
"message": f"Changed '{field}' from '{old}' to '{new}'"
|
|
||||||
}
|
|
||||||
|
|
||||||
def build_history_entries(comments, versions):
|
|
||||||
history = []
|
|
||||||
for comment in comments:
|
|
||||||
history.append(map_comment_to_history_entry(comment))
|
|
||||||
for version in versions:
|
|
||||||
data = json.loads(version.get("data", "[]"))
|
|
||||||
for changed_data in data.get("changed", []):
|
|
||||||
entry = map_version_data_to_history_entry(
|
|
||||||
changed_data,
|
|
||||||
version.get("creation"),
|
|
||||||
version.get("owner")
|
|
||||||
)
|
|
||||||
if entry:
|
|
||||||
history.append(entry)
|
|
||||||
# Sort by timestamp descending
|
|
||||||
history.sort(key=lambda x: x["timestamp"], reverse=True)
|
|
||||||
return history
|
|
||||||
|
|
||||||
def normalize_name(name: str, split_target: str = "_") -> str:
|
|
||||||
"""Normalize a name by splitting off anything after and including the split_target."""
|
|
||||||
return name.split(split_target)[0] if split_target in name else name
|
|
||||||
|
|
||||||
class DbUtils:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def process_datatable_request(filters, sortings, page, page_size):
|
|
||||||
# turn filters and sortings from json strings to dicts/lists
|
|
||||||
if isinstance(filters, str):
|
|
||||||
filters = json.loads(filters)
|
|
||||||
if isinstance(sortings, str):
|
|
||||||
sortings = json.loads(sortings)
|
|
||||||
page = int(page)
|
|
||||||
page_size = int(page_size)
|
|
||||||
return filters, sortings,page, page_size
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.db_utils import build_full_address
|
|
||||||
|
|
||||||
def before_insert(doc, method):
|
|
||||||
print("DEBUG: Before Insert Triggered for Address")
|
|
||||||
if not doc.full_address:
|
|
||||||
doc.full_address = build_full_address(doc)
|
|
||||||
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
|
|
||||||
def before_save(doc, method):
|
|
||||||
print("DEBUG: Before save hook triggered for Customer:", doc.name)
|
|
||||||
print("DEBUG: current state: ", doc.as_dict())
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from erpnext.selling.doctype.quotation.quotation import make_sales_order
|
|
||||||
from custom_ui.services import DbService, AddressService, ClientService
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
|
||||||
print("DEBUG: After insert hook triggered for Quotation:", doc.name)
|
|
||||||
AddressService.append_link_v2(
|
|
||||||
doc.custom_job_address, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template}
|
|
||||||
)
|
|
||||||
AddressService.append_link_v2(
|
|
||||||
doc.custom_job_address, "links", {"link_doctype": "Quotation", "link_name": doc.name}
|
|
||||||
)
|
|
||||||
ClientService.append_link_v2(
|
|
||||||
doc.actual_customer_name, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template}
|
|
||||||
)
|
|
||||||
template = doc.custom_project_template or "Other"
|
|
||||||
if template == "Other":
|
|
||||||
print("WARN: No project template specified.")
|
|
||||||
if template == "SNW Install":
|
|
||||||
print("DEBUG: SNW Install template detected, updating custom address field.")
|
|
||||||
AddressService.update_value(
|
|
||||||
doc.custom_job_address,
|
|
||||||
"estimate_sent_status",
|
|
||||||
"In Progress"
|
|
||||||
)
|
|
||||||
|
|
||||||
def before_insert(doc, method):
|
|
||||||
print("DEBUG: Before insert hook triggered for Quotation:", doc)
|
|
||||||
print("DEBUG: CHECKING CUSTOMER TYPE")
|
|
||||||
print(doc.customer_type)
|
|
||||||
print("DEBUG: CHECKING CUSTOMER NAME")
|
|
||||||
print(doc.actual_customer_name)
|
|
||||||
print("Quotation_to:", doc.quotation_to)
|
|
||||||
doc.customer_address = frappe.get_value(doc.customer_type, doc.actual_customer_name, "custom_billing_address")
|
|
||||||
# print("Party_type:", doc.party_type)
|
|
||||||
if doc.custom_project_template == "SNW Install":
|
|
||||||
print("DEBUG: Quotation uses SNW Install template, making sure no duplicate linked estimates.")
|
|
||||||
address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
|
||||||
# if "SNW Install" in [link.project_template for link in address_doc.quotations]:
|
|
||||||
# raise frappe.ValidationError("An Estimate with project template 'SNW Install' is already linked to this address.")
|
|
||||||
|
|
||||||
def before_submit(doc, method):
|
|
||||||
print("DEBUG: Before submit hook triggered for Quotation:", doc.name)
|
|
||||||
if doc.custom_project_template == "SNW Install":
|
|
||||||
print("DEBUG: Quotation uses SNW Install template.")
|
|
||||||
if doc.custom_sent == 1:
|
|
||||||
print("DEBUG: Current status is 'Estimate Sent', updating Address status to 'Sent'.")
|
|
||||||
AddressService.update_value(
|
|
||||||
doc.custom_job_address,
|
|
||||||
"estimate_sent_status",
|
|
||||||
"Completed"
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_update_after_submit(doc, method):
|
|
||||||
print("DEBUG: on_update_after_submit hook triggered for Quotation:", doc.name)
|
|
||||||
print("DEBUG: Current custom_current_status:", doc.custom_current_status)
|
|
||||||
if doc.custom_current_status == "Won":
|
|
||||||
print("DEBUG: Quotation is already marked as Won, no action needed.")
|
|
||||||
return
|
|
||||||
if doc.custom_current_status == "Estimate Accepted":
|
|
||||||
doc.custom_current_status = "Won"
|
|
||||||
print("DEBUG: Quotation marked as Won, updating current status.")
|
|
||||||
if doc.customer_type == "Lead":
|
|
||||||
print("DEBUG: Customer is a Lead, converting to Customer and updating Quotation.")
|
|
||||||
new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name)
|
|
||||||
doc.customer_type = "Customer"
|
|
||||||
doc.actual_customer_name = new_customer.name
|
|
||||||
new_customer.reload()
|
|
||||||
doc.save()
|
|
||||||
print("DEBUG: Creating Sales Order from accepted Estimate")
|
|
||||||
new_sales_order = make_sales_order(doc.name)
|
|
||||||
new_sales_order.requires_half_payment = doc.requires_half_payment
|
|
||||||
new_sales_order.customer = doc.actual_customer_name
|
|
||||||
new_sales_order.customer_name = doc.actual_customer_name
|
|
||||||
new_sales_order.customer_address = doc.customer_address
|
|
||||||
# new_sales_order.custom_installation_address = doc.custom_installation_address
|
|
||||||
new_sales_order.custom_job_address = doc.custom_job_address
|
|
||||||
new_sales_order.payment_schedule = []
|
|
||||||
print("DEBUG: Setting payment schedule for Sales Order")
|
|
||||||
new_sales_order.set_payment_schedule()
|
|
||||||
print("DEBUG: Inserting Sales Order:", new_sales_order.as_dict())
|
|
||||||
new_sales_order.delivery_date = new_sales_order.transaction_date
|
|
||||||
# backup = new_sales_order.customer_address
|
|
||||||
# new_sales_order.customer_address = None
|
|
||||||
new_sales_order.insert()
|
|
||||||
print("DEBUG: Submitting Sales Order")
|
|
||||||
# new_sales_order.customer_address = backup
|
|
||||||
new_sales_order.submit()
|
|
||||||
# frappe.db.commit()
|
|
||||||
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
def attach_bid_note_form_to_project_template(doc, method):
|
|
||||||
"""Attatch Bid Meeting Note Form to Project Template on insert."""
|
|
||||||
print("DEBUG: Attaching Bid Meeting Note Form to Project Template")
|
|
||||||
frappe.set_value("Project Template", doc.project_template, "bid_meeting_note_form", doc.name)
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services import SalesOrderService, AddressService, ClientService, ServiceAppointmentService, TaskService
|
|
||||||
from datetime import timedelta
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
|
||||||
print("DEBUG: After Insert Triggered for Project")
|
|
||||||
print("DEBUG: Linking Project to address and Customer")
|
|
||||||
AddressService.append_link_v2(
|
|
||||||
doc.job_address, "projects", {"project": doc.name, "project_template": doc.project_template}
|
|
||||||
)
|
|
||||||
AddressService.append_link_v2(
|
|
||||||
doc.job_address, "links", {"link_doctype": "Project", "link_name": doc.name}
|
|
||||||
)
|
|
||||||
ClientService.append_link_v2(
|
|
||||||
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
|
|
||||||
)
|
|
||||||
if doc.project_template == "SNW Install":
|
|
||||||
print("DEBUG: Project template is SNW Install, creating Service Appointment")
|
|
||||||
AddressService.update_value(
|
|
||||||
doc.job_address,
|
|
||||||
"job_status",
|
|
||||||
"In Progress"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
service_apt = ServiceAppointmentService.create({
|
|
||||||
"project": doc.name,
|
|
||||||
"customer": doc.customer,
|
|
||||||
"service_address": doc.job_address,
|
|
||||||
"company": doc.company,
|
|
||||||
"project_template": doc.project_template
|
|
||||||
})
|
|
||||||
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)]
|
|
||||||
for task_name in task_names:
|
|
||||||
doc.append("tasks", {
|
|
||||||
"task": task_name
|
|
||||||
})
|
|
||||||
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.fire_task_triggers(task_names, "Created", current_triggering_dict=doc.as_dict())
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def before_insert(doc, method):
|
|
||||||
# This is where we will add logic to set tasks and other properties of a job based on it's project_template
|
|
||||||
if doc.requires_half_payment:
|
|
||||||
print("DEBUG: Project requires half payment, setting flag.")
|
|
||||||
doc.ready_to_schedule = 0
|
|
||||||
|
|
||||||
def before_save(doc, method):
|
|
||||||
print("DEBUG: Before Save Triggered for Project:", doc.name)
|
|
||||||
print("DEBUG: Checking status: ", doc.status)
|
|
||||||
if doc.expected_start_date and doc.expected_end_date:
|
|
||||||
print("DEBUG: Project has expected start and end dates, marking as scheduled")
|
|
||||||
doc.is_scheduled = 1
|
|
||||||
while frappe.db.exists("Holiday", {"holiday_date": doc.expected_end_date}):
|
|
||||||
print("DEBUG: Expected end date falls on a holiday, extending end date by 1 day")
|
|
||||||
doc.expected_end_date += timedelta(days=1)
|
|
||||||
elif not doc.expected_start_date or not doc.expected_end_date:
|
|
||||||
print("DEBUG: Project missing expected start or end date, marking as unscheduled")
|
|
||||||
doc.is_scheduled = 0
|
|
||||||
event = TaskService.determine_event(doc)
|
|
||||||
if event:
|
|
||||||
TaskService.fire_task_triggers(
|
|
||||||
[task.task for task in doc.tasks],
|
|
||||||
event,
|
|
||||||
current_triggering_dict=doc.as_dict()
|
|
||||||
)
|
|
||||||
|
|
||||||
def after_save(doc, method):
|
|
||||||
print("DEBUG: After Save Triggered for Project:", doc.name)
|
|
||||||
if doc.status == "Completed":
|
|
||||||
print("DEBUG: Project marked as Completed. Generating and sending final invoice.")
|
|
||||||
sales_order_status = frappe.get_value("Sales Order", doc.sales_order, "billing_status")
|
|
||||||
if sales_order_status == "Not Billed":
|
|
||||||
SalesOrderService.create_sales_invoice_from_sales_order(doc.sales_order)
|
|
||||||
if doc.ready_to_schedule:
|
|
||||||
service_apt_ready_to_schedule = frappe.get_value("Service Address 2", doc.service_appointment, "ready_to_schedule")
|
|
||||||
if not service_apt_ready_to_schedule:
|
|
||||||
print("DEBUG: Project is ready to schedule, setting Service Appointment to ready to schedule.")
|
|
||||||
service_apt_doc = frappe.get_doc("Service Address 2", doc.service_appointment)
|
|
||||||
service_apt_doc.ready_to_schedule = 1
|
|
||||||
service_apt_doc.save(ignore_permissions=True)
|
|
||||||
if doc.project_template == "SNW Install":
|
|
||||||
print("DEBUG: Project template is SNW Install, updating Address Job Status based on Project status")
|
|
||||||
status_mapping = {
|
|
||||||
"Open": "In Progress",
|
|
||||||
"Completed": "Completed",
|
|
||||||
"Closed": "Completed"
|
|
||||||
}
|
|
||||||
new_status = status_mapping.get(doc.status, "In Progress")
|
|
||||||
if frappe.db.get_value("Address", doc.job_address, "job_status") != new_status:
|
|
||||||
print("DEBUG: Updating Address job_status to:", new_status)
|
|
||||||
AddressService.update_value(
|
|
||||||
doc.job_address,
|
|
||||||
"job_status",
|
|
||||||
new_status
|
|
||||||
)
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services import DbService, AddressService, ClientService
|
|
||||||
|
|
||||||
def before_insert(doc, method):
|
|
||||||
print("DEBUG: Before Insert Triggered for On-Site Meeting")
|
|
||||||
if doc.project_template == "SNW Install":
|
|
||||||
address_doc = AddressService.get_or_throw(doc.address)
|
|
||||||
# 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:
|
|
||||||
if link.project_template == "SNW Install":
|
|
||||||
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):
|
|
||||||
print("DEBUG: After Insert Triggered for On-Site Meeting")
|
|
||||||
print("DEBUG: Linking bid meeting to customer and address")
|
|
||||||
AddressService.append_link_v2(doc.address, "onsite_meetings", {"onsite_meeting": doc.name, "project_template": doc.project_template})
|
|
||||||
AddressService.append_link_v2(doc.address, "links", {"link_doctype": "On-Site Meeting", "link_name": doc.name})
|
|
||||||
ClientService.append_link(doc.party_name, "onsite_meetings", "onsite_meeting", doc.name)
|
|
||||||
if doc.project_template == "SNW Install":
|
|
||||||
print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
|
|
||||||
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "In Progress")
|
|
||||||
|
|
||||||
|
|
||||||
def before_save(doc, method):
|
|
||||||
|
|
||||||
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" and doc.status != "Cancelled":
|
|
||||||
print("DEBUG: Meeting has start and end time, setting status to Scheduled")
|
|
||||||
doc.status = "Scheduled"
|
|
||||||
if doc.project_template == "SNW Install":
|
|
||||||
print("DEBUG: Project template is SNW Install")
|
|
||||||
if doc.status == "Completed":
|
|
||||||
print("DEBUG: Meeting marked as Completed, updating Address status")
|
|
||||||
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
|
|
||||||
if doc.status == "Cancelled":
|
|
||||||
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):
|
|
||||||
print("DEBUG: Validating Address link for On-Site Meeting")
|
|
||||||
if doc.onsite_meeting:
|
|
||||||
meeting = DbService.get_or_throw("On-Site Meeting", doc.onsite_meeting)
|
|
||||||
doc.project_template = meeting.project_template
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
def on_submit(doc, method):
|
|
||||||
print("DEBUG: On Submit Triggered for Payment Entry")
|
|
||||||
is_advance_payment = any(ref.reference_doctype == "Sales Order" for ref in doc.references)
|
|
||||||
if is_advance_payment:
|
|
||||||
print("DEBUG: Payment Entry is for an advance payment, checking Sales Order if half down requirement is met.")
|
|
||||||
so_ref = next((ref for ref in doc.references if ref.reference_doctype == "Sales Order"), None)
|
|
||||||
if so_ref:
|
|
||||||
so_doc = frappe.get_doc("Sales Order", so_ref.reference_name)
|
|
||||||
if so_doc.requires_half_payment:
|
|
||||||
is_paid = so_doc.custom_halfdown_amount <= so_doc.advance_paid or so_doc.advance_paid >= so_doc.grand_total / 2
|
|
||||||
if is_paid and not so_doc.custom_halfdown_is_paid:
|
|
||||||
print("DEBUG: Sales Order requires half payment and it has not been marked as paid, marking it as paid now.")
|
|
||||||
so_doc.custom_halfdown_is_paid = 1
|
|
||||||
so_doc.save()
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services.email_service import EmailService
|
|
||||||
|
|
||||||
def on_submit(doc, method):
|
|
||||||
print("DEBUG: On Submit Triggered for Sales Invoice:", doc.name)
|
|
||||||
|
|
||||||
# Send invoice email to customer
|
|
||||||
try:
|
|
||||||
print("DEBUG: Preparing to send invoice email for", doc.name)
|
|
||||||
EmailService.send_invoice_email(doc.name)
|
|
||||||
print("DEBUG: Invoice email sent successfully for", doc.name)
|
|
||||||
frappe.set_value("Project", doc.project, "invoice_status", "Invoice Sent")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to send invoice email: {str(e)}")
|
|
||||||
# Don't raise the exception - we don't want to block the invoice submission
|
|
||||||
frappe.log_error(f"Failed to send invoice email for {doc.name}: {str(e)}", "Invoice Email Error")
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
|
||||||
print("DEBUG: After Insert Triggered for Sales Invoice:", doc.name)
|
|
||||||
# Additional logic can be added here if needed after invoice creation
|
|
||||||
frappe.set_value("Project", doc.project, "invoice_status", "Invoice Created")
|
|
||||||
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services import DbService, AddressService, ClientService
|
|
||||||
|
|
||||||
|
|
||||||
def before_save(doc, method):
|
|
||||||
print("DEBUG: before_save hook triggered for Sales Order", doc.name)
|
|
||||||
if doc.docstatus == 1:
|
|
||||||
if doc.requires_half_payment:
|
|
||||||
half_down_is_paid = doc.custom_halfdown_is_paid or doc.advance_paid >= doc.custom_halfdown_amount or doc.advance_paid >= doc.grand_total / 2
|
|
||||||
if half_down_is_paid and not doc.custom_halfdown_is_paid:
|
|
||||||
doc.custom_halfdown_is_paid = 1
|
|
||||||
|
|
||||||
|
|
||||||
def before_insert(doc, method):
|
|
||||||
print("DEBUG: Before Insert triggered for Sales Order: ", doc.name)
|
|
||||||
if doc.custom_project_template == "SNW Install":
|
|
||||||
print("DEBUG: Sales Order uses SNW Install template, checking for duplicates.")
|
|
||||||
address_doc = AddressService.get_or_throw(doc.custom_job_address)
|
|
||||||
for link in address_doc.sales_orders:
|
|
||||||
if link.project_template == "SNW Install":
|
|
||||||
raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
|
|
||||||
print("DEBUG: before_insert hook triggered for Sales Order")
|
|
||||||
if doc.requires_half_payment:
|
|
||||||
print("DEBUG: Sales Order requires half payment, calculating half-down amount.")
|
|
||||||
half_down_amount = doc.grand_total / 2
|
|
||||||
doc.custom_halfdown_amount = half_down_amount
|
|
||||||
print("DEBUG: Half-down amount set to:", half_down_amount)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def on_submit(doc, method):
|
|
||||||
print("DEBUG: on_submit hook triggered for Sales Order:", doc.name)
|
|
||||||
print("DEBUG: Info from Sales Order")
|
|
||||||
print(f"Sales Order Name: {doc.name}")
|
|
||||||
print(f"Grand Total: {doc.grand_total}")
|
|
||||||
print(f"Company: {doc.company}")
|
|
||||||
print(f"Requires Half Payment: {doc.requires_half_payment}")
|
|
||||||
print(f"Customer: {doc.customer}")
|
|
||||||
print(f"Job Address: {doc.custom_job_address}")
|
|
||||||
print(f"Project Template: {doc.custom_project_template}")
|
|
||||||
# Create Invoice and Project from Sales Order
|
|
||||||
try:
|
|
||||||
print("Creating Project from Sales Order", doc.name)
|
|
||||||
if doc.custom_project_template or doc.project_template:
|
|
||||||
new_job = frappe.get_doc({
|
|
||||||
"doctype": "Project",
|
|
||||||
"custom_job_address": doc.custom_job_address,
|
|
||||||
"project_name": f"{doc.custom_project_template} - {doc.custom_job_address}",
|
|
||||||
"project_template": doc.custom_project_template,
|
|
||||||
"custom_warranty_duration_days": 90,
|
|
||||||
"customer": doc.customer,
|
|
||||||
"job_address": doc.custom_job_address,
|
|
||||||
"sales_order": doc.name,
|
|
||||||
"requires_half_payment": doc.requires_half_payment
|
|
||||||
})
|
|
||||||
# attatch the job to the sales_order links
|
|
||||||
new_job.insert()
|
|
||||||
# frappe.db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
print("ERROR creating Project from Sales Order:", str(e))
|
|
||||||
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
|
||||||
print("DEBUG: after_insert hook triggered for Sales Order:", doc.name)
|
|
||||||
AddressService.append_link_v2(
|
|
||||||
doc.custom_job_address, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
|
|
||||||
)
|
|
||||||
AddressService.append_link_v2(
|
|
||||||
doc.custom_job_address, "links", {"link_doctype": "Sales Order", "link_name": doc.name}
|
|
||||||
)
|
|
||||||
ClientService.append_link_v2(
|
|
||||||
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send down payment email if required
|
|
||||||
if doc.requires_half_payment:
|
|
||||||
try:
|
|
||||||
print("DEBUG: Sales Order requires half payment, preparing to send down payment email")
|
|
||||||
from custom_ui.services.email_service import EmailService
|
|
||||||
|
|
||||||
# Use EmailService to send the down payment email
|
|
||||||
EmailService.send_downpayment_email(doc.name)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to send down payment email: {str(e)}")
|
|
||||||
# Don't raise the exception - we don't want to block the sales order creation
|
|
||||||
frappe.log_error(f"Failed to send down payment email for {doc.name}: {str(e)}", "Down Payment Email Error")
|
|
||||||
|
|
||||||
|
|
||||||
def on_update_after_submit(doc, method):
|
|
||||||
print("DEBUG: on_update_after_submit hook triggered for Sales Order:", doc.name)
|
|
||||||
if doc.requires_half_payment and doc.custom_halfdown_is_paid:
|
|
||||||
project_is_scheduable = frappe.get_value("Project", doc.project, "ready_to_schedule")
|
|
||||||
if not project_is_scheduable:
|
|
||||||
print("DEBUG: Half-down payment made, setting Project to ready to schedule.")
|
|
||||||
project_doc = frappe.get_doc("Project", doc.project)
|
|
||||||
project_doc.ready_to_schedule = 1
|
|
||||||
project_doc.save()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_sales_invoice_from_sales_order(doc, method):
|
|
||||||
pass
|
|
||||||
# try:
|
|
||||||
# print("DEBUG: after_submit hook triggered for Sales Order:", doc.name)
|
|
||||||
# invoice_ammount = doc.grand_total / 2 if doc.requires_half_payment else doc.grand_total
|
|
||||||
# items = []
|
|
||||||
# for so_item in doc.items:
|
|
||||||
# # proportionally reduce rate if half-payment
|
|
||||||
# rate = so_item.rate / 2 if doc.requires_half_payment else so_item.rate
|
|
||||||
# qty = so_item.qty # usually full qty, but depends on half-payment rules
|
|
||||||
# items.append({
|
|
||||||
# "item_code": so_item.item_code,
|
|
||||||
# "qty": qty,
|
|
||||||
# "rate": rate,
|
|
||||||
# "income_account": so_item.income_account,
|
|
||||||
# "cost_center": so_item.cost_center,
|
|
||||||
# "so_detail": so_item.name # links item to Sales Order
|
|
||||||
# })
|
|
||||||
# invoice = frappe.get_doc({
|
|
||||||
# "doctype": "Sales Invoice",
|
|
||||||
# "customer": doc.customer,
|
|
||||||
# "company": doc.company,
|
|
||||||
# "posting_date": frappe.utils.nowdate(),
|
|
||||||
# "due_date": frappe.utils.nowdate(), # or calculate from payment terms
|
|
||||||
# "currency": doc.currency,
|
|
||||||
# "update_stock": 0,
|
|
||||||
# "items": items,
|
|
||||||
# "sales_order": doc.name, # link invoice to Sales Order
|
|
||||||
# "ignore_pricing_rule": 1,
|
|
||||||
# "payment_schedule": doc.payment_schedule if not half_payment else [] # optional
|
|
||||||
# })
|
|
||||||
|
|
||||||
# invoice.insert()
|
|
||||||
# invoice.submit()
|
|
||||||
# frappe.db.commit()
|
|
||||||
# return invoice
|
|
||||||
# except Exception as 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")
|
|
||||||
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
from custom_ui.services import TaskService
|
|
||||||
|
|
||||||
def on_update(doc, method):
|
|
||||||
print("DEBUG: On Update Triggered for Service Appointment")
|
|
||||||
# event = TaskService.determine_event(doc)
|
|
||||||
# if event:
|
|
||||||
# tasks = TaskService.get_tasks_by_project(doc.project)
|
|
||||||
# task_names = [task.name for task in tasks]
|
|
||||||
# TaskService.calculate_and_set_due_dates(task_names=task_names, event=event, triggering_doctype="Service Address 2")
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
|
||||||
print("DEBUG: After Insert Triggered for Service Appointment")
|
|
||||||
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
|
|
||||||
TaskService.fire_task_triggers(task_names=task_names, event="Created", current_triggering_dict=doc.as_dict())
|
|
||||||
|
|
||||||
def before_save(doc, method):
|
|
||||||
print("DEBUG: Before Save Triggered for Service Appointment")
|
|
||||||
if doc.status == "Open" and doc.expected_start_date:
|
|
||||||
doc.status = "Scheduled"
|
|
||||||
elif doc.status == "Scheduled" and not doc.expected_start_date:
|
|
||||||
doc.status = "Open"
|
|
||||||
if doc.status == "Scheduled" and doc.actual_start_date:
|
|
||||||
doc.status = "Started"
|
|
||||||
elif doc.status != "Completed" and doc.status != "Canceled" and doc.actual_end_date:
|
|
||||||
doc.status = "Completed"
|
|
||||||
event = TaskService.determine_event(doc)
|
|
||||||
if event:
|
|
||||||
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
|
|
||||||
TaskService.fire_task_triggers(task_names=task_names, event=event, current_triggering_dict=doc.as_dict())
|
|
||||||
if doc.status == "Completed" and frappe.get_value("Service Address 2", doc.name, "status") != "Completed":
|
|
||||||
frappe.set_value("Project", doc.project, "invoice_status", "Ready to Invoice")
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services import AddressService, ClientService, TaskService
|
|
||||||
|
|
||||||
def before_insert(doc, method):
|
|
||||||
"""Set values before inserting a Task."""
|
|
||||||
print("DEBUG: Before Insert Triggered for Task")
|
|
||||||
project_doc = frappe.get_doc("Project", doc.project)
|
|
||||||
doc.project_template = project_doc.project_template
|
|
||||||
doc.customer = project_doc.customer
|
|
||||||
if project_doc.job_address:
|
|
||||||
doc.custom_property = project_doc.job_address
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
|
||||||
print("DEBUG: After Insert Triggered for Task")
|
|
||||||
print("DEBUG: Linking Task to Customer and Address")
|
|
||||||
AddressService.append_link_v2(
|
|
||||||
doc.custom_property, "tasks", {"task": doc.name, "project_template": doc.project_template }
|
|
||||||
)
|
|
||||||
AddressService.append_link_v2(
|
|
||||||
doc.custom_property, "links", {"link_doctype": "Task", "link_name": doc.name}
|
|
||||||
)
|
|
||||||
ClientService.append_link_v2(
|
|
||||||
doc.customer, "tasks", {"task": doc.name, "project_template": doc.project_template }
|
|
||||||
)
|
|
||||||
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
|
|
||||||
TaskService.fire_task_triggers(task_names, "Created", current_triggering_dict=doc.as_dict())
|
|
||||||
|
|
||||||
def before_save(doc, method):
|
|
||||||
print("DEBUG: Before Save Triggered for Task:", doc.name)
|
|
||||||
task_type_weight = frappe.get_value("Task Type", doc.type, "weight") or 0
|
|
||||||
if doc.task_weight != task_type_weight:
|
|
||||||
print(f"DEBUG: Updating Task weight from {doc.task_weight} to {task_type_weight}")
|
|
||||||
doc.task_weight = task_type_weight
|
|
||||||
event = TaskService.determine_event(doc)
|
|
||||||
if event:
|
|
||||||
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
|
|
||||||
TaskService.fire_task_triggers(task_names, event, current_triggering_dict=doc.as_dict())
|
|
||||||
|
|
||||||
def after_save(doc, method):
|
|
||||||
print("DEBUG: After Save Triggered for Task:", doc.name)
|
|
||||||
if doc.project and doc.status == "Completed":
|
|
||||||
print("DEBUG: Task is completed, checking if project has calculated 100% Progress.")
|
|
||||||
project_doc = frappe.get_doc("Project", doc.project)
|
|
||||||
if project_doc.percent_complete == 100:
|
|
||||||
project_update_required = False
|
|
||||||
if project_doc.status == "Completed" and project_doc.customCompletionDate is None:
|
|
||||||
print("DEBUG: Project is marked as Completed but customCompletionDate is not set, updating customCompletionDate.")
|
|
||||||
project_doc.customCompletionDate = frappe.utils.nowdate()
|
|
||||||
project_update_required = True
|
|
||||||
if project_doc.invoice_status == "Not Ready":
|
|
||||||
project_doc.invoice_status = "Ready to Invoice"
|
|
||||||
project_update_required = True
|
|
||||||
if project_update_required:
|
|
||||||
project_doc.save(ignore_permissions=True)
|
|
||||||
print("DEBUG: Updated Project document after Task completion")
|
|
||||||
@ -1,519 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Opportunity",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-06-28 04:58:27.527685",
|
|
||||||
"module": null,
|
|
||||||
"name": "Set Item Price",
|
|
||||||
"script": "frappe.ui.form.on('Opportunity Item', {\r\n item_code: function(frm, cdt, cdn) {\r\n var row = locals[cdt][cdn];\r\n if (row.item_code) {\r\n frappe.call({\r\n method: 'frappe.client.get_value',\r\n args: {\r\n 'doctype': 'Item Price',\r\n 'filters': {\r\n 'item_code': row.item_code,\r\n 'selling': 1\r\n },\r\n 'fieldname': ['price_list_rate']\r\n },\r\n callback: function(r) {\r\n if (r.message) {\r\n frappe.model.set_value(cdt, cdn, 'rate', r.message.price_list_rate);\r\n frappe.model.set_value(cdt, cdn, 'amount', row.qty * r.message.price_list_rate);\r\n }\r\n }\r\n });\r\n }\r\n },\r\n qty: function(frm, cdt, cdn) {\r\n var row = locals[cdt][cdn];\r\n if (row.qty && row.rate) {\r\n frappe.model.set_value(cdt, cdn, 'amount', row.qty * row.rate);\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "SNW Jobs",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-07-18 02:56:08.345504",
|
|
||||||
"module": null,
|
|
||||||
"name": "SNW Jobs - auto populate days",
|
|
||||||
"script": "frappe.ui.form.on('SNW Jobs', {\n refresh(frm) {\n // your code here (if needed)\n },\n start_date(frm) {\n calculate_total_days(frm);\n },\n end_date(frm) {\n calculate_total_days(frm);\n }\n});\n\nfunction calculate_total_days(frm) {\n if (frm.doc.start_date && frm.doc.end_date) {\n const startDate = new Date(frm.doc.start_date);\n const endDate = new Date(frm.doc.end_date);\n const timeDiff = endDate - startDate;\n const dayDiff = timeDiff / (1000 * 3600 * 24);\n frm.set_value('number_of_days', dayDiff);\n }\n}",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "SNW Jobs",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-07-18 03:04:59.760299",
|
|
||||||
"module": null,
|
|
||||||
"name": "SNW Jobs - Calculate Balance",
|
|
||||||
"script": "frappe.ui.form.on('SNW Jobs', {\r\n refresh(frm) {\r\n // Your code here (if needed)\r\n },\r\n total_expected_price(frm) {\r\n calculate_balance(frm);\r\n },\r\n paid(frm) {\r\n calculate_balance(frm);\r\n }\r\n});\r\n\r\nfunction calculate_balance(frm) {\r\n if (frm.doc.total_expected_price != null && frm.doc.paid != null) {\r\n const balance = frm.doc.total_expected_price - frm.doc.paid;\r\n frm.set_value('balance', balance);\r\n }\r\n}\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "SNW Jobs",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-07-18 03:12:21.825901",
|
|
||||||
"module": null,
|
|
||||||
"name": "SNW Jobs - pull crew lead employees",
|
|
||||||
"script": "frappe.ui.form.on('SNW Jobs', {\n\trefresh(frm) {\n\t\t// your code here\n\t},\n\t onload(frm) {\n frm.set_query('crew_leader', function() {\n return {\n filters: {\n designation: 'Crew Lead'\n }\n };\n });\n }\n})",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Follow Up Checklist",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-09-16 05:58:34.623817",
|
|
||||||
"module": null,
|
|
||||||
"name": "auto populate follow up form",
|
|
||||||
"script": "frappe.ui.form.on('Follow Up Checklist', {\n customer:function(frm) {\n\t\t // Fetch customer details like phone and email\n frappe.db.get_value(\"Address\", {\"customer\": frm.doc.customer, \"customer_primary_address\": 1}, \"address_line1\", function(r) {\n if(r && r.address_line1) {\n frm.set_value(\"address\", r.address_line1); // Set the address field\n }\n });\n }\n});",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Quotation",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-04-24 05:36:48.042696",
|
|
||||||
"module": null,
|
|
||||||
"name": "Item Markup Script",
|
|
||||||
"script": "frappe.ui.form.on('Quotation Item', {\r\n item_code: function(frm, cdt, cdn) {\r\n let row = locals[cdt][cdn];\r\n if (row.item_code) {\r\n // Fetch the item price from the Item Price List\r\n frappe.call({\r\n method: 'frappe.client.get_list',\r\n args: {\r\n doctype: 'Item Price',\r\n filters: {\r\n item_code: row.item_code,\r\n price_list: frm.doc.selling_price_list // Assuming the price list is set in the Quotation\r\n },\r\n fields: ['price_list_rate']\r\n },\r\n callback: function(response) {\r\n if (response.message && response.message.length > 0) {\r\n // Get the price from the Item Price List\r\n let base_rate = response.message[0].price_list_rate || 0;\r\n\r\n // Fetch the markup percentage from the Item master\r\n frappe.call({\r\n method: 'frappe.client.get',\r\n args: {\r\n doctype: 'Item',\r\n name: row.item_code\r\n },\r\n callback: function(r) {\r\n if (r.message) {\r\n // Fetch the markup percentage from the Item master\r\n let markup = r.message.custom_markup_percentage || 0; // Default to 0% if not set\r\n \r\n // Calculate the new rate with markup\r\n let new_rate = base_rate + (base_rate * (markup / 100));\r\n frappe.model.set_value(cdt, cdn, 'rate', new_rate);\r\n \r\n // Refresh the items table to show the updated rate\r\n frm.refresh_field('items');\r\n }\r\n }\r\n });\r\n }\r\n }\r\n });\r\n }\r\n }\r\n});\r\n\r\n// Optional: Recalculate all items on form refresh or load\r\nfrappe.ui.form.on('Quotation', {\r\n refresh: function(frm) {\r\n frm.doc.items.forEach(function(item) {\r\n //frappe.model.trigger('item_code', item.name);\r\n });\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Quotation",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-01-08 05:04:26.743210",
|
|
||||||
"module": null,
|
|
||||||
"name": "Quotation - Set Same Valid Until Date",
|
|
||||||
"script": "frappe.ui.form.on(\"Quotation\", {\n onload: function(frm) {\n frm.set_value('valid_till', '2025-12-31');\n }\n});",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Note",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-10-24 23:43:27.548340",
|
|
||||||
"module": null,
|
|
||||||
"name": "Open Note in Edit Mode",
|
|
||||||
"script": "frappe.ui.form.on('Note', {\r\n onload_post_render: function(frm) {\r\n // Check if this is a new document or in read mode, then switch to edit mode\r\n if (frm.is_new() || frm.doc.__unsaved) {\r\n frm.page.set_primary_action(__('Save'), () => frm.save());\r\n frm.page.wrapper.find('.btn-primary').removeClass('hidden');\r\n frm.enable_save();\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-05 11:55:14.179233",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address Doctype Linked City Auto Populate",
|
|
||||||
"script": "frappe.ui.form.on('Address', {\r\n custom_linked_city: function (frm) {\r\n if (frm.doc.custom_linked_city) {\r\n frappe.db.get_doc('City', frm.doc.custom_linked_city).then(city_doc => {\r\n frm.set_value('city', city_doc.city_name); // Sync to mandatory City field\r\n frm.set_value('state', city_doc.state); // Populate State\r\n if (city_doc.zip_code) {\r\n frm.set_value('zip_code', city_doc.zip_code); // Populate Zip Code\r\n }\r\n });\r\n }\r\n },\r\n city: function (frm) {\r\n // Optionally, sync back to custom_linked_city when the mandatory City field is used\r\n if (!frm.doc.custom_linked_city && frm.doc.city) {\r\n frappe.msgprint(__('Consider selecting a linked city for better accuracy.'));\r\n }\r\n }\r\n});",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-06 05:15:31.487671",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address - Irrigation District Visibility Condition",
|
|
||||||
"script": "frappe.ui.form.on('Address', {\r\n refresh: function(frm) {\r\n let show_irrigation_district = false;\r\n\r\n // Check if 'is_shipping_address' is checked\r\n if (frm.doc.is_shipping_address) {\r\n // Required companies list\r\n const required_companies = ['Sprinklers Northwest', 'Nuco Yard Care'];\r\n\r\n // Check child table rows\r\n if (frm.doc.custom_linked_companies) {\r\n show_irrigation_district = frm.doc.custom_linked_companies.some(row =>\r\n required_companies.includes(row.company)\r\n );\r\n }\r\n }\r\n\r\n // Show or hide the custom_irrigation_district field\r\n frm.set_df_property('custom_irrigation_district', 'hidden', !show_irrigation_district);\r\n },\r\n\r\n is_service_address: function(frm) {\r\n // Re-run visibility logic when 'is_service_address' changes\r\n frm.trigger('refresh');\r\n },\r\n\r\n custom_linked_companies: function(frm) {\r\n // Re-run visibility logic when the child table is updated\r\n frm.trigger('refresh');\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-06 05:27:19.840296",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address - Installed By Snw Visibility Condition",
|
|
||||||
"script": "frappe.ui.form.on('Address', {\r\n refresh: function(frm) {\r\n frm.trigger('toggle_irrigation_fields');\r\n },\r\n\r\n custom_installed_by_sprinklers_nw: function(frm) {\r\n frm.trigger('toggle_irrigation_fields');\r\n },\r\n\r\n toggle_irrigation_fields: function(frm) {\r\n const is_installed = frm.doc.custom_installed_by_sprinklers_nw;\r\n\r\n // Toggle visibility for irrigation-related fields\r\n frm.set_df_property('custom_install_month', 'hidden', !is_installed);\r\n frm.set_df_property('custom_install_year', 'hidden', !is_installed);\r\n frm.set_df_property('custom_backflow_test_report', 'hidden', !is_installed);\r\n frm.set_df_property('custom_photo_attachment', 'hidden', !is_installed);\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 0,
|
|
||||||
"modified": "2024-12-13 07:14:26.487456",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address - Google Map Display",
|
|
||||||
"script": "frappe.ui.form.on('Address', {\r\n refresh: function(frm) {\r\n // Render Google Map if address fields are available and saved\r\n if (!frm.is_new() && frm.doc.address_line1 && frm.doc.city && frm.doc.country) {\r\n frm.trigger('render_google_map');\r\n }\r\n },\r\n\r\n render_google_map: function(frm) {\r\n // Construct the full address string\r\n const address = [\r\n frm.doc.address_line1,\r\n frm.doc.address_line2 || '', // Optional\r\n frm.doc.city,\r\n frm.doc.state || '', // Optional\r\n frm.doc.pincode || '', // Optional\r\n frm.doc.country\r\n ].filter(Boolean).join(', '); // Remove empty fields\r\n\r\n // Replace with your Google Maps API Key\r\n const apiKey = 'AIzaSyB2uNXSQpMp-lGJHIWFpzloWxs76zjkU8Y';\r\n\r\n // Generate the embed URL\r\n const mapUrl = `https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${encodeURIComponent(address)}`;\r\n\r\n // Render the iframe in the HTML field\r\n frm.fields_dict.custom_google_map.$wrapper.html(`\r\n <iframe \r\n width=\"100%\" \r\n height=\"400\" \r\n frameborder=\"0\" \r\n style=\"border:0\" \r\n src=\"${mapUrl}\" \r\n allowfullscreen>\r\n </iframe>\r\n `);\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 0,
|
|
||||||
"modified": "2025-05-15 09:00:39.086280",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address - Auto-Filling Mutiple Contact Email and Phone",
|
|
||||||
"script": "frappe.ui.form.on('Address Contact Role', {\r\n contact: function(frm, cdt, cdn) {\r\n let row = frappe.get_doc(cdt, cdn);\r\n\r\n if (row.contact) {\r\n // Fetch email and phone from the selected Contact\r\n frappe.db.get_doc('Contact', row.contact).then(contact => {\r\n if (contact) {\r\n // Pull primary email and phone from the Contact\r\n row.email = contact.email_id || '';\r\n row.phone = contact.phone || contact.mobile_no || ''; // Prefer phone, fallback to mobile_no\r\n frm.refresh_field('custom_linked_contacts');\r\n }\r\n });\r\n } else {\r\n // Clear fields if no Contact selected\r\n row.email = '';\r\n row.phone = '';\r\n frm.refresh_field('custom_linked_contacts');\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Locate Log",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-11 12:11:35.789512",
|
|
||||||
"module": null,
|
|
||||||
"name": "Locate Button",
|
|
||||||
"script": "frappe.ui.form.on('Locate Log', {\r\n refresh: function(frm) {\r\n // Add the custom button \"Mark as Located\"\r\n if (frm.doc.status !== 'Completed') {\r\n frm.add_custom_button(__('Mark as Located'), function() {\r\n frm.trigger('mark_as_located');\r\n });\r\n }\r\n },\r\n mark_as_located: function(frm) {\r\n // Check if Dig Ticket # is provided\r\n if (!frm.doc.dig_ticket_number) {\r\n frappe.msgprint(__('Please enter a Dig Ticket #.'));\r\n return;\r\n }\r\n\r\n // Trigger the backend method to update the Job Queue and Locate Log\r\n frappe.call({\r\n method: 'my_app.my_module.api.update_locate_log_and_job_queue',\r\n args: {\r\n locate_log: frm.doc.name,\r\n dig_ticket_number: frm.doc.dig_ticket_number\r\n },\r\n callback: function(r) {\r\n if (r.message) {\r\n frappe.msgprint(__('Locate marked as completed.'));\r\n frm.reload_doc(); // Reload the form to reflect changes\r\n }\r\n }\r\n });\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Pre-Built Routes",
|
|
||||||
"enabled": 0,
|
|
||||||
"modified": "2024-12-11 13:24:02.894613",
|
|
||||||
"module": null,
|
|
||||||
"name": "Real-Time Updates for Address -Route Connection",
|
|
||||||
"script": "frappe.ui.form.on('Assigned Address', {\r\n address_name: function (frm, cdt, cdn) {\r\n const row = locals[cdt][cdn];\r\n\r\n // Ensure the address_name field is set\r\n if (row.address_name) {\r\n // Fetch the custom_confirmation_status from the Address Doctype\r\n frappe.db.get_value('Address', row.address_name, 'custom_confirmation_status', (value) => {\r\n if (value && value.custom_confirmation_status) {\r\n // Set the status in the child table to match the Address confirmation status\r\n frappe.model.set_value(cdt, cdn, 'status', value.custom_confirmation_status);\r\n } else {\r\n // Default to No Response if Address confirmation status is not set\r\n frappe.model.set_value(cdt, cdn, 'status', 'No Response');\r\n }\r\n });\r\n } else {\r\n // If no address_name is set, default status to No Response\r\n frappe.model.set_value(cdt, cdn, 'status', 'No Response');\r\n }\r\n }\r\n});\r\n\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 0,
|
|
||||||
"modified": "2024-12-11 13:09:36.908372",
|
|
||||||
"module": null,
|
|
||||||
"name": "Real Time Updates for Address Doctype Connection to Pre-Built",
|
|
||||||
"script": "frappe.ui.form.on('Address', {\r\n custom_confirmation_status: function (frm) {\r\n // Check if the Service Route is set\r\n if (!frm.doc.custom_service_route) {\r\n frappe.msgprint(__('Service Route is not assigned for this address.'));\r\n return;\r\n }\r\n\r\n // Retrieve the Assigned Address row where this address is linked\r\n frappe.call({\r\n method: 'frappe.client.get_value',\r\n args: {\r\n doctype: 'Assigned Address',\r\n filters: { address_name: frm.doc.name },\r\n fieldname: 'name'\r\n },\r\n callback: function (response) {\r\n if (response.message) {\r\n const assigned_row_name = response.message.name;\r\n // Update the Status in the Assigned Address row\r\n frappe.call({\r\n method: 'frappe.client.set_value',\r\n args: {\r\n doctype: 'Assigned Address',\r\n name: assigned_row_name,\r\n fieldname: 'status',\r\n value: frm.doc.custom_confirmation_status\r\n },\r\n callback: function (response) {\r\n if (!response.exc) {\r\n frappe.msgprint(__('Status updated in Pre-Built Route.'));\r\n }\r\n }\r\n });\r\n } else {\r\n frappe.msgprint(__('This address is not linked to any Pre-Built Route.'));\r\n }\r\n }\r\n });\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Pre-Built Routes",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-13 09:14:52.635425",
|
|
||||||
"module": null,
|
|
||||||
"name": "Color Code Route Child Table",
|
|
||||||
"script": "frappe.ui.form.on('Pre-Built Routes', {\r\n refresh: function (frm) {\r\n // Loop through the rows in the Assigned Addresses table\r\n frm.fields_dict['assigned_addresses'].grid.wrapper.find('.grid-row').each(function () {\r\n const row = $(this);\r\n const doc = row.data('doc'); // Get the child table row data\r\n\r\n if (doc && doc.status) {\r\n // Apply color based on the Status field\r\n switch (doc.status) {\r\n case 'Confirmed':\r\n row.css('background-color', '#D4EDDA'); // Green for Scheduled\r\n break;\r\n case 'Reschedule':\r\n row.css('background-color', '#FFF3CD'); // Yellow for Completed\r\n break;\r\n case 'Declined':\r\n row.css('background-color', '#F8D7DA'); // Red for Pending Reschedule\r\n break;\r\n default:\r\n row.css('background-color', '#E2E0E0',); // Reset to default if no match\r\n break;\r\n }\r\n }\r\n });\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Pre-Built Routes",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-11 13:25:17.161414",
|
|
||||||
"module": null,
|
|
||||||
"name": "Dynamically Update Confirmation Status on Pre-Built Route",
|
|
||||||
"script": "frappe.ui.form.on('Assigned Address', {\r\n address_name: function (frm, cdt, cdn) {\r\n const row = locals[cdt][cdn];\r\n\r\n // Ensure the address_name field is set\r\n if (row.address_name) {\r\n // Fetch the custom_confirmation_status from the Address Doctype\r\n frappe.db.get_value('Address', row.address_name, 'custom_confirmation_status', (value) => {\r\n if (value && value.custom_confirmation_status) {\r\n // Set the status in the child table to match the Address confirmation status\r\n frappe.model.set_value(cdt, cdn, 'status', value.custom_confirmation_status);\r\n } else {\r\n // Default to No Response if Address confirmation status is not set\r\n frappe.model.set_value(cdt, cdn, 'status', 'No Response');\r\n }\r\n });\r\n } else {\r\n // If no address_name is set, default status to No Response\r\n frappe.model.set_value(cdt, cdn, 'status', 'No Response');\r\n }\r\n }\r\n});",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Pre-Built Routes",
|
|
||||||
"enabled": 0,
|
|
||||||
"modified": "2024-12-13 09:03:06.173211",
|
|
||||||
"module": null,
|
|
||||||
"name": "Route Map",
|
|
||||||
"script": "frappe.ui.form.on('Pre-Built Routes', {\r\n refresh: function (frm) {\r\n // Check if Google Maps API is loaded\r\n if (!window.google || !window.google.maps) {\r\n frappe.msgprint(__('Google Maps API is not loaded. Attempting to load...'));\r\n\r\n // Dynamically load the Google Maps API\r\n const script = document.createElement('script');\r\n script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyB2uNXSQpMp-lGJHIWFpzloWxs76zjkU8Y`;\r\n script.async = true;\r\n script.defer = true;\r\n\r\n // Call renderMap after the API is loaded\r\n script.onload = function () {\r\n console.log(\"Google Maps API loaded successfully.\");\r\n renderMap(frm); // Render the map after API loads\r\n };\r\n\r\n // Handle API load failure\r\n script.onerror = function () {\r\n frappe.msgprint(__('Failed to load Google Maps API. Please check your API key.'));\r\n };\r\n\r\n document.head.appendChild(script);\r\n } else {\r\n // If API is already loaded, render the map immediately\r\n renderMap(frm);\r\n }\r\n }\r\n});\r\n\r\nfunction renderMap(frm) {\r\n // Ensure the form has assigned addresses\r\n const addresses = frm.doc.assigned_addresses || [];\r\n if (!addresses.length) {\r\n frappe.msgprint(__('No addresses found for this route.'));\r\n return;\r\n }\r\n\r\n // Prepare points for the map\r\n const points = addresses.map((row) => ({\r\n lat: parseFloat(row.latitude || 0),\r\n lng: parseFloat(row.longitude || 0),\r\n status: row.status || 'No Response',\r\n title: row.address_name || 'Unknown Address'\r\n }));\r\n\r\n console.log(\"Map points:\", points); // Debug the points array\r\n\r\n // Define marker colors based on status\r\n const statusColors = {\r\n 'Confirmed': 'green',\r\n 'Reschedule': 'yellow',\r\n 'Decline': 'red',\r\n 'No Response': 'white'\r\n };\r\n\r\n // Initialize map container\r\n const mapContainer = frm.fields_dict['route_map'].wrapper;\r\n mapContainer.innerHTML = '<div id=\"route-map\" style=\"height: 400px;\"></div>';\r\n const map = new google.maps.Map(document.getElementById('route-map'), {\r\n zoom: 10,\r\n mapTypeId: 'roadmap'\r\n });\r\n\r\n // Fit map bounds to all points\r\n const bounds = new google.maps.LatLngBounds();\r\n\r\n // Add markers for each point\r\n points.forEach((point) => {\r\n if (point.lat && point.lng) {\r\n const marker = new google.maps.Marker({\r\n position: { lat: point.lat, lng: point.lng },\r\n map: map,\r\n title: point.title,\r\n icon: {\r\n path: google.maps.SymbolPath.CIRCLE,\r\n scale: 8,\r\n fillColor: statusColors[point.status],\r\n fillOpacity: 1,\r\n strokeWeight: 0\r\n }\r\n });\r\n bounds.extend(marker.getPosition());\r\n }\r\n });\r\n\r\n // Adjust map view to fit all markers\r\n if (!bounds.isEmpty()) {\r\n map.fitBounds(bounds);\r\n } else {\r\n frappe.msgprint(__('No valid points to display on the map.'));\r\n }\r\n}\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-05-15 12:34:32.006687",
|
|
||||||
"module": null,
|
|
||||||
"name": "Test - Update Address Map Display with lat/long",
|
|
||||||
"script": "frappe.ui.form.on('Address', {\r\n refresh: function(frm) {\r\n frm.trigger('render_google_map');\r\n },\r\n\r\n render_google_map: function(frm) {\r\n if (!frm.is_new() && frm.doc.address_line1 && frm.doc.city && frm.doc.country) {\r\n // Render Google Map if address fields are available and saved\r\n\r\n const address = [\r\n frm.doc.address_line1,\r\n frm.doc.address_line2 || '',\r\n frm.doc.city,\r\n frm.doc.state || '',\r\n frm.doc.pincode || '',\r\n frm.doc.country\r\n ].filter(Boolean).join(', ');\r\n \r\n const apiKey = 'AIzaSyCd3ALZe6wjt3xnc7X_rRItfKAEJugfuZ4';\r\n const mapUrl = `https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${encodeURIComponent(address)}`;\r\n \r\n frm.fields_dict.custom_google_map.$wrapper.html(`\r\n <iframe \r\n width=\"100%\" \r\n height=\"400\" \r\n frameborder=\"0\" \r\n style=\"border:0\" \r\n src=\"${mapUrl}\" \r\n allowfullscreen>\r\n </iframe>\r\n `);\r\n } else {\r\n frm.fields_dict.custom_google_map.$wrapper.html('');\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Pre-Built Routes",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-13 08:47:49.392360",
|
|
||||||
"module": null,
|
|
||||||
"name": "Test - Pull The lat/long into Assigned Address",
|
|
||||||
"script": "frappe.ui.form.on('Assigned Address', {\r\n address_name: function(frm, cdt, cdn) {\r\n let row = locals[cdt][cdn];\r\n if (row.address_name) {\r\n frappe.db.get_value('Address', row.address_name, ['custom_latitude', 'custom_longitude'], (value) => {\r\n if (value) {\r\n frappe.model.set_value(cdt, cdn, 'latitude', value.custom_latitude);\r\n frappe.model.set_value(cdt, cdn, 'longitude', value.custom_longitude);\r\n } else {\r\n frappe.msgprint(__('Could not fetch coordinates for the selected address.'));\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Pre-Built Routes",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-13 09:07:36.356251",
|
|
||||||
"module": null,
|
|
||||||
"name": "Test - Render color coded map",
|
|
||||||
"script": "frappe.ui.form.on('Pre-Built Routes', {\r\n refresh: function(frm) {\r\n // Dynamically load Google Maps API if not already loaded\r\n if (!window.google || !window.google.maps) {\r\n const apiKey = 'AIzaSyCd3ALZe6wjt3xnc7X_rRItfKAEJugfuZ4'; // Replace with your actual API key\r\n const script = document.createElement('script');\r\n script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`;\r\n script.async = true;\r\n script.defer = true;\r\n script.onload = function () {\r\n console.log(\"Google Maps API loaded successfully.\");\r\n renderMap(frm); // Render map after API is loaded\r\n };\r\n script.onerror = function () {\r\n frappe.msgprint(__('Failed to load Google Maps API. Please check your API key.'));\r\n };\r\n document.head.appendChild(script);\r\n return;\r\n }\r\n\r\n // If API is already loaded, render the map immediately\r\n renderMap(frm);\r\n }\r\n});\r\n\r\nfunction renderMap(frm) {\r\n const addresses = frm.doc.assigned_addresses || [];\r\n if (!addresses.length) {\r\n frappe.msgprint(__('No addresses to display on the map.'));\r\n return;\r\n }\r\n\r\n // Prepare points for the map\r\n const points = addresses.map(row => ({\r\n lat: parseFloat(row.latitude || 0),\r\n lng: parseFloat(row.longitude || 0),\r\n status: row.status || 'No Response',\r\n title: row.address_name || 'Unknown Address'\r\n }));\r\n\r\n // Define marker colors based on status\r\n const statusColors = {\r\n 'Confirmed': 'green',\r\n 'Reschedule': 'yellow',\r\n 'Declined': 'red',\r\n 'No Response': 'gray'\r\n };\r\n\r\n const mapContainer = frm.fields_dict['route_map'].wrapper;\r\n mapContainer.innerHTML = '<div id=\"route-map\" style=\"height: 400px;\"></div>';\r\n const map = new google.maps.Map(document.getElementById('route-map'), {\r\n zoom: 10,\r\n mapTypeId: 'roadmap'\r\n });\r\n\r\n const bounds = new google.maps.LatLngBounds();\r\n\r\n // Add markers for each point\r\n points.forEach(point => {\r\n if (point.lat && point.lng) {\r\n const marker = new google.maps.Marker({\r\n position: { lat: point.lat, lng: point.lng },\r\n map: map,\r\n title: point.title,\r\n icon: {\r\n path: google.maps.SymbolPath.CIRCLE,\r\n scale: 8,\r\n fillColor: statusColors[point.status],\r\n fillOpacity: 1,\r\n strokeWeight: 0\r\n }\r\n });\r\n bounds.extend(marker.getPosition());\r\n }\r\n });\r\n\r\n if (!bounds.isEmpty()) {\r\n map.fitBounds(bounds);\r\n }\r\n}\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Locate Log",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-16 14:47:43.738700",
|
|
||||||
"module": null,
|
|
||||||
"name": "calculated start date",
|
|
||||||
"script": "frappe.ui.form.on('Your DocType', {\r\n date_located: function(frm) {\r\n if (frm.doc.date_located) {\r\n var input_date = frm.doc.date_located;\r\n var calculated_date = addBusinessDays(input_date, 2);\r\n frm.set_value('legal_start_date', calculated_date);\r\n }\r\n }\r\n});\r\n\r\n// Function to calculate date after a specified number of business days\r\nfunction addBusinessDays(startDate, numBusinessDays) {\r\n var date = new Date(startDate);\r\n var daysAdded = 0;\r\n \r\n while (daysAdded < numBusinessDays) {\r\n date.setDate(date.getDate() + 1);\r\n // Check if the new date is a weekend (Saturday = 6, Sunday = 0)\r\n if (date.getDay() !== 0 && date.getDay() !== 6) {\r\n daysAdded++;\r\n }\r\n }\r\n \r\n // Return the calculated date in the correct format for ERPNext\r\n return frappe.datetime.add_days(date, 0);\r\n}\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Locate Log",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-16 14:47:43.716572",
|
|
||||||
"module": null,
|
|
||||||
"name": "calculated start date-list",
|
|
||||||
"script": "frappe.ui.form.on('Your DocType', {\r\n date_located: function(frm) {\r\n if (frm.doc.date_located) {\r\n var input_date = frm.doc.date_located;\r\n var calculated_date = addBusinessDays(input_date, 2);\r\n frm.set_value('legal_start_date', calculated_date);\r\n }\r\n }\r\n});\r\n\r\n// Function to calculate date after a specified number of business days\r\nfunction addBusinessDays(startDate, numBusinessDays) {\r\n var date = new Date(startDate);\r\n var daysAdded = 0;\r\n \r\n while (daysAdded < numBusinessDays) {\r\n date.setDate(date.getDate() + 1);\r\n // Check if the new date is a weekend (Saturday = 6, Sunday = 0)\r\n if (date.getDay() !== 0 && date.getDay() !== 6) {\r\n daysAdded++;\r\n }\r\n }\r\n \r\n // Return the calculated date in the correct format for ERPNext\r\n return frappe.datetime.add_days(date, 0);\r\n}\r\n",
|
|
||||||
"view": "List"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Locate Log",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-16 14:47:18.813864",
|
|
||||||
"module": null,
|
|
||||||
"name": "expiration date",
|
|
||||||
"script": "frappe.ui.form.on('Your DocType', {\r\n // Trigger when the \"Marked as Located\" button is clicked\r\n refresh: function(frm) {\r\n frm.add_custom_button(__('Marked as Located'), function() {\r\n update_expiration_date(frm);\r\n });\r\n }\r\n});\r\n\r\n// Function to update the expiration_date based on legal_start_date and state\r\nfunction update_expiration_date(frm) {\r\n if (frm.doc.legal_start_date && frm.doc.state) {\r\n var legalStartDate = frm.doc.legal_start_date;\r\n var expirationDate = null;\r\n \r\n // Check the state and calculate expiration date accordingly\r\n if (frm.doc.state === \"ID\") {\r\n expirationDate = frappe.datetime.add_days(legalStartDate, 28); // 28 days for ID\r\n } else if (frm.doc.state === \"WA\") {\r\n expirationDate = frappe.datetime.add_days(legalStartDate, 45); // 45 days for WA\r\n }\r\n\r\n // Set the calculated expiration date if a valid state is selected\r\n if (expirationDate) {\r\n frm.set_value('expiration_date', expirationDate);\r\n frappe.msgprint(__('Expiration Date has been calculated and updated.'));\r\n } else {\r\n frappe.msgprint(__('Please make sure both legal start date and state are filled.'));\r\n }\r\n } else {\r\n frappe.msgprint(__('Please make sure both legal start date and state are filled.'));\r\n }\r\n}\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Locate Log",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2024-12-16 14:53:13.214793",
|
|
||||||
"module": null,
|
|
||||||
"name": "expiration date -list",
|
|
||||||
"script": "frappe.ui.form.on('Your DocType', {\r\n date_located: function(frm) {\r\n if (frm.doc.date_located) {\r\n var input_date = frm.doc.date_located;\r\n var calculated_date = addBusinessDays(input_date, 30);\r\n frm.set_value('expiration_date', calculated_date);\r\n }\r\n }\r\n});\r\n\r\n// Function to calculate date after a specified number of business days\r\nfunction addBusinessDays(startDate, numBusinessDays) {\r\n var date = new Date(startDate);\r\n var daysAdded = 0;\r\n \r\n while (daysAdded < numBusinessDays) {\r\n date.setDate(date.getDate() + 1);\r\n // Check if the new date is a weekend (Saturday = 6, Sunday = 0)\r\n if (date.getDay() !== 0 && date.getDay() !== 6) {\r\n daysAdded++;\r\n }\r\n }\r\n \r\n // Return the calculated date in the correct format for ERPNext\r\n return frappe.datetime.add_days(date, 0);\r\n}\r\n",
|
|
||||||
"view": "List"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Quotation",
|
|
||||||
"enabled": 0,
|
|
||||||
"modified": "2025-01-24 13:24:25.882893",
|
|
||||||
"module": null,
|
|
||||||
"name": "Auto populate address from customer - test",
|
|
||||||
"script": "frappe.ui.form.on('Quotation', {\r\n customer: function(frm) {\r\n // Trigger when the customer field is set or changed\r\n if (frm.doc.customer) {\r\n frappe.call({\r\n method: 'frappe.client.get_value',\r\n args: {\r\n doctype: 'Customer',\r\n filters: { name: frm.doc.customer },\r\n fieldname: 'address_html'\r\n },\r\n callback: function(response) {\r\n if (response.message && response.message.address_html) {\r\n // Set the customer_address field in the Quotation\r\n frm.set_value('customer_address', response.message.address_html);\r\n } else {\r\n frappe.msgprint(__('No address found for the selected customer.'));\r\n }\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 0,
|
|
||||||
"modified": "2025-01-24 14:05:52.607856",
|
|
||||||
"module": "Brotherton SOP",
|
|
||||||
"name": "address concatenate",
|
|
||||||
"script": "frappe.ui.form.on('<Address>', {\n validate: function(frm) {\n // Concatenate the fields\n frm.set_value('address_line1', \n (frm.doc.custom_street_number || '') + ', ' + \n (frm.doc.custom_directional || '') + ', ' + \n (frm.doc.custom_street_name || '') + ', ' + \n (frm.doc.custom_street_suffix || '')\n );\n }\n});",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-05-15 12:30:18.280633",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address Validation",
|
|
||||||
"script": "const renameInProgress = new Set();\r\nfunction rename(frm) {\r\n const newName = frm.doc.address_line1;\r\n if (!renameInProgress.has(newName)) {\r\n renameInProgress.add(newName);\r\n return frappe.call({\r\n method: 'frappe.rename_doc',\r\n freeze: true,\r\n \t\tfreeze_message: \"Updating name...\",\r\n args: {\r\n doctype: 'Address',\r\n old: frm.doc.name,\r\n new: newName,\r\n merge: 0\r\n },\r\n \r\n // From https://github.com/frappe/frappe/blob/f708acb59e3cdc9ec1a91bcdfc0f36d6d012cbf5/frappe/public/js/frappe/model/model.js#L787\r\n callback: function (r, rt) {\r\n \t\t\tif (!r.exc) {\r\n \t\t\t\t$(document).trigger(\"rename\", [\r\n \t\t\t\t\t'Address',\r\n \t\t\t\t\tfrm.doc.name,\r\n \t\t\t\t\tr.message || newName,\r\n \t\t\t\t]);\r\n \t\t\t}\r\n \t\t\trenameInProgress.delete(newName);\r\n \t\t},\r\n })\r\n }\r\n}\r\n\r\nfrappe.ui.form.on('Address', {\r\n // Trigger validation and formatting on refresh\r\n refresh: function (frm) {\r\n // Trigger field validation when the form is refreshed\r\n frm.trigger('format_address_fields');\r\n \r\n // Rename to remove appended type, if needed\r\n if (!frm.is_new() && frm.doc.name !== frm.doc.address_line1 && frm.doc.address_type === \"Other\") {\r\n // Trial and error, seems like 1 second is needed to not cause issues\r\n setTimeout(() => rename(frm), 1000);\r\n }\r\n },\r\n\r\n // Watch for changes in address_line1 and address_line2 fields\r\n address_line1: function (frm) {\r\n frm.trigger('format_address_fields');\r\n if (frm.doc.address_line1 && frm.doc.address_title !== frm.doc.address_line1) {\r\n frm.set_value('address_title', frm.doc.address_line1);\r\n }\r\n },\r\n address_line2: function (frm) {\r\n frm.trigger('format_address_fields');\r\n },\r\n\r\n // Format and validate address fields\r\n format_address_fields: function (frm) {\r\n // Helper function to capitalize text and remove punctuation\r\n function format_text(field) {\r\n let value = frm.doc[field] || '';\r\n // Remove punctuation and capitalize the text\r\n let formatted_value = value\r\n .replace(/[.,!?;:']/g, '') // Remove punctuation\r\n .toUpperCase(); // Capitalize text\r\n return formatted_value;\r\n }\r\n\r\n // Format address_line1 and address_line2\r\n const formatted_line1 = format_text('address_line1');\r\n const formatted_line2 = format_text('address_line2');\r\n\r\n // Set the formatted values back to the form\r\n if (formatted_line1 !== frm.doc.address_line1) {\r\n frm.set_value('address_line1', formatted_line1);\r\n }\r\n if (formatted_line2 !== frm.doc.address_line2) {\r\n frm.set_value('address_line2', formatted_line2);\r\n }\r\n\r\n // Validate if punctuation exists (after formatting)\r\n if (/[.,!?;:']/.test(frm.doc.address_line1) || /[.,!?;:']/.test(frm.doc.address_line2)) {\r\n frappe.msgprint(__('Punctuation is not allowed in address fields.'));\r\n }\r\n },\r\n\r\n // Before saving the document, validate the fields\r\n validate: function (frm) {\r\n const invalidFields = [];\r\n\r\n // Check if punctuation still exists in address fields\r\n ['address_line1', 'address_line2'].forEach(field => {\r\n if (/[.,!?;:']/.test(frm.doc[field])) {\r\n invalidFields.push(field);\r\n }\r\n });\r\n\r\n // If invalid fields exist, stop the save process and alert the user\r\n if (invalidFields.length > 0) {\r\n frappe.msgprint(__('Punctuation is not allowed in address fields: ') + invalidFields.join(', '));\r\n frappe.validated = false; // Prevent saving\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "QB Export",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-02-04 03:12:39.473331",
|
|
||||||
"module": null,
|
|
||||||
"name": "QB Export",
|
|
||||||
"script": "frappe.ui.form.on('QB Export', {\n\tonload(frm) {\n\t if (!frm.doc.start_date) frm.doc.start_date = new Date(new Date().getFullYear(), new Date().getMonth()-1)\n if (!frm.doc.end_date) frm.doc.end_date = new Date(new Date().getFullYear(), new Date().getMonth(), 0)\n }\n})",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Customer",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-02-05 05:28:54.588303",
|
|
||||||
"module": null,
|
|
||||||
"name": "Customer - Allow Multiple Addresses To Be Asssigned",
|
|
||||||
"script": "frappe.ui.form.on('Custom Customer Address Link', {\r\n address_name: function(frm, cdt, cdn) {\r\n let row = locals[cdt][cdn];\r\n \r\n if (row.address_name) {\r\n frappe.call({\r\n method: \"frappe.client.get\",\r\n args: {\r\n doctype: \"Address\",\r\n name: row.address_name\r\n },\r\n callback: function(r) {\r\n if (r.message) {\r\n frappe.model.set_value(cdt, cdn, \"address_line1\", r.message.address_line1);\r\n frappe.model.set_value(cdt, cdn, \"city\", r.message.city);\r\n frappe.model.set_value(cdt, cdn, \"state\", r.message.state);\r\n frappe.model.set_value(cdt, cdn, \"pincode\", r.message.pincode);\r\n frappe.model.set_value(cdt, cdn, \"address_type\", r.message.address_type);\r\n }\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Sales Order",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-03-05 05:13:03.150519",
|
|
||||||
"module": null,
|
|
||||||
"name": "Carry Over Installation Address",
|
|
||||||
"script": "frappe.ui.form.on(\"Sales Order\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.quotation) {\r\n frappe.db.get_value(\"Quotation\", frm.doc.quotation, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n \r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Sales Invoice",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-03-05 05:13:42.768423",
|
|
||||||
"module": null,
|
|
||||||
"name": "Quotation to Sales Invoice Carry Over",
|
|
||||||
"script": "frappe.ui.form.on(\"Sales Invoice\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.quotation) {\r\n frappe.db.get_value(\"Quotation\", frm.doc.quotation, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Work Order",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-03-05 07:09:42.680637",
|
|
||||||
"module": null,
|
|
||||||
"name": "Sales Order to Work Order Carry Over",
|
|
||||||
"script": "frappe.ui.form.on(\"Work Order\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.sales_order) {\r\n frappe.db.get_value(\"Sales Order\", frm.doc.sales_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Delivery Note",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-03-05 05:15:06.408083",
|
|
||||||
"module": null,
|
|
||||||
"name": "Sales Order to Delivery Note Carry Over",
|
|
||||||
"script": "frappe.ui.form.on(\"Delivery Note\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.against_sales_order) {\r\n frappe.db.get_value(\"Sales Order\", frm.doc.against_sales_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Payment Entry",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-04-25 04:17:25.626671",
|
|
||||||
"module": null,
|
|
||||||
"name": "Sales Invoice to Payment Entry Carry Over",
|
|
||||||
"script": "frappe.ui.form.on(\"Payment Entry\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.reference_doctype && frm.doc.reference_name) {\r\n let source_doctype = frm.doc.reference_doctype;\r\n let source_name = frm.doc.reference_name;\r\n\r\n frappe.db.get_value(\"Sales Invoice\", frm.doc.sales_invoice, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Job Card",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-03-05 05:22:37.566028",
|
|
||||||
"module": null,
|
|
||||||
"name": "Work Order to Job Card Carry Over",
|
|
||||||
"script": "frappe.ui.form.on(\"Job Card\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.work_order) {\r\n frappe.db.get_value(\"Work Order\", frm.doc.work_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_jobsite\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Project",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-03-05 05:19:29.828616",
|
|
||||||
"module": null,
|
|
||||||
"name": "Sales Order to Project Carry Over",
|
|
||||||
"script": "frappe.ui.form.on(\"Project\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.sales_order) {\r\n frappe.db.get_value(\"Sales Order\", frm.doc.sales_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message && r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-09-02 11:38:09.302073",
|
|
||||||
"module": null,
|
|
||||||
"name": "Buttons on Address Doctype",
|
|
||||||
"script": "frappe.ui.form.on(\"Address\", {\r\n refresh: function (frm) {\r\n if (!frm.is_new()) {\r\n frm.add_custom_button(__('Schedule On-Site Meeting'), function () {\r\n sessionStorage.setItem('on-site-meeting-address', frm.doc.name);\r\n frappe.set_route(\"List\", \"On-Site Meeting\", \"Calendar\", \"On-Site Meeting Calendar\");\r\n }, __(\"Create\"));\r\n \r\n // Add button to create a Sales Order\r\n frm.add_custom_button(__('Create Sales Order'), function () {\r\n frappe.new_doc('Sales Order', {\r\n custom_installation_address: frm.doc.name,\r\n customer: frm.doc.custom_customer_to_bill\r\n });\r\n }, __(\"Create\"));\r\n \r\n // Add button to create a Quotation\r\n frm.add_custom_button(__('Create Quotation'), function () {\r\n frappe.new_doc('Quotation', {\r\n custom_installation_address: frm.doc.name,\r\n quotation_to: 'Customer',\r\n party_name: frm.doc.custom_customer_to_bill\r\n });\r\n }, __(\"Create\"));\r\n // Add button to create a new Service Appointment\r\n frm.add_custom_button(__('Create Service Appointment'), function() {\r\n frappe.new_doc('Service Appointment', {\r\n //custom_location_of_meeting: frm.doc.name,\r\n contact: frm.doc.custom_customer_to_bill\r\n });\r\n }, __(\"Create\"));\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "On-Site Meeting",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-04-17 12:19:42.335880",
|
|
||||||
"module": null,
|
|
||||||
"name": "On-Site Meeting - autofill address from session Enabled",
|
|
||||||
"script": "frappe.ui.form.on('On-Site Meeting', {\n\tonload(frm) {\n\t if (sessionStorage.getItem('on-site-meeting-address')) {\n\t frm.set_value('address', sessionStorage.getItem('on-site-meeting-address'));\n\t console.log('start_time', frm.doc.start_time)\n\t if (frm.doc.start_time) {\n\t frm.save();\n\t }\n\t }\n\t},\n\tvalidate(frm) {\n\t let end = new Date(frm.doc.start_time + \" GMT\");\n\t end.setHours(end.getHours() + 1)\n\t frm.set_value('end_time', end.toISOString().replace('T', ' ').split('.')[0]);\n\t},\n\tafter_save(frm) {\n\t console.log('on submit')\n\t if (frm.doc.address === sessionStorage.getItem('on-site-meeting-address')) {\n\t frappe.set_route('Form', 'Address', frm.doc.address);\n\t }\n\t sessionStorage.removeItem('on-site-meeting-address');\n\t},\n\tafter_cancel(frm) {\n\t sessionStorage.removeItem('on-site-meeting-address');\n\t},\n\tafter_discard(frm) {\n\t sessionStorage.removeItem('on-site-meeting-address');\n\t},\n})",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Quotation",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-05-08 13:56:46.345762",
|
|
||||||
"module": null,
|
|
||||||
"name": "Quick-send quote button (SNW)",
|
|
||||||
"script": "frappe.ui.form.on('Quotation', {\n\trefresh(frm) {\n\t if (frm.doc.status == \"Open\" && frm.doc.company == \"Sprinklers Northwest\") {\n \t\tfrm.add_custom_button(__('Send Email'), async function () {\n \t\t const party = frm.doc.quotation_to && frm.doc.party_name ? await frappe.db.get_doc(frm.doc.quotation_to, frm.doc.party_name) : null;\n \t\t const address = frm.doc.custom_installation_address ? await frappe.db.get_doc('Address', frm.doc.custom_installation_address) : null;\n \t\t \n \t\t let email = null;\n \t\t if (party && party.email_id) {\n \t\t email = party.email_id;\n \t\t } else if (party && party.email_ids && party.email_ids.length) {\n \t\t const primary = party.email_ids.find(email => email.is_primary);\n \t\t if (primary) email = primary.email_id;\n \t\t else email = party.email_ids[0].email_id;\n \t\t } else if (address && address.email_id) {\n \t\t email = address.email_id;\n \t\t }\n \t\t \n \t\t if (!email) {\n \t\t frappe.msgprint(\"No email on customer or address\");\n \t\t return;\n \t\t }\n \t\t \n frappe.confirm(`Send quote to ${frm.doc.party_name} (${email})?`,\n async () => {\n const { message: { subject, message } } = await frappe.call({\n method: \"frappe.email.doctype.email_template.email_template.get_email_template\",\n type: \"POST\",\n args: {\n template_name: 'Quote with Actions - SNW',\n doc: frm.doc\n }\n });\n \n await frappe.call({\n method: \"frappe.core.doctype.communication.email.make\",\n type: 'POST',\n args: {\n recipients: email, \n subject,\n content: message,\n doctype: 'Quotation',\n name: frm.doc.name,\n send_email: 1,\n send_me_a_copy: 0,\n print_format: 'SNW Quotations',\n email_template: 'Quote with Actions - SNW',\n read_receipt: 1,\n print_letterhead: 1\n }\n });\n \n frappe.msgprint(\"Email Queued\");\n },\n null\n );\n });\n\t }\n\t}\n})",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Payment Entry",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-05-02 11:03:34.086145",
|
|
||||||
"module": null,
|
|
||||||
"name": "Installation Address Carry Over from Sales Order to Payment Address",
|
|
||||||
"script": "frappe.ui.form.on(\"Payment Entry\", {\r\n onload: function(frm) {\r\n if (!frm.doc.custom_installation_address && frm.doc.against_sales_order) {\r\n frappe.db.get_value(\"Sales Order\", frm.doc.against_sales_order, \"custom_installation_address\")\r\n .then(r => {\r\n if (r.message.custom_installation_address) {\r\n frm.set_value(\"custom_installation_address\", r.message.custom_installation_address);\r\n }\r\n });\r\n }\r\n }\r\n});\r\n\r\n\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Payment Entry",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-05-08 13:20:53.255294",
|
|
||||||
"module": null,
|
|
||||||
"name": "Fetch billing customer from installation address",
|
|
||||||
"script": "frappe.ui.form.on('Payment Entry', {\n\tcustom_installation_address(frm) {\n\t if (frm.doc.custom_installation_address) {\n\t frappe.db.get_doc('Address', frm.doc.custom_installation_address).then(doc => {\n frm.set_value('party_type', 'Customer')\n frm.set_value('party', doc.custom_customer_to_bill)\n })\n\t }\n\t}\n})",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Pre-Built Routes",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-06-17 02:12:53.095227",
|
|
||||||
"module": null,
|
|
||||||
"name": "Filter Route Technicians by Role",
|
|
||||||
"script": "frappe.ui.form.on('Pre-Built Routes', {\r\n onload: function(frm) {\r\n\r\n // Filter Crew Leader: Only show employees with designation = \"Crew Lead\"\r\n frm.set_query('crew_leader', () => {\r\n return {\r\n filters: {\r\n designation: 'Crew Lead',\r\n status: 'Active'\r\n }\r\n };\r\n });\r\n\r\n // Filter Technicians: Only show active employees who are NOT crew leads\r\n frm.fields_dict['assigned_technicians'].grid.get_field('employee').get_query = function() {\r\n return {\r\n filters: [\r\n ['designation', '!=', 'Crew Lead'],\r\n ['status', '=', 'Active']\r\n ]\r\n };\r\n };\r\n\r\n },\r\n\r\n // Optional: Prevent assigning crew leader as a technician too\r\n validate: function(frm) {\r\n let crew_leader = frm.doc.crew_leader;\r\n let techs = frm.doc.assigned_technicians || [];\r\n\r\n let duplicate = techs.find(row => row.employee === crew_leader);\r\n if (duplicate) {\r\n frappe.msgprint(__('Crew Leader cannot be listed as a Technician.'));\r\n frappe.validated = false;\r\n }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Pre-Built Routes",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-06-17 02:20:56.937981",
|
|
||||||
"module": null,
|
|
||||||
"name": "Auto-Fetch Relevant Sales Order",
|
|
||||||
"script": "// Trigger this logic whenever the address_name field is changed\nfrappe.ui.form.on('Assigned Address', {\n address_name: function(frm, cdt, cdn) {\n let row = locals[cdt][cdn];\n// Exit early if no address is selected\n if (!row.address_name) return;\n // Call the server to fetch all Sales Orders with this installation address\n frappe.call({\n method: \"frappe.client.get_list\",\n args: {\n doctype: \"Sales Order\",\n filters: {\n custom_installation_address: row.address_name\n },\n fields: [\"name\", \"customer\", \"transaction_date\", \"status\", \"grand_total\"]\n },\n callback: function(response) {\n const orders = response.message;\n // Case: No Sales Orders found\n if (!orders || orders.length === 0) {\n frappe.msgprint(\"No Sales Orders found for this address.\");\n frappe.model.set_value(cdt, cdn, \"linked_sales_order\", \"\");\n return;\n }\n // Case: Exactly one Sales Order found — auto-select it\n if (orders.length === 1) {\n frappe.model.set_value(cdt, cdn, \"linked_sales_order\", orders[0].name);\n return;\n }\n\n // Case: Multiple Sales Orders found — show a dialog to select one\n\n // Create a user-friendly list of options for the select field\n const options = orders.map(order => {\n return {\n label: `${order.name} | ${order.customer} | ${frappe.datetime.str_to_user(order.transaction_date)} | ${order.status} | $${order.grand_total}`,\n value: order.name\n };\n });\n // Define and show a custom dialog for selecting the appropriate Sales Order\n const dialog = new frappe.ui.Dialog({\n title: \"Select Sales Order for \" + row.address_name,\n fields: [\n {\n fieldname: 'selected_so',\n label: 'Sales Order',\n fieldtype: 'Select',\n options: options,\n reqd: 1\n }\n ],\n primary_action_label: 'Select',\n primary_action(values) {\n frappe.model.set_value(cdt, cdn, \"linked_sales_order\", values.selected_so);\n dialog.hide();\n }\n });\n\n dialog.show();\n }\n });\n }\n});\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Project",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-08-29 17:31:31.663751",
|
|
||||||
"module": null,
|
|
||||||
"name": "Project Warranty Countdown",
|
|
||||||
"script": "frappe.ui.form.on('Project', {\n\trefresh(frm) {\n\t\tif (frm.doc.status == \"Completed\" && frm.doc.project_template == \"SNW Install\") {\n\t\t let message;\n\t\t const days = Math.abs(frappe.datetime.get_day_diff(frm.doc.custom_warranty_expiration_date, frm.doc.custom_completion_date));\n\t\t let dayMessage = days == 1 ? \"day\" : \"days\";\n\t\t if (frappe.datetime.get_today() <= frm.doc.custom_warranty_expiration_date) {\n\t\t message = `Warranty is valid for ${days} more ${dayMessage}`;\n\t\t } else {\n\t\t message = `Warranty has expired ${days} ${dayMessage} ago.`;\n\t\t }\n\t\t frm.set_value(\"custom_warranty_information\", message);\n\t\t}\n\t}\n});\n",
|
|
||||||
"view": "Form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Client Script",
|
|
||||||
"dt": "Address",
|
|
||||||
"enabled": 1,
|
|
||||||
"modified": "2025-05-15 09:21:20.813661",
|
|
||||||
"module": null,
|
|
||||||
"name": "Address Error Fix",
|
|
||||||
"script": "frappe.ui.form.on('Address', {\r\n validate: function (frm) {\r\n // Default value for the custom field (if it doesn't exist in Address DocType)\r\n if (!frm.doc.hasOwnProperty('custom_is_your_company_address')) {\r\n frm.doc.custom_is_your_company_address = false;\r\n }\r\n\r\n // Custom validation logic\r\n // if (frm.doc.custom_is_your_company_address && !frm.doc.some_other_field) {\r\n // frappe.throw(__('Please ensure that the required fields are filled out for Company Address.'));\r\n // }\r\n }\r\n});\r\n",
|
|
||||||
"view": "Form"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -26,10 +26,6 @@ add_to_apps_screen = [
|
|||||||
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
requires = [
|
|
||||||
"holidays==0.89"
|
|
||||||
]
|
|
||||||
# Apps
|
# Apps
|
||||||
# ------------------
|
# ------------------
|
||||||
|
|
||||||
@ -37,13 +33,13 @@ requires = [
|
|||||||
|
|
||||||
# Each item in the list will be shown as an app in the apps page
|
# Each item in the list will be shown as an app in the apps page
|
||||||
# add_to_apps_screen = [
|
# add_to_apps_screen = [
|
||||||
# {
|
# {
|
||||||
# "name": "custom_ui",
|
# "name": "custom_ui",
|
||||||
# "logo": "/assets/custom_ui/logo.png",
|
# "logo": "/assets/custom_ui/logo.png",
|
||||||
# "title": "Custom Ui",
|
# "title": "Custom Ui",
|
||||||
# "route": "/custom_ui",
|
# "route": "/custom_ui",
|
||||||
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
# "has_permission": "custom_ui.api.permission.has_app_permission"
|
||||||
# }
|
# }
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
# Includes in <head>
|
# Includes in <head>
|
||||||
@ -86,7 +82,7 @@ requires = [
|
|||||||
|
|
||||||
# website user home page (by Role)
|
# website user home page (by Role)
|
||||||
# role_home_page = {
|
# role_home_page = {
|
||||||
# "Role": "home_page"
|
# "Role": "home_page"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Generators
|
# Generators
|
||||||
@ -100,8 +96,8 @@ requires = [
|
|||||||
|
|
||||||
# add methods and filters to jinja environment
|
# add methods and filters to jinja environment
|
||||||
# jinja = {
|
# jinja = {
|
||||||
# "methods": "custom_ui.utils.jinja_methods",
|
# "methods": "custom_ui.utils.jinja_methods",
|
||||||
# "filters": "custom_ui.utils.jinja_filters"
|
# "filters": "custom_ui.utils.jinja_filters"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
@ -143,11 +139,11 @@ requires = [
|
|||||||
# Permissions evaluated in scripted ways
|
# Permissions evaluated in scripted ways
|
||||||
|
|
||||||
# permission_query_conditions = {
|
# permission_query_conditions = {
|
||||||
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
|
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
# has_permission = {
|
# has_permission = {
|
||||||
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# DocType Class
|
# DocType Class
|
||||||
@ -155,114 +151,41 @@ requires = [
|
|||||||
# Override standard doctype classes
|
# Override standard doctype classes
|
||||||
|
|
||||||
# override_doctype_class = {
|
# override_doctype_class = {
|
||||||
# "ToDo": "custom_app.overrides.CustomToDo"
|
# "ToDo": "custom_app.overrides.CustomToDo"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Document Events
|
# Document Events
|
||||||
# ---------------
|
# ---------------
|
||||||
# Hook on document methods and events
|
# Hook on document methods and events
|
||||||
|
|
||||||
doc_events = {
|
# doc_events = {
|
||||||
"On-Site Meeting": {
|
# "*": {
|
||||||
"after_insert": "custom_ui.events.onsite_meeting.after_insert",
|
# "on_update": "method",
|
||||||
"before_save": "custom_ui.events.onsite_meeting.before_save",
|
# "on_cancel": "method",
|
||||||
"before_insert": "custom_ui.events.onsite_meeting.before_insert"
|
# "on_trash": "method"
|
||||||
},
|
# }
|
||||||
"Address": {
|
# }
|
||||||
"before_insert": "custom_ui.events.address.before_insert"
|
|
||||||
},
|
|
||||||
"Quotation": {
|
|
||||||
"before_insert": "custom_ui.events.estimate.before_insert",
|
|
||||||
"after_insert": "custom_ui.events.estimate.after_insert",
|
|
||||||
# "before_save": "custom_ui.events.estimate.before_save",
|
|
||||||
"before_submit": "custom_ui.events.estimate.before_submit",
|
|
||||||
"on_update_after_submit": "custom_ui.events.estimate.on_update_after_submit"
|
|
||||||
},
|
|
||||||
"Sales Order": {
|
|
||||||
"before_save": "custom_ui.events.sales_order.before_save",
|
|
||||||
"before_insert": "custom_ui.events.sales_order.before_insert",
|
|
||||||
"after_insert": "custom_ui.events.sales_order.after_insert",
|
|
||||||
"on_submit": "custom_ui.events.sales_order.on_submit",
|
|
||||||
"on_update_after_submit": "custom_ui.events.sales_order.on_update_after_submit"
|
|
||||||
},
|
|
||||||
"Project": {
|
|
||||||
"before_insert": "custom_ui.events.jobs.before_insert",
|
|
||||||
"after_insert": "custom_ui.events.jobs.after_insert",
|
|
||||||
"before_save": "custom_ui.events.jobs.before_save",
|
|
||||||
"on_update": "custom_ui.events.jobs.after_save",
|
|
||||||
"after_save": "custom_ui.events.jobs.after_save"
|
|
||||||
},
|
|
||||||
"Task": {
|
|
||||||
"before_insert": "custom_ui.events.task.before_insert",
|
|
||||||
"after_insert": "custom_ui.events.task.after_insert",
|
|
||||||
"before_save": "custom_ui.events.task.before_save"
|
|
||||||
},
|
|
||||||
"Bid Meeting Note Form": {
|
|
||||||
"after_insert": "custom_ui.events.general.attach_bid_note_form_to_project_template"
|
|
||||||
},
|
|
||||||
"Service Address 2": {
|
|
||||||
"before_save": "custom_ui.events.service_appointment.before_save",
|
|
||||||
"after_insert": "custom_ui.events.service_appointment.after_insert",
|
|
||||||
"on_update": "custom_ui.events.service_appointment.on_update"
|
|
||||||
},
|
|
||||||
"Payment Entry": {
|
|
||||||
"on_submit": "custom_ui.events.payments.on_submit"
|
|
||||||
},
|
|
||||||
"Sales Invoice": {
|
|
||||||
"on_submit": "custom_ui.events.sales_invoice.on_submit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fixtures = [
|
|
||||||
{
|
|
||||||
"dt": "Email Template",
|
|
||||||
"filters": [
|
|
||||||
["name", "in", ["Customer Invoice"]]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dt": "DocType",
|
|
||||||
"filters": [
|
|
||||||
["custom", "=", 1]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
# These don't have reliable flags → export all
|
|
||||||
{"dt": "Custom Field"},
|
|
||||||
{"dt": "Property Setter"},
|
|
||||||
{"dt": "Client Script"},
|
|
||||||
{"dt": "Server Script"},
|
|
||||||
# {"dt": "Report"},
|
|
||||||
# {"dt": "Print Format"},
|
|
||||||
# {"dt": "Dashboard"},
|
|
||||||
# {"dt": "Workspace"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Scheduled Tasks
|
# Scheduled Tasks
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
scheduler_events = {
|
# scheduler_events = {
|
||||||
# "all": [
|
# "all": [
|
||||||
# "custom_ui.tasks.all"
|
# "custom_ui.tasks.all"
|
||||||
# ],
|
# ],
|
||||||
"daily": [
|
# "daily": [
|
||||||
"custom_ui.scheduled_tasks.daily"
|
# "custom_ui.tasks.daily"
|
||||||
],
|
# ],
|
||||||
# "hourly": [
|
# "hourly": [
|
||||||
# "custom_ui.tasks.hourly"
|
# "custom_ui.tasks.hourly"
|
||||||
# ],
|
# ],
|
||||||
# "weekly": [
|
# "weekly": [
|
||||||
# "custom_ui.tasks.weekly"
|
# "custom_ui.tasks.weekly"
|
||||||
# ],
|
# ],
|
||||||
# "monthly": [
|
# "monthly": [
|
||||||
# "custom_ui.tasks.monthly"
|
# "custom_ui.tasks.monthly"
|
||||||
# ],
|
# ],
|
||||||
}
|
# }
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
# -------
|
# -------
|
||||||
@ -273,14 +196,14 @@ scheduler_events = {
|
|||||||
# ------------------------------
|
# ------------------------------
|
||||||
#
|
#
|
||||||
# override_whitelisted_methods = {
|
# override_whitelisted_methods = {
|
||||||
# "frappe.desk.doctype.event.event.get_events": "custom_ui.event.get_events"
|
# "frappe.desk.doctype.event.event.get_events": "custom_ui.event.get_events"
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
# each overriding function accepts a `data` argument;
|
# each overriding function accepts a `data` argument;
|
||||||
# generated from the base implementation of the doctype dashboard,
|
# generated from the base implementation of the doctype dashboard,
|
||||||
# along with any modifications made in other Frappe apps
|
# along with any modifications made in other Frappe apps
|
||||||
# override_doctype_dashboards = {
|
# override_doctype_dashboards = {
|
||||||
# "Task": "custom_ui.task.get_dashboard_data"
|
# "Task": "custom_ui.task.get_dashboard_data"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# exempt linked doctypes from being automatically cancelled
|
# exempt linked doctypes from being automatically cancelled
|
||||||
@ -306,37 +229,37 @@ scheduler_events = {
|
|||||||
# --------------------
|
# --------------------
|
||||||
|
|
||||||
# user_data_fields = [
|
# user_data_fields = [
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_1}",
|
# "doctype": "{doctype_1}",
|
||||||
# "filter_by": "{filter_by}",
|
# "filter_by": "{filter_by}",
|
||||||
# "redact_fields": ["{field_1}", "{field_2}"],
|
# "redact_fields": ["{field_1}", "{field_2}"],
|
||||||
# "partial": 1,
|
# "partial": 1,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_2}",
|
# "doctype": "{doctype_2}",
|
||||||
# "filter_by": "{filter_by}",
|
# "filter_by": "{filter_by}",
|
||||||
# "partial": 1,
|
# "partial": 1,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_3}",
|
# "doctype": "{doctype_3}",
|
||||||
# "strict": False,
|
# "strict": False,
|
||||||
# },
|
# },
|
||||||
# {
|
# {
|
||||||
# "doctype": "{doctype_4}"
|
# "doctype": "{doctype_4}"
|
||||||
# }
|
# }
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
# Authentication and authorization
|
# Authentication and authorization
|
||||||
# --------------------------------
|
# --------------------------------
|
||||||
|
|
||||||
# auth_hooks = [
|
# auth_hooks = [
|
||||||
# "custom_ui.auth.validate"
|
# "custom_ui.auth.validate"
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
# Automatically update python controller files with type annotations for this app.
|
# Automatically update python controller files with type annotations for this app.
|
||||||
# export_python_type_annotations = True
|
# export_python_type_annotations = True
|
||||||
|
|
||||||
# default_log_clearing_doctypes = {
|
# default_log_clearing_doctypes = {
|
||||||
# "Logging DocType Name": 30 # days to retain logs
|
# "Logging DocType Name": 30 # days to retain logs
|
||||||
# }
|
# }
|
||||||
|
|
||||||
|
|||||||
1707
custom_ui/install.py
1707
custom_ui/install.py
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
|||||||
from .payments import PaymentData
|
|
||||||
from .item_models import BOMItem, PackageCreationData
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BOMItem:
|
|
||||||
item_code: str
|
|
||||||
qty: float
|
|
||||||
uom: str
|
|
||||||
item_name: str = None
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PackageCreationData:
|
|
||||||
package_name: str
|
|
||||||
items: list[BOMItem]
|
|
||||||
item_group: str
|
|
||||||
code_prefix: str
|
|
||||||
rate: float = 0.0
|
|
||||||
company: str = None
|
|
||||||
description: str = None
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PaymentData:
|
|
||||||
mode_of_payment: str
|
|
||||||
reference_no: str
|
|
||||||
reference_date: str
|
|
||||||
received_amount: float
|
|
||||||
company: str = None
|
|
||||||
reference_doc_name: str = None
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services import TaskService
|
|
||||||
|
|
||||||
def daily_task():
|
|
||||||
"""Scheduled task to run daily."""
|
|
||||||
print("#################### Running Daily Task ####################")
|
|
||||||
print("DEBUG: Checking Task due dates")
|
|
||||||
TaskService.find_and_update_overdue_tasks()
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
from .address_service import AddressService
|
|
||||||
from .contact_service import ContactService
|
|
||||||
from .db_service import DbService
|
|
||||||
from .client_service import ClientService
|
|
||||||
from .estimate_service import EstimateService
|
|
||||||
from .onsite_meeting_service import OnSiteMeetingService
|
|
||||||
from .task_service import TaskService
|
|
||||||
from .service_appointment_service import ServiceAppointmentService
|
|
||||||
from .stripe_service import StripeService
|
|
||||||
from .payment_service import PaymentService
|
|
||||||
from .item_service import ItemService
|
|
||||||
from .project_service import ProjectService
|
|
||||||
from .sales_order_service import SalesOrderService
|
|
||||||
from .email_service import EmailService
|
|
||||||
@ -1,224 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from frappe.model.document import Document
|
|
||||||
import requests
|
|
||||||
from .contact_service import ContactService, DbService
|
|
||||||
|
|
||||||
class AddressService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_address_title(customer_name, address_data) -> str:
|
|
||||||
"""Build a title for the address based on its fields."""
|
|
||||||
print(f"DEBUG: Building address title for customer '{customer_name}' with address data: {address_data}")
|
|
||||||
is_billing = address_data.get("is_billing_address", False)
|
|
||||||
address_type = "Billing" if is_billing else "Service"
|
|
||||||
return f"{customer_name} - {address_data.get('address_line1', '')} {address_data.get('city')} - {address_type}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_full_dict(
|
|
||||||
address_doc: Document,
|
|
||||||
included_links: list = ["contacts", "on-site meetings", "quotations", "sales orders", "projects", "companies"]) -> frappe._dict:
|
|
||||||
"""Build a full dictionary representation of an address, including all links. Can optionally exclude links."""
|
|
||||||
print(f"DEBUG: Building full dict for Address {address_doc.name}")
|
|
||||||
address_dict = address_doc.as_dict()
|
|
||||||
if "contacts" in included_links:
|
|
||||||
address_dict["contacts"] = [ContactService.get_or_throw(link.contact).as_dict() for link in address_doc.contacts]
|
|
||||||
if "on-site meetings" in included_links:
|
|
||||||
address_dict["onsite_meetings"] = [DbService.get_or_throw("On-Site Meeting", link.onsite_meeting).as_dict() for link in address_doc.onsite_meetings]
|
|
||||||
if "quotations" in included_links:
|
|
||||||
address_dict["quotations"] = [DbService.get_or_throw("Quotation", link.quotation).as_dict() for link in address_doc.quotations]
|
|
||||||
if "sales orders" in included_links:
|
|
||||||
address_dict["sales_orders"] = [DbService.get_or_throw("Sales Order", link.sales_order).as_dict() for link in address_doc.sales_orders]
|
|
||||||
if "projects" in included_links:
|
|
||||||
address_dict["projects"] = [DbService.get_or_throw("Project", link.project).as_dict() for link in address_doc.projects]
|
|
||||||
if "companies" in included_links:
|
|
||||||
address_dict["companies"] = [DbService.get_or_throw("Company", link.company).as_dict() for link in address_doc.companies]
|
|
||||||
print(f"DEBUG: Built full dict for Address {address_doc.name}: {address_dict}")
|
|
||||||
return address_dict
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_address_by_full_address(full_address: str) -> Document:
|
|
||||||
"""Retrieve an address document by its full_address field. Returns None if not found."""
|
|
||||||
print(f"DEBUG: Retrieving Address document with full_address: {full_address}")
|
|
||||||
address_name = frappe.db.get_value("Address", {"full_address": full_address})
|
|
||||||
if address_name:
|
|
||||||
address_doc = DbService.get_or_throw("Address", address_name)
|
|
||||||
print("DEBUG: Address document found.")
|
|
||||||
return address_doc
|
|
||||||
print("DEBUG: Address document not found.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def exists(address_name: str) -> bool:
|
|
||||||
"""Check if an address with the given name exists."""
|
|
||||||
print(f"DEBUG: Checking existence of Address with name: {address_name}")
|
|
||||||
result = frappe.db.exists("Address", address_name) is not None
|
|
||||||
print(f"DEBUG: Address existence: {result}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get(address_name: str) -> Document:
|
|
||||||
"""Retrieve an address document by name. Returns None if not found."""
|
|
||||||
print(f"DEBUG: Retrieving Address document with name: {address_name}")
|
|
||||||
if AddressService.exists(address_name):
|
|
||||||
address_doc = DbService.get_or_throw("Address", address_name)
|
|
||||||
print("DEBUG: Address document found.")
|
|
||||||
return address_doc
|
|
||||||
print("DEBUG: Address document not found.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_or_throw(address_name: str) -> Document:
|
|
||||||
"""Retrieve an address document by name or throw an error if not found."""
|
|
||||||
address_doc = AddressService.get(address_name)
|
|
||||||
if address_doc:
|
|
||||||
return address_doc
|
|
||||||
raise ValueError(f"Address with name {address_name} does not exist.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_value(doc_name: str, fieldname: str, value, save: bool = True) -> Document:
|
|
||||||
"""Update a specific field value of a document."""
|
|
||||||
print(f"DEBUG: Updating Address {doc_name}, setting {fieldname} to {value}")
|
|
||||||
if AddressService.exists(doc_name) is False:
|
|
||||||
raise ValueError(f"Address with name {doc_name} does not exist.")
|
|
||||||
if save:
|
|
||||||
print("DEBUG: Saving updated Address document.")
|
|
||||||
address_doc = AddressService.get_or_throw(doc_name)
|
|
||||||
setattr(address_doc, fieldname, value)
|
|
||||||
address_doc.save(ignore_permissions=True)
|
|
||||||
else:
|
|
||||||
print("DEBUG: Not saving Address document as save=False.")
|
|
||||||
frappe.db.set_value("Address", doc_name, fieldname, value)
|
|
||||||
print(f"DEBUG: Updated Address {doc_name}: set {fieldname} to {value}")
|
|
||||||
return address_doc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_value(doc_name: str, fieldname: str) -> any:
|
|
||||||
"""Get a specific field value of a document. Returns None if document does not exist."""
|
|
||||||
print(f"DEBUG: Getting value of field {fieldname} from Address {doc_name}")
|
|
||||||
if not AddressService.exists(doc_name):
|
|
||||||
print("DEBUG: Value cannot be retrieved; Address does not exist.")
|
|
||||||
return None
|
|
||||||
value = frappe.db.get_value("Address", doc_name, fieldname)
|
|
||||||
print(f"DEBUG: Retrieved value: {value}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_value_or_throw(doc_name: str, fieldname: str) -> any:
|
|
||||||
"""Get a specific field value of a document or throw an error if document does not exist."""
|
|
||||||
value = AddressService.get_value(doc_name, fieldname)
|
|
||||||
if value is not None:
|
|
||||||
return value
|
|
||||||
raise ValueError(f"Address with name {doc_name} does not exist.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create(address_data: dict) -> Document:
|
|
||||||
"""Create a new address."""
|
|
||||||
print("DEBUG: Creating new Address with data:", address_data)
|
|
||||||
address = frappe.get_doc({
|
|
||||||
"doctype": "Address",
|
|
||||||
**address_data
|
|
||||||
})
|
|
||||||
address.insert(ignore_permissions=True)
|
|
||||||
print("DEBUG: Created new Address:", address.as_dict())
|
|
||||||
return address
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def link_address_to_customer(address_doc: Document, customer_type: str, customer_name: str):
|
|
||||||
"""Link an address to a customer or lead."""
|
|
||||||
print(f"DEBUG: Linking Address {address_doc.name} to {customer_type} {customer_name}")
|
|
||||||
address_doc.customer_type = customer_type
|
|
||||||
address_doc.customer_name = customer_name
|
|
||||||
address_doc.append("links", {
|
|
||||||
"link_doctype": customer_type,
|
|
||||||
"link_name": customer_name
|
|
||||||
})
|
|
||||||
address_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Linked Address {address_doc.name} to {customer_type} {customer_name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def link_address_to_contact(address_doc: Document, contact_name: str):
|
|
||||||
"""Link an address to a contact."""
|
|
||||||
print(f"DEBUG: Linking Address {address_doc.name} to Contact {contact_name}")
|
|
||||||
address_doc.append("contacts", {
|
|
||||||
"contact": contact_name
|
|
||||||
})
|
|
||||||
address_doc.append("links", {
|
|
||||||
"link_doctype": "Contact",
|
|
||||||
"link_name": contact_name
|
|
||||||
})
|
|
||||||
address_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Linked Address {address_doc.name} to Contact {contact_name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_address(address_data: dict) -> Document:
|
|
||||||
"""Create a new address."""
|
|
||||||
address = frappe.get_doc({
|
|
||||||
"doctype": "Address",
|
|
||||||
**address_data
|
|
||||||
})
|
|
||||||
address.insert(ignore_permissions=True)
|
|
||||||
return address
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_primary_contact(address_name: str, contact_name: str):
|
|
||||||
"""Set the primary contact for an address."""
|
|
||||||
print(f"DEBUG: Setting primary contact for Address {address_name} to Contact {contact_name}")
|
|
||||||
frappe.db.set_value("Address", address_name, "primary_contact", contact_name)
|
|
||||||
print(f"DEBUG: Set primary contact for Address {address_name} to Contact {contact_name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def append_link(address_name: str, field: str, link_doctype: str, link_name: str):
|
|
||||||
"""Set a link field for an address."""
|
|
||||||
print(f"DEBUG: Setting link field {field} for Address {address_name} to {link_doctype} {link_name}")
|
|
||||||
address_doc = AddressService.get_or_throw(address_name)
|
|
||||||
address_doc.append(field, {
|
|
||||||
link_doctype.lower(): link_name
|
|
||||||
})
|
|
||||||
address_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Set link field {field} for Address {address_name} to {link_doctype} {link_name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def append_link_v2(address_name: str, field: str, link: dict):
|
|
||||||
"""Set a link field for an address using a link dictionary."""
|
|
||||||
print(f"DEBUG: Setting link field {field} for Address {address_name} with link data {link}")
|
|
||||||
address_doc = AddressService.get_or_throw(address_name)
|
|
||||||
print("DEBUG: Appending link:", link)
|
|
||||||
address_doc.append(field, link)
|
|
||||||
print("DEBUG: Saving address document after appending link.")
|
|
||||||
address_doc.save(ignore_permissions=True)
|
|
||||||
frappe.db.commit()
|
|
||||||
print(f"DEBUG: Set link field {field} for Address {address_name} with link data {link}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_county_and_set(address_doc: Document, save: bool = False):
|
|
||||||
"""Get the county from the address document and set it if not already set."""
|
|
||||||
if not address_doc.county:
|
|
||||||
print(f"DEBUG: Getting county for Address {address_doc.name}")
|
|
||||||
# Example logic to determine county from address fields
|
|
||||||
# This is a placeholder; actual implementation may vary
|
|
||||||
url = "https://geocoding.geo.cencus.gov/geocoder/geographies/coordinates"
|
|
||||||
params = {
|
|
||||||
"x": address_doc.longitude,
|
|
||||||
"y": address_doc.latitude,
|
|
||||||
"benchmark": "Public_AR_Current",
|
|
||||||
"vintage": "Current_Current",
|
|
||||||
"format": "json"
|
|
||||||
}
|
|
||||||
|
|
||||||
r = requests.get(url, params=params, timeout=10)
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
try:
|
|
||||||
county = data['result']['geographies']['Counties'][0]['NAME']
|
|
||||||
county_fips = data['result']['geographies']['Counties'][0]['GEOID']
|
|
||||||
except (KeyError, IndexError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
county_info = {
|
|
||||||
"county": county,
|
|
||||||
"county_fips": county_fips
|
|
||||||
}
|
|
||||||
|
|
||||||
AddressService.update_value(address_doc.name, "county", county_info, save)
|
|
||||||
AddressService.update_value(address_doc.name, "county_fips", county_fips, save)
|
|
||||||
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from frappe.model.document import Document
|
|
||||||
from .db_service import DbService
|
|
||||||
from erpnext.crm.doctype.lead.lead import make_customer
|
|
||||||
from .address_service import AddressService
|
|
||||||
from .contact_service import ContactService
|
|
||||||
from .estimate_service import EstimateService
|
|
||||||
from .onsite_meeting_service import OnSiteMeetingService
|
|
||||||
|
|
||||||
class ClientService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_client_or_throw(client_name: str) -> Document:
|
|
||||||
"""Retrieve a Client document (Customer or Lead) or throw an error if it does not exist."""
|
|
||||||
doctype = ClientService.get_client_doctype(client_name)
|
|
||||||
return DbService.get_or_throw(doctype, client_name)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_client_doctype(client_name: str) -> str:
|
|
||||||
"""Determine if the client is a Customer or Lead."""
|
|
||||||
if DbService.exists("Customer", client_name):
|
|
||||||
return "Customer"
|
|
||||||
elif DbService.exists("Lead", client_name):
|
|
||||||
return "Lead"
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Client with name {client_name} does not exist as Customer or Lead.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_primary_contact(client_name: str, contact_name: str):
|
|
||||||
"""Set the primary contact for a client (Customer or Lead)."""
|
|
||||||
print(f"DEBUG: Setting primary contact for client {client_name} to contact {contact_name}")
|
|
||||||
client_doctype = ClientService.get_client_doctype(client_name)
|
|
||||||
frappe.db.set_value(client_doctype, client_name, "primary_contact", contact_name)
|
|
||||||
print(f"DEBUG: Set primary contact for client {client_name} to contact {contact_name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def append_link(client_name: str, field: str, link_doctype: str, link_name: str):
|
|
||||||
"""Set a link field for a client (Customer or Lead)."""
|
|
||||||
print(f"DEBUG: Setting link field {field} for client {client_name} to {link_doctype} {link_name}")
|
|
||||||
client_doctype = ClientService.get_client_doctype(client_name)
|
|
||||||
client_doc = frappe.get_doc(client_doctype, client_name)
|
|
||||||
client_doc.append(field, {
|
|
||||||
link_doctype.lower(): link_name
|
|
||||||
})
|
|
||||||
client_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Set link field {field} for client {client_doc.get('name')} to {link_doctype} {link_name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def append_link_v2(client_name: str, field: str, link: dict):
|
|
||||||
"""Set a link field for a client (Customer or Lead) using a link dictionary."""
|
|
||||||
print(f"DEBUG: Setting link field {field} for client {client_name} with link data {link}")
|
|
||||||
client_doctype = ClientService.get_client_doctype(client_name)
|
|
||||||
client_doc = DbService.get_or_throw(client_doctype, client_name)
|
|
||||||
print("DEBUG: Appending link:", link)
|
|
||||||
client_doc.append(field, link)
|
|
||||||
print("DEBUG: Saving client document after appending link.")
|
|
||||||
client_doc.save(ignore_permissions=True)
|
|
||||||
frappe.db.commit()
|
|
||||||
print(f"DEBUG: Set link field {field} for client {client_doc.get('name')} with link data {link}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def convert_lead_to_customer(
|
|
||||||
lead_name: str,
|
|
||||||
update_quotations: bool = True,
|
|
||||||
update_addresses: bool = True,
|
|
||||||
update_contacts: bool = True,
|
|
||||||
update_onsite_meetings: bool = True,
|
|
||||||
update_companies: bool = True
|
|
||||||
) -> Document:
|
|
||||||
"""Convert a Lead to a Customer."""
|
|
||||||
print(f"DEBUG: Converting Lead {lead_name} to Customer")
|
|
||||||
try:
|
|
||||||
lead_doc = DbService.get_or_throw("Lead", lead_name)
|
|
||||||
print(f"DEBUG: Retrieved Lead document: {lead_doc.name}")
|
|
||||||
|
|
||||||
print("DEBUG: RUNNING make_customer()")
|
|
||||||
customer_doc = make_customer(lead_doc.name)
|
|
||||||
print(f"DEBUG: make_customer() returned document type: {type(customer_doc)}")
|
|
||||||
print(f"DEBUG: Customer doc name: {customer_doc.name if hasattr(customer_doc, 'name') else 'NO NAME'}")
|
|
||||||
customer_doc.custom_billing_address = lead_doc.custom_billing_address
|
|
||||||
print("DEBUG: Calling customer_doc.insert()")
|
|
||||||
customer_doc.insert(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Customer inserted successfully: {customer_doc.name}")
|
|
||||||
|
|
||||||
frappe.db.commit()
|
|
||||||
print("DEBUG: Database committed after customer insert")
|
|
||||||
print("DEBUG: CREATED CUSTOMER:", customer_doc.as_dict())
|
|
||||||
if update_addresses:
|
|
||||||
print("DEBUG: Lead_doc addresses:", lead_doc.get("addresses", []))
|
|
||||||
print(f"DEBUG: Updating addresses. Count: {len(lead_doc.get('properties', []))}")
|
|
||||||
for address in lead_doc.get("properties", []):
|
|
||||||
try:
|
|
||||||
print(f"DEBUG: Processing address: {address.get('address')}")
|
|
||||||
ClientService.append_link_v2(customer_doc.name, "properties", {"address": address.get("address")})
|
|
||||||
customer_doc.reload()
|
|
||||||
address_doc = AddressService.get_or_throw(address.get("address"))
|
|
||||||
AddressService.link_address_to_customer(address_doc, "Customer", customer_doc.name)
|
|
||||||
print(f"DEBUG: Linked address {address.get('address')} to customer")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to link address {address.get('address')}: {str(e)}")
|
|
||||||
frappe.log_error(f"Address linking error: {str(e)}", "convert_lead_to_customer")
|
|
||||||
|
|
||||||
if update_contacts:
|
|
||||||
print(f"DEBUG: Updating contacts. Count: {len(lead_doc.get('contacts', []))}")
|
|
||||||
for contact in lead_doc.get("contacts", []):
|
|
||||||
try:
|
|
||||||
print(f"DEBUG: Processing contact: {contact.get('contact')}")
|
|
||||||
ClientService.append_link_v2(customer_doc.name, "contacts", {"contact": contact.get("contact")})
|
|
||||||
customer_doc.reload()
|
|
||||||
contact_doc = ContactService.get_or_throw(contact.get("contact"))
|
|
||||||
ContactService.link_contact_to_customer(contact_doc, "Customer", customer_doc.name)
|
|
||||||
print(f"DEBUG: Linked contact {contact.get('contact')} to customer")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to link contact {contact.get('contact')}: {str(e)}")
|
|
||||||
frappe.log_error(f"Contact linking error: {str(e)}", "convert_lead_to_customer")
|
|
||||||
|
|
||||||
if update_quotations:
|
|
||||||
print(f"DEBUG: Updating quotations. Count: {len(lead_doc.get('quotations', []))}")
|
|
||||||
for quotation in lead_doc.get("quotations", []):
|
|
||||||
try:
|
|
||||||
print(f"DEBUG: Processing quotation: {quotation.get('quotation')}")
|
|
||||||
ClientService.append_link_v2(customer_doc.name, "quotations", {"quotation": quotation.get("quotation")})
|
|
||||||
customer_doc.reload()
|
|
||||||
quotation_doc = EstimateService.get_or_throw(quotation.get("quotation"))
|
|
||||||
EstimateService.link_estimate_to_customer(quotation_doc, "Customer", customer_doc.name)
|
|
||||||
print(f"DEBUG: Linked quotation {quotation.get('quotation')} to customer")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to link quotation {quotation.get('quotation')}: {str(e)}")
|
|
||||||
|
|
||||||
if update_onsite_meetings:
|
|
||||||
print(f"DEBUG: Updating onsite meetings. Count: {len(lead_doc.get('onsite_meetings', []))}")
|
|
||||||
for meeting in lead_doc.get("onsite_meetings", []):
|
|
||||||
try:
|
|
||||||
print(f"DEBUG: Processing onsite meeting: {meeting.get('onsite_meeting')}")
|
|
||||||
meeting_doc = DbService.get_or_throw("On-Site Meeting",meeting.get("onsite_meeting"))
|
|
||||||
ClientService.append_link_v2(customer_doc.name, "onsite_meetings", {"onsite_meeting": meeting.get("onsite_meeting")})
|
|
||||||
customer_doc.reload()
|
|
||||||
OnSiteMeetingService.link_onsite_meeting_to_customer(meeting_doc, "Customer", customer_doc.name)
|
|
||||||
print(f"DEBUG: Linked onsite meeting {meeting.get('onsite_meeting')} to customer")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to link onsite meeting {meeting.get('onsite_meeting')}: {str(e)}")
|
|
||||||
frappe.log_error(f"Onsite meeting linking error: {str(e)}", "convert_lead_to_customer")
|
|
||||||
if update_companies:
|
|
||||||
print(f"DEBUG: Updating companies. Count: {len(lead_doc.get('companies', []))}")
|
|
||||||
for company in lead_doc.get("companies", []):
|
|
||||||
try:
|
|
||||||
print(f"DEBUG: Processing company: {company.get('company')}")
|
|
||||||
ClientService.append_link_v2(customer_doc.name, "companies", {"company": company.get("company")})
|
|
||||||
customer_doc.reload()
|
|
||||||
print(f"DEBUG: Linked company {company.get('company')} to customer")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to link company {company.get('company')}: {str(e)}")
|
|
||||||
frappe.log_error(f"Company linking error: {str(e)}", "convert_lead_to_customer")
|
|
||||||
print(f"DEBUG: Converted Lead {lead_name} to Customer {customer_doc.name}")
|
|
||||||
frappe.db.commit()
|
|
||||||
return customer_doc
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Exception in convert_lead_to_customer: {str(e)}")
|
|
||||||
frappe.log_error(f"convert_lead_to_customer failed: {str(e)}", "ClientService")
|
|
||||||
raise
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from frappe.model.document import Document
|
|
||||||
from .db_service import DbService
|
|
||||||
|
|
||||||
class ContactService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create(data: dict) -> Document:
|
|
||||||
"""Create a new contact."""
|
|
||||||
print("DEBUG: Creating new Contact with data:", data)
|
|
||||||
contact = frappe.get_doc({
|
|
||||||
"doctype": "Contact",
|
|
||||||
**data
|
|
||||||
})
|
|
||||||
contact.insert(ignore_permissions=True)
|
|
||||||
print("DEBUG: Created new Contact:", contact.as_dict())
|
|
||||||
return contact
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def link_contact_to_customer(contact_doc: Document, customer_type: str, customer_name: str):
|
|
||||||
"""Link a contact to a customer or lead."""
|
|
||||||
print(f"DEBUG: Linking Contact {contact_doc.name} to {customer_type} {customer_name}")
|
|
||||||
contact_doc.customer_type = customer_type
|
|
||||||
contact_doc.customer_name = customer_name
|
|
||||||
contact_doc.append("links", {
|
|
||||||
"link_doctype": customer_type,
|
|
||||||
"link_name": customer_name
|
|
||||||
})
|
|
||||||
contact_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Linked Contact {contact_doc.name} to {customer_type} {customer_name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def link_contact_to_address(contact_doc: Document, address_name: str):
|
|
||||||
"""Link an address to a contact."""
|
|
||||||
print(f"DEBUG: Linking Address {address_name} to Contact {contact_doc.name}")
|
|
||||||
contact_doc.append("addresses", {
|
|
||||||
"address": address_name
|
|
||||||
})
|
|
||||||
contact_doc.append("links", {
|
|
||||||
"link_doctype": "Address",
|
|
||||||
"link_name": address_name
|
|
||||||
})
|
|
||||||
contact_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Linked Address {address_name} to Contact {contact_doc.name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_or_throw(contact_name: str) -> Document:
|
|
||||||
"""Retrieve a Contact document or throw an error if it does not exist."""
|
|
||||||
return DbService.get_or_throw("Contact", contact_name)
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
class DbService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def exists(doctype: str, name: str) -> bool:
|
|
||||||
"""Check if a document exists by doctype and name."""
|
|
||||||
result = frappe.db.exists(doctype, name) is not None
|
|
||||||
print(f"DEBUG: {doctype} existence for {name}: {result}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get(doctype: str, name: str):
|
|
||||||
"""Retrieve a document by doctype and name. Returns None if not found."""
|
|
||||||
print(f"DEBUG: Retrieving {doctype} document with name: {name}")
|
|
||||||
if DbService.exists(doctype, name):
|
|
||||||
doc = frappe.get_doc(doctype, name)
|
|
||||||
print(f"DEBUG: {doctype} document found.")
|
|
||||||
return doc
|
|
||||||
print(f"DEBUG: {doctype} document not found.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_or_throw(doctype: str, name: str):
|
|
||||||
"""Retrieve a document by doctype and name or throw an error if not found."""
|
|
||||||
doc = DbService.get(doctype, name)
|
|
||||||
if doc:
|
|
||||||
return doc
|
|
||||||
raise ValueError(f"{doctype} document with name {name} does not exist.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_value(doctype: str, name: str, fieldname: str):
|
|
||||||
"""Get a specific field value of a document. Returns None if document does not exist."""
|
|
||||||
print(f"DEBUG: Getting value of field {fieldname} from {doctype} {name}")
|
|
||||||
if not DbService.exists(doctype, name):
|
|
||||||
print("DEBUG: Value cannot be retrieved; document does not exist.")
|
|
||||||
return None
|
|
||||||
value = frappe.db.get_value(doctype, name, fieldname)
|
|
||||||
print(f"DEBUG: Retrieved value: {value}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_value_or_throw(doctype: str, name: str, fieldname: str):
|
|
||||||
"""Get a specific field value of a document or throw an error if document does not exist."""
|
|
||||||
value = DbService.get_value(doctype, name, fieldname)
|
|
||||||
if value is not None:
|
|
||||||
return value
|
|
||||||
raise ValueError(f"{doctype} document with name {name} does not exist.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create(doctype: str, data: dict):
|
|
||||||
"""Create a new document of the specified doctype."""
|
|
||||||
print(f"DEBUG: Creating new {doctype} document with data: {data}")
|
|
||||||
doc = frappe.get_doc({
|
|
||||||
"doctype": doctype,
|
|
||||||
**data
|
|
||||||
})
|
|
||||||
doc.insert(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Created new {doctype} document with name: {doc.name}")
|
|
||||||
return doc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_value(doctype: str, name: str, fieldname: str, value: any, save: bool = True):
|
|
||||||
"""Set a specific field value of a document."""
|
|
||||||
print(f"DEBUG: Setting value of field {fieldname} in {doctype} {name} to {value}")
|
|
||||||
if save:
|
|
||||||
print("DEBUG: Saving updated document.")
|
|
||||||
doc = DbService.get_or_throw(doctype, name)
|
|
||||||
setattr(doc, fieldname, value)
|
|
||||||
doc.save(ignore_permissions=True)
|
|
||||||
return doc
|
|
||||||
else:
|
|
||||||
print("DEBUG: Not saving document as save=False.")
|
|
||||||
frappe.db.set_value(doctype, name, fieldname, value)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update(doctype: str, name: str, update_data: dict, save: bool = True):
|
|
||||||
"""Update an existing document of the specified doctype."""
|
|
||||||
print(f"DEBUG: Updating {doctype} {name}")
|
|
||||||
doc = DbService.get_or_throw(doctype, name)
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(doc, key, value)
|
|
||||||
if save:
|
|
||||||
doc.save(ignore_permissions=True)
|
|
||||||
else:
|
|
||||||
DbService.set_value(doctype, name, key, value, save=False)
|
|
||||||
print(f"DEBUG: Updated {doctype} document: {doc.as_dict()}")
|
|
||||||
return doc
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,240 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from frappe.utils import get_url
|
|
||||||
|
|
||||||
class EmailService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_customer_email(customer_name: str, doctype: str = "Customer") -> str | None:
|
|
||||||
"""
|
|
||||||
Get the primary email for a customer or lead.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
customer_name: Name of the Customer or Lead
|
|
||||||
doctype: Either "Customer" or "Lead"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Email address if found, None otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
customer_doc = frappe.get_doc(doctype, customer_name)
|
|
||||||
email = None
|
|
||||||
|
|
||||||
# Try primary_contact field
|
|
||||||
if hasattr(customer_doc, 'primary_contact') and customer_doc.primary_contact:
|
|
||||||
try:
|
|
||||||
primary_contact = frappe.get_doc("Contact", customer_doc.primary_contact)
|
|
||||||
email = primary_contact.email_id
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not get primary_contact: {str(e)}")
|
|
||||||
|
|
||||||
# Fallback to customer_primary_contact
|
|
||||||
if not email and hasattr(customer_doc, 'customer_primary_contact') and customer_doc.customer_primary_contact:
|
|
||||||
try:
|
|
||||||
primary_contact = frappe.get_doc("Contact", customer_doc.customer_primary_contact)
|
|
||||||
email = primary_contact.email_id
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not get customer_primary_contact: {str(e)}")
|
|
||||||
|
|
||||||
# Last resort - get any contact linked to this customer/lead
|
|
||||||
if not email:
|
|
||||||
contact_links = frappe.get_all("Dynamic Link",
|
|
||||||
filters={
|
|
||||||
"link_doctype": doctype,
|
|
||||||
"link_name": customer_name,
|
|
||||||
"parenttype": "Contact"
|
|
||||||
},
|
|
||||||
pluck="parent"
|
|
||||||
)
|
|
||||||
if contact_links:
|
|
||||||
try:
|
|
||||||
contact = frappe.get_doc("Contact", contact_links[0])
|
|
||||||
email = contact.email_id
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not get contact from dynamic link: {str(e)}")
|
|
||||||
|
|
||||||
return email
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to get email for {doctype} {customer_name}: {str(e)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def send_templated_email(
|
|
||||||
recipients: str | list,
|
|
||||||
subject: str,
|
|
||||||
template_path: str,
|
|
||||||
template_context: dict,
|
|
||||||
doctype: str = None,
|
|
||||||
docname: str = None,
|
|
||||||
cc: str | list = None,
|
|
||||||
bcc: str | list = None,
|
|
||||||
attachments: list = None
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Send an email using a Jinja2 template.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
recipients: Email address(es) to send to
|
|
||||||
subject: Email subject line
|
|
||||||
template_path: Path to the Jinja2 template (relative to app root)
|
|
||||||
template_context: Dictionary of variables to pass to template
|
|
||||||
doctype: Optional doctype to link email to
|
|
||||||
docname: Optional document name to link email to
|
|
||||||
cc: Optional CC recipients
|
|
||||||
bcc: Optional BCC recipients
|
|
||||||
attachments: Optional list of attachments
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if email sent successfully, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Render the email template
|
|
||||||
message = frappe.render_template(template_path, template_context)
|
|
||||||
|
|
||||||
# Prepare sendmail arguments
|
|
||||||
email_args = {
|
|
||||||
"recipients": recipients,
|
|
||||||
"subject": subject,
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
|
|
||||||
if doctype:
|
|
||||||
email_args["doctype"] = doctype
|
|
||||||
if docname:
|
|
||||||
email_args["name"] = docname
|
|
||||||
if cc:
|
|
||||||
email_args["cc"] = cc
|
|
||||||
if bcc:
|
|
||||||
email_args["bcc"] = bcc
|
|
||||||
if attachments:
|
|
||||||
email_args["attachments"] = attachments
|
|
||||||
|
|
||||||
# Send email
|
|
||||||
frappe.sendmail(**email_args)
|
|
||||||
|
|
||||||
print(f"DEBUG: Email sent successfully to {recipients}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to send email: {str(e)}")
|
|
||||||
frappe.log_error(f"Failed to send email to {recipients}: {str(e)}", "Email Service Error")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def send_downpayment_email(sales_order_name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Send a down payment email for a Sales Order.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sales_order_name: Name of the Sales Order
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if email sent successfully, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
doc = frappe.get_doc("Sales Order", sales_order_name)
|
|
||||||
|
|
||||||
# Get customer email
|
|
||||||
email = EmailService.get_customer_email(doc.customer, "Customer")
|
|
||||||
|
|
||||||
if not email:
|
|
||||||
print(f"ERROR: No email found for customer {doc.customer}, cannot send down payment email")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Prepare template context
|
|
||||||
half_down_amount = doc.custom_halfdown_amount or (doc.grand_total / 2)
|
|
||||||
base_url = get_url()
|
|
||||||
|
|
||||||
template_context = {
|
|
||||||
"company_name": doc.company,
|
|
||||||
"customer_name": doc.customer_name or doc.customer,
|
|
||||||
"sales_order_number": doc.name,
|
|
||||||
"total_amount": frappe.utils.fmt_money(half_down_amount, currency=doc.currency),
|
|
||||||
"base_url": base_url
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send email
|
|
||||||
template_path = "custom_ui/templates/emails/downpayment.html"
|
|
||||||
subject = f"Down Payment Required - {doc.company} - {doc.name}"
|
|
||||||
|
|
||||||
return EmailService.send_templated_email(
|
|
||||||
recipients=email,
|
|
||||||
subject=subject,
|
|
||||||
template_path=template_path,
|
|
||||||
template_context=template_context,
|
|
||||||
doctype="Sales Order",
|
|
||||||
docname=doc.name
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to send down payment email for {sales_order_name}: {str(e)}")
|
|
||||||
frappe.log_error(f"Failed to send down payment email for {sales_order_name}: {str(e)}", "Down Payment Email Error")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def send_invoice_email(sales_invoice_name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Send an invoice email for a Sales Invoice.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sales_invoice_name: Name of the Sales Invoice
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if email sent successfully, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
doc = frappe.get_doc("Sales Invoice", sales_invoice_name)
|
|
||||||
|
|
||||||
# Get customer email
|
|
||||||
email = EmailService.get_customer_email(doc.customer, "Customer")
|
|
||||||
|
|
||||||
if not email:
|
|
||||||
print(f"ERROR: No email found for customer {doc.customer}, cannot send invoice email")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Calculate amounts
|
|
||||||
outstanding_amount = doc.outstanding_amount
|
|
||||||
paid_amount = doc.grand_total - outstanding_amount
|
|
||||||
|
|
||||||
# Get related Sales Order if available
|
|
||||||
sales_order = None
|
|
||||||
if hasattr(doc, 'items') and doc.items:
|
|
||||||
for item in doc.items:
|
|
||||||
if item.sales_order:
|
|
||||||
sales_order = item.sales_order
|
|
||||||
break
|
|
||||||
|
|
||||||
# Prepare template context
|
|
||||||
base_url = get_url()
|
|
||||||
|
|
||||||
template_context = {
|
|
||||||
"company_name": doc.company,
|
|
||||||
"customer_name": doc.customer_name or doc.customer,
|
|
||||||
"invoice_number": doc.name,
|
|
||||||
"invoice_date": doc.posting_date,
|
|
||||||
"due_date": doc.due_date,
|
|
||||||
"grand_total": frappe.utils.fmt_money(doc.grand_total, currency=doc.currency),
|
|
||||||
"outstanding_amount": frappe.utils.fmt_money(outstanding_amount, currency=doc.currency),
|
|
||||||
"paid_amount": frappe.utils.fmt_money(paid_amount, currency=doc.currency),
|
|
||||||
"sales_order": sales_order,
|
|
||||||
"base_url": base_url,
|
|
||||||
"payment_url": f"{base_url}/api/method/custom_ui.api.public.payments.invoice_stripe_payment?sales_invoice={doc.name}" if outstanding_amount > 0 else None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send email
|
|
||||||
template_path = "custom_ui/templates/emails/invoice.html"
|
|
||||||
subject = f"Invoice {doc.name} - {doc.company}"
|
|
||||||
|
|
||||||
return EmailService.send_templated_email(
|
|
||||||
recipients=email,
|
|
||||||
subject=subject,
|
|
||||||
template_path=template_path,
|
|
||||||
template_context=template_context,
|
|
||||||
doctype="Sales Invoice",
|
|
||||||
docname=doc.name
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to send invoice email for {sales_invoice_name}: {str(e)}")
|
|
||||||
frappe.log_error(f"Failed to send invoice email for {sales_invoice_name}: {str(e)}", "Invoice Email Error")
|
|
||||||
return False
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from .item_service import ItemService
|
|
||||||
|
|
||||||
class EstimateService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def exists(estimate_name: str) -> bool:
|
|
||||||
"""Check if a Quotation document exists by name."""
|
|
||||||
print(f"DEBUG: Checking existence of Quotation document with name: {estimate_name}")
|
|
||||||
result = frappe.db.exists("Quotation", estimate_name) is not None
|
|
||||||
print(f"DEBUG: Quotation document existence: {result}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get(estimate_name: str) -> frappe._dict:
|
|
||||||
"""Retrieve a Quotation document by name. Returns None if not found."""
|
|
||||||
print(f"DEBUG: Retrieving Quotation document with name: {estimate_name}")
|
|
||||||
if EstimateService.exists(estimate_name):
|
|
||||||
estimate_doc = frappe.get_doc("Quotation", estimate_name)
|
|
||||||
print("DEBUG: Quotation document found.")
|
|
||||||
return estimate_doc
|
|
||||||
print("DEBUG: Quotation document not found.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_or_throw(estimate_name: str) -> frappe._dict:
|
|
||||||
"""Retrieve a Quotation document by name or throw an error if not found."""
|
|
||||||
estimate_doc = EstimateService.get(estimate_name)
|
|
||||||
if estimate_doc:
|
|
||||||
return estimate_doc
|
|
||||||
raise ValueError(f"Quotation document with name {estimate_name} does not exist.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_value(docname: str, fieldname: str, value, save: bool = True) -> None:
|
|
||||||
"""Update a specific field value of an Quotation document."""
|
|
||||||
print(f"DEBUG: Updating Quotation {docname}, setting {fieldname} to {value}")
|
|
||||||
if save:
|
|
||||||
print("DEBUG: Saving updated Quotation document.")
|
|
||||||
estimate_doc = EstimateService.get_or_throw(docname)
|
|
||||||
setattr(estimate_doc, fieldname, value)
|
|
||||||
estimate_doc.save(ignore_permissions=True)
|
|
||||||
else:
|
|
||||||
print("DEBUG: Not saving Quotation document as save=False.")
|
|
||||||
frappe.db.set_value("Quotation", docname, fieldname, value)
|
|
||||||
print(f"DEBUG: Updated Quotation {docname}: set {fieldname} to {value}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_value(docname: str, fieldname: str) -> any:
|
|
||||||
"""Get a specific field value of a Quotation document. Returns None if document does not exist."""
|
|
||||||
print(f"DEBUG: Getting value of field {fieldname} from Quotation {docname}")
|
|
||||||
if not EstimateService.exists(docname):
|
|
||||||
print("DEBUG: Value cannot be retrieved; Quotation document does not exist.")
|
|
||||||
return None
|
|
||||||
value = frappe.db.get_value("Quotation", docname, fieldname)
|
|
||||||
print(f"DEBUG: Retrieved value: {value}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_value_or_throw(docname: str, fieldname: str) -> any:
|
|
||||||
"""Get a specific field value of a Quotation document or throw an error if document does not exist."""
|
|
||||||
value = EstimateService.get_value(docname, fieldname)
|
|
||||||
if value is not None:
|
|
||||||
return value
|
|
||||||
raise ValueError(f"Quotation document with name {docname} does not exist.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update(estimate_name: str, update_data: dict) -> frappe._dict:
|
|
||||||
"""Update an existing Quotation document."""
|
|
||||||
print(f"DEBUG: Updating Quotation {estimate_name} with data: {update_data}")
|
|
||||||
estimate_doc = EstimateService.get_or_throw(estimate_name)
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(estimate_doc, key, value)
|
|
||||||
estimate_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Updated Quotation document: {estimate_doc.as_dict()}")
|
|
||||||
return estimate_doc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create(estimate_data: dict) -> frappe._dict:
|
|
||||||
"""Create a new Quotation document."""
|
|
||||||
print(f"DEBUG: Creating new Quotation with data: {estimate_data}")
|
|
||||||
estimate_doc = frappe.get_doc({
|
|
||||||
"doctype": "Quotation",
|
|
||||||
**estimate_data
|
|
||||||
})
|
|
||||||
estimate_doc.insert(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Created Quotation document: {estimate_doc.as_dict()}")
|
|
||||||
return estimate_doc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def link_estimate_to_customer(estimate_doc: frappe._dict, customer_type: str, customer_name: str) -> None:
|
|
||||||
"""Link a Quotation document to a client document."""
|
|
||||||
print(f"DEBUG: Linking Quotation {estimate_doc.name} to {customer_type} {customer_name}")
|
|
||||||
estimate_doc.customer_type = customer_type
|
|
||||||
estimate_doc.customer = customer_name
|
|
||||||
estimate_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Linked Quotation {estimate_doc.name} to {customer_type} {customer_name}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def map_project_template_to_filter(project_template: str = None) -> dict | None:
|
|
||||||
"""Map a project template to a filter."""
|
|
||||||
print(f"DEBUG: Mapping project template {project_template} to quotation category")
|
|
||||||
if not project_template:
|
|
||||||
print("DEBUG: No project template provided, defaulting to 'General'")
|
|
||||||
return None
|
|
||||||
mapping = {
|
|
||||||
# SNW Install is both Irrigation and SNW-S categories
|
|
||||||
"SNW Install": ["in", ["Irrigation", "SNW-S", "Landscaping"]],
|
|
||||||
}
|
|
||||||
category = mapping.get(project_template, "General")
|
|
||||||
print(f"DEBUG: Mapped to quotation category: {category}")
|
|
||||||
return { "item_group": category }
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
class ItemService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_item_category(item_code: str) -> str:
|
|
||||||
"""Retrieve the category of an Item document by item code."""
|
|
||||||
print(f"DEBUG: Getting category for Item {item_code}")
|
|
||||||
category = frappe.db.get_value("Item", item_code, "item_group")
|
|
||||||
print(f"DEBUG: Retrieved category: {category}")
|
|
||||||
return category
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_full_dict(item_code: str) -> frappe._dict:
|
|
||||||
"""Retrieve the full Item document by item code."""
|
|
||||||
print(f"DEBUG: Getting full document for Item {item_code}")
|
|
||||||
item_doc = frappe.get_doc("Item", item_code).as_dict()
|
|
||||||
item_doc["bom"] = ItemService.get_full_bom_dict(item_code) if item_doc.get("default_bom") else None
|
|
||||||
return item_doc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_full_bom_dict(item_code: str):
|
|
||||||
"""Retrieve the Bill of Materials (BOM) associated with an Item."""
|
|
||||||
print(f"DEBUG: Getting BOM for Item {item_code}")
|
|
||||||
bom_name = frappe.db.get_value("BOM", {"item": item_code, "is_active": 1}, "name")
|
|
||||||
bom_dict = frappe.get_doc("BOM", bom_name).as_dict()
|
|
||||||
for item in bom_dict.get('items', []):
|
|
||||||
bom_no = item.get("bom_no")
|
|
||||||
if bom_no:
|
|
||||||
bom_item_code = frappe.db.get_value("BOM", bom_no, "item")
|
|
||||||
item["bom"] = ItemService.get_full_bom_dict(bom_item_code)
|
|
||||||
return bom_dict
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def exists(item_code: str) -> bool:
|
|
||||||
"""Check if an Item document exists by item code."""
|
|
||||||
print(f"DEBUG: Checking existence of Item {item_code}")
|
|
||||||
exists = frappe.db.exists("Item", item_code) is not None
|
|
||||||
print(f"DEBUG: Item {item_code} exists: {exists}")
|
|
||||||
return exists
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_child_groups(item_group: str) -> list[str]:
|
|
||||||
"""Retrieve all child item groups of a given item group."""
|
|
||||||
print(f"DEBUG: Getting child groups for Item Group {item_group}")
|
|
||||||
children = []
|
|
||||||
child_groups = frappe.get_all("Item Group", filters={"parent_item_group": item_group}, pluck="name")
|
|
||||||
if child_groups:
|
|
||||||
children.extend(child_groups)
|
|
||||||
print(f"DEBUG: Found child groups: {child_groups}. Checking for further children.")
|
|
||||||
for child_group in child_groups:
|
|
||||||
additional_child_groups = ItemService.get_child_groups(child_group)
|
|
||||||
children.extend(additional_child_groups)
|
|
||||||
|
|
||||||
print(f"DEBUG: Retrieved child groups: {child_groups}")
|
|
||||||
return children
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_item_names_by_group(item_groups: set[str]) -> list[str]:
|
|
||||||
"""Retrieve item names for items belonging to the specified item groups."""
|
|
||||||
print(f"DEBUG: Getting item names for Item Groups {item_groups}")
|
|
||||||
items = frappe.get_all("Item", filters={"item_group": ["in", list(item_groups)]}, pluck="name")
|
|
||||||
print(f"DEBUG: Retrieved item names: {items}")
|
|
||||||
return items
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_items_by_groups(item_groups: list[str]) -> list[dict]:
|
|
||||||
"""Retrieve all items belonging to the specified item groups."""
|
|
||||||
print(f"DEBUG: Getting items for Item Groups {item_groups}")
|
|
||||||
all_groups = set(item_groups)
|
|
||||||
for group in item_groups:
|
|
||||||
all_groups.update(ItemService.get_child_groups(group))
|
|
||||||
|
|
||||||
# Batch fetch all items at once with all needed fields
|
|
||||||
items = frappe.get_all(
|
|
||||||
"Item",
|
|
||||||
filters={"item_group": ["in", list(all_groups)]},
|
|
||||||
fields=[
|
|
||||||
"name", "item_code", "item_name", "item_group", "description",
|
|
||||||
"standard_rate", "stock_uom", "default_bom"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get all item codes that have BOMs
|
|
||||||
items_with_boms = [item for item in items if item.get("default_bom")]
|
|
||||||
item_codes_with_boms = [item["item_code"] for item in items_with_boms]
|
|
||||||
|
|
||||||
# Batch fetch all BOMs and their nested structure
|
|
||||||
bom_dict = ItemService.batch_fetch_boms(item_codes_with_boms) if item_codes_with_boms else {}
|
|
||||||
|
|
||||||
# Attach BOMs to items
|
|
||||||
for item in items:
|
|
||||||
if item.get("default_bom"):
|
|
||||||
item["bom"] = bom_dict.get(item["item_code"])
|
|
||||||
else:
|
|
||||||
item["bom"] = None
|
|
||||||
|
|
||||||
print(f"DEBUG: Retrieved {len(items)} items")
|
|
||||||
return items
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def batch_fetch_boms(item_codes: list[str]) -> dict:
|
|
||||||
"""Batch fetch all BOMs and build nested structure efficiently."""
|
|
||||||
if not item_codes:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
print(f"DEBUG: Batch fetching BOMs for {len(item_codes)} items")
|
|
||||||
|
|
||||||
# Fetch all active BOMs for the given items
|
|
||||||
boms = frappe.get_all(
|
|
||||||
"BOM",
|
|
||||||
filters={"item": ["in", item_codes], "is_active": 1},
|
|
||||||
fields=["name", "item"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not boms:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
bom_names = [bom["name"] for bom in boms]
|
|
||||||
|
|
||||||
# Fetch all BOM items (children) in one query
|
|
||||||
bom_items = frappe.get_all(
|
|
||||||
"BOM Item",
|
|
||||||
filters={"parent": ["in", bom_names]},
|
|
||||||
fields=["parent", "item_code", "item_name", "qty", "uom", "bom_no"],
|
|
||||||
order_by="idx"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Group BOM items by their parent BOM
|
|
||||||
bom_items_map = {}
|
|
||||||
nested_bom_items = set()
|
|
||||||
|
|
||||||
for bom_item in bom_items:
|
|
||||||
parent = bom_item["parent"]
|
|
||||||
if parent not in bom_items_map:
|
|
||||||
bom_items_map[parent] = []
|
|
||||||
bom_items_map[parent].append(bom_item)
|
|
||||||
|
|
||||||
# Track which items have nested BOMs
|
|
||||||
if bom_item.get("bom_no"):
|
|
||||||
nested_bom_items.add(bom_item["item_code"])
|
|
||||||
|
|
||||||
# Recursively fetch nested BOMs if any
|
|
||||||
nested_bom_dict = {}
|
|
||||||
if nested_bom_items:
|
|
||||||
nested_bom_dict = ItemService.batch_fetch_boms(list(nested_bom_items))
|
|
||||||
|
|
||||||
# Build the result dictionary mapping item_code to its BOM structure
|
|
||||||
result = {}
|
|
||||||
for bom in boms:
|
|
||||||
bom_name = bom["name"]
|
|
||||||
item_code = bom["item"]
|
|
||||||
|
|
||||||
items = bom_items_map.get(bom_name, [])
|
|
||||||
# Attach nested BOMs to items
|
|
||||||
for item in items:
|
|
||||||
if item.get("bom_no"):
|
|
||||||
item["bom"] = nested_bom_dict.get(item["item_code"])
|
|
||||||
else:
|
|
||||||
item["bom"] = None
|
|
||||||
|
|
||||||
result[item_code] = {
|
|
||||||
"name": bom_name,
|
|
||||||
"items": items
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_category_dict(items: list[dict]) -> dict:
|
|
||||||
"""Build a dictionary categorizing items by their item group."""
|
|
||||||
print(f"DEBUG: Building category dictionary for items")
|
|
||||||
category_dict = {}
|
|
||||||
category_dict["Packages"] = {}
|
|
||||||
for item in items:
|
|
||||||
if item.get("bom"):
|
|
||||||
if item.get("item_group", "Uncategorized") not in category_dict["Packages"]:
|
|
||||||
category_dict["Packages"][item.get("item_group", "Uncategorized")] = []
|
|
||||||
category_dict["Packages"][item.get("item_group", "Uncategorized")].append(item)
|
|
||||||
else:
|
|
||||||
category = item.get("item_group", "Uncategorized")
|
|
||||||
if category not in category_dict:
|
|
||||||
category_dict[category] = []
|
|
||||||
category_dict[category].append(item)
|
|
||||||
print(f"DEBUG: Built category dictionary with categories: {list(category_dict.keys())}")
|
|
||||||
return category_dict
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_item_code(prefix: str, item_name: str) -> str:
|
|
||||||
"""Build a unique item code based on the provided prefix and item name."""
|
|
||||||
print(f"DEBUG: Building item code with prefix: {prefix} and item name: {item_name}")
|
|
||||||
# Replace all " " with "-" and convert to uppercase
|
|
||||||
base_code = f"{prefix}-{item_name.replace(' ', '-').upper()}"
|
|
||||||
# Check for existing items with the same base code and append a number if necessary
|
|
||||||
existing_codes = frappe.get_all("Item", filters={"item_code": ["like", f"{base_code}-%"]}, pluck="item_code")
|
|
||||||
if base_code in existing_codes:
|
|
||||||
suffix = 1
|
|
||||||
while f"{base_code}-{suffix}" in existing_codes:
|
|
||||||
suffix += 1
|
|
||||||
final_code = f"{base_code}-{suffix}"
|
|
||||||
else:
|
|
||||||
final_code = base_code
|
|
||||||
print(f"DEBUG: Built item code: {final_code}")
|
|
||||||
return final_code
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
class OnSiteMeetingService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def exists(onsite_meeting_name: str) -> bool:
|
|
||||||
"""Check if an OnSite Meeting document exists by name."""
|
|
||||||
result = frappe.db.exists("OnSite Meeting", onsite_meeting_name) is not None
|
|
||||||
print(f"DEBUG: OnSite Meeting existence for {onsite_meeting_name}: {result}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get(onsite_meeting_name: str) -> frappe._dict:
|
|
||||||
"""Retrieve an OnSite Meeting document by name. Returns None if not found."""
|
|
||||||
print(f"DEBUG: Retrieving OnSite Meeting document with name: {onsite_meeting_name}")
|
|
||||||
if OnSiteMeetingService.exists(onsite_meeting_name):
|
|
||||||
onsite_meeting_doc = frappe.get_doc("OnSite Meeting", onsite_meeting_name)
|
|
||||||
print("DEBUG: OnSite Meeting document found.")
|
|
||||||
return onsite_meeting_doc
|
|
||||||
print("DEBUG: OnSite Meeting document not found.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_or_throw(onsite_meeting_name: str) -> frappe._dict:
|
|
||||||
"""Retrieve an OnSite Meeting document or throw an error if not found."""
|
|
||||||
onsite_meeting_doc = OnSiteMeetingService.get(onsite_meeting_name)
|
|
||||||
if not onsite_meeting_doc:
|
|
||||||
raise ValueError(f"OnSite Meeting with name {onsite_meeting_name} does not exist.")
|
|
||||||
return onsite_meeting_doc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def link_onsite_meeting_to_customer(onsite_meeting_doc, customer_type, customer_name):
|
|
||||||
"""Link an onsite meeting to a customer or lead."""
|
|
||||||
print(f"DEBUG: Linking Onsite Meeting {onsite_meeting_doc.name} to {customer_type} {customer_name}")
|
|
||||||
onsite_meeting_doc.party_type = customer_type
|
|
||||||
onsite_meeting_doc.party_name = customer_name
|
|
||||||
onsite_meeting_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Linked Onsite Meeting {onsite_meeting_doc.name} to {customer_type} {customer_name}")
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services import DbService, StripeService
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from custom_ui.models import PaymentData
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_payment_entry(data: PaymentData) -> frappe._dict:
|
|
||||||
"""Create a Payment Entry document based on the reference document."""
|
|
||||||
print(f"DEBUG: Creating Payment Entry for {data.reference_doc_name} with data: {data}")
|
|
||||||
reference_doctype = PaymentService.determine_reference_doctype(data.reference_doc_name)
|
|
||||||
reference_doc = DbService.get_or_throw(reference_doctype, data.reference_doc_name)
|
|
||||||
account = StripeService.get_stripe_settings(data.company).custom_account
|
|
||||||
pe = frappe.get_doc({
|
|
||||||
"doctype": "Payment Entry",
|
|
||||||
"company": data.company,
|
|
||||||
"payment_type": "Receive",
|
|
||||||
"party_type": "Customer",
|
|
||||||
"mode_of_payment": data.mode_of_payment or "Stripe",
|
|
||||||
"party": reference_doc.customer,
|
|
||||||
"party_name": reference_doc.customer,
|
|
||||||
"paid_to": account,
|
|
||||||
"reference_no": data.reference_no,
|
|
||||||
"reference_date": data.reference_date or frappe.utils.nowdate(),
|
|
||||||
"paid_amount": data.received_amount,
|
|
||||||
"received_amount": data.received_amount,
|
|
||||||
"paid_currency": "USD",
|
|
||||||
"received_currency": "USD",
|
|
||||||
"references": [{
|
|
||||||
"reference_doctype": reference_doc.doctype,
|
|
||||||
"reference_name": reference_doc.name,
|
|
||||||
"allocated_amount": data.received_amount,
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
pe.insert()
|
|
||||||
print(f"DEBUG: Created Payment Entry with name: {pe.name}")
|
|
||||||
return pe
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def determine_reference_doctype(reference_doc_name: str) -> str:
|
|
||||||
"""Determine the reference doctype based on the document name pattern."""
|
|
||||||
print(f"DEBUG: Determining reference doctype for document name: {reference_doc_name}")
|
|
||||||
if DbService.exists("Sales Order", reference_doc_name):
|
|
||||||
return "Sales Order"
|
|
||||||
elif DbService.exists("Sales Invoice", reference_doc_name):
|
|
||||||
return "Sales Invoice"
|
|
||||||
else:
|
|
||||||
frappe.throw("Unable to determine reference doctype from document name.")
|
|
||||||
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services import TaskService, AddressService
|
|
||||||
|
|
||||||
class ProjectService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_project_item_groups(project_template: str) -> list[str]:
|
|
||||||
"""Retrieve item groups associated with a given project template."""
|
|
||||||
print(f"DEBUG: Getting item groups for Project Template {project_template}")
|
|
||||||
item_groups_str = frappe.db.get_value("Project Template", project_template, "item_groups") or ""
|
|
||||||
item_groups = [item_group.strip() for item_group in item_groups_str.split(",") if item_group.strip()]
|
|
||||||
print(f"DEBUG: Retrieved item groups: {item_groups}")
|
|
||||||
return item_groups
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_full_project_details(project_name: str) -> dict:
|
|
||||||
"""Retrieve comprehensive details for a given project, including linked sales order and invoice information."""
|
|
||||||
print(f"DEBUG: Getting full project details for project: {project_name}")
|
|
||||||
project = frappe.get_doc("Project", project_name).as_dict()
|
|
||||||
project["tasks"] = [frappe.get_doc("Task", task["task"]).as_dict() for task in project["tasks"] if task.get("task")]
|
|
||||||
for task in project["tasks"]:
|
|
||||||
task["type"] = frappe.get_doc("Task Type", task["type"]).as_dict() if task.get("type") else None
|
|
||||||
project["job_address"] = frappe.get_doc("Address", project["job_address"]).as_dict() if project["job_address"] else None
|
|
||||||
project["service_appointment"] = frappe.get_doc("Service Address 2", project["service_appointment"]).as_dict() if project["service_appointment"] else None
|
|
||||||
project["client"] = frappe.get_doc("Customer", project["customer"]).as_dict() if project["customer"] else None
|
|
||||||
project["sales_order"] = frappe.get_doc("Sales Order", project["sales_order"]).as_dict() if project["sales_order"] else None
|
|
||||||
project["billing_address"] = frappe.get_doc("Address", project["client"]["custom_billing_address"]).as_dict() if project["client"] and project["client"].get("custom_billing_address") else None
|
|
||||||
project["invoice"] = frappe.get_doc("Sales Invoice", {"project": project["name"]}).as_dict() if frappe.db.exists("Sales Invoice", {"project": project["name"]}) else None
|
|
||||||
return project
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from frappe.utils import today
|
|
||||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
|
||||||
|
|
||||||
class SalesOrderService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_sales_invoice_from_sales_order(sales_order_name):
|
|
||||||
try:
|
|
||||||
sales_order_doc = frappe.get_doc("Sales Order", sales_order_name)
|
|
||||||
sales_invoice = make_sales_invoice(sales_order_doc.name)
|
|
||||||
sales_invoice.project = sales_order_doc.project
|
|
||||||
sales_invoice.posting_date = today()
|
|
||||||
sales_invoice.due_date = today()
|
|
||||||
sales_invoice.remarks = f"Auto-generated from Sales Order {sales_order_doc.name}"
|
|
||||||
sales_invoice.job_address = sales_order_doc.custom_job_address
|
|
||||||
sales_invoice.project_template = sales_order_doc.custom_project_template
|
|
||||||
|
|
||||||
sales_invoice.set_advances()
|
|
||||||
sales_invoice.set_missing_values()
|
|
||||||
sales_invoice.calculate_taxes_and_totals()
|
|
||||||
|
|
||||||
sales_invoice.insert()
|
|
||||||
sales_invoice.submit()
|
|
||||||
return sales_invoice.name
|
|
||||||
except Exception as e:
|
|
||||||
print("ERROR creating Sales Invoice from Sales Order:", str(e))
|
|
||||||
return None
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from custom_ui.services import ContactService, AddressService, ClientService, DbService
|
|
||||||
|
|
||||||
class ServiceAppointmentService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create(data):
|
|
||||||
"""Create a new Service Appointment document."""
|
|
||||||
print("DEBUG: Creating Service Appointment with data:", data)
|
|
||||||
service_appointment_doc = frappe.get_doc({
|
|
||||||
"doctype": "Service Address 2",
|
|
||||||
**data
|
|
||||||
})
|
|
||||||
service_appointment_doc.insert()
|
|
||||||
print("DEBUG: Created Service Appointment with name:", service_appointment_doc.name)
|
|
||||||
return service_appointment_doc
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_full_dict(service_appointment_name: str) -> dict:
|
|
||||||
"""Retrieve a Service Appointment document as a full dictionary."""
|
|
||||||
print(f"DEBUG: Retrieving Service Appointment document with name: {service_appointment_name}")
|
|
||||||
service_appointment = frappe.get_doc("Service Address 2", service_appointment_name).as_dict()
|
|
||||||
service_appointment["service_address"] = AddressService.get_or_throw(service_appointment["service_address"]).as_dict()
|
|
||||||
service_appointment["customer"] = ClientService.get_client_or_throw(service_appointment["customer"]).as_dict()
|
|
||||||
service_appointment["project"] = DbService.get_or_throw("Project", service_appointment["project"]).as_dict()
|
|
||||||
|
|
||||||
return service_appointment
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_scheduled_dates(service_appointment_name: str, crew_lead_name: str,start_date, end_date, start_time=None, end_time=None):
|
|
||||||
"""Update the scheduled start and end dates of a Service Appointment."""
|
|
||||||
print(f"DEBUG: Updating scheduled dates for Service Appointment {service_appointment_name} to start: {start_date}, end: {end_date}")
|
|
||||||
service_appointment = DbService.get_or_throw("Service Address 2", service_appointment_name)
|
|
||||||
service_appointment.expected_start_date = start_date
|
|
||||||
service_appointment.expected_end_date = end_date
|
|
||||||
service_appointment.foreman = crew_lead_name
|
|
||||||
if start_time:
|
|
||||||
service_appointment.expected_start_time = start_time
|
|
||||||
if end_time:
|
|
||||||
service_appointment.expected_end_time = end_time
|
|
||||||
service_appointment.save()
|
|
||||||
print(f"DEBUG: Updated scheduled dates for Service Appointment {service_appointment_name}")
|
|
||||||
return service_appointment
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_field(service_appointment_name: str, updates: list[tuple[str, any]]):
|
|
||||||
"""Update specific fields of a Service Appointment."""
|
|
||||||
print(f"DEBUG: Updating fields for Service Appointment {service_appointment_name} with updates: {updates}")
|
|
||||||
service_appointment = DbService.get_or_throw("Service Address 2", service_appointment_name)
|
|
||||||
for field, value in updates:
|
|
||||||
setattr(service_appointment, field, value)
|
|
||||||
service_appointment.save()
|
|
||||||
print(f"DEBUG: Updated fields for Service Appointment {service_appointment_name}")
|
|
||||||
return service_appointment
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_status(service_appointment_name: str, new_status: str):
|
|
||||||
"""Update the status of a Service Appointment."""
|
|
||||||
print(f"DEBUG: Updating status for Service Appointment {service_appointment_name} to {new_status}")
|
|
||||||
service_appointment = DbService.get_or_throw("Service Address 2", service_appointment_name)
|
|
||||||
service_appointment.status = new_status
|
|
||||||
service_appointment.save()
|
|
||||||
print(f"DEBUG: Updated status for Service Appointment {service_appointment_name} to {new_status}")
|
|
||||||
return service_appointment
|
|
||||||
@ -1,168 +0,0 @@
|
|||||||
import frappe
|
|
||||||
import stripe
|
|
||||||
import json
|
|
||||||
from custom_ui.services import DbService
|
|
||||||
from frappe.utils import get_url
|
|
||||||
|
|
||||||
class StripeService:
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_stripe_settings(company: str):
|
|
||||||
"""Fetch Stripe settings for a given company."""
|
|
||||||
settings_name = frappe.get_all("Stripe Settings", pluck="name", filters={"custom_company": company})
|
|
||||||
if not settings_name:
|
|
||||||
frappe.throw(f"Stripe Settings not found for company: {company}")
|
|
||||||
settings = frappe.get_doc("Stripe Settings", settings_name[0]) if settings_name else None
|
|
||||||
return settings
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_api_key(company: str) -> str:
|
|
||||||
"""Retrieve the Stripe API key for the specified company."""
|
|
||||||
settings = StripeService.get_stripe_settings(company)
|
|
||||||
return settings.get_password("secret_key")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_webhook_secret(company: str) -> str:
|
|
||||||
"""Retrieve the Stripe webhook secret for the specified company."""
|
|
||||||
settings = StripeService.get_stripe_settings(company)
|
|
||||||
if not settings.custom_webhook_secret:
|
|
||||||
frappe.throw(f"Stripe Webhook Secret not configured for company: {company}")
|
|
||||||
return settings.custom_webhook_secret
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_checkout_session(
|
|
||||||
company: str,
|
|
||||||
amount: float,
|
|
||||||
service: str,
|
|
||||||
order_num: str,
|
|
||||||
currency: str = "usd",
|
|
||||||
for_advance_payment: bool = False,
|
|
||||||
line_items: list | None = None,
|
|
||||||
sales_invoice: str = None
|
|
||||||
) -> stripe.checkout.Session:
|
|
||||||
"""
|
|
||||||
Create a Stripe Checkout Session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
company: Company name
|
|
||||||
amount: Payment amount (should be the outstanding amount for invoices)
|
|
||||||
service: Service description
|
|
||||||
order_num: Sales Order name if for_advance_payment is True, otherwise Sales Invoice name
|
|
||||||
currency: Currency code (default: "usd")
|
|
||||||
for_advance_payment: True if this is an advance/down payment, False for full invoice payment
|
|
||||||
line_items: Optional custom line items for the checkout session
|
|
||||||
sales_invoice: Sales Invoice name (for full payments)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
stripe.checkout.Session object
|
|
||||||
"""
|
|
||||||
stripe.api_key = StripeService.get_api_key(company)
|
|
||||||
|
|
||||||
# Determine payment description
|
|
||||||
if for_advance_payment:
|
|
||||||
description = f"Advance payment for {company}{' - ' + service if service else ''}"
|
|
||||||
else:
|
|
||||||
description = f"Invoice payment for {company}{' - ' + service if service else ''}"
|
|
||||||
if sales_invoice:
|
|
||||||
description = f"Invoice {sales_invoice} - {company}"
|
|
||||||
|
|
||||||
# Use custom line items if provided and not an advance payment, otherwise create default line item
|
|
||||||
line_items = line_items if line_items and not for_advance_payment else [{
|
|
||||||
"price_data": {
|
|
||||||
"currency": currency.lower(),
|
|
||||||
"product_data": {
|
|
||||||
"name": description
|
|
||||||
},
|
|
||||||
"unit_amount": int(amount * 100), # Stripe expects amount in cents
|
|
||||||
},
|
|
||||||
"quantity": 1,
|
|
||||||
}]
|
|
||||||
|
|
||||||
# Prepare metadata
|
|
||||||
metadata = {
|
|
||||||
"company": company,
|
|
||||||
"payment_type": "advance" if for_advance_payment else "full"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add appropriate document reference to metadata
|
|
||||||
if for_advance_payment:
|
|
||||||
metadata["sales_order"] = order_num
|
|
||||||
else:
|
|
||||||
metadata["sales_invoice"] = sales_invoice or order_num
|
|
||||||
if sales_invoice:
|
|
||||||
# Check if there's a related sales order
|
|
||||||
invoice_doc = frappe.get_doc("Sales Invoice", sales_invoice)
|
|
||||||
if hasattr(invoice_doc, 'items') and invoice_doc.items:
|
|
||||||
for item in invoice_doc.items:
|
|
||||||
if item.sales_order:
|
|
||||||
metadata["sales_order"] = item.sales_order
|
|
||||||
break
|
|
||||||
|
|
||||||
session = stripe.checkout.Session.create(
|
|
||||||
mode="payment",
|
|
||||||
payment_method_types=["card"],
|
|
||||||
line_items=line_items,
|
|
||||||
metadata=metadata,
|
|
||||||
success_url=f"{get_url()}/payment_success?session_id={{CHECKOUT_SESSION_ID}}",
|
|
||||||
cancel_url=f"{get_url()}/payment_cancelled",
|
|
||||||
)
|
|
||||||
|
|
||||||
return session
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_event(payload: bytes, sig_header: str, company: str = None) -> stripe.Event:
|
|
||||||
print("DEBUG: Stripe webhook received")
|
|
||||||
print(f"DEBUG: Signature header present: {bool(sig_header)}")
|
|
||||||
|
|
||||||
# If company not provided, try to extract from payload metadata
|
|
||||||
if not company:
|
|
||||||
try:
|
|
||||||
payload_dict = json.loads(payload)
|
|
||||||
print(f"DEBUG: Parsed payload type: {payload_dict.get('type')}")
|
|
||||||
|
|
||||||
metadata = payload_dict.get("data", {}).get("object", {}).get("metadata", {})
|
|
||||||
print(f"DEBUG: Metadata from payload: {metadata}")
|
|
||||||
|
|
||||||
company = metadata.get("company")
|
|
||||||
print(f"DEBUG: Extracted company from metadata: {company}")
|
|
||||||
except (json.JSONDecodeError, KeyError, AttributeError) as e:
|
|
||||||
print(f"DEBUG: Failed to parse payload: {str(e)}")
|
|
||||||
|
|
||||||
# If we still don't have a company, reject the webhook
|
|
||||||
if not company:
|
|
||||||
print("ERROR: Company information missing in webhook payload")
|
|
||||||
frappe.throw("Company information missing in webhook payload.")
|
|
||||||
|
|
||||||
print(f"DEBUG: Validating webhook signature for company: {company}")
|
|
||||||
|
|
||||||
# Validate webhook signature with the specified company's secret
|
|
||||||
try:
|
|
||||||
event = stripe.Webhook.construct_event(
|
|
||||||
payload=payload,
|
|
||||||
sig_header=sig_header,
|
|
||||||
secret=StripeService.get_webhook_secret(company)
|
|
||||||
)
|
|
||||||
print(f"DEBUG: Webhook signature validated successfully for company: {company}")
|
|
||||||
print(f"DEBUG: Event type: {event.type}")
|
|
||||||
print(f"DEBUG: Event ID: {event.id}")
|
|
||||||
except ValueError as e:
|
|
||||||
print(f"ERROR: Invalid payload: {str(e)}")
|
|
||||||
frappe.throw(f"Invalid payload: {str(e)}")
|
|
||||||
except stripe.error.SignatureVerificationError as e:
|
|
||||||
print(f"ERROR: Invalid signature for company {company}: {str(e)}")
|
|
||||||
frappe.throw(f"Invalid signature for company {company}: {str(e)}")
|
|
||||||
|
|
||||||
return event
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_session_and_metadata(payload: bytes, sig_header: str, company: str = None) -> tuple[stripe.checkout.Session, dict]:
|
|
||||||
"""Retrieve the Stripe Checkout Session and its metadata from a webhook payload."""
|
|
||||||
event = StripeService.get_event(payload, sig_header, company)
|
|
||||||
if event.type != "checkout.session.completed":
|
|
||||||
frappe.throw(f"Unhandled event type: {event.type}")
|
|
||||||
session = event.data.object
|
|
||||||
metadata = session["metadata"] if "metadata" in session else {}
|
|
||||||
|
|
||||||
return session, metadata
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
import frappe
|
|
||||||
from frappe.utils.safe_exec import safe_eval
|
|
||||||
from datetime import timedelta, datetime, date
|
|
||||||
from frappe.utils import getdate
|
|
||||||
class TaskService:
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def fire_task_triggers(task_names: list[str], event: str, current_triggering_dict=None):
|
|
||||||
"""Calculate the due date for a list of tasks based on their expected end dates."""
|
|
||||||
for task_name in task_names:
|
|
||||||
TaskService.fire_task_trigger(task_name, event, current_triggering_dict)
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_tasks_by_project(project_name: str):
|
|
||||||
"""Retrieve all tasks associated with a given project."""
|
|
||||||
task_names = frappe.get_all("Task", filters={"project": project_name}, pluck="name")
|
|
||||||
tasks = [frappe.get_doc("Task", task_name) for task_name in task_names]
|
|
||||||
return tasks
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def fire_task_trigger(task_name: str, event: str, current_triggering_dict=None):
|
|
||||||
"""Determine the triggering configuration for a given task."""
|
|
||||||
task_type_doc = TaskService.get_task_type_doc(task_name)
|
|
||||||
schedule_trigger = task_type_doc.triggering_doctype == current_triggering_dict.get("doctype") if current_triggering_dict else False
|
|
||||||
completion_trigger = task_type_doc.custom_completion_trigger_doctype == current_triggering_dict.get("doctype") if current_triggering_dict else False
|
|
||||||
match_schedule_event = task_type_doc.trigger == event
|
|
||||||
match_completion_event = task_type_doc.custom_completion_trigger == event
|
|
||||||
if not schedule_trigger and not completion_trigger:
|
|
||||||
print(f"DEBUG: Task {task_name} triggering doctype {task_type_doc.triggering_doctype} does not match triggering doctype {current_triggering_dict.get('doctype')}, skipping calculation.")
|
|
||||||
return
|
|
||||||
if not match_schedule_event and not match_completion_event:
|
|
||||||
print(f"DEBUG: Task {task_name} trigger {task_type_doc.trigger} does not match event {event}, skipping calculation.")
|
|
||||||
return
|
|
||||||
if task_type_doc.logic_key:
|
|
||||||
print(f"DEBUG: Task {task_name} has a logic key set, skipping calculations and running logic.")
|
|
||||||
safe_eval(task_type_doc.logic_key, {"task_name": task_name, "task_type_doc": task_type_doc})
|
|
||||||
return
|
|
||||||
if schedule_trigger:
|
|
||||||
triggering_doc_dict = current_triggering_dict if current_triggering_dict else TaskService.get_triggering_doc_dict(task_name=task_name, doctype=task_type_doc.triggering_doctype, task_type_calculate_from=task_type_doc.task_type_calculate_from)
|
|
||||||
calculate_from = task_type_doc.calculate_from
|
|
||||||
trigger = task_type_doc.trigger
|
|
||||||
print(f"DEBUG: Calculating triggering data for Task {task_name} from {calculate_from} on trigger {trigger}")
|
|
||||||
|
|
||||||
calculated_due_date, calculated_start_date = TaskService.calculate_dates(
|
|
||||||
task_name=task_name,
|
|
||||||
triggering_doc_dict=triggering_doc_dict,
|
|
||||||
task_type_doc=task_type_doc
|
|
||||||
)
|
|
||||||
|
|
||||||
update_required = TaskService.determine_due_date_update_required(
|
|
||||||
task_name=task_name,
|
|
||||||
calculated_due_date=calculated_due_date,
|
|
||||||
calculated_start_date=calculated_start_date
|
|
||||||
)
|
|
||||||
if update_required:
|
|
||||||
TaskService.update_task(
|
|
||||||
task_name=task_name,
|
|
||||||
calculated_due_date=calculated_due_date,
|
|
||||||
calculated_start_date=calculated_start_date
|
|
||||||
)
|
|
||||||
if completion_trigger:
|
|
||||||
triggering_doc_dict = current_triggering_dict if current_triggering_dict else TaskService.get_triggering_doc_dict(task_name=task_name, doctype=task_type_doc.custom_completion_trigger_doctype)
|
|
||||||
print(f"DEBUG: Running completion trigger logic for Task {task_name}")
|
|
||||||
update_required = TaskService.determine_completion_update_required(
|
|
||||||
task_name=task_name,
|
|
||||||
task_type_doc=task_type_doc,
|
|
||||||
)
|
|
||||||
if update_required:
|
|
||||||
TaskService.update_task(
|
|
||||||
task_name=task_name,
|
|
||||||
status="Completed"
|
|
||||||
)
|
|
||||||
print(f"DEBUG: Marked Task {task_name} as Completed due to completion trigger.")
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_task_type_doc(task_name: str):
|
|
||||||
task_type_name = frappe.get_value("Task", task_name, "type")
|
|
||||||
return frappe.get_doc("Task Type", task_type_name)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def calculate_dates(task_name: str, triggering_doc_dict: dict, task_type_doc) -> tuple[date | None, date | None]:
|
|
||||||
offset_direction = task_type_doc.offset_direction
|
|
||||||
offset_days = task_type_doc.offset_days
|
|
||||||
base_date_field = TaskService.map_base_date_to_field(task_type_doc.base_date, task_type_doc.triggering_doctype)
|
|
||||||
print(f"DEBUG: base_date_field for Task {task_name} is {base_date_field}")
|
|
||||||
if offset_direction == "Before":
|
|
||||||
offset_days = -offset_days
|
|
||||||
|
|
||||||
base_date_field_value = triggering_doc_dict.get(base_date_field)
|
|
||||||
print(f"DEBUG: base_date_field_value for Task {task_name} is {base_date_field_value}")
|
|
||||||
|
|
||||||
if isinstance(base_date_field_value, datetime):
|
|
||||||
base_date_field_value = base_date_field_value
|
|
||||||
else:
|
|
||||||
base_date_field_value = getdate(base_date_field_value)
|
|
||||||
|
|
||||||
calculated_due_date = base_date_field_value + timedelta(days=offset_days)
|
|
||||||
calculated_start_date = None
|
|
||||||
if task_type_doc.days > 1:
|
|
||||||
calculated_start_date = calculated_due_date - timedelta(days=task_type_doc.days)
|
|
||||||
print(f"DEBUG: Calculated dates for Task {task_name} - Due Date: {calculated_due_date}, Start Date: {calculated_start_date}")
|
|
||||||
return calculated_due_date, calculated_start_date
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def determine_due_date_update_required(task_name: str, calculated_due_date: date | None, calculated_start_date: date | None) -> bool:
|
|
||||||
current_due_date = frappe.get_value("Task", task_name, "exp_end_date")
|
|
||||||
current_start_date = frappe.get_value("Task", task_name, "exp_start_date")
|
|
||||||
if current_due_date != calculated_due_date or current_start_date != calculated_start_date:
|
|
||||||
print(f"DEBUG: Update required for Task {task_name}. Current due date: {current_due_date}, Calculated due date: {calculated_due_date}. Current start date: {current_start_date}, Calculated start date: {calculated_start_date}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"DEBUG: No update required for Task {task_name}. Dates are up to date.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def determine_completion_update_required(task_name: str, task_type_doc) -> bool:
|
|
||||||
current_status = frappe.get_value("Task", task_name, "status")
|
|
||||||
determination = False
|
|
||||||
if current_status == "Completed":
|
|
||||||
print(f"DEBUG: Task {task_name} is already marked as Completed, no update required.")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
triggering_doc_dict = TaskService.get_triggering_doc_dict(task_name=task_name, doctype=task_type_doc.custom_completion_trigger_doctype)
|
|
||||||
check_field = TaskService.map_completion_check_field(task_type_doc.custom_completion_trigger)
|
|
||||||
check_value = triggering_doc_dict.get(check_field)
|
|
||||||
trigger = task_type_doc.custom_completion_trigger
|
|
||||||
if trigger == "Completed" and check_value == "Completed" and current_status != "Completed":
|
|
||||||
determination = True
|
|
||||||
elif trigger == "Percentage Reached" and check_value >= task_type_doc.custom_target_percent and current_status != "Completed":
|
|
||||||
determination = True
|
|
||||||
elif trigger in ["Scheduled", "Started", "Created"] and check_value and current_status != "Completed":
|
|
||||||
determination = True
|
|
||||||
print(f"DEBUG: Completion trigger '{trigger}' met for Task {task_name}, check field {check_field} has value {check_value}.")
|
|
||||||
return determination
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_triggering_doc_dict(task_name: str, doctype, task_type_calculate_from = None) -> dict | None:
|
|
||||||
project_name = frappe.get_value("Task", task_name, "project")
|
|
||||||
print(f"DEBUG: Project name: {project_name}")
|
|
||||||
dict = None
|
|
||||||
if doctype == "Project":
|
|
||||||
dict = frappe.get_doc("Project", project_name).as_dict()
|
|
||||||
if doctype == "Service Address 2":
|
|
||||||
service_name = frappe.get_value("Project", project_name, "service_appointment")
|
|
||||||
dict = frappe.get_doc("Service Address 2", service_name).as_dict()
|
|
||||||
if doctype == "Task":
|
|
||||||
project_doc = frappe.get_doc("Project", project_name)
|
|
||||||
for task in project_doc.tasks:
|
|
||||||
if task.task_type == task_type_calculate_from:
|
|
||||||
dict = frappe.get_doc("Task", task.task).as_dict()
|
|
||||||
print(f"DEBUG: Triggering doc dict for Task {task_name}: {dict}")
|
|
||||||
return dict
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_task(task_name: str, calculated_due_date: date | None = None, calculated_start_date: date | None = None, status: str | None = None):
|
|
||||||
task_doc = frappe.get_doc("Task", task_name)
|
|
||||||
if calculated_due_date is not None:
|
|
||||||
task_doc.exp_end_date = calculated_due_date
|
|
||||||
if calculated_start_date is not None:
|
|
||||||
task_doc.exp_start_date = calculated_start_date
|
|
||||||
if status is not None:
|
|
||||||
task_doc.status = status
|
|
||||||
if status == "Completed":
|
|
||||||
task_doc.actual_end_date = datetime.now()
|
|
||||||
task_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Updated Task {task_name} with new dates - Start: {calculated_start_date}, End: {calculated_due_date}, Status: {status}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def map_base_date_to_field(base_date: str, triggering_doctype: str) -> str:
|
|
||||||
"""Map a base date configuration to a corresponding field name."""
|
|
||||||
base_date_field_map = {
|
|
||||||
"Start": "expected_start_date",
|
|
||||||
"End": "expected_end_date",
|
|
||||||
"Creation": "creation",
|
|
||||||
"Completion": "actual_end_date"
|
|
||||||
}
|
|
||||||
task_date_field_map = {
|
|
||||||
"Start": "exp_start_date",
|
|
||||||
"End": "exp_end_date",
|
|
||||||
"Creation": "creation",
|
|
||||||
"Completion": "actual_end_date"
|
|
||||||
}
|
|
||||||
if triggering_doctype == "Task":
|
|
||||||
return task_date_field_map.get(base_date, "exp_end_date")
|
|
||||||
return base_date_field_map.get(base_date, "expected_end_date")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def map_completion_check_field(completion_trigger: str) -> str:
|
|
||||||
completion_check_field_map = {
|
|
||||||
"Completed": "status",
|
|
||||||
"Scheduled": "expected_end_date",
|
|
||||||
"Created": "creation",
|
|
||||||
"Started": "actual_start_date",
|
|
||||||
"Percentage Reached": "progress"
|
|
||||||
}
|
|
||||||
return completion_check_field_map.get(completion_trigger, "status")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def determine_event(triggering_doc) -> str | None:
|
|
||||||
print("DEBUG: Current Document:", triggering_doc.as_dict())
|
|
||||||
if not frappe.db.exists(triggering_doc.doctype, triggering_doc.name):
|
|
||||||
print("DEBUG: Document does not exist in database, returning None for event.")
|
|
||||||
return None
|
|
||||||
prev_doc = frappe.get_doc(triggering_doc.doctype, triggering_doc.name, as_dict=False, ignore_if_missing=True)
|
|
||||||
start_date_field = "expected_start_date" if triggering_doc.doctype != "Task" else "exp_start_date"
|
|
||||||
end_date_field = "expected_end_date" if triggering_doc.doctype != "Task" else "exp_end_date"
|
|
||||||
print("DEBUG: Previous Document:", prev_doc.as_dict() if prev_doc else "None")
|
|
||||||
if not prev_doc:
|
|
||||||
return None
|
|
||||||
if getattr(prev_doc, end_date_field) != getattr(triggering_doc, end_date_field) or getattr(prev_doc, start_date_field) != getattr(triggering_doc, start_date_field):
|
|
||||||
return "Scheduled"
|
|
||||||
elif prev_doc.status != triggering_doc.status and triggering_doc.status == "Completed":
|
|
||||||
return "Completed"
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def find_and_update_overdue_tasks():
|
|
||||||
today = date.today()
|
|
||||||
overdue_tasks = frappe.get_all("Task", filters={"exp_end_date": ("<", today), "status": ["not in", ["Completed", "Template", "Cancelled", "Overdue"]]}, pluck="name")
|
|
||||||
print(f"DEBUG: Found {len(overdue_tasks)} overdue tasks.")
|
|
||||||
for task_name in overdue_tasks:
|
|
||||||
task_doc = frappe.get_doc("Task", task_name)
|
|
||||||
task_doc.status = "Overdue"
|
|
||||||
task_doc.save(ignore_permissions=True)
|
|
||||||
print(f"DEBUG: Updated Task {task_name} to Overdue status.")
|
|
||||||
frappe.db.commit()
|
|
||||||
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Payment Already Completed</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.message-container {
|
|
||||||
text-align: center;
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 50px;
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.message-icon {
|
|
||||||
font-size: 5rem;
|
|
||||||
color: #74b9ff;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.message-title {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.message-text {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.contact-info {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border-left: 5px solid #74b9ff;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #333;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="message-container">
|
|
||||||
<div class="message-icon">ℹ️</div>
|
|
||||||
<h1 class="message-title">Payment Already Completed</h1>
|
|
||||||
<p class="message-text">The half down payment for this sales order has already been paid.</p>
|
|
||||||
<div class="contact-info">
|
|
||||||
<strong>If you have any questions:</strong><br>
|
|
||||||
Please contact our sales team for assistance or clarification regarding your order.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Down Payment Required</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background-color: #ffffff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
color: #2c3e50;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
.payment-details {
|
|
||||||
background-color: #ecf0f1;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.payment-details h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #e74c3c;
|
|
||||||
}
|
|
||||||
.cta-button {
|
|
||||||
display: inline-block;
|
|
||||||
background-color: #3498db;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 12px 24px;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #eee;
|
|
||||||
color: #7f8c8d;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Thank You for Confirming Your Quote</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear Valued Customer,</p>
|
|
||||||
<p>Thank you for accepting our quote for services at {{ company_name }}. We're excited to work with you and appreciate your trust in our team.</p>
|
|
||||||
<p>To proceed with scheduling your service, a half down payment is required. This helps us secure the necessary resources and ensures everything is prepared for your appointment.</p>
|
|
||||||
<div class="payment-details">
|
|
||||||
<h2>Payment Details</h2>
|
|
||||||
<p><strong>Sales Order Number:</strong> {{ sales_order_number }}</p>
|
|
||||||
<p><strong>Down Payment Amount:</strong> {{ total_amount }}</p>
|
|
||||||
</div>
|
|
||||||
<p>Please click the button below to make your secure payment through our payment processor:</p>
|
|
||||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.payments.half_down_stripe_payment?sales_order={{ sales_order_number }}" class="cta-button">Make Payment</a>
|
|
||||||
<p>If you have any questions or need assistance, feel free to contact us. We're here to help!</p>
|
|
||||||
<p>Best regards,<br>The Team at {{ company_name }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>This is an automated email. Please do not reply directly.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,251 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Estimate from {{ company }}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
}
|
|
||||||
.email-container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
.letterhead {
|
|
||||||
text-align: center;
|
|
||||||
padding: 30px 20px;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-bottom: 3px solid #0066cc;
|
|
||||||
}
|
|
||||||
.letterhead img {
|
|
||||||
max-width: 250px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.company-name {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333333;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 40px 30px;
|
|
||||||
}
|
|
||||||
.greeting {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #333333;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.intro-text {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #555555;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.estimate-box {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 25px;
|
|
||||||
margin: 30px 0;
|
|
||||||
}
|
|
||||||
.estimate-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666666;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.estimate-value {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #333333;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.estimate-value:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.price-section {
|
|
||||||
background-color: #0066cc;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.price-label {
|
|
||||||
font-size: 14px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.price-amount {
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.additional-section {
|
|
||||||
background-color: #fff9e6;
|
|
||||||
border-left: 4px solid #ffc107;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 30px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.additional-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #856404;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.additional-text {
|
|
||||||
font-size: 15px;
|
|
||||||
color: #333333;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.action-buttons {
|
|
||||||
text-align: center;
|
|
||||||
margin: 40px 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 14px 28px;
|
|
||||||
margin: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
.btn-accept {
|
|
||||||
background-color: #28a745;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.btn-decline {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.btn-call {
|
|
||||||
background-color: #ffc107;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
.closing-text {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #555555;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
.contact-info {
|
|
||||||
font-size: 15px;
|
|
||||||
color: #0066cc;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 30px;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
.footer-text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666666;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
.content {
|
|
||||||
padding: 30px 20px;
|
|
||||||
}
|
|
||||||
.estimate-box {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.price-amount {
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
.company-name {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
display: block;
|
|
||||||
margin: 10px auto;
|
|
||||||
max-width: 250px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="email-container">
|
|
||||||
<!-- Letterhead Section -->
|
|
||||||
<div class="letterhead">
|
|
||||||
{% if letterhead_image %}
|
|
||||||
<img src="{{ letterhead_image }}" alt="{{ company }} Logo">
|
|
||||||
{% else %}
|
|
||||||
<div class="company-name">{{ company }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="content">
|
|
||||||
<div class="greeting">Hello {{ customer_name }},</div>
|
|
||||||
|
|
||||||
<div class="intro-text">
|
|
||||||
Thank you for considering {{ company }} for your project. We are pleased to provide you with the following estimate for the services requested.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Estimate Details Box -->
|
|
||||||
<div class="estimate-box">
|
|
||||||
<div class="estimate-label">Service Location</div>
|
|
||||||
<div class="estimate-value">{{ address }}</div>
|
|
||||||
|
|
||||||
<!-- Price Section -->
|
|
||||||
<div class="price-section">
|
|
||||||
<div class="price-label">Total Estimate</div>
|
|
||||||
<div class="price-amount">{{ price }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Additional Notes (Conditional) -->
|
|
||||||
{% if additional %}
|
|
||||||
<div class="additional-section">
|
|
||||||
<div class="additional-label">Additional Notes</div>
|
|
||||||
<div class="additional-text">{{ additional }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="action-buttons">
|
|
||||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Accepted" class="btn btn-accept">Accept</a>
|
|
||||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Rejected" class="btn btn-decline">Decline</a>
|
|
||||||
<a href="{{ base_url }}/api/method/custom_ui.api.public.estimates.update_response?name={{ estimate_name }}&response=Requested%20call" class="btn btn-call">Request a Call</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Closing -->
|
|
||||||
<div class="closing-text">
|
|
||||||
This estimate is valid for 30 days from the date of this email. If you have any questions or would like to proceed with this estimate, please don't hesitate to contact us.
|
|
||||||
{% if company_phone %}
|
|
||||||
<div class="contact-info">Call us at: {{ company_phone }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<br>
|
|
||||||
We look forward to working with you!
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-text">
|
|
||||||
<strong>{{ company }}</strong><br>
|
|
||||||
This is an automated message. Please do not reply directly to this email.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Invoice - {{ invoice_number }}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background-color: #ffffff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
color: #2c3e50;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
.invoice-details {
|
|
||||||
background-color: #ecf0f1;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.invoice-details h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #3498db;
|
|
||||||
}
|
|
||||||
.detail-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 5px 0;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
.detail-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 2px solid #3498db;
|
|
||||||
}
|
|
||||||
.detail-label {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.cta-button {
|
|
||||||
display: inline-block;
|
|
||||||
background-color: #27ae60;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 12px 24px;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #eee;
|
|
||||||
color: #7f8c8d;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.note {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border-left: 4px solid #ffc107;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Invoice</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Dear {{ customer_name }},</p>
|
|
||||||
<p>Thank you for your business with {{ company_name }}. Please find your invoice details below:</p>
|
|
||||||
|
|
||||||
<div class="invoice-details">
|
|
||||||
<h2>Invoice Details</h2>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Invoice Number:</span>
|
|
||||||
<span>{{ invoice_number }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Invoice Date:</span>
|
|
||||||
<span>{{ invoice_date }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Due Date:</span>
|
|
||||||
<span>{{ due_date }}</span>
|
|
||||||
</div>
|
|
||||||
{% if sales_order %}
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Related Sales Order:</span>
|
|
||||||
<span>{{ sales_order }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Invoice Total:</span>
|
|
||||||
<span>{{ grand_total }}</span>
|
|
||||||
</div>
|
|
||||||
{% if paid_amount and paid_amount != "$0.00" %}
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Amount Paid:</span>
|
|
||||||
<span>{{ paid_amount }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Amount Due:</span>
|
|
||||||
<span>{{ outstanding_amount }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if payment_url and outstanding_amount != "$0.00" %}
|
|
||||||
<div class="note">
|
|
||||||
<strong>Payment Required:</strong> There is an outstanding balance on this invoice. Please click the button below to make a secure payment.
|
|
||||||
</div>
|
|
||||||
<a href="{{ payment_url }}" class="cta-button">Pay Now</a>
|
|
||||||
{% else %}
|
|
||||||
<div class="note">
|
|
||||||
<strong>Paid in Full:</strong> This invoice has been paid in full. Thank you!
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p>If you have any questions about this invoice, please don't hesitate to contact us.</p>
|
|
||||||
<p>Best regards,<br>The Team at {{ company_name }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>This is an automated email. Please do not reply directly.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
<!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;
|
|
||||||
}
|
|
||||||
.error-details {
|
|
||||||
color: #d32f2f;
|
|
||||||
font-size: 1em;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 10px;
|
|
||||||
background-color: #ffebee;
|
|
||||||
border-left: 4px solid #d32f2f;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.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>
|
|
||||||
<p class="error-details">Error: {{ error or "An unknown error occurred." }}</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>No Down Payment Required</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.message-container {
|
|
||||||
text-align: center;
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 50px;
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.message-icon {
|
|
||||||
font-size: 5rem;
|
|
||||||
color: #4ecdc4;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.message-title {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.message-text {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.contact-info {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border-left: 5px solid #4ecdc4;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #333;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="message-container">
|
|
||||||
<div class="message-icon">✅</div>
|
|
||||||
<h1 class="message-title">No Down Payment Required</h1>
|
|
||||||
<p class="message-text">This sales order does not require any down payment.</p>
|
|
||||||
<div class="contact-info">
|
|
||||||
<strong>If you have any questions:</strong><br>
|
|
||||||
Please contact our sales team for assistance or clarification regarding your order.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Error - Something Went Wrong</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.error-container {
|
|
||||||
text-align: center;
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 50px;
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.error-icon {
|
|
||||||
font-size: 5rem;
|
|
||||||
color: #ff6b6b;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
animation: bounce 2s infinite;
|
|
||||||
}
|
|
||||||
@keyframes bounce {
|
|
||||||
0%, 20%, 50%, 80%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.error-title {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.error-message {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.error-details {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border-left: 5px solid #ff6b6b;
|
|
||||||
text-align: left;
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #333;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="error-container">
|
|
||||||
<div class="error-icon">⚠️</div>
|
|
||||||
<h1 class="error-title">Oops, something went wrong!</h1>
|
|
||||||
<p class="error-message">We're sorry, but an error occurred while processing your request. Please try again later or contact support if the problem persists.</p>
|
|
||||||
{% if error_message %}
|
|
||||||
<div class="error-details">
|
|
||||||
<strong>Error Details:</strong><br>
|
|
||||||
{{ error_message }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Payment Cancelled</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.payment-container {
|
|
||||||
text-align: center;
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 50px;
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.cancelled-icon {
|
|
||||||
font-size: 5rem;
|
|
||||||
color: #e74c3c;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
animation: cancelledAnimation 1.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cancelledAnimation {
|
|
||||||
0% {
|
|
||||||
transform: scale(0) rotate(180deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.2) rotate(0deg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: scale(0.9) rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1) rotate(0deg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-title {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-message {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelled-notice {
|
|
||||||
background-color: #ffeaea;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-top: 30px;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelled-notice h3 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
color: #721c24;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelled-notice p {
|
|
||||||
margin: 0;
|
|
||||||
color: #721c24;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-steps {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-steps h4 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #333;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-steps ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-steps li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="payment-container">
|
|
||||||
<div class="cancelled-icon">✕</div>
|
|
||||||
<h1 class="payment-title">Payment Cancelled</h1>
|
|
||||||
<p class="payment-message">Your payment has been cancelled.</p>
|
|
||||||
|
|
||||||
<div class="cancelled-notice">
|
|
||||||
<h3>Payment Not Processed</h3>
|
|
||||||
<p>No charges have been made to your account. If you cancelled by mistake or need assistance, please try again or contact support.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="next-steps">
|
|
||||||
<h4>What happens next?</h4>
|
|
||||||
<ul>
|
|
||||||
<li>No payment has been processed</li>
|
|
||||||
<li>You can safely close this window</li>
|
|
||||||
<li>Try your payment again if needed</li>
|
|
||||||
<li>Contact us if you need help</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Payment Cancelled</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.payment-container {
|
|
||||||
text-align: center;
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 50px;
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.cancelled-icon {
|
|
||||||
font-size: 5rem;
|
|
||||||
color: #e74c3c;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
animation: cancelledAnimation 1.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cancelledAnimation {
|
|
||||||
0% {
|
|
||||||
transform: scale(0) rotate(180deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.2) rotate(0deg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: scale(0.9) rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1) rotate(0deg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-title {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-message {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelled-notice {
|
|
||||||
background-color: #ffeaea;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-top: 30px;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelled-notice h3 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
color: #721c24;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelled-notice p {
|
|
||||||
margin: 0;
|
|
||||||
color: #721c24;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-steps {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-steps h4 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #333;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-steps ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-steps li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="payment-container">
|
|
||||||
<div class="cancelled-icon">✕</div>
|
|
||||||
<h1 class="payment-title">Payment Cancelled</h1>
|
|
||||||
<p class="payment-message">Your payment has been cancelled.</p>
|
|
||||||
|
|
||||||
<div class="cancelled-notice">
|
|
||||||
<h3>Payment Not Processed</h3>
|
|
||||||
<p>No charges have been made to your account. If you cancelled by mistake or need assistance, please try again or contact support.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="next-steps">
|
|
||||||
<h4>What happens next?</h4>
|
|
||||||
<ul>
|
|
||||||
<li>No payment has been processed</li>
|
|
||||||
<li>You can safely close this window</li>
|
|
||||||
<li>Try your payment again if needed</li>
|
|
||||||
<li>Contact us if you need help</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Payment Successful</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.payment-container {
|
|
||||||
text-align: center;
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 50px;
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.success-icon {
|
|
||||||
font-size: 5rem;
|
|
||||||
color: #00b894;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
animation: checkmarkAnimation 1.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes checkmarkAnimation {
|
|
||||||
0% {
|
|
||||||
transform: scale(0) rotate(-180deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.2) rotate(0deg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: scale(0.9) rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1) rotate(0deg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-title {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-message {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advance-notice {
|
|
||||||
background-color: #e3f2fd;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
border-left: 4px solid #2196f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-section {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-top: 30px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-section h3 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
color: #333;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-section > p {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-details {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 5px 0;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #495057;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-value {
|
|
||||||
font-weight: 400;
|
|
||||||
color: #6c757d;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-value a {
|
|
||||||
color: #007bff;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-value a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="payment-container">
|
|
||||||
<div class="success-icon">✓</div>
|
|
||||||
{% if reference_doc %}
|
|
||||||
<h1 class="payment-title">
|
|
||||||
{% if company_doc and company_doc.company_name %}
|
|
||||||
{{ company_doc.company_name }}
|
|
||||||
{% else %}
|
|
||||||
Payment Received
|
|
||||||
{% endif %}
|
|
||||||
</h1>
|
|
||||||
{% if reference_doc.doctype == "Sales Order" %}
|
|
||||||
<p class="payment-message">
|
|
||||||
{% if reference_doc.customer %}
|
|
||||||
Thank you {{ reference_doc.customer }} for your advance payment!
|
|
||||||
{% else %}
|
|
||||||
Thank you for your advance payment!
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<div class="advance-notice">
|
|
||||||
<p>The remaining balance will be invoiced once the project is complete.</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="payment-message">
|
|
||||||
{% if reference_doc.customer %}
|
|
||||||
Thank you {{ reference_doc.customer }} for your payment!
|
|
||||||
{% else %}
|
|
||||||
Thank you for your payment!
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if company_doc %}
|
|
||||||
<div class="contact-section">
|
|
||||||
<h3>Have Questions?</h3>
|
|
||||||
<p>We're here to help! Contact us if you need assistance.</p>
|
|
||||||
<div class="contact-details">
|
|
||||||
{% if company_doc.company_name %}
|
|
||||||
<div class="contact-row">
|
|
||||||
<span class="contact-label">Company:</span>
|
|
||||||
<span class="contact-value">{{ company_doc.company_name }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if company_doc.phone_no %}
|
|
||||||
<div class="contact-row">
|
|
||||||
<span class="contact-label">Phone:</span>
|
|
||||||
<span class="contact-value">{{ company_doc.phone_no }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if company_doc.email %}
|
|
||||||
<div class="contact-row">
|
|
||||||
<span class="contact-label">Email:</span>
|
|
||||||
<span class="contact-value"><a href="mailto:{{ company_doc.email }}">{{ company_doc.email }}</a></span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if company_doc.website %}
|
|
||||||
<div class="contact-row">
|
|
||||||
<span class="contact-label">Website:</span>
|
|
||||||
<span class="contact-value"><a href="{{ company_doc.website }}" target="_blank">{{ company_doc.website }}</a></span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<h1 class="payment-title">Payment Received</h1>
|
|
||||||
<p class="payment-message">Thank you for your payment!</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
def get_context(context):
|
|
||||||
context.no_cache = 1
|
|
||||||
|
|
||||||
context.title = "Payment Received"
|
|
||||||
context.message = "Thank you for your payment! Your transaction was successful."
|
|
||||||
|
|
||||||
context.session_id = frappe.form_dict.get("session_id")
|
|
||||||
|
|
||||||
payment_entry = frappe.get_value("Payment Entry", {"reference_no": context.session_id}, "name")
|
|
||||||
payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry) if payment_entry else None
|
|
||||||
reference = payment_entry_doc.references[0] if payment_entry_doc and payment_entry_doc.references else None
|
|
||||||
reference_doc = frappe.get_doc(reference.reference_doctype, reference.reference_name) if reference else None
|
|
||||||
company_doc = frappe.get_doc("Company", reference_doc.company) if reference_doc and reference_doc.company else None
|
|
||||||
context.reference_doc = reference_doc.as_dict() if reference_doc else None
|
|
||||||
context.company_doc = company_doc.as_dict() if company_doc else None
|
|
||||||
return context
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
services:
|
|
||||||
mailhog:
|
|
||||||
image: mailhog/mailhog:latest
|
|
||||||
container_name: mailhog
|
|
||||||
ports:
|
|
||||||
- "8025:8025" # MailHog web UI
|
|
||||||
- "1025:1025" # SMTP server
|
|
||||||
restart: unless-stopped
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
# DocType Field Differences Report
|
|
||||||
|
|
||||||
## Fields present in LOCAL but missing in STAGE
|
|
||||||
|
|
||||||
### Address
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| is_service_address | Is Service Address | Check | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Event
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| participants | Participants | Section Break | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Project
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| service_appointment | Service Appointment | Link | Service Address 2 | 0 | 0 | False |
|
|
||||||
| tasks | Tasks | Table | Project Task Link | 0 | 0 | False |
|
|
||||||
| ready_to_schedule | Ready to Schedule | Check | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Project Template
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| bid_meeting_note_form | Bid Meeting Note Form | Link | Bid Meeting Note Form | 0 | 0 | False |
|
|
||||||
| item_groups | Item Groups | Data | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Quotation
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| from_template | From Template | Link | Quotation Template | 0 | 0 | False |
|
|
||||||
| project_template | Project Template | Link | Project Template | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Sales Invoice
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| project_template | Project Template | Link | Project Template | 0 | 0 | False |
|
|
||||||
| job_address | Job Address | Link | Address | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Task
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| customer | Customer | Link | Customer | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Task Type
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| base_date | Base Date | Select | Start
|
|
||||||
End
|
|
||||||
Completion
|
|
||||||
Creation | 1 | 0 | False |
|
|
||||||
| offset_days | Offset Days | Int | None | 1 | 0 | False |
|
|
||||||
| skip_weekends | Skip Weekends | Check | None | 0 | 0 | False |
|
|
||||||
| skip_holidays | Skip Holidays | Check | None | 0 | 0 | False |
|
|
||||||
| logic_key | Logic Key | Data | None | 0 | 0 | False |
|
|
||||||
| offset_direction | Offset Direction | Select | After
|
|
||||||
Before | 1 | 0 | False |
|
|
||||||
| title | Title | Data | None | 1 | 0 | False |
|
|
||||||
| days | Days | Int | None | 0 | 0 | False |
|
|
||||||
| calculate_from | Calculate From | Select | Service Address 2
|
|
||||||
Project
|
|
||||||
Task | 1 | 0 | False |
|
|
||||||
| trigger | Trigger | Select | Scheduled
|
|
||||||
Completed
|
|
||||||
Created | 1 | 0 | False |
|
|
||||||
| task_type_calculate_from | Task Type For Task Calculate From | Link | Task Type | 0 | 0 | False |
|
|
||||||
| work_type | Work Type | Select | Admin
|
|
||||||
Labor
|
|
||||||
QA | 1 | 0 | False |
|
|
||||||
| no_due_date | No Due Date | Check | None | 0 | 0 | False |
|
|
||||||
| triggering_doctype | Triggering Doctype | Select | Service Address 2
|
|
||||||
Project
|
|
||||||
Task | 1 | 0 | False |
|
|
||||||
|
|
||||||
## Fields present in STAGE but missing in LOCAL
|
|
||||||
|
|
||||||
### Communication Link
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| communication_date | Communication Date | Datetime | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Event
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| notifications | Notifications | Table | Event Notifications | 0 | 0 | False |
|
|
||||||
| location | Location | Data | None | 0 | 0 | False |
|
|
||||||
| attending | Attending | Select |
|
|
||||||
Yes
|
|
||||||
No
|
|
||||||
Maybe | 0 | 0 | False |
|
|
||||||
| participants_tab | Participants | Tab Break | None | 0 | 0 | False |
|
|
||||||
| links_tab | Links | Tab Break | None | 0 | 0 | False |
|
|
||||||
| notifications_tab | Notifications | Tab Break | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Event Notifications
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| type | Type | Select | Notification
|
|
||||||
Email | 0 | 0 | False |
|
|
||||||
| before | Before | Int | None | 0 | 0 | False |
|
|
||||||
| interval | Interval | Select | None | 0 | 0 | False |
|
|
||||||
| time | Time | Time | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Event Participants
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| attending | Attending | Select |
|
|
||||||
Yes
|
|
||||||
No
|
|
||||||
Maybe | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Job Opening
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| job_opening_template | Job Opening Template | Link | Job Opening Template | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Job Opening Template
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| template_title | Template Title | Data | None | 1 | 0 | False |
|
|
||||||
| department | Department | Link | Department | 0 | 0 | False |
|
|
||||||
| column_break_wkcr | None | Column Break | None | 0 | 0 | False |
|
|
||||||
| employment_type | Employment Type | Link | Employment Type | 0 | 0 | False |
|
|
||||||
| location | Location | Link | Branch | 0 | 0 | False |
|
|
||||||
| section_break_dwfh | None | Section Break | None | 0 | 0 | False |
|
|
||||||
| description | Description | Text Editor | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Payment Ledger Entry
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| project | Project | Link | Project | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Quotation Item
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| ordered_qty | Ordered Qty | Float | None | 1 | 1 | False |
|
|
||||||
|
|
||||||
### Salary Structure Assignment
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| leave_encashment_amount_per_day | Leave Encashment Amount Per Day | Currency | currency | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Selling Settings
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| set_zero_rate_for_expired_batch | Set Incoming Rate as Zero for Expired Batch | Check | None | 0 | 0 | False |
|
|
||||||
|
|
||||||
### Service Appointment
|
|
||||||
| Fieldname | Label | Fieldtype | Options | Required | Hidden | Custom Field |
|
|
||||||
|-----------|-------|----------|---------|---------|--------|--------------|
|
|
||||||
| custom_location_of_meeting | Service Address | Link | Address | 0 | 0 | False |
|
|
||||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -22,5 +22,3 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.env
|
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
# Calendar View Update Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Updated the Calendar.vue component from a weekly view to a daily view with foremen as columns and 30-minute time slots as rows.
|
|
||||||
|
|
||||||
## Key Changes Made
|
|
||||||
|
|
||||||
### 1. Layout Structure
|
|
||||||
- **Before**: Weekly calendar with 7 day columns
|
|
||||||
- **After**: Daily calendar with 10 foreman columns
|
|
||||||
|
|
||||||
### 2. Header Changes
|
|
||||||
- Changed from "Sprinkler Service Calendar" to "Daily Schedule - Sprinkler Service"
|
|
||||||
- Navigation changed from week-based (previousWeek/nextWeek) to day-based (previousDay/nextDay)
|
|
||||||
- Display shows full day name instead of week range
|
|
||||||
|
|
||||||
### 3. Grid Structure
|
|
||||||
- **Columns**: Now shows foremen names instead of days of the week
|
|
||||||
- **Rows**: Still uses 30-minute time slots from 7 AM to 7 PM
|
|
||||||
- Grid template updated from `repeat(7, 1fr)` to `repeat(10, 1fr)` for 10 foremen
|
|
||||||
|
|
||||||
### 4. Foremen Data
|
|
||||||
Added 10 foremen to the system:
|
|
||||||
- Mike Thompson
|
|
||||||
- Sarah Johnson
|
|
||||||
- David Martinez
|
|
||||||
- Chris Wilson
|
|
||||||
- Lisa Anderson
|
|
||||||
- Robert Thomas
|
|
||||||
- Maria White
|
|
||||||
- James Clark
|
|
||||||
- Patricia Lewis
|
|
||||||
- Kevin Walker
|
|
||||||
|
|
||||||
### 5. Event Scheduling Logic
|
|
||||||
- Events now filter by foreman name instead of day
|
|
||||||
- Drag and drop updated to assign services to specific foremen
|
|
||||||
- Time slot conflict detection now checks per foreman instead of per day
|
|
||||||
- Preview slots updated to show foreman-specific scheduling
|
|
||||||
|
|
||||||
### 6. Visual Updates
|
|
||||||
- Foreman headers show name and job count for the day
|
|
||||||
- CSS classes renamed from `day-column` to `foreman-column`
|
|
||||||
- Updated styling to accommodate wider layout for 10 columns
|
|
||||||
- Maintained all existing drag-and-drop visual feedback
|
|
||||||
|
|
||||||
### 7. Functionality Preserved
|
|
||||||
- All existing drag-and-drop functionality
|
|
||||||
- Service priority handling
|
|
||||||
- Unscheduled services panel
|
|
||||||
- Event details modal
|
|
||||||
- Time slot highlighting for current time
|
|
||||||
|
|
||||||
## Technical Implementation Details
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
1. `currentDate` (string) - tracks the currently viewed date
|
|
||||||
2. `foremen` (array) - contains foreman ID and name pairs
|
|
||||||
3. Services filter by `foreman` name and `scheduledDate` matching `currentDate`
|
|
||||||
4. Grid renders 10 columns × ~24 time slots (30-min intervals)
|
|
||||||
|
|
||||||
### Key Methods Updated
|
|
||||||
- `getEventsForTimeSlot(foremanName, time, date)` - filters by foreman and date
|
|
||||||
- `isTimeSlotOccupied(foremanName, startTime, duration)` - checks conflicts per foreman
|
|
||||||
- `getOccupiedSlots(foremanId, startTime, duration)` - preview slots per foreman
|
|
||||||
- `handleDragOver/handleDrop` - updated to work with foreman IDs
|
|
||||||
- Navigation: `previousDay()`, `nextDay()`, `goToToday()`
|
|
||||||
|
|
||||||
### CSS Grid Layout
|
|
||||||
```css
|
|
||||||
.calendar-header-row, .time-row {
|
|
||||||
grid-template-columns: 80px repeat(10, 1fr);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This provides a time column (80px) plus 10 equal-width foreman columns.
|
|
||||||
|
|
||||||
## Benefits of New Design
|
|
||||||
1. **Better resource allocation** - See all foremen's schedules at once
|
|
||||||
2. **Easier scheduling** - Drag services directly to specific foremen
|
|
||||||
3. **Conflict prevention** - Visual feedback for time conflicts per foreman
|
|
||||||
4. **Daily focus** - Concentrate on optimizing a single day's schedule
|
|
||||||
5. **Scalable** - Easy to add/remove foremen by updating the foremen array
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
- Use left/right arrows to navigate between days
|
|
||||||
- Drag unscheduled services from the right panel to specific foreman time slots
|
|
||||||
- Services automatically get assigned to the foreman and time slot where dropped
|
|
||||||
- Current time slot is highlighted across all foremen columns
|
|
||||||
- Each foreman header shows their job count for the selected day
|
|
||||||
@ -1,490 +0,0 @@
|
|||||||
# Server-Side Pagination Implementation Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This implementation provides server-side pagination with persistent state management for large datasets (5000+ records). It combines PrimeVue's lazy loading capabilities with Pinia stores for state persistence.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Stores
|
|
||||||
|
|
||||||
1. **`usePaginationStore`** - Manages pagination state (page, pageSize, totalRecords, sorting)
|
|
||||||
2. **`useFiltersStore`** - Manages filter state (existing, enhanced for pagination)
|
|
||||||
3. **`useLoadingStore`** - Manages loading states (existing, works with pagination)
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
1. **`DataTable`** - Enhanced with lazy loading support
|
|
||||||
2. **`Api`** - Updated with pagination and filtering parameters
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
✅ **Server-side pagination** - Only loads current page data
|
|
||||||
✅ **Persistent state** - Page and filter state survive navigation
|
|
||||||
✅ **Real-time filtering** - Filters reset to page 1 and re-query server
|
|
||||||
✅ **Sorting support** - Server-side sorting with state persistence
|
|
||||||
✅ **Loading states** - Integrated with existing loading system
|
|
||||||
✅ **Performance** - Handles 5000+ records efficiently
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic Paginated DataTable
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<DataTable
|
|
||||||
:data="tableData"
|
|
||||||
:columns="columns"
|
|
||||||
tableName="clients"
|
|
||||||
:lazy="true"
|
|
||||||
:totalRecords="totalRecords"
|
|
||||||
:loading="isLoading"
|
|
||||||
:onLazyLoad="handleLazyLoad"
|
|
||||||
@lazy-load="handleLazyLoad"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import { usePaginationStore } from "@/stores/pagination";
|
|
||||||
import { useFiltersStore } from "@/stores/filters";
|
|
||||||
import Api from "@/api";
|
|
||||||
|
|
||||||
const paginationStore = usePaginationStore();
|
|
||||||
const filtersStore = useFiltersStore();
|
|
||||||
|
|
||||||
const tableData = ref([]);
|
|
||||||
const totalRecords = ref(0);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
|
|
||||||
const handleLazyLoad = async (event) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
const paginationParams = {
|
|
||||||
page: event.page || 0,
|
|
||||||
pageSize: event.rows || 10,
|
|
||||||
sortField: event.sortField,
|
|
||||||
sortOrder: event.sortOrder,
|
|
||||||
};
|
|
||||||
|
|
||||||
const filters = {};
|
|
||||||
if (event.filters) {
|
|
||||||
Object.keys(event.filters).forEach((key) => {
|
|
||||||
if (key !== "global" && event.filters[key]?.value) {
|
|
||||||
filters[key] = event.filters[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await Api.getPaginatedData(paginationParams, filters);
|
|
||||||
|
|
||||||
tableData.value = result.data;
|
|
||||||
totalRecords.value = result.totalRecords;
|
|
||||||
paginationStore.setTotalRecords("tableName", result.totalRecords);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading data:", error);
|
|
||||||
tableData.value = [];
|
|
||||||
totalRecords.value = 0;
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// Initialize stores
|
|
||||||
paginationStore.initializeTablePagination("tableName", { rows: 10 });
|
|
||||||
filtersStore.initializeTableFilters("tableName", columns);
|
|
||||||
|
|
||||||
// Load initial data
|
|
||||||
const pagination = paginationStore.getTablePagination("tableName");
|
|
||||||
const filters = filtersStore.getTableFilters("tableName");
|
|
||||||
|
|
||||||
await handleLazyLoad({
|
|
||||||
page: pagination.page,
|
|
||||||
rows: pagination.rows,
|
|
||||||
first: pagination.first,
|
|
||||||
sortField: pagination.sortField,
|
|
||||||
sortOrder: pagination.sortOrder,
|
|
||||||
filters: filters,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Implementation
|
|
||||||
|
|
||||||
### Required API Method Structure
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In your API class
|
|
||||||
static async getPaginatedData(paginationParams = {}, filters = {}) {
|
|
||||||
const {
|
|
||||||
page = 0,
|
|
||||||
pageSize = 10,
|
|
||||||
sortField = null,
|
|
||||||
sortOrder = null
|
|
||||||
} = paginationParams;
|
|
||||||
|
|
||||||
// Build database query with pagination
|
|
||||||
const offset = page * pageSize;
|
|
||||||
const limit = pageSize;
|
|
||||||
|
|
||||||
// Apply filters to query
|
|
||||||
const whereClause = buildWhereClause(filters);
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
const orderBy = sortField ? `${sortField} ${sortOrder === -1 ? 'DESC' : 'ASC'}` : '';
|
|
||||||
|
|
||||||
// Execute queries
|
|
||||||
const [data, totalCount] = await Promise.all([
|
|
||||||
db.query(`SELECT * FROM table ${whereClause} ${orderBy} LIMIT ${limit} OFFSET ${offset}`),
|
|
||||||
db.query(`SELECT COUNT(*) FROM table ${whereClause}`)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: data,
|
|
||||||
totalRecords: totalCount[0].count
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frappe Framework Implementation
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
static async getPaginatedClientDetails(paginationParams = {}, filters = {}) {
|
|
||||||
const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams;
|
|
||||||
|
|
||||||
// Build Frappe filters
|
|
||||||
let frappeFilters = {};
|
|
||||||
Object.keys(filters).forEach(key => {
|
|
||||||
if (filters[key] && filters[key].value) {
|
|
||||||
switch (key) {
|
|
||||||
case 'fullName':
|
|
||||||
frappeFilters.address_line1 = ['like', `%${filters[key].value}%`];
|
|
||||||
break;
|
|
||||||
// Add other filter mappings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get total count and paginated data
|
|
||||||
const [totalCount, records] = await Promise.all([
|
|
||||||
this.getDocCount("DocType", frappeFilters),
|
|
||||||
this.getDocsList("DocType", ["*"], frappeFilters, page, pageSize)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Process and return data
|
|
||||||
const processedData = records.map(record => ({
|
|
||||||
id: record.name,
|
|
||||||
// ... other fields
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: processedData,
|
|
||||||
totalRecords: totalCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## DataTable Props
|
|
||||||
|
|
||||||
### New Props for Pagination
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const props = defineProps({
|
|
||||||
// Existing props...
|
|
||||||
|
|
||||||
// Server-side pagination
|
|
||||||
lazy: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false, // Set to true for server-side pagination
|
|
||||||
},
|
|
||||||
totalRecords: {
|
|
||||||
type: Number,
|
|
||||||
default: 0, // Total records from server
|
|
||||||
},
|
|
||||||
onLazyLoad: {
|
|
||||||
type: Function,
|
|
||||||
default: null, // Lazy load handler function
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Events
|
|
||||||
|
|
||||||
- **`@lazy-load`** - Emitted when pagination/filtering/sorting changes
|
|
||||||
- **`@page-change`** - Emitted when page changes
|
|
||||||
- **`@sort-change`** - Emitted when sorting changes
|
|
||||||
- **`@filter-change`** - Emitted when filters change
|
|
||||||
|
|
||||||
## Pagination Store Methods
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const paginationStore = usePaginationStore();
|
|
||||||
|
|
||||||
// Initialize pagination for a table
|
|
||||||
paginationStore.initializeTablePagination("clients", {
|
|
||||||
rows: 10,
|
|
||||||
totalRecords: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update pagination after API response
|
|
||||||
paginationStore.setTotalRecords("clients", 1250);
|
|
||||||
|
|
||||||
// Navigate pages
|
|
||||||
paginationStore.setPage("clients", 2);
|
|
||||||
paginationStore.nextPage("clients");
|
|
||||||
paginationStore.previousPage("clients");
|
|
||||||
|
|
||||||
// Get pagination parameters for API calls
|
|
||||||
const params = paginationStore.getPaginationParams("clients");
|
|
||||||
// Returns: { page: 2, pageSize: 10, offset: 20, limit: 10, sortField: null, sortOrder: null }
|
|
||||||
|
|
||||||
// Get page information for display
|
|
||||||
const info = paginationStore.getPageInfo("clients");
|
|
||||||
// Returns: { start: 21, end: 30, total: 1250 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Methods
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Handle PrimeVue lazy load events
|
|
||||||
const params = paginationStore.handleLazyLoad("clients", primeVueEvent);
|
|
||||||
|
|
||||||
// Set sorting
|
|
||||||
paginationStore.setSorting("clients", "name", 1); // 1 for ASC, -1 for DESC
|
|
||||||
|
|
||||||
// Change rows per page
|
|
||||||
paginationStore.setRowsPerPage("clients", 25);
|
|
||||||
|
|
||||||
// Reset to first page (useful when filters change)
|
|
||||||
paginationStore.resetToFirstPage("clients");
|
|
||||||
|
|
||||||
// Get computed properties
|
|
||||||
const totalPages = paginationStore.getTotalPages("clients");
|
|
||||||
const hasNext = paginationStore.hasNextPage("clients");
|
|
||||||
const hasPrevious = paginationStore.hasPreviousPage("clients");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Filter Integration
|
|
||||||
|
|
||||||
Filters work seamlessly with pagination:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// When a filter changes, pagination automatically resets to page 1
|
|
||||||
const handleFilterChange = (fieldName, value) => {
|
|
||||||
// Update filter
|
|
||||||
filtersStore.updateTableFilter("clients", fieldName, value);
|
|
||||||
|
|
||||||
// Pagination automatically resets to page 1 in DataTable component
|
|
||||||
// New API call is triggered with updated filters
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Persistence
|
|
||||||
|
|
||||||
Both pagination and filter states persist across:
|
|
||||||
|
|
||||||
- Component re-mounts
|
|
||||||
- Page navigation
|
|
||||||
- Browser refresh (if using localStorage)
|
|
||||||
|
|
||||||
### Persistence Configuration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In your store, you can add persistence
|
|
||||||
import { defineStore } from "pinia";
|
|
||||||
|
|
||||||
export const usePaginationStore = defineStore("pagination", {
|
|
||||||
// ... store definition
|
|
||||||
|
|
||||||
persist: {
|
|
||||||
enabled: true,
|
|
||||||
strategies: [
|
|
||||||
{
|
|
||||||
key: "pagination-state",
|
|
||||||
storage: localStorage, // or sessionStorage
|
|
||||||
paths: ["tablePagination"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Database Optimization
|
|
||||||
|
|
||||||
1. **Indexes** - Ensure filtered and sorted columns are indexed
|
|
||||||
2. **Query Optimization** - Use efficient WHERE clauses
|
|
||||||
3. **Connection Pooling** - Handle concurrent requests efficiently
|
|
||||||
|
|
||||||
### Frontend Optimization
|
|
||||||
|
|
||||||
1. **Debounced Filtering** - Avoid excessive API calls
|
|
||||||
2. **Loading States** - Provide user feedback during requests
|
|
||||||
3. **Error Handling** - Gracefully handle API failures
|
|
||||||
4. **Memory Management** - Clear data when not needed
|
|
||||||
|
|
||||||
### Recommended Page Sizes
|
|
||||||
|
|
||||||
- **Small screens**: 5-10 records
|
|
||||||
- **Desktop**: 10-25 records
|
|
||||||
- **Large datasets**: 25-50 records
|
|
||||||
- **Avoid**: 100+ records per page
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleLazyLoad = async (event) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
const result = await Api.getPaginatedData(params, filters);
|
|
||||||
|
|
||||||
// Success handling
|
|
||||||
tableData.value = result.data;
|
|
||||||
totalRecords.value = result.totalRecords;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Pagination error:", error);
|
|
||||||
|
|
||||||
// Reset to safe state
|
|
||||||
tableData.value = [];
|
|
||||||
totalRecords.value = 0;
|
|
||||||
|
|
||||||
// Show user-friendly error
|
|
||||||
showErrorToast("Failed to load data. Please try again.");
|
|
||||||
|
|
||||||
// Optionally retry with fallback parameters
|
|
||||||
if (event.page > 0) {
|
|
||||||
paginationStore.setPage(tableName, 0);
|
|
||||||
// Retry with page 0
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration from Client-Side
|
|
||||||
|
|
||||||
### Before (Client-side)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Old approach - loads all data
|
|
||||||
onMounted(async () => {
|
|
||||||
const data = await Api.getAllClients(); // 5000+ records
|
|
||||||
tableData.value = data;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### After (Server-side)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// New approach - loads only current page
|
|
||||||
onMounted(async () => {
|
|
||||||
paginationStore.initializeTablePagination("clients");
|
|
||||||
|
|
||||||
await handleLazyLoad({
|
|
||||||
page: 0,
|
|
||||||
rows: 10,
|
|
||||||
// ... other params
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { usePaginationStore } from "@/stores/pagination";
|
|
||||||
|
|
||||||
describe("Pagination Store", () => {
|
|
||||||
it("should initialize pagination correctly", () => {
|
|
||||||
const store = usePaginationStore();
|
|
||||||
store.initializeTablePagination("test", { rows: 20 });
|
|
||||||
|
|
||||||
const pagination = store.getTablePagination("test");
|
|
||||||
expect(pagination.rows).toBe(20);
|
|
||||||
expect(pagination.page).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle page navigation", () => {
|
|
||||||
const store = usePaginationStore();
|
|
||||||
store.setTotalRecords("test", 100);
|
|
||||||
store.setPage("test", 2);
|
|
||||||
|
|
||||||
expect(store.getTablePagination("test").page).toBe(2);
|
|
||||||
expect(store.hasNextPage("test")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Test lazy loading with mock API
|
|
||||||
const mockLazyLoad = vi.fn().mockResolvedValue({
|
|
||||||
data: [{ id: 1, name: "Test" }],
|
|
||||||
totalRecords: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test component with mocked API
|
|
||||||
const wrapper = mount(DataTableComponent, {
|
|
||||||
props: {
|
|
||||||
lazy: true,
|
|
||||||
onLazyLoad: mockLazyLoad,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify API calls
|
|
||||||
expect(mockLazyLoad).toHaveBeenCalledWith({
|
|
||||||
page: 0,
|
|
||||||
rows: 10,
|
|
||||||
// ... expected parameters
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Infinite Loading**
|
|
||||||
- Check API endpoint returns correct totalRecords
|
|
||||||
- Verify pagination parameters are calculated correctly
|
|
||||||
|
|
||||||
2. **Filters Not Working**
|
|
||||||
- Ensure filter parameters are passed to API correctly
|
|
||||||
- Check database query includes WHERE clauses
|
|
||||||
|
|
||||||
3. **Page State Not Persisting**
|
|
||||||
- Verify store persistence is configured
|
|
||||||
- Check localStorage/sessionStorage permissions
|
|
||||||
|
|
||||||
4. **Performance Issues**
|
|
||||||
- Add database indexes for filtered/sorted columns
|
|
||||||
- Optimize API query efficiency
|
|
||||||
- Consider reducing page size
|
|
||||||
|
|
||||||
### Debug Information
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Add debug logging to lazy load handler
|
|
||||||
const handleLazyLoad = async (event) => {
|
|
||||||
console.log("Lazy Load Event:", {
|
|
||||||
page: event.page,
|
|
||||||
rows: event.rows,
|
|
||||||
sortField: event.sortField,
|
|
||||||
sortOrder: event.sortOrder,
|
|
||||||
filters: event.filters,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ... rest of implementation
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
This implementation provides a robust, performant solution for handling large datasets with persistent pagination and filtering state.
|
|
||||||
@ -1,278 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Updated DataTable Actions Behavior Test</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 20px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.test-case {
|
|
||||||
margin: 20px 0;
|
|
||||||
padding: 15px;
|
|
||||||
border-left: 4px solid #007bff;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
.example {
|
|
||||||
background-color: #f1f1f1;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 10px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background-color: #f2f2f2;
|
|
||||||
}
|
|
||||||
.code {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Updated DataTable Actions Behavior Test</h1>
|
|
||||||
|
|
||||||
<h2>✅ New Action Behavior Summary</h2>
|
|
||||||
|
|
||||||
<div class="test-case">
|
|
||||||
<h3>Action Type Changes:</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Global Actions</strong>: Default behavior - always available above
|
|
||||||
table
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Single Selection Actions</strong> (<span class="code"
|
|
||||||
>requiresSelection: true</span
|
|
||||||
>): Above table, enabled only when exactly one row selected
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Row Actions</strong> (<span class="code">rowAction: true</span>): In
|
|
||||||
actions column, available per row
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Bulk Actions</strong> (<span class="code"
|
|
||||||
>requiresMultipleSelection: true</span
|
|
||||||
>): Above table when rows selected
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-case">
|
|
||||||
<h3>Updated Action Types Matrix:</h3>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<th>Action Type</th>
|
|
||||||
<th>Property</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Enabled When</th>
|
|
||||||
<th>Data Received</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Global</td>
|
|
||||||
<td>None (default)</td>
|
|
||||||
<td>Above table</td>
|
|
||||||
<td>Always</td>
|
|
||||||
<td>None</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Single Selection</td>
|
|
||||||
<td><span class="code">requiresSelection: true</span></td>
|
|
||||||
<td>Above table</td>
|
|
||||||
<td>Exactly 1 row selected</td>
|
|
||||||
<td>Selected row object</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Row Action</td>
|
|
||||||
<td><span class="code">rowAction: true</span></td>
|
|
||||||
<td>Actions column</td>
|
|
||||||
<td>Always (per row)</td>
|
|
||||||
<td>Individual row object</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Bulk</td>
|
|
||||||
<td><span class="code">requiresMultipleSelection: true</span></td>
|
|
||||||
<td>Above table (when selected)</td>
|
|
||||||
<td>1+ rows selected</td>
|
|
||||||
<td>Array of selected rows</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-case">
|
|
||||||
<h3>Implementation Changes Made:</h3>
|
|
||||||
<ol>
|
|
||||||
<li>
|
|
||||||
<strong>Computed Properties Updated</strong>:
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<span class="code">globalActions</span>: Actions with no special
|
|
||||||
properties
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="code">singleSelectionActions</span>: Actions with
|
|
||||||
<span class="code">requiresSelection: true</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="code">rowActions</span>: Actions with
|
|
||||||
<span class="code">rowAction: true</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="code">bulkActions</span>: Actions with
|
|
||||||
<span class="code">requiresMultipleSelection: true</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Template Updates</strong>:
|
|
||||||
<ul>
|
|
||||||
<li>Global Actions section now includes single selection actions</li>
|
|
||||||
<li>
|
|
||||||
Single selection actions are disabled unless exactly one row is
|
|
||||||
selected
|
|
||||||
</li>
|
|
||||||
<li>Visual feedback shows selection state</li>
|
|
||||||
<li>
|
|
||||||
Actions column only shows
|
|
||||||
<span class="code">rowAction: true</span> actions
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>New Handler Added</strong>:
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<span class="code">handleSingleSelectionAction</span>: Passes selected
|
|
||||||
row data to action
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-case">
|
|
||||||
<h3>Example Configuration (Clients.vue):</h3>
|
|
||||||
<div class="example">
|
|
||||||
<pre>
|
|
||||||
const tableActions = [
|
|
||||||
// Global action - always available
|
|
||||||
{
|
|
||||||
label: "Add Client",
|
|
||||||
action: () => modalStore.openModal("createClient"),
|
|
||||||
icon: "pi pi-plus",
|
|
||||||
style: "primary"
|
|
||||||
},
|
|
||||||
// Single selection action - enabled when exactly one row selected
|
|
||||||
{
|
|
||||||
label: "View Details",
|
|
||||||
action: (rowData) => router.push(`/clients/${rowData.id}`),
|
|
||||||
icon: "pi pi-eye",
|
|
||||||
style: "info",
|
|
||||||
requiresSelection: true
|
|
||||||
},
|
|
||||||
// Bulk action - enabled when rows selected
|
|
||||||
{
|
|
||||||
label: "Export Selected",
|
|
||||||
action: (selectedRows) => exportData(selectedRows),
|
|
||||||
icon: "pi pi-download",
|
|
||||||
style: "success",
|
|
||||||
requiresMultipleSelection: true
|
|
||||||
},
|
|
||||||
// Row actions - appear in each row
|
|
||||||
{
|
|
||||||
label: "Edit",
|
|
||||||
action: (rowData) => editClient(rowData),
|
|
||||||
icon: "pi pi-pencil",
|
|
||||||
style: "secondary",
|
|
||||||
rowAction: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Quick View",
|
|
||||||
action: (rowData) => showPreview(rowData),
|
|
||||||
icon: "pi pi-search",
|
|
||||||
style: "info",
|
|
||||||
rowAction: true
|
|
||||||
}
|
|
||||||
];</pre
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-case">
|
|
||||||
<h3>User Experience Improvements:</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Clearer Action Organization</strong>: Actions are logically grouped by
|
|
||||||
their purpose
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Better Visual Feedback</strong>: Users see why certain actions are
|
|
||||||
disabled
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>More Flexible Layout</strong>: Actions can be placed where they make
|
|
||||||
most sense
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Reduced Clutter</strong>: Row actions only show contextual actions for
|
|
||||||
that specific row
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Intuitive Behavior</strong>: Single selection actions work like "View
|
|
||||||
Details" - need one item selected
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-case">
|
|
||||||
<h3>Action Flow Examples:</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Adding New Item</strong>: Global action → Always available → No data
|
|
||||||
needed
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Viewing Item Details</strong>: Single selection action → Select one row
|
|
||||||
→ View details of selected item
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Editing Item</strong>: Row action → Click edit in specific row → Edit
|
|
||||||
that item
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Bulk Operations</strong>: Bulk action → Select multiple rows → Operate
|
|
||||||
on all selected
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>✅ Testing Checklist</h2>
|
|
||||||
<ul>
|
|
||||||
<li>[ ] Global actions are always enabled and visible above table</li>
|
|
||||||
<li>[ ] Single selection actions are disabled when no rows selected</li>
|
|
||||||
<li>[ ] Single selection actions are disabled when multiple rows selected</li>
|
|
||||||
<li>[ ] Single selection actions are enabled when exactly one row is selected</li>
|
|
||||||
<li>[ ] Single selection actions receive correct row data</li>
|
|
||||||
<li>[ ] Row actions appear in each row's actions column</li>
|
|
||||||
<li>[ ] Row actions receive correct individual row data</li>
|
|
||||||
<li>[ ] Bulk actions appear when rows are selected</li>
|
|
||||||
<li>[ ] Bulk actions receive array of selected row data</li>
|
|
||||||
<li>[ ] Visual feedback shows selection state appropriately</li>
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
# 🎉 Integrated Error Store with Automatic Notifications
|
|
||||||
|
|
||||||
## What's New
|
|
||||||
|
|
||||||
The error store now automatically creates PrimeVue Toast notifications when errors are set. **No need to import both stores anymore!**
|
|
||||||
|
|
||||||
## ✅ Benefits
|
|
||||||
|
|
||||||
- **Single Import**: Only import `useErrorStore`
|
|
||||||
- **Automatic Notifications**: Error toasts appear automatically
|
|
||||||
- **Cleaner Code**: Less boilerplate in components
|
|
||||||
- **Consistent UI**: All notifications use PrimeVue Toast
|
|
||||||
- **Better Organization**: All error handling in one place
|
|
||||||
|
|
||||||
## 📖 Usage Examples
|
|
||||||
|
|
||||||
### Before (Old Way)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Had to import both stores
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
import { useNotificationStore } from "@/stores/notifications-primevue";
|
|
||||||
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
// Manual notification creation
|
|
||||||
errorStore.setGlobalError(new Error("Something failed"));
|
|
||||||
notificationStore.addError("Something failed"); // Had to do this manually
|
|
||||||
```
|
|
||||||
|
|
||||||
### After (New Way)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Only need one import
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
|
|
||||||
// Automatic notification - toast appears automatically!
|
|
||||||
errorStore.setGlobalError(new Error("Something failed"));
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ Available Methods
|
|
||||||
|
|
||||||
### Error Methods (Auto-create toasts)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Global errors
|
|
||||||
errorStore.setGlobalError(new Error("System error"));
|
|
||||||
|
|
||||||
// Component-specific errors
|
|
||||||
errorStore.setComponentError("form", new Error("Validation failed"));
|
|
||||||
|
|
||||||
// API errors
|
|
||||||
errorStore.setApiError("fetch-users", new Error("Network error"));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Convenience Methods (Direct notifications)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Success messages
|
|
||||||
errorStore.setSuccess("Operation completed!");
|
|
||||||
|
|
||||||
// Warnings
|
|
||||||
errorStore.setWarning("Please check your input");
|
|
||||||
|
|
||||||
// Info messages
|
|
||||||
errorStore.setInfo("Loading data...");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Disable Automatic Notifications
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Set errors without showing toasts
|
|
||||||
errorStore.setGlobalError(new Error("Silent error"), false);
|
|
||||||
errorStore.setComponentError("form", new Error("Silent error"), false);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Migration Guide
|
|
||||||
|
|
||||||
### Components Using Both Stores
|
|
||||||
|
|
||||||
**Old Code:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
import { useNotificationStore } from "@/stores/notifications-primevue";
|
|
||||||
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
// Show error
|
|
||||||
errorStore.setGlobalError(error);
|
|
||||||
notificationStore.addError("Failed to save");
|
|
||||||
```
|
|
||||||
|
|
||||||
**New Code:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
|
|
||||||
// Error toast shown automatically!
|
|
||||||
errorStore.setGlobalError(error);
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Wrapper Updates
|
|
||||||
|
|
||||||
The `ApiWithToast` wrapper has been updated to use only the error store. All existing usage remains the same, but now it's even simpler internally.
|
|
||||||
|
|
||||||
## 🎯 What Changed Internally
|
|
||||||
|
|
||||||
1. **Error Store**: Now imports `notifications-primevue` store
|
|
||||||
2. **Automatic Calls**: Error methods automatically call toast notifications
|
|
||||||
3. **Formatted Titles**: Component names are nicely formatted (e.g., "demo-component" → "Demo Component Error")
|
|
||||||
4. **Convenience Methods**: Added `setSuccess()`, `setWarning()`, `setInfo()` methods
|
|
||||||
5. **ApiWithToast**: Updated to use only error store
|
|
||||||
6. **Demo Pages**: Updated to show single-store usage
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
Visit `/dev/error-handling-demo` to test:
|
|
||||||
|
|
||||||
- All buttons now work with single error store
|
|
||||||
- Automatic toast notifications
|
|
||||||
- Error history still works
|
|
||||||
- Component errors formatted nicely
|
|
||||||
|
|
||||||
The notifications will appear in the top-right corner using PrimeVue Toast styling!
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
# Global Loading State Usage Guide
|
|
||||||
|
|
||||||
This document explains how to use the global loading state system in your Vue app.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The loading system provides multiple ways to handle loading states:
|
|
||||||
|
|
||||||
1. **Global Loading Overlay** - Shows over the entire app
|
|
||||||
2. **Component-specific Loading** - For individual components like DataTable and Form
|
|
||||||
3. **Operation-specific Loading** - For tracking specific async operations
|
|
||||||
|
|
||||||
## Loading Store
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
|
||||||
|
|
||||||
const loadingStore = useLoadingStore();
|
|
||||||
|
|
||||||
// Set global loading
|
|
||||||
loadingStore.setLoading(true, "Processing...");
|
|
||||||
|
|
||||||
// Set component-specific loading
|
|
||||||
loadingStore.setComponentLoading("dataTable", true, "Loading data...");
|
|
||||||
|
|
||||||
// Use async wrapper
|
|
||||||
const data = await loadingStore.withLoading(
|
|
||||||
"fetchUsers",
|
|
||||||
() => Api.getUsers(),
|
|
||||||
"Fetching user data...",
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Available Methods
|
|
||||||
|
|
||||||
- `setLoading(isLoading, message?)` - Global loading state
|
|
||||||
- `setComponentLoading(componentName, isLoading, message?)` - Component loading
|
|
||||||
- `startOperation(operationKey, message?)` - Start tracked operation
|
|
||||||
- `stopOperation(operationKey)` - Stop tracked operation
|
|
||||||
- `withLoading(operationKey, asyncFn, message?)` - Async wrapper
|
|
||||||
- `withComponentLoading(componentName, asyncFn, message?)` - Component async wrapper
|
|
||||||
|
|
||||||
### Convenience Methods
|
|
||||||
|
|
||||||
- `startApiCall(apiName?)` - Quick API loading
|
|
||||||
- `stopApiCall()` - Stop API loading
|
|
||||||
- `startDataTableLoading(message?)` - DataTable loading
|
|
||||||
- `stopDataTableLoading()` - Stop DataTable loading
|
|
||||||
- `startFormLoading(message?)` - Form loading
|
|
||||||
- `stopFormLoading()` - Stop Form loading
|
|
||||||
|
|
||||||
## DataTable Component
|
|
||||||
|
|
||||||
The DataTable component automatically integrates with the loading store:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<DataTable
|
|
||||||
:data="tableData"
|
|
||||||
:columns="columns"
|
|
||||||
tableName="clients"
|
|
||||||
:loading="customLoading"
|
|
||||||
loadingMessage="Custom loading message..."
|
|
||||||
emptyMessage="No clients found"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// DataTable will automatically show loading when:
|
|
||||||
// 1. props.loading is true
|
|
||||||
// 2. Global loading store has loading for 'dataTable'
|
|
||||||
// 3. Global loading store has loading for props.tableName
|
|
||||||
// 4. Any global loading (if useGlobalLoading is true)
|
|
||||||
|
|
||||||
// You can also control it directly:
|
|
||||||
const tableRef = ref();
|
|
||||||
tableRef.value?.startLoading("Custom loading...");
|
|
||||||
tableRef.value?.stopLoading();
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Form Component
|
|
||||||
|
|
||||||
The Form component also integrates with loading:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<Form
|
|
||||||
:fields="formFields"
|
|
||||||
formName="userForm"
|
|
||||||
:loading="customLoading"
|
|
||||||
loadingMessage="Saving user..."
|
|
||||||
@submit="handleSubmit"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// Form will disable all inputs and show loading buttons when:
|
|
||||||
// 1. props.loading is true
|
|
||||||
// 2. Global loading store has loading for 'form'
|
|
||||||
// 3. Global loading store has loading for props.formName
|
|
||||||
// 4. Internal isSubmitting is true
|
|
||||||
|
|
||||||
// Control directly:
|
|
||||||
const formRef = ref();
|
|
||||||
formRef.value?.startLoading("Processing...");
|
|
||||||
formRef.value?.stopLoading();
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Integration Example
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In your page component
|
|
||||||
import { useLoadingStore } from "../../stores/loading";
|
|
||||||
|
|
||||||
const loadingStore = useLoadingStore();
|
|
||||||
|
|
||||||
// Method 1: Manual control
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
loadingStore.startDataTableLoading("Loading clients...");
|
|
||||||
const data = await Api.getClients();
|
|
||||||
tableData.value = data;
|
|
||||||
} finally {
|
|
||||||
loadingStore.stopDataTableLoading();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Method 2: Using wrapper (recommended)
|
|
||||||
const loadData = async () => {
|
|
||||||
const data = await loadingStore.withComponentLoading(
|
|
||||||
"clients",
|
|
||||||
() => Api.getClients(),
|
|
||||||
"Loading clients...",
|
|
||||||
);
|
|
||||||
tableData.value = data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Method 3: For global overlay
|
|
||||||
const performGlobalAction = async () => {
|
|
||||||
const result = await loadingStore.withLoading(
|
|
||||||
"globalOperation",
|
|
||||||
() => Api.performHeavyOperation(),
|
|
||||||
"Processing your request...",
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Global Loading Overlay
|
|
||||||
|
|
||||||
The `GlobalLoadingOverlay` component shows automatically when global loading is active:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- Already added to App.vue -->
|
|
||||||
<GlobalLoadingOverlay />
|
|
||||||
|
|
||||||
<!-- Customizable props -->
|
|
||||||
<GlobalLoadingOverlay
|
|
||||||
:globalOnly="false" <!-- Show for any loading, not just global -->
|
|
||||||
:minDisplayTime="500" <!-- Minimum display time in ms -->
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Use component-specific loading** for individual components
|
|
||||||
2. **Use global loading** for app-wide operations (login, navigation, etc.)
|
|
||||||
3. **Use operation tracking** for multiple concurrent operations
|
|
||||||
4. **Always use try/finally** when manually controlling loading
|
|
||||||
5. **Prefer async wrappers** over manual start/stop calls
|
|
||||||
6. **Provide meaningful loading messages** to users
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
const data = await loadingStore.withComponentLoading(
|
|
||||||
"clients",
|
|
||||||
() => Api.getClients(),
|
|
||||||
"Loading clients...",
|
|
||||||
);
|
|
||||||
tableData.value = data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load clients:", error);
|
|
||||||
// Show error message to user
|
|
||||||
// Loading state is automatically cleared by the wrapper
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@ -1,296 +0,0 @@
|
|||||||
# Simple API Error Handling with PrimeVue Toast
|
|
||||||
|
|
||||||
This guide shows how to implement clean, simple error handling using PrimeVue Toast instead of complex custom notification components.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The simplified approach provides:
|
|
||||||
|
|
||||||
- **Automatic error toasts** using PrimeVue Toast
|
|
||||||
- **Loading state management** with component-specific tracking
|
|
||||||
- **Success notifications** for create/update operations
|
|
||||||
- **Retry logic** with exponential backoff
|
|
||||||
- **Clean error storage** for debugging and component-specific error handling
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
### 1. PrimeVue Notification Store
|
|
||||||
|
|
||||||
**File:** `/src/stores/notifications-primevue.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { ref } from "vue";
|
|
||||||
import { defineStore } from "pinia";
|
|
||||||
|
|
||||||
export const useNotificationStore = defineStore(
|
|
||||||
"notifications-primevue",
|
|
||||||
() => {
|
|
||||||
// Toast instance reference
|
|
||||||
const toastInstance = ref(null);
|
|
||||||
|
|
||||||
// Set the toast instance (called from App.vue)
|
|
||||||
const setToastInstance = (toast) => {
|
|
||||||
toastInstance.value = toast;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convenience methods for different toast types
|
|
||||||
const addSuccess = (message, life = 4000) => {
|
|
||||||
if (toastInstance.value) {
|
|
||||||
toastInstance.value.add({
|
|
||||||
severity: "success",
|
|
||||||
summary: "Success",
|
|
||||||
detail: message,
|
|
||||||
life,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ... other methods
|
|
||||||
},
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Enhanced API Wrapper
|
|
||||||
|
|
||||||
**File:** `/src/api-toast.js`
|
|
||||||
|
|
||||||
Provides a wrapper around your existing API calls with automatic:
|
|
||||||
|
|
||||||
- Error handling and toast notifications
|
|
||||||
- Loading state management
|
|
||||||
- Component-specific error tracking
|
|
||||||
- Retry logic
|
|
||||||
- Success messages
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Simple usage - automatic error toasts
|
|
||||||
try {
|
|
||||||
const result = await ApiWithToast.getClientStatusCounts();
|
|
||||||
// Success - data loaded
|
|
||||||
} catch (error) {
|
|
||||||
// Error toast automatically shown
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create operations with success toasts
|
|
||||||
await ApiWithToast.createClient(formData);
|
|
||||||
// Shows: "Client created successfully!"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage in Components
|
|
||||||
|
|
||||||
### 1. Basic Setup
|
|
||||||
|
|
||||||
In your component:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
import ApiWithToast from "@/api-toast";
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
import { useLoadingStore } from "@/stores/loading";
|
|
||||||
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
const loadingStore = useLoadingStore();
|
|
||||||
|
|
||||||
// Simple API call
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
const result = await ApiWithToast.getPaginatedClientDetails(
|
|
||||||
pagination,
|
|
||||||
filters,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
// Handle success
|
|
||||||
} catch (error) {
|
|
||||||
// Error toast shown automatically
|
|
||||||
// Component error stored automatically
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Loading States
|
|
||||||
|
|
||||||
The API wrapper automatically manages loading states:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<Button
|
|
||||||
@click="loadClients"
|
|
||||||
:loading="loadingStore.isComponentLoading('clients')"
|
|
||||||
label="Load Clients"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Component-Specific Errors
|
|
||||||
|
|
||||||
Access errors for debugging or custom handling:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div v-if="errorStore.getComponentError('clients')" class="error-info">
|
|
||||||
Error: {{ errorStore.getComponentError("clients").message }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
## App.vue Integration
|
|
||||||
|
|
||||||
Ensure your `App.vue` includes the Toast component and connects it to the store:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div id="app">
|
|
||||||
<!-- Your app content -->
|
|
||||||
<router-view />
|
|
||||||
|
|
||||||
<!-- PrimeVue Toast for notifications -->
|
|
||||||
<Toast ref="toast" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import Toast from "primevue/toast";
|
|
||||||
import { useNotificationStore } from "@/stores/notifications-primevue";
|
|
||||||
|
|
||||||
const toast = ref();
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Connect toast instance to the store
|
|
||||||
notificationStore.setToastInstance(toast.value);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Wrapper Methods
|
|
||||||
|
|
||||||
### Convenience Methods
|
|
||||||
|
|
||||||
Pre-configured methods for common operations:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Data fetching (no success toast)
|
|
||||||
await ApiWithToast.getClientStatusCounts();
|
|
||||||
await ApiWithToast.getPaginatedClientDetails(pagination, filters, sorting);
|
|
||||||
await ApiWithToast.getPaginatedJobDetails(pagination, filters, sorting);
|
|
||||||
await ApiWithToast.getPaginatedWarrantyData(pagination, filters, sorting);
|
|
||||||
|
|
||||||
// Create operations (success toast included)
|
|
||||||
await ApiWithToast.createClient(clientData);
|
|
||||||
|
|
||||||
// Utility operations (with retry logic)
|
|
||||||
await ApiWithToast.getCityStateByZip(zipcode);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom API Calls
|
|
||||||
|
|
||||||
For custom operations:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
await ApiWithToast.makeApiCall(() => yourApiFunction(), {
|
|
||||||
componentName: "myComponent",
|
|
||||||
showSuccessToast: true,
|
|
||||||
successMessage: "Operation completed!",
|
|
||||||
showErrorToast: true,
|
|
||||||
customErrorMessage: "Custom error message",
|
|
||||||
retryCount: 3,
|
|
||||||
retryDelay: 1000,
|
|
||||||
showLoading: true,
|
|
||||||
loadingMessage: "Processing...",
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
|
||||||
| -------------------- | ------- | -------------- | ----------------------------------------------- |
|
|
||||||
| `componentName` | string | null | Component identifier for error/loading tracking |
|
|
||||||
| `showErrorToast` | boolean | true | Show error toast on failure |
|
|
||||||
| `showSuccessToast` | boolean | false | Show success toast on completion |
|
|
||||||
| `showLoading` | boolean | true | Show loading indicator |
|
|
||||||
| `loadingMessage` | string | 'Loading...' | Loading message to display |
|
|
||||||
| `successMessage` | string | null | Success message for toast |
|
|
||||||
| `customErrorMessage` | string | null | Override error message |
|
|
||||||
| `retryCount` | number | 0 | Number of retry attempts |
|
|
||||||
| `retryDelay` | number | 1000 | Delay between retries (ms) |
|
|
||||||
| `operationKey` | string | auto-generated | Unique identifier for operation |
|
|
||||||
|
|
||||||
## Demo Pages
|
|
||||||
|
|
||||||
### 1. Simple API Demo
|
|
||||||
|
|
||||||
**URL:** `/dev/simple-api-demo`
|
|
||||||
|
|
||||||
Shows practical usage with real API calls:
|
|
||||||
|
|
||||||
- Loading client data
|
|
||||||
- Creating test clients
|
|
||||||
- Error handling
|
|
||||||
- Retry logic
|
|
||||||
|
|
||||||
### 2. PrimeVue Toast Demo
|
|
||||||
|
|
||||||
**URL:** `/dev/toast-demo`
|
|
||||||
|
|
||||||
Demonstrates Toast types and error store integration:
|
|
||||||
|
|
||||||
- Different toast severities
|
|
||||||
- Error store testing
|
|
||||||
- API simulation
|
|
||||||
|
|
||||||
## Migration from Custom Notifications
|
|
||||||
|
|
||||||
### Old Approach (Custom NotificationDisplay)
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- Complex setup needed -->
|
|
||||||
<NotificationDisplay />
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useNotificationStore } from "@/stores/notifications";
|
|
||||||
// Manual notification management
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### New Approach (PrimeVue Toast)
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- Just use the API wrapper -->
|
|
||||||
<script setup>
|
|
||||||
import ApiWithToast from "@/api-toast";
|
|
||||||
// Automatic toast notifications
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Consistency**: Uses PrimeVue components throughout
|
|
||||||
2. **Simplicity**: No custom notification components needed
|
|
||||||
3. **Automatic**: Error handling happens automatically
|
|
||||||
4. **Flexible**: Easy to customize per operation
|
|
||||||
5. **Maintainable**: Centralized error handling logic
|
|
||||||
6. **Type Safety**: Clear API with documented options
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Test the implementation by:
|
|
||||||
|
|
||||||
1. Visit `/dev/simple-api-demo`
|
|
||||||
2. Try different operations:
|
|
||||||
- Load Clients (success case)
|
|
||||||
- Create Test Client (success with toast)
|
|
||||||
- Test Error (error toast)
|
|
||||||
- Test Retry (retry logic demonstration)
|
|
||||||
|
|
||||||
The toasts will appear in the top-right corner using PrimeVue's default styling.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Replace existing API calls with `ApiWithToast` methods
|
|
||||||
2. Remove custom notification components
|
|
||||||
3. Update components to use the simplified error handling
|
|
||||||
4. Test across all your existing workflows
|
|
||||||
|
|
||||||
This approach provides cleaner, more maintainable error handling while leveraging your existing PrimeVue setup.
|
|
||||||
@ -1,958 +0,0 @@
|
|||||||
# DataTable Component Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A feature-rich data table component built with PrimeVue's DataTable. This component provides advanced functionality including server-side pagination, sorting, manual filtering with apply buttons, row selection, page data caching, and customizable column types with persistent state management.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<DataTable
|
|
||||||
:columns="tableColumns"
|
|
||||||
:data="tableData"
|
|
||||||
:tableActions="tableActions"
|
|
||||||
table-name="my-table"
|
|
||||||
@row-click="handleRowClick"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from "vue";
|
|
||||||
import DataTable from "./components/common/DataTable.vue";
|
|
||||||
|
|
||||||
const tableColumns = ref([
|
|
||||||
{
|
|
||||||
fieldName: "name",
|
|
||||||
label: "Name",
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fieldName: "status",
|
|
||||||
label: "Status",
|
|
||||||
type: "status",
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const tableData = ref([
|
|
||||||
{ id: 1, name: "John Doe", status: "completed" },
|
|
||||||
{ id: 2, name: "Jane Smith", status: "in progress" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const tableActions = ref([
|
|
||||||
{
|
|
||||||
label: "Add Item",
|
|
||||||
action: () => console.log("Add clicked"),
|
|
||||||
icon: "pi pi-plus",
|
|
||||||
style: "primary",
|
|
||||||
// Global action - always available
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "View Details",
|
|
||||||
action: (rowData) => console.log("View:", rowData),
|
|
||||||
icon: "pi pi-eye",
|
|
||||||
style: "info",
|
|
||||||
requiresSelection: true,
|
|
||||||
// Single selection action - enabled when exactly one row selected
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Edit",
|
|
||||||
action: (rowData) => console.log("Edit:", rowData),
|
|
||||||
icon: "pi pi-pencil",
|
|
||||||
style: "secondary",
|
|
||||||
rowAction: true,
|
|
||||||
// Row action - appears in each row's actions column
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRowClick = (event) => {
|
|
||||||
console.log("Row clicked:", event.data);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Props
|
|
||||||
|
|
||||||
### `columns` (Array) - Required
|
|
||||||
|
|
||||||
- **Description:** Array of column configuration objects that define the table structure
|
|
||||||
- **Type:** `Array<Object>`
|
|
||||||
- **Required:** `true`
|
|
||||||
|
|
||||||
### `data` (Array) - Required
|
|
||||||
|
|
||||||
- **Description:** Array of data objects to display in the table
|
|
||||||
- **Type:** `Array<Object>`
|
|
||||||
- **Required:** `true`
|
|
||||||
|
|
||||||
### `tableName` (String) - Required
|
|
||||||
|
|
||||||
- **Description:** Unique identifier for the table, used for persistent filter state management
|
|
||||||
- **Type:** `String`
|
|
||||||
- **Required:** `true`
|
|
||||||
|
|
||||||
### `totalRecords` (Number)
|
|
||||||
|
|
||||||
- **Description:** Total number of records available on the server (for lazy loading)
|
|
||||||
- **Type:** `Number`
|
|
||||||
- **Default:** `0`
|
|
||||||
|
|
||||||
### `onLazyLoad` (Function)
|
|
||||||
|
|
||||||
- **Description:** Custom pagination event handler for server-side data loading
|
|
||||||
- **Type:** `Function`
|
|
||||||
- **Default:** `null`
|
|
||||||
|
|
||||||
### `filters` (Object)
|
|
||||||
|
|
||||||
- **Description:** Initial filter configuration object (used for non-lazy tables)
|
|
||||||
- **Type:** `Object`
|
|
||||||
- **Default:** `{ global: { value: null, matchMode: FilterMatchMode.CONTAINS } }`
|
|
||||||
|
|
||||||
### `tableActions` (Array)
|
|
||||||
|
|
||||||
- **Description:** Array of action objects that define interactive buttons for the table. Actions can be global (always available), single-selection (enabled when exactly one row is selected), row-specific (displayed per row), or bulk (for multiple selected rows).
|
|
||||||
- **Type:** `Array<Object>`
|
|
||||||
- **Default:** `[]`
|
|
||||||
|
|
||||||
## Server-Side Pagination & Lazy Loading
|
|
||||||
|
|
||||||
When `lazy` is set to `true`, the DataTable operates in server-side mode with the following features:
|
|
||||||
|
|
||||||
### Automatic Caching
|
|
||||||
|
|
||||||
- **Page Data Caching:** Previously loaded pages are cached to prevent unnecessary API calls
|
|
||||||
- **Cache Duration:** 5 minutes default expiration time
|
|
||||||
- **Cache Size:** Maximum 50 pages per table with automatic cleanup
|
|
||||||
- **Smart Cache Keys:** Based on page, sorting, and filter combinations
|
|
||||||
|
|
||||||
### Manual Filter Controls
|
|
||||||
|
|
||||||
- **Apply Button:** Filters are applied manually via button click to prevent excessive API calls
|
|
||||||
- **Clear Button:** Quick reset of all active filters
|
|
||||||
- **Enter Key Support:** Apply filters by pressing Enter in any filter field
|
|
||||||
- **Visual Feedback:** Shows active filters and pending changes
|
|
||||||
|
|
||||||
### Quick Page Navigation
|
|
||||||
|
|
||||||
- **Page Dropdown:** Jump directly to any page number
|
|
||||||
- **Page Info Display:** Shows current record range and totals
|
|
||||||
- **Persistent State:** Page selection survives component re-mounts
|
|
||||||
|
|
||||||
## Column Configuration
|
|
||||||
|
|
||||||
Each column object in the `columns` array supports the following properties:
|
|
||||||
|
|
||||||
### Basic Properties
|
|
||||||
|
|
||||||
- **`fieldName`** (String, required) - The field name in the data object
|
|
||||||
- **`label`** (String, required) - Display label for the column header
|
|
||||||
- **`sortable`** (Boolean, default: `false`) - Enables sorting for this column
|
|
||||||
- **`filterable`** (Boolean, default: `false`) - Enables row-level filtering for this column
|
|
||||||
|
|
||||||
### Column Types
|
|
||||||
|
|
||||||
- **`type`** (String) - Defines special rendering behavior for the column
|
|
||||||
|
|
||||||
#### Available Types:
|
|
||||||
|
|
||||||
##### `'status'` Type
|
|
||||||
|
|
||||||
Renders values as colored tags/badges:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
fieldName: 'status',
|
|
||||||
label: 'Status',
|
|
||||||
type: 'status',
|
|
||||||
sortable: true,
|
|
||||||
filterable: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Status Colors:**
|
|
||||||
|
|
||||||
- `'completed'` → Success (green)
|
|
||||||
- `'in progress'` → Warning (yellow/orange)
|
|
||||||
- `'not started'` → Danger (red)
|
|
||||||
- Other values → Info (blue)
|
|
||||||
|
|
||||||
##### `'button'` Type
|
|
||||||
|
|
||||||
Renders values as clickable buttons:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
fieldName: 'action',
|
|
||||||
label: 'Action',
|
|
||||||
type: 'button'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Table Actions Configuration
|
|
||||||
|
|
||||||
Table actions allow you to add interactive buttons to your DataTable. Actions can be either global (displayed above the table) or row-specific (displayed in an actions column).
|
|
||||||
|
|
||||||
### Action Object Properties
|
|
||||||
|
|
||||||
Each action object in the `tableActions` array supports the following properties:
|
|
||||||
|
|
||||||
#### Basic Properties
|
|
||||||
|
|
||||||
- **`label`** (String, required) - Display text for the button
|
|
||||||
- **`action`** (Function, required) - Function to execute when button is clicked
|
|
||||||
- **`icon`** (String, optional) - PrimeVue icon class (e.g., 'pi pi-plus')
|
|
||||||
- **`style`** (String, optional) - Button severity: 'primary', 'secondary', 'success', 'info', 'warning', 'danger'
|
|
||||||
- **`size`** (String, optional) - Button size: 'small', 'normal', 'large'
|
|
||||||
- **`requiresSelection`** (Boolean, default: false) - When true, action appears above table but is only enabled when exactly one row is selected
|
|
||||||
- **`requiresMultipleSelection`** (Boolean, default: false) - Determines if action is for bulk operations on selected rows
|
|
||||||
- **`rowAction`** (Boolean, default: false) - When true, action appears in each row's actions column
|
|
||||||
- **`layout`** (Object, optional) - Layout configuration for action positioning and styling
|
|
||||||
|
|
||||||
#### Layout Configuration
|
|
||||||
|
|
||||||
The `layout` property allows you to control where and how actions are displayed:
|
|
||||||
|
|
||||||
##### For Top-Level Actions (Global and Single Selection)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
layout: {
|
|
||||||
position: "left" | "center" | "right", // Where to position in action bar
|
|
||||||
variant: "filled" | "outlined" | "text" // Visual style variant
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### For Row Actions
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
layout: {
|
|
||||||
priority: "primary" | "secondary" | "dropdown", // Display priority in row
|
|
||||||
variant: "outlined" | "text" | "compact" | "icon-only" // Visual style
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### For Bulk Actions
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
layout: {
|
|
||||||
position: "left" | "center" | "right", // Where to position in bulk action bar
|
|
||||||
variant: "filled" | "outlined" | "text" // Visual style variant
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Action Types
|
|
||||||
|
|
||||||
##### Global Actions (default behavior)
|
|
||||||
|
|
||||||
Global actions are displayed above the table and are always available:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
label: "Add New Item",
|
|
||||||
action: () => {
|
|
||||||
// Global action - no row data
|
|
||||||
console.log("Opening create modal");
|
|
||||||
},
|
|
||||||
icon: "pi pi-plus",
|
|
||||||
style: "primary"
|
|
||||||
// No requiresSelection, requiresMultipleSelection, or rowAction properties
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Single Selection Actions (`requiresSelection: true`)
|
|
||||||
|
|
||||||
Single selection actions are displayed above the table but are only enabled when exactly one row is selected. They receive the selected row data as a parameter:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
label: "View Details",
|
|
||||||
action: (rowData) => {
|
|
||||||
// Single selection action - receives selected row data
|
|
||||||
console.log("Viewing:", rowData.name);
|
|
||||||
router.push(`/items/${rowData.id}`);
|
|
||||||
},
|
|
||||||
icon: "pi pi-eye",
|
|
||||||
style: "info",
|
|
||||||
requiresSelection: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Row Actions (`rowAction: true`)
|
|
||||||
|
|
||||||
Row actions are displayed in an "Actions" column for each row and receive that row's data as a parameter:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
label: "Edit",
|
|
||||||
action: (rowData) => {
|
|
||||||
// Row action - receives individual row data
|
|
||||||
console.log("Editing:", rowData.name);
|
|
||||||
openEditModal(rowData);
|
|
||||||
},
|
|
||||||
icon: "pi pi-pencil",
|
|
||||||
style: "secondary",
|
|
||||||
rowAction: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Bulk Actions (`requiresMultipleSelection: true`)
|
|
||||||
|
|
||||||
Bulk actions are displayed above the table when rows are selected and receive an array of selected row data:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
label: "Delete Selected",
|
|
||||||
action: (selectedRows) => {
|
|
||||||
// Bulk action - receives array of selected row data
|
|
||||||
console.log("Deleting:", selectedRows.length, "items");
|
|
||||||
selectedRows.forEach(row => deleteItem(row.id));
|
|
||||||
},
|
|
||||||
icon: "pi pi-trash",
|
|
||||||
style: "danger",
|
|
||||||
requiresMultipleSelection: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Table Actions Configuration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const tableActions = [
|
|
||||||
// Global action - shows above table, always available
|
|
||||||
{
|
|
||||||
label: "Add Client",
|
|
||||||
action: () => modalStore.openModal("createClient"),
|
|
||||||
icon: "pi pi-plus",
|
|
||||||
style: "primary",
|
|
||||||
},
|
|
||||||
// Single selection action - shows above table, enabled when exactly one row selected
|
|
||||||
{
|
|
||||||
label: "View Details",
|
|
||||||
action: (rowData) => router.push(`/clients/${rowData.id}`),
|
|
||||||
icon: "pi pi-eye",
|
|
||||||
style: "info",
|
|
||||||
requiresSelection: true,
|
|
||||||
},
|
|
||||||
// Bulk action - shows when rows selected
|
|
||||||
{
|
|
||||||
label: "Delete Selected",
|
|
||||||
action: (selectedRows) => {
|
|
||||||
if (confirm(`Delete ${selectedRows.length} clients?`)) {
|
|
||||||
selectedRows.forEach((row) => deleteClient(row.id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: "pi pi-trash",
|
|
||||||
style: "danger",
|
|
||||||
requiresMultipleSelection: true,
|
|
||||||
},
|
|
||||||
// Row actions - show in each row's actions column
|
|
||||||
{
|
|
||||||
label: "Edit",
|
|
||||||
action: (rowData) => editClient(rowData),
|
|
||||||
icon: "pi pi-pencil",
|
|
||||||
style: "secondary",
|
|
||||||
rowAction: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Quick View",
|
|
||||||
action: (rowData) => showQuickPreview(rowData),
|
|
||||||
icon: "pi pi-search",
|
|
||||||
style: "info",
|
|
||||||
rowAction: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
### `rowClick`
|
|
||||||
|
|
||||||
- **Description:** Emitted when a button-type column is clicked
|
|
||||||
- **Payload:** PrimeVue slot properties object containing row data
|
|
||||||
- **Usage:** `@row-click="handleRowClick"`
|
|
||||||
|
|
||||||
### `lazy-load`
|
|
||||||
|
|
||||||
- **Description:** Emitted when lazy loading is triggered (pagination, sorting, filtering)
|
|
||||||
- **Payload:** Event object with page, sorting, and filter information
|
|
||||||
- **Usage:** `@lazy-load="handleLazyLoad"`
|
|
||||||
|
|
||||||
### `page-change`
|
|
||||||
|
|
||||||
- **Description:** Emitted when page changes
|
|
||||||
- **Payload:** PrimeVue page event object
|
|
||||||
|
|
||||||
### `sort-change`
|
|
||||||
|
|
||||||
- **Description:** Emitted when sorting changes
|
|
||||||
- **Payload:** PrimeVue sort event object
|
|
||||||
|
|
||||||
### `filter-change`
|
|
||||||
|
|
||||||
- **Description:** Emitted when filters are applied
|
|
||||||
- **Payload:** PrimeVue filter event object
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleRowClick = (slotProps) => {
|
|
||||||
console.log("Clicked row data:", slotProps.data);
|
|
||||||
console.log("Row index:", slotProps.index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLazyLoad = async (event) => {
|
|
||||||
// event contains: page, rows, sortField, sortOrder, filters
|
|
||||||
console.log("Lazy load event:", event);
|
|
||||||
|
|
||||||
// Load data from API based on event parameters
|
|
||||||
const result = await Api.getData({
|
|
||||||
page: event.page,
|
|
||||||
pageSize: event.rows,
|
|
||||||
sortField: event.sortField,
|
|
||||||
sortOrder: event.sortOrder,
|
|
||||||
filters: event.filters,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update component data
|
|
||||||
tableData.value = result.data;
|
|
||||||
totalRecords.value = result.totalRecords;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Pagination
|
|
||||||
|
|
||||||
- **Rows per page options:** 5, 10, 20, 50
|
|
||||||
- **Default rows per page:** 10
|
|
||||||
- **Built-in pagination controls**
|
|
||||||
|
|
||||||
### Sorting
|
|
||||||
|
|
||||||
- **Multiple column sorting** support
|
|
||||||
- **Removable sort** - click to remove sort from a column
|
|
||||||
- **Sort indicators** in column headers
|
|
||||||
|
|
||||||
### Filtering
|
|
||||||
|
|
||||||
- **Manual filter application** with Apply/Clear buttons
|
|
||||||
- **Text-based search** for filterable columns
|
|
||||||
- **Persistent filter state** across component re-renders and page navigation
|
|
||||||
- **Visual filter feedback** showing active filters and pending changes
|
|
||||||
- **Enter key support** for quick filter application
|
|
||||||
|
|
||||||
### Selection
|
|
||||||
|
|
||||||
- **Multiple row selection** with checkboxes
|
|
||||||
- **Meta key selection** (Ctrl/Cmd + click for individual selection)
|
|
||||||
- **Unique row identification** using `dataKey="id"`
|
|
||||||
|
|
||||||
### Scrolling
|
|
||||||
|
|
||||||
- **Vertical scrolling** with fixed height (70vh)
|
|
||||||
- **Horizontal scrolling** for wide tables
|
|
||||||
- **Fixed headers** during scroll
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
|
|
||||||
- **Persistent filters** using Pinia store (`useFiltersStore`)
|
|
||||||
- **Automatic filter initialization** on component mount
|
|
||||||
- **Cross-component filter synchronization**
|
|
||||||
|
|
||||||
### Table Actions
|
|
||||||
|
|
||||||
- **Global actions** displayed above the table for general operations
|
|
||||||
- **Row-specific actions** in dedicated actions column with row data access
|
|
||||||
- **Bulk actions** for selected rows with multi-selection support
|
|
||||||
- **Customizable button styles** with PrimeVue severity levels
|
|
||||||
- **Icon support** using PrimeVue icons
|
|
||||||
- **Automatic action handling** with error catching
|
|
||||||
- **Disabled state** during loading operations
|
|
||||||
- **Dynamic bulk action visibility** based on row selection
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Server-Side Paginated Table (Recommended for Large Datasets)
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
import { ref } from "vue";
|
|
||||||
import DataTable from "./components/common/DataTable.vue";
|
|
||||||
import Api from "./api.js";
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ fieldName: "id", label: "ID", sortable: true },
|
|
||||||
{ fieldName: "name", label: "Name", sortable: true, filterable: true },
|
|
||||||
{ fieldName: "email", label: "Email", filterable: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const tableData = ref([]);
|
|
||||||
const totalRecords = ref(0);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
|
|
||||||
const handleLazyLoad = async (event) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
// Convert PrimeVue event to API parameters
|
|
||||||
const params = {
|
|
||||||
page: event.page,
|
|
||||||
pageSize: event.rows,
|
|
||||||
sortField: event.sortField,
|
|
||||||
sortOrder: event.sortOrder,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert filters
|
|
||||||
const filters = {};
|
|
||||||
if (event.filters) {
|
|
||||||
Object.keys(event.filters).forEach((key) => {
|
|
||||||
if (event.filters[key]?.value) {
|
|
||||||
filters[key] = event.filters[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// API call with caching support
|
|
||||||
const result = await Api.getPaginatedData(params, filters);
|
|
||||||
|
|
||||||
tableData.value = result.data;
|
|
||||||
totalRecords.value = result.totalRecords;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading data:", error);
|
|
||||||
tableData.value = [];
|
|
||||||
totalRecords.value = 0;
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DataTable
|
|
||||||
:data="tableData"
|
|
||||||
:columns="columns"
|
|
||||||
tableName="myTable"
|
|
||||||
:lazy="true"
|
|
||||||
:totalRecords="totalRecords"
|
|
||||||
:loading="isLoading"
|
|
||||||
:onLazyLoad="handleLazyLoad"
|
|
||||||
@lazy-load="handleLazyLoad"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interactive Table with Actions
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useModalStore } from "./stores/modal";
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const modalStore = useModalStore();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ fieldName: "name", label: "Name", sortable: true, filterable: true },
|
|
||||||
{ fieldName: "status", label: "Status", type: "status", sortable: true },
|
|
||||||
{ fieldName: "email", label: "Email", filterable: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ id: 1, name: "John Doe", status: "completed", email: "john@example.com" },
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Jane Smith",
|
|
||||||
status: "in progress",
|
|
||||||
email: "jane@example.com",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const tableActions = [
|
|
||||||
// Global action
|
|
||||||
{
|
|
||||||
label: "Add User",
|
|
||||||
action: () => {
|
|
||||||
modalStore.openModal("createUser");
|
|
||||||
},
|
|
||||||
icon: "pi pi-plus",
|
|
||||||
style: "primary",
|
|
||||||
},
|
|
||||||
// Single selection action
|
|
||||||
{
|
|
||||||
label: "View Details",
|
|
||||||
action: (rowData) => {
|
|
||||||
router.push(`/users/${rowData.id}`);
|
|
||||||
},
|
|
||||||
icon: "pi pi-eye",
|
|
||||||
style: "info",
|
|
||||||
requiresSelection: true,
|
|
||||||
},
|
|
||||||
// Bulk actions
|
|
||||||
{
|
|
||||||
label: "Export Selected",
|
|
||||||
action: (selectedRows) => {
|
|
||||||
exportUsers(selectedRows);
|
|
||||||
},
|
|
||||||
icon: "pi pi-download",
|
|
||||||
style: "success",
|
|
||||||
requiresMultipleSelection: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete Selected",
|
|
||||||
action: (selectedRows) => {
|
|
||||||
if (confirm(`Delete ${selectedRows.length} users?`)) {
|
|
||||||
bulkDeleteUsers(selectedRows.map((row) => row.id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: "pi pi-trash",
|
|
||||||
style: "danger",
|
|
||||||
requiresMultipleSelection: true,
|
|
||||||
},
|
|
||||||
// Row actions
|
|
||||||
{
|
|
||||||
label: "Edit",
|
|
||||||
action: (rowData) => {
|
|
||||||
modalStore.openModal("editUser", rowData);
|
|
||||||
},
|
|
||||||
icon: "pi pi-pencil",
|
|
||||||
style: "secondary",
|
|
||||||
rowAction: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Quick Actions",
|
|
||||||
action: (rowData) => {
|
|
||||||
showQuickActionsMenu(rowData);
|
|
||||||
},
|
|
||||||
icon: "pi pi-ellipsis-v",
|
|
||||||
style: "info",
|
|
||||||
rowAction: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const deleteUser = async (userId) => {
|
|
||||||
// API call to delete user
|
|
||||||
await Api.deleteUser(userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const bulkDeleteUsers = async (userIds) => {
|
|
||||||
// API call to delete multiple users
|
|
||||||
await Api.bulkDeleteUsers(userIds);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportUsers = (users) => {
|
|
||||||
// Export selected users to CSV/Excel
|
|
||||||
const csv = generateCSV(users);
|
|
||||||
downloadFile(csv, "users.csv");
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = () => {
|
|
||||||
// Refresh table data
|
|
||||||
location.reload();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:data="data"
|
|
||||||
:tableActions="tableActions"
|
|
||||||
table-name="users-table"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Basic Client-Side Table
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
const columns = [
|
|
||||||
{ fieldName: "id", label: "ID", sortable: true },
|
|
||||||
{ fieldName: "name", label: "Name", sortable: true, filterable: true },
|
|
||||||
{ fieldName: "email", label: "Email", filterable: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ id: 1, name: "John Doe", email: "john@example.com" },
|
|
||||||
{ id: 2, name: "Jane Smith", email: "jane@example.com" },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DataTable :data="data" :columns="columns" tableName="basicTable" />
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Status Table
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
const columns = [
|
|
||||||
{ fieldName: "task", label: "Task", sortable: true, filterable: true },
|
|
||||||
{
|
|
||||||
fieldName: "status",
|
|
||||||
label: "Status",
|
|
||||||
type: "status",
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
},
|
|
||||||
{ fieldName: "assignee", label: "Assignee", filterable: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ id: 1, task: "Setup project", status: "completed", assignee: "John" },
|
|
||||||
{ id: 2, task: "Write tests", status: "in progress", assignee: "Jane" },
|
|
||||||
{ id: 3, task: "Deploy app", status: "not started", assignee: "Bob" },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DataTable :columns="columns" :data="data" table-name="tasks-table" />
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interactive Table with Buttons
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
const columns = [
|
|
||||||
{ fieldName: "name", label: "Name", sortable: true, filterable: true },
|
|
||||||
{ fieldName: "status", label: "Status", type: "status", sortable: true },
|
|
||||||
{ fieldName: "action", label: "Action", type: "button" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ id: 1, name: "Project A", status: "completed", action: "View Details" },
|
|
||||||
{ id: 2, name: "Project B", status: "in progress", action: "Edit" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleRowClick = (slotProps) => {
|
|
||||||
const { data, index } = slotProps;
|
|
||||||
console.log(`Action clicked for ${data.name} at row ${index}`);
|
|
||||||
// Handle the action (navigate, open modal, etc.)
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:data="data"
|
|
||||||
table-name="projects-table"
|
|
||||||
@row-click="handleRowClick"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Filters
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
import { FilterMatchMode } from "@primevue/core";
|
|
||||||
|
|
||||||
const customFilters = {
|
|
||||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
|
||||||
name: { value: "John", matchMode: FilterMatchMode.STARTS_WITH },
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:data="data"
|
|
||||||
:filters="customFilters"
|
|
||||||
table-name="filtered-table"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Layout-Aware Actions Example
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
import { ref } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useModalStore } from "@/stores/modal";
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const modalStore = useModalStore();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ fieldName: "name", label: "Client Name", sortable: true, filterable: true },
|
|
||||||
{ fieldName: "email", label: "Email", filterable: true },
|
|
||||||
{ fieldName: "status", label: "Status", type: "status", sortable: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const data = ref([
|
|
||||||
{ id: 1, name: "Acme Corp", email: "contact@acme.com", status: "completed" },
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Tech Solutions",
|
|
||||||
email: "info@tech.com",
|
|
||||||
status: "in progress",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const tableActions = [
|
|
||||||
// Left-positioned action with filled style
|
|
||||||
{
|
|
||||||
label: "Quick Export",
|
|
||||||
icon: "pi pi-download",
|
|
||||||
action: () => exportAllClients(),
|
|
||||||
severity: "success",
|
|
||||||
layout: { position: "left", variant: "outlined" },
|
|
||||||
},
|
|
||||||
// Center-positioned bulk action
|
|
||||||
{
|
|
||||||
label: "Archive Selected",
|
|
||||||
icon: "pi pi-archive",
|
|
||||||
action: (selectedRows) => archiveClients(selectedRows),
|
|
||||||
severity: "warning",
|
|
||||||
requiresMultipleSelection: true,
|
|
||||||
layout: { position: "center", variant: "outlined" },
|
|
||||||
},
|
|
||||||
// Right-positioned main action
|
|
||||||
{
|
|
||||||
label: "Create Client",
|
|
||||||
icon: "pi pi-plus",
|
|
||||||
action: () => modalStore.openModal("createClient"),
|
|
||||||
severity: "info",
|
|
||||||
layout: { position: "right", variant: "filled" },
|
|
||||||
},
|
|
||||||
// Single selection action
|
|
||||||
{
|
|
||||||
label: "Edit Details",
|
|
||||||
icon: "pi pi-pencil",
|
|
||||||
action: (rowData) => router.push(`/clients/${rowData.id}/edit`),
|
|
||||||
severity: "secondary",
|
|
||||||
requiresSelection: true,
|
|
||||||
layout: { position: "left", variant: "text" },
|
|
||||||
},
|
|
||||||
// Primary row action - most important
|
|
||||||
{
|
|
||||||
label: "View",
|
|
||||||
icon: "pi pi-eye",
|
|
||||||
action: (rowData) => router.push(`/clients/${rowData.id}`),
|
|
||||||
severity: "info",
|
|
||||||
rowAction: true,
|
|
||||||
layout: { priority: "primary", variant: "outlined" },
|
|
||||||
},
|
|
||||||
// Secondary row action - less important
|
|
||||||
{
|
|
||||||
label: "Contact",
|
|
||||||
icon: "pi pi-phone",
|
|
||||||
action: (rowData) => initiateContact(rowData),
|
|
||||||
severity: "success",
|
|
||||||
rowAction: true,
|
|
||||||
layout: { priority: "secondary", variant: "text" },
|
|
||||||
},
|
|
||||||
// Dropdown row action - additional options
|
|
||||||
{
|
|
||||||
label: "More",
|
|
||||||
icon: "pi pi-ellipsis-v",
|
|
||||||
action: (rowData) => showMoreOptions(rowData),
|
|
||||||
rowAction: true,
|
|
||||||
layout: { priority: "dropdown", variant: "icon-only" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const exportAllClients = () => {
|
|
||||||
// Export logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const archiveClients = (clients) => {
|
|
||||||
// Archive logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const initiateContact = (client) => {
|
|
||||||
// Contact logic
|
|
||||||
};
|
|
||||||
|
|
||||||
const showMoreOptions = (client) => {
|
|
||||||
// More options logic
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:data="data"
|
|
||||||
:tableActions="tableActions"
|
|
||||||
table-name="clients-with-layout"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Store Integration
|
|
||||||
|
|
||||||
The component integrates with a Pinia store (`useFiltersStore`) for persistent filter state:
|
|
||||||
|
|
||||||
### Store Methods Used
|
|
||||||
|
|
||||||
- `initializeTableFilters(tableName, columns)` - Initialize filters for a table
|
|
||||||
- `getTableFilters(tableName)` - Get current filters for a table
|
|
||||||
- `updateTableFilter(tableName, fieldName, value, matchMode)` - Update a specific filter
|
|
||||||
|
|
||||||
### Filter Persistence
|
|
||||||
|
|
||||||
- Filters are automatically saved when changed
|
|
||||||
- Filters persist across component re-mounts
|
|
||||||
- Each table maintains separate filter state based on `tableName`
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
The component uses PrimeVue's default DataTable styling with:
|
|
||||||
|
|
||||||
- **Scrollable layout** with fixed 70vh height
|
|
||||||
- **Responsive design** that adapts to container width
|
|
||||||
- **Consistent spacing** and typography
|
|
||||||
- **Accessible color schemes** for status badges
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Large Datasets
|
|
||||||
|
|
||||||
- **Virtual scrolling** is not implemented - consider for datasets > 1000 rows
|
|
||||||
- **Client-side pagination** may impact performance with very large datasets
|
|
||||||
- **Debounced filtering** helps with real-time search performance
|
|
||||||
|
|
||||||
### Memory Management
|
|
||||||
|
|
||||||
- **Filter state persistence** may accumulate over time
|
|
||||||
- Consider implementing filter cleanup for unused tables
|
|
||||||
- **Component re-rendering** is optimized through computed properties
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Use unique `tableName`** for each table instance to avoid filter conflicts
|
|
||||||
2. **Define clear column labels** for better user experience
|
|
||||||
3. **Enable sorting and filtering** on searchable/comparable columns
|
|
||||||
4. **Use appropriate column types** (`status`, `button`) for better UX
|
|
||||||
5. **Handle `rowClick` events** for interactive functionality
|
|
||||||
6. **Consider data structure** - ensure `id` field exists for selection
|
|
||||||
7. **Test with various data sizes** to ensure performance
|
|
||||||
8. **Use consistent status values** for proper badge coloring
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
The component includes:
|
|
||||||
|
|
||||||
- **Keyboard navigation** support via PrimeVue
|
|
||||||
- **Screen reader compatibility** with proper ARIA labels
|
|
||||||
- **High contrast** status badges for visibility
|
|
||||||
- **Focus management** for interactive elements
|
|
||||||
- **Semantic HTML structure** for assistive technologies
|
|
||||||
|
|
||||||
## Browser Support
|
|
||||||
|
|
||||||
Compatible with all modern browsers that support:
|
|
||||||
|
|
||||||
- Vue 3 Composition API
|
|
||||||
- ES6+ features
|
|
||||||
- CSS Grid and Flexbox
|
|
||||||
- PrimeVue components
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- **Vue 3** with Composition API
|
|
||||||
- **PrimeVue** DataTable, Column, Tag, Button, InputText components
|
|
||||||
- **@primevue/core** for FilterMatchMode
|
|
||||||
- **Pinia** store for state management (`useFiltersStore`)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,268 +0,0 @@
|
|||||||
# Dynamic Modal Component Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A flexible and customizable modal component built with Vuetify's v-dialog. This component provides extensive configuration options and supports slot-based content rendering.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<Modal
|
|
||||||
v-model:visible="isModalVisible"
|
|
||||||
:options="modalOptions"
|
|
||||||
@close="handleClose"
|
|
||||||
@confirm="handleConfirm"
|
|
||||||
>
|
|
||||||
<p>Your modal content goes here</p>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import Modal from './components/Modal.vue'
|
|
||||||
|
|
||||||
const isModalVisible = ref(false)
|
|
||||||
const modalOptions = {
|
|
||||||
title: 'My Modal',
|
|
||||||
maxWidth: '500px'
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
console.log('Modal closed')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
console.log('Modal confirmed')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Props
|
|
||||||
|
|
||||||
### `visible` (Boolean)
|
|
||||||
- **Default:** `false`
|
|
||||||
- **Description:** Controls the visibility state of the modal
|
|
||||||
- **Usage:** Use with `v-model:visible` for two-way binding
|
|
||||||
|
|
||||||
### `options` (Object)
|
|
||||||
- **Default:** `{}`
|
|
||||||
- **Description:** Configuration object for customizing modal behavior and appearance
|
|
||||||
|
|
||||||
## Options Object Properties
|
|
||||||
|
|
||||||
### Dialog Configuration
|
|
||||||
- **`persistent`** (Boolean, default: `false`) - Prevents closing when clicking outside or pressing escape
|
|
||||||
- **`fullscreen`** (Boolean, default: `false`) - Makes the modal fullscreen
|
|
||||||
- **`maxWidth`** (String, default: `'500px'`) - Maximum width of the modal
|
|
||||||
- **`width`** (String) - Fixed width of the modal
|
|
||||||
- **`height`** (String) - Fixed height of the modal
|
|
||||||
- **`attach`** (String) - Element to attach the modal to
|
|
||||||
- **`transition`** (String, default: `'dialog-transition'`) - CSS transition name
|
|
||||||
- **`scrollable`** (Boolean, default: `false`) - Makes the modal content scrollable
|
|
||||||
- **`retainFocus`** (Boolean, default: `true`) - Retains focus within the modal
|
|
||||||
- **`closeOnBack`** (Boolean, default: `true`) - Closes modal on browser back button
|
|
||||||
- **`closeOnContentClick`** (Boolean, default: `false`) - Closes modal when clicking content
|
|
||||||
- **`closeOnOutsideClick`** (Boolean, default: `true`) - Closes modal when clicking outside
|
|
||||||
- **`closeOnEscape`** (Boolean, default: `true`) - Closes modal when pressing escape key
|
|
||||||
|
|
||||||
### Styling Options
|
|
||||||
- **`overlayColor`** (String) - Color of the backdrop overlay
|
|
||||||
- **`overlayOpacity`** (Number) - Opacity of the backdrop overlay
|
|
||||||
- **`zIndex`** (Number) - Z-index of the modal
|
|
||||||
- **`dialogClass`** (String) - Additional CSS classes for the dialog
|
|
||||||
- **`cardClass`** (String) - Additional CSS classes for the card
|
|
||||||
- **`cardColor`** (String) - Background color of the card
|
|
||||||
- **`cardVariant`** (String) - Vuetify card variant
|
|
||||||
- **`elevation`** (Number) - Shadow elevation of the card
|
|
||||||
- **`flat`** (Boolean) - Removes elevation
|
|
||||||
- **`noRadius`** (Boolean) - Removes border radius
|
|
||||||
|
|
||||||
### Header Configuration
|
|
||||||
- **`title`** (String) - Modal title text
|
|
||||||
- **`showHeader`** (Boolean, default: `true`) - Shows/hides the header
|
|
||||||
- **`showHeaderDivider`** (Boolean) - Shows divider below header
|
|
||||||
- **`headerClass`** (String) - Additional CSS classes for header
|
|
||||||
- **`showCloseButton`** (Boolean, default: `true`) - Shows/hides close button
|
|
||||||
- **`closeButtonColor`** (String, default: `'grey'`) - Color of close button
|
|
||||||
- **`closeIcon`** (String, default: `'mdi-close'`) - Icon for close button
|
|
||||||
|
|
||||||
### Content Configuration
|
|
||||||
- **`message`** (String) - Default message content (HTML supported)
|
|
||||||
- **`contentClass`** (String) - Additional CSS classes for content area
|
|
||||||
- **`contentHeight`** (String) - Fixed height of content area
|
|
||||||
- **`contentMaxHeight`** (String) - Maximum height of content area
|
|
||||||
- **`contentMinHeight`** (String) - Minimum height of content area
|
|
||||||
- **`noPadding`** (Boolean) - Removes padding from content area
|
|
||||||
|
|
||||||
### Actions Configuration
|
|
||||||
- **`showActions`** (Boolean, default: `true`) - Shows/hides action buttons
|
|
||||||
- **`actionsClass`** (String) - Additional CSS classes for actions area
|
|
||||||
- **`actionsAlign`** (String) - Alignment of action buttons (`'left'`, `'center'`, `'right'`, `'space-between'`)
|
|
||||||
|
|
||||||
### Button Configuration
|
|
||||||
- **`showConfirmButton`** (Boolean, default: `true`) - Shows/hides confirm button
|
|
||||||
- **`confirmButtonText`** (String, default: `'Confirm'`) - Text for confirm button
|
|
||||||
- **`confirmButtonColor`** (String, default: `'primary'`) - Color of confirm button
|
|
||||||
- **`confirmButtonVariant`** (String, default: `'elevated'`) - Variant of confirm button
|
|
||||||
- **`showCancelButton`** (Boolean, default: `true`) - Shows/hides cancel button
|
|
||||||
- **`cancelButtonText`** (String, default: `'Cancel'`) - Text for cancel button
|
|
||||||
- **`cancelButtonColor`** (String, default: `'grey'`) - Color of cancel button
|
|
||||||
- **`cancelButtonVariant`** (String, default: `'text'`) - Variant of cancel button
|
|
||||||
- **`loading`** (Boolean) - Shows loading state on confirm button
|
|
||||||
|
|
||||||
### Behavior Configuration
|
|
||||||
- **`autoCloseOnConfirm`** (Boolean, default: `true`) - Auto-closes modal after confirm
|
|
||||||
- **`autoCloseOnCancel`** (Boolean, default: `true`) - Auto-closes modal after cancel
|
|
||||||
- **`onOpen`** (Function) - Callback function when modal opens
|
|
||||||
- **`onClose`** (Function) - Callback function when modal closes
|
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
- **`update:visible`** - Emitted when visibility state changes
|
|
||||||
- **`close`** - Emitted when modal is closed
|
|
||||||
- **`confirm`** - Emitted when confirm button is clicked
|
|
||||||
- **`cancel`** - Emitted when cancel button is clicked
|
|
||||||
- **`outside-click`** - Emitted when clicking outside the modal
|
|
||||||
- **`escape-key`** - Emitted when escape key is pressed
|
|
||||||
|
|
||||||
## Slots
|
|
||||||
|
|
||||||
### Default Slot
|
|
||||||
```vue
|
|
||||||
<Modal>
|
|
||||||
<p>Your content here</p>
|
|
||||||
</Modal>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Title Slot
|
|
||||||
```vue
|
|
||||||
<Modal>
|
|
||||||
<template #title>
|
|
||||||
<v-icon class="mr-2">mdi-account</v-icon>
|
|
||||||
Custom Title
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Actions Slot
|
|
||||||
```vue
|
|
||||||
<Modal>
|
|
||||||
<template #actions="{ close, options }">
|
|
||||||
<v-btn @click="customAction">Custom Action</v-btn>
|
|
||||||
<v-btn @click="close">Close</v-btn>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Basic Modal
|
|
||||||
```vue
|
|
||||||
const basicOptions = {
|
|
||||||
title: 'Information',
|
|
||||||
maxWidth: '400px'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Confirmation Modal
|
|
||||||
```vue
|
|
||||||
const confirmOptions = {
|
|
||||||
title: 'Confirm Action',
|
|
||||||
persistent: false,
|
|
||||||
confirmButtonText: 'Delete',
|
|
||||||
confirmButtonColor: 'error',
|
|
||||||
cancelButtonText: 'Keep'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Form Modal
|
|
||||||
```vue
|
|
||||||
const formOptions = {
|
|
||||||
title: 'Add New Item',
|
|
||||||
maxWidth: '600px',
|
|
||||||
persistent: true,
|
|
||||||
confirmButtonText: 'Save',
|
|
||||||
loading: isLoading.value
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fullscreen Modal
|
|
||||||
```vue
|
|
||||||
const fullscreenOptions = {
|
|
||||||
fullscreen: true,
|
|
||||||
showActions: false,
|
|
||||||
scrollable: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Styled Modal
|
|
||||||
```vue
|
|
||||||
const customOptions = {
|
|
||||||
maxWidth: '500px',
|
|
||||||
cardColor: 'primary',
|
|
||||||
elevation: 12,
|
|
||||||
overlayOpacity: 0.8,
|
|
||||||
transition: 'scale-transition'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### With Reactive Options
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const formValid = ref(false)
|
|
||||||
|
|
||||||
const modalOptions = computed(() => ({
|
|
||||||
title: 'Dynamic Title',
|
|
||||||
loading: loading.value,
|
|
||||||
confirmButtonText: formValid.value ? 'Save' : 'Validate First',
|
|
||||||
persistent: !formValid.value
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Modals
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<!-- Each modal can have different configurations -->
|
|
||||||
<Modal v-model:visible="modal1" :options="options1">
|
|
||||||
Content 1
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal v-model:visible="modal2" :options="options2">
|
|
||||||
Content 2
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Use persistent modals for forms** to prevent accidental data loss
|
|
||||||
2. **Set appropriate maxWidth** for different screen sizes
|
|
||||||
3. **Use loading states** for async operations
|
|
||||||
4. **Provide clear button labels** that describe the action
|
|
||||||
5. **Use slots for complex content** instead of the message option
|
|
||||||
6. **Handle all events** to provide good user feedback
|
|
||||||
7. **Test keyboard navigation** and accessibility features
|
|
||||||
|
|
||||||
## Responsive Behavior
|
|
||||||
|
|
||||||
The modal automatically adjusts for mobile devices:
|
|
||||||
- Reduced padding on smaller screens
|
|
||||||
- Appropriate font sizes
|
|
||||||
- Touch-friendly button sizes
|
|
||||||
- Proper viewport handling
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
The component includes:
|
|
||||||
- Proper ARIA attributes
|
|
||||||
- Keyboard navigation support
|
|
||||||
- Focus management
|
|
||||||
- Screen reader compatibility
|
|
||||||
- High contrast support
|
|
||||||
@ -1,357 +0,0 @@
|
|||||||
# NotificationDisplay Component
|
|
||||||
|
|
||||||
The `NotificationDisplay` component provides a global notification system for displaying toast-style messages to users. It integrates seamlessly with the notification store to show success, error, warning, and info messages with optional action buttons.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
- **Location**: `src/components/common/NotificationDisplay.vue`
|
|
||||||
- **Type**: Global Component
|
|
||||||
- **Dependencies**: `@/stores/notifications`
|
|
||||||
- **Integration**: Added to `App.vue` for global usage
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- ✅ **Multiple notification types** (success, error, warning, info)
|
|
||||||
- ✅ **Configurable positioning** (6 different positions)
|
|
||||||
- ✅ **Auto-dismiss with progress bars**
|
|
||||||
- ✅ **Persistent notifications**
|
|
||||||
- ✅ **Action buttons** with custom handlers
|
|
||||||
- ✅ **Smooth animations** (slide-in/out effects)
|
|
||||||
- ✅ **Responsive design**
|
|
||||||
- ✅ **Accessibility features**
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic Integration
|
|
||||||
|
|
||||||
The component is automatically included in your application via `App.vue`:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div id="app">
|
|
||||||
<!-- Your app content -->
|
|
||||||
|
|
||||||
<!-- Global Notifications -->
|
|
||||||
<NotificationDisplay />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Triggering Notifications
|
|
||||||
|
|
||||||
Use the notification store to display notifications:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useNotificationStore } from "@/stores/notifications";
|
|
||||||
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
// Simple success notification
|
|
||||||
notificationStore.addSuccess("Data saved successfully!");
|
|
||||||
|
|
||||||
// Error notification
|
|
||||||
notificationStore.addError("Failed to save data", "Save Error");
|
|
||||||
|
|
||||||
// Custom notification with options
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: "warning",
|
|
||||||
title: "Unsaved Changes",
|
|
||||||
message: "You have unsaved changes. What would you like to do?",
|
|
||||||
persistent: true,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "Save",
|
|
||||||
variant: "primary",
|
|
||||||
handler: () => saveData(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Discard",
|
|
||||||
variant: "danger",
|
|
||||||
handler: () => discardChanges(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notification Types
|
|
||||||
|
|
||||||
### Success
|
|
||||||
|
|
||||||
- **Color**: Green (#10b981)
|
|
||||||
- **Icon**: Check circle
|
|
||||||
- **Use case**: Successful operations, confirmations
|
|
||||||
|
|
||||||
### Error
|
|
||||||
|
|
||||||
- **Color**: Red (#ef4444)
|
|
||||||
- **Icon**: Alert circle
|
|
||||||
- **Default duration**: 6000ms (longer than others)
|
|
||||||
- **Use case**: Errors, failures, critical issues
|
|
||||||
|
|
||||||
### Warning
|
|
||||||
|
|
||||||
- **Color**: Orange (#f59e0b)
|
|
||||||
- **Icon**: Alert triangle
|
|
||||||
- **Use case**: Warnings, potential issues, confirmations needed
|
|
||||||
|
|
||||||
### Info
|
|
||||||
|
|
||||||
- **Color**: Blue (#3b82f6)
|
|
||||||
- **Icon**: Information circle
|
|
||||||
- **Use case**: General information, tips, status updates
|
|
||||||
|
|
||||||
## Positioning Options
|
|
||||||
|
|
||||||
The notification container can be positioned in 6 different locations:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Set position via notification store
|
|
||||||
notificationStore.setPosition("top-right"); // Default
|
|
||||||
|
|
||||||
// Available positions:
|
|
||||||
// - 'top-right'
|
|
||||||
// - 'top-left'
|
|
||||||
// - 'top-center'
|
|
||||||
// - 'bottom-right'
|
|
||||||
// - 'bottom-left'
|
|
||||||
// - 'bottom-center'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Action Buttons
|
|
||||||
|
|
||||||
Notifications can include action buttons for user interaction:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: "info",
|
|
||||||
title: "File Upload",
|
|
||||||
message: "File uploaded successfully. What would you like to do next?",
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "View File",
|
|
||||||
variant: "primary",
|
|
||||||
icon: "mdi mdi-eye",
|
|
||||||
handler: (notification) => {
|
|
||||||
// Custom action logic
|
|
||||||
console.log("Viewing file from notification:", notification);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Share",
|
|
||||||
variant: "secondary",
|
|
||||||
icon: "mdi mdi-share",
|
|
||||||
handler: () => shareFile(),
|
|
||||||
dismissAfter: false, // Don't auto-dismiss after action
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Action Button Variants
|
|
||||||
|
|
||||||
- **primary**: Blue background
|
|
||||||
- **danger**: Red background
|
|
||||||
- **secondary**: Gray background (default)
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
### Global Configuration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
// Set default duration (milliseconds)
|
|
||||||
notificationStore.setDefaultDuration(5000);
|
|
||||||
|
|
||||||
// Set maximum number of notifications
|
|
||||||
notificationStore.setMaxNotifications(3);
|
|
||||||
|
|
||||||
// Set position
|
|
||||||
notificationStore.setPosition("top-center");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Per-Notification Options
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Custom Notification',
|
|
||||||
message: 'This notification has custom settings',
|
|
||||||
|
|
||||||
// Duration (0 = no auto-dismiss)
|
|
||||||
duration: 8000,
|
|
||||||
|
|
||||||
// Persistent (won't auto-dismiss regardless of duration)
|
|
||||||
persistent: false,
|
|
||||||
|
|
||||||
// Custom actions
|
|
||||||
actions: [...],
|
|
||||||
|
|
||||||
// Additional data for handlers
|
|
||||||
data: { userId: 123, action: 'update' }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Responsive Behavior
|
|
||||||
|
|
||||||
The component automatically adapts to different screen sizes:
|
|
||||||
|
|
||||||
- **Desktop**: Fixed width (320px minimum, 400px maximum)
|
|
||||||
- **Mobile**: Full width with adjusted padding
|
|
||||||
- **Positioning**: Center positions become full-width on mobile
|
|
||||||
|
|
||||||
## Animations
|
|
||||||
|
|
||||||
The component includes smooth CSS transitions:
|
|
||||||
|
|
||||||
- **Enter**: Slide in from the appropriate direction
|
|
||||||
- **Leave**: Slide out in the same direction
|
|
||||||
- **Duration**: 300ms ease-out/ease-in
|
|
||||||
- **Progress Bar**: Animated countdown for timed notifications
|
|
||||||
|
|
||||||
## Accessibility Features
|
|
||||||
|
|
||||||
- **Keyboard Navigation**: Buttons are focusable and keyboard accessible
|
|
||||||
- **Screen Readers**: Proper ARIA labels and semantic HTML
|
|
||||||
- **Color Contrast**: High contrast colors for readability
|
|
||||||
- **Focus Management**: Proper focus indicators
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
The component uses scoped CSS with CSS custom properties for easy customization:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Custom styling example */
|
|
||||||
.notification-container {
|
|
||||||
/* Override default styles */
|
|
||||||
--notification-success-color: #059669;
|
|
||||||
--notification-error-color: #dc2626;
|
|
||||||
--notification-warning-color: #d97706;
|
|
||||||
--notification-info-color: #2563eb;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Do's
|
|
||||||
|
|
||||||
- ✅ Use appropriate notification types for different scenarios
|
|
||||||
- ✅ Keep messages concise and actionable
|
|
||||||
- ✅ Use action buttons for common follow-up actions
|
|
||||||
- ✅ Set appropriate durations (longer for errors, shorter for success)
|
|
||||||
- ✅ Use persistent notifications for critical actions requiring user input
|
|
||||||
|
|
||||||
### Don'ts
|
|
||||||
|
|
||||||
- ❌ Don't show too many notifications at once (overwhelming)
|
|
||||||
- ❌ Don't use persistent notifications for simple confirmations
|
|
||||||
- ❌ Don't make notification messages too long
|
|
||||||
- ❌ Don't use error notifications for non-critical issues
|
|
||||||
|
|
||||||
## Integration with Error Store
|
|
||||||
|
|
||||||
The NotificationDisplay component works seamlessly with the Error Store:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
|
|
||||||
// Errors automatically trigger notifications
|
|
||||||
await errorStore.withErrorHandling(
|
|
||||||
"api-call",
|
|
||||||
async () => {
|
|
||||||
// Your async operation
|
|
||||||
},
|
|
||||||
{
|
|
||||||
componentName: "myComponent",
|
|
||||||
showNotification: true, // Automatically shows error notifications
|
|
||||||
},
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Basic Usage Examples
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
// Simple notifications
|
|
||||||
notificationStore.addSuccess("Changes saved!");
|
|
||||||
notificationStore.addError("Network connection failed");
|
|
||||||
notificationStore.addWarning("Unsaved changes detected");
|
|
||||||
notificationStore.addInfo("New feature available");
|
|
||||||
|
|
||||||
// Advanced notification with multiple actions
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: "warning",
|
|
||||||
title: "Confirm Deletion",
|
|
||||||
message: "This action cannot be undone. Are you sure?",
|
|
||||||
persistent: true,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
variant: "danger",
|
|
||||||
handler: () => {
|
|
||||||
performDeletion();
|
|
||||||
notificationStore.addSuccess("Item deleted successfully");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cancel",
|
|
||||||
variant: "secondary",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Integration Examples
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Show loading notification that updates on completion
|
|
||||||
const loadingId =
|
|
||||||
notificationStore.showLoadingNotification("Uploading file...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await uploadFile();
|
|
||||||
notificationStore.updateToSuccess(loadingId, "File uploaded successfully!");
|
|
||||||
} catch (error) {
|
|
||||||
notificationStore.updateToError(loadingId, "Upload failed: " + error.message);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Notifications not appearing**
|
|
||||||
- Ensure NotificationDisplay is included in App.vue
|
|
||||||
- Check z-index conflicts with other components
|
|
||||||
- Verify notification store is properly imported
|
|
||||||
|
|
||||||
2. **Notifications appearing in wrong position**
|
|
||||||
- Check the position setting in the store
|
|
||||||
- Verify CSS is not being overridden
|
|
||||||
|
|
||||||
3. **Action buttons not working**
|
|
||||||
- Ensure handler functions are properly defined
|
|
||||||
- Check for JavaScript errors in handlers
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
|
|
||||||
Enable debug logging in development:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In your main.js or component
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
// Watch for notification changes
|
|
||||||
watch(
|
|
||||||
() => notificationStore.notifications,
|
|
||||||
(notifications) => {
|
|
||||||
console.log("Notifications updated:", notifications);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,699 +0,0 @@
|
|||||||
# Errors Store
|
|
||||||
|
|
||||||
The errors store provides comprehensive error handling and management for the entire application. It centralizes error tracking, automatic retry logic, and integration with the notification system to provide users with consistent error feedback.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
- **Location**: `src/stores/errors.js`
|
|
||||||
- **Type**: Pinia Store
|
|
||||||
- **Purpose**: Centralized error state management and handling
|
|
||||||
- **Integration**: Works with notification store for user feedback
|
|
||||||
|
|
||||||
## Installation & Setup
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Import in your component
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
|
|
||||||
// Use in component
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Structure
|
|
||||||
|
|
||||||
### Core State Properties
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
state: {
|
|
||||||
hasError: false, // Global error flag
|
|
||||||
lastError: null, // Most recent global error
|
|
||||||
apiErrors: new Map(), // API-specific errors by key
|
|
||||||
componentErrors: { // Component-specific errors
|
|
||||||
dataTable: null,
|
|
||||||
form: null,
|
|
||||||
clients: null,
|
|
||||||
jobs: null,
|
|
||||||
timesheets: null,
|
|
||||||
warranties: null,
|
|
||||||
routes: null
|
|
||||||
},
|
|
||||||
errorHistory: [], // Historical error log
|
|
||||||
maxHistorySize: 50, // Maximum history entries
|
|
||||||
autoNotifyErrors: true // Auto-show notifications for errors
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Object Structure
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
message: 'Error description', // Human-readable error message
|
|
||||||
type: 'api_error', // Error classification
|
|
||||||
timestamp: '2025-11-12T10:30:00Z', // When error occurred
|
|
||||||
name: 'ValidationError', // Error name (if available)
|
|
||||||
stack: 'Error stack trace...', // Stack trace (if available)
|
|
||||||
status: 404, // HTTP status (for API errors)
|
|
||||||
statusText: 'Not Found', // HTTP status text
|
|
||||||
data: {...} // Additional error data
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getters
|
|
||||||
|
|
||||||
### `hasAnyError`
|
|
||||||
|
|
||||||
Check if there are any errors in the application.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const hasErrors = errorStore.hasAnyError;
|
|
||||||
```
|
|
||||||
|
|
||||||
### `getComponentError(componentName)`
|
|
||||||
|
|
||||||
Get error for a specific component.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const formError = errorStore.getComponentError("form");
|
|
||||||
```
|
|
||||||
|
|
||||||
### `getApiError(apiKey)`
|
|
||||||
|
|
||||||
Get error for a specific API operation.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const loginError = errorStore.getApiError("user-login");
|
|
||||||
```
|
|
||||||
|
|
||||||
### `getRecentErrors(limit)`
|
|
||||||
|
|
||||||
Get recent errors from history.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const recentErrors = errorStore.getRecentErrors(10);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Actions
|
|
||||||
|
|
||||||
### Global Error Management
|
|
||||||
|
|
||||||
#### `setGlobalError(error, showNotification)`
|
|
||||||
|
|
||||||
Set a global application error.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.setGlobalError(
|
|
||||||
new Error("Critical system failure"),
|
|
||||||
true, // Show notification
|
|
||||||
);
|
|
||||||
|
|
||||||
// With custom error object
|
|
||||||
errorStore.setGlobalError({
|
|
||||||
message: "Database connection lost",
|
|
||||||
type: "connection_error",
|
|
||||||
recoverable: true,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `clearGlobalError()`
|
|
||||||
|
|
||||||
Clear the global error state.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.clearGlobalError();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component-Specific Error Management
|
|
||||||
|
|
||||||
#### `setComponentError(componentName, error, showNotification)`
|
|
||||||
|
|
||||||
Set an error for a specific component.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Set error for form component
|
|
||||||
errorStore.setComponentError("form", new Error("Validation failed"), true);
|
|
||||||
|
|
||||||
// Clear error (pass null)
|
|
||||||
errorStore.setComponentError("form", null);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `clearComponentError(componentName)`
|
|
||||||
|
|
||||||
Clear error for a specific component.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.clearComponentError("form");
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Error Management
|
|
||||||
|
|
||||||
#### `setApiError(apiKey, error, showNotification)`
|
|
||||||
|
|
||||||
Set an error for a specific API operation.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Set API error
|
|
||||||
errorStore.setApiError("user-login", apiError, true);
|
|
||||||
|
|
||||||
// Clear API error (pass null)
|
|
||||||
errorStore.setApiError("user-login", null);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `clearApiError(apiKey)`
|
|
||||||
|
|
||||||
Clear error for a specific API operation.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.clearApiError("user-login");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bulk Operations
|
|
||||||
|
|
||||||
#### `clearAllErrors()`
|
|
||||||
|
|
||||||
Clear all errors from the store.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.clearAllErrors();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Error Handling
|
|
||||||
|
|
||||||
#### `handleApiCall(apiKey, apiFunction, options)`
|
|
||||||
|
|
||||||
Handle an API call with automatic error management and retry logic.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const result = await errorStore.handleApiCall(
|
|
||||||
"fetch-users",
|
|
||||||
async () => {
|
|
||||||
return await api.getUsers();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
showNotification: true,
|
|
||||||
retryCount: 2,
|
|
||||||
retryDelay: 1000,
|
|
||||||
onSuccess: (result) => console.log("Success:", result),
|
|
||||||
onError: (error) => console.log("Failed:", error),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `withErrorHandling(operationKey, asyncOperation, options)`
|
|
||||||
|
|
||||||
Wrap an async operation with comprehensive error handling.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const result = await errorStore.withErrorHandling(
|
|
||||||
"save-data",
|
|
||||||
async () => {
|
|
||||||
return await saveUserData();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
componentName: "userForm",
|
|
||||||
showNotification: true,
|
|
||||||
rethrow: false, // Don't re-throw errors
|
|
||||||
},
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Types
|
|
||||||
|
|
||||||
The store automatically categorizes errors into different types:
|
|
||||||
|
|
||||||
### `string_error`
|
|
||||||
|
|
||||||
Simple string errors.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.setGlobalError("Something went wrong");
|
|
||||||
```
|
|
||||||
|
|
||||||
### `javascript_error`
|
|
||||||
|
|
||||||
Standard JavaScript Error objects.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.setGlobalError(new Error("Validation failed"));
|
|
||||||
```
|
|
||||||
|
|
||||||
### `api_error`
|
|
||||||
|
|
||||||
HTTP/API response errors with status codes.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Automatically detected from axios-style error objects
|
|
||||||
{
|
|
||||||
message: 'Not Found',
|
|
||||||
status: 404,
|
|
||||||
statusText: 'Not Found',
|
|
||||||
type: 'api_error',
|
|
||||||
data: {...}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `network_error`
|
|
||||||
|
|
||||||
Network connectivity errors.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
message: 'Network error - please check your connection',
|
|
||||||
type: 'network_error'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `unknown_error`
|
|
||||||
|
|
||||||
Unrecognized error formats.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
message: 'An unknown error occurred',
|
|
||||||
type: 'unknown_error',
|
|
||||||
originalError: {...}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Methods
|
|
||||||
|
|
||||||
### `setAutoNotifyErrors(enabled)`
|
|
||||||
|
|
||||||
Enable/disable automatic error notifications.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.setAutoNotifyErrors(false); // Disable auto-notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
### `setMaxHistorySize(size)`
|
|
||||||
|
|
||||||
Set maximum number of errors to keep in history.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
errorStore.setMaxHistorySize(100);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Patterns
|
|
||||||
|
|
||||||
### Basic Error Handling
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
|
|
||||||
// Simple error setting
|
|
||||||
try {
|
|
||||||
await riskyOperation();
|
|
||||||
} catch (error) {
|
|
||||||
errorStore.setGlobalError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component-specific error
|
|
||||||
try {
|
|
||||||
await validateForm();
|
|
||||||
} catch (validationError) {
|
|
||||||
errorStore.setComponentError("form", validationError);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Error Handling with Retry
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Automatic retry logic
|
|
||||||
const userData = await errorStore.handleApiCall(
|
|
||||||
"fetch-user-data",
|
|
||||||
async () => {
|
|
||||||
const response = await fetch("/api/users");
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch users");
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retryCount: 3,
|
|
||||||
retryDelay: 1000,
|
|
||||||
showNotification: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Comprehensive Operation Wrapping
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Wrap complex operations
|
|
||||||
const result = await errorStore.withErrorHandling(
|
|
||||||
"complex-workflow",
|
|
||||||
async () => {
|
|
||||||
// Step 1: Validate data
|
|
||||||
await validateInputData();
|
|
||||||
|
|
||||||
// Step 2: Save to database
|
|
||||||
const saveResult = await saveToDatabase();
|
|
||||||
|
|
||||||
// Step 3: Send notification email
|
|
||||||
await sendNotificationEmail();
|
|
||||||
|
|
||||||
return saveResult;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
componentName: "workflow",
|
|
||||||
showNotification: true,
|
|
||||||
rethrow: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// Success - result contains the return value
|
|
||||||
console.log("Workflow completed:", result);
|
|
||||||
} else {
|
|
||||||
// Error occurred - check component error for details
|
|
||||||
const workflowError = errorStore.getComponentError("workflow");
|
|
||||||
console.log("Workflow failed:", workflowError?.message);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration with Vue Components
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In a Vue component
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
import { useNotificationStore } from "@/stores/notifications";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
setup() {
|
|
||||||
const errorStore = useErrorStore();
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
const submitForm = async (formData) => {
|
|
||||||
// Clear any previous errors
|
|
||||||
errorStore.clearComponentError("form");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await errorStore.withErrorHandling(
|
|
||||||
"form-submit",
|
|
||||||
async () => {
|
|
||||||
const result = await api.submitForm(formData);
|
|
||||||
notificationStore.addSuccess("Form submitted successfully!");
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
componentName: "form",
|
|
||||||
showNotification: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset form on success
|
|
||||||
resetForm();
|
|
||||||
} catch (error) {
|
|
||||||
// Error handling is automatic, but you can add custom logic here
|
|
||||||
console.log("Form submission failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
submitForm,
|
|
||||||
formError: computed(() => errorStore.getComponentError("form")),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Enhanced API
|
|
||||||
|
|
||||||
The errors store works seamlessly with the enhanced API wrapper:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { ApiWithErrorHandling } from "@/api-enhanced";
|
|
||||||
|
|
||||||
// The enhanced API automatically uses the error store
|
|
||||||
try {
|
|
||||||
const clients = await ApiWithErrorHandling.getPaginatedClientDetails(
|
|
||||||
pagination,
|
|
||||||
filters,
|
|
||||||
[],
|
|
||||||
{
|
|
||||||
componentName: "clients",
|
|
||||||
retryCount: 2,
|
|
||||||
showErrorNotifications: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// Error is automatically handled by the error store
|
|
||||||
// Check component error for details
|
|
||||||
const clientError = errorStore.getComponentError("clients");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Recovery Patterns
|
|
||||||
|
|
||||||
### Graceful Degradation
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const loadCriticalData = async () => {
|
|
||||||
let primaryData = null;
|
|
||||||
let fallbackData = null;
|
|
||||||
|
|
||||||
// Try primary data source
|
|
||||||
try {
|
|
||||||
primaryData = await errorStore.withErrorHandling(
|
|
||||||
"primary-data",
|
|
||||||
() => api.getPrimaryData(),
|
|
||||||
{ showNotification: false, rethrow: true },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Primary data failed, trying fallback...");
|
|
||||||
|
|
||||||
// Try fallback data source
|
|
||||||
try {
|
|
||||||
fallbackData = await errorStore.withErrorHandling(
|
|
||||||
"fallback-data",
|
|
||||||
() => api.getFallbackData(),
|
|
||||||
{ showNotification: false, rethrow: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
notificationStore.addWarning(
|
|
||||||
"Using cached data due to connectivity issues",
|
|
||||||
);
|
|
||||||
} catch (fallbackError) {
|
|
||||||
errorStore.setGlobalError("Unable to load data from any source");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return primaryData || fallbackData;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### User-Driven Error Recovery
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleApiError = async (operation) => {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
// Show error with recovery options
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: "error",
|
|
||||||
title: "Operation Failed",
|
|
||||||
message: "Would you like to try again or continue with cached data?",
|
|
||||||
persistent: true,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "Retry",
|
|
||||||
variant: "primary",
|
|
||||||
handler: () => handleApiError(operation), // Recursive retry
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Use Cached Data",
|
|
||||||
variant: "secondary",
|
|
||||||
handler: () => loadCachedData(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cancel",
|
|
||||||
variant: "secondary",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
throw error; // Let caller handle as needed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Debugging & Monitoring
|
|
||||||
|
|
||||||
### Error History Access
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Get all error history for debugging
|
|
||||||
const allErrors = errorStore.errorHistory;
|
|
||||||
|
|
||||||
// Get recent errors with details
|
|
||||||
const recent = errorStore.getRecentErrors(5);
|
|
||||||
recent.forEach((error) => {
|
|
||||||
console.log(`[${error.timestamp}] ${error.source}: ${error.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter errors by type
|
|
||||||
const apiErrors = errorStore.errorHistory.filter((e) => e.type === "api_error");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Reporting
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Send error reports to monitoring service
|
|
||||||
const reportErrors = () => {
|
|
||||||
const recentErrors = errorStore.getRecentErrors(10);
|
|
||||||
|
|
||||||
recentErrors.forEach((error) => {
|
|
||||||
if (error.type === "api_error" && error.status >= 500) {
|
|
||||||
// Report server errors
|
|
||||||
analyticsService.reportError({
|
|
||||||
message: error.message,
|
|
||||||
url: window.location.href,
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
timestamp: error.timestamp,
|
|
||||||
stack: error.stack,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Unit Testing
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { setActivePinia, createPinia } from "pinia";
|
|
||||||
import { useErrorStore } from "@/stores/errors";
|
|
||||||
|
|
||||||
describe("Errors Store", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets and clears global errors", () => {
|
|
||||||
const store = useErrorStore();
|
|
||||||
|
|
||||||
store.setGlobalError("Test error");
|
|
||||||
expect(store.hasError).toBe(true);
|
|
||||||
expect(store.lastError.message).toBe("Test error");
|
|
||||||
|
|
||||||
store.clearGlobalError();
|
|
||||||
expect(store.hasError).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles component errors", () => {
|
|
||||||
const store = useErrorStore();
|
|
||||||
|
|
||||||
store.setComponentError("form", new Error("Validation failed"));
|
|
||||||
expect(store.getComponentError("form").message).toBe("Validation failed");
|
|
||||||
|
|
||||||
store.clearComponentError("form");
|
|
||||||
expect(store.getComponentError("form")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks error history", () => {
|
|
||||||
const store = useErrorStore();
|
|
||||||
|
|
||||||
store.setGlobalError("Error 1");
|
|
||||||
store.setGlobalError("Error 2");
|
|
||||||
|
|
||||||
expect(store.errorHistory).toHaveLength(2);
|
|
||||||
expect(store.getRecentErrors(1)[0].message).toBe("Error 2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration Testing
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Test error handling with API calls
|
|
||||||
it("handles API errors correctly", async () => {
|
|
||||||
const store = useErrorStore();
|
|
||||||
const mockApi = jest.fn().mockRejectedValue(new Error("API Error"));
|
|
||||||
|
|
||||||
const result = await store.withErrorHandling("test-api", mockApi, {
|
|
||||||
componentName: "test",
|
|
||||||
showNotification: false,
|
|
||||||
rethrow: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(store.getComponentError("test").message).toBe("API Error");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Do's
|
|
||||||
|
|
||||||
- ✅ Use component-specific errors for UI validation
|
|
||||||
- ✅ Use API-specific errors for network operations
|
|
||||||
- ✅ Enable auto-notifications for user-facing errors
|
|
||||||
- ✅ Use retry logic for transient failures
|
|
||||||
- ✅ Clear errors when operations succeed
|
|
||||||
- ✅ Keep error messages user-friendly and actionable
|
|
||||||
|
|
||||||
### Don'ts
|
|
||||||
|
|
||||||
- ❌ Don't set global errors for minor validation issues
|
|
||||||
- ❌ Don't ignore error context (component/API source)
|
|
||||||
- ❌ Don't let error history grow indefinitely
|
|
||||||
- ❌ Don't show technical stack traces to end users
|
|
||||||
- ❌ Don't retry operations that will consistently fail
|
|
||||||
|
|
||||||
### Performance Considerations
|
|
||||||
|
|
||||||
- Error history is automatically trimmed to prevent memory leaks
|
|
||||||
- Use component-specific errors to isolate issues
|
|
||||||
- Clear errors promptly when no longer relevant
|
|
||||||
- Consider disabling auto-notifications for high-frequency operations
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Form Validation Errors
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const validateAndSubmit = async (formData) => {
|
|
||||||
errorStore.clearComponentError("form");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await errorStore.withErrorHandling(
|
|
||||||
"form-validation",
|
|
||||||
async () => {
|
|
||||||
validateFormData(formData);
|
|
||||||
await submitForm(formData);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
componentName: "form",
|
|
||||||
showNotification: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// Validation errors are now stored in component error
|
|
||||||
// and automatically displayed to user
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Background Task Monitoring
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const monitorBackgroundTask = async (taskId) => {
|
|
||||||
await errorStore.handleApiCall(
|
|
||||||
`task-${taskId}`,
|
|
||||||
async () => {
|
|
||||||
const status = await api.getTaskStatus(taskId);
|
|
||||||
|
|
||||||
if (status.failed) {
|
|
||||||
throw new Error(`Task failed: ${status.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return status;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retryCount: 5,
|
|
||||||
retryDelay: 2000,
|
|
||||||
showNotification: status.failed, // Only notify on final failure
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@ -1,609 +0,0 @@
|
|||||||
# Notifications Store
|
|
||||||
|
|
||||||
The notifications store provides centralized state management for application-wide notifications. It handles the creation, management, and lifecycle of toast-style notifications with support for multiple types, positions, and interactive actions.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
- **Location**: `src/stores/notifications.js`
|
|
||||||
- **Type**: Pinia Store
|
|
||||||
- **Purpose**: Global notification state management
|
|
||||||
- **Integration**: Used by NotificationDisplay component
|
|
||||||
|
|
||||||
## Installation & Setup
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Import in your component
|
|
||||||
import { useNotificationStore } from "@/stores/notifications";
|
|
||||||
|
|
||||||
// Use in component
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Structure
|
|
||||||
|
|
||||||
### Core State Properties
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
state: {
|
|
||||||
notifications: [], // Array of active notifications
|
|
||||||
defaultDuration: 4000, // Default auto-dismiss time (ms)
|
|
||||||
maxNotifications: 5, // Maximum concurrent notifications
|
|
||||||
position: 'top-right', // Default position
|
|
||||||
nextId: 1 // Auto-incrementing ID counter
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Notification Object Structure
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
id: 1, // Unique identifier
|
|
||||||
type: 'success', // 'success' | 'error' | 'warning' | 'info'
|
|
||||||
title: 'Operation Complete', // Notification title
|
|
||||||
message: 'Data saved successfully', // Main message
|
|
||||||
duration: 4000, // Auto-dismiss time (0 = no auto-dismiss)
|
|
||||||
persistent: false, // If true, won't auto-dismiss
|
|
||||||
actions: [], // Array of action buttons
|
|
||||||
data: null, // Additional data for handlers
|
|
||||||
timestamp: '2025-11-12T10:30:00Z', // Creation timestamp
|
|
||||||
dismissed: false, // Whether notification is dismissed
|
|
||||||
seen: false // Whether user has interacted with it
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getters
|
|
||||||
|
|
||||||
### `getNotificationsByType(type)`
|
|
||||||
|
|
||||||
Get all notifications of a specific type.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const errorNotifications = notificationStore.getNotificationsByType("error");
|
|
||||||
```
|
|
||||||
|
|
||||||
### `activeNotifications`
|
|
||||||
|
|
||||||
Get all non-dismissed notifications.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const active = notificationStore.activeNotifications;
|
|
||||||
```
|
|
||||||
|
|
||||||
### `activeCount`
|
|
||||||
|
|
||||||
Get count of active notifications.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const count = notificationStore.activeCount;
|
|
||||||
```
|
|
||||||
|
|
||||||
### `hasErrorNotifications`
|
|
||||||
|
|
||||||
Check if there are any active error notifications.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const hasErrors = notificationStore.hasErrorNotifications;
|
|
||||||
```
|
|
||||||
|
|
||||||
### `hasSuccessNotifications`
|
|
||||||
|
|
||||||
Check if there are any active success notifications.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const hasSuccess = notificationStore.hasSuccessNotifications;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Actions
|
|
||||||
|
|
||||||
### Core Notification Methods
|
|
||||||
|
|
||||||
#### `addNotification(notification)`
|
|
||||||
|
|
||||||
Add a new notification with full configuration options.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const notificationId = notificationStore.addNotification({
|
|
||||||
type: "warning",
|
|
||||||
title: "Confirm Action",
|
|
||||||
message: "This will permanently delete the item.",
|
|
||||||
persistent: true,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
variant: "danger",
|
|
||||||
handler: () => performDelete(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cancel",
|
|
||||||
variant: "secondary",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
data: { itemId: 123 },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Convenience Methods
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Quick success notification
|
|
||||||
notificationStore.addSuccess("Operation completed!");
|
|
||||||
notificationStore.addSuccess("Custom message", "Custom Title", {
|
|
||||||
duration: 6000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Quick error notification
|
|
||||||
notificationStore.addError("Something went wrong!");
|
|
||||||
notificationStore.addError("Custom error", "Error Title", { persistent: true });
|
|
||||||
|
|
||||||
// Quick warning notification
|
|
||||||
notificationStore.addWarning("Please confirm this action");
|
|
||||||
|
|
||||||
// Quick info notification
|
|
||||||
notificationStore.addInfo("New feature available");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Notification Management
|
|
||||||
|
|
||||||
#### `dismissNotification(id)`
|
|
||||||
|
|
||||||
Mark a notification as dismissed (hides it but keeps in history).
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.dismissNotification(notificationId);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `removeNotification(id)`
|
|
||||||
|
|
||||||
Completely remove a notification from the store.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.removeNotification(notificationId);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `markAsSeen(id)`
|
|
||||||
|
|
||||||
Mark a notification as seen (user has interacted with it).
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.markAsSeen(notificationId);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `updateNotification(id, updates)`
|
|
||||||
|
|
||||||
Update an existing notification's properties.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.updateNotification(notificationId, {
|
|
||||||
type: "success",
|
|
||||||
message: "Updated message",
|
|
||||||
persistent: false,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bulk Operations
|
|
||||||
|
|
||||||
#### `clearType(type)`
|
|
||||||
|
|
||||||
Remove all notifications of a specific type.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.clearType("error"); // Remove all error notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `clearAll()`
|
|
||||||
|
|
||||||
Remove all notifications.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.clearAll();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `clearDismissed()`
|
|
||||||
|
|
||||||
Remove all dismissed notifications from history.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.clearDismissed();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading Notifications
|
|
||||||
|
|
||||||
#### `showLoadingNotification(message, title)`
|
|
||||||
|
|
||||||
Show a persistent loading notification that can be updated later.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const loadingId = notificationStore.showLoadingNotification(
|
|
||||||
"Uploading file...",
|
|
||||||
"Please Wait",
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `updateToSuccess(id, message, title)`
|
|
||||||
|
|
||||||
Update a loading notification to success and auto-dismiss.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.updateToSuccess(
|
|
||||||
loadingId,
|
|
||||||
"File uploaded successfully!",
|
|
||||||
"Upload Complete",
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `updateToError(id, message, title)`
|
|
||||||
|
|
||||||
Update a loading notification to error and auto-dismiss.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.updateToError(
|
|
||||||
loadingId,
|
|
||||||
"Upload failed. Please try again.",
|
|
||||||
"Upload Failed",
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Integration Helpers
|
|
||||||
|
|
||||||
#### `showApiSuccess(operation, customMessage)`
|
|
||||||
|
|
||||||
Show standardized success notifications for API operations.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Uses default messages based on operation
|
|
||||||
notificationStore.showApiSuccess("create"); // "Item created successfully"
|
|
||||||
notificationStore.showApiSuccess("update"); // "Item updated successfully"
|
|
||||||
notificationStore.showApiSuccess("delete"); // "Item deleted successfully"
|
|
||||||
notificationStore.showApiSuccess("fetch"); // "Data loaded successfully"
|
|
||||||
|
|
||||||
// With custom message
|
|
||||||
notificationStore.showApiSuccess("create", "New client added successfully!");
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `showApiError(operation, error, customMessage)`
|
|
||||||
|
|
||||||
Show standardized error notifications for API operations.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Automatically extracts error message from different error formats
|
|
||||||
notificationStore.showApiError("create", apiError);
|
|
||||||
notificationStore.showApiError("update", "Network timeout occurred");
|
|
||||||
notificationStore.showApiError("delete", errorObject, "Custom error message");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Methods
|
|
||||||
|
|
||||||
#### `setPosition(position)`
|
|
||||||
|
|
||||||
Set the global notification position.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Available positions:
|
|
||||||
notificationStore.setPosition("top-right"); // Default
|
|
||||||
notificationStore.setPosition("top-left");
|
|
||||||
notificationStore.setPosition("top-center");
|
|
||||||
notificationStore.setPosition("bottom-right");
|
|
||||||
notificationStore.setPosition("bottom-left");
|
|
||||||
notificationStore.setPosition("bottom-center");
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `setDefaultDuration(duration)`
|
|
||||||
|
|
||||||
Set the default auto-dismiss duration (milliseconds).
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.setDefaultDuration(5000); // 5 seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `setMaxNotifications(max)`
|
|
||||||
|
|
||||||
Set maximum number of concurrent notifications.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.setMaxNotifications(3);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Workflow Helper
|
|
||||||
|
|
||||||
#### `withNotifications(operation, asyncFunction, options)`
|
|
||||||
|
|
||||||
Wrap an async operation with automatic loading/success/error notifications.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const result = await notificationStore.withNotifications(
|
|
||||||
"save",
|
|
||||||
async () => {
|
|
||||||
return await saveDataToApi();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loadingMessage: "Saving changes...",
|
|
||||||
successMessage: "Changes saved successfully!",
|
|
||||||
errorMessage: null, // Use default error handling
|
|
||||||
showLoading: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Action Button Configuration
|
|
||||||
|
|
||||||
### Action Object Structure
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
label: 'Button Text', // Required: Button label
|
|
||||||
variant: 'primary', // Optional: 'primary' | 'danger' | 'secondary'
|
|
||||||
icon: 'mdi mdi-check', // Optional: Icon class
|
|
||||||
handler: (notification) => {}, // Optional: Click handler function
|
|
||||||
dismissAfter: true // Optional: Auto-dismiss after click (default: true)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Action Examples
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: "warning",
|
|
||||||
title: "Unsaved Changes",
|
|
||||||
message: "You have unsaved changes. What would you like to do?",
|
|
||||||
persistent: true,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "Save",
|
|
||||||
variant: "primary",
|
|
||||||
icon: "mdi mdi-content-save",
|
|
||||||
handler: (notification) => {
|
|
||||||
saveChanges();
|
|
||||||
// Access notification data if needed
|
|
||||||
console.log("Saving from notification:", notification.data);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Discard",
|
|
||||||
variant: "danger",
|
|
||||||
icon: "mdi mdi-delete",
|
|
||||||
handler: () => {
|
|
||||||
discardChanges();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Keep Editing",
|
|
||||||
variant: "secondary",
|
|
||||||
dismissAfter: false, // Don't dismiss, let user continue editing
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Patterns
|
|
||||||
|
|
||||||
### Basic Notifications
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
// Simple success
|
|
||||||
notificationStore.addSuccess("Data saved successfully!");
|
|
||||||
|
|
||||||
// Simple error
|
|
||||||
notificationStore.addError("Failed to connect to server");
|
|
||||||
|
|
||||||
// Custom duration
|
|
||||||
notificationStore.addWarning("Session expires in 5 minutes", "Warning", {
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Operation Notifications
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Method 1: Manual handling
|
|
||||||
try {
|
|
||||||
await apiCall();
|
|
||||||
notificationStore.addSuccess("Operation completed successfully!");
|
|
||||||
} catch (error) {
|
|
||||||
notificationStore.addError(error.message, "Operation Failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Using API helpers
|
|
||||||
try {
|
|
||||||
await apiCall();
|
|
||||||
notificationStore.showApiSuccess("create");
|
|
||||||
} catch (error) {
|
|
||||||
notificationStore.showApiError("create", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Using workflow helper
|
|
||||||
await notificationStore.withNotifications("create", async () => {
|
|
||||||
return await apiCall();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading States
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Show loading notification
|
|
||||||
const loadingId = notificationStore.showLoadingNotification("Processing...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await longRunningOperation();
|
|
||||||
notificationStore.updateToSuccess(loadingId, "Operation completed!");
|
|
||||||
} catch (error) {
|
|
||||||
notificationStore.updateToError(loadingId, "Operation failed");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interactive Notifications
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Confirmation dialog
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: "warning",
|
|
||||||
title: "Delete Confirmation",
|
|
||||||
message: `Are you sure you want to delete "${itemName}"?`,
|
|
||||||
persistent: true,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "Yes, Delete",
|
|
||||||
variant: "danger",
|
|
||||||
handler: async () => {
|
|
||||||
const deleteId =
|
|
||||||
notificationStore.showLoadingNotification("Deleting...");
|
|
||||||
try {
|
|
||||||
await deleteItem(itemId);
|
|
||||||
notificationStore.updateToSuccess(
|
|
||||||
deleteId,
|
|
||||||
"Item deleted successfully",
|
|
||||||
);
|
|
||||||
refreshData();
|
|
||||||
} catch (error) {
|
|
||||||
notificationStore.updateToError(deleteId, "Failed to delete item");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cancel",
|
|
||||||
variant: "secondary",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Multi-step workflow
|
|
||||||
notificationStore.addNotification({
|
|
||||||
type: "info",
|
|
||||||
title: "Export Complete",
|
|
||||||
message: "Your data has been exported successfully.",
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: "Download",
|
|
||||||
variant: "primary",
|
|
||||||
icon: "mdi mdi-download",
|
|
||||||
handler: () => downloadFile(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Email",
|
|
||||||
variant: "secondary",
|
|
||||||
icon: "mdi mdi-email",
|
|
||||||
handler: () => emailFile(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "View",
|
|
||||||
variant: "secondary",
|
|
||||||
icon: "mdi mdi-eye",
|
|
||||||
handler: () => viewFile(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Vue Components
|
|
||||||
|
|
||||||
### In Composition API
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useNotificationStore } from "@/stores/notifications";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
setup() {
|
|
||||||
const notificationStore = useNotificationStore();
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await submitForm();
|
|
||||||
notificationStore.addSuccess("Form submitted successfully!");
|
|
||||||
} catch (error) {
|
|
||||||
notificationStore.addError("Failed to submit form", "Submission Error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleSubmit,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### In Options API
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { mapActions, mapGetters } from "pinia";
|
|
||||||
import { useNotificationStore } from "@/stores/notifications";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
computed: {
|
|
||||||
...mapGetters(useNotificationStore, [
|
|
||||||
"activeCount",
|
|
||||||
"hasErrorNotifications",
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
...mapActions(useNotificationStore, ["addSuccess", "addError", "clearAll"]),
|
|
||||||
|
|
||||||
async saveData() {
|
|
||||||
try {
|
|
||||||
await this.apiCall();
|
|
||||||
this.addSuccess("Data saved!");
|
|
||||||
} catch (error) {
|
|
||||||
this.addError(error.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Do's
|
|
||||||
|
|
||||||
- ✅ Use appropriate notification types for different scenarios
|
|
||||||
- ✅ Keep messages concise and actionable
|
|
||||||
- ✅ Use loading notifications for long-running operations
|
|
||||||
- ✅ Provide clear action buttons for next steps
|
|
||||||
- ✅ Set appropriate durations (longer for errors, shorter for success)
|
|
||||||
- ✅ Use persistent notifications for critical actions requiring user input
|
|
||||||
|
|
||||||
### Don'ts
|
|
||||||
|
|
||||||
- ❌ Don't overwhelm users with too many notifications
|
|
||||||
- ❌ Don't use persistent notifications for simple confirmations
|
|
||||||
- ❌ Don't make notification messages too long
|
|
||||||
- ❌ Don't forget to handle loading state cleanup
|
|
||||||
- ❌ Don't use success notifications for every small action
|
|
||||||
|
|
||||||
### Performance Considerations
|
|
||||||
|
|
||||||
- The store automatically limits concurrent notifications
|
|
||||||
- Dismissed notifications are kept in history for debugging
|
|
||||||
- Use `clearDismissed()` periodically to prevent memory leaks
|
|
||||||
- Action handlers should be lightweight to avoid blocking the UI
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Unit Testing
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { setActivePinia, createPinia } from "pinia";
|
|
||||||
import { useNotificationStore } from "@/stores/notifications";
|
|
||||||
|
|
||||||
describe("Notifications Store", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds notifications correctly", () => {
|
|
||||||
const store = useNotificationStore();
|
|
||||||
const id = store.addSuccess("Test message");
|
|
||||||
|
|
||||||
expect(store.activeNotifications).toHaveLength(1);
|
|
||||||
expect(store.activeNotifications[0].message).toBe("Test message");
|
|
||||||
expect(store.activeNotifications[0].type).toBe("success");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dismisses notifications", () => {
|
|
||||||
const store = useNotificationStore();
|
|
||||||
const id = store.addInfo("Test");
|
|
||||||
|
|
||||||
store.dismissNotification(id);
|
|
||||||
expect(store.activeNotifications).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
# Using the Theme Store
|
|
||||||
|
|
||||||
This guide shows how to read and react to the company theme in Vue components.
|
|
||||||
|
|
||||||
## Imports
|
|
||||||
```js
|
|
||||||
import { useThemeStore } from "@/stores/theme";
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reading theme tokens
|
|
||||||
```js
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
// Access current theme object
|
|
||||||
console.log(themeStore.currentTheme.primary);
|
|
||||||
```
|
|
||||||
|
|
||||||
Theme tokens exposed as CSS variables (runtime-applied to `:root`):
|
|
||||||
- `--theme-primary`: main brand color
|
|
||||||
- `--theme-primary-strong`: deeper shade of primary for borders/active states
|
|
||||||
- `--theme-secondary`: secondary accent color
|
|
||||||
- `--theme-accent`: softer accent/highlight
|
|
||||||
- `--theme-gradient-start` / `--theme-gradient-end`: primary gradient stops
|
|
||||||
- `--theme-secondary-gradient-start` / `--theme-secondary-gradient-end`: secondary gradient stops
|
|
||||||
- `--theme-surface`: default card/background surface
|
|
||||||
- `--theme-surface-alt`: alternate surface
|
|
||||||
- `--theme-border`: standard border color
|
|
||||||
- `--theme-hover`: hover surface color
|
|
||||||
- `--theme-text`: primary text color
|
|
||||||
- `--theme-text-muted`: muted/secondary text
|
|
||||||
- `--theme-text-dark`: dark text color (use on light surfaces)
|
|
||||||
- `--theme-text-light`: light text color (use on dark/gradient surfaces)
|
|
||||||
- Backward-compat (mapped to the above): `--primary-color`, `--primary-600`, `--surface-card`, `--surface-border`, `--surface-hover`, `--text-color`
|
|
||||||
|
|
||||||
## Applying theme in components
|
|
||||||
### Option 1: Use CSS vars in `<style>`
|
|
||||||
```vue
|
|
||||||
<style scoped>
|
|
||||||
.button-primary {
|
|
||||||
background: var(--theme-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.card-surface {
|
|
||||||
background: var(--surface-card);
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
}
|
|
||||||
.banner-gradient {
|
|
||||||
background: linear-gradient(135deg, var(--theme-gradient-start), var(--theme-gradient-end));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Use reactive values in script
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
import { useThemeStore } from "@/stores/theme";
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const styles = computed(() => ({
|
|
||||||
background: `linear-gradient(135deg, ${themeStore.currentTheme.primaryGradientStart}, ${themeStore.currentTheme.primaryGradientEnd})`,
|
|
||||||
color: themeStore.currentTheme.text,
|
|
||||||
}));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :style="styles">Themed box</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reacting to company changes
|
|
||||||
The app already applies themes on company change. If you need side-effects in a component:
|
|
||||||
```js
|
|
||||||
watch(() => themeStore.currentTheme, (t) => {
|
|
||||||
console.log("theme changed", t.primary);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a new company theme
|
|
||||||
1) Open `src/stores/theme.js` and add a new entry to `themeMap` with all keys: `primary`, `primaryStrong`, `secondary`, `accent`, `primaryGradientStart/End`, `secondaryGradientStart/End`, `surface`, `surfaceAlt`, `border`, `hover`, `text`, `textMuted`.
|
|
||||||
2) No further changes are needed; selecting that company will apply the theme.
|
|
||||||
|
|
||||||
## Quick reference for gradients
|
|
||||||
- Primary gradient: `--theme-gradient-start` → `--theme-gradient-end`
|
|
||||||
- Secondary gradient: `--theme-secondary-gradient-start` → `--theme-secondary-gradient-end`
|
|
||||||
|
|
||||||
Use primary for main brand moments; secondary for supporting UI accents.
|
|
||||||
858
frontend/package-lock.json
generated
858
frontend/package-lock.json
generated
@ -9,19 +9,12 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconoir/vue": "^7.11.0",
|
"@iconoir/vue": "^7.11.0",
|
||||||
"@mdi/font": "^7.4.47",
|
|
||||||
"@primeuix/themes": "^1.2.5",
|
"@primeuix/themes": "^1.2.5",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"chart.js": "^4.5.1",
|
|
||||||
"frappe-ui": "^0.1.205",
|
"frappe-ui": "^0.1.205",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"primeicons": "^7.0.0",
|
|
||||||
"primevue": "^4.4.1",
|
"primevue": "^4.4.1",
|
||||||
"sass-embedded": "^1.96.0",
|
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-chartjs": "^5.3.3",
|
|
||||||
"vue-leaflet": "^0.1.0",
|
|
||||||
"vue-router": "^4.6.3",
|
"vue-router": "^4.6.3",
|
||||||
"vuetify": "^3.10.7"
|
"vuetify": "^3.10.7"
|
||||||
},
|
},
|
||||||
@ -98,12 +91,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@bufbuild/protobuf": {
|
|
||||||
"version": "2.10.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.1.tgz",
|
|
||||||
"integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==",
|
|
||||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.11",
|
"version": "0.25.11",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
|
||||||
@ -750,314 +737,6 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@kurkle/color": {
|
|
||||||
"version": "0.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
|
||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@mdi/font": {
|
|
||||||
"version": "7.4.47",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
|
|
||||||
"integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^1.0.3",
|
|
||||||
"is-glob": "^4.0.3",
|
|
||||||
"micromatch": "^4.0.5",
|
|
||||||
"node-addon-api": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@parcel/watcher-android-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
|
||||||
"@parcel/watcher-freebsd-x64": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-ia32": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-x64": "2.5.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-android-arm64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-darwin-x64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-win32-arm64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-win32-ia32": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-win32-x64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@ -2636,12 +2315,6 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-builder": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==",
|
|
||||||
"license": "MIT/X11"
|
|
||||||
},
|
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@ -2671,18 +2344,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chart.js": {
|
|
||||||
"version": "4.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@kurkle/color": "^0.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"pnpm": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@ -2764,12 +2425,6 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/colorjs.io": {
|
|
||||||
"version": "0.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
|
|
||||||
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@ -2897,19 +2552,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
|
||||||
"detect-libc": "bin/detect-libc.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/devlop": {
|
"node_modules/devlop": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||||
@ -3443,12 +3085,6 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/immutable": {
|
|
||||||
"version": "5.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
|
||||||
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@ -3551,12 +3187,6 @@
|
|||||||
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
|
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/leaflet": {
|
|
||||||
"version": "1.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/linkify-it": {
|
"node_modules/linkify-it": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
@ -3679,33 +3309,6 @@
|
|||||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/micromatch": {
|
|
||||||
"version": "4.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
|
||||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"braces": "^3.0.3",
|
|
||||||
"picomatch": "^2.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromatch/node_modules/picomatch": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
@ -3804,13 +3407,6 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-addon-api": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@ -4003,12 +3599,6 @@
|
|||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/primeicons": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/primevue": {
|
"node_modules/primevue": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.4.1.tgz",
|
||||||
@ -4459,15 +4049,6 @@
|
|||||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rxjs": {
|
|
||||||
"version": "7.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@ -4488,402 +4069,6 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sass": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-8u4xqqUeugGNCYwr9ARNtQKTOj4KmYiJAVKXf2CTIivTCR51j96htbMKWDru8H5SaQWpyVgTfOF8Ylyf5pun1Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"chokidar": "^4.0.0",
|
|
||||||
"immutable": "^5.0.2",
|
|
||||||
"source-map-js": ">=0.6.2 <2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"sass": "sass.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@parcel/watcher": "^2.4.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-z9PQ7owvdhn7UuZGrpPccdkcH9xJd9iCv+UQhcPqppBslYEp0R9LRQVyyPTZg7jfA77bGxz/I8V48LXJR5LjXQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@bufbuild/protobuf": "^2.5.0",
|
|
||||||
"buffer-builder": "^0.2.0",
|
|
||||||
"colorjs.io": "^0.5.0",
|
|
||||||
"immutable": "^5.0.2",
|
|
||||||
"rxjs": "^7.4.0",
|
|
||||||
"supports-color": "^8.1.1",
|
|
||||||
"sync-child-process": "^1.0.2",
|
|
||||||
"varint": "^6.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"sass": "dist/bin/sass.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"sass-embedded-all-unknown": "1.96.0",
|
|
||||||
"sass-embedded-android-arm": "1.96.0",
|
|
||||||
"sass-embedded-android-arm64": "1.96.0",
|
|
||||||
"sass-embedded-android-riscv64": "1.96.0",
|
|
||||||
"sass-embedded-android-x64": "1.96.0",
|
|
||||||
"sass-embedded-darwin-arm64": "1.96.0",
|
|
||||||
"sass-embedded-darwin-x64": "1.96.0",
|
|
||||||
"sass-embedded-linux-arm": "1.96.0",
|
|
||||||
"sass-embedded-linux-arm64": "1.96.0",
|
|
||||||
"sass-embedded-linux-musl-arm": "1.96.0",
|
|
||||||
"sass-embedded-linux-musl-arm64": "1.96.0",
|
|
||||||
"sass-embedded-linux-musl-riscv64": "1.96.0",
|
|
||||||
"sass-embedded-linux-musl-x64": "1.96.0",
|
|
||||||
"sass-embedded-linux-riscv64": "1.96.0",
|
|
||||||
"sass-embedded-linux-x64": "1.96.0",
|
|
||||||
"sass-embedded-unknown-all": "1.96.0",
|
|
||||||
"sass-embedded-win32-arm64": "1.96.0",
|
|
||||||
"sass-embedded-win32-x64": "1.96.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-all-unknown": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-UfUHoWZtxmsDjDfK+fKCy0aJe6zThu7oaIQx0c/vnHgvprcddEPIay01qTXhiUa3cFcsMmvlBvPTVw0gjKVtVQ==",
|
|
||||||
"cpu": [
|
|
||||||
"!arm",
|
|
||||||
"!arm64",
|
|
||||||
"!riscv64",
|
|
||||||
"!x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"sass": "1.96.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-android-arm": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-0mwVRBFig9hH8vFcRExBuBoR+CfUOcWdwarZwbxIFGI1IyH4BLBGiX85vVn6ssSCVNydpE6lFGm45CN8O0tQig==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-android-arm64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-TJiebTo4TBF5Wrn+lFkUfSN3wazvl8kkFm9a1nA9ZtRdaE0nsJLGnMM6KLQLP2Vl+IOf6ovetZseISkClRoGXw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-android-riscv64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-7AVu/EeJqKN3BGNhm+tc1XzmoqbOtCwHG2VgN6j6Lyqh1JZlx0dglRtyQuKDZ7odTKiWmotEIuYZ6OxLmr2Ejg==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-android-x64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-ei/UsT0q8rF5JzWhn1A7B0M1y/IiWVY3l4zibQrXk5MGaOXHlCM6ffZD+2j7C613Jm9/KAQ7yX1NIIu72LPgDQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-darwin-arm64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-OMvN5NWcrrisC24ZR3GyaWJ1uFxw25qLnUkpEso9TSlaMWiomjU82/uQ/AkQvIMl+EMlJqeYLxZWvq/byLH5Xg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-darwin-x64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-J/R5sv0eW+/DU98rccHPO1f3lsTFjVTpdkU9d3P1yB7BFmQjw5PYde9BVRlXeOawPwfgT3p/hvY4RELScICdww==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-linux-arm": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-XuQvV6gNld5Bz3rX0SFLtKPGMu4UQdXNp//9A+bDmtVGZ6yu8REIqphQBxOMpgkAKsA4JZLKKk1N97woeVsIlA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-linux-arm64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-VcbVjK0/O/mru0h0FC1WSUWIzMqRrzuJ8eZNMXTs4vApfkh28pxNaUodwU81f1L1nngJ3vpFDBniUKpW6NwJhw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-linux-musl-arm": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-qK7FrnczCVECZXtyYOoI3azFlMDZn70GI1yJPPuZLpWvwIPYoZOLv3u6JSec5o3wT6KeKyWG3ZpGIpigLUjPig==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-linux-musl-arm64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-lVyLObEeu8Wgw8riC6dSMlkF7jVNAjdZ1jIBhvX1yDsrQwwaI60pM21YXmnZSFyCE6KVFkKAgwRQNO/IkoCwMA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-linux-musl-riscv64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-Y+DuGVRsM2zGl268QN5aF/Y6OFYTILb3f+6huEXKlGL6FK2MXadsmeoVbmKVrTamQHzyA2bWWMU1C0jhVFtlzg==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-linux-musl-x64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-sAQtUQ8fFNxnxSf3fncOh892Hfxa4PW4e5qrnSE0Y1IGV/wsTzk7m5Z6IeT7sa3BsvXh5TFN6+JGbUoOJ5RigA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-linux-riscv64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-Bf6bAjuUm6sfGHo0XoZEstjVkEWwmmtOSomGoPuAwXFS9GQnFcqDz9EXKNkZEOsQi2D+aDeDxs8HcU9/OLMT9g==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-linux-x64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-U4GROkS0XM6ekqs/ubroWwFAGY9N35wqrt5q6Y+MJCpTK5bHPHlgFo7J75ZUSaEObL+UrDqvMDQkCdYEFiiQbg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-unknown-all": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-OHzGEr2VElK2SaQdkkTX0O0KwTbiv1N/EhnHgzXYaZWOTvv0gxEfR7q7x/oScCBIZc2x8dSfvThfBnohIClo/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"!android",
|
|
||||||
"!darwin",
|
|
||||||
"!linux",
|
|
||||||
"!win32"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"sass": "1.96.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-win32-arm64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-KKz1h5pr45fwrKcxrxHsujo3f/HgVkX64YNJ9PRPuOuX7lU8g18IEgDxoTGQ64PPBQ5RXOt6jxpT+x2OLPVnCw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded-win32-x64": {
|
|
||||||
"version": "1.96.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.96.0.tgz",
|
|
||||||
"integrity": "sha512-MDreKaWcgiyKD5YPShaRvUBoe5dC2y8IPJK49G7iQjoMfw9INDCBkDdLcz00Mn0eJq4nJJp5UEE98M6ljIrBRg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass-embedded/node_modules/supports-color": {
|
|
||||||
"version": "8.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
|
||||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-flag": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass/node_modules/chokidar": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"readdirp": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.16.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass/node_modules/readdirp": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.18.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/scule": {
|
"node_modules/scule": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||||
@ -4999,27 +4184,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sync-child-process": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"sync-message-port": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sync-message-port": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.14",
|
"version": "4.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
|
||||||
@ -5329,12 +4493,6 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/varint": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.10",
|
"version": "7.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
|
||||||
@ -5431,22 +4589,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-chartjs": {
|
|
||||||
"version": "5.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
|
|
||||||
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"chart.js": "^4.1.1",
|
|
||||||
"vue": "^3.0.0-0 || ^2.7.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vue-leaflet": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-leaflet/-/vue-leaflet-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-J2QxmQSbmnpM/Ng+C8vxowXcWp/IEe99r87psHyWYpBz2nbxkQAeYXW7WFcgzV4O7d7Vm4a1GcqKzrU9DeDpBA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.3",
|
"version": "4.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user