Compare commits

..

No commits in common. "main" and "ben-demo" have entirely different histories.

114 changed files with 218 additions and 33672 deletions

2
.gitignore vendored
View File

@ -5,8 +5,6 @@
tags
node_modules
__pycache__
venv/
.venv/
*dist/
.vscode/

View File

@ -1,125 +0,0 @@
import frappe
from custom_ui.db_utils import build_error_response, build_success_response
@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 = frappe.get_doc("Address", {"full_address": full_address}).as_dict()
customer_exists = frappe.db.exists("Customer", address.get("custom_customer_to_bill"))
doctype = "Customer" if customer_exists else "Lead"
name = ""
if doctype == "Customer":
name = address.get("custom_customer_to_bill")
else:
## filter through links for one with doctype Lead
lead_links = address.get("links", [])
print(f"DEBUG: lead_links: {lead_links}")
lead_name = [link.link_name for link in lead_links if link.link_doctype == "Lead"]
name = lead_name[0] if lead_name else ""
address["customer"] = frappe.get_doc(doctype, name).as_dict()
contacts = []
for contact_link in address.custom_linked_contacts:
contact_doc = frappe.get_doc("Contact", contact_link.contact)
contacts.append(contact_doc.as_dict())
address["contacts"] = contacts
return build_success_response(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 = frappe.get_doc("Address", address_name)
return build_success_response(address.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_contacts_for_address(address_name):
"""Get contacts linked to a specific address."""
try:
address = frappe.get_doc("Address", 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 get_addresses(fields=["*"], filters={}):
"""Get addresses with optional filtering."""
if isinstance(fields, str):
import json
fields = json.loads(fields)
if isinstance(filters, str):
import json
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 create_address(address_data):
"""Create a new address."""
address = frappe.get_doc({
"doctype": "Address",
**address_data
})
address.insert(ignore_permissions=True)
return address
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)

View File

@ -1,144 +0,0 @@
import frappe
import json
from custom_ui.db_utils import build_error_response, build_success_response, process_filters, process_sorting
@frappe.whitelist()
def get_week_bid_meetings(week_start, week_end):
"""Get On-Site Meetings scheduled within a specific week."""
try:
meetings = frappe.db.get_all(
"On-Site Meeting",
fields=["*"],
filters=[
["start_time", ">=", week_start],
["start_time", "<=", week_end]
],
order_by="start_time asc"
)
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 Week On-Site Meetings Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_bid_meetings(fields=["*"], filters={}):
"""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_unscheduled_bid_meetings():
"""Get On-Site Meetings that are unscheduled."""
try:
meetings = frappe.db.get_all(
"On-Site Meeting",
fields=["*"],
filters={"status": "Unscheduled"},
order_by="creation desc"
)
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 create_bid_meeting(address, notes=""):
"""Create a new On-Site Meeting with Unscheduled status."""
try:
print(f"DEBUG: Creating meeting with address='{address}', notes='{notes}'")
# Validate address parameter
if not address or address == "None" or not address.strip():
return build_error_response("Address is required and cannot be empty.", 400)
# Get the address document name from the full address string
address_name = frappe.db.get_value("Address", filters={"full_address": address}, fieldname="name")
print(f"DEBUG: Address lookup result: address_name='{address_name}'")
if not address_name:
return build_error_response(f"Address '{address}' not found in the system.", 404)
# Create the meeting with Unscheduled status
meeting = frappe.get_doc({
"doctype": "On-Site Meeting",
"address": address_name,
"notes": notes or "",
"status": "Unscheduled"
})
meeting.flags.ignore_permissions = True
meeting.insert(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."""
defualts = {
"address": None,
"start_time": None,
"end_time": None,
"notes": None,
"assigned_employee": None,
"completed_by": None
}
try:
if isinstance(data, str):
data = json.loads(data)
# Ensure we always have the expected keys so fields can be cleared
data = {**defualts, **(data or {})}
meeting = frappe.get_doc("On-Site Meeting", name)
for key, value in data.items():
# Allow explicitly clearing date/time and assignment fields
if key in ["start_time", "end_time", "assigned_employee", "completed_by"] and value is None:
meeting.set(key, None)
continue
if value is not None:
if key == "address":
value = frappe.db.get_value("Address", {"full_address": value}, "name")
elif key in ["assigned_employee", "completed_by"]:
value = frappe.db.get_value("Employee", {"employee_name": value}, "name")
meeting.set(key, value)
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)

View File

@ -1,389 +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
from erpnext.crm.doctype.lead.lead import make_customer
from custom_ui.api.db.addresses import address_exists, create_address, create_address_links
from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links
# ===============================================================================
# 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": [], "sales_orders": [], "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":
clientData["addresses"] = customer.custom_select_address or []
clientData["contacts"] = customer.custom_add_contacts or []
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_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 = False
tableRow = {}
links = address.links
customer_links = [link for link in links if link.link_doctype == "Customer"] if links else None
customer_name = address.get("custom_customer_to_bill", None)
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:
print("DEBUG: No customer links found and no customer to bill.")
customer_name = "N/A"
elif not customer_name and customer_links:
print("DEBUG: No customer to bill. Customer links found:", customer_links)
customer_name = frappe.get_value("Lead", customer_links[0].link_name, "lead_name") if is_lead else customer_links[0].link_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']}"
)
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 upsert_client(data):
"""Create or update a client (customer and address)."""
try:
data = json.loads(data)
print("#####DEBUG: Upsert client data received:", data)
if address_exists(
data.get("address_line1"),
data.get("address_line2"),
data.get("city"),
data.get("state"),
data.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: Checking for existing customer with name:", data.get("customer_name"))
customer = frappe.db.exists("Customer", {"customer_name": data.get("customer_name")})
if not customer:
print("#####DEBUG: No existing customer found. Checking for existing lead")
customer = frappe.db.exists("Lead", {"lead_name": data.get("customer_name")})
if not customer:
print("#####DEBUG: No existing lead found. Creating new lead.")
primary_contact = next((c for c in data.get("contacts", []) if c.get("is_primary")), None)
if not primary_contact:
return build_error_response("Primary contact information is required to create a new customer.", 400)
print("#####DEBUG: Primary contact found:", primary_contact)
client_doc = create_lead({
"lead_name": data.get("customer_name"),
"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"),
"customer_type": data.get("customer_type"),
"company": data.get("company")
})
else:
print("#####DEBUG: Existing lead found:", customer)
client_doc = frappe.get_doc("Lead", customer)
else:
print("#####DEBUG: Existing customer found:", customer)
client_doc = frappe.get_doc("Customer", customer)
print(f"#####DEBUG: {client_doc.doctype}:", client_doc.as_dict())
# Handle address creation
address_doc = create_address({
"address_title": build_address_title(data.get("customer_name"), data),
"address_line1": data.get("address_line1"),
"address_line2": data.get("address_line2"),
"city": data.get("city"),
"state": data.get("state"),
"country": "United States",
"pincode": data.get("pincode")
})
#Handle contact creation
contact_docs = []
for contact_data in data.get("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 = create_contact({
"first_name": contact_data.get("first_name"),
"last_name": contact_data.get("last_name"),
# "email_id": contact_data.get("email"),
# "phone": contact_data.get("phone_number"),
"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,
"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
}]
})
contact_docs.append(contact_doc)
##### Create links
# Customer -> Address
if client_doc.doctype == "Customer":
print("#####DEBUG: Linking address to customer.")
client_doc.append("custom_select_address", {
"address_name": address_doc.name,
})
# Customer -> Contact
print("#####DEBUG: Linking contacts to customer.")
for contact_doc in contact_docs:
client_doc.append("custom_add_contacts", {
"contact": contact_doc.name,
"email": contact_doc.custom_email,
"phone": contact_doc.phone,
"role": contact_doc.role
})
client_doc.save(ignore_permissions=True)
# Address -> Customer/Lead
create_address_links(address_doc, client_doc, contact_docs)
# Contact -> Customer/Lead & Address
create_contact_links(contact_docs, client_doc, address_doc)
frappe.local.message_log = []
return build_success_response({
"customer": client_doc.as_dict(),
"address": address_doc.as_dict(),
"contacts": [contact_doc.as_dict() for contact_doc in contact_docs]
})
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={"lead_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)

View File

@ -1,50 +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 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)

View File

@ -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)

View File

@ -1,340 +0,0 @@
import frappe, json
from frappe.utils.pdf import get_pdf
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
from werkzeug.wrappers import Response
from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
# ===============================================================================
# ESTIMATES & INVOICES API METHODS
# ===============================================================================
@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 - 1) * page_size,
order_by=processed_sortings
)
tableRows = []
for estimate in estimates:
full_address = frappe.db.get_value("Address", estimate.get("custom_installation_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():
"""Get all available quotation items."""
try:
items = frappe.get_all("Item", fields=["*"], filters={"item_group": "SNW-S"})
return build_success_response(items)
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_installation_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
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_installation_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_installation_address
# FROM `tabQuotation` q
# JOIN `tabAddress` a
# ON q.custom_installation_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)
party_exists = frappe.db.exists(quotation.quotation_to, quotation.party_name)
if not party_exists:
return build_error_response("No email found for the customer.", 400)
party = frappe.get_doc(quotation.quotation_to, quotation.party_name)
email = None
if (getattr(party, 'email_id', None)):
email = party.email_id
elif (getattr(party, 'contact_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_installation_address:
address = frappe.get_doc("Address", quotation.custom_installation_address)
email = getattr(address, 'email_id', None)
if not email:
return build_error_response("No email found for the customer or address.", 400)
# email = "casey@shilohcode.com"
template_name = "Quote with Actions - SNW"
template = frappe.get_doc("Email Template", template_name)
message = frappe.render_template(template.response, {"name": quotation.name})
subject = frappe.render_template(template.subject, {"doc": quotation})
print("DEBUG: Message: ", message)
print("DEBUG: Subject: ", subject)
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.")
frappe.sendmail(
recipients=email,
subject=subject,
content=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.")
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(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})
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")
@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)
estimate_name = data.get("estimate_name")
is_customer = True if frappe.db.exists("Customer", data.get("customer")) else False
# 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_name")
estimate.party_name = data.get("customer")
estimate.contact_person = data.get("contact_name")
estimate.custom_requires_half_payment = data.get("requires_half_payment", 0)
# 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"),
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
})
estimate.save()
print(f"DEBUG: Estimate updated: {estimate.name}")
return build_success_response(estimate.as_dict())
# Otherwise, create new estimate
else:
print("DEBUG: Creating new estimate")
print("DEBUG: Retrieved address name:", data.get("address_name"))
new_estimate = frappe.get_doc({
"doctype": "Quotation",
"custom_requires_half_payment": data.get("requires_half_payment", 0),
# "custom_installation_address": data.get("address_name"),
"custom_current_status": "Draft",
"contact_email": data.get("contact_email"),
"party_name": data.get("customer"),
"quotation_to": "Customer" if is_customer else "Lead",
"company": data.get("company"),
"customer_name": data.get("customer"),
"customer_address": data.get("address_name"),
"contact_person": data.get("contact_name"),
"letter_head": data.get("company"),
})
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"),
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
})
new_estimate.insert()
print("DEBUG: New estimate created with name:", new_estimate.name)
return build_success_response(new_estimate.as_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_estimate_counts():
# """Get specific counts of estimates based on their status."""
# try:
# counts = {
# "total_estimates": frappe.db.count("Quotation"),
# "ready_to_"
# }

View File

@ -1,105 +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
# ===============================================================================
# INVOICES API METHODS
# ===============================================================================
@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 invoice 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)

View File

@ -1,190 +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
# ===============================================================================
# JOB MANAGEMENT API METHODS
# ===============================================================================
@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_job = frappe.get_doc({
"doctype": "Project",
"custom_installation_address": sales_order.custom_installation_address,
"project_name": sales_order.custom_installation_address,
"project_template": project_template,
"custom_warranty_duration_days": 90,
"sales_order": sales_order
})
new_job.insert()
return build_success_response(new_job.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 = frappe.get_doc("Project", 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:
tableRow = {}
tableRow["name"] = task["name"]
tableRow["subject"] = task["subject"]
tableRow["address"] = task.get("custom_property", "")
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)
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 - 1) * page_size,
order_by=processed_sortings
)
tableRows = []
for project in projects:
tableRow = {}
tableRow["id"] = project["name"]
tableRow["name"] = project["name"]
tableRow["installation_address"] = project.get("custom_installation_address", "")
tableRow["customer"] = project.get("customer", "")
tableRow["status"] = project.get("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_install_projects(start_date=None, end_date=None):
"""Get install projects for the calendar."""
try:
filters = {"project_template": "SNW Install"}
# 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).
projects = frappe.get_all("Project", fields=["*"], filters=filters)
calendar_events = []
for project in projects:
# Determine status
status = "unscheduled"
if project.get("expected_start_date"):
status = "scheduled"
# Map to calendar event format
event = {
"id": project.name,
"serviceType": project.project_name, # Using project name as service type/title
"customer": project.customer,
"status": status,
"scheduledDate": project.expected_start_date,
"scheduledTime": "08:00", # Default time if not specified? Project doesn't seem to have time.
"duration": 480, # Default 8 hours?
"foreman": project.get("custom_install_crew"),
"crew": [], # Need to map crew
"estimatedCost": project.estimated_costing,
"priority": project.priority.lower() if project.priority else "medium",
"notes": project.notes,
"address": project.custom_installation_address
}
calendar_events.append(event)
return {"status": "success", "data": calendar_events}
except Exception as e:
return {"status": "error", "message": str(e)}

View File

@ -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)

View File

@ -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

View File

@ -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.")

View File

@ -3,51 +3,6 @@ import os
import subprocess
import frappe
from custom_ui.utils import create_module
from custom_ui.db_utils 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.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")
except subprocess.CalledProcessError as e:
click.echo(f"\n❌ Frontend build failed: {e}\n")
exit(1)
else:
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")

View File

@ -1,5 +1,4 @@
<div id="custom-ui-app"></div>
<span id="test-footer">THIS IS A TEST</span>
{% if bundle_path %}
<script type="module" src="{{ bundle_path }}"></script>
{% else %}

View File

@ -1,47 +1,20 @@
frappe.pages["custom_ui"].on_page_load = async (wrapper) => {
// Create root div for spa if it doesn't exist
const appRootId = "custom-ui-app";
if (!document.getElementById(appRootId)) {
$(wrapper).html('<div id="custom-ui-app"></div>');
console.log("App root div created");
}
$(wrapper).html('<div id="custom-ui-app"></div>');
console.log("App root div created");
manifest = await fetch("/assets/custom_ui/dist/.vite/manifest.json").then((res) => res.json());
console.log("Fetched manifest:", manifest);
// Attempt to load the manifest file
try {
// Cache busting by appending a timestamp
const manifestUrl = `/assets/custom_ui/dist/.vite/manifest.json?v=${Date.now()}`;
manifest = await fetch(manifestUrl).then((res) => res.json());
console.log("Fetched manifest:", manifest);
const script = document.createElement("script");
script.src = "/assets/custom_ui/dist/" + manifest["src/main.js"]["file"];
script.type = "module";
document.body.appendChild(script);
console.log("Appended script:", script.src);
// Check existence of old script and link elements and remove them
const existingScript = document.getElementById("custom-ui-main-js");
if (existingScript) existingScript.remove();
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/assets/custom_ui/dist/" + manifest["src/main.js"]["css"][0];
document.head.appendChild(link);
const existingLink = document.getElementById("custom-ui-main-css");
if (existingLink) existingLink.remove();
// 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);
}
console.log("Custom UI stylesheet loaded:", link.href);
console.log("Custom UI script loaded:", script.src);
};

View File

@ -1,225 +0,0 @@
import frappe
import json
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_name",
"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 = {
"lead_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 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
)

View File

@ -1,9 +0,0 @@
import frappe
from custom_ui.db_utils import build_full_address
def after_insert(doc, method):
print(doc.as_dict())
if not doc.full_address:
doc.full_address = build_full_address(doc)
doc.save()

View File

@ -1,49 +0,0 @@
import frappe
from erpnext.selling.doctype.quotation.quotation import make_sales_order
def after_insert(doc, method):
try:
print("DEBUG: after_insert hook triggered for Quotation:", doc.name)
if not doc.custom_installation_address:
print("ERROR: custom_installation_address is empty")
return
address_doc = frappe.get_doc("Address", doc.custom_installation_address)
address_doc.custom_estimate_sent_status = "In Progress"
address_doc.save()
except Exception as e:
print("ERROR in after_insert hook:", str(e))
frappe.log_error(f"Error in estimate after_insert: {str(e)}", "Estimate Hook Error")
def after_save(doc, method):
print("DEBUG: after_save hook triggered for Quotation:", doc.name)
if doc.custom_sent and doc.custom_response:
print("DEBUG: Quotation has been sent, updating Address status")
address_doc = frappe.get_doc("Address", doc.custom_installation_address)
address_doc.custom_estimate_sent_status = "Completed"
address_doc.save()
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 == "Estimate Accepted":
doc.custom_current_status = "Won"
print("DEBUG: Creating Sales Order from accepted Estimate")
address_doc = frappe.get_doc("Address", doc.customer_address)
address_doc.custom_estimate_sent_status = "Completed"
address_doc.save()
try:
new_sales_order = make_sales_order(doc.name)
new_sales_order.custom_requires_half_payment = doc.requires_half_payment
new_sales_order.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
new_sales_order.insert()
print("DEBUG: Submitting Sales Order")
new_sales_order.submit()
frappe.db.commit()
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
except Exception as e:
print("ERROR creating Sales Order from Estimate:", str(e))
frappe.log_error(f"Error creating Sales Order from Estimate {doc.name}: {str(e)}", "Estimate on_update_after_submit Error")

View File

@ -1,21 +0,0 @@
import frappe
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for On-Site Meeting")
print("DEBUG: Updating on-site meeting status in Address")
if doc.address and not doc.end_time and not doc.start_time:
address_doc = frappe.get_doc("Address", doc.address)
address_doc.custom_onsite_meeting_scheduled = "In Progress"
address_doc.save()
def after_save(doc, method):
print("DEBUG: After Save Triggered for On-Site Meeting")
if doc.status == "Completed":
print("DEBUG: Meeting marked as Completed, updating Address status")
address_doc = frappe.get_doc("Address", doc.address)
address_doc.custom_onsite_meeting_scheduled = "Completed"
address_doc.save()
return
if doc.status != "Scheduled" and doc.start_time and doc.end_time:
doc.status = "Scheduled"
doc.save()

View File

@ -1,44 +0,0 @@
import frappe
def after_insert(doc, method):
print(doc.as_dict())
# Create Invoice and Project from Sales Order
def create_sales_invoice_from_sales_order(doc, method):
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")

View File

@ -1,59 +0,0 @@
[
{
"allow_in_quick_entry": 0,
"allow_on_submit": 1,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Quotation",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "custom_quotation_template",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "amended_from",
"is_system_generated": 0,
"is_virtual": 0,
"label": "Quotation Template",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2025-12-23 02:28:02.771813",
"module": null,
"name": "Quotation-custom_quotation_template",
"no_copy": 0,
"non_negative": 0,
"options": "Quotation Template",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
}
]

View File

@ -1,974 +0,0 @@
[
{
"_assign": null,
"_comments": null,
"_last_update": null,
"_liked_by": null,
"_user_tags": null,
"actions": [],
"allow_auto_repeat": 0,
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 1,
"app": null,
"autoname": null,
"beta": 0,
"color": null,
"colour": null,
"custom": 1,
"default_email_template": null,
"default_print_format": null,
"default_view": null,
"description": null,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"documentation": null,
"editable_grid": 0,
"email_append_to": 0,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": "Human readable name",
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "template_name",
"fieldtype": "Data",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Template Name",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 1,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": "1",
"depends_on": null,
"description": "Hide old templates",
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "is_active",
"fieldtype": "Check",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Active",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": "Optional",
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "description",
"fieldtype": "Small Text",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Description",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": "Audit trail",
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "source_quotation",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Created From Quotation",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Quotation",
"parent": "Quotation Template",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": "30",
"depends_on": null,
"description": "Quote valid for",
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "validity_days",
"fieldtype": "Int",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Default Validity Days",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "items",
"fieldtype": "Table",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Items",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Quotation Template Item",
"parent": "Quotation Template",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
}
],
"force_re_route_to_default_view": 0,
"grid_page_length": 50,
"has_web_view": 0,
"hide_toolbar": 0,
"icon": null,
"image_field": null,
"in_create": 0,
"index_web_pages_for_search": 1,
"is_calendar_and_gantt": 0,
"is_published_field": null,
"is_submittable": 0,
"is_tree": 0,
"is_virtual": 0,
"issingle": 0,
"istable": 0,
"links": [],
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": null,
"modified": "2025-12-23 02:03:44.840865",
"module": "Selling",
"name": "Quotation Template",
"naming_rule": "",
"nsm_parent_field": null,
"parent_node": null,
"permissions": [
{
"amend": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 0,
"match": null,
"parent": "Quotation Template",
"parentfield": "permissions",
"parenttype": "DocType",
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"select": 0,
"share": 1,
"submit": 0,
"write": 1
}
],
"print_outline": null,
"protect_attached_files": 0,
"queue_in_background": 0,
"quick_entry": 0,
"read_only": 0,
"recipient_account_field": null,
"restrict_to_domain": null,
"route": null,
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"search_fields": null,
"sender_field": null,
"sender_name_field": null,
"show_name_in_global_search": 0,
"show_preview_popup": 0,
"show_title_field_in_link": 0,
"smallicon": null,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"subject": null,
"subject_field": null,
"tag_fields": null,
"timeline_field": null,
"title_field": null,
"track_changes": 0,
"track_seen": 0,
"track_views": 0,
"translated_doctype": 0,
"website_search_field": null
},
{
"_assign": null,
"_comments": null,
"_last_update": null,
"_liked_by": null,
"_user_tags": null,
"actions": [],
"allow_auto_repeat": 0,
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 1,
"app": null,
"autoname": null,
"beta": 0,
"color": null,
"colour": null,
"custom": 1,
"default_email_template": null,
"default_print_format": null,
"default_view": null,
"description": null,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"documentation": null,
"editable_grid": 1,
"email_append_to": 0,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": "Item code link",
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "item_code",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Item Code",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": "Item",
"parent": "Quotation Template Item",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "item_name",
"fieldtype": "Data",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Item Name",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template Item",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "description",
"fieldtype": "Small Text",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Description",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template Item",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": "1.00",
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "quantity",
"fieldtype": "Float",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Quantity",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template Item",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "discount_percentage",
"fieldtype": "Float",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Discount %",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template Item",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"documentation_url": null,
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "rate",
"fieldtype": "Currency",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"is_virtual": 0,
"label": "Rate",
"length": 0,
"link_filters": null,
"make_attachment_public": 0,
"mandatory_depends_on": null,
"max_height": null,
"no_copy": 0,
"non_negative": 0,
"oldfieldname": null,
"oldfieldtype": null,
"options": null,
"parent": "Quotation Template Item",
"parentfield": "fields",
"parenttype": "DocType",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"show_dashboard": 0,
"show_on_timeline": 0,
"show_preview_popup": 0,
"sort_options": 0,
"translatable": 0,
"trigger": null,
"unique": 0,
"width": null
}
],
"force_re_route_to_default_view": 0,
"grid_page_length": 50,
"has_web_view": 0,
"hide_toolbar": 0,
"icon": null,
"image_field": null,
"in_create": 0,
"index_web_pages_for_search": 1,
"is_calendar_and_gantt": 0,
"is_published_field": null,
"is_submittable": 0,
"is_tree": 0,
"is_virtual": 0,
"issingle": 0,
"istable": 1,
"links": [],
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
"migration_hash": null,
"modified": "2025-12-23 02:00:30.908719",
"module": "Selling",
"name": "Quotation Template Item",
"naming_rule": "",
"nsm_parent_field": null,
"parent_node": null,
"permissions": [],
"print_outline": null,
"protect_attached_files": 0,
"queue_in_background": 0,
"quick_entry": 0,
"read_only": 0,
"recipient_account_field": null,
"restrict_to_domain": null,
"route": null,
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"search_fields": null,
"sender_field": null,
"sender_name_field": null,
"show_name_in_global_search": 0,
"show_preview_popup": 0,
"show_title_field_in_link": 0,
"smallicon": null,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"subject": null,
"subject_field": null,
"tag_fields": null,
"timeline_field": null,
"title_field": null,
"track_changes": 0,
"track_seen": 0,
"track_views": 0,
"translated_doctype": 0,
"website_search_field": null
}
]

View File

@ -158,43 +158,13 @@ add_to_apps_screen = [
# ---------------
# Hook on document methods and events
doc_events = {
"On-Site Meeting": {
"after_insert": "custom_ui.events.onsite_meeting.after_insert",
"on_update": "custom_ui.events.onsite_meeting.after_save"
},
"Address": {
"after_insert": "custom_ui.events.address.after_insert"
},
"Quotation": {
"after_insert": "custom_ui.events.estimate.after_insert",
"on_update": "custom_ui.events.estimate.after_save",
"after_submit": "custom_ui.events.estimate.after_submit",
"on_update_after_submit": "custom_ui.events.estimate.on_update_after_submit"
},
"Sales Order": {
"after_insert": "custom_ui.events.sales_order.after_insert"
}
}
fixtures = [
{
"dt": "DocType",
"filters": [
["name", "in", [
"Quotation Template",
"Quotation Template Item"
]]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Quotation"],
["fieldname", "=", "custom_quotation_template"]
]
}
]
# doc_events = {
# "*": {
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }
# }
# Scheduled Tasks
# ---------------

View File

@ -1,38 +1,15 @@
import os
import subprocess
import sys
import frappe
from .utils import create_module
def after_install():
create_module()
add_custom_fields()
frappe.db.commit()
# Proper way to refresh metadata
frappe.clear_cache(doctype="Address")
frappe.reload_doctype("Address")
frappe.clear_cache(doctype="On-Site Meeting")
frappe.reload_doctype("On-Site Meeting")
update_onsite_meeting_fields()
update_address_fields()
build_frontend()
def after_migrate():
add_custom_fields()
update_onsite_meeting_fields()
frappe.db.commit()
# Proper way to refresh metadata
frappe.clear_cache(doctype="Address")
frappe.reload_doctype("Address")
frappe.clear_cache(doctype="On-Site Meeting")
frappe.reload_doctype("On-Site Meeting")
update_address_fields()
build_frontend()
def build_frontend():
app_package_path = frappe.get_app_path("custom_ui")
app_root = os.path.dirname(app_package_path)
@ -63,391 +40,4 @@ def build_frontend():
print("\n✅ Frontend build completed successfully.\n")
except subprocess.CalledProcessError as e:
frappe.log_error(message=str(e), title="Frontend Build Failed")
print(f"\n❌ Frontend build failed: {e}\n")
def add_custom_fields():
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
print("\n🔧 Adding custom fields to Address doctype...")
custom_fields = {
"Lead": [
dict(
fieldname="customer_type",
label="Customer Type",
fieldtype="Select",
options="Individual\nCompany\nPartnership",
insert_after="lead_name"
)
],
"Address": [
dict(
fieldname="full_address",
label="Full Address",
fieldtype="Data",
insert_after="country"
),
dict(
fieldname="latitude",
label="Latitude",
fieldtype="Float",
precision=8,
insert_after="full_address"
),
dict(
fieldname="longitude",
label="Longitude",
fieldtype="Float",
precision=8,
insert_after="latitude"
),
dict(
fieldname="onsite_meeting_scheduled",
label="On-Site Meeting Scheduled",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="longitude"
),
dict(
fieldname="estimate_sent_status",
label="Estimate Sent Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="onsite_meeting_scheduled"
),
dict(
fieldname="job_status",
label="Job Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="estimate_sent_status"
),
dict(
fieldname="payment_received_status",
label="Payment Received Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="job_status"
),
dict(
fieldname="lead_name",
label="Lead Name",
fieldtype="Data",
insert_after="custom_customer_to_bill"
)
],
"Contact": [
dict(
fieldname="role",
label="Role",
fieldtype="Select",
options="Owner\nProperty Manager\nTenant\nBuilder\nNeighbor\nFamily Member\nRealtor\nOther",
insert_after="designation"
),
dict(
fieldname="email",
label="Email",
fieldtype="Data",
insert_after="last_name",
options="Email"
)
],
"On-Site Meeting": [
dict(
fieldname="notes",
label="Notes",
fieldtype="Small Text",
insert_after="address"
),
dict(
fieldname="assigned_employee",
label="Assigned Employee",
fieldtype="Link",
options="Employee",
insert_after="notes"
),
dict(
fieldname="status",
label="Status",
fieldtype="Select",
options="Unscheduled\nScheduled\nCompleted\nCancelled",
default="Unscheduled",
insert_after="start_time"
),
dict(
fieldname="completed_by",
label="Completed By",
fieldtype="Link",
options="Employee",
insert_after="status"
)
],
"Quotation": [
dict(
fieldname="requires_half_payment",
label="Requires Half Payment",
fieldtype="Check",
default=0,
insert_after="custom_installation_address"
)
],
"Sales Order": [
dict(
fieldname="requires_half_payment",
label="Requires Half Payment",
fieldtype="Check",
default=0,
insert_after="custom_installation_address"
)
]
}
field_count = len(custom_fields["Address"])
print(f"📝 Creating {field_count} custom fields for Address doctype...")
try:
create_custom_fields(custom_fields)
print("✅ Custom fields added successfully!")
print(" • full_address (Data)")
print(" • latitude (Float)")
print(" • longitude (Float)")
print(" • onsite_meeting_scheduled (Select)")
print(" • estimate_sent_status (Select)")
print(" • job_status (Select)")
print(" • payment_received_status (Select)")
print("🔧 Custom fields installation complete.\n")
except Exception as e:
print(f"❌ Error creating custom fields: {str(e)}")
frappe.log_error(message=str(e), title="Custom Fields Creation Failed")
raise
def update_onsite_meeting_fields():
"""Update On-Site Meeting doctype fields to make start_time and end_time optional."""
print("\n🔧 Updating On-Site Meeting doctype fields...")
try:
# Get the doctype
doctype = frappe.get_doc("DocType", "On-Site Meeting")
# Find and update start_time and end_time fields
updated_fields = []
for field in doctype.fields:
if field.fieldname in ['start_time', 'end_time']:
if field.reqd == 1:
field.reqd = 0
updated_fields.append(field.fieldname)
if updated_fields:
# Save the doctype
doctype.save(ignore_permissions=True)
print(f"✅ Updated fields: {', '.join(updated_fields)} (set to not required)")
else:
print("✅ Fields already configured correctly")
print("🔧 On-Site Meeting field update complete.\n")
except Exception as e:
print(f"❌ Error updating On-Site Meeting fields: {str(e)}")
frappe.log_error(message=str(e), title="On-Site Meeting Field Update Failed")
# Don't raise - this is not critical enough to stop migration
def update_address_fields():
addresses = frappe.get_all("Address", pluck="name")
total_addresses = len(addresses)
if total_addresses == 0:
print("📍 No addresses found to update.")
return
print(f"\n📍 Updating fields for {total_addresses} addresses...")
# Verify custom fields exist by checking the meta for every doctype that was customized
def has_any_field(meta, candidates):
return any(meta.has_field(f) for f in candidates)
custom_field_expectations = {
"Address": [
["full_address"],
["custom_onsite_meeting_scheduled", "onsite_meeting_scheduled"],
["custom_estimate_sent_status", "estimate_sent_status"],
["custom_job_status", "job_status"],
["custom_payment_received_status", "payment_received_status",],
["custom_lead_name", "lead_name"]
],
"Contact": [
["custom_role", "role"],
["custom_email", "email"],
],
"On-Site Meeting": [
["custom_notes", "notes"],
["custom_assigned_employee", "assigned_employee"],
["custom_status", "status"],
["custom_completed_by", "completed_by"]
],
"Quotation": [
["custom_requires_half_payment", "requires_half_payment"]
],
"Sales Order": [
["custom_requires_half_payment", "requires_half_payment"]
],
"Lead": [
["custom_customer_type", "customer_type"]
]
}
missing_fields = []
for doctype, field_options in custom_field_expectations.items():
meta = frappe.get_meta(doctype)
for candidates in field_options:
if not has_any_field(meta, candidates):
missing_fields.append(f"{doctype}: {'/'.join(candidates)}")
if missing_fields:
print("\n❌ Missing custom fields:")
for entry in missing_fields:
print(f"{entry}")
print(" Custom fields creation may have failed. Skipping address updates.")
return
print("✅ All custom fields verified. Proceeding with address updates...")
# Field update counters
field_counters = {
'full_address': 0,
'custom_onsite_meeting_scheduled': 0,
'custom_estimate_sent_status': 0,
'custom_job_status': 0,
'custom_payment_received_status': 0
}
total_field_updates = 0
addresses_updated = 0
onsite_meta = frappe.get_meta("On-Site Meeting")
onsite_status_field = "custom_status" if onsite_meta.has_field("custom_status") else "status"
for index, name in enumerate(addresses, 1):
# Calculate progress
progress_percentage = int((index / total_addresses) * 100)
bar_length = 30
filled_length = int(bar_length * index // total_addresses)
bar = '' * filled_length + '' * (bar_length - filled_length)
# Print a three-line, refreshing progress block without adding new lines each loop
progress_line = f"📊 Progress: [{bar}] {progress_percentage:3d}% ({index}/{total_addresses})"
counters_line = f" Fields updated: {total_field_updates} | Addresses updated: {addresses_updated}"
detail_line = f" Processing: {name[:40]}..."
if index == 1:
# First render: write the three lines
sys.stdout.write(
f"\r\033[K{progress_line}\n"
f"\033[K{counters_line}\n"
f"\033[K{detail_line}"
)
else:
# Move cursor up 3 lines, then rewrite each line in place
sys.stdout.write("\033[2F")
sys.stdout.write(f"\r\033[K{progress_line}\n")
sys.stdout.write(f"\033[K{counters_line}\n")
sys.stdout.write(f"\033[K{detail_line}")
if index == total_addresses:
sys.stdout.write("\n")
sys.stdout.flush()
should_update = False
address = frappe.get_doc("Address", name)
current_address_updates = 0
# Use getattr with default values instead of direct attribute access
if not getattr(address, 'full_address', None):
address_parts_1 = [
address.address_line1 or "",
address.address_line2 or "",
address.city or "",
]
address_parts_2 = [
address.state or "",
address.pincode or "",
]
full_address = ", ".join([
" ".join(filter(None, address_parts_1)),
" ".join(filter(None, address_parts_2))
]).strip()
address.full_address = full_address
field_counters['full_address'] += 1
current_address_updates += 1
should_update = True
onsite_meeting = "Not Started"
estimate_sent = "Not Started"
job_status = "Not Started"
payment_received = "Not Started"
onsite_meetings = frappe.get_all("On-Site Meeting", fields=[onsite_status_field], filters={"address": address.address_title})
if onsite_meetings and onsite_meetings[0]:
status_value = onsite_meetings[0].get(onsite_status_field)
onsite_meeting = "Completed" if status_value == "Completed" else "In Progress"
estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus", "custom_response"], filters={"custom_installation_address": address.address_title})
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]:
estimate_sent = "Completed"
elif estimates and estimates[0] and not (estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]):
estimate_sent = "In Progress"
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address.address_title, "project_template": "SNW Install"})
if jobs and jobs[0] and jobs[0]["status"] == "Completed":
job_status = "Completed"
elif jobs and jobs[0]:
job_status = "In Progress"
sales_invoices = frappe.get_all("Sales Invoice", fields=["outstanding_amount"], filters={"custom_installation_address": address.address_title})
# payments = frappe.get_all("Payment Entry", filters={"custom_installation_address": address.address_title})
if sales_invoices and sales_invoices[0] and sales_invoices[0]["outstanding_amount"] == 0:
payment_received = "Completed"
elif sales_invoices and sales_invoices[0]:
payment_received = "In Progress"
if getattr(address, 'custom_onsite_meeting_scheduled', None) != onsite_meeting:
address.custom_onsite_meeting_scheduled = onsite_meeting
field_counters['custom_onsite_meeting_scheduled'] += 1
current_address_updates += 1
should_update = True
if getattr(address, 'custom_estimate_sent_status', None) != estimate_sent:
address.custom_estimate_sent_status = estimate_sent
field_counters['custom_estimate_sent_status'] += 1
current_address_updates += 1
should_update = True
if getattr(address, 'custom_job_status', None) != job_status:
address.custom_job_status = job_status
field_counters['custom_job_status'] += 1
current_address_updates += 1
should_update = True
if getattr(address, 'custom_payment_received_status', None) != payment_received:
address.custom_payment_received_status = payment_received
field_counters['custom_payment_received_status'] += 1
current_address_updates += 1
should_update = True
if should_update:
address.save(ignore_permissions=True)
addresses_updated += 1
total_field_updates += current_address_updates
# Print completion summary
print(f"\n\n✅ Address field update completed!")
print(f"📊 Summary:")
print(f" • Total addresses processed: {total_addresses:,}")
print(f" • Addresses updated: {addresses_updated:,}")
print(f" • Total field updates: {total_field_updates:,}")
print(f"\n📝 Field-specific updates:")
print(f" • Full Address: {field_counters['full_address']:,}")
print(f" • On-Site Meeting Status: {field_counters['custom_onsite_meeting_scheduled']:,}")
print(f" • Estimate Sent Status: {field_counters['custom_estimate_sent_status']:,}")
print(f" • Job Status: {field_counters['custom_job_status']:,}")
print(f" • Payment Received Status: {field_counters['custom_payment_received_status']:,}")
print("📍 Address field updates complete.\n")
print(f"\n❌ Frontend build failed: {e}\n")

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

2
frontend/.gitignore vendored
View File

@ -22,5 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

View File

@ -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

View File

@ -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.

View File

@ -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>

View File

@ -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!

View File

@ -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
}
};
```

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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);
},
);
}
```

View File

@ -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
},
);
};
```

View File

@ -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);
});
});
```

View File

@ -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.

View File

@ -9,19 +9,12 @@
"version": "0.0.0",
"dependencies": {
"@iconoir/vue": "^7.11.0",
"@mdi/font": "^7.4.47",
"@primeuix/themes": "^1.2.5",
"axios": "^1.12.2",
"chart.js": "^4.5.1",
"frappe-ui": "^0.1.205",
"leaflet": "^1.9.4",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.4.1",
"sass-embedded": "^1.96.0",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-leaflet": "^0.1.0",
"vue-router": "^4.6.3",
"vuetify": "^3.10.7"
},
@ -98,12 +91,6 @@
"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": {
"version": "0.25.11",
"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==",
"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": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@ -2636,12 +2315,6 @@
"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": {
"version": "1.0.2",
"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"
}
},
"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": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -2764,12 +2425,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -2897,19 +2552,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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@ -3443,12 +3085,6 @@
],
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -3551,12 +3187,6 @@
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@ -3679,33 +3309,6 @@
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"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": {
"version": "1.52.0",
"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_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": {
"version": "3.0.0",
"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"
}
},
"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": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.4.1.tgz",
@ -4459,15 +4049,6 @@
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"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": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -4488,402 +4069,6 @@
],
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
@ -4999,27 +4184,6 @@
"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": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
@ -5329,12 +4493,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"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": {
"version": "7.1.10",
"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": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",

View File

@ -10,19 +10,12 @@
},
"dependencies": {
"@iconoir/vue": "^7.11.0",
"@mdi/font": "^7.4.47",
"@primeuix/themes": "^1.2.5",
"axios": "^1.12.2",
"chart.js": "^4.5.1",
"frappe-ui": "^0.1.205",
"leaflet": "^1.9.4",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.4.1",
"sass-embedded": "^1.96.0",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-leaflet": "^0.1.0",
"vue-router": "^4.6.3",
"vuetify": "^3.10.7"
},

View File

@ -1,40 +1,6 @@
<script setup>
import { IconoirProvider } from "@iconoir/vue";
import SideBar from "./components/SideBar.vue";
import { watchEffect, onMounted, ref } from "vue";
import { useCompanyStore } from "@/stores/company";
import { useThemeStore } from "@/stores/theme";
import CreateClientModal from "./components/modals/CreateClientModal.vue";
import CreateEstimateModal from "./components/modals/CreateEstimateModal.vue";
import CreateJobModal from "./components/modals/CreateJobModal.vue";
import CreateInvoiceModal from "./components/modals/CreateInvoiceModal.vue";
import CreateWarrantyModal from "./components/modals/CreateWarrantyModal.vue";
import GlobalLoadingOverlay from "./components/common/GlobalLoadingOverlay.vue";
import ScrollPanel from "primevue/scrollpanel";
import Toast from "primevue/toast";
import { useNotificationStore } from "@/stores/notifications-primevue";
// Get the notification store and create a ref for the toast
const notificationStore = useNotificationStore();
const toast = ref();
const companyStore = useCompanyStore();
const themeStore = useThemeStore();
const sidebarCollapsed = ref(false);
// Connect the toast instance to the store when component mounts
onMounted(() => {
if (toast.value) {
notificationStore.setToastInstance(toast.value);
}
// Apply initial theme
themeStore.applyTheme(companyStore.selectedCompany);
});
// Reactively apply theme when company changes
watchEffect(() => {
themeStore.applyTheme(companyStore.selectedCompany);
});
</script>
<template>
@ -47,28 +13,11 @@ watchEffect(() => {
}"
>
<div id="snw-ui">
<div class="sidebar-column" :class="{ collapsed: sidebarCollapsed }">
<SideBar @toggle="sidebarCollapsed = $event" />
</div>
<SideBar />
<div id="display-content">
<ScrollPanel style="width: 100%; height: 100%">
<RouterView />
</ScrollPanel>
<RouterView />
</div>
</div>
<!-- Global Modals -->
<CreateClientModal />
<CreateEstimateModal />
<CreateJobModal />
<CreateInvoiceModal />
<CreateWarrantyModal />
<!-- Global Loading Overlay -->
<GlobalLoadingOverlay />
<!-- Global Notifications -->
<Toast ref="toast" />
</IconoirProvider>
</template>
@ -78,59 +27,18 @@ watchEffect(() => {
flex-direction: row;
border-radius: 10px;
padding: 10px;
border: 4px solid var(--theme-border);
max-width: 2500px;
width: 100%;
border: 4px solid rgb(235, 230, 230);
max-width: 1280px;
min-width: 800px;
margin: 10px auto;
height: 90vh;
background: var(--theme-background-gradient);
}
.sidebar-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 170px;
min-width: 170px;
transition: width 0.3s ease, min-width 0.3s ease;
}
.sidebar-column.collapsed {
width: 56px;
min-width: 56px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.sidebar-column {
width: 140px;
min-width: 140px;
}
.sidebar-column.collapsed {
width: 56px;
min-width: 56px;
}
}
@media (max-width: 480px) {
.sidebar-column {
width: 120px;
min-width: 120px;
}
.sidebar-column.collapsed {
width: 56px;
min-width: 56px;
}
height: 80vh;
}
#display-content {
flex-grow: 1;
/* flex-grow: 1; */
margin-left: auto;
margin-right: auto;
max-width: 100%;
max-width: 50vw;
min-width: 80%;
height: 100%;
}
</style>

View File

@ -1,265 +0,0 @@
/**
* Enhanced API wrapper with integrated error handling and notifications
*
* This example shows how to use the error store and notification system together
* to provide comprehensive error handling and user feedback for API calls.
*/
import { useErrorStore } from "@/stores/errors";
import { useNotificationStore } from "@/stores/notifications";
import { useLoadingStore } from "@/stores/loading";
import Api from "./api";
export class ApiWithErrorHandling {
static async makeApiCall(apiFunction, options = {}) {
const {
// Error handling options
componentName = null,
showErrorNotifications = true,
showSuccessNotifications = true,
// Loading options
showLoading = true,
loadingMessage = "Loading...",
// Success options
successMessage = null,
successTitle = "Success",
// Error options
errorTitle = "Error",
customErrorMessage = null,
// Retry options
retryCount = 0,
retryDelay = 1000,
// Operation identifier for tracking
operationKey = null,
} = options;
const errorStore = useErrorStore();
const notificationStore = useNotificationStore();
const loadingStore = useLoadingStore();
// Generate operation key if not provided
const operation = operationKey || `api-${Date.now()}`;
try {
// Clear any existing errors
if (componentName) {
errorStore.clearComponentError(componentName);
}
// Show loading state
if (showLoading) {
if (componentName) {
loadingStore.setComponentLoading(componentName, true, loadingMessage);
} else {
loadingStore.startOperation(operation, loadingMessage);
}
}
// Make the API call with retry logic
const result = await errorStore.handleApiCall(operation, apiFunction, {
showNotification: showErrorNotifications,
retryCount,
retryDelay,
});
// Show success notification
if (showSuccessNotifications && successMessage) {
notificationStore.addSuccess(successMessage, successTitle);
}
return result;
} catch (error) {
// The error store has already handled the error notification
// But we can add custom handling here if needed
console.error("API call failed:", error);
// Optionally show a custom error message
if (customErrorMessage && !showErrorNotifications) {
notificationStore.addError(customErrorMessage, errorTitle);
}
// Re-throw the error so calling code can handle it if needed
throw error;
} finally {
// Always clear loading state
if (showLoading) {
if (componentName) {
loadingStore.setComponentLoading(componentName, false);
} else {
loadingStore.stopOperation(operation);
}
}
}
}
// Convenience methods for common API operations
static async getClientStatusCounts(options = {}) {
return this.makeApiCall(() => Api.getClientStatusCounts(), {
operationKey: "client-status-counts",
componentName: "clients",
loadingMessage: "Loading client status data...",
successMessage: null, // Don't show success for data fetches
...options,
});
}
static async getPaginatedClientDetails(paginationParams, filters, sorting, options = {}) {
return this.makeApiCall(
() => Api.getPaginatedClientDetails(paginationParams, filters, sorting),
{
operationKey: "client-table-data",
componentName: "dataTable",
loadingMessage: "Loading client data...",
successMessage: null, // Don't show success for data fetches
...options,
},
);
}
static async createClient(clientData, options = {}) {
return this.makeApiCall(() => Api.createClient(clientData), {
operationKey: "create-client",
componentName: "form",
loadingMessage: "Creating client...",
successMessage: "Client created successfully!",
errorTitle: "Failed to Create Client",
...options,
});
}
static async getPaginatedJobDetails(paginationParams, filters, sorting, options = {}) {
return this.makeApiCall(
() => Api.getPaginatedJobDetails(paginationParams, filters, sorting),
{
operationKey: "job-table-data",
componentName: "jobs",
loadingMessage: "Loading job data...",
successMessage: null,
...options,
},
);
}
static async getPaginatedWarrantyData(paginationParams, filters, sorting, options = {}) {
return this.makeApiCall(
() => Api.getPaginatedWarrantyData(paginationParams, filters, sorting),
{
operationKey: "warranty-table-data",
componentName: "warranties",
loadingMessage: "Loading warranty data...",
successMessage: null,
...options,
},
);
}
static async getCityStateByZip(zipcode, options = {}) {
return this.makeApiCall(() => Api.getCityStateByZip(zipcode), {
operationKey: "zip-lookup",
componentName: "form",
loadingMessage: "Looking up location...",
successMessage: null,
errorTitle: "Zip Code Lookup Failed",
customErrorMessage: "Unable to find location for the provided zip code",
retryCount: 2,
retryDelay: 1000,
...options,
});
}
}
/**
* Example usage in Vue components:
*
* <script setup>
* import { ref, onMounted } from 'vue';
* import { ApiWithErrorHandling } from '@/api-enhanced';
* import { useErrorStore } from '@/stores/errors';
* import { useNotificationStore } from '@/stores/notifications';
*
* const errorStore = useErrorStore();
* const notificationStore = useNotificationStore();
*
* const clientData = ref([]);
* const pagination = ref({ page: 0, pageSize: 10 });
* const filters = ref({});
*
* // Example 1: Basic API call with automatic error handling
* const loadClientData = async () => {
* try {
* const result = await ApiWithErrorHandling.getPaginatedClientDetails(
* pagination.value,
* filters.value,
* []
* );
* clientData.value = result.data;
* } catch (error) {
* // Error has already been handled by the error store and notifications shown
* // This catch block is optional - you only need it if you want custom handling
* console.log('Failed to load client data, but user has been notified');
* }
* };
*
* // Example 2: API call with custom options
* const createNewClient = async (formData) => {
* try {
* await ApiWithErrorHandling.createClient(formData, {
* componentName: 'createClientForm',
* successMessage: 'New client added successfully!',
* errorTitle: 'Client Creation Failed',
* customErrorMessage: 'There was an issue creating the client. Please try again.',
* retryCount: 1
* });
*
* // Refresh data after successful creation
* await loadClientData();
* } catch (error) {
* // Handle any additional logic needed on error
* }
* };
*
* // Example 3: Using error store directly for custom error handling
* const handleCustomOperation = async () => {
* await errorStore.withErrorHandling('custom-operation', async () => {
* // Your custom async operation here
* const result = await someApiCall();
* return result;
* }, {
* componentName: 'customComponent',
* showNotification: true,
* rethrow: false // Don't re-throw errors, just handle them
* });
* };
*
* // Example 4: Using notification store directly
* const showCustomNotification = () => {
* notificationStore.addNotification({
* type: 'info',
* title: 'Custom Notification',
* message: 'This is a custom message with actions',
* actions: [
* {
* label: 'Retry',
* variant: 'primary',
* handler: () => loadClientData()
* },
* {
* label: 'Cancel',
* variant: 'secondary'
* }
* ]
* });
* };
*
* onMounted(() => {
* loadClientData();
* });
* </script>
*/
export default ApiWithErrorHandling;

View File

@ -1,241 +0,0 @@
/**
* Enhanced API wrapper with PrimeVue Toast integration
*
* This provides a cleaner, simpler API error handling system using PrimeVue Toast
* instead of a complex custom notification system.
*/
import { useErrorStore } from "@/stores/errors";
import { useLoadingStore } from "@/stores/loading";
import Api from "./api";
export class ApiWithToast {
static async makeApiCall(apiFunction, options = {}) {
const {
// Error handling options
componentName = null,
showErrorToast = true,
showSuccessToast = false,
// Loading options
showLoading = true,
loadingMessage = "Loading...",
// Success options
successMessage = null,
// Error options
customErrorMessage = null,
// Retry options
retryCount = 0,
retryDelay = 1000,
// Operation identifier for tracking
operationKey = null,
rethrow = false,
} = options;
const errorStore = useErrorStore();
const loadingStore = useLoadingStore();
// Generate operation key if not provided
const operation = operationKey || `api-${Date.now()}`;
try {
// Clear any existing errors
if (componentName) {
errorStore.clearComponentError(componentName);
}
// Show loading state
if (showLoading) {
if (componentName) {
loadingStore.setComponentLoading(componentName, true, loadingMessage);
} else {
loadingStore.startOperation(operation, loadingMessage);
}
}
// Make the API call with retry logic
let attempt = 0;
while (attempt <= retryCount) {
try {
const result = await apiFunction();
// Show success toast if requested
if (showSuccessToast && successMessage) {
errorStore.setSuccess(successMessage);
}
return result;
} catch (error) {
attempt++;
if (attempt <= retryCount) {
// Wait before retry
await new Promise((resolve) => setTimeout(resolve, retryDelay));
continue;
}
// Final attempt failed - handle error
if (componentName) {
errorStore.setComponentError(componentName, error, false);
}
// Show error toast
if (showErrorToast) {
const errorMessage = customErrorMessage || this.extractErrorMessage(error);
errorStore.setGlobalError(new Error(errorMessage));
}
// Rethrow error if requested (default is to rethrow)
if (rethrow) {
throw error;
}
// If not rethrowing, return null to indicate failure
return null;
}
}
} finally {
// Always clear loading state
if (showLoading) {
if (componentName) {
loadingStore.setComponentLoading(componentName, false);
} else {
loadingStore.stopOperation(operation);
}
}
}
}
// Helper to extract meaningful error messages
static extractErrorMessage(error) {
if (typeof error === "string") {
return error;
}
if (error?.response?.data?.message) {
return error.response.data.message;
}
if (error?.message) {
return error.message;
}
if (error?.request) {
return "Network error - please check your connection";
}
return "An unexpected error occurred";
}
// Convenience methods for common API operations
static async getClientStatusCounts(options = {}) {
return this.makeApiCall(() => Api.getClientStatusCounts(), {
operationKey: "client-status-counts",
componentName: "clients",
loadingMessage: "Loading client status data...",
showSuccessToast: false, // Don't show success for data fetches
...options,
});
}
static async getPaginatedClientDetails(paginationParams, filters, sorting, options = {}) {
return this.makeApiCall(
() => Api.getPaginatedClientDetails(paginationParams, filters, sorting),
{
operationKey: "client-table-data",
componentName: "dataTable",
loadingMessage: "Loading client data...",
showSuccessToast: false,
...options,
},
);
}
static async createClient(clientData, options = {}) {
return this.makeApiCall(() => Api.createClient(clientData), {
operationKey: "create-client",
componentName: "form",
loadingMessage: "Creating client...",
showSuccessToast: true,
successMessage: "Client created successfully!",
...options,
});
}
static async getPaginatedJobDetails(paginationParams, filters, sorting, options = {}) {
return this.makeApiCall(
() => Api.getPaginatedJobDetails(paginationParams, filters, sorting),
{
operationKey: "job-table-data",
componentName: "jobs",
loadingMessage: "Loading job data...",
showSuccessToast: false,
...options,
},
);
}
static async getPaginatedWarrantyData(paginationParams, filters, sorting, options = {}) {
return this.makeApiCall(
() => Api.getPaginatedWarrantyData(paginationParams, filters, sorting),
{
operationKey: "warranty-table-data",
componentName: "warranties",
loadingMessage: "Loading warranty data...",
showSuccessToast: false,
...options,
},
);
}
static async getCityStateByZip(zipcode, options = {}) {
return this.makeApiCall(() => Api.getCityStateByZip(zipcode), {
operationKey: "zip-lookup",
componentName: "form",
loadingMessage: "Looking up location...",
showSuccessToast: false,
customErrorMessage: "Unable to find location for the provided zip code",
retryCount: 2,
retryDelay: 1000,
...options,
});
}
}
/**
* Simple usage examples:
*
* // Basic API call with automatic toast notifications
* try {
* const result = await ApiWithToast.getPaginatedClientDetails(pagination, filters, []);
* // Success - data loaded, no toast shown for data fetches
* } catch (error) {
* // Error toast automatically shown, component error set
* }
*
* // Create operation with success toast
* try {
* await ApiWithToast.createClient(formData);
* // Success toast shown automatically: "Client created successfully!"
* } catch (error) {
* // Error toast shown automatically with appropriate message
* }
*
* // Custom options
* await ApiWithToast.makeApiCall(
* () => someApiCall(),
* {
* componentName: 'myComponent',
* showSuccessToast: true,
* successMessage: 'Operation completed!',
* retryCount: 3,
* customErrorMessage: 'Custom error message'
* }
* );
*/
export default ApiWithToast;

View File

@ -1,591 +1,42 @@
import ApiUtils from "./apiUtils";
import DataUtils from "./utils";
import { useErrorStore } from "./stores/errors";
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
// Proxy method for external API calls
const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request";
// Estimate methods
const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate";
const FRAPPE_GET_ESTIMATES_METHOD = "custom_ui.api.db.estimates.get_estimate_table_data";
const FRAPPE_GET_ESTIMATE_BY_ADDRESS_METHOD = "custom_ui.api.db.estimates.get_estimate_from_address";
const FRAPPE_SEND_ESTIMATE_EMAIL_METHOD = "custom_ui.api.db.estimates.send_estimate_email";
const FRAPPE_LOCK_ESTIMATE_METHOD = "custom_ui.api.db.estimates.lock_estimate";
const FRAPPE_ESTIMATE_UPDATE_RESPONSE_METHOD = "custom_ui.api.db.estimates.manual_response";
// Job methods
const FRAPPE_GET_JOB_METHOD = "custom_ui.api.db.jobs.get_job";
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.jobs.get_jobs_table_data";
const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job";
const FRAPPE_GET_JOB_TASK_LIST_METHOD = "custom_ui.api.db.jobs.get_job_task_table_data";
const FRAPPE_GET_INSTALL_PROJECTS_METHOD = "custom_ui.api.db.jobs.get_install_projects";
// Invoice methods
const FRAPPE_GET_INVOICES_METHOD = "custom_ui.api.db.invoices.get_invoice_table_data";
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
// Warranty methods
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
// On-Site Meeting methods
const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD =
"custom_ui.api.db.bid_meetings.get_week_bid_meetings";
const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meetings";
// Address methods
const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
// Client methods
const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client";
const FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD = "custom_ui.api.db.clients.get_client_status_counts";
const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data";
const FRAPPE_GET_CLIENT_METHOD = "custom_ui.api.db.clients.get_client";
const FRAPPE_GET_CLIENT_NAMES_METHOD = "custom_ui.api.db.clients.get_client_names";
class Api {
// ============================================================================
// CORE REQUEST METHOD
// ============================================================================
static async request(frappeMethod, args = {}) {
const errorStore = useErrorStore();
args = ApiUtils.toSnakeCaseObject(args);
const request = { method: frappeMethod, args };
console.log("DEBUG: API - Request Args: ", request);
try {
let response = await frappe.call(request);
response = ApiUtils.toCamelCaseObject(response);
response.method = frappeMethod;
console.log("DEBUG: API - Request Response: ", response);
if (response.message.status && response.message.status === "error") {
throw new Error(response.message.message);
}
return response.message.data;
} catch (error) {
console.error("ERROR: API - Request Error: ", error);
errorStore.setApiError("Frappe API", error.message || "API request error");
throw error;
}
static async getAddresses(fields = []) {
const addressNames = await frappe.db.get_list("Address", { fields });
console.log("DEBUG: API - Fetched Address list: ", addressNames);
return addressNames;
}
// ============================================================================
// CLIENT METHODS
// ============================================================================
static async getClientStatusCounts(params = {}) {
return await this.request(FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD, params);
static async getDetailedAddress(name) {
const address = await frappe.db.get_doc("Address", name);
console.log("DEBUG: API - Fetched Detailed Address: ", address);
return address;
}
static async getClientDetails(clientName) {
return await this.request(FRAPPE_GET_CLIENT_METHOD, { clientName });
static async getCustomerList(fields = []) {
const customers = await frappe.db.get_list("Customer", { fields });
console.log("DEBUG: API - Fetched Customer list: ", customers);
return customers;
}
static async getClient(clientName) {
return await this.request(FRAPPE_GET_CLIENT_METHOD, { clientName });
static async getDetailedCustomer(name) {
const customer = await frappe.db.get_doc("Customer", name);
console.log("DEBUG: API - Fetched Detailed Customer: ", customer);
return customer;
}
/**
* Get paginated client data with filtering and sorting
* @param {Object} paginationParams - Pagination parameters from store
* @param {Object} filters - Filter parameters from store
* @param {Object} sorting - Sorting parameters from store (optional)
* @returns {Promise<{data: Array, pagination: Object}>}
*/
static async getPaginatedClientDetails(paginationParams = {}, filters = {}, sortings = []) {
const { page = 0, pageSize = 10 } = paginationParams;
const result = await this.request(FRAPPE_GET_CLIENT_TABLE_DATA_METHOD, {
filters,
sortings,
page: page + 1,
pageSize,
});
return result;
}
static async getCustomerNames(type) {
return await this.request(FRAPPE_GET_CLIENT_NAMES_METHOD, { type });
}
static async getInstallProjects(startDate, endDate) {
return await this.request(FRAPPE_GET_INSTALL_PROJECTS_METHOD, {
start_date: startDate,
end_date: endDate,
});
}
static async upsertJob(data) {
return await this.request(FRAPPE_UPSERT_JOB_METHOD, { data });
}
static async getClientNames(clientName) {
return await this.request(FRAPPE_GET_CLIENT_NAMES_METHOD, { searchTerm: clientName });
}
static async searchClientNames(searchTerm) {
return await this.request("custom_ui.api.db.clients.search_client_names", { searchTerm });
}
static async createClient(clientData) {
const result = await this.request(FRAPPE_UPSERT_CLIENT_METHOD, { data: clientData });
console.log("DEBUG: API - Created/Updated Client: ", result);
return result;
}
// ============================================================================
// ON-SITE MEETING METHODS
// ============================================================================
static async getUnscheduledBidMeetings() {
return await this.request(
"custom_ui.api.db.bid_meetings.get_unscheduled_bid_meetings",
);
}
static async getScheduledBidMeetings(fields = ["*"], filters = {}) {
return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters });
}
static async getWeekBidMeetings(weekStart, weekEnd) {
return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd });
}
static async updateBidMeeting(name, data) {
return await this.request("custom_ui.api.db.bid_meetings.update_bid_meeting", {
name,
data,
});
}
static async createBidMeeting(address, notes = "") {
return await this.request("custom_ui.api.db.bid_meetings.create_bid_meeting", {
address,
notes,
});
}
// ============================================================================
// ESTIMATE / QUOTATION METHODS
// ============================================================================
static async getQuotationItems() {
return await this.request("custom_ui.api.db.estimates.get_quotation_items");
}
static async getEstimateFromAddress(fullAddress) {
return await this.request(FRAPPE_GET_ESTIMATE_BY_ADDRESS_METHOD, {
full_address: fullAddress,
});
}
static async getEstimate(estimateName) {
return await this.request("custom_ui.api.db.estimates.get_estimate", {
estimate_name: estimateName,
});
}
static async getEstimateItems() {
return await this.request("custom_ui.api.db.estimates.get_estimate_items");
}
static async getPaginatedEstimateDetails(paginationParams = {}, filters = {}, sorting = null) {
const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams;
// Use sorting from the dedicated sorting parameter first, then fall back to pagination params
const actualSortField = sorting?.field || sortField;
const actualSortOrder = sorting?.order || sortOrder;
const options = {
page: page + 1, // Backend expects 1-based pages
page_size: pageSize,
filters,
sorting:
actualSortField && actualSortOrder
? `${actualSortField} ${actualSortOrder === -1 ? "desc" : "asc"}`
: null,
for_table: true,
};
console.log("DEBUG: API - Sending estimate options to backend:", options);
const result = await this.request(FRAPPE_GET_ESTIMATES_METHOD, { options });
return result;
}
static async createEstimate(estimateData) {
const result = await this.request(FRAPPE_UPSERT_ESTIMATE_METHOD, { data: estimateData });
return result;
}
static async sendEstimateEmail(estimateName) {
return await this.request(FRAPPE_SEND_ESTIMATE_EMAIL_METHOD, { estimateName });
}
static async lockEstimate(estimateName) {
return await this.request(FRAPPE_LOCK_ESTIMATE_METHOD, { estimateName });
}
static async updateEstimateResponse(estimateName, response) {
return await this.request(FRAPPE_ESTIMATE_UPDATE_RESPONSE_METHOD, {name: estimateName, response});
}
// ============================================================================
// JOB / PROJECT METHODS
// ============================================================================
static async getJobDetails() {
const projects = await this.getDocsList("Project");
static async getClientDetails() {
const data = [];
for (let prj of projects) {
let project = await this.getDetailedDoc("Project", prj.name);
const tableRow = {
name: project.name,
customInstallationAddress: project.custom_installation_address,
customer: project.customer,
status: project.status,
percentComplete: project.percent_complete,
};
data.push(tableRow);
const addresses = await this.getAddresses();
for (const addr of addresses) {
const clientDetail = {};
const fullAddress = await this.getDetailedAddress(addr["name"] || addr["Name"]);
const customer = await this.getDetailedCustomer(fullAddress["links"][0]["link_name"]);
clientDetail.customer = customer;
clientDetail.address = fullAddress;
data.push(clientDetail);
}
console.log("DEBUG: API - Fetched Client Details: ", data);
return data;
}
/**
* Get paginated job data with filtering and sorting
* @param {Object} paginationParams - Pagination parameters from store
* @param {Object} filters - Filter parameters from store
* @param {Object} sorting - Sorting parameters from store (optional)
* @returns {Promise<{data: Array, pagination: Object}>}
*/
static async getPaginatedJobDetails(paginationParams = {}, filters = {}, sorting = null) {
const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams;
// Use sorting from the dedicated sorting parameter first, then fall back to pagination params
const actualSortField = sorting?.field || sortField;
const actualSortOrder = sorting?.order || sortOrder;
const options = {
page: page + 1, // Backend expects 1-based pages
page_size: pageSize,
filters,
sorting:
actualSortField && actualSortOrder
? `${actualSortField} ${actualSortOrder === -1 ? "desc" : "asc"}`
: null,
for_table: true,
};
console.log("DEBUG: API - Sending job options to backend:", options);
const result = await this.request(FRAPPE_GET_JOBS_METHOD, { options });
return result;
}
static async getJob(jobName) {
if (frappe.db.exists("Project", jobName)) {
const result = await this.request(FRAPPE_GET_JOB_METHOD, { jobId: jobName })
console.log(`DEBUG: API - retrieved Job ${jobName}:`, result);
return result;
}
}
static async createJob(jobData) {
const payload = DataUtils.toSnakeCaseObject(jobData);
const result = await this.request(FRAPPE_UPSERT_JOB_METHOD, { data: payload });
console.log("DEBUG: API - Created Job: ", result);
return result;
}
static async getJobTaskList(jobName) {
if (frappe.db.exists("Project", jobName)) {
const result = await this.request(FRAPPE_GET_JOB_TASK_LIST_METHOD, { jobId: jobName })
console.log(`DEBUG: API - retrieved task list from job ${jobName}:`, result);
return result;
}
else {
console.log(`DEBUG: API - no record found for task like from job ${jobName}: `, result);
}
}
static async getPaginatedJobTaskDetails(paginationParams = {}, filters = {}, sorting = null) {
const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams;
// Use sorting from the dedicated sorting parameter first, then fall back to pagination params
const actualSortField = sorting?.field || sortField;
const actualSortOrder = sorting?.order || sortOrder;
const options = {
page: page + 1, // Backend expects 1-based pages
page_size: pageSize,
filters,
sorting:
actualSortField && actualSortOrder
? `${actualSortField} ${actualSortOrder === -1 ? "desc" : "asc"}`
: null,
for_table: true,
};
console.log("DEBUG: API - Sending job task options to backend:", options);
const result = await this.request(FRAPPE_GET_JOB_TASK_LIST_METHOD, { options, filters });
return result;
}
// ============================================================================
// INVOICE / PAYMENT METHODS
// ============================================================================
static async getPaginatedInvoiceDetails(paginationParams = {}, filters = {}, sorting = null) {
const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams;
// Use sorting from the dedicated sorting parameter first, then fall back to pagination params
const actualSortField = sorting?.field || sortField;
const actualSortOrder = sorting?.order || sortOrder;
const options = {
page: page + 1, // Backend expects 1-based pages
page_size: pageSize,
filters,
sorting:
actualSortField && actualSortOrder
? `${actualSortField} ${actualSortOrder === -1 ? "desc" : "asc"}`
: null,
for_table: true,
};
console.log("DEBUG: API - Sending invoice options to backend:", options);
const result = await this.request(FRAPPE_GET_INVOICES_METHOD, { options });
return result;
}
static async createInvoice(invoiceData) {
const payload = DataUtils.toSnakeCaseObject(invoiceData);
const result = await this.request(FRAPPE_UPSERT_INVOICE_METHOD, { data: payload });
console.log("DEBUG: API - Created Invoice: ", result);
return result;
}
// ============================================================================
// WARRANTY METHODS
// ============================================================================
static async getWarrantyData() {
const data = await this.request(FRAPPE_GET_WARRANTY_CLAIMS_METHOD);
console.log("DEBUG: API - getWarrantyData result: ", data);
return data;
}
/**
* Get paginated warranty claims data with filtering and sorting
* @param {Object} paginationParams - Pagination parameters from store
* @param {Object} filters - Filter parameters from store
* @param {Object} sorting - Sorting parameters from store (optional)
* @returns {Promise<{data: Array, pagination: Object}>}
*/
static async getPaginatedWarrantyData(paginationParams = {}, filters = {}, sorting = null) {
const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams;
// Use sorting from the dedicated sorting parameter first, then fall back to pagination params
const actualSortField = sorting?.field || sortField;
const actualSortOrder = sorting?.order || sortOrder;
const options = {
page: page + 1, // Backend expects 1-based pages
page_size: pageSize,
filters,
sorting:
actualSortField && actualSortOrder
? `${actualSortField} ${actualSortOrder === -1 ? "desc" : "asc"}`
: null,
for_table: true,
};
console.log("DEBUG: API - Sending warranty options to backend:", options);
const result = await this.request(FRAPPE_GET_WARRANTY_CLAIMS_METHOD, { options });
return result;
}
static async createWarranty(warrantyData) {
const payload = DataUtils.toSnakeCaseObject(warrantyData);
const result = await this.request(FRAPPE_UPSERT_INVOICE_METHOD, { data: payload });
console.log("DEBUG: API - Created Warranty: ", result);
return result;
}
// ============================================================================
// ADDRESS METHODS
// ============================================================================
static async getAddressByFullAddress(fullAddress) {
return await this.request("custom_ui.api.db.addresses.get_address_by_full_address", {
full_address: fullAddress,
});
}
static async getAddress(fullAddress) {
return await this.request("custom_ui.api.db.addresses.get_address", { fullAddress });
}
static async getContactsForAddress(fullAddress) {
return await this.request("custom_ui.api.db.addresses.get_contacts_for_address", {
fullAddress,
});
}
static async searchAddresses(searchTerm) {
const filters = {
full_address: ["like", `%${searchTerm}%`],
};
return await this.getAddresses(["full_address"], filters);
}
static async getAddresses(fields = ["*"], filters = {}) {
return await this.request(FRAPPE_GET_ADDRESSES_METHOD, { fields, filters });
}
// ============================================================================
// SERVICE / ROUTE / TIMESHEET METHODS
// ============================================================================
static async getServiceData() {
const data = DataUtils.dummyServiceData;
return data;
}
static async getRouteData() {
const data = DataUtils.dummyRouteData;
//const data = [];
const routes = getDocList("Pre-Built Routes");
for (const rt of routes) {
route = getDetailedDoc("Pre-Built Routes", rt.name);
let tableRow = {};
}
return data;
}
static async getTimesheetData() {
//const data = DataUtils.dummyTimesheetData;
const data = [];
const timesheets = await this.getDocsList("Timesheet");
for (const ts of timesheets) {
const timesheet = await this.getDetailedDoc("Timesheet", ts.name);
const tableRow = {
timesheetId: timesheet.name,
employee: timesheet.employee_name,
date: timesheet.date,
customer: timesheet.customer,
totalHours: timesheet.total_hours,
status: timesheet.status,
totalPay: timesheet.total_costing_amount,
};
console.log("Timesheet Row: ", tableRow);
data.push(tableRow);
}
console.log("DEBUG: API - getTimesheetData result: ", data);
return data;
}
// ============================================================================
// GENERIC DOCTYPE METHODS
// ============================================================================
/**
* Fetch a list of documents from a specific doctype.
*
* @param {String} doctype
* @param {string[]} fields
* @param {Object} filters
* @returns {Promise<Object[]>}
*/
static async getDocsList(
doctype,
fields = [],
filters = {},
page = 0,
start = 0,
pageLength = 0,
) {
const docs = await frappe.db.get_list(doctype, {
fields,
filters,
start: start,
limit: pageLength,
});
console.log(
`DEBUG: API - Fetched ${doctype} list (page ${page + 1}, start ${start}): `,
docs,
);
return docs;
}
/**
* Fetch a detailed document by doctype and name.
*
* @param {String} doctype
* @param {String} name
* @param {Object} filters
* @returns {Promise<Object>}
*/
static async getDetailedDoc(doctype, name, filters = {}) {
const doc = await frappe.db.get_doc(doctype, name, filters);
console.log(`DEBUG: API - Fetched Detailed ${doctype}: `, doc);
return doc;
}
static async getDocCount(doctype, filters = {}) {
const count = await frappe.db.count(doctype, filters);
console.log(`DEBUG: API - Counted ${doctype}: `, count);
return count;
}
static async createDoc(doctype, data) {
const doc = await frappe.db.insert({
...data,
doctype,
});
console.log(`DEBUG: API - Created ${doctype}: `, doc);
return doc;
}
static async getCompanyNames() {
const companies = await this.getDocsList("Company", ["name"]);
const companyNames = companies.map((company) => company.name);
console.log("DEBUG: API - Fetched Company Names: ", companyNames);
return companyNames;
}
// ============================================================================
// EXTERNAL API METHODS
// ============================================================================
/**
* Fetch a list of places (city/state) by zipcode using Zippopotamus API.
*
* @param {String} zipcode
* @returns {Promise<Object[]>}
*/
static async getCityStateByZip(zipcode) {
const url = `${ZIPPOPOTAMUS_BASE_URL}/${zipcode}`;
const response = await this.request(FRAPPE_PROXY_METHOD, { url, method: "GET" });
const { places } = response || {};
if (!places || places.length === 0) {
throw new Error(`No location data found for zip code ${zipcode}`);
}
return places.map((place) => ({
city: place["place name"],
state: place["state abbreviation"],
}));
}
static async getGeocode(address) {
const urlSafeAddress = encodeURIComponent(address);
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&q=${urlSafeAddress}`;
const response = await this.request(FRAPPE_PROXY_METHOD, {
url,
method: "GET",
headers: { "User-Agent": "FrappeApp/1.0 (+https://yourappdomain.com)" },
});
const { lat, lon } = response[0] || {};
return { latitude: parseFloat(lat), longitude: parseFloat(lon) };
}
}
export default Api;

View File

@ -1,53 +0,0 @@
class ApiUtils {
static toSnakeCaseObject(obj) {
console.log("Converting to snake case:", obj);
const newObj = Object.entries(obj).reduce((acc, [key, value]) => {
const snakeKey = key.replace(/[A-Z]/g, (match) => "_" + match.toLowerCase());
if (key === "sortings") {
value = value
? value.map((item) => {
const [field, order] = item;
const snakeField = field.replace(
/[A-Z]/g,
(match) => "_" + match.toLowerCase(),
);
return [snakeField, order];
})
: value;
}
if (Array.isArray(value)) {
value = value.map((item) => {
return Object.prototype.toString.call(item) === "[object Object]"
? this.toSnakeCaseObject(item)
: item;
});
} else if (Object.prototype.toString.call(value) === "[object Object]") {
value = this.toSnakeCaseObject(value);
}
acc[snakeKey] = value;
return acc;
}, {});
return newObj;
}
static toCamelCaseObject(obj) {
const newObj = Object.entries(obj).reduce((acc, [key, value]) => {
const camelKey = key.replace(/_([a-zA-Z0-9])/g, (match, p1) => p1.toUpperCase());
// check if value is an array
if (Array.isArray(value)) {
value = value.map((item) => {
return Object.prototype.toString.call(item) === "[object Object]"
? this.toCamelCaseObject(item)
: item;
});
} else if (Object.prototype.toString.call(value) === "[object Object]") {
value = this.toCamelCaseObject(value);
}
acc[camelKey] = value;
return acc;
}, {});
return newObj;
}
}
export default ApiUtils;

View File

@ -1,92 +0,0 @@
<script setup>
import { computed } from "vue";
import Select from "primevue/select";
import { useCompanyStore } from "@/stores/company";
const companyStore = useCompanyStore();
const companyOptions = computed(() =>
companyStore.companies.map((company) => ({ label: company, value: company })),
);
const selectedCompany = computed({
get: () => companyStore.selectedCompany,
set: (value) => companyStore.setSelectedCompany(value),
});
const fontSize = computed(() => {
const len = selectedCompany.value.length;
if (len > 12) return '0.71rem';
if (len > 10) return '0.75rem';
return '0.875rem';
});
</script>
<template>
<div class="company-selector">
<Select
v-model="selectedCompany"
:options="companyOptions"
option-label="label"
option-value="value"
placeholder="Select Company"
class="company-select"
/>
</div>
</template>
<style scoped>
.company-selector {
margin-bottom: 6px;
}
.company-select {
width: 100%;
}
:deep(.p-select) {
border-radius: 6px;
border: 1px solid var(--surface-border);
background-color: var(--surface-card);
color: var(--text-color);
transition: all 0.2s ease;
}
:deep(.p-select:hover) {
border-color: var(--theme-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:deep(.p-select.p-focus) {
border-color: var(--theme-primary);
box-shadow: 0 0 0 1px var(--theme-primary);
}
:deep(.p-select .p-select-label) {
padding: 0.5rem 0.75rem;
font-size: v-bind(fontSize);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.p-select .p-select-trigger) {
padding: 0 0.75rem;
}
:deep(.p-select-overlay) {
border-radius: 6px;
border: 1px solid var(--surface-border);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
:deep(.p-select-option) {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
:deep(.p-select-option:hover) {
background-color: var(--surface-hover);
}
</style>

View File

@ -0,0 +1,43 @@
<template lang="">
<div id="paging-controls">
<p>Show rows:</p>
<button class="page-num-button">25</button>
<button class="page-num-button">50</button>
<button class="page-num-button">100</button>
<button class="page-turn-button">Prev</button>
<button class="page-turn-button">Next</button>
</div>
<div ref="dataTableContainer"></div>
</template>
<script setup>
import { defineProps, ref, onMounted } from "vue";
const dataTableContainer = ref(null);
const props = defineProps({
columns: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
});
onMounted(() => {
console.log(dataTableContainer);
if (!dataTableContainer.value) {
console.error("Datatable container not found!");
return;
}
const columnsList = props.columns.map((col) => col.label);
const dataList = props.data.map((row) => props.columns.map((col) => row[col.fieldName] || ""));
// Pass the actual DOM element
new frappe.DataTable(dataTableContainer.value, {
columns: columnsList,
data: dataList,
});
});
</script>
<style lang=""></style>

View File

@ -1,9 +1,6 @@
<script setup>
import { ref, nextTick } from "vue";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useModalStore } from "@/stores/modal";
import { useNotificationStore } from "@/stores/notifications-primevue"
import CompanySelector from "./CompanySelector.vue";
import {
Home,
Community,
@ -13,464 +10,80 @@ import {
PathArrowSolid,
Clock,
HistoricShield,
Developer,
Neighbourhood,
Calculator,
ReceiveDollars,
NavArrowLeft,
NavArrowRight,
} from "@iconoir/vue";
import SidebarSpeedDial from "./SidebarSpeedDial.vue";
const emit = defineEmits(['toggle']);
const router = useRouter();
const modalStore = useModalStore();
const notifications = useNotificationStore();
const isCollapsed = ref(false);
const pendingOpen = ref(null);
const focusClientInput = (inputId) => {
if (typeof document === "undefined") return;
nextTick(() => {
const el = document.getElementById(inputId);
if (el && typeof el.focus === "function") {
el.focus();
}
});
};
const toggleSidebar = () => {
isCollapsed.value = !isCollapsed.value;
emit('toggle', isCollapsed.value);
};
const openSidebarAndDial = (category) => {
isCollapsed.value = false;
pendingOpen.value = category.name;
// ensure re-render picks up forceOpen
nextTick(() => {
pendingOpen.value = category.name;
});
};
const clientButtons = ref([
{
label: "Customer Lookup",
command: () => {
if (router.currentRoute.value.path === "/clients") {
focusClientInput("customerSearchInput");
} else {
router.push("/clients?lookup=customer");
}
}
},
{
label: "Property Lookup",
command: () => {
if (router.currentRoute.value.path === "/clients") {
focusClientInput("propertySearchInput");
} else {
router.push("/clients?lookup=property");
}
}
},
]);
const createButtons = ref([
{
label: "Client",
command: () => {
router.push("/client?new=true");
},
},
{
label: "Bid",
command: () => {
router.push("/calendar?tab=bids&new=true");
},
},
{
label: "Estimate",
command: () => {
//frappe.new_doc("Estimate");
router.push("/estimate?new=true");
},
},
{
label: "Job",
command: () => {
//frappe.new_doc("Job");
// modalStore.openModal("createJob");
notifications.addWarning("Job creation coming soon!");
},
},
{
label: "Invoice",
command: () => {
// modalStore.openModal("createInvoice");
notifications.addWarning("Invoice creation coming soon!");
},
},
{
label: "Warranty Claim",
command: () => {
// modalStore.openModal("createWarranty");
notifications.addWarning("Warranty Claim creation coming soon!");
},
},
{
label: "Note",
command: () => {
notifications.addWarning("Sending Notes coming soon!");
}
},
]);
const categories = ref([
const categories = [
{ name: "Home", icon: Home, url: "/" },
{ name: "Calendar", icon: Calendar, url: "/calendar" },
{
name: "CRM",
icon: Community,
buttons: clientButtons,
},
// { name: "Bids", icon: Neighbourhood, url: "/schedule-bid" },
{ name: "Estimates", icon: Calculator, url: "/estimates" },
{ name: "Clients", icon: Community, url: "/clients" },
{ name: "Jobs", icon: Hammer, url: "/jobs" },
{ name: "Payments/Invoices", icon: ReceiveDollars, url: "/invoices" },
{ name: "Routes", icon: PathArrowSolid, url: "/routes" },
{ name: "Create", icon: MultiplePagesPlus, url: "/create" },
{ name: "Time Sheets", icon: Clock, url: "/timesheets" },
{ name: "Warranties", icon: HistoricShield, url: "/warranties" },
{
name: "Create New",
icon: MultiplePagesPlus,
buttons: createButtons,
},
// { name: "Development", icon: Developer, buttons: developmentButtons },
]);
];
const handleCategoryClick = (category) => {
router.push(category.url);
};
</script>
<template>
<div id="sidebar" class="sidebar" :class="{ collapsed: isCollapsed }">
<div class="sidebar-top" :class="{ collapsed: isCollapsed }">
<CompanySelector />
</div>
<!-- Toggle Button -->
<div id="sidebar" class="sidebar">
<button
class="sidebar-toggle"
@click="toggleSidebar"
:title="isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
v-for="category in categories"
:class="[
'sidebar-button',
router.currentRoute.value.path === category.url ? 'active' : '',
]"
:key="category.name"
@click="handleCategoryClick(category)"
>
<component :is="isCollapsed ? NavArrowRight : NavArrowLeft" class="toggle-icon" />
<component :is="category.icon" class="button-icon" /><span class="button-text">{{
category.name
}}</span>
</button>
<template v-for="category in categories">
<template v-if="category.url">
<button
:class="[
'sidebar-button',
router.currentRoute.value.path === category.url ? 'active' : '',
]"
:key="`btn-${category.name}`"
@click="handleCategoryClick(category)"
:title="isCollapsed ? category.name : ''"
>
<component :is="category.icon" class="button-icon" />
<span class="button-text" v-if="!isCollapsed">{{ category.name }}</span>
</button>
</template>
<template v-else>
<SidebarSpeedDial
v-if="!isCollapsed"
:key="`dial-${category.name}`"
:icon="category.icon"
:label="category.name"
:items="category.buttons"
:force-open="pendingOpen === category.name"
@opened="pendingOpen = null"
/>
<button
v-else
class="sidebar-button"
:key="`collapsed-${category.name}`"
:title="category.name"
@click="openSidebarAndDial(category)"
>
<component :is="category.icon" class="button-icon" />
</button>
</template>
</template>
</div>
</template>
<style lang="css">
.sidebar-button.active,
.sidebar-button.active:hover{
background-color: var(--theme-primary);
.sidebar-button.active {
background-color: rgb(25, 60, 53);
color: white;
border-left: 3px solid var(--theme-primary-strong);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
}
.sidebar-button:hover {
background-color: var(--surface-hover);
color: var(--theme-primary);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.24);
transform: translateX(1px);
background-color: rgb(82, 132, 119);
}
.button-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
margin-left: 10px;
margin-right: 10px;
opacity: 0.9;
color: black;
}
.sidebar-button.active .button-icon,
.sidebar-button.active:hover .button-icon{
opacity: 1;
color: white;
}
.sidebar-button:hover .button-icon {
opacity: 1;
color: var(--theme-primary);
justify-self: flex-start;
margin-left: 5px;
}
.button-text {
flex: 1;
text-align: left;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 10px;
line-height: 1.3;
font-weight: 500;
letter-spacing: -0.01em;
margin-left: auto;
margin-right: auto;
}
.sidebar-button {
border-radius: 6px;
border: 1px solid transparent;
background-color: var(--surface-card);
color: var(--text-color);
border-radius: 5px;
border: none;
background-color: rgb(69, 112, 101);
color: white;
display: flex;
width: 100%;
align-items: center;
min-height: 40px;
height: 40px;
padding: 0;
box-sizing: border-box;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.speeddial-caret {
margin-right: 10px;
font-weight: 700;
font-size: 1rem;
line-height: 1;
}
.sidebar-button:active {
transform: scale(0.97);
}
#sidebar {
display: flex;
flex-direction: column;
width: 170px;
width: 150px;
align-self: flex-start;
gap: 5px;
background-color: var(--theme-surface-alt);
gap: 10px;
background-color: #f3f3f3;
padding: 10px;
border-radius: 8px;
border-radius: 5px;
margin-top: 10px;
position: relative;
border: 1px solid var(--surface-border);
transition: all 0.3s ease;
}
.sidebar-top {
margin-bottom: 6px;
}
.sidebar-top.collapsed {
margin-bottom: 8px;
width: 100%;
}
#sidebar.collapsed {
width: 56px;
min-width: 56px;
}
.sidebar-toggle {
position: absolute;
top: 8px;
right: -12px;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--theme-primary);
background-color: var(--surface-0);
color: var(--theme-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.sidebar-toggle:hover {
background-color: var(--theme-primary);
color: white;
transform: scale(1.15);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25);
}
.toggle-icon {
color: black;
width: 16px;
height: 16px;
}
.toggle-icon:hover {
color: white;
}
.collapsed .button-text {
display: none;
}
.collapsed .sidebar-button {
justify-content: center;
padding: 0;
}
.collapsed .button-icon {
margin: 0;
}
/* SpeedDial customization */
.sidebar-speeddial {
display: flex;
flex-direction: column;
width: 100%;
}
.sidebar-submenu {
background-color: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 6px;
margin-top: 6px;
padding: 6px 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.sidebar-sub-button {
border: none;
background: transparent;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
color: var(--text-color);
transition: background-color 0.15s ease, transform 0.1s ease;
}
.sidebar-sub-button:hover {
background-color: var(--surface-hover);
transform: translateX(2px);
}
.sub-button-text {
padding-left: 0;
}
.sidebar-accordion-enter-active,
.sidebar-accordion-leave-active {
transition: max-height 0.25s ease, opacity 0.2s ease, margin 0.2s ease;
}
.sidebar-accordion-enter-from,
.sidebar-accordion-leave-to {
max-height: 0;
opacity: 0;
margin-top: 0;
}
.sidebar-accordion-enter-to,
.sidebar-accordion-leave-from {
max-height: 600px;
opacity: 1;
margin-top: 6px;
}
/* Responsive adjustments for smaller screens */
@media (max-width: 768px) {
#sidebar {
width: 140px;
min-width: 140px;
padding: 6px;
gap: 3px;
}
.button-text {
font-size: 0.8rem;
}
.sidebar-button {
min-height: 36px;
height: 36px;
}
.button-icon {
width: 16px;
height: 16px;
margin-left: 8px;
margin-right: 8px;
}
}
@media (max-width: 480px) {
#sidebar {
width: 120px;
min-width: 120px;
padding: 5px;
gap: 2px;
}
.sidebar-button {
min-height: 34px;
height: 34px;
}
.button-text {
font-size: 0.75rem;
}
.button-icon {
width: 14px;
height: 14px;
margin-left: 6px;
margin-right: 6px;
}
}
</style>

View File

@ -1,80 +0,0 @@
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
icon: {
type: [Object, Function],
required: true,
},
label: {
type: String,
required: true,
},
items: {
type: Array,
default: () => [],
},
initiallyOpen: {
type: Boolean,
default: false,
},
forceOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["opened", "closed"]);
const isOpen = ref(props.initiallyOpen);
const toggle = () => {
isOpen.value = !isOpen.value;
};
const handleItem = (item) => {
if (typeof item?.command === "function") {
item.command();
}
toggle();
};
watch(
() => props.forceOpen,
(value) => {
if (value) {
isOpen.value = true;
}
},
);
watch(isOpen, (value) => {
if (value) {
emit("opened", props.label);
} else {
emit("closed", props.label);
}
});
</script>
<template>
<div class="sidebar-speeddial" :class="{ open: isOpen }">
<button class="sidebar-button" @click="toggle" :aria-expanded="isOpen">
<component :is="icon" class="button-icon" />
<span class="button-text">{{ label }}</span>
<span class="speeddial-caret" aria-hidden="true">{{ isOpen ? "-" : "+" }}</span>
</button>
<transition name="sidebar-accordion">
<div v-show="isOpen" class="sidebar-submenu">
<button
v-for="item in items"
:key="item.label"
class="sidebar-sub-button"
@click="handleItem(item)"
>
<span class="sub-button-text">{{ item.label }}</span>
</button>
</div>
</transition>
</div>
</template>

View File

@ -1,78 +0,0 @@
<template>
<div class="calendar-navigation">
<Tabs value="0">
<TabList>
<Tab value="0">Bids</Tab>
<Tab value="1">Install</Tab>
<Tab value="2">Service</Tab>
<Tab value="3">Lowe Fencing</Tab>
<Tab value="4">Daniel's Landscaping</Tab>
<Tab value="5">Nuco Yardcare</Tab>
<Tab value="6">Warranties</Tab>
</TabList>
<TabPanels>
<TabPanel header="Bids" value="0">
<ScheduleBid />
</TabPanel>
<TabPanel header="Install" value="1">
<InstallsCalendar />
</TabPanel>
<TabPanel header="Service" value="2">
<div class="coming-soon">
<p>Service feature coming soon!</p>
</div>
</TabPanel>
<TabPanel header="Lowe Fencing" value="3">
<div class="coming-soon">
<p>Lowe Fencing calendar coming soon!</p>
</div>
</TabPanel>
<TabPanel header="Daniel's Landscaping" value="4">
<div class="coming-soon">
<p>Daniel's Calendar coming soon!</p>
</div>
</TabPanel>
<TabPanel header="Nuco Yardcare" value="5">
<div class="coming-soon">
<p>Nuco calendar coming soon!</p>
</div>
</TabPanel>
<TabPanel header="Warranties" value="6">
<div class="coming-soon">
<p>Warranties Calendar coming soon!</p>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Tab from 'primevue/tab';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import TabPanel from 'primevue/tabpanel';
import TabPanels from 'primevue/tabpanels';
import ScheduleBid from '../calendar/bids/ScheduleBid.vue';
import JobsCalendar from '../calendar/jobs/JobsCalendar.vue';
import InstallsCalendar from '../calendar/jobs/InstallsCalendar.vue';
import { useNotificationStore } from '../../stores/notifications-primevue';
const notifications = useNotificationStore();
</script>
<style scoped>
.calendar-navigation {
width: 100%;
}
.coming-soon {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
color: var(--theme-text-muted);
font-size: 1.2rem;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,188 +0,0 @@
<template>
<div class="form-section">
<h3>Address Information</h3>
<div class="form-grid">
<div class="form-field full-width">
<label for="address-line1"> Address Line 1 <span class="required">*</span> </label>
<InputText
id="address-line1"
v-model="localFormData.addressLine1"
:disabled="isSubmitting"
placeholder="Street address"
class="w-full"
/>
</div>
<div class="form-field full-width">
<label for="address-line2">Address Line 2</label>
<InputText
id="address-line2"
v-model="localFormData.addressLine2"
:disabled="isSubmitting"
placeholder="Apt, suite, unit, etc."
class="w-full"
/>
</div>
<div class="form-field">
<label for="zipcode"> Zip Code <span class="required">*</span> </label>
<InputText
id="zipcode"
v-model="localFormData.pincode"
:disabled="isSubmitting"
@input="handleZipcodeInput"
maxlength="5"
placeholder="12345"
class="w-full"
/>
</div>
<div class="form-field">
<label for="city"> City <span class="required">*</span> </label>
<InputText
id="city"
v-model="localFormData.city"
:disabled="isSubmitting || zipcodeLookupDisabled"
placeholder="City"
class="w-full"
/>
</div>
<div class="form-field">
<label for="state"> State <span class="required">*</span> </label>
<InputText
id="state"
v-model="localFormData.state"
:disabled="isSubmitting || zipcodeLookupDisabled"
placeholder="State"
class="w-full"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import InputText from "primevue/inputtext";
import Api from "../../api";
import { useNotificationStore } from "../../stores/notifications-primevue";
const props = defineProps({
formData: {
type: Object,
required: true,
},
isSubmitting: {
type: Boolean,
default: false,
},
isEditMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:formData"]);
const notificationStore = useNotificationStore();
const localFormData = computed({
get: () => props.formData,
set: (value) => emit("update:formData", value),
});
const zipcodeLookupDisabled = ref(true);
const handleZipcodeInput = async (event) => {
const input = event.target.value;
// Only allow digits
const digitsOnly = input.replace(/\D/g, "");
// Limit to 5 digits
if (digitsOnly.length > 5) {
return;
}
localFormData.value.pincode = digitsOnly;
// Reset city/state if zipcode is not complete
if (digitsOnly.length < 5 && zipcodeLookupDisabled.value) {
localFormData.value.city = "";
localFormData.value.state = "";
zipcodeLookupDisabled.value = false;
}
// Fetch city/state when 5 digits entered
if (digitsOnly.length === 5) {
try {
console.log("DEBUG: Looking up city/state for zip code:", digitsOnly);
const places = await Api.getCityStateByZip(digitsOnly);
console.log("DEBUG: Retrieved places:", places);
if (places && places.length > 0) {
// Auto-populate city and state
localFormData.value.city = places[0]["city"];
localFormData.value.state = places[0]["state"];
zipcodeLookupDisabled.value = true;
notificationStore.addSuccess(`Found: ${places[0]["city"]}, ${places[0]["state"]}`);
}
} catch (error) {
// Enable manual entry if lookup fails
zipcodeLookupDisabled.value = false;
notificationStore.addWarning(
"Could not find city/state for this zip code. Please enter manually.",
);
}
}
};
</script>
<style scoped>
.form-section {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.form-section h3 {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.25rem;
font-weight: 600;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field.full-width {
grid-column: 1 / -1;
}
.form-field label {
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.required {
color: var(--red-500);
}
.w-full {
width: 100% !important;
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,435 +0,0 @@
<template>
<div>
<div class="form-section">
<div class="section-header">
<h3>Client Information</h3>
<label class="toggle-container" v-if="!isEditMode">
<v-switch v-model="isNewClient" color="success" />
<span class="toggle-label">New Client</span>
</label>
</div>
<div class="form-grid">
<div class="form-field">
<label for="customer-name"> Customer Name <span class="required">*</span> </label>
<div class="input-with-button">
<InputText
id="customer-name"
v-model="localFormData.customerName"
:disabled="isSubmitting || isEditMode"
placeholder="Enter customer name"
class="w-full"
/>
<Button
label="Check Client"
size="small"
icon="pi pi-user-check"
class="check-btn"
@click="checkCustomerExists"
:disabled="isSubmitting || !localFormData.customerName.trim()"
>Check</Button>
<Button
v-if="!isNewClient && !isEditMode"
@click="searchCustomers"
:disabled="isSubmitting || !localFormData.customerName.trim()"
size="small"
icon="pi pi-search"
class="search-btn"
></Button>
</div>
</div>
<div class="form-field">
<label for="customer-type"> Customer Type <span class="required">*</span> </label>
<Select
id="customer-type"
v-model="localFormData.customerType"
:options="customerTypeOptions"
:disabled="isSubmitting || (!isNewClient && !isEditMode)"
placeholder="Select customer type"
class="w-full"
/>
</div>
</div>
</div>
<!-- Customer Search Results Modal -->
<Dialog
:visible="showCustomerSearchModal"
@update:visible="showCustomerSearchModal = $event"
header="Select Customer"
:modal="true"
class="search-dialog"
>
<div class="search-results">
<div v-if="customerSearchResults.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<p>No customers found matching your search.</p>
</div>
<div v-else class="results-list">
<div
v-for="(customerName, index) in customerSearchResults"
:key="index"
class="result-item"
@click="selectCustomer(customerName)"
>
<strong>{{ customerName }}</strong>
<i class="pi pi-chevron-right"></i>
</div>
</div>
</div>
<template #footer>
<Button label="Cancel" severity="secondary" @click="showCustomerSearchModal = false" />
</template>
</Dialog>
</div>
</template><script setup>
import { ref, watch, computed } from "vue";
import InputText from "primevue/inputtext";
import Select from "primevue/select";
import Dialog from "primevue/dialog";
import Api from "../../api";
import { useNotificationStore } from "../../stores/notifications-primevue";
const props = defineProps({
formData: {
type: Object,
required: true,
},
isSubmitting: {
type: Boolean,
default: false,
},
isEditMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:formData", "newClientToggle", "customerSelected"]);
const notificationStore = useNotificationStore();
const localFormData = computed({
get: () => props.formData,
set: (value) => emit("update:formData", value),
});
const isNewClient = ref(true);
const showCustomerSearchModal = ref(false);
const customerSearchResults = ref([]);
const customerTypeOptions = ["Individual", "Partnership", "Company"];
const mapContactsFromClient = (contacts = []) => {
if (!Array.isArray(contacts) || contacts.length === 0) {
return [
{
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: true,
},
];
}
return contacts.map((contact, index) => ({
firstName: contact.firstName || "",
lastName: contact.lastName || "",
phoneNumber: contact.phoneNumber || contact.phone || contact.mobileNo || "",
email: contact.email || contact.emailId || contact.customEmail || "",
contactRole: contact.contactRole || contact.role || "",
isPrimary: contact.isPrimary ?? contact.isPrimaryContact ?? index === 0,
}));
};
// Watch for toggle changes
watch(isNewClient, (newValue) => {
emit("newClientToggle", newValue);
});
const searchCustomers = async () => {
const searchTerm = localFormData.value.customerName.trim();
if (!searchTerm) return;
try {
// Get all customers and filter by search term
const allCustomers = await Api.getClientNames(searchTerm);
const matchingNames = allCustomers.filter((name) =>
name.toLowerCase().includes(searchTerm.toLowerCase()),
);
if (matchingNames.length === 0) {
notificationStore.addWarning("No customers found matching your search criteria.");
} else {
// Store just the names for display
customerSearchResults.value = matchingNames;
showCustomerSearchModal.value = true;
}
} catch (error) {
console.error("Error searching customers:", error);
notificationStore.addError("Failed to search customers. Please try again.");
}
};
const checkCustomerExists = async () => {
const searchTerm = localFormData.value.customerName.trim();
if (!searchTerm) return;
try {
const client = await Api.getClient(searchTerm);
if (!client) {
notificationStore.addInfo("Customer is not in our system yet.");
return;
}
localFormData.value.customerName = client.customerName || searchTerm;
localFormData.value.customerType = client.customerType || localFormData.value.customerType;
localFormData.value.contacts = mapContactsFromClient(client.contacts);
isNewClient.value = false;
showCustomerSearchModal.value = false;
emit("customerSelected", client);
notificationStore.addSuccess(
`Customer ${localFormData.value.customerName} found and loaded from system.`,
);
} catch (error) {
console.error("Error checking customer:", error);
const message =
typeof error?.message === "string" &&
error.message.toLowerCase().includes("not found")
? "Customer is not in our system yet."
: "Failed to check customer. Please try again.";
if (message.includes("not in our system")) {
notificationStore.addInfo(message);
} else {
notificationStore.addError(message);
}
}
};
const selectCustomer = async (customerName) => {
try {
// Fetch full customer data
const clientData = await Api.getClient(customerName);
localFormData.value.customerName = clientData.customerName;
localFormData.value.customerType = clientData.customerType;
localFormData.value.contacts = mapContactsFromClient(clientData.contacts);
showCustomerSearchModal.value = false;
// Pass the full client data including contacts
emit("customerSelected", clientData);
} catch (error) {
console.error(`Error fetching client ${customerName}:`, error);
notificationStore.addError("Failed to load customer details. Please try again.");
}
};
defineExpose({
isNewClient,
});
</script>
<style scoped>
.form-section {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.25rem;
font-weight: 600;
}
.toggle-container {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.85rem;
}
.toggle-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-secondary);
cursor: pointer;
user-select: none;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field label {
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.required {
color: var(--red-500);
}
.input-with-button {
display: flex;
gap: 0.5rem;
}
.w-full {
width: 100% !important;
}
.search-dialog {
max-width: 500px;
}
.search-results {
min-height: 200px;
}
.no-results {
text-align: center;
padding: 40px 20px;
color: var(--text-color-secondary);
}
.no-results i {
font-size: 2em;
color: var(--orange-500);
margin-bottom: 10px;
display: block;
}
.results-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.result-item {
padding: 1rem;
border: 1px solid var(--surface-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.result-item:hover {
background-color: var(--surface-hover);
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.customer-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.customer-type {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
.iconoir-btn {
background: none;
border: none;
padding: 0.25rem 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
}
.iconoir-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.iconoir-btn:hover:not(:disabled) {
background: var(--surface-hover);
}
.check-btn {
border: 1px solid var(--primary-color);
color: white;
background: var(--primary-color);
padding: 0.25rem 0.5rem;
min-width: 5rem;
justify-content: center;
font-size: 0.8rem;
}
.check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.check-btn:hover:not(:disabled) {
background: var(--surface-hover);
}
.search-btn {
background: var(--primary-color);
border: 1px solid var(--primary-color);
padding: 0.25rem 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
color: white;
min-width: 8rem;
}
.search-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.search-btn:hover:not(:disabled) {
background: var(--surface-hover);
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>

View File

@ -1,363 +0,0 @@
<template>
<div class="form-section">
<div class="section-header">
<h3>Contact Information</h3>
</div>
<div class="form-grid">
<div
v-for="(contact, index) in localFormData.contacts"
:key="index"
class="contact-item"
>
<div class="contact-header">
<h4>Contact {{ index + 1 }}</h4>
<Button
v-if="localFormData.contacts.length > 1"
@click="removeContact(index)"
size="small"
severity="danger"
label="Delete"
class="remove-btn"
/>
</div>
<div class="form-rows">
<div class="form-row">
<div class="form-field">
<label :for="`first-name-${index}`">
First Name <span class="required">*</span>
</label>
<InputText
:id="`first-name-${index}`"
v-model="contact.firstName"
:disabled="isSubmitting"
placeholder="Enter first name"
class="w-full"
/>
</div>
<div class="form-field">
<label :for="`last-name-${index}`">
Last Name <span class="required">*</span>
</label>
<InputText
:id="`last-name-${index}`"
v-model="contact.lastName"
:disabled="isSubmitting"
placeholder="Enter last name"
class="w-full"
/>
</div>
<div class="form-field">
<label :for="`contact-role-${index}`">Role</label>
<Select
:id="`contact-role-${index}`"
v-model="contact.contactRole"
:options="roleOptions"
optionLabel="label"
optionValue="value"
:disabled="isSubmitting"
placeholder="Select a role"
class="w-full"
/>
</div>
</div>
<div class="form-row">
<div class="form-field">
<label :for="`email-${index}`">Email</label>
<InputText
:id="`email-${index}`"
v-model="contact.email"
:disabled="isSubmitting"
type="email"
placeholder="email@example.com"
class="w-full"
/>
</div>
<div class="form-field">
<label :for="`phone-number-${index}`">Phone</label>
<InputText
:id="`phone-number-${index}`"
v-model="contact.phoneNumber"
:disabled="isSubmitting"
placeholder="(555) 123-4567"
class="w-full"
@input="formatPhone(index, $event)"
@keydown="handlePhoneKeydown($event, index)"
/>
</div>
<div class="form-field">
<v-checkbox
v-model="contact.isPrimary"
label="Primary Contact"
:disabled="isSubmitting"
@change="setPrimary(index)"
/>
</div>
</div>
</div>
</div>
<div class="form-field full-width">
<Button label="Add another contact" @click="addContact" :disabled="isSubmitting" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed, onMounted } from "vue";
import InputText from "primevue/inputtext";
import Select from "primevue/select";
import Button from "primevue/button";
const props = defineProps({
formData: {
type: Object,
required: true,
},
isSubmitting: {
type: Boolean,
default: false,
},
isEditMode: {
type: Boolean,
default: false,
},
isNewClientLocked: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:formData"]);
const localFormData = computed({
get: () => {
if (!props.formData.contacts || props.formData.contacts.length === 0) {
props.formData.contacts = [
{
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: true,
},
];
}
return props.formData;
},
set: (value) => emit("update:formData", value),
});
const roleOptions = ref([
{ label: "Owner", value: "Owner" },
{ label: "Property Manager", value: "Property Manager" },
{ label: "Tenant", value: "Tenant" },
{ label: "Builder", value: "Builder" },
{ label: "Neighbor", value: "Neighbor" },
{ label: "Family Member", value: "Family Member" },
{ label: "Realtor", value: "Realtor" },
{ label: "Other", value: "Other" },
]);
// Ensure at least one contact
onMounted(() => {
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
localFormData.value.contacts = [
{
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: true,
},
];
}
});
const addContact = () => {
localFormData.value.contacts.push({
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: false,
});
};
const removeContact = (index) => {
if (localFormData.value.contacts.length > 1) {
const wasPrimary = localFormData.value.contacts[index].isPrimary;
localFormData.value.contacts.splice(index, 1);
if (wasPrimary && localFormData.value.contacts.length > 0) {
localFormData.value.contacts[0].isPrimary = true;
}
}
};
const setPrimary = (index) => {
localFormData.value.contacts.forEach((contact, i) => {
contact.isPrimary = i === index;
});
};
const formatPhoneNumber = (value) => {
const digits = value.replace(/\D/g, "").slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
};
const formatPhone = (index, event) => {
const value = event.target.value;
const formatted = formatPhoneNumber(value);
localFormData.value.contacts[index].phoneNumber = formatted;
};
const handlePhoneKeydown = (event, index) => {
const allowedKeys = [
"Backspace",
"Delete",
"Tab",
"Escape",
"Enter",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
];
if (allowedKeys.includes(event.key)) {
return;
}
// Allow Ctrl+A, Ctrl+C, Ctrl+V, etc.
if (event.ctrlKey || event.metaKey) {
return;
}
// Check if it's a digit
if (!/\d/.test(event.key)) {
event.preventDefault();
return;
}
// Check current digit count
const currentDigits = localFormData.value.contacts[index].phoneNumber.replace(
/\D/g,
"",
).length;
if (currentDigits >= 10) {
event.preventDefault();
}
};
defineExpose({});
</script>
<style scoped>
.form-section {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.25rem;
font-weight: 600;
}
.contact-item {
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
background: var(--surface-section);
}
.contact-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.contact-header h4 {
margin: 0;
color: var(--text-color);
font-size: 1.1rem;
font-weight: 600;
}
.remove-btn {
margin-left: auto;
}
.contact-item .form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.form-rows {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field.full-width {
grid-column: 1 / -1;
}
.form-field label {
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.required {
color: var(--red-500);
}
.w-full {
width: 100% !important;
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>

View File

@ -1,34 +0,0 @@
<template>
<h3>History</h3>
<Tabs value="0" class="tabs">
<TabList>
<Tab value="0">Communication History</Tab>
<Tab value="1">Site Visits</Tab>
<Tab value="2">Ownership History</Tab>
</TabList>
<TabPanels>
<TabPanel value="0">
<div>Descending order of communications with the customer go here.</div>
<button class="sidebar-button">Add New</button>
</TabPanel>
<TabPanel value="1">
<div>Site Visits</div>
</TabPanel>
<TabPanel value="2">
<div>Ownership History</div>
</TabPanel>
</TabPanels>
</Tabs>
</template>
<script setup>
import { ref, onMounted } from "vue";
import Tabs from "primevue/tabs";
import TabList from "primevue/tablist";
import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
</script>
<style scoped>
</style>

View File

@ -1,811 +0,0 @@
<template>
<div class="overview-container">
<template v-if="!editMode">
<Button
@click="toggleEditMode"
icon="pi pi-pencil"
label="Edit Information"
size="small"
severity="secondary"
/>
</template>
<div class="status-cards">
<template v-if="isNew || editMode">
<ClientInformationForm
ref="clientInfoRef"
:form-data="formData"
@update:form-data="formData = $event"
:is-submitting="isSubmitting"
:is-edit-mode="editMode"
@new-client-toggle="handleNewClientToggle"
@customer-selected="handleCustomerSelected"
/>
<ContactInformationForm
ref="contactInfoRef"
:form-data="formData"
@update:form-data="formData = $event"
:is-submitting="isSubmitting"
:is-edit-mode="editMode"
:is-new-client-locked="isNewClientMode"
:available-contacts="availableContacts"
@new-contact-toggle="handleNewContactToggle"
/>
<AddressInformationForm
:form-data="formData"
@update:form-data="formData = $event"
:is-submitting="isSubmitting"
:is-edit-mode="editMode"
/>
</template>
<!-- Display Mode (existing client view) -->
<template v-else>
<!-- Address Info Card -->
<div class="info-card" v-if="selectedAddressData">
<h3>General Information</h3>
<div class="info-grid">
<div class="info-item full-width">
<label>Address Title:</label>
<span>{{ selectedAddressData.addressTitle || "N/A" }}</span>
</div>
<div class="info-item full-width">
<label>Full Address:</label>
<span>{{ fullAddress }}</span>
</div>
<div class="info-item full-width">
<label>City:</label>
<span>{{ selectedAddressData.city || "N/A" }}</span>
</div>
<div class="info-item full-width">
<label>State:</label>
<span>{{ selectedAddressData.state || "N/A" }}</span>
</div>
<div class="info-item full-width">
<label>Zip Code:</label>
<span>{{ selectedAddressData.pincode || "N/A" }}</span>
</div>
</div>
</div>
<!-- Client Basic Info Card -->
<div class="info-card">
<h3>Contact Information</h3>
<template v-if="contactsForAddress.length > 0">
<div class="info-grid">
<div class="info-item">
<label>Customer Name:</label>
<span>{{ clientData?.customerName || "N/A" }}</span>
</div>
<div class="info-item">
<label>Customer Type:</label>
<span>{{ clientData?.customerType || "N/A" }}</span>
</div>
<div v-if="contactsForAddress.length > 1" class="contact-selector">
<Dropdown
v-model="selectedContactIndex"
:options="contactOptions"
option-label="label"
option-value="value"
placeholder="Select Contact"
class="w-full"
/>
</div>
<div class="info-grid">
<div class="info-item">
<label>Contact Name:</label>
<span>{{ contactFullName }}</span>
</div>
<div class="info-item">
<label>Phone:</label>
<span>{{ primaryContactPhone }}</span>
</div>
<div class="info-item">
<label>Email:</label>
<span>{{ primaryContactEmail }}</span>
</div>
</div>
<div class="info-item">
<label>Customer Group:</label>
<span>{{ clientData?.customerGroup || "N/A" }}</span>
</div>
<div class="info-item">
<label>Territory:</label>
<span>{{ clientData?.territory || "N/A" }}</span>
</div>
</div>
</template>
<template v-else>
<p>No contacts available for this address.</p>
</template>
</div>
<!-- Financials At a Glance -->
<div class="info-card">
<h3>Open Balances</h3>
<span>$3200.00</span>
<button class="sidebar-button" @click="navigateTo('/invoices')">
Go to Invoices
</button>
</div>
</template>
</div>
<!-- Form Actions -->
<div class="form-actions" v-if="isNew || editMode">
<Button
@click="handleCancel"
:label="editMode ? 'Cancel' : 'Clear'"
severity="secondary"
:disabled="isSubmitting"
/>
<Button
@click="handleSave"
:label="isNew ? 'Create' : 'Update'"
:loading="isSubmitting"
:disabled="!isFormValid"
/>
</div>
<div class="status-cards">
<!-- Location Map (only for existing clients) -->
<div class="map-card" v-if="!isNew && !editMode">
<h3>Location</h3>
<LeafletMap
:latitude="latitude"
:longitude="longitude"
:address-title="selectedAddressData?.addressTitle || 'Client Location'"
map-height="350px"
:zoom-level="16"
/>
<div v-if="latitude && longitude" class="coordinates-info">
<small>
<strong>Coordinates:</strong>
{{ parseFloat(latitude).toFixed(6) }}, {{ parseFloat(longitude).toFixed(6) }}
</small>
</div>
</div>
<!-- Client History -->
<div class="info-card">
<History/>
</div>
</div>
<!-- Edit Confirmation Dialog -->
<Dialog
:visible="showEditConfirmDialog"
header="Confirm Edit"
:modal="true"
:closable="false"
class="confirm-dialog"
>
<p>
Are you sure you want to edit this client information? This will enable editing
mode.
</p>
<template #footer>
<Button
label="Cancel"
severity="secondary"
@click="showEditConfirmDialog = false"
/>
<Button label="Yes, Edit" @click="confirmEdit" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { computed, ref, watch, onMounted } from "vue";
import Badge from "primevue/badge";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import Dropdown from "primevue/dropdown";
import LeafletMap from "../common/LeafletMap.vue";
import ClientInformationForm from "./ClientInformationForm.vue";
import ContactInformationForm from "./ContactInformationForm.vue";
import AddressInformationForm from "./AddressInformationForm.vue";
import History from "./History.vue";
import DataUtils from "../../utils";
import Api from "../../api";
import { useRouter } from "vue-router";
import { useNotificationStore } from "../../stores/notifications-primevue";
import { useCompanyStore } from "../../stores/company";
const props = defineProps({
clientData: {
type: Object,
default: () => ({}),
},
selectedAddress: {
type: String,
default: "",
},
isNew: {
type: Boolean,
default: false,
},
});
const router = useRouter();
const notificationStore = useNotificationStore();
const companyStore = useCompanyStore();
const navigateTo = (path) => {
router.push(path);
};
// Refs for child components
const clientInfoRef = ref(null);
const contactInfoRef = ref(null);
// Form state
const editMode = ref(false);
const showEditConfirmDialog = ref(false);
const isSubmitting = ref(false);
const isNewClientMode = ref(false);
const availableContacts = ref([]);
// Form data
const formData = ref({
customerName: "",
customerType: "",
addressTitle: "",
addressLine1: "",
addressLine2: "",
pincode: "",
city: "",
state: "",
contacts: [],
});
// Initialize form data when component mounts
onMounted(() => {
if (props.isNew) {
resetForm();
isNewClientMode.value = true; // Set to true for new client mode
console.log("Mounted in new client mode - initialized empty form");
} else if (props.clientData && Object.keys(props.clientData).length > 0) {
populateFormFromClientData();
console.log("Mounted with existing client data - populated form");
} else {
resetForm();
console.log("Mounted with no client data - initialized empty form");
}
});
// Watch for clientData changes
watch(
() => props.clientData,
(newData) => {
if (props.isNew) {
resetForm();
} else if (newData && Object.keys(newData).length > 0) {
populateFormFromClientData();
} else {
resetForm();
}
},
{ deep: true },
);
// Watch for isNew prop changes
watch(
() => props.isNew,
(isNewValue) => {
if (isNewValue) {
resetForm();
editMode.value = false;
console.log("Switched to new client mode - reset form data");
} else if (props.clientData && Object.keys(props.clientData).length > 0) {
populateFormFromClientData();
} else {
resetForm();
}
},
{ immediate: false },
);
// Find the address data object that matches the selected address string
const selectedAddressData = computed(() => {
if (!props.clientData?.addresses || !props.selectedAddress) {
return null;
}
return props.clientData.addresses.find(
(addr) => DataUtils.calculateFullAddress(addr) === props.selectedAddress,
);
});
// Get coordinates from the selected address
const latitude = computed(() => {
if (!selectedAddressData.value) return null;
return selectedAddressData.value.customLatitude || selectedAddressData.value.latitude || null;
});
const longitude = computed(() => {
if (!selectedAddressData.value) return null;
return (
selectedAddressData.value.customLongitude || selectedAddressData.value.longitude || null
);
});
// Calculate full address for display
const fullAddress = computed(() => {
if (!selectedAddressData.value) return "N/A";
return DataUtils.calculateFullAddress(selectedAddressData.value);
});
// Get contacts linked to the selected address
const contactsForAddress = computed(() => {
console.log("DEBUG: props.clientData:", props.clientData);
console.log("DEBUG: Calculating contacts for address:", props.selectedAddress);
if (!selectedAddressData.value?.customLinkedContacts && !props.clientData?.contacts) return [];
return props.clientData.contacts
});
// Selected contact index for display
const selectedContactIndex = ref(0);
// Options for contact dropdown
const contactOptions = computed(() =>
contactsForAddress.value.map((c, i) => ({
label:
c.fullName || `${c.firstName || ""} ${c.lastName || ""}`.trim() || "Unnamed Contact",
value: i,
})),
);
// Selected contact for display
const selectedContact = computed(
() => contactsForAddress.value[selectedContactIndex.value] || null,
);
// Calculate contact full name
const contactFullName = computed(() => {
if (!selectedContact.value) return "N/A";
return (
selectedContact.value.fullName ||
`${selectedContact.value.firstName || ""} ${selectedContact.value.lastName || ""}`.trim() ||
"N/A"
);
});
// Calculate primary contact phone
const primaryContactPhone = computed(() => {
if (!selectedContact.value) return "N/A";
return selectedContact.value.phone || selectedContact.value.mobileNo || "N/A";
});
// Calculate primary contact email
const primaryContactEmail = computed(() => {
if (!selectedContact.value) return "N/A";
return selectedContact.value.emailId || selectedContact.value.customEmail || "N/A";
});
// Form validation
const isFormValid = computed(() => {
const hasCustomerName = formData.value.customerName?.trim();
const hasCustomerType = formData.value.customerType?.trim();
const hasAddressLine1 = formData.value.addressLine1?.trim();
const hasPincode = formData.value.pincode?.trim();
const hasCity = formData.value.city?.trim();
const hasState = formData.value.state?.trim();
const hasContacts = formData.value.contacts && formData.value.contacts.length > 0;
// Check that all contacts have required fields
const allContactsValid = formData.value.contacts?.every((contact) => {
return (
contact.firstName?.trim() &&
contact.lastName?.trim() &&
contact.email?.trim() &&
contact.phoneNumber?.trim() &&
contact.contactRole?.trim()
);
});
return (
hasCustomerName &&
hasCustomerType &&
hasAddressLine1 &&
hasPincode &&
hasCity &&
hasState &&
hasContacts &&
allContactsValid
);
});
// Helper function to get badge severity based on status
const getStatusSeverity = (status) => {
switch (status) {
case "Not Started":
return "danger";
case "In Progress":
return "warn";
case "Completed":
return "success";
default:
return "secondary";
}
};
// Handle status button clicks
const handleStatusClick = (type) => {
let status;
let path;
switch (type) {
case "onsite":
status = selectedAddressData.value.customOnsiteMeetingScheduled || "Not Started";
path = "/schedule-onsite";
break;
case "estimate":
status = selectedAddressData.value.customEstimateSentStatus || "Not Started";
path = "/estimate";
break;
case "job":
status = selectedAddressData.value.customJobStatus || "Not Started";
path = "/job";
break;
case "payment":
status = selectedAddressData.value.customPaymentReceivedStatus || "Not Started";
path = "/invoices";
break;
default:
return;
}
const query = { address: fullAddress.value };
if (status === "Not Started") {
query.new = true;
}
router.push({ path, query });
};
// Form methods
const resetForm = () => {
formData.value = {
customerName: "",
customerType: "",
addressTitle: "",
addressLine1: "",
addressLine2: "",
pincode: "",
city: "",
state: "",
contacts: [],
};
availableContacts.value = [];
isNewClientMode.value = false;
editMode.value = false;
console.log("Form reset - all fields cleared");
};
const populateFormFromClientData = () => {
if (!selectedAddressData.value) return;
formData.value = {
customerName: props.clientData.customerName || "",
customerType: props.clientData.customerType || "",
addressTitle: selectedAddressData.value.addressTitle || "",
addressLine1: selectedAddressData.value.addressLine1 || "",
addressLine2: selectedAddressData.value.addressLine2 || "",
pincode: selectedAddressData.value.pincode || "",
city: selectedAddressData.value.city || "",
state: selectedAddressData.value.state || "",
contacts:
contactsForAddress.value.map((c) => ({
firstName: c.firstName || "",
lastName: c.lastName || "",
phoneNumber: c.phone || c.mobileNo || "",
email: c.emailId || c.customEmail || "",
contactRole: c.role || "",
isPrimary: c.isPrimaryContact || false,
})) || [],
};
// Populate available contacts if any
if (contactsForAddress.value.length > 0) {
availableContacts.value = contactsForAddress.value;
}
};
// Event handlers
const handleNewClientToggle = (isNewClient) => {
isNewClientMode.value = isNewClient;
if (isNewClient) {
// Reset form when toggling to new client
resetForm();
}
};
const handleCustomerSelected = (clientData) => {
// When a customer is selected, populate available contacts from the first address
if (clientData.addresses && clientData.addresses.length > 0) {
availableContacts.value = clientData.addresses[0].contacts || [];
} else {
availableContacts.value = [];
}
};
const handleNewContactToggle = (isNewContact) => {
if (!isNewContact && availableContacts.value.length === 0) {
notificationStore.addWarning("No contacts available for this customer.");
}
};
// Edit mode methods
const toggleEditMode = () => {
showEditConfirmDialog.value = true;
};
const confirmEdit = () => {
showEditConfirmDialog.value = false;
editMode.value = true;
populateFormFromClientData();
};
// Save/Cancel actions
const handleSave = async () => {
if (!isFormValid.value) {
notificationStore.addError("Please fill in all required fields");
return;
}
isSubmitting.value = true;
try {
// Prepare client data for upsert
const clientData = {
customerName: formData.value.customerName,
customerType: formData.value.customerType,
companyName: companyStore.currentCompany,
addressTitle: formData.value.addressTitle,
addressLine1: formData.value.addressLine1,
addressLine2: formData.value.addressLine2,
pincode: formData.value.pincode,
city: formData.value.city,
state: formData.value.state,
contacts: formData.value.contacts,
};
console.log("Upserting client with data:", clientData);
// Call the upsert API
const result = await Api.createClient(clientData);
// Calculate full address for redirect
const fullAddressParts = [formData.value.addressLine1];
if (formData.value.addressLine2?.trim()) {
fullAddressParts.push(formData.value.addressLine2);
}
fullAddressParts.push(`${formData.value.city}, ${formData.value.state}`);
fullAddressParts.push(formData.value.pincode);
const fullAddress = fullAddressParts.join(" ");
if (props.isNew) {
notificationStore.addSuccess(
`Client ${formData.value.customerName} created successfully!`,
);
// Redirect to the new client page
await router.push({
path: "/client",
query: {
client: formData.value.customerName,
address: fullAddress,
},
});
} else {
notificationStore.addSuccess("Client updated successfully!");
editMode.value = false;
// Reload the client data
// Note: Parent component should handle reloading
}
} catch (error) {
console.error("Error saving client:", error);
notificationStore.addError("Failed to save client information");
} finally {
isSubmitting.value = false;
}
};
const handleCancel = () => {
if (props.isNew) {
// Clear form for new client
resetForm();
} else {
// Exit edit mode and restore original data
editMode.value = false;
populateFormFromClientData();
}
};
</script>
<style scoped>
.overview-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
}
.info-card,
.map-card {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.card-header h3 {
margin: 0;
}
.info-card h3,
.map-card h3 {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.25rem;
font-weight: 600;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.info-item.full-width {
grid-column: 1 / -1;
}
.info-item label {
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.info-item span {
color: var(--text-color);
font-size: 0.95rem;
}
.contact-selector {
margin-bottom: 1rem;
}
/* Form input styling */
.info-item :deep(.p-inputtext),
.info-item :deep(.p-autocomplete),
.info-item :deep(.p-select) {
width: 100%;
}
.info-item :deep(.p-autocomplete .p-inputtext) {
width: 100%;
}
/* Required field indicator */
.info-item label:has(+ .p-inputtext[required])::after,
.info-item label:has(+ .p-autocomplete)::after {
content: " *";
color: var(--red-500);
}
.status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.status-card {
background: var(--surface-card);
border-radius: 8px;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
}
.status-card h4 {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: var(--text-color);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
background: var(--surface-card);
border-radius: 8px;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.coordinates-info {
margin-top: 0.75rem;
text-align: center;
color: var(--text-color-secondary);
padding-top: 0.75rem;
border-top: 1px solid var(--surface-border);
}
.confirm-dialog {
max-width: 400px;
}
.confirm-dialog :deep(.p-dialog-footer) {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* Utilities */
.w-full {
width: 100% !important;
}
@media (max-width: 768px) {
.overview-container {
padding: 0.5rem;
gap: 1rem;
}
.info-card,
.map-card {
padding: 1rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.status-cards {
grid-template-columns: repeat(2, 1fr);
}
.form-actions {
padding: 1rem;
flex-direction: column;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
@media (max-width: 480px) {
.status-cards {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,5 +0,0 @@
<template lang="">
<div></div>
</template>
<script setup></script>
<style lang=""></style>

View File

@ -1,29 +0,0 @@
<template>
<div class="additional-info-bar">
<span>additional address information. coming soon</span>
</div>
</template>
<script setup>
const props = defineProps({
address: {
type: Object,
default: null
}
})
</script>
<style lang="scss" scoped>
.additional-info-bar {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem 1rem;
background: linear-gradient(135deg, var(--theme-gradient-start) 0%, var(--theme-gradient-end) 100%);
color: #fff;
border-bottom: 1px solid var(--theme-primary-strong);
font-size: 0.75rem;
font-weight: 500;
min-height: 30px;
}
</style>

View File

@ -1,180 +0,0 @@
<template>
<div class="top-bar">
<div class="address-section">
<label class="section-label">Address:</label>
<Dropdown
v-model="selectedAddressIdx"
:options="addressOptions"
option-label="label"
option-value="value"
placeholder="Select Address"
class="address-dropdown"
/>
</div>
<div class="contact-section">
<div class="contact-info">
<div class="contact-item">
<strong>{{ primaryContact.name }}</strong>
</div>
<div class="contact-item">
<i class="pi pi-phone"></i> {{ primaryContact.phone }}
</div>
<div class="contact-item">
<i class="pi pi-envelope"></i> {{ primaryContact.email }}
</div>
</div>
<div v-if="client.customerName !== primaryContact.fullName" class="customer-name">
<span class="customer-label">Customer:</span> {{ client.customerName }}
</div>
</div>
<div class="visit-section">
<i class="pi pi-calendar"></i>
<span>Next Visit: {{ nextVisitDate ? formatDate(nextVisitDate) : 'None' }}</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Dropdown from 'primevue/dropdown'
const props = defineProps({
client: {
type: Object,
required: true
},
nextVisitDate: {
type: [String, Date],
default: null
}
})
const selectedAddressIdx = defineModel('selectedAddressIdx')
const addressOptions = computed(() => {
return props.client.addresses.map((addr, index) => ({
label: addr.fullAddress,
value: index
}))
})
const primaryContact = computed(() => {
const contactName = props.client.addresses[selectedAddressIdx.value]?.customContactName
return props.client.contacts.find(contact => contact.name === contactName) || {}
})
const formatDate = (date) => {
// Assuming date is a string or Date object, format as needed
return new Date(date).toLocaleDateString()
}
</script>
<style lang="scss" scoped>
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, var(--theme-gradient-start) 0%, var(--theme-gradient-end) 100%);
color: #fff;
border-bottom: 1px solid var(--theme-primary-strong);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
flex-wrap: wrap;
gap: 0.5rem;
min-height: 50px;
}
.address-section {
display: flex;
align-items: center;
gap: 0.25rem;
min-width: 200px;
}
.section-label {
font-weight: 500;
color: var(--theme-text-muted);
white-space: nowrap;
font-size: 0.85rem;
}
.address-dropdown {
flex: 1;
min-width: 150px;
}
.contact-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
text-align: center;
}
.contact-info {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.contact-item {
display: flex;
align-items: center;
gap: 0.2rem;
font-size: 0.8rem;
color: #fff;
}
.customer-name {
font-size: 0.75rem;
color: var(--theme-text-muted);
font-weight: 500;
}
.customer-label {
font-weight: 600;
color: #fff;
}
.visit-section {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
font-size: 0.8rem;
background: var(--theme-primary-strong);
padding: 0.25rem 0.5rem;
border-radius: 10px;
white-space: nowrap;
color: #fff;
}
@media (max-width: 768px) {
.top-bar {
flex-direction: column;
align-items: stretch;
padding: 0.5rem;
min-height: auto;
}
.address-section {
justify-content: center;
}
.contact-section {
align-items: center;
}
.contact-info {
flex-direction: column;
gap: 0.25rem;
}
.visit-section {
justify-content: center;
}
}
</style>

View File

@ -1,30 +0,0 @@
<template>
<div class="card">
<slot name="header">
</slot>
<slot name="content">
</slot>
</div>
</template>
<script setup>
</script>
<style scoped>
.card {
width: 200px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 12px;
display: flex;
justify-content: space-between;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
background-color: var(--theme-surface-alt);
}
.card:hover {
/*transform: translateY(-2px);*/
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,60 +0,0 @@
<template>
<div
v-if="showOverlay"
class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-[9999] transition-opacity duration-200"
:class="{ 'opacity-100': showOverlay, 'opacity-0 pointer-events-none': !showOverlay }"
>
<div class="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full mx-4 text-center">
<div class="mb-4">
<i class="pi pi-spin pi-spinner text-4xl text-blue-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">Loading</h3>
<p class="text-gray-600">{{ loadingMessage }}</p>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useLoadingStore } from "../../stores/loading";
const props = defineProps({
// Show overlay only for global loading, not component-specific
globalOnly: {
type: Boolean,
default: true,
},
// Minimum display time to prevent flashing
minDisplayTime: {
type: Number,
default: 300,
},
});
const loadingStore = useLoadingStore();
const showOverlay = computed(() => {
if (props.globalOnly) {
return loadingStore.isLoading;
}
return loadingStore.isAnyLoading;
});
const loadingMessage = computed(() => {
return loadingStore.loadingMessage;
});
</script>
<style scoped>
/* Additional styling for better visual appearance */
.bg-opacity-30 {
background-color: rgba(0, 0, 0, 0.3);
}
/* Backdrop blur effect for modern browsers */
@supports (backdrop-filter: blur(4px)) {
.fixed.inset-0 {
backdrop-filter: blur(4px);
}
}
</style>

View File

@ -1,219 +0,0 @@
<template>
<div class="map-container">
<div ref="mapElement" class="map" :style="{ height: mapHeight }"></div>
<div v-if="!latitude || !longitude" class="map-overlay">
<div class="no-coordinates">
<i
class="pi pi-map-marker"
style="font-size: 2rem; color: #64748b; margin-bottom: 0.5rem"
></i>
<p>No coordinates available</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// Fix Leaflet default marker icons
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
});
const props = defineProps({
latitude: {
type: [Number, String],
required: false,
default: null,
},
longitude: {
type: [Number, String],
required: false,
default: null,
},
addressTitle: {
type: String,
default: "Location",
},
mapHeight: {
type: String,
default: "400px",
},
zoomLevel: {
type: Number,
default: 15,
},
interactive: {
type: Boolean,
default: true,
},
});
const mapElement = ref(null);
let map = null;
let marker = null;
const initializeMap = async () => {
if (!mapElement.value) return;
// Clean up existing map
if (map) {
map.remove();
map = null;
marker = null;
}
const lat = parseFloat(props.latitude);
const lng = parseFloat(props.longitude);
// Only create map if we have valid coordinates
if (!isNaN(lat) && !isNaN(lng)) {
await nextTick();
// Initialize map
map = L.map(mapElement.value, {
zoomControl: props.interactive,
dragging: props.interactive,
touchZoom: props.interactive,
scrollWheelZoom: props.interactive,
doubleClickZoom: props.interactive,
boxZoom: props.interactive,
keyboard: props.interactive,
}).setView([lat, lng], props.zoomLevel);
// Add tile layer
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap contributors",
}).addTo(map);
// Add marker
marker = L.marker([lat, lng])
.addTo(map)
.bindPopup(
`
<div style="text-align: center;">
<strong>${props.addressTitle}</strong><br>
<small>Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}</small>
</div>
`,
)
.openPopup();
}
};
const updateMap = () => {
const lat = parseFloat(props.latitude);
const lng = parseFloat(props.longitude);
if (map && !isNaN(lat) && !isNaN(lng)) {
// Update map view
map.setView([lat, lng], props.zoomLevel);
// Update marker
if (marker) {
marker.setLatLng([lat, lng]);
marker.setPopupContent(`
<div style="text-align: center;">
<strong>${props.addressTitle}</strong><br>
<small>Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}</small>
</div>
`);
} else {
marker = L.marker([lat, lng])
.addTo(map)
.bindPopup(
`
<div style="text-align: center;">
<strong>${props.addressTitle}</strong><br>
<small>Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}</small>
</div>
`,
)
.openPopup();
}
} else if (!isNaN(lat) && !isNaN(lng)) {
// Coordinates available but no map yet - initialize
initializeMap();
}
};
// Watch for coordinate changes
watch(
() => [props.latitude, props.longitude, props.addressTitle],
() => {
updateMap();
},
{ immediate: false },
);
onMounted(() => {
initializeMap();
});
onUnmounted(() => {
if (map) {
map.remove();
map = null;
marker = null;
}
});
</script>
<style scoped>
.map-container {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.map {
width: 100%;
z-index: 1;
}
.map-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--surface-ground);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.no-coordinates {
text-align: center;
color: var(--text-color-secondary);
padding: 2rem;
}
.no-coordinates p {
margin: 0;
font-size: 0.9rem;
}
/* Leaflet popup customization */
:deep(.leaflet-popup-content-wrapper) {
border-radius: 6px;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);
}
:deep(.leaflet-popup-tip) {
background: white;
border: none;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);
}
</style>

View File

@ -1,289 +0,0 @@
<template>
<v-dialog
v-model="localVisible"
:persistent="options.persistent || false"
:fullscreen="options.fullscreen || false"
:max-width="options.maxWidth || '500px'"
:width="options.width"
:height="options.height"
:attach="options.attach"
:transition="options.transition || 'dialog-transition'"
:scrollable="options.scrollable || false"
:retain-focus="options.retainFocus !== false"
:close-on-back="options.closeOnBack !== false"
:close-on-content-click="options.closeOnContentClick || false"
:overlay-color="options.overlayColor"
:overlay-opacity="options.overlayOpacity"
:z-index="options.zIndex"
:class="options.dialogClass"
@click:outside="handleOutsideClick"
@keydown.esc="handleEscapeKey"
>
<v-card
:class="[
'modal-card',
options.cardClass,
{
'elevation-0': options.flat,
'rounded-0': options.noRadius,
},
]"
:color="options.cardColor"
:variant="options.cardVariant"
:elevation="options.elevation"
>
<!-- Header Section -->
<v-card-title
v-if="options.showHeader !== false"
:class="[
'modal-header d-flex align-center justify-space-between',
options.headerClass,
]"
>
<div class="modal-title">
<slot name="title">
{{ options.title }}
</slot>
</div>
<!-- Close button -->
<v-btn
v-if="options.showCloseButton !== false && !options.persistent"
icon
variant="text"
size="small"
:color="options.closeButtonColor || 'grey'"
@click="closeModal"
class="modal-close-btn"
>
<v-icon>{{ options.closeIcon || "mdi-close" }}</v-icon>
</v-btn>
</v-card-title>
<v-divider v-if="options.showHeaderDivider && options.showHeader !== false" />
<!-- Content Section -->
<v-card-text
:class="[
'modal-content',
options.contentClass,
{
'pa-0': options.noPadding,
'overflow-y-auto': options.scrollable,
},
]"
:style="contentStyle"
>
<slot>
<!-- Default content if no slot provided -->
<div v-if="options.message" v-html="options.message"></div>
</slot>
</v-card-text>
<!-- Actions Section -->
<v-card-actions
v-if="options.showActions !== false || $slots.actions"
:class="[
'modal-actions',
options.actionsClass,
{
'justify-end': options.actionsAlign === 'right',
'justify-center': options.actionsAlign === 'center',
'justify-start': options.actionsAlign === 'left',
'justify-space-between': options.actionsAlign === 'space-between',
},
]"
>
<slot name="actions" :close="closeModal" :options="options">
<!-- Default action buttons -->
<v-btn
v-if="options.showCancelButton !== false"
:color="options.cancelButtonColor || 'grey'"
:variant="options.cancelButtonVariant || 'text'"
@click="handleCancel"
>
{{ options.cancelButtonText || "Cancel" }}
</v-btn>
<v-btn
v-if="options.showConfirmButton !== false"
:color="options.confirmButtonColor || 'primary'"
:variant="options.confirmButtonVariant || 'elevated'"
:loading="options.loading"
@click="handleConfirm"
>
{{ options.confirmButtonText || "Confirm" }}
</v-btn>
</slot>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed, watch } from "vue";
// Props
const props = defineProps({
// Modal visibility state
visible: {
type: Boolean,
default: false,
},
// Options object for configuration
options: {
type: Object,
default: () => ({}),
},
});
// Emits
const emit = defineEmits([
"update:visible",
"close",
"confirm",
"cancel",
"outside-click",
"escape-key",
]);
// Local visibility state that syncs with parent
const localVisible = computed({
get() {
return props.visible;
},
set(value) {
emit("update:visible", value);
},
});
// Computed styles for content area
const contentStyle = computed(() => {
const styles = {};
if (props.options.contentHeight) {
styles.height = props.options.contentHeight;
}
if (props.options.contentMaxHeight) {
styles.maxHeight = props.options.contentMaxHeight;
}
if (props.options.contentMinHeight) {
styles.minHeight = props.options.contentMinHeight;
}
return styles;
});
// Methods
const closeModal = () => {
localVisible.value = false;
emit("close");
};
const handleConfirm = () => {
emit("confirm");
// Auto-close unless specified not to
if (props.options.autoCloseOnConfirm !== false) {
closeModal();
}
};
const handleCancel = () => {
emit("cancel");
// Auto-close unless specified not to
if (props.options.autoCloseOnCancel !== false) {
closeModal();
}
};
const handleOutsideClick = () => {
emit("outside-click");
// Close on outside click unless persistent or disabled
if (!props.options.persistent && props.options.closeOnOutsideClick !== false) {
closeModal();
}
};
const handleEscapeKey = () => {
emit("escape-key");
// Close on escape key unless persistent or disabled
if (!props.options.persistent && props.options.closeOnEscape !== false) {
closeModal();
}
};
// Watch for external visibility changes
watch(
() => props.visible,
(newValue) => {
if (newValue && props.options.onOpen) {
props.options.onOpen();
} else if (!newValue && props.options.onClose) {
props.options.onClose();
}
},
);
</script>
<style scoped>
.modal-card {
position: relative;
}
.modal-header {
background-color: var(--v-theme-surface-variant);
padding: 16px 24px;
}
.modal-title {
font-size: 1.25rem;
font-weight: 500;
flex: 1;
}
.modal-close-btn {
flex-shrink: 0;
}
.modal-content {
position: relative;
}
.modal-actions {
padding: 16px 24px;
gap: 8px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.modal-header {
padding: 12px 16px;
}
.modal-actions {
padding: 12px 16px;
}
.modal-title {
font-size: 1.1rem;
}
}
/* Custom transitions */
.v-dialog--fullscreen .modal-card {
height: 100vh;
border-radius: 0;
}
/* Loading state */
.modal-card.loading {
pointer-events: none;
}
</style>

View File

@ -1,438 +0,0 @@
<template>
<div class="notification-container" :class="positionClass">
<TransitionGroup name="notification" tag="div" class="notification-list">
<div
v-for="notification in activeNotifications"
:key="notification.id"
:class="notificationClass(notification)"
class="notification"
@click="markAsSeen(notification.id)"
>
<!-- Notification Header -->
<div class="notification-header">
<div class="notification-icon">
<i :class="getIcon(notification.type)"></i>
</div>
<div class="notification-content">
<h4 v-if="notification.title" class="notification-title">
{{ notification.title }}
</h4>
<p class="notification-message">{{ notification.message }}</p>
</div>
<button
@click.stop="dismissNotification(notification.id)"
class="notification-close"
type="button"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<!-- Notification Actions -->
<div
v-if="notification.actions && notification.actions.length > 0"
class="notification-actions"
>
<button
v-for="action in notification.actions"
:key="action.label"
@click.stop="handleAction(action, notification)"
:class="action.variant || 'primary'"
class="notification-action-btn"
type="button"
>
<i v-if="action.icon" :class="action.icon"></i>
{{ action.label }}
</button>
</div>
<!-- Progress Bar for timed notifications -->
<div
v-if="!notification.persistent && notification.duration > 0"
class="notification-progress"
>
<div
class="notification-progress-bar"
:style="{ animationDuration: notification.duration + 'ms' }"
></div>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script>
import { computed } from "vue";
import { useNotificationStore } from "../../stores/notifications-primevue";
export default {
name: "NotificationDisplay",
setup() {
const notificationStore = useNotificationStore();
const activeNotifications = computed(() => notificationStore.activeNotifications);
const positionClass = computed(
() => `notification-container--${notificationStore.position}`,
);
const notificationClass = (notification) => [
`notification--${notification.type}`,
{
"notification--seen": notification.seen,
"notification--persistent": notification.persistent,
},
];
const getIcon = (type) => {
const icons = {
success: "mdi mdi-check-circle",
error: "mdi mdi-alert-circle",
warning: "mdi mdi-alert",
info: "mdi mdi-information",
};
return icons[type] || icons.info;
};
const dismissNotification = (id) => {
notificationStore.dismissNotification(id);
};
const markAsSeen = (id) => {
notificationStore.markAsSeen(id);
};
const handleAction = (action, notification) => {
if (action.handler) {
action.handler(notification);
}
// Auto-dismiss notification after action unless specified otherwise
if (action.dismissAfter !== false) {
dismissNotification(notification.id);
}
};
return {
activeNotifications,
positionClass,
notificationClass,
getIcon,
dismissNotification,
markAsSeen,
handleAction,
};
},
};
</script>
<style scoped>
.notification-container {
position: fixed;
z-index: 9999;
pointer-events: none;
max-width: 400px;
width: 100%;
padding: 1rem;
}
/* Position variants */
.notification-container--top-right {
top: 0;
right: 0;
}
.notification-container--top-left {
top: 0;
left: 0;
}
.notification-container--bottom-right {
bottom: 0;
right: 0;
}
.notification-container--bottom-left {
bottom: 0;
left: 0;
}
.notification-container--top-center {
top: 0;
left: 50%;
transform: translateX(-50%);
}
.notification-container--bottom-center {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
.notification-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.notification {
pointer-events: auto;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-left: 4px solid;
overflow: hidden;
min-width: 320px;
max-width: 100%;
position: relative;
}
/* Notification type variants */
.notification--success {
border-left-color: #10b981;
}
.notification--error {
border-left-color: #ef4444;
}
.notification--warning {
border-left-color: #f59e0b;
}
.notification--info {
border-left-color: #3b82f6;
}
.notification-header {
display: flex;
align-items: flex-start;
padding: 1rem;
gap: 0.75rem;
}
.notification-icon {
flex-shrink: 0;
font-size: 1.25rem;
margin-top: 0.125rem;
}
.notification--success .notification-icon {
color: #10b981;
}
.notification--error .notification-icon {
color: #ef4444;
}
.notification--warning .notification-icon {
color: #f59e0b;
}
.notification--info .notification-icon {
color: #3b82f6;
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-title {
margin: 0 0 0.25rem 0;
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.notification-message {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
line-height: 1.4;
word-wrap: break-word;
}
.notification-close {
flex-shrink: 0;
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
transition: all 0.2s;
font-size: 1rem;
}
.notification-close:hover {
color: #6b7280;
background-color: #f3f4f6;
}
.notification-actions {
padding: 0 1rem 1rem 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: -0.25rem;
}
.notification-action-btn {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
background: white;
color: #374151;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.25rem;
}
.notification-action-btn:hover {
background: #f9fafb;
border-color: #9ca3af;
}
.notification-action-btn.primary {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.notification-action-btn.primary:hover {
background: #2563eb;
}
.notification-action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.notification-action-btn.danger:hover {
background: #dc2626;
}
.notification-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.1);
}
.notification-progress-bar {
height: 100%;
background: currentColor;
opacity: 0.3;
animation: progress-decrease linear forwards;
transform-origin: left;
}
.notification--success .notification-progress-bar {
background: #10b981;
}
.notification--error .notification-progress-bar {
background: #ef4444;
}
.notification--warning .notification-progress-bar {
background: #f59e0b;
}
.notification--info .notification-progress-bar {
background: #3b82f6;
}
@keyframes progress-decrease {
from {
width: 100%;
}
to {
width: 0%;
}
}
/* Transition animations */
.notification-enter-active {
transition: all 0.3s ease-out;
}
.notification-leave-active {
transition: all 0.3s ease-in;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%);
}
/* Adjustments for left-positioned containers */
.notification-container--top-left .notification-enter-from,
.notification-container--bottom-left .notification-enter-from,
.notification-container--top-left .notification-leave-to,
.notification-container--bottom-left .notification-leave-to {
transform: translateX(-100%);
}
/* Adjustments for center-positioned containers */
.notification-container--top-center .notification-enter-from,
.notification-container--bottom-center .notification-enter-from,
.notification-container--top-center .notification-leave-to,
.notification-container--bottom-center .notification-leave-to {
transform: translateY(-100%);
}
.notification-container--bottom-center .notification-enter-from,
.notification-container--bottom-center .notification-leave-to {
transform: translateY(100%);
}
/* Hover effects */
.notification:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.notification--seen {
opacity: 0.95;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.notification-container {
left: 0;
right: 0;
max-width: none;
padding: 0.5rem;
}
.notification-container--top-center,
.notification-container--bottom-center {
transform: none;
}
.notification {
min-width: auto;
}
.notification-header {
padding: 0.75rem;
}
.notification-actions {
padding: 0 0.75rem 0.75rem 0.75rem;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,282 +0,0 @@
<template>
<!--<div class="todo-chart-container"> -->
<!-- Loading Overlay -->
<!--<div v-if="loading" class="loading-overlay">-->
<!-- <div class="spinner"></div>-->
<!-- <div class="loading-text">Loading chart data...</div>-->
<!--</div>-->
<!-- Chart Container -->
<div class="chart-wrapper">
<canvas ref="chartCanvas" class="chart-canvas" v-show="!loading"></canvas>
<!-- Center Data Display -->
<div class="center-data" v-if="centerData && !loading">
<div class="center-label">{{ centerData.label }}</div>
<div class="center-value">{{ centerData.value }}</div>
<div class="center-percentage" v-if="centerData.percentage">
{{ centerData.percentage }}
</div>
</div>
</div>
<!--</div> -->
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed, onUnmounted} from "vue";
import { Chart, registerables } from "chart.js";
// Register Chart.js components
Chart.register(...registerables);
const props = defineProps({
title: String,
todoNumber: Number,
completedNumber: Number,
loading: {
type: Boolean,
default: false,
},
});
//Constants
const categories = ["To-do", "Completed"];
//Reactive data
const centerData = ref(null);
const hoveredSegment = ref(null);
const chartCanvas = ref(null);
const chartInstance = ref(null);
// Handle view changes
const handleViewChange = () => {
updateChart();
};
const getHoveredCategoryIndex = () => {
return hoveredSegment.value
}
const getCategoryValue = (categoryIndex) => {
if (categoryIndex === 0) {
return props.todoNumber
} else {
return props.completedNumber
}
}
const getChartData = () => {
const chartData = {
name: props.title,
datasets: [
{
label: "",
data: [props.todoNumber, props.completedNumber],
backgroundColor: ["#b22222", "#4caf50"]
},
]
};
return chartData;
};
const updateCenterData = () => {
const total = props.todoNumber + props.completedNumber;
const todos = props.todoNumber;
if (todos === 0 && total > 0) {
centerData.value = {
label: "Completed",
value: "0",
percentage: "100%",
};
return;
}
if (todos === 0 || isNaN(todos)){
centerData.value = {
label: "No To-Dos",
value: "0",
percentage: null,
};
return;
}
const hoveredCategoryIndex = getHoveredCategoryIndex()
if (hoveredCategoryIndex !== null) {
// Show specific segment data when hovered
const value = getCategoryValue(hoveredCategoryIndex);
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) + "%" : "0%";
centerData.value = {
label: categories[hoveredCategoryIndex],
value: value,
percentage: percentage,
};
} else {
centerData.value = {
label: "To-do",
value: props.todoNumber,
percentage: null,
};
}
};
// Chart options
const getChartOptions = () => {
return {
responsive: true,
maintainAspectRatio: false,
cutout: "60%",
plugins: {
legend: {
position: "bottom",
labels: {
padding: 20,
usePointStyle: true,
font: { size: 12 },
},
},
tooltip: { enabled: false },
title: {
display: true,
text: props.title,
},
},
elements: {
arc: {
borderWidth: 2,
borderColor: "#ffffff",
},
},
animation: {
animateRotate: true,
animateScale: true,
duration: 1000,
easing: "easeOutQuart",
},
interaction: {
mode: "nearest",
intersect: true,
},
onHover: (event, elements) => {
const categoryIndex = getHoveredCategoryIndex();
const total = getCategoryValue(categoryIndex);
if (elements && elements.length > 0) {
const elementIndex = elements[0].index;
if (hoveredSegment.value !== elementIndex) {
hoveredSegment.value = elementIndex;
updateCenterData();
}
} else {
if (hoveredSegment.value !== null) {
hoveredSegment.value = null;
updateCenterData();
}
}
},
};
};
const createChart = () => {
if (!chartCanvas.value || props.loading) return;
console.log(`DEBUG: Creating chart for ${props.title}`);
console.log(props);
const ctx = chartCanvas.value.getContext("2d");
if (chartInstance.value) {
chartInstance.value.destroy();
}
const chart = new Chart(ctx, {
type: "doughnut",
data: getChartData(),
options: getChartOptions(),
});
// Don't let Vue mutate Chart members for reactivity
Object.seal(chart);
chartInstance.value = chart;
// Populate Chart display
updateCenterData();
}
// Update chart
const updateChart = () => {
if (props.loading || !chartInstance.value) {
return;
}
const newData = getChartData();
chartInstance.value.data = newData;
chartInstance.value.update("none");
updateCenterData();
};
onMounted(() => {
createChart();
});
watch(() => props.completedNumber, (newValue) => {
updateChart();
});
</script>
<style scoped>
/*.todo-chart-container {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
min-height: 400px;
}*/
.chart-wrapper {
position: relative;
height: 200px;
width: 200px;
/*margin-top: 20px;*/
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.chart-canvas {
max-height: 100%;
width: 100% !important;
height: 100% !important;
}
.center-data {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
transition: all 0.3s ease;
}
.center-label {
font-size: 14px;
font-weight: 500;
color: #6b7280;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.center-value {
font-size: 32px;
font-weight: bold;
color: #111827;
line-height: 1;
margin-bottom: 2px;
}
.center-percentage {
font-size: 16px;
font-weight: 600;
color: #3b82f6;
}
</style>

View File

@ -1,311 +0,0 @@
<template>
<!-- New Meeting Creation Modal -->
<Modal
v-model:visible="showModal"
:options="modalOptions"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<template #title>Schedule New Bid Meeting</template>
<div class="new-meeting-form">
<div class="form-group">
<label for="meeting-address">Address: <span class="required">*</span></label>
<div class="address-input-group">
<InputText
id="meeting-address"
v-model="formData.address"
class="address-input"
placeholder="Enter meeting address"
@input="validateForm"
/>
<Button
label="Search"
icon="pi pi-search"
size="small"
:disabled="!formData.address.trim()"
@click="searchAddress"
class="search-btn"
/>
</div>
</div>
<div class="form-group">
<label for="meeting-notes">Notes (Optional):</label>
<Textarea
id="meeting-notes"
v-model="formData.notes"
class="w-full"
placeholder="Additional notes..."
rows="3"
/>
</div>
</div>
</Modal>
<!-- Address Search Results Modal -->
<Modal
v-model:visible="showAddressSearchModal"
:options="searchModalOptions"
@confirm="closeAddressSearch"
>
<template #title>Address Search Results</template>
<div class="address-search-results">
<div v-if="addressSearchResults.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<p>No addresses found matching your search.</p>
</div>
<div v-else class="results-list">
<div
v-for="(address, index) in addressSearchResults"
:key="index"
class="address-result-item"
@click="selectAddress(address)"
>
<i class="pi pi-map-marker"></i>
<span>{{ address }}</span>
</div>
</div>
</div>
</Modal>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import Modal from "../common/Modal.vue";
import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import Button from "primevue/button";
import { useNotificationStore } from "../../stores/notifications-primevue";
import Api from "../../api";
const notificationStore = useNotificationStore();
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
initialAddress: {
type: String,
default: "",
},
});
// Emits
const emit = defineEmits(["update:visible", "confirm", "cancel"]);
// Local state
const showModal = computed({
get() {
return props.visible;
},
set(value) {
emit("update:visible", value);
},
});
const showAddressSearchModal = ref(false);
const addressSearchResults = ref([]);
const isFormValid = ref(false);
// Form data
const formData = ref({
address: "",
notes: "",
});
// Form validation state
// Modal options
const modalOptions = computed(() => ({
maxWidth: "500px",
persistent: true,
confirmButtonText: "Create",
cancelButtonText: "Cancel",
confirmButtonColor: "primary",
showConfirmButton: true,
showCancelButton: true,
confirmButtonProps: {
disabled: !isFormValid.value,
},
}));
const searchModalOptions = computed(() => ({
maxWidth: "600px",
showCancelButton: false,
confirmButtonText: "Close",
confirmButtonColor: "primary",
}));
// Methods
const validateForm = () => {
const hasValidAddress = formData.value.address && formData.value.address.trim().length > 0;
isFormValid.value = hasValidAddress;
};
const searchAddress = async () => {
const searchTerm = formData.value.address.trim();
if (!searchTerm) return;
try {
const results = await Api.searchAddresses(searchTerm);
console.info("Address search results:", results);
// Ensure results is always an array
// const safeResults = Array.isArray(results) ? results : [];
addressSearchResults.value = results;
if (results.length === 0) {
notificationStore.addWarning("No addresses found matching your search criteria.");
} else {
showAddressSearchModal.value = true;
}
} catch (error) {
console.error("Error searching addresses:", error);
addressSearchResults.value = [];
notificationStore.addError("Failed to search addresses. Please try again.");
}
};
const selectAddress = (address) => {
formData.value.address = address;
showAddressSearchModal.value = false;
validateForm();
};
const closeAddressSearch = () => {
showAddressSearchModal.value = false;
};
const handleConfirm = () => {
if (!isFormValid.value) return;
emit("confirm", { ...formData.value });
resetForm();
};
const handleCancel = () => {
emit("cancel");
resetForm();
};
const resetForm = () => {
formData.value = {
address: props.initialAddress || "",
notes: "",
};
validateForm();
};
// Watch for prop changes
watch(
() => props.initialAddress,
(newAddress) => {
formData.value.address = newAddress || "";
validateForm();
},
{ immediate: true },
);
watch(
() => props.visible,
(isVisible) => {
if (isVisible) {
resetForm();
}
},
);
// Initial validation
validateForm();
</script>
<style scoped>
.new-meeting-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-weight: 500;
color: #333;
font-size: 0.9em;
}
.required {
color: #e74c3c;
}
.address-input-group {
display: flex;
gap: 8px;
align-items: stretch;
}
.address-input {
flex: 1;
}
.search-btn {
flex-shrink: 0;
}
.address-search-results {
min-height: 200px;
}
.no-results {
text-align: center;
padding: 40px 20px;
color: #666;
}
.no-results i {
font-size: 2em;
color: #f39c12;
margin-bottom: 10px;
display: block;
}
.results-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.address-result-item {
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 12px;
}
.address-result-item:hover {
background-color: #f8f9fa;
border-color: #2196f3;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.address-result-item i {
color: #2196f3;
font-size: 1.1em;
}
.address-result-item span {
flex: 1;
font-size: 0.9em;
color: #333;
}
</style>

View File

@ -1,500 +0,0 @@
<template>
<Modal
:visible="isVisible"
:options="modalOptions"
@update:visible="handleVisibilityChange"
@close="handleClose"
>
<template #title> Create New Client </template>
<!-- Status Message -->
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`">
<i :class="getStatusIcon(statusType)" class="status-icon"></i>
{{ statusMessage }}
</div>
<Form
ref="formRef"
:fields="formFields"
:form-data="formData"
:show-cancel-button="true"
:validate-on-change="false"
:validate-on-blur="true"
:validate-on-submit="true"
:loading="isSubmitting"
:disable-on-loading="true"
submit-button-text="Create Client"
cancel-button-text="Cancel"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { useModalStore } from "@/stores/modal";
import Modal from "@/components/common/Modal.vue";
import Form from "@/components/common/Form.vue";
import Api from "@/api";
import DataUtils from "../../utils";
const modalStore = useModalStore();
// Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen("createClient"));
const customerNames = ref([]);
// Form reference for controlling its state
const formRef = ref(null);
// Form data
const formData = reactive({
customertype: "",
customerName: "",
addressLine1: "",
phone: "",
email: "",
pincode: "",
city: "",
state: "",
});
// Available cities for the selected zipcode
const availableCities = ref([]);
// Loading state for zipcode lookup
const isLoadingZipcode = ref(false);
// Status message for user feedback
const statusMessage = ref("");
const statusType = ref("info"); // 'info', 'warning', 'error', 'success'
// Modal configuration
const modalOptions = {
maxWidth: "600px",
persistent: false,
showActions: false,
title: "Create New Client",
overlayColor: "rgb(59, 130, 246)", // Blue background
overlayOpacity: 0.8,
cardClass: "create-client-modal",
closeOnOutsideClick: true,
closeOnEscape: true,
};
// Form field definitions
const formFields = computed(() => [
{
name: "addressTitle",
label: "Address Title",
type: "text",
required: true,
placeholder: "Enter address title",
helpText: "A short title to identify this address (e.g., Johnson Home, Johnson Office)",
cols: 12,
md: 6,
},
{
name: "customertype",
label: "Client Type",
type: "select",
required: true,
placeholder: "Select client type",
cols: 12,
md: 6,
options: [
{ label: "Individual", value: "Individual" },
{ label: "Company", value: "Company" },
],
helpText: "Select whether this is an individual or company client",
},
{
name: "customerName",
label: "Client Name",
type: "autocomplete",
required: true,
placeholder: "Type or select client name",
cols: 12,
md: 6,
options: customerNames.value,
forceSelection: false, // Allow custom entries not in the list
dropdown: true,
helpText: "Select an existing client or enter a new client name",
},
{
name: "addressLine1",
label: "Address",
type: "text",
required: true,
placeholder: "Enter street address",
cols: 12,
md: 12,
},
{
name: "phone",
label: "Phone Number",
type: "text",
required: true,
placeholder: "Enter phone number",
format: "tel",
cols: 12,
md: 6,
validate: (value) => {
if (value && !/^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/.test(value)) {
return "Please enter a valid phone number";
}
return null;
},
},
{
name: "email",
label: "Email Address",
type: "text",
required: true,
placeholder: "Enter email address",
format: "email",
cols: 12,
md: 6,
},
{
name: "pincode",
label: "Zip Code",
type: "text",
required: true,
placeholder: "Enter 5-digit zip code",
cols: 12,
md: 4,
maxLength: 5,
inputMode: "numeric",
pattern: "[0-9]*",
onChangeOverride: handleZipcodeChange,
onInput: (value) => {
// Only allow numbers and limit to 5 digits
return value.replace(/\D/g, "").substring(0, 5);
},
validate: (value) => {
if (value && !/^\d{5}$/.test(value)) {
return "Please enter a valid 5-digit zip code";
}
return null;
},
},
{
name: "city",
label: "City",
type: availableCities.value.length > 0 ? "select" : "text",
required: true,
disabled: false,
showClear: availableCities.value.length > 1,
placeholder: availableCities.value.length > 0 ? "Select city" : "Enter city name",
options: availableCities.value.map((place) => ({
label: place["place name"],
value: place["place name"],
})),
cols: 12,
md: 4,
helpText: isLoadingZipcode.value
? "Loading cities..."
: availableCities.value.length > 0
? "Select from available cities"
: "Enter city manually (auto-lookup unavailable)",
},
{
name: "state",
label: "State",
type: "select",
options: DataUtils.US_STATES.map((stateAbbr) => ({
label: stateAbbr,
value: stateAbbr,
})),
required: true,
disabled: availableCities.value.length > 0,
placeholder:
availableCities.value.length > 0 ? "Auto-populated" : "Enter state (e.g., CA, TX, NY)",
cols: 12,
md: 4,
helpText:
availableCities.value.length > 0
? "Auto-populated from zip code"
: "Enter state abbreviation manually",
validate: (value) => {
// Only validate manually entered states (when API lookup failed)
if (availableCities.value.length === 0 && value) {
const upperValue = value.toUpperCase();
if (!DataUtils.US_STATES.includes(upperValue)) {
return "Please enter a valid US state abbreviation (e.g., CA, TX, NY)";
}
}
return null;
},
},
]);
// Handle zipcode change and API lookup
async function handleZipcodeChange(value, fieldName, currentFormData) {
if (value.length < 5) {
return;
}
if (fieldName === "pincode" && value && value.length >= 5) {
// Only process if it's a valid zipcode format
const zipcode = value.replace(/\D/g, "").substring(0, 5);
if (zipcode.length === 5) {
isLoadingZipcode.value = true;
try {
const places = await Api.getCityStateByZip(zipcode);
console.log("API response for zipcode", zipcode, ":", places);
if (places && places.length > 0) {
availableCities.value = places;
// Update the reactive formData directly to ensure reactivity
// Use "state abbreviation" instead of "state" for proper abbreviation format
const stateValue = places[0]["state abbreviation"] || places[0].state;
console.log("Setting state to:", stateValue, "from place object:", places[0]);
formData.state = stateValue;
// If only one city, auto-select it
if (places.length === 1) {
formData.city = places[0]["place name"];
showStatusMessage(
`Location found: ${places[0]["place name"]}, ${places[0]["state abbreviation"] || places[0].state}`,
"success",
);
} else {
// Clear city selection if multiple cities
formData.city = "";
showStatusMessage(
`Found ${places.length} cities for this zip code. Please select one.`,
"info",
);
}
} else {
// No results found - enable manual entry
handleApiFailure("No location data found for this zip code");
}
} catch (error) {
console.error("Error fetching city/state data:", error);
// Check if it's a network/CORS error
if (error.code === "ERR_NETWORK" || error.message.includes("Network Error")) {
handleApiFailure(
"Unable to fetch location data. Please enter city and state manually.",
);
} else {
handleApiFailure(
"Location lookup failed. Please enter city and state manually.",
);
}
} finally {
isLoadingZipcode.value = false;
}
}
}
}
// Handle API failure by enabling manual entry
function handleApiFailure(message) {
console.warn("Zipcode API failed:", message);
// Clear existing data
availableCities.value = [];
formData.city = "";
formData.state = "";
// Show user-friendly message
showStatusMessage(message, "warning");
}
// Show status message to user
function showStatusMessage(message, type = "info") {
statusMessage.value = message;
statusType.value = type;
// Auto-clear message after 5 seconds
setTimeout(() => {
statusMessage.value = "";
}, 5000);
}
// Get icon class for status messages
function getStatusIcon(type) {
switch (type) {
case "warning":
return "pi pi-exclamation-triangle";
case "error":
return "pi pi-times-circle";
case "success":
return "pi pi-check-circle";
default:
return "pi pi-info-circle";
}
}
// Submission state to prevent double submission
const isSubmitting = ref(false);
// Handle form submission
async function handleSubmit(formDataFromEvent) {
// Prevent double submission with detailed logging
if (isSubmitting.value) {
console.warn(
"CreateClientModal: Form submission already in progress, ignoring duplicate submission",
);
return;
}
console.log(
"CreateClientModal: Form submission started with data:",
formDataFromEvent || formData,
);
isSubmitting.value = true;
try {
showStatusMessage("Creating client...", "info");
// Use the form data from the event if provided, otherwise use reactive formData
const dataToSubmit = formDataFromEvent || formData;
console.log("CreateClientModal: Calling API with data:", dataToSubmit);
// Call API to create client
const response = await Api.createClient(dataToSubmit);
console.log("CreateClientModal: API response received:", response);
if (response && response.success) {
showStatusMessage("Client created successfully!", "success");
// Close modal after a brief delay
setTimeout(() => {
handleClose();
}, 1500);
} else {
throw new Error(response?.message || "Failed to create client");
}
} catch (error) {
console.error("CreateClientModal: Error creating client:", error);
showStatusMessage(error.message || "Failed to create client. Please try again.", "error");
} finally {
isSubmitting.value = false;
// Also reset the Form component's internal submission state
if (formRef.value && formRef.value.stopLoading) {
formRef.value.stopLoading();
}
console.log("CreateClientModal: Form submission completed, isSubmitting reset to false");
}
}
// Handle cancel action
function handleCancel() {
handleClose();
}
// Handle modal close
function handleClose() {
modalStore.closeModal("createClient");
resetForm();
}
// Handle visibility changes
function handleVisibilityChange(visible) {
if (!visible) {
handleClose();
}
}
// Reset form data
function resetForm() {
Object.keys(formData).forEach((key) => {
formData[key] = "";
});
availableCities.value = [];
isLoadingZipcode.value = false;
statusMessage.value = "";
statusType.value = "info";
}
// Initialize modal in store when component mounts
modalStore.initializeModal("createClient", {
closeOnEscape: true,
closeOnOutsideClick: true,
});
watch(isVisible, async () => {
if (isVisible.value) {
try {
const names = await Api.getCustomerNames();
customerNames.value = names;
} catch (error) {
console.error("Error loading customer names:", error);
}
}
});
</script>
<style scoped>
.create-client-modal {
border-radius: 12px;
}
/* Custom styling for the modal content */
:deep(.modal-header) {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
:deep(.modal-title) {
font-weight: 600;
font-size: 1.25rem;
}
:deep(.modal-close-btn) {
color: white !important;
}
:deep(.modal-content) {
padding: 24px;
}
/* Status message styling */
.status-message {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
display: flex;
align-items: center;
font-size: 0.9rem;
border-left: 4px solid;
}
.status-icon {
margin-right: 8px;
font-size: 1rem;
}
.status-info {
background-color: #e3f2fd;
color: #1565c0;
border-left-color: #2196f3;
}
.status-warning {
background-color: #fff3e0;
color: #ef6c00;
border-left-color: #ff9800;
}
.status-error {
background-color: #ffebee;
color: #c62828;
border-left-color: #f44336;
}
.status-success {
background-color: #e8f5e8;
color: #2e7d32;
border-left-color: #4caf50;
}
</style>

View File

@ -1,333 +0,0 @@
<template>
<Modal
:visible="isVisible"
:options="modalOptions"
@update:visible="handleVisibilityChange"
@close="handleClose"
>
<template #title> Create New Estimate </template>
<!-- Status Message -->
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`">
<i :class="getStatusIcon(statusType)" class="status-icon"></i>
{{ statusMessage }}
</div>
<Form
ref="formRef"
:fields="formFields"
:form-data="formData"
:show-cancel-button="true"
:validate-on-change="false"
:validate-on-blur="true"
:validate-on-submit="true"
:loading="isSubmitting"
:disable-on-loading="true"
submit-button-text="Create Estimate"
cancel-button-text="Cancel"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { useModalStore } from "@/stores/modal";
import Modal from "@/components/common/Modal.vue";
import Form from "@/components/common/Form.vue";
import Api from "@/api";
import DataUtils from "../../utils";
const modalStore = useModalStore();
// Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen("createEstimate"));
const companyNames = ref([]);
// Form reference for controlling its state
const formRef = ref(null);
// Form data
const formData = reactive({
address: "",
company: "",
date: "",
quotationTo: "",
partyName: "",
items: "",
});
// Available cities for the selected zipcode
const availableCities = ref([]);
// Loading state for zipcode lookup
const isLoadingZipcode = ref(false);
// Status message for user feedback
const statusMessage = ref("");
const statusType = ref("info"); // 'info', 'warning', 'error', 'success'
// Modal configuration
const modalOptions = {
maxWidth: "600px",
persistent: false,
showActions: false,
title: "Create New Estimate",
overlayColor: "rgb(59, 130, 246)", // Blue background
overlayOpacity: 0.8,
cardClass: "create-estimate-modal",
closeOnOutsideClick: true,
closeOnEscape: true,
};
// Form field definitions
const formFields = computed(() => [
{
name: "address",
label: "Client Address",
type: "text",
required: true,
placeholder: "Enter street address",
cols: 12,
md: 6,
helpText: "Enter address for this estimate",
},
{
name: "company",
label: "Company Name",
type: "autocomplete", // Changed from 'select' to 'autocomplete'
required: true,
placeholder: "Select Company",
cols: 12,
md: 6,
options: companyNames.value, // Direct array of strings
dropdown: true,
// For string arrays, don't set optionLabel at all
helpText: "Select company associated with this estimate.",
// Let the Form component handle filtering automatically
},
{
name: "date",
label: "Current Date",
type: "date",
required: true,
placeholder: "",
cols: 12,
md: 6,
},
{
name: "quotationTo",
label: "Client Type",
type: "select",
required: true,
placeholder: "Select Customer or Business",
cols: 12,
md: 6,
options: [
{"label": "Customer", "value": "Customer"},
{"label": "Business", "value": "Business"}
]
},
{
name: "partyName",
label: "Client Name",
type: "text",
required: true,
placeholder: "",
cols: 12,
md: 4,
},
]);
// Show status message to user
function showStatusMessage(message, type = "info") {
statusMessage.value = message;
statusType.value = type;
// Auto-clear message after 5 seconds
setTimeout(() => {
statusMessage.value = "";
}, 5000);
}
// Get icon class for status messages
function getStatusIcon(type) {
switch (type) {
case "warning":
return "pi pi-exclamation-triangle";
case "error":
return "pi pi-times-circle";
case "success":
return "pi pi-check-circle";
default:
return "pi pi-info-circle";
}
}
// Submission state to prevent double submission
const isSubmitting = ref(false);
// Handle form submission
async function handleSubmit(formDataFromEvent) {
// Prevent double submission with detailed logging
if (isSubmitting.value) {
console.warn(
"CreateEstimateModal: Form submission already in progress, ignoring duplicate submission",
);
return;
}
console.log(
"CreateEstimateModal: Form submission started with data:",
formDataFromEvent || formData,
);
isSubmitting.value = true;
try {
showStatusMessage("Creating estimate...", "info");
// Use the form data from the event if provided, otherwise use reactive formData
const dataToSubmit = formDataFromEvent || formData;
console.log("CreateEstimateModal: Calling API with data:", dataToSubmit);
// Call API to create client
const response = await Api.createEstimate(dataToSubmit);
console.log("CreateEstimateModal: API response received:", response);
if (response && response.success) {
showStatusMessage("Estimate created successfully!", "success");
// Close modal after a brief delay
setTimeout(() => {
handleClose();
}, 1500);
} else {
throw new Error(response?.message || "Failed to create estimate");
}
} catch (error) {
console.error("CreateEstimateModal: Error creating client:", error);
showStatusMessage(
error.message || "Failed to create estimate. Please try again.",
"error",
);
} finally {
isSubmitting.value = false;
// Also reset the Form component's internal submission state
if (formRef.value && formRef.value.stopLoading) {
formRef.value.stopLoading();
}
console.log("CreateEstimateModal: Form submission completed, isSubmitting reset to false");
}
}
// Handle cancel action
function handleCancel() {
handleClose();
}
// Handle modal close
function handleClose() {
modalStore.closeModal("createEstimate");
resetForm();
}
// Handle visibility changes
function handleVisibilityChange(visible) {
if (!visible) {
handleClose();
}
}
// Reset form data
function resetForm() {
Object.keys(formData).forEach((key) => {
formData[key] = "";
});
statusMessage.value = "";
statusType.value = "info";
}
// Initialize modal in store when component mounts
modalStore.initializeModal("createEstimate", {
closeOnEscape: true,
closeOnOutsideClick: true,
});
watch(isVisible, async () => {
if (isVisible.value) {
try {
const names = await Api.getCompanyNames();
companyNames.value = names;
} catch (error) {
console.error("Error loading company names:", error);
}
}
});
</script>
<style scoped>
.create-client-modal {
border-radius: 12px;
}
/* Custom styling for the modal content */
:deep(.modal-header) {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
:deep(.modal-title) {
font-weight: 600;
font-size: 1.25rem;
}
:deep(.modal-close-btn) {
color: white !important;
}
:deep(.modal-content) {
padding: 24px;
}
/* Status message styling */
.status-message {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
display: flex;
align-items: center;
font-size: 0.9rem;
border-left: 4px solid;
}
.status-icon {
margin-right: 8px;
font-size: 1rem;
}
.status-info {
background-color: #e3f2fd;
color: #1565c0;
border-left-color: #2196f3;
}
.status-warning {
background-color: #fff3e0;
color: #ef6c00;
border-left-color: #ff9800;
}
.status-error {
background-color: #ffebee;
color: #c62828;
border-left-color: #f44336;
}
.status-success {
background-color: #e8f5e8;
color: #2e7d32;
border-left-color: #4caf50;
}
</style>

View File

@ -1,398 +0,0 @@
<template>
<Modal
:visible="isVisible"
:options="modalOptions"
@update:visible="handleVisibilityChange"
@close="handleClose"
>
<template #title> Create New Invoice </template>
<!-- Status Message -->
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`">
<i :class="getStatusIcon(statusType)" class="status-icon"></i>
{{ statusMessage }}
</div>
<Form
ref="formRef"
:fields="formFields"
:form-data="formData"
:show-cancel-button="true"
:validate-on-change="false"
:validate-on-blur="true"
:validate-on-submit="true"
:loading="isSubmitting"
:disable-on-loading="true"
submit-button-text="Create Invoice"
cancel-button-text="Cancel"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { useModalStore } from "@/stores/modal";
import Modal from "@/components/common/Modal.vue";
import Form from "@/components/common/Form.vue";
import Api from "@/api";
import DataUtils from "../../utils";
const modalStore = useModalStore();
// Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen("createInvoice"));
const customerNames = ref([]);
const companyNames = ref([]);
// Form reference for controlling its state
const formRef = ref(null);
// Form data
const formData = reactive({
customerName: "",
address: "",
company: "",
dueDate: "",
});
// Status message for user feedback
const statusMessage = ref("");
const statusType = ref("info"); // 'info', 'warning', 'error', 'success'
// Modal configuration
const modalOptions = {
maxWidth: "600px",
persistent: false,
showActions: false,
title: "Create New Invoice",
overlayColor: "rgb(59, 130, 246)", // Blue background
overlayOpacity: 0.8,
cardClass: "create-invoice-modal",
closeOnOutsideClick: true,
closeOnEscape: true,
};
// Form field definitions
const formFields = computed(() => [
{
name: "customerName",
label: "Client Name",
type: "autocomplete", // Changed from 'select' to 'autocomplete'
required: true,
placeholder: "Type or select client name",
cols: 12,
md: 6,
options: customerNames.value, // Direct array of strings
forceSelection: false, // Allow custom entries not in the list
dropdown: true,
// For string arrays, don't set optionLabel at all
helpText: "Select an existing client or enter a new client name",
// Let the Form component handle filtering automatically
},
{
name: "address",
label: "Address",
type: "text",
required: true,
placeholder: "Enter street address",
cols: 12,
md: 6,
},
{
name: "company",
label: "Company",
type: "autocomplete",
required: true,
placeholder: "Type or select Company",
cols: 12,
md: 6,
options: companyNames.value,
forceSelection: true,
dropdown: true,
helpText: "Select the company associated with this Invoice."
},
{
name: "dueDate",
label: "Due Date",
type: "date",
required: true,
cols: 12,
md: 6
}
]);
// Handle zipcode change and API lookup
async function handleZipcodeChange(value, fieldName, currentFormData) {
if (value.length < 5) {
return;
}
if (fieldName === "pincode" && value && value.length >= 5) {
// Only process if it's a valid zipcode format
const zipcode = value.replace(/\D/g, "").substring(0, 5);
if (zipcode.length === 5) {
isLoadingZipcode.value = true;
try {
const places = await Api.getCityStateByZip(zipcode);
console.log("API response for zipcode", zipcode, ":", places);
if (places && places.length > 0) {
availableCities.value = places;
// Update the reactive formData directly to ensure reactivity
// Use "state abbreviation" instead of "state" for proper abbreviation format
const stateValue = places[0]["state abbreviation"] || places[0].state;
console.log("Setting state to:", stateValue, "from place object:", places[0]);
formData.state = stateValue;
// If only one city, auto-select it
if (places.length === 1) {
formData.city = places[0]["place name"];
showStatusMessage(
`Location found: ${places[0]["place name"]}, ${places[0]["state abbreviation"] || places[0].state}`,
"success",
);
} else {
// Clear city selection if multiple cities
formData.city = "";
showStatusMessage(
`Found ${places.length} cities for this zip code. Please select one.`,
"info",
);
}
} else {
// No results found - enable manual entry
handleApiFailure("No location data found for this zip code");
}
} catch (error) {
console.error("Error fetching city/state data:", error);
// Check if it's a network/CORS error
if (error.code === "ERR_NETWORK" || error.message.includes("Network Error")) {
handleApiFailure(
"Unable to fetch location data. Please enter city and state manually.",
);
} else {
handleApiFailure(
"Location lookup failed. Please enter city and state manually.",
);
}
} finally {
isLoadingZipcode.value = false;
}
}
}
}
// Handle API failure by enabling manual entry
function handleApiFailure(message) {
console.warn("Zipcode API failed:", message);
// Clear existing data
availableCities.value = [];
formData.city = "";
formData.state = "";
// Show user-friendly message
showStatusMessage(message, "warning");
}
// Show status message to user
function showStatusMessage(message, type = "info") {
statusMessage.value = message;
statusType.value = type;
// Auto-clear message after 5 seconds
setTimeout(() => {
statusMessage.value = "";
}, 5000);
}
// Get icon class for status messages
function getStatusIcon(type) {
switch (type) {
case "warning":
return "pi pi-exclamation-triangle";
case "error":
return "pi pi-times-circle";
case "success":
return "pi pi-check-circle";
default:
return "pi pi-info-circle";
}
}
// Submission state to prevent double submission
const isSubmitting = ref(false);
// Handle form submission
async function handleSubmit(formDataFromEvent) {
// Prevent double submission with detailed logging
if (isSubmitting.value) {
console.warn(
"CreateInvoiceModal: Form submission already in progress, ignoring duplicate submission",
);
return;
}
console.log(
"CreateInvoiceModal: Form submission started with data:",
formDataFromEvent || formData,
);
isSubmitting.value = true;
try {
showStatusMessage("Creating invoice...", "info");
// Use the form data from the event if provided, otherwise use reactive formData
const dataToSubmit = formDataFromEvent || formData;
console.log("CreateInvoiceModal: Calling API with data:", dataToSubmit);
// Call API to create invoice
const response = await Api.createInvoice(dataToSubmit);
console.log("CreateInvoiceModal: API response received:", response);
if (response && response.success) {
showStatusMessage("Invoice created successfully!", "success");
// Close modal after a brief delay
setTimeout(() => {
handleClose();
}, 1500);
} else {
throw new Error(response?.message || "Failed to create invoice");
}
} catch (error) {
console.error("CreateInvoiceModal: Error creating invoice:", error);
showStatusMessage(error.message || "Failed to create invoice. Please try again.", "error");
} finally {
isSubmitting.value = false;
// Also reset the Form component's internal submission state
if (formRef.value && formRef.value.stopLoading) {
formRef.value.stopLoading();
}
console.log("CreateInvoiceModal: Form submission completed, isSubmitting reset to false");
}
}
// Handle cancel action
function handleCancel() {
handleClose();
}
// Handle modal close
function handleClose() {
modalStore.closeModal("createInvoice");
resetForm();
}
// Handle visibility changes
function handleVisibilityChange(visible) {
if (!visible) {
handleClose();
}
}
// Reset form data
function resetForm() {
Object.keys(formData).forEach((key) => {
formData[key] = "";
});
availableCities.value = [];
isLoadingZipcode.value = false;
statusMessage.value = "";
statusType.value = "info";
}
// Initialize modal in store when component mounts
modalStore.initializeModal("createInvoice", {
closeOnEscape: true,
closeOnOutsideClick: true,
});
watch(isVisible, async () => {
if (isVisible.value) {
try {
const names = await Api.getCustomerNames();
customerNames.value = names;
} catch (error) {
console.error("Error loading customer names:", error);
}
try {
const names = await Api.getCompanyNames();
companyNames.value = names;
} catch (error) {
console.error("Error loading company names:", error);
}
}
});
</script>
<style scoped>
.create-invoice-modal {
border-radius: 12px;
}
/* Custom styling for the modal content */
:deep(.modal-header) {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
:deep(.modal-title) {
font-weight: 600;
font-size: 1.25rem;
}
:deep(.modal-close-btn) {
color: white !important;
}
:deep(.modal-content) {
padding: 24px;
}
/* Status message styling */
.status-message {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
display: flex;
align-items: center;
font-size: 0.9rem;
border-left: 4px solid;
}
.status-icon {
margin-right: 8px;
font-size: 1rem;
}
.status-info {
background-color: #e3f2fd;
color: #1565c0;
border-left-color: #2196f3;
}
.status-warning {
background-color: #fff3e0;
color: #ef6c00;
border-left-color: #ff9800;
}
.status-error {
background-color: #ffebee;
color: #c62828;
border-left-color: #f44336;
}
.status-success {
background-color: #e8f5e8;
color: #2e7d32;
border-left-color: #4caf50;
}
</style>

View File

@ -1,325 +0,0 @@
<template>
<Modal
:visible="isVisible"
:options="modalOptions"
@update:visible="handleVisibilityChange"
@close="handleClose"
>
<template #title> Create New Job </template>
<!-- Status Message -->
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`">
<i :class="getStatusIcon(statusType)" class="status-icon"></i>
{{ statusMessage }}
</div>
<Form
ref="formRef"
:fields="formFields"
:form-data="formData"
:show-cancel-button="true"
:validate-on-change="false"
:validate-on-blur="true"
:validate-on-submit="true"
:loading="isSubmitting"
:disable-on-loading="true"
submit-button-text="Create Job"
cancel-button-text="Cancel"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { useModalStore } from "@/stores/modal";
import Modal from "@/components/common/Modal.vue";
import Form from "@/components/common/Form.vue";
import Api from "@/api";
import DataUtils from "../../utils";
const modalStore = useModalStore();
// Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen("createJob"));
const customerNames = ref([]);
const companyNames = ref([]);
// Form reference for controlling its state
const formRef = ref(null);
// Form data
const formData = reactive({
address: "",
requireHalfDown: "",
customerName: "",
company: "",
});
// Status message for user feedback
const statusMessage = ref("");
const statusType = ref("info"); // 'info', 'warning', 'error', 'success'
// Modal configuration
const modalOptions = {
maxWidth: "600px",
persistent: false,
showActions: false,
title: "Create New Job",
overlayColor: "rgb(59, 130, 246)", // Blue background
overlayOpacity: 0.8,
cardClass: "create-job-modal",
closeOnOutsideClick: true,
closeOnEscape: true,
};
// Form field definitions
const formFields = computed(() => [
{
name: "address",
label: "Installation Address",
type: "text",
required: true,
placeholder: "Enter street address",
helpText: "Street address for the installation service.",
cols: 12,
md: 6,
},
{
name: "requireHalfDown",
label: "Requires Half Down?",
type: "checkbox",
required: true,
defaultValue: false,
cols: 12,
md: 6,
helpText: "Check this box if the job requires half down to start.",
},
{
name: "customerName",
label: "Client Name",
type: "autocomplete", // Changed from 'select' to 'autocomplete'
required: true,
placeholder: "Type or select client name",
cols: 12,
md: 6,
options: customerNames.value, // Direct array of strings
forceSelection: false, // Allow custom entries not in the list
dropdown: true,
// For string arrays, don't set optionLabel at all
helpText: "Select an existing client or enter a new client name",
// Let the Form component handle filtering automatically
},
{
name: "company",
label: "Company Name",
type: "autocomplete", // Changed from 'select' to 'autocomplete'
required: true,
placeholder: "Select Company",
cols: 12,
md: 6,
options: companyNames.value, // Direct array of strings
dropdown: true,
// For string arrays, don't set optionLabel at all
helpText: "Select company associated with this job.",
// Let the Form component handle filtering automatically
},
]);
// Show status message to user
function showStatusMessage(message, type = "info") {
statusMessage.value = message;
statusType.value = type;
// Auto-clear message after 5 seconds
setTimeout(() => {
statusMessage.value = "";
}, 5000);
}
// Get icon class for status messages
function getStatusIcon(type) {
switch (type) {
case "warning":
return "pi pi-exclamation-triangle";
case "error":
return "pi pi-times-circle";
case "success":
return "pi pi-check-circle";
default:
return "pi pi-info-circle";
}
}
// Submission state to prevent double submission
const isSubmitting = ref(false);
// Handle form submission
async function handleSubmit(formDataFromEvent) {
// Prevent double submission with detailed logging
if (isSubmitting.value) {
console.warn(
"CreateJobModal: Form submission already in progress, ignoring duplicate submission",
);
return;
}
console.log(
"CreateJobModal: Form submission started with data:",
formDataFromEvent || formData,
);
isSubmitting.value = true;
try {
showStatusMessage("Creating job...", "info");
// Use the form data from the event if provided, otherwise use reactive formData
const dataToSubmit = formDataFromEvent || formData;
console.log("CreateJobModal: Calling API with data:", dataToSubmit);
// Call API to create client
const response = await Api.createJob(dataToSubmit);
console.log("CreateJobModal: API response received:", response);
if (response && response.success) {
showStatusMessage("Job created successfully!", "success");
// Close modal after a brief delay
setTimeout(() => {
handleClose();
}, 1500);
} else {
throw new Error(response?.message || "Failed to create job");
}
} catch (error) {
console.error("CreateJobModal: Error creating job:", error);
showStatusMessage(error.message || "Failed to create job. Please try again.", "error");
} finally {
isSubmitting.value = false;
// Also reset the Form component's internal submission state
if (formRef.value && formRef.value.stopLoading) {
formRef.value.stopLoading();
}
console.log("CreateJobModal: Form submission completed, isSubmitting reset to false");
}
}
// Handle cancel action
function handleCancel() {
handleClose();
}
// Handle modal close
function handleClose() {
modalStore.closeModal("createJob");
resetForm();
}
// Handle visibility changes
function handleVisibilityChange(visible) {
if (!visible) {
handleClose();
}
}
// Reset form data
function resetForm() {
Object.keys(formData).forEach((key) => {
formData[key] = "";
});
availableCities.value = [];
isLoadingZipcode.value = false;
statusMessage.value = "";
statusType.value = "info";
}
// Initialize modal in store when component mounts
modalStore.initializeModal("createJob", {
closeOnEscape: true,
closeOnOutsideClick: true,
});
watch(isVisible, async () => {
if (isVisible.value) {
try {
const names = await Api.getCustomerNames();
customerNames.value = names;
} catch (error) {
console.error("Error loading customer names:", error);
}
try {
const names = await Api.getCompanyNames();
companyNames.value = names;
} catch (error) {
console.error("Error loading company names:", error);
}
}
});
</script>
<style scoped>
.create-client-modal {
border-radius: 12px;
}
/* Custom styling for the modal content */
:deep(.modal-header) {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
:deep(.modal-title) {
font-weight: 600;
font-size: 1.25rem;
}
:deep(.modal-close-btn) {
color: white !important;
}
:deep(.modal-content) {
padding: 24px;
}
/* Status message styling */
.status-message {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
display: flex;
align-items: center;
font-size: 0.9rem;
border-left: 4px solid;
}
.status-icon {
margin-right: 8px;
font-size: 1rem;
}
.status-info {
background-color: #e3f2fd;
color: #1565c0;
border-left-color: #2196f3;
}
.status-warning {
background-color: #fff3e0;
color: #ef6c00;
border-left-color: #ff9800;
}
.status-error {
background-color: #ffebee;
color: #c62828;
border-left-color: #f44336;
}
.status-success {
background-color: #e8f5e8;
color: #2e7d32;
border-left-color: #4caf50;
}
</style>

View File

@ -1,331 +0,0 @@
<template>
<Modal
:visible="isVisible"
:options="modalOptions"
@update:visible="handleVisibilityChange"
@close="handleClose"
>
<template #title> Create New Warranty </template>
<!-- Status Message -->
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`">
<i :class="getStatusIcon(statusType)" class="status-icon"></i>
{{ statusMessage }}
</div>
<Form
ref="formRef"
:fields="formFields"
:form-data="formData"
:show-cancel-button="true"
:validate-on-change="false"
:validate-on-blur="true"
:validate-on-submit="true"
:loading="isSubmitting"
:disable-on-loading="true"
submit-button-text="Create Warranty"
cancel-button-text="Cancel"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { useModalStore } from "@/stores/modal";
import Modal from "@/components/common/Modal.vue";
import Form from "@/components/common/Form.vue";
import Api from "@/api";
import DataUtils from "../../utils";
const modalStore = useModalStore();
// Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen("createWarranty"));
const customerNames = ref([]);
// Form reference for controlling its state
const formRef = ref(null);
// Form data
const formData = reactive({
customerName: "",
status: "",
issueDate: "",
issue: ""
});
// Status message for user feedback
const statusMessage = ref("");
const statusType = ref("info"); // 'info', 'warning', 'error', 'success'
// Modal configuration
const modalOptions = {
maxWidth: "600px",
persistent: false,
showActions: false,
title: "Create New Warranty",
overlayColor: "rgb(59, 130, 246)", // Blue background
overlayOpacity: 0.8,
cardClass: "create-warranty-modal",
closeOnOutsideClick: true,
closeOnEscape: true,
};
// Form field definitions
const formFields = computed(() => [
{
name: "customerName",
label: "Client Name",
type: "autocomplete",
required: true,
placeholder: "Type or select client name",
cols: 12,
md: 6,
options: customerNames.value,
forceSelection: false, // Allow custom entries not in the list
dropdown: true,
helpText: "Select an existing client or enter a new client name",
},
{
name: "status",
label: "Status",
type: "select",
required: true,
placeholder: "Choose a status",
cols: 12,
md: 6,
options: [
{"label": "Open", "value": "Open"},
{"label": "Closed", "value": "Closed"},
{"label": "Work In Progress", "value": "Work In Progress"},
{"label": "Cancelled", "value": "Cancelled"}
],
dropdown: true,
helpText: "Select a Warranty Status from the list."
},
{
name: "issueDate",
label: "Issue Date",
type: "date",
required: true,
cols: 12,
md: 6,
helpText: "Enter day this issue first occurred."
},
{
name: "issue",
label: "Issue",
type: "textarea",
rows: 20,
cols: 12,
md: 12,
placeholder: "Describe the warranty issue."
}
]);
// Handle API failure by enabling manual entry
function handleApiFailure(message) {
console.warn("Zipcode API failed:", message);
// Clear existing data
availableCities.value = [];
formData.city = "";
formData.state = "";
// Show user-friendly message
showStatusMessage(message, "warning");
}
// Show status message to user
function showStatusMessage(message, type = "info") {
statusMessage.value = message;
statusType.value = type;
// Auto-clear message after 5 seconds
setTimeout(() => {
statusMessage.value = "";
}, 5000);
}
// Get icon class for status messages
function getStatusIcon(type) {
switch (type) {
case "warning":
return "pi pi-exclamation-triangle";
case "error":
return "pi pi-times-circle";
case "success":
return "pi pi-check-circle";
default:
return "pi pi-info-circle";
}
}
// Submission state to prevent double submission
const isSubmitting = ref(false);
// Handle form submission
async function handleSubmit(formDataFromEvent) {
// Prevent double submission with detailed logging
if (isSubmitting.value) {
console.warn(
"CreateWarrantyModal: Form submission already in progress, ignoring duplicate submission",
);
return;
}
console.log(
"CreateWarrantyModal: Form submission started with data:",
formDataFromEvent || formData,
);
isSubmitting.value = true;
try {
showStatusMessage("Creating warranty...", "info");
// Use the form data from the event if provided, otherwise use reactive formData
const dataToSubmit = formDataFromEvent || formData;
console.log("CreateWarrantyModal: Calling API with data:", dataToSubmit);
// Call API to create warranty
const response = await Api.createWarranty(dataToSubmit);
console.log("CreateWarrantyModal: API response received:", response);
if (response && response.success) {
showStatusMessage("Warranty created successfully!", "success");
// Close modal after a brief delay
setTimeout(() => {
handleClose();
}, 1500);
} else {
throw new Error(response?.message || "Failed to create warranty");
}
} catch (error) {
console.error("CreateWarrantyModal: Error creating warranty:", error);
showStatusMessage(error.message || "Failed to create warranty. Please try again.", "error");
} finally {
isSubmitting.value = false;
// Also reset the Form component's internal submission state
if (formRef.value && formRef.value.stopLoading) {
formRef.value.stopLoading();
}
console.log("CreateWarrantyModal: Form submission completed, isSubmitting reset to false");
}
}
// Handle cancel action
function handleCancel() {
handleClose();
}
// Handle modal close
function handleClose() {
modalStore.closeModal("createWarranty");
resetForm();
}
// Handle visibility changes
function handleVisibilityChange(visible) {
if (!visible) {
handleClose();
}
}
// Reset form data
function resetForm() {
Object.keys(formData).forEach((key) => {
formData[key] = "";
});
availableCities.value = [];
isLoadingZipcode.value = false;
statusMessage.value = "";
statusType.value = "info";
}
// Initialize modal in store when component mounts
modalStore.initializeModal("createWarranty", {
closeOnEscape: true,
closeOnOutsideClick: true,
});
watch(isVisible, async () => {
if (isVisible.value) {
try {
const names = await Api.getCustomerNames();
customerNames.value = names;
} catch (error) {
console.error("Error loading customer names:", error);
}
}
});
</script>
<style scoped>
.create-warranty-modal {
border-radius: 12px;
}
/* Custom styling for the modal content */
:deep(.modal-header) {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
:deep(.modal-title) {
font-weight: 600;
font-size: 1.25rem;
}
:deep(.modal-close-btn) {
color: white !important;
}
:deep(.modal-content) {
padding: 24px;
}
/* Status message styling */
.status-message {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
display: flex;
align-items: center;
font-size: 0.9rem;
border-left: 4px solid;
}
.status-icon {
margin-right: 8px;
font-size: 1rem;
}
.status-info {
background-color: #e3f2fd;
color: #1565c0;
border-left-color: #2196f3;
}
.status-warning {
background-color: #fff3e0;
color: #ef6c00;
border-left-color: #ff9800;
}
.status-error {
background-color: #ffebee;
color: #c62828;
border-left-color: #f44336;
}
.status-success {
background-color: #e8f5e8;
color: #2e7d32;
border-left-color: #4caf50;
}
</style>

View File

@ -1,117 +0,0 @@
<template>
<Modal v-model:visible="showModal" :options="modalOptions" @confirm="handleClose">
<template #title>Meeting Details</template>
<div v-if="meeting" class="meeting-details">
<div class="detail-row">
<v-icon class="mr-2">mdi-map-marker</v-icon>
<strong>Addresss:</strong> {{ meeting.address.fullAddress }}
</div>
<div class="detail-row" v-if="meeting.client">
<v-icon class="mr-2">mdi-account</v-icon>
<strong>Client:</strong> {{ meeting.client }}
</div>
<div class="detail-row">
<v-icon class="mr-2">mdi-calendar</v-icon>
<strong>Date:</strong> {{ formatDate(meeting.date) }}
</div>
<div class="detail-row">
<v-icon class="mr-2">mdi-clock</v-icon>
<strong>Time:</strong> {{ formatTimeDisplay(meeting.scheduledTime) }}
</div>
<div class="detail-row" v-if="meeting.duration">
<v-icon class="mr-2">mdi-timer</v-icon>
<strong>Duration:</strong> {{ meeting.duration }} minutes
</div>
<div class="detail-row" v-if="meeting.notes">
<v-icon class="mr-2">mdi-note-text</v-icon>
<strong>Notes:</strong> {{ meeting.notes }}
</div>
<div class="detail-row" v-if="meeting.status">
<v-icon class="mr-2">mdi-check-circle</v-icon>
<strong>Status:</strong> {{ meeting.status }}
</div>
</div>
</Modal>
</template>
<script setup>
import { computed } from "vue";
import Modal from "../common/Modal.vue";
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
meeting: {
type: Object,
default: null,
},
});
// Emits
const emit = defineEmits(["update:visible", "close"]);
// Local state
const showModal = computed({
get() {
return props.visible;
},
set(value) {
emit("update:visible", value);
},
});
// Modal options
const modalOptions = computed(() => ({
maxWidth: "600px",
showCancelButton: false,
confirmButtonText: "Close",
confirmButtonColor: "primary",
}));
// Methods
const handleClose = () => {
emit("close");
};
const formatTimeDisplay = (time) => {
if (!time) return "";
const [hours, minutes] = time.split(":").map(Number);
const displayHour = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
const ampm = hours >= 12 ? "PM" : "AM";
return `${displayHour}:${minutes.toString().padStart(2, "0")} ${ampm}`;
};
const formatDate = (dateStr) => {
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
};
</script>
<style scoped>
.meeting-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.detail-row strong {
margin-right: 8px;
color: #333;
}
</style>

View File

@ -1,333 +0,0 @@
<template>
<Modal
:visible="isVisible"
:options="modalOptions"
@update:visible="handleVisibilityChange"
@close="handleClose"
>
<template #title> Submit New Estimate </template>
<!-- Status Message -->
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`">
<i :class="getStatusIcon(statusType)" class="status-icon"></i>
{{ statusMessage }}
</div>
<Form
ref="formRef"
:fields="formFields"
:form-data="formData"
:show-cancel-button="true"
:validate-on-change="false"
:validate-on-blur="true"
:validate-on-submit="true"
:loading="isSubmitting"
:disable-on-loading="true"
submit-button-text="Submit Estimate"
cancel-button-text="Cancel"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { useModalStore } from "@/stores/modal";
import Modal from "@/components/common/Modal.vue";
import Form from "@/components/common/Form.vue";
import Api from "@/api";
import DataUtils from "../../utils";
const modalStore = useModalStore();
// Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen("submitEstimate"));
const companyNames = ref([]);
// Form reference for controlling its state
const formRef = ref(null);
// Form data
const formData = reactive({
address: "",
company: "",
date: "",
quotationTo: "",
partyName: "",
items: "",
});
// Available cities for the selected zipcode
const availableCities = ref([]);
// Loading state for zipcode lookup
const isLoadingZipcode = ref(false);
// Status message for user feedback
const statusMessage = ref("");
const statusType = ref("info"); // 'info', 'warning', 'error', 'success'
// Modal configuration
const modalOptions = {
maxWidth: "600px",
persistent: false,
showActions: false,
title: "Create New Estimate",
overlayColor: "rgb(59, 130, 246)", // Blue background
overlayOpacity: 0.8,
cardClass: "create-estimate-modal",
closeOnOutsideClick: true,
closeOnEscape: true,
};
// Form field definitions
const formFields = computed(() => [
{
name: "address",
label: "Client Address",
type: "text",
required: true,
placeholder: "Enter street address",
cols: 12,
md: 6,
helpText: "Enter address for this estimate",
},
{
name: "company",
label: "Company Name",
type: "autocomplete", // Changed from 'select' to 'autocomplete'
required: true,
placeholder: "Select Company",
cols: 12,
md: 6,
options: companyNames.value, // Direct array of strings
dropdown: true,
// For string arrays, don't set optionLabel at all
helpText: "Select company associated with this estimate.",
// Let the Form component handle filtering automatically
},
{
name: "date",
label: "Current Date",
type: "date",
required: true,
placeholder: "",
cols: 12,
md: 6,
},
{
name: "quotationTo",
label: "Client Type",
type: "select",
required: true,
placeholder: "Select Customer or Business",
cols: 12,
md: 6,
options: [
{"label": "Customer", "value": "Customer"},
{"label": "Business", "value": "Business"}
]
},
{
name: "partyName",
label: "Client Name",
type: "text",
required: true,
placeholder: "",
cols: 12,
md: 4,
},
]);
// Show status message to user
function showStatusMessage(message, type = "info") {
statusMessage.value = message;
statusType.value = type;
// Auto-clear message after 5 seconds
setTimeout(() => {
statusMessage.value = "";
}, 5000);
}
// Get icon class for status messages
function getStatusIcon(type) {
switch (type) {
case "warning":
return "pi pi-exclamation-triangle";
case "error":
return "pi pi-times-circle";
case "success":
return "pi pi-check-circle";
default:
return "pi pi-info-circle";
}
}
// Submission state to prevent double submission
const isSubmitting = ref(false);
// Handle form submission
async function handleSubmit(formDataFromEvent) {
// Prevent double submission with detailed logging
if (isSubmitting.value) {
console.warn(
"CreateEstimateModal: Form submission already in progress, ignoring duplicate submission",
);
return;
}
console.log(
"CreateEstimateModal: Form submission started with data:",
formDataFromEvent || formData,
);
isSubmitting.value = true;
try {
showStatusMessage("Creating estimate...", "info");
// Use the form data from the event if provided, otherwise use reactive formData
const dataToSubmit = formDataFromEvent || formData;
console.log("CreateEstimateModal: Calling API with data:", dataToSubmit);
// Call API to create client
const response = await Api.createEstimate(dataToSubmit);
console.log("CreateEstimateModal: API response received:", response);
if (response && response.success) {
showStatusMessage("Estimate created successfully!", "success");
// Close modal after a brief delay
setTimeout(() => {
handleClose();
}, 1500);
} else {
throw new Error(response?.message || "Failed to create estimate");
}
} catch (error) {
console.error("CreateEstimateModal: Error creating client:", error);
showStatusMessage(
error.message || "Failed to create estimate. Please try again.",
"error",
);
} finally {
isSubmitting.value = false;
// Also reset the Form component's internal submission state
if (formRef.value && formRef.value.stopLoading) {
formRef.value.stopLoading();
}
console.log("CreateEstimateModal: Form submission completed, isSubmitting reset to false");
}
}
// Handle cancel action
function handleCancel() {
handleClose();
}
// Handle modal close
function handleClose() {
modalStore.closeModal("submitEstimate");
resetForm();
}
// Handle visibility changes
function handleVisibilityChange(visible) {
if (!visible) {
handleClose();
}
}
// Reset form data
function resetForm() {
Object.keys(formData).forEach((key) => {
formData[key] = "";
});
statusMessage.value = "";
statusType.value = "info";
}
// Initialize modal in store when component mounts
modalStore.initializeModal("submitEstimate", {
closeOnEscape: true,
closeOnOutsideClick: true,
});
watch(isVisible, async () => {
if (isVisible.value) {
try {
const names = await Api.getCompanyNames();
companyNames.value = names;
} catch (error) {
console.error("Error loading company names:", error);
}
}
});
</script>
<style scoped>
.create-client-modal {
border-radius: 12px;
}
/* Custom styling for the modal content */
:deep(.modal-header) {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
:deep(.modal-title) {
font-weight: 600;
font-size: 1.25rem;
}
:deep(.modal-close-btn) {
color: white !important;
}
:deep(.modal-content) {
padding: 24px;
}
/* Status message styling */
.status-message {
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
display: flex;
align-items: center;
font-size: 0.9rem;
border-left: 4px solid;
}
.status-icon {
margin-right: 8px;
font-size: 1rem;
}
.status-info {
background-color: #e3f2fd;
color: #1565c0;
border-left-color: #2196f3;
}
.status-warning {
background-color: #fff3e0;
color: #ef6c00;
border-left-color: #ff9800;
}
.status-error {
background-color: #ffebee;
color: #c62828;
border-left-color: #f44336;
}
.status-success {
background-color: #e8f5e8;
color: #2e7d32;
border-left-color: #4caf50;
}
</style>

View File

@ -1,10 +1,6 @@
<template>
<CalendarNavigation />
<div>
<h2>Calendar</h2>
</div>
</template>
<script setup>
import CalendarNavigation from '@/components/calendar/CalendarNavigation.vue'
</script>
<style scoped>
</style>
<script setup></script>

View File

@ -1,187 +0,0 @@
<template>
<div class="client-page">
<!-- Client Header -->
<TopBar :selectedAddressIdx="selectedAddressIdx" :client="client" :nextVisitDate="nextVisitDate" v-if="client.customerName" @update:selectedAddressIdx="selectedAddressIdx = $event" />
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
<Tabs value="0">
<TabList>
<Tab value="0">Overview</Tab>
<Tab value="1">Projects <span class="tab-info-alert">1</span></Tab>
<Tab value="2">Financials</Tab>
</TabList>
<TabPanels>
<TabPanel value="0">
<Overview
:client-data="client"
:selected-address="selectedAddress"
:is-new="isNew"
/>
</TabPanel>
<TabPanel value="1">
<div id="projects-tab"><h3>Project Status</h3></div>
</TabPanel>
<TabPanel value="2">
<div id="financials-tab"><h3>Accounting</h3></div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import Tabs from "primevue/tabs";
import TabList from "primevue/tablist";
import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
import Api from "../../api";
import { useRoute } from "vue-router";
import { useLoadingStore } from "../../stores/loading";
import { useNotificationStore } from "../../stores/notifications-primevue";
import DataUtils from "../../utils";
import Overview from "../clientSubPages/Overview.vue";
import ProjectStatus from "../clientSubPages/ProjectStatus.vue";
import TopBar from "../clientView/TopBar.vue";
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
const route = useRoute();
const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const address = route.query.address || null;
const clientName = route.query.client || null;
const isNew = computed(() => route.query.new === "true" || false);
const clientNames = ref([]);
const client = ref({});
const geocode = ref({});
const selectedAddress = ref(address);
const selectedAddressObject = computed(() =>
client.value.addresses?.find(
(addr) => DataUtils.calculateFullAddress(addr) === selectedAddress.value,
),
);
const addresses = computed(() => {
if (client.value && client.value.addresses) {
return client.value.addresses.map((addr) => DataUtils.calculateFullAddress(addr));
}
return [];
});
const nextVisitDate = ref(null); // Placeholder, update as needed
const selectedAddressIdx = computed({
get: () => addresses.value.indexOf(selectedAddress.value),
set: (idx) => {
if (idx >= 0 && idx < addresses.value.length) {
selectedAddress.value = addresses.value[idx];
}
}
});
const getClientNames = async (type) => {
loadingStore.setLoading(true);
try {
const names = await Api.getClientNames(type);
clientNames.value = names;
} catch (error) {
console.error("Error fetching client names in Client.vue: ", error.message || error);
} finally {
loadingStore.setLoading(false);
}
};
const getClient = async (name) => {
loadingStore.setLoading(true);
try {
const clientData = await Api.getClient(name);
client.value = clientData || {};
// Set initial selected address if provided in route or use first address
if (address && client.value.addresses) {
const fullAddresses = client.value.addresses.map((addr) =>
DataUtils.calculateFullAddress(addr),
);
if (fullAddresses.includes(address)) {
selectedAddress.value = address;
} else if (fullAddresses.length > 0) {
selectedAddress.value = fullAddresses[0];
}
} else if (client.value.addresses && client.value.addresses.length > 0) {
selectedAddress.value = DataUtils.calculateFullAddress(client.value.addresses[0]);
}
if (
selectedAddressObject.value?.customLongitude &&
selectedAddressObject.value?.customLatitude
) {
geocode.value = {
longitude: selectedAddressObject.value.customLongitude || selectedAddressObject.value.longitude,
latitude: selectedAddressObject.value.customLatitude || selectedAddressObject.value.latitude,
};
} else if (selectedAddress.value) {
// geocode.value = await Api.getGeocode(selectedAddress.value);
}
} catch (error) {
console.error("Error fetching client data in Client.vue: ", error.message || error);
} finally {
loadingStore.setLoading(false);
}
};
onMounted(async () => {
if (clientName) {
await getClient(clientName);
console.log("Displaying existing client data");
}
console.debug(
"DEBUG: Client.vue mounted with clientName:",
clientName,
"isNew:",
isNew.value,
"address:",
address,
"addresses:",
addresses.value,
"selectedAddress:",
selectedAddress.value,
"Does selected address match an address in addresses?:",
selectedAddress.value && addresses.value.includes(selectedAddress.value),
"geocode:",
geocode.value,
);
});
watch(
() => route.query,
async (newQuery, oldQuery) => {
const clientName = newQuery.client || null;
const isNewClient = newQuery.new === "true" || false;
const address = newQuery.address || null;
// Clear client data if switching to new client mode
if (isNewClient) {
client.value = {};
selectedAddress.value = null;
geocode.value = {};
console.log("Switched to new client mode - cleared client data");
} else if (clientName && clientName !== oldQuery.client) {
// Load client data if switching to existing client
await getClient(clientName);
console.log("Route query changed - displaying existing client data");
}
},
);
</script>
<style lang="css">
.tab-info-alert {
background-color: #a95e46;
border-radius: 10px;
color: white;
padding-left: 5px;
padding-right: 5px;
padding-top: 2px;
padding-bottom: 2px;
}
</style>

View File

@ -1,418 +1,43 @@
<template>
<div class="page-container">
<div>
<H2>Client Contact List</H2>
<DataTable
:data="tableData"
:columns="columns"
:filters="filters"
:tableActions="tableActions"
tableName="clients"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
@row-click="handleRowClick"
/>
<div id="filter-container" class="filter-container">
<input placeholder="Type to Search" />
<p>Type:</p>
<select id="type-selector"></select>
<button @click="onClick" id="add-customer-button" class="interaction-button">
Add
</button>
</div>
<DataTable v-if="tableData.length > 0" :data="tableData" :columns="columns" />
</div>
</template>
<script setup>
import { onMounted, ref, watch, computed } from "vue";
import Card from "../common/Card.vue";
import DataTable from "../common/DataTable.vue";
import StatusChart from "../common/StatusChart.vue";
import { onMounted, ref } from "vue";
import DataTable from "../DataTable.vue";
import Api from "../../api";
import { FilterMatchMode } from "@primevue/core";
import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters";
import { useModalStore } from "../../stores/modal";
import { useRouter, useRoute } from "vue-router";
import { useNotificationStore } from "../../stores/notifications-primevue";
import TodoChart from "../common/TodoChart.vue";
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
const notifications = useNotificationStore();
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
const modalStore = useModalStore();
const router = useRouter();
const route = useRoute();
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const statusCounts = ref({}); // Start with empty object
const currentWeekParams = ref({});
const chartLoading = ref(true); // Start with loading state
const lookup = route.query.lookup;
// Computed property to get current filters for the chart
const currentFilters = computed(() => {
return filtersStore.getTableFilters("clients");
});
// Handle week change from chart
const handleWeekChange = async (weekParams) => {
console.log("handleWeekChange called with:", weekParams);
currentWeekParams.value = weekParams;
await refreshStatusCounts();
};
// Refresh status counts with current week and filters
const refreshStatusCounts = async () => {
chartLoading.value = true;
try {
let params = {};
// Only apply weekly filtering if weekParams is provided (not null)
if (currentWeekParams.value) {
params = {
weekly: true,
weekStartDate: currentWeekParams.value.weekStartDate,
weekEndDate: currentWeekParams.value.weekEndDate,
};
console.log("Using weekly filter:", params);
} else {
// No weekly filtering - get all time data
params = {
weekly: false,
};
console.log("Using all-time data (no weekly filter)");
}
// Add current filters to the params
const currentFilters = filtersStore.getTableFilters("clients");
if (currentFilters && Object.keys(currentFilters).length > 0) {
params.filters = currentFilters;
}
const response = await Api.getClientStatusCounts(params);
statusCounts.value = response || {};
console.log("Status counts updated:", statusCounts.value);
} catch (error) {
console.error("Error refreshing status counts:", error);
statusCounts.value = {};
} finally {
chartLoading.value = false;
}
};
const filters = {
customerName: { value: null, matchMode: FilterMatchMode.CONTAINS },
address: { value: null, matchMode: FilterMatchMode.CONTAINS },
const onClick = () => {
frappe.new_doc("Customer");
};
const searchFields = { fields: ["full_name", "address", "email_id", "phone"] };
const columns = [
{
label: "Customer Name",
fieldName: "customerName",
type: "text",
sortable: true,
filterable: true,
filterInputId: "customerSearchInput",
},
{
label: "Type",
fieldName: "clientType",
type: "text",
sortable: true,
},
{
label: "Property",
fieldName: "address",
type: "text",
sortable: true,
filterable: true,
filterInputId: "propertySearchInput",
},
//{
// label: "Appt. Scheduled",
// fieldName: "appointmentScheduledStatus",
// type: "status-button",
// sortable: true,
// buttonVariant: "outlined",
// onStatusClick: (status, rowData) => handleAppointmentClick(status, rowData),
// // disableCondition: (status) => status?.toLowerCase() !== "not started",
//},
//{
// label: "Estimate Sent",
// fieldName: "estimateSentStatus",
// type: "status-button",
// sortable: true,
// buttonVariant: "outlined",
// onStatusClick: (status, rowData) => handleEstimateClick(status, rowData),
// // disableCondition: (status) => status?.toLowerCase() !== "not started",
//},
//{
// label: "Payment Received",
// fieldName: "paymentReceivedStatus",
// type: "status-button",
// sortable: true,
// buttonVariant: "outlined",
// onStatusClick: (status, rowData) => handlePaymentClick(status, rowData),
// // disableCondition: (status) => status?.toLowerCase() !== "not started",
//},
//{
// label: "Job Status",
// fieldName: "jobStatus",
// type: "status-button",
// sortable: true,
// buttonVariant: "outlined",
// onStatusClick: (status, rowData) => handleJobClick(status, rowData),
// // disableCondition: (status) => status?.toLowerCase() !== "not started",
//},
{ label: "Name", fieldName: "full_name" },
{ label: "Location", fieldName: "address" },
{ label: "Type", fieldName: "contact_type" },
{ label: "Contact", fieldName: "full_name" },
{ label: "Email", fieldName: "email_id" },
{ label: "Phone", fieldName: "phone" },
];
const tableActions = [
{
label: "Add Client",
action: () => {
router.push("/client?new=true");
},
type: "button",
style: "primary",
icon: "pi pi-plus",
layout: {
position: "left",
variant: "filled",
},
// Global action - always available
},
// {
// label: "Export Selected",
// action: (selectedRows) => {
// console.log("Exporting", selectedRows.length, "clients:", selectedRows);
// // Implementation would export selected clients
// },
// type: "button",
// style: "success",
// icon: "pi pi-download",
// requiresMultipleSelection: true, // Bulk action - operates on selected rows
// layout: {
// position: "right".,
// variant: "filled",
// },
// },
];
// Handle lazy loading events from DataTable
const handleLazyLoad = async (event) => {
console.log("Clients page - handling lazy load:", event);
try {
isLoading.value = true;
// If this is a sort event, update the store first
if (event.sortField !== undefined && event.sortOrder !== undefined) {
console.log("Sort event detected - updating store with:", {
sortField: event.sortField,
sortOrder: event.sortOrder,
orderType: typeof event.sortOrder,
});
filtersStore.updateTableSorting("clients", event.sortField, event.sortOrder);
}
// Get sorting information from filters store in backend format
const sortingArray = filtersStore.getTableSortingForBackend("clients");
console.log("Current sorting array for backend:", sortingArray);
// Get pagination parameters
const paginationParams = {
page: event.page || 0,
pageSize: event.rows || 10,
}; // Get filters (convert PrimeVue format to API format)
const filters = {};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) {
filters[key] = event.filters[key];
}
});
}
// For cache key, use primary sort field/order for compatibility
const primarySortField = filtersStore.getPrimarySortField("clients") || event.sortField;
const primarySortOrder = filtersStore.getPrimarySortOrder("clients") || event.sortOrder;
// Always fetch fresh data from API (cache only stores pagination/filter/sort state, not data)
// Call API with pagination, filters, and sorting in backend format
console.log("Making API call with:", {
paginationParams,
filters,
sortingArray,
});
const result = await Api.getPaginatedClientDetails(
paginationParams,
filters,
sortingArray,
);
console.log("API response:", result);
// Update local state - extract from pagination structure
tableData.value = result.data;
totalRecords.value = result.pagination.total;
// Update pagination store with new total
paginationStore.setTotalRecords("clients", result.pagination.total);
} catch (error) {
console.error("Error loading client data:", error);
// You could also show a toast or other error notification here
tableData.value = [];
totalRecords.value = 0;
} finally {
isLoading.value = false;
}
};
// Status button click handlers
const handleAppointmentClick = (status, rowData) => {
const address = encodeURIComponent(rowData.address);
if (status?.toLowerCase() === "not started") {
// Navigate to schedule on-site meeting
router.push(`/calendar?tab=bids&new=true&address=${address}`);
} else {
// Navigate to view appointment details
router.push('/calendar?tab=bids&address=' + address);
}
};
const handleEstimateClick = (status, rowData) => {
const address = encodeURIComponent(rowData.address);
if (status?.toLowerCase() === "not started") {
// Navigate to create quotation/estimate
router.push(`/estimate?new=true&address=${address}`);
} else {
// Navigate to view estimate details
router.push('/estimate?address=' + address);
}
};
const handlePaymentClick = (status, rowData) => {
notifications.addWarning("Payment view/create coming soon!");
// const address = encodeURIComponent(rowData.address);
// if (status?.toLowerCase() === "not started") {
// // Navigate to payment processing
// router.push(`/payments?new=true&address=${address}`);
// }
// else {
// // Navigate to view payment details
// router.push('/payments?address=' + address);
// }
};
const handleJobClick = (status, rowData) => {
notifications.addWarning("Job view/create coming soon!");
// const address = encodeURIComponent(rowData.address);
// if (status?.toLowerCase() === "not started") {
// // Navigate to job creation
// router.push(`/job?new=true&address=${address}`);
// } else {
// // Navigate to view job details
// router.push('/job?address=' + address);
// }
};
// Watch for filters change to update status counts
watch(
() => filtersStore.getTableFilters("clients"),
async () => {
await refreshStatusCounts();
},
{ deep: true },
);
// Handle row click to navigate to client details
const handleRowClick = (event) => {
const rowData = event.data;
router.push(`/client?client=${rowData.customerName}&address=${rowData.address}`);
};
onMounted(async () => {
// if lookup has a value (it will either be "customer" or "property", put focus onto the appropriate search input)
if (lookup) {
const inputElement = document.getElementById(`${lookup}SearchInput`);
if (inputElement) {
inputElement.focus();
}
}
// Initialize pagination and filters
paginationStore.initializeTablePagination("clients", { rows: 10 });
filtersStore.initializeTableFilters("clients", columns);
filtersStore.initializeTableSorting("clients");
let data = await Api.getClientDetails();
// Load first page
const initialPagination = paginationStore.getTablePagination("clients");
const initialFilters = filtersStore.getTableFilters("clients");
const primarySortField = filtersStore.getPrimarySortField("clients");
const primarySortOrder = filtersStore.getPrimarySortOrder("clients");
// Don't load initial status counts here - let the chart component handle it
// The chart will emit the initial week parameters and trigger refreshStatusCounts
await handleLazyLoad({
page: initialPagination.page,
rows: initialPagination.rows,
first: initialPagination.first,
sortField: primarySortField || initialPagination.sortField,
sortOrder: primarySortOrder || initialPagination.sortOrder,
filters: initialFilters,
});
console.log(data);
console.log(tableData.value);
tableData.value = data;
});
</script scoped>
<style lang="css">
.widgets-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.widget-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 0;
}
.widget-icon {
color: var(--theme-primary-strong);
width: 24px;
height: 24px;
}
.widget-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.widget-content {
display: flex;
flex-direction: column;
margin: 0;
width: 200px;
align-items: center;
padding: 20px 20px 20px;
/*gap: 15px;*/
}
.sidebar-button {
justify-content: center;
}
.page-container {
height: 100%;
margin: 20px;
gap: 20px;
background-color: transparent;
}
.chart-section {
margin-bottom: 20px;
}
</style>
</script>
<style lang="css"></style>

View File

@ -0,0 +1,9 @@
<template>
<div>
<h2>Create Page</h2>
</div>
</template>
<script>
export default {};
</script>
<style lang=""></style>

File diff suppressed because it is too large Load Diff

View File

@ -1,330 +0,0 @@
<template>
<div class="page-container">
<h2>Estimates</h2>
<!-- Todo Chart Section -->
<div class="widgets-grid">
<!-- Incomplete Bids Widget -->
<Card>
<template #header>
<div class="widget-header">
<Edit class="widget-icon" />
<h3>Incomplete Bids</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Incomplete Bids"
:todoNumber="bidsTodoNumber"
:completedNumber="bidsCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/calendar')">
Incomplete Bids
</button>
</div>
</template>
</Card>
<!-- Unapproved Estimates Widget -->
<Card>
<template #header>
<div class="widget-header">
<ChatBubbleQuestion class="widget-icon" />
<h3>Unapproved Estimates</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Unapproved Estimates"
:todoNumber="estimatesTodoNumber"
:completedNumber="estimatesCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/estimates')">
Unapproved Estimates
</button>
</div>
</template>
</Card>
<!-- Half Down Widget -->
<Card>
<template #header>
<div class="widget-header">
<CreditCard class="widget-icon" />
<h3>Half Down Payments</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Half Down Payments"
:todoNumber="halfDownTodoNumber"
:completedNumber="halfDownCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/jobs')">
Half Down Payments
</button>
</div>
</template>
</Card>
<!-- Late Balances Widget -->
<Card>
<template #header>
<div class="widget-header">
<Hammer class="widget-icon" />
<h3>Jobs In Queue</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Jobs In Queue"
:todoNumber="jobQueueTodoNumber"
:completedNumber="jobQueueCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/jobs')">
View Queued Jobs
</button>
</div>
</template>
</Card>
</div>
<DataTable
:data="tableData"
:columns="columns"
:tableActions="tableActions"
tableName="estimates"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
/>
<Modal
:visible="showSubmitEstimateModal"
@update:visible="showSubmitEstimateModal = $event"
@close="closeSubmitEstimateModal"
:options="{ showActions: false }"
>
<template #title>Add Item</template>
<div class="modal-content">
<DataTable
:data="filteredItems"
:columns="itemColumns"
:tableName="'estimate-items'"
:tableActions="tableActions"
selectable
:paginator="false"
:rows="filteredItems.length"
/>
</div>
</Modal>
</div>
</template>
<script setup>
import Card from "../common/Card.vue";
import DataTable from "../common/DataTable.vue";
import TodoChart from "../common/TodoChart.vue";
import { Edit, ChatBubbleQuestion, CreditCard, Hammer } from "@iconoir/vue";
import { ref, onMounted } from "vue";
import Api from "../../api";
import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters";
import { useRouter } from "vue-router";
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
const router = useRouter();
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const showSubmitEstimateModal = ref(true);
//Junk
const filteredItems= []
// End junk
const columns = [
{ label: "Estimate Address", fieldName: "address", type: "text", sortable: true, filterable: true },
//{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
{
label: "Status",
fieldName: "status",
type: "status-button",
sortable: true,
buttonVariant: "outlined",
onStatusClick: (status, rowData) => handleEstimateClick(status, rowData),
//disableCondition: (status) => status?.toLowerCase() === "draft",
disableCondition: false
},
{ label: "Order Type", fieldName: "orderType", type: "text", sortable: true },
//{ label: "Estimate Amount", fieldName:
];
const tableActions = [
{
label: "View Details",
action: (rowData) => {
router.push(`/estimate?name=${encodeURIComponent(rowData.id)}`);
},
type: "button",
style: "info",
icon: "pi pi-eye",
requiresSelection: true,
layout: {
position: "center",
variant: "outlined",
},
},
];
const handleEstimateClick = (status, rowData) => {
// Navigate to estimate details page with the name
router.push(`/estimate?name=${encodeURIComponent(rowData.name)}`);
};
const closeSubmitEstimateModal = () => {
showSubmitEstimateModal.value = false;
};
const handleLazyLoad = async (event) => {
console.log("Estimates page - handling lazy load:", event);
try {
isLoading.value = true;
// Get sorting information from filters store first (needed for cache key)
const sorting = filtersStore.getTableSorting("estimates");
console.log("Current sorting state:", sorting);
// Get pagination parameters
const paginationParams = {
page: event.page || 0,
pageSize: event.rows || 10,
sortField: event.sortField,
sortOrder: event.sortOrder,
};
// Get filters (convert PrimeVue format to API format)
const filters = {};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) {
filters[key] = event.filters[key];
}
});
}
// Always fetch fresh data from API (cache only stores pagination/filter/sort state, not data)
console.log("Making API call with:", { paginationParams, filters });
// Call API with pagination, filters, and sorting
const result = await Api.getPaginatedEstimateDetails(paginationParams, filters, sorting);
console.log("DEBUG: Result from api:", result);
// Update local state - extract from pagination structure
tableData.value = result.data;
totalRecords.value = result.pagination.total;
// Update pagination store with new total
paginationStore.setTotalRecords("estimates", result.pagination.total);
console.log("Updated pagination state:", {
tableData: tableData.value.length,
totalRecords: totalRecords.value,
storeTotal: paginationStore.getTablePagination("estimates").totalRecords,
storeTotalPages: paginationStore.getTotalPages("estimates"),
});
console.log("Loaded from API:", {
records: result.data.length,
total: result.pagination.total,
page: paginationParams.page + 1,
});
} catch (error) {
console.error("Error loading estimate data:", error);
// You could also show a toast or other error notification here
tableData.value = [];
totalRecords.value = 0;
} finally {
isLoading.value = false;
}
};
// Load initial data
onMounted(async () => {
// Initialize pagination and filters
paginationStore.initializeTablePagination("estimates", { rows: 10 });
filtersStore.initializeTableFilters("estimates", columns);
filtersStore.initializeTableSorting("estimates");
// Load first page
const initialPagination = paginationStore.getTablePagination("estimates");
const initialFilters = filtersStore.getTableFilters("estimates");
const initialSorting = filtersStore.getTableSorting("estimates");
await handleLazyLoad({
page: initialPagination.page,
rows: initialPagination.rows,
first: initialPagination.first,
sortField: initialSorting.field || initialPagination.sortField,
sortOrder: initialSorting.order || initialPagination.sortOrder,
filters: initialFilters,
});
});
</script>
<style lang="css">
.widgets-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.widget-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 0;
}
.widget-icon {
color: var(--theme-primary-strong);
width: 24px;
height: 24px;
}
.widget-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.widget-content {
display: flex;
flex-direction: column;
margin: 0;
width: 200px;
align-items: center;
padding: 20px 20px 20px;
/*gap: 15px;*/
}
.page-container {
height: 100%;
margin: 20px;
gap: 20px;
background-color: transparent;
}
</style>

View File

@ -1,495 +1,9 @@
<template>
<div class="dashboard">
<h1 class="dashboard-title">Today's Tasks</h1>
<div class="widgets-grid">
<!-- Locates Widget -->
<Card>
<template #header>
<div class="widget-header">
<ShieldSearch class="widget-icon" />
<h3>Locates</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Locates"
:todoNumber="locatesTodoNumber"
:completedNumber="locatesCompletedNumber"
>
</TodoChart>
<button class="sidebar-button" @click="navigateTo('/jobs')">
View Locates
</button>
</div>
</template>
</Card>
<!-- Permits Widget -->
<Card >
<template #header>
<div class="widget-header">
<ClipboardCheck class="widget-icon" />
<h3>Permits</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Permits"
:todoNumber="permitsTodoNumber"
:completedNumber="permitsCompletedNumber"
>
</TodoChart>
<button class="sidebar-button" @click="navigateTo('/jobs')">
View Permits
</button>
</div>
</template>
</Card>
<!-- Permits Finalization Widget -->
<Card >
<template #header>
<div class="widget-header">
<DoubleCheck class="widget-icon" />
<h3>Permit Finalizations</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Permit Finalization"
:todoNumber="permitFinalizationsTodoNumber"
:completedNumber="permitFinalizationsCompletedNumber"
>
</TodoChart>
<button class="sidebar-button" @click="navigateTo('/jobs')">
View Finalizations
</button>
</div>
</template>
</Card>
<!-- Warranties Widget -->
<Card >
<template #header>
<div class="widget-header">
<Shield class="widget-icon" />
<h3>Warranties</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Warranty Claims"
:todoNumber="warrantyTodoNumber"
:completedNumber="warrantyCompletedNumber"
>
</TodoChart>
<button class="sidebar-button" @click="navigateTo('/jobs')">
View Warranties
</button>
</div>
</template>
</Card>
<!-- Incomplete Bids Widget -->
<Card >
<template #header>
<div class="widget-header">
<Edit class="widget-icon" />
<h3>Incomplete Bids</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Incomplete Bids"
:todoNumber="bidsTodoNumber"
:completedNumber="bidsCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/calendar')">
Incomplete Bids
</button>
</div>
</template>
</Card>
<!-- Unapproved Estimates Widget -->
<Card >
<template #header>
<div class="widget-header">
<ChatBubbleQuestion class="widget-icon" />
<h3>Unapproved Estimates</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Unapproved Estimates"
:todoNumber="estimatesTodoNumber"
:completedNumber="estimatesCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/estimates')">
Unapproved Estimates
</button>
</div>
</template>
</Card>
<!-- Half Down Widget -->
<Card >
<template #header>
<div class="widget-header">
<CreditCard class="widget-icon" />
<h3>Half Down Payments</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Half Down Payments"
:todoNumber="halfDownTodoNumber"
:completedNumber="halfDownCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/jobs')">
Half Down Payments
</button>
</div>
</template>
</Card>
<!-- 15 Day Follow Up Widget -->
<Card >
<template #header>
<div class="widget-header">
<Calendar class="widget-icon" />
<h3>15 Day Follow Ups</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="15 Day Follow Ups"
:todoNumber="fifteenDayTodoNumber"
:completedNumber="fifteenDayCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/calendar')">
View Follow Ups
</button>
</div>
</template>
</Card>
<!-- Late Balances Widget -->
<Card >
<template #header>
<div class="widget-header">
<CardNoAccess class="widget-icon" />
<h3>Late Balances</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Late Balances"
:todoNumber="balancesTodoNumber"
:completedNumber="balancesCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/invoices')">
Late Balances
</button>
</div>
</template>
</Card>
<!-- Backflow Tests Widget -->
<Card >
<template #header>
<div class="widget-header">
<WateringSoil class="widget-icon" />
<h3>Backflow Tests</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Backflow Tests"
:todoNumber="backflowsTodoNumber"
:completedNumber="backflowsCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/jobs')">
Late Balances
</button>
</div>
</template>
</Card>
<!-- Curbing Widget -->
<Card >
<template #header>
<div class="widget-header">
<SoilAlt class="widget-icon" />
<h3>Curbing</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Curbing"
:todoNumber="curbingTodoNumber"
:completedNumber="curbingCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/jobs')">
Curbing
</button>
</div>
</template>
</Card>
<!-- Hyrdoseeding Widget -->
<Card >
<template #header>
<div class="widget-header">
<SoilAlt class="widget-icon" />
<h3>Hydroseeding</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Hydroseeding"
:todoNumber="hydroseedingTodoNumber"
:completedNumber="hydroseedingCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/jobs')">
Hydroseeding
</button>
</div>
</template>
</Card>
<!-- Machines Widget -->
<Card >
<template #header>
<div class="widget-header">
<Truck class="widget-icon" />
<h3>Machines</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Machines"
:todoNumber="machinesTodoNumber"
:completedNumber="machinesCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/jobs')">
Machines
</button>
</div>
</template>
</Card>
<!-- Deliveries Widget -->
<Card >
<template #header>
<div class="widget-header">
<Truck class="widget-icon" />
<h3>Deliveries</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Deliveries"
:todoNumber="deliveriesTodoNumber"
:completedNumber="delivieriesCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/jobs')">
Deliveries
</button>
</div>
</template>
</Card>
</div>
<template lang="">
<div>
<h2>Hello!</h2>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
//import Card from "primevue/card";
import Card from "../common/Card.vue";
import Tag from "primevue/tag";
import { Calendar, Community, Hammer, PathArrowSolid, Clock, Shield, ShieldSearch,
ClipboardCheck, DoubleCheck, CreditCard, CardNoAccess, ChatBubbleQuestion, Edit,
WateringSoil, Soil, Truck, SoilAlt } from "@iconoir/vue";
import DataUtils from "../../utils.js";
import { useNotificationStore } from "../../stores/notifications-primevue";
//import SimpleChart from "../common/SimpleChart.vue";
import TodoChart from "../common/TodoChart.vue";
const router = useRouter();
// Dummy data from utils
const clientData = ref(DataUtils.dummyClientData);
const jobData = ref(DataUtils.dummyJobData);
const locatesTodoNumber = ref(45);
const locatesCompletedNumber = ref(5);
const permitsTodoNumber = ref(24);
const permitsCompletedNumber = ref(7);
const permitFinalizationsTodoNumber = ref(35);
const permitFinalizationsCompletedNumber = ref(2);
const warrantyTodoNumber = ref(0);
const warrantyCompletedNumber = ref(10);
const notifications = useNotificationStore();
// Computed values for dashboard metrics
const totalRevenue = computed(() => "$47,250");
const completedJobs = computed(
() => jobData.value.filter((job) => job.overAllStatus === "completed").length,
);
const clientSatisfaction = computed(() => 94);
const avgResponseTime = computed(() => 2.3);
const navigateTo = (path) => {
router.push(path);
};
onMounted(() => {
notifications.addWarning("Dashboard metrics are based on dummy data for demonstration purposes. UPDATES COMING SOON!");
});
<script>
export default {};
</script>
<style scoped>
.dashboard {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.dashboard-title {
color: #2c3e50;
margin-bottom: 30px;
font-size: 2.5rem;
font-weight: 300;
}
.widgets-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.widget-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 0;
}
.widget-icon {
color: var(--theme-primary-strong);
width: 24px;
height: 24px;
}
.widget-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.widget-content {
display: flex;
flex-direction: column;
margin: 0;
width: 200px;
align-items: center;
padding: 20px 20px 20px;
/*gap: 15px;*/
}
.metric {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.metric-number {
font-size: 2rem;
font-weight: bold;
color: rgb(69, 112, 101);
}
.metric-label {
color: #666;
font-size: 0.9rem;
}
.status-row {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.summary-section {
margin-top: 30px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.stat-item {
text-align: center;
padding: 20px;
border-radius: 8px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: bold;
color: rgb(69, 112, 101);
margin-bottom: 5px;
}
.stat-label {
color: #666;
font-size: 0.9rem;
}
.sidebar-button {
justify-content: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.widgets-grid {
grid-template-columns: 1fr;
}
.dashboard-title {
font-size: 2rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
<style lang=""></style>

View File

@ -1,235 +0,0 @@
<template>
<div class="page-container">
<h2>Invoices</h2>
<!-- Todo Chart Section -->
<div class = "widgets-grid">
<!-- Ready to Invoice Widget -->
<Card>
<template #header>
<div class="widget-header">
<CalendarCheck class="widget-icon" />
<h3>Ready To Invoice</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Ready To Invoice"
:todoNumber="invoiceTodoNumber"
:completedNumber="invoiceCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="filterBy('ready')">
View Ready To Invoice
</button>
</div>
</template>
</Card>
<!-- Late Balances Widget -->
<Card>
<template #header>
<div class="widget-header">
<CardNoAccess class="widget-icon" />
<h3>Late Balances</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Late Balances"
:todoNumber="balancesTodoNumber"
:completedNumber="balancesCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="filterBy('late')">
View Late Balances
</button>
</div>
</template>
</Card>
</div>
<DataTable
:data="tableData"
:columns="columns"
tableName="invoices"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
/>
</div>
</template>
<script setup>
import Card from "../common/Card.vue";
import DataTable from "../common/DataTable.vue";
import TodoChart from "../common/TodoChart.vue";
import { ref, onMounted } from "vue";
import Api from "../../api";
import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters";
import { CardNoAccess, CalendarCheck } from "@iconoir/vue";
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const columns = [
{ label: "Customer Address", fieldName: "address", type: "text", sortable: true },
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
{
label: "Status",
fieldName: "status",
type: "status-button",
sortable: true,
buttonVariant: "outlined",
onStatusClick: (status, rowData) => handleInvoiceClick(status, rowData),
//disableCondition: (status) => status?.toLowerCase() === "draft",
disableCondition: false
},
{ label: "Grand Total", fieldName: "grandTotal", type: "text", sortable: true },
];
const filterBy = () => {
console.log("DEBUG: Invoices filterBy not implemented yet.");
}
const handleInvoiceClick = () => {
}
const handleLazyLoad = async (event) => {
console.log("Invoices page - handling lazy load:", event);
try {
isLoading.value = true;
// Get sorting information from filters store first (needed for cache key)
const sorting = filtersStore.getTableSorting("estimates");
console.log("Current sorting state:", sorting);
// Get pagination parameters
const paginationParams = {
page: event.page || 0,
pageSize: event.rows || 10,
sortField: event.sortField,
sortOrder: event.sortOrder,
};
// Get filters (convert PrimeVue format to API format)
const filters = {};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) {
filters[key] = event.filters[key];
}
});
}
// Always fetch fresh data from API (cache only stores pagination/filter/sort state, not data)
console.log("Making API call with:", { paginationParams, filters });
// Call API with pagination, filters, and sorting
const result = await Api.getPaginatedInvoiceDetails(paginationParams, filters, sorting);
console.log("DEBUG: Result from api:", result);
// Update local state - extract from pagination structure
tableData.value = result.data;
totalRecords.value = result.pagination.total;
// Update pagination store with new total
paginationStore.setTotalRecords("invoices", result.pagination.total);
console.log("Updated pagination state:", {
tableData: tableData.value.length,
totalRecords: totalRecords.value,
storeTotal: paginationStore.getTablePagination("invoices").totalRecords,
storeTotalPages: paginationStore.getTotalPages("invoices"),
});
console.log("Loaded from API:", {
records: result.data.length,
total: result.pagination.total,
page: paginationParams.page + 1,
});
} catch (error) {
console.error("Error loading invoice data:", error);
// You could also show a toast or other error notification here
tableData.value = [];
totalRecords.value = 0;
} finally {
isLoading.value = false;
}
};
// Load initial data
onMounted(async () => {
// Initialize pagination and filters
paginationStore.initializeTablePagination("invoices", { rows: 10 });
filtersStore.initializeTableFilters("invoices", columns);
filtersStore.initializeTableSorting("invoices");
// Load first page
const initialPagination = paginationStore.getTablePagination("invoices");
const initialFilters = filtersStore.getTableFilters("invoices");
const initialSorting = filtersStore.getTableSorting("invoices");
await handleLazyLoad({
page: initialPagination.page,
rows: initialPagination.rows,
first: initialPagination.first,
sortField: initialSorting.field || initialPagination.sortField,
sortOrder: initialSorting.order || initialPagination.sortOrder,
filters: initialFilters,
});
});
</script>
<style lang="css">
.widgets-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.widget-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 0;
}
.widget-icon {
color: var(--theme-primary-strong);
width: 24px;
height: 24px;
}
.widget-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.widget-content {
display: flex;
flex-direction: column;
margin: 0;
width: 200px;
align-items: center;
padding: 20px 20px 20px;
/*gap: 15px;*/
}
.page-container {
height: 100%;
margin: 20px;
gap: 20px;
background-color: transparent;
}
</style>

View File

@ -1,268 +0,0 @@
<template>
<div class= "job-page">
<h2>{{ isNew ? 'Create Job' : 'View Job' }}</h2>
<div class="page-actions">
</div>
<div class="info-section">
<div class="address-info">
<Card>
<template #header>
<div class="widget-header">
<h3>Address</h3>
</div>
</template>
<template #content>
<div class="widget-content">
{{ job.customInstallationAddress || "" }}
</div>
</template>
</Card>
</div>
<div class="customer-info">
<Card>
<template #header>
<div class="widget-header">
<h3>Customer</h3>
</div>
</template>
<template #content>
<div class="widget-content">
{{ job.customer || "" }}
</div>
</template>
</Card>
</div>
</div>
<div class="task-list">
<DataTable
:data="tableData"
:columns="columns"
tableName="jobtasks"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
/>
</div>
</div>
</template>
<script setup>
import Card from "../common/Card.vue";
import DataTable from "../common/DataTable.vue";
import { ref, onMounted, computed } from "vue";
import { useRoute } from "vue-router";
import Api from "../../api";
import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters";
import { useNotificationStore } from "../../stores/notifications-primevue";
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
const notifications = useNotificationStore();
const route = useRoute();
const jobIdQuery = computed(() => route.query.jobId || "");
const isNew = computed(() => route.query.new === "true");
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const job = ref(null);
const taskList = ref(null);
const columns = [
{ label: "Task", fieldName: "subject", type: "text" },
{ label: "ID", fieldName: "name", type: "text", sortable: true, filterable: true },
{ label: "Address", fieldname: "address", type: "text" },
{ label: "Category", fieldName: "", type: "text", sortable: true, filterable: true },
{ label: "Status", fieldName: "status", type: "text", sortable: true, filterable: true },
];
const handleLazyLoad = async (event) => {
console.log("Task list on Job Page - handling lazy load:", event);
try {
isLoading.value = true;
// Get sorting information from filters store first (needed for cache key)
const sorting = filtersStore.getTableSorting("jobTasks");
console.log("Current sorting state:", sorting);
// Get pagination parameters
const paginationParams = {
page: event.page || 0,
pageSize: event.rows || 10,
sortField: event.sortField,
sortOrder: event.sortOrder,
};
// Get filters (convert PrimeVue format to API format)
const filters = {project: jobIdQuery.value};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) {
filters[key] = event.filters[key];
}
});
}
// Clear cache when filters or sorting are active to ensure fresh data
const hasActiveFilters = Object.keys(filters).length > 0;
const hasActiveSorting = paginationParams.sortField && paginationParams.sortOrder;
if (hasActiveFilters || hasActiveSorting) {
paginationStore.clearTableCache("jobTasks");
}
// Check cache first
const cachedData = paginationStore.getCachedPage(
"jobTasks",
paginationParams.page,
paginationParams.pageSize,
sorting.field || paginationParams.sortField,
sorting.order || paginationParams.sortOrder,
filters,
);
if (cachedData) {
// Use cached data
tableData.value = cachedData.records;
totalRecords.value = cachedData.totalRecords;
paginationStore.setTotalRecords("jobTasks", cachedData.totalRecords);
console.log("Loaded from cache:", {
records: cachedData.records.length,
total: cachedData.totalRecords,
page: paginationParams.page + 1,
});
return;
}
console.log("Making API call with:", { paginationParams, filters });
// Call API with pagination, filters, and sorting
const result = await Api.getPaginatedJobTaskDetails(paginationParams, filters, sorting);
// Update local state - extract from pagination structure
tableData.value = result.data;
totalRecords.value = result.pagination.total;
// Update pagination store with new total
paginationStore.setTotalRecords("jobTasks", result.pagination.total);
console.log("Updated pagination state:", {
tableData: tableData.value.length,
totalRecords: totalRecords.value,
storeTotal: paginationStore.getTablePagination("jobTasks").totalRecords,
storeTotalPages: paginationStore.getTotalPages("jobTasks"),
});
// Cache the result
paginationStore.setCachedPage(
"jobTasks",
paginationParams.page,
paginationParams.pageSize,
sorting.field || paginationParams.sortField,
sorting.order || paginationParams.sortOrder,
filters,
{
records: result.data,
totalRecords: result.pagination.total,
},
);
console.log("Loaded from API:", {
records: result.data.length,
total: result.pagination.total,
page: paginationParams.page + 1,
});
} catch (error) {
console.error("Error loading job data:", error);
// You could also show a toast or other error notification here
tableData.value = [];
totalRecords.value = 0;
} finally {
isLoading.value = false;
}
};
onMounted(async () => {
console.log("DEBUG: Query params:", route.query);
if (jobIdQuery.value) {
// Viewing existing Job
try {
job.value = await Api.getJob(jobIdQuery.value);
//taskList.value = await Api.getJobTaskList(jobIdQuery.value);
console.log("DEBUG: Loaded job:", job.value);
} catch (error) {
console.error("Error loading job from DB:", error);
}
}
// Initialize pagination and filters
paginationStore.initializeTablePagination("jobTasks", { rows: 10 });
filtersStore.initializeTableFilters("jobTasks", columns);
filtersStore.initializeTableSorting("jobsTasks");
// Load first page of tasks
const initialPagination = paginationStore.getTablePagination("jobTasks");
const initialFilters = filtersStore.getTableFilters("jobTasks");
const initialSorting = filtersStore.getTableSorting("jobTasks");
await handleLazyLoad({
page: initialPagination.page,
rows: initialPagination.rows,
first: initialPagination.first,
sortField: initialSorting.field || initialPagination.sortField,
sortOrder: initialSorting.order || initialPagination.sortOrder,
});
});
</script>
<style scoped>
.info-section {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.widget-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 0;
}
.widget-icon {
color: var(--theme-primary-strong);
width: 24px;
height: 24px;
}
.widget-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.widget-content {
display: flex;
flex-direction: column;
margin: 0;
width: 200px;
align-items: center;
padding: 20px 20px 20px;
/*gap: 15px;*/
}
.job-page{
height: 100%;
margin: 20px;
gap: 20px;
background-color: transparent;
}
</style>

View File

@ -1,326 +1,9 @@
<template>
<div class="page-container">
<div>
<h2>Jobs</h2>
<!-- Todo Chart Section -->
<div class = "widgets-grid">
<!-- Jobs in Queue Widget -->
<Card>
<template #header>
<div class="widget-header">
<Hammer class="widget-icon" />
<h3>Jobs In Queue</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Jobs In Queue"
:todoNumber="jobQueueTodoNumber"
:completedNumber="jobQueueCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="filterBy('in queue')">
View Queued Jobs
</button>
</div>
</template>
</Card>
<!-- Jobs In Progress Widget -->
<Card>
<template #header>
<div class="widget-header">
<Hammer class="widget-icon" />
<h3>Jobs In Progress</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Jobs in Progress"
:todoNumber="progressTodoNumber"
:completedNumber="progressCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="filterBy('in progress')">
View Jobs In Progress
</button>
</div>
</template>
</Card>
<!-- Late Jobs Widget -->
<Card>
<template #header>
<div class="widget-header">
<Alarm class="widget-icon" />
<h3>Late Jobs</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Late Jobs"
:todoNumber="lateTodoNumber"
:completedNumber="lateCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="filterBy('late')">
View Late Jobs
</button>
</div>
</template>
</Card>
<!-- Ready to Invoice Widget -->
<Card>
<template #header>
<div class="widget-header">
<CalendarCheck class="widget-icon" />
<h3>Ready To Invoice</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<TodoChart
title="Ready To Invoice"
:todoNumber="invoiceTodoNumber"
:completedNumber="invoiceCompletedNumber"
>
</TodoChart>
<button class="sidebar-button"
@click="navigateTo('/invoices')">
View Ready To Invoice
</button>
</div>
</template>
</Card>
</div>
<DataTable
:data="tableData"
:columns="columns"
tableName="jobs"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
@row-click="handleRowClick"
/>
</div>
</template>
<script setup>
import Card from "../common/Card.vue";
import DataTable from "../common/DataTable.vue";
import TodoChart from "../common/TodoChart.vue";
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import Api from "../../api";
import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters";
import { useNotificationStore } from "../../stores/notifications-primevue";
import { Alarm, CalendarCheck, Hammer } from "@iconoir/vue";
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
const notifications = useNotificationStore();
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const columns = [
{ label: "Job ID", fieldName: "name", type: "text", sortable: true, filterable: true },
{ label: "Address", fieldName: "customInstallationAddress", type: "text", sortable: true },
{ label: "Customer", fieldName: "customer", type: "text", sortable: true, filterable: true },
{ label: "Overall Status", fieldName: "status", type: "status", sortable: true },
{ label: "Progress", fieldName: "percentComplete", type: "text", sortable: true },
];
const router = useRouter();
const navigateTo = (path) => {
router.push(path);
};
const filterBy = (filter) => {
console.log("DEBUG: Jobs filterBy not implemented yet.");
};
// Handle lazy loading events from DataTable
const handleLazyLoad = async (event) => {
console.log("Jobs page - handling lazy load:", event);
try {
isLoading.value = true;
// Get sorting information from filters store first (needed for cache key)
const sorting = filtersStore.getTableSorting("jobs");
console.log("Current sorting state:", sorting);
// Get pagination parameters
const paginationParams = {
page: event.page || 0,
pageSize: event.rows || 10,
sortField: event.sortField,
sortOrder: event.sortOrder,
};
// Get filters (convert PrimeVue format to API format)
const filters = {};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) {
filters[key] = event.filters[key];
}
});
}
// Clear cache when filters or sorting are active to ensure fresh data
const hasActiveFilters = Object.keys(filters).length > 0;
const hasActiveSorting = paginationParams.sortField && paginationParams.sortOrder;
if (hasActiveFilters || hasActiveSorting) {
paginationStore.clearTableCache("jobs");
}
// Check cache first
const cachedData = paginationStore.getCachedPage(
"jobs",
paginationParams.page,
paginationParams.pageSize,
sorting.field || paginationParams.sortField,
sorting.order || paginationParams.sortOrder,
filters,
);
if (cachedData) {
// Use cached data
tableData.value = cachedData.records;
totalRecords.value = cachedData.totalRecords;
paginationStore.setTotalRecords("jobs", cachedData.totalRecords);
console.log("Loaded from cache:", {
records: cachedData.records.length,
total: cachedData.totalRecords,
page: paginationParams.page + 1,
});
return;
}
console.log("Making API call with:", { paginationParams, filters });
// Call API with pagination, filters, and sorting
const result = await Api.getPaginatedJobDetails(paginationParams, filters, sorting);
// Update local state - extract from pagination structure
tableData.value = result.data;
totalRecords.value = result.pagination.total;
// Update pagination store with new total
paginationStore.setTotalRecords("jobs", result.pagination.total);
console.log("Updated pagination state:", {
tableData: tableData.value.length,
totalRecords: totalRecords.value,
storeTotal: paginationStore.getTablePagination("jobs").totalRecords,
storeTotalPages: paginationStore.getTotalPages("jobs"),
});
// Cache the result
paginationStore.setCachedPage(
"jobs",
paginationParams.page,
paginationParams.pageSize,
sorting.field || paginationParams.sortField,
sorting.order || paginationParams.sortOrder,
filters,
{
records: result.data,
totalRecords: result.pagination.total,
},
);
console.log("Loaded from API:", {
records: result.data.length,
total: result.pagination.total,
page: paginationParams.page + 1,
});
} catch (error) {
console.error("Error loading job data:", error);
// You could also show a toast or other error notification here
tableData.value = [];
totalRecords.value = 0;
} finally {
isLoading.value = false;
}
};
const handleRowClick = (event) => {
const rowData = event.data;
router.push(`/job?jobId=${rowData.name}`);
}
// Load initial data
onMounted(async () => {
notifications.addWarning("Jobs page coming soon");
// Initialize pagination and filters
paginationStore.initializeTablePagination("jobs", { rows: 10 });
filtersStore.initializeTableFilters("jobs", columns);
filtersStore.initializeTableSorting("jobs");
// // Load first page
const initialPagination = paginationStore.getTablePagination("jobs");
const initialFilters = filtersStore.getTableFilters("jobs");
const initialSorting = filtersStore.getTableSorting("jobs");
await handleLazyLoad({
page: initialPagination.page,
rows: initialPagination.rows,
first: initialPagination.first,
sortField: initialSorting.field || initialPagination.sortField,
sortOrder: initialSorting.order || initialPagination.sortOrder,
});
});
<script>
export default {};
</script>
<style lang="css">
.widgets-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.widget-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 0;
}
.widget-icon {
color: var(--theme-primary-strong);
width: 24px;
height: 24px;
}
.widget-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.widget-content {
display: flex;
flex-direction: column;
margin: 0;
width: 200px;
align-items: center;
padding: 20px 20px 20px;
/*gap: 15px;*/
}
.page-container {
height: 100%;
margin: 20px;
gap: 20px;
background-color: transparent;
}
</style>
<style lang=""></style>

View File

@ -1,706 +1,9 @@
<template>
<div class="routes-page">
<div class="routes-header">
<h2>Service Routes</h2>
<p class="routes-subtitle">Manage and track daily service routes for technicians</p>
</div>
<!-- Routes Data Table -->
<div class="routes-table-container">
<DataTable
:data="tableData"
:columns="columns"
tableName="routes"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
:onLazyLoad="handleLazyLoad"
@lazy-load="handleLazyLoad"
@row-click="viewRouteDetails"
/>
</div>
<!-- Route Details Modal -->
<v-dialog v-model="routeDialog" max-width="1200px" persistent>
<v-card v-if="selectedRoute">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<div>
<h3>{{ selectedRoute.routeName }}</h3>
<span class="text-subtitle-1 text-medium-emphasis"
>{{ selectedRoute.routeId }} - {{ selectedRoute.date }}</span
>
</div>
<div class="d-flex align-center gap-2">
<v-chip :color="getStatusColor(selectedRoute.status)" size="small">
{{ selectedRoute.status.toUpperCase() }}
</v-chip>
<v-btn
icon="mdi-close"
variant="text"
@click="routeDialog = false"
></v-btn>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-0">
<div class="route-details-container">
<!-- Route Info Panel -->
<div class="route-info-panel">
<div class="route-summary pa-4">
<h4 class="mb-3">Route Summary</h4>
<div class="info-grid">
<div class="info-item">
<v-icon class="mr-2" size="small"
>mdi-account-hard-hat</v-icon
>
<span class="label">Technician:</span>
<span class="value">{{ selectedRoute.technician }}</span>
</div>
<div class="info-item">
<v-icon class="mr-2" size="small">mdi-clock-start</v-icon>
<span class="label">Start Time:</span>
<span class="value">{{ selectedRoute.startTime }}</span>
</div>
<div class="info-item">
<v-icon class="mr-2" size="small">mdi-car</v-icon>
<span class="label">Vehicle:</span>
<span class="value">{{ selectedRoute.vehicleId }}</span>
</div>
<div class="info-item">
<v-icon class="mr-2" size="small"
>mdi-map-marker-distance</v-icon
>
<span class="label">Total Miles:</span>
<span class="value"
>{{ selectedRoute.totalMileage }} mi</span
>
</div>
<div class="info-item">
<v-icon class="mr-2" size="small">mdi-timer</v-icon>
<span class="label">Est. Duration:</span>
<span class="value">{{
selectedRoute.estimatedDuration
}}</span>
</div>
<div class="info-item">
<v-icon class="mr-2" size="small"
>mdi-map-marker-multiple</v-icon
>
<span class="label">Stops:</span>
<span class="value"
>{{ selectedRoute.completedStops }}/{{
selectedRoute.totalStops
}}</span
>
</div>
</div>
</div>
<!-- Stops List -->
<div class="stops-section pa-4">
<h4 class="mb-3">Route Stops</h4>
<div class="stops-list">
<div
v-for="stop in selectedRoute.stops"
:key="stop.stopId"
class="stop-item"
:class="getStopStatusClass(stop.status)"
>
<div class="stop-number">{{ stop.stopId }}</div>
<div class="stop-content">
<div class="stop-header">
<span class="customer-name">{{
stop.customer
}}</span>
<v-chip
:color="getStopStatusColor(stop.status)"
size="x-small"
>
{{ stop.status }}
</v-chip>
</div>
<div class="stop-details">
<div class="stop-address">
<v-icon size="x-small" class="mr-1"
>mdi-map-marker</v-icon
>
{{ stop.address }}
</div>
<div class="stop-service">
<v-icon size="x-small" class="mr-1"
>mdi-wrench</v-icon
>
{{ stop.serviceType }}
</div>
<div class="stop-time">
<v-icon size="x-small" class="mr-1"
>mdi-clock</v-icon
>
{{ stop.estimatedTime }} ({{ stop.duration }}
min)
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map Panel -->
<div class="map-panel">
<div class="map-container">
<div class="map-placeholder">
<div class="map-content">
<v-icon size="64" color="primary">mdi-map</v-icon>
<h4 class="mt-3 mb-2">Route Map</h4>
<p class="text-body-2 text-center mb-4">
Interactive map showing route path and stops
</p>
<!-- Mock Map Legend -->
<div class="map-legend">
<div class="legend-item">
<div class="legend-dot completed"></div>
<span>Completed</span>
</div>
<div class="legend-item">
<div class="legend-dot in-progress"></div>
<span>In Progress</span>
</div>
<div class="legend-item">
<div class="legend-dot not-started"></div>
<span>Not Started</span>
</div>
</div>
<!-- Mock Route Stats -->
<div class="route-stats mt-4">
<div class="stat-item">
<div class="stat-value">
{{ selectedRoute.totalMileage }}
</div>
<div class="stat-label">Total Miles</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{ selectedRoute.totalStops }}
</div>
<div class="stat-label">Stops</div>
</div>
<div class="stat-item">
<div class="stat-value">
{{
Math.round(
(selectedRoute.totalMileage /
selectedRoute.totalStops) *
10,
) / 10
}}
</div>
<div class="stat-label">Avg Miles/Stop</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn color="primary" variant="outlined" @click="routeDialog = false">
Close
</v-btn>
<v-btn color="primary" @click="optimizeRoute"> Optimize Route </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<template lang="">
<div>
<h2>Routes Page</h2>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import DataTable from "../common/DataTable.vue";
import Api from "../../api";
import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters";
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
// Reactive data
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const routeDialog = ref(false);
const selectedRoute = ref(null);
const fullRouteData = ref([]); // Store full route data for modal access
// Table columns configuration
const columns = [
{ label: "Route ID", fieldName: "routeId", type: "text", sortable: true, filterable: true },
{ label: "Route Name", fieldName: "routeName", type: "text", sortable: true },
{
label: "Technician",
fieldName: "technician",
type: "text",
sortable: true,
filterable: true,
},
{ label: "Date", fieldName: "date", type: "text", sortable: true },
{ label: "Status", fieldName: "status", type: "status", sortable: true },
{ label: "Progress", fieldName: "progress", type: "text", sortable: true },
{ label: "Total Stops", fieldName: "totalStops", type: "text", sortable: true },
{ label: "Est. Duration", fieldName: "estimatedDuration", type: "text", sortable: true },
{ label: "Actions", fieldName: "actions", type: "button", sortable: false },
];
// Handle lazy loading events from DataTable
const handleLazyLoad = async (event) => {
console.log("Routes page - handling lazy load:", event);
try {
isLoading.value = true;
// Get pagination parameters
const paginationParams = {
page: event.page || 0,
pageSize: event.rows || 10,
sortField: event.sortField,
sortOrder: event.sortOrder,
};
// Get filters (convert PrimeVue format to API format)
const filters = {};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) {
filters[key] = event.filters[key];
}
});
}
// Check cache first
const cachedData = paginationStore.getCachedPage(
"routes",
paginationParams.page,
paginationParams.pageSize,
paginationParams.sortField,
paginationParams.sortOrder,
filters,
);
if (cachedData) {
// Use cached data
tableData.value = cachedData.records;
totalRecords.value = cachedData.totalRecords;
fullRouteData.value = cachedData.fullData || [];
paginationStore.setTotalRecords("routes", cachedData.totalRecords);
console.log("Loaded from cache:", {
records: cachedData.records.length,
total: cachedData.totalRecords,
page: paginationParams.page + 1,
});
return;
}
console.log("Making API call with:", { paginationParams, filters });
// For now, use existing API but we should create a paginated version
// TODO: Create Api.getPaginatedRouteData() method
const data = await Api.getRouteData();
// Store full data for modal access
fullRouteData.value = data;
// Simulate pagination on client side for now
const startIndex = paginationParams.page * paginationParams.pageSize;
const endIndex = startIndex + paginationParams.pageSize;
// Transform data for table display
const transformedData = data.map((route) => ({
routeId: route.routeId,
routeName: route.routeName,
technician: route.technician,
date: route.date,
status: route.status,
progress: `${route.completedStops}/${route.totalStops}`,
totalStops: route.totalStops,
estimatedDuration: route.estimatedDuration,
actions: "View Details",
}));
const paginatedData = transformedData.slice(startIndex, endIndex);
// Update local state
tableData.value = paginatedData;
totalRecords.value = transformedData.length;
// Update pagination store with new total
paginationStore.setTotalRecords("routes", transformedData.length);
// Cache the result
paginationStore.setCachedPage(
"routes",
paginationParams.page,
paginationParams.pageSize,
paginationParams.sortField,
paginationParams.sortOrder,
filters,
{
records: paginatedData,
totalRecords: transformedData.length,
fullData: data,
},
);
console.log("Loaded from API:", {
records: paginatedData.length,
total: transformedData.length,
page: paginationParams.page + 1,
});
} catch (error) {
console.error("Error loading route data:", error);
tableData.value = [];
totalRecords.value = 0;
fullRouteData.value = [];
} finally {
isLoading.value = false;
}
};
// Methods
const viewRouteDetails = (event) => {
const routeId = event.data.routeId;
const route = fullRouteData.value.find((r) => r.routeId === routeId);
if (route) {
selectedRoute.value = route;
routeDialog.value = true;
}
};
const getStatusColor = (status) => {
switch (status?.toLowerCase()) {
case "completed":
return "success";
case "in progress":
return "warning";
case "not started":
return "info";
default:
return "grey";
}
};
const getStopStatusColor = (status) => {
switch (status?.toLowerCase()) {
case "completed":
return "success";
case "in progress":
return "warning";
case "not started":
return "grey";
default:
return "grey";
}
};
const getStopStatusClass = (status) => {
return `stop-status-${status.replace(" ", "-")}`;
};
const optimizeRoute = () => {
alert("Route optimization feature coming soon!");
};
// Load initial data
onMounted(async () => {
// Initialize pagination and filters
paginationStore.initializeTablePagination("routes", { rows: 10 });
filtersStore.initializeTableFilters("routes", columns);
// Load first page
const initialPagination = paginationStore.getTablePagination("routes");
const initialFilters = filtersStore.getTableFilters("routes");
await handleLazyLoad({
page: initialPagination.page,
rows: initialPagination.rows,
first: initialPagination.first,
sortField: initialPagination.sortField,
sortOrder: initialPagination.sortOrder,
filters: initialFilters,
});
});
<script>
export default {};
</script>
<style scoped>
.routes-page {
padding: 20px;
}
.routes-header {
margin-bottom: 24px;
}
.routes-header h2 {
margin-bottom: 8px;
color: #1976d2;
}
.routes-subtitle {
color: #666;
margin: 0;
}
.routes-table-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.route-details-container {
display: flex;
height: 600px;
}
.route-info-panel {
flex: 1;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
}
.route-summary {
border-bottom: 1px solid #e0e0e0;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
}
.info-item .label {
font-weight: 500;
min-width: 80px;
}
.info-item .value {
color: #666;
}
.stops-section {
flex: 1;
}
.stops-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 400px;
overflow-y: auto;
}
.stop-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-radius: 8px;
border: 1px solid #e0e0e0;
transition: all 0.2s;
}
.stop-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stop-item.stop-status-completed {
background-color: #f1f8e9;
border-color: #4caf50;
}
.stop-item.stop-status-in-progress {
background-color: #fff8e1;
border-color: #ff9800;
}
.stop-item.stop-status-not-started {
background-color: #fafafa;
border-color: #e0e0e0;
}
.stop-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background-color: #1976d2;
color: white;
border-radius: 50%;
font-weight: 600;
font-size: 0.9em;
flex-shrink: 0;
}
.stop-content {
flex: 1;
min-width: 0;
}
.stop-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.customer-name {
font-weight: 600;
color: #1976d2;
}
.stop-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.stop-address,
.stop-service,
.stop-time {
display: flex;
align-items: center;
font-size: 0.9em;
color: #666;
}
.map-panel {
width: 400px;
background-color: #f8f9fa;
}
.map-container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.map-placeholder {
text-align: center;
width: 100%;
}
.map-content {
background: white;
padding: 32px;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.map-legend {
display: flex;
justify-content: center;
gap: 16px;
margin: 16px 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8em;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.legend-dot.completed {
background-color: #4caf50;
}
.legend-dot.in-progress {
background-color: #ff9800;
}
.legend-dot.not-started {
background-color: #9e9e9e;
}
.route-stats {
display: flex;
justify-content: space-around;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.5em;
font-weight: 600;
color: #1976d2;
}
.stat-label {
font-size: 0.75em;
color: #666;
margin-top: 4px;
}
/* Responsive design */
@media (max-width: 900px) {
.route-details-container {
flex-direction: column;
height: auto;
}
.route-info-panel {
border-right: none;
border-bottom: 1px solid #e0e0e0;
}
.map-panel {
width: 100%;
min-height: 300px;
}
.info-grid {
grid-template-columns: 1fr;
}
}
</style>
<style lang=""></style>

File diff suppressed because it is too large Load Diff

View File

@ -1,252 +1,9 @@
<template>
<template lang="">
<div>
<h2>Warranty Claims</h2>
<div id="filter-container" class="filter-container">
<button @click="addNewWarranty" id="add-warranty-button" class="interaction-button">
Add New Warranty Claim
</button>
</div>
<DataTable
:data="tableData"
:columns="columns"
tableName="warranties"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
:onLazyLoad="handleLazyLoad"
@lazy-load="handleLazyLoad"
/>
<h2>Warranties Page</h2>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import DataTable from "../common/DataTable.vue";
import Api from "../../api";
import { FilterMatchMode } from "@primevue/core";
import { useLoadingStore } from "../../stores/loading";
import { usePaginationStore } from "../../stores/pagination";
import { useFiltersStore } from "../../stores/filters";
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const addNewWarranty = () => {
// TODO: Open modal or navigate to create warranty form
console.log("Add new warranty claim clicked");
// In the future, this could open a modal or navigate to a form
// For now, just log the action
};
const columns = [
{
label: "Warranty ID",
fieldName: "warrantyId",
type: "text",
sortable: true,
filterable: true,
},
{
label: "Customer",
fieldName: "customer",
type: "text",
sortable: true,
filterable: true,
},
{
label: "Service Address",
fieldName: "serviceAddress",
type: "text",
sortable: true,
filterable: true,
},
{
label: "Issue Description",
fieldName: "issueDescription",
type: "text",
sortable: false,
filterable: false,
},
{
label: "Priority",
fieldName: "priority",
type: "status",
sortable: true,
filterable: false,
},
{
label: "Status",
fieldName: "status",
type: "status",
sortable: true,
filterable: true,
},
{
label: "Complaint Date",
fieldName: "complaintDate",
type: "date",
sortable: true,
filterable: false,
},
{
label: "Raised By",
fieldName: "complaintRaisedBy",
type: "text",
sortable: true,
filterable: true,
},
{
label: "Territory",
fieldName: "territory",
type: "text",
sortable: true,
filterable: true,
},
{
label: "Warranty Status",
fieldName: "warrantyStatus",
type: "status",
sortable: true,
filterable: true,
},
];
// Handle lazy loading events from DataTable
const handleLazyLoad = async (event) => {
console.log("Warranties page - handling lazy load:", event);
try {
isLoading.value = true;
// Get pagination parameters
const paginationParams = {
page: event.page || 0,
pageSize: event.rows || 10,
sortField: event.sortField,
sortOrder: event.sortOrder,
};
// Get filters (convert PrimeVue format to API format)
const filters = {};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key] && event.filters[key].value) {
filters[key] = event.filters[key];
}
});
}
// Check cache first
const cachedData = paginationStore.getCachedPage(
"warranties",
paginationParams.page,
paginationParams.pageSize,
paginationParams.sortField,
paginationParams.sortOrder,
filters,
);
if (cachedData) {
// Use cached data
tableData.value = cachedData.records;
totalRecords.value = cachedData.totalRecords;
paginationStore.setTotalRecords("warranties", cachedData.totalRecords);
console.log("Loaded from cache:", {
records: cachedData.records.length,
total: cachedData.totalRecords,
page: paginationParams.page + 1,
});
return;
}
console.log("Making API call with:", { paginationParams, filters });
// Get sorting from store for proper API call
const sorting = filtersStore.getTableSorting("warranties");
// Use the new paginated API method
const result = await Api.getPaginatedWarrantyData(paginationParams, filters, sorting);
// Update local state
tableData.value = result.data;
totalRecords.value = result.pagination.total;
// Update pagination store with new total
paginationStore.setTotalRecords("warranties", result.pagination.total);
// Cache the result
paginationStore.setCachedPage(
"warranties",
paginationParams.page,
paginationParams.pageSize,
paginationParams.sortField,
paginationParams.sortOrder,
filters,
{
records: result.data,
totalRecords: result.pagination.total,
},
);
console.log("Loaded from API:", {
records: result.data.length,
total: result.pagination.total,
page: paginationParams.page + 1,
});
} catch (error) {
console.error("Error loading warranty data:", error);
tableData.value = [];
totalRecords.value = 0;
} finally {
isLoading.value = false;
}
};
// Load initial data
onMounted(async () => {
// Initialize pagination and filters
paginationStore.initializeTablePagination("warranties", { rows: 10 });
filtersStore.initializeTableFilters("warranties", columns);
// Load first page
const initialPagination = paginationStore.getTablePagination("warranties");
const initialFilters = filtersStore.getTableFilters("warranties");
await handleLazyLoad({
page: initialPagination.page,
rows: initialPagination.rows,
first: initialPagination.first,
sortField: initialPagination.sortField,
sortOrder: initialPagination.sortOrder,
filters: initialFilters,
});
});
<script>
export default {};
</script>
<style scoped>
.filter-container {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.interaction-button {
background-color: #007bff;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.interaction-button:hover {
background-color: #0056b3;
}
</style>
<style lang=""></style>

View File

@ -1,5 +0,0 @@
import Aura from "@primeuix/themes/aura";
export const globalSettings = {
theme: Aura,
};

View File

@ -2,43 +2,5 @@ import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import router from "./router";
import PrimeVue from "primevue/config";
import { globalSettings } from "./globalSettings";
import { createPinia } from "pinia";
import 'primeicons/primeicons.css';
// Vuetify
import "@primeuix/themes/aura";
import "vuetify/styles";
import { createVuetify } from "vuetify";
import * as components from "vuetify/components";
import * as directives from "vuetify/directives";
import { mdi } from "vuetify/iconsets/mdi";
import "@mdi/font/css/materialdesignicons.css";
const vuetify = createVuetify({
components,
directives,
icons: {
defaultSet: "mdi",
sets: {
mdi,
},
},
});
const pinia = createPinia();
createApp(App)
.use(router)
.use(pinia)
.use(vuetify)
.use(PrimeVue, {
theme: {
options: {
darkModeSelector: false,
},
preset: globalSettings.theme,
},
})
.mount("#custom-ui-app");
createApp(App).use(router).mount("#custom-ui-app");

Some files were not shown because too many files have changed in this diff Show More