Compare commits

..

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

94 changed files with 195 additions and 27900 deletions

2
.gitignore vendored
View File

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

View File

@ -1,65 +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."""
try:
address = frappe.get_doc("Address", {"full_address": full_address}).as_dict()
address["customer"] = frappe.get_doc("Customer", address.get("custom_customer_to_bill")).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)

View File

@ -1,362 +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
# ===============================================================================
# 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."""
try:
clientData = {"addresses": [], "contacts": [], "jobs": [], "sales_invoices": [], "payment_entries": [], "sales_orders": [], "tasks": []}
customer = frappe.get_doc("Customer", client_name)
clientData = {**clientData, **customer.as_dict()}
for contact_link in customer.custom_add_contacts:
contact_doc = frappe.get_doc("Contact", contact_link.contact)
clientData["contacts"].append(contact_doc.as_dict())
for address_link in customer.custom_select_address:
address_doc = frappe.get_doc("Address", address_link.address_name)
# # addressData = {"jobs": [], "contacts": []}
# addressData = {**addressData, **address_doc.as_dict()}
# addressData["estimates"] = frappe.db.get_all("Quotation", fields=["*"], filters={"custom_installation_address": address_doc.address_title})
# addressData["onsite_meetings"] = frappe.db.get_all("On-Site Meeting", fields=["*"], filters={"address": address_doc.address_title})
# jobs = frappe.db.get_all("Project", fields=["*"], or_filters=[
# ["custom_installation_address", "=", address.address_title],
# ["custom_address", "=", address.address_title]
# ])
# for job in jobs if jobs else []:
# jobData = {}
# jobData = {**jobData, **job}
# jobData["sales_invoices"] = frappe.db.get_all("Sales Invoice", fields=["*"], filters={"project": job.name})
# jobData["payment_entries"] = frappe.db.get_all(
# "Payment Entry",
# fields=["*"],
# filters={"party_type": "Customer"},
# or_filters=[
# ["party", "=", client_name],
# ["party_name", "=", client_name]
# ])
# jobData["sales_orders"] = frappe.db.get_all("Sales Order", fields=["*"], filters={"project": job.name})
# jobData["tasks"] = frappe.db.get_all("Task", fields=["*"], filters={"project": job.name})
# addressData["jobs"].append(jobData)
clientData["addresses"].append(address_doc.as_dict())
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:
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")
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 = 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["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)
# Handle customer creation/update
print("#####DEBUG: Upsert client data received:", data)
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:
customer_doc = frappe.get_doc({
"doctype": "Customer",
"customer_name": data.get("customer_name"),
"customer_type": data.get("customer_type")
}).insert(ignore_permissions=True)
else:
customer_doc = frappe.get_doc("Customer", data.get("customer_name"))
print("Customer:", customer_doc.as_dict())
# Handle address creation
print("#####DEBUG: Checking for existing address for customer:", data.get("customer_name"))
existing_address = frappe.db.exists(
"Address",
{
"address_line1": data.get("address_line1"),
"city": data.get("city"),
"state": data.get("state"),
})
print("Existing address check:", existing_address)
if existing_address:
frappe.throw(f"Address already exists for customer {data.get('customer_name')}.", frappe.ValidationError)
address_doc = frappe.get_doc({
"doctype": "Address",
"address_title": data.get("address_title"),
"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"),
"custom_customer_to_bill": customer_doc.name
}).insert(ignore_permissions=True)
print("Address:", address_doc.as_dict())
#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_exists = frappe.db.exists("Contact", {"email_id": contact_data.get("email"), "phone": contact_data.get("phone_number")})
if not contact_exists:
contact_doc = frappe.get_doc({
"doctype": "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"),
"custom_customer": customer_doc.name,
"role": contact_data.get("contact_role", "Other"),
"custom_email": contact_data.get("email"),
"is_primary_contact":1 if 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
}]
}).insert(ignore_permissions=True)
print("Created new contact:", contact_doc.as_dict())
else:
contact_doc = frappe.get_doc("Contact", {"email_id": data.get("email")})
print("Contact already exists:", contact_doc.as_dict())
contact_docs.append(contact_doc)
##### Create links
# Customer -> Address
print("#####DEBUG: Creating links between customer, address, and contacts.")
customer_doc.append("custom_select_address", {
"address_name": address_doc.name,
"address_line_1": address_doc.address_line1,
"city": address_doc.city,
"state": address_doc.state,
"pincode": address_doc.pincode
})
# Customer -> Contact
print("#####DEBUG: Linking contacts to customer.")
for contact_doc in contact_docs:
print("Linking contact:", contact_doc.as_dict())
print("with role:", contact_doc.role)
print("customer to append to:", customer_doc.as_dict())
customer_doc.append("custom_add_contacts", {
"contact": contact_doc.name,
"email": contact_doc.custom_email,
"phone": contact_doc.phone,
"role": contact_doc.role
})
# Address -> Customer
print("#####DEBUG: Linking address to customer.")
address_doc.append("links", {
"link_doctype": "Customer",
"link_name": customer_doc.name
})
# Address -> Contact
print("#####DEBUG: Linking address to contacts.")
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
})
# Contact -> Customer & Address
print("#####DEBUG: Linking contacts to customer.")
for contact_doc in contact_docs:
contact_doc.address = address_doc.name
contact_doc.append("links", {
"link_doctype": "Customer",
"link_name": customer_doc.name
})
contact_doc.append("links", {
"link_doctype": "Address",
"link_name": address_doc.name
})
contact_doc.custom_customer = customer_doc.name
contact_doc.save(ignore_permissions=True)
address_doc.save(ignore_permissions=True)
customer_doc.save(ignore_permissions=True)
frappe.local.message_log = []
return build_success_response({
"customer": customer_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",
pluck="name")
return build_success_response(client_names)
except Exception as e:
return build_error_response(str(e), 500)

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,234 +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
# ===============================================================================
# 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)
return build_success_response(estimate.as_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()
updated_quotation = frappe.get_doc("Quotation", estimate_name)
print("DEBUG: Quotation submitted successfully.")
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(allow_guest=True)
def update_response(name, response):
"""Update the response for a given estimate."""
estimate = frappe.get_doc("Quotation", name)
accepted = True if response == "Accepted" else False
new_status = "Estimate Accepted" if accepted else "Lost"
estimate.custom_response = response
estimate.custom_current_status = new_status
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
estimate.flags.ignore_permissions = True
estimate.save()
frappe.db.commit()
@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")
# 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("contact_name")
# 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"),
})
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_installation_address": data.get("address_name"),
"custom_current_status": "Draft",
"contact_email": data.get("contact_email"),
"party_name": data.get("contact_name"),
"company": "Sprinklers Northwest",
"customer_name": data.get("customer_name"),
})
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"),
})
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)

View File

@ -1,104 +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,49 +0,0 @@
import frappe, json
from custom_ui.db_utils import process_query_conditions, build_datatable_response, get_count_or_filters
# ===============================================================================
# JOB MANAGEMENT API METHODS
# ===============================================================================
@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)
return build_datatable_response(data=tableRows, count=count, page=page, page_size=page_size)
@frappe.whitelist()
def upsert_job(data):
"""Create or update a job (project)."""
# TODO: Implement job creation/update logic
pass

View File

@ -1,136 +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_onsite_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_onsite_meetings(fields=["*"], filters={}):
"""Get paginated On-Site Meetings with filtering and sorting support."""
try:
print("DEBUG: Raw onsite 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_onsite_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_onsite_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_onsite_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)
data = {**defualts, **data}
meeting = frappe.get_doc("On-Site Meeting", name)
for key, value in data.items():
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()
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,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

@ -29,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,145 +0,0 @@
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
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 ""

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,23 +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):
if not doc.custom_sent or not doc.custom_response:
return
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()

View File

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

View File

@ -158,19 +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",
"after_save": "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",
"after_save": "custom_ui.events.estimate.after_save"
}
}
# doc_events = {
# "*": {
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }
# }
# Scheduled Tasks
# ---------------

View File

@ -5,33 +5,11 @@ 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)
@ -62,315 +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 = {
"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"
)
],
"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"
)
]
}
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
address_meta = frappe.get_meta("Address")
required_fields = ['full_address', 'custom_onsite_meeting_scheduled',
'custom_estimate_sent_status', 'custom_job_status',
'custom_payment_received_status']
missing_fields = []
for field in required_fields:
if not address_meta.has_field(field):
missing_fields.append(field)
if missing_fields:
print(f"\n❌ Missing custom fields: {', '.join(missing_fields)}")
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,
'latitude': 0,
'longitude': 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
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 progress bar with field update count
print(f"\r📊 Progress: [{bar}] {progress_percentage:3d}% ({index}/{total_addresses}) | Fields Updated: {total_field_updates} - Processing: {name[:25]}...", end='', flush=True)
should_update = False
address = frappe.get_doc("Address", name)
current_address_updates = 0
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=["docstatus"],filters={"address": address.address_title})
if onsite_meetings and onsite_meetings[0]:
onsite_meeting = "Completed" if onsite_meetings[0]["docstatus"] == 1 else "In Progress"
estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus"], filters={"custom_installation_address": address.address_title})
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["docstatus"] == 1:
estimate_sent = "Completed"
elif estimates and estimates[0] and estimates[0]["docstatus"] != 1:
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" • Latitude: {field_counters['latitude']:,}")
print(f" • Longitude: {field_counters['longitude']:,}")
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")

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

@ -9,18 +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",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-leaflet": "^0.1.0",
"vue-router": "^4.6.3",
"vuetify": "^3.10.7"
},
@ -743,18 +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/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@ -2362,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",
@ -3217,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",
@ -3635,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",
@ -4631,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,18 +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",
"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,27 +1,6 @@
<script setup>
import { ref, onMounted } from "vue";
import { IconoirProvider } from "@iconoir/vue";
import SideBar from "./components/SideBar.vue";
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();
// Connect the toast instance to the store when component mounts
onMounted(() => {
if (toast.value) {
notificationStore.setToastInstance(toast.value);
}
});
</script>
<template>
@ -36,24 +15,9 @@ onMounted(() => {
<div id="snw-ui">
<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>
@ -64,10 +28,10 @@ onMounted(() => {
border-radius: 10px;
padding: 10px;
border: 4px solid rgb(235, 230, 230);
max-width: 2500px;
width: 100%;
max-width: 1280px;
min-width: 800px;
margin: 10px auto;
height: 90vh;
height: 80vh;
}
#display-content {
@ -76,6 +40,5 @@ onMounted(() => {
margin-right: auto;
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,526 +1,42 @@
import ApiUtils from "./apiUtils";
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";
// Job methods
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.get_jobs";
const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job";
// 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.onsite_meetings.get_week_onsite_meetings";
const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.onsite_meetings.get_onsite_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 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 getUnscheduledOnSiteMeetings() {
return await this.request(
"custom_ui.api.db.onsite_meetings.get_unscheduled_onsite_meetings",
);
}
static async getScheduledOnSiteMeetings(fields = ["*"], filters = {}) {
return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters });
}
static async getWeekOnSiteMeetings(weekStart, weekEnd) {
return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd });
}
static async updateOnSiteMeeting(name, data) {
return await this.request("custom_ui.api.db.onsite_meetings.update_onsite_meeting", {
name,
data,
});
}
static async createOnSiteMeeting(address, notes = "") {
return await this.request("custom_ui.api.db.onsite_meetings.create_onsite_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 });
}
// ============================================================================
// 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 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;
}
// ============================================================================
// 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-z])/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

@ -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,8 +1,6 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useModalStore } from "@/stores/modal";
import { useNotificationStore } from "@/stores/notifications-primevue"
import {
Home,
Community,
@ -12,371 +10,80 @@ import {
PathArrowSolid,
Clock,
HistoricShield,
Developer,
Neighbourhood,
Calculator,
ReceiveDollars,
NavArrowLeft,
NavArrowRight,
} from "@iconoir/vue";
import SpeedDial from "primevue/speeddial";
const router = useRouter();
const modalStore = useModalStore();
const notifications = useNotificationStore();
const isCollapsed = ref(false);
const toggleSidebar = () => {
isCollapsed.value = !isCollapsed.value;
};
const createButtons = ref([
{
label: "Client",
command: () => {
router.push("/client?new=true");
},
},
{
label: "On-Site Meeting",
command: () => {
router.push("/schedule-onsite?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!");
},
},
]);
const categories = ref([
const categories = [
{ name: "Home", icon: Home, url: "/" },
{ name: "Calendar", icon: Calendar, url: "/calendar" },
{ name: "Clients", icon: Community, url: "/clients" },
{ name: "On-Site Meetings", icon: Neighbourhood, url: "/schedule-onsite" },
{ name: "Estimates", icon: Calculator, url: "/estimates" },
{ 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 }">
<!-- 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="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>
<SpeedDial
:model="category.buttons"
direction="down"
type="linear"
radius="50"
v-if="!isCollapsed"
>
<template #button="{ toggleCallback }">
<button
class="sidebar-button"
@click="toggleCallback"
:key="category.name"
>
<component :is="category.icon" class="button-icon" />
<span class="button-text">{{ category.name }}</span>
</button>
</template>
<template #item="{ item, toggleCallback }">
<button class="create-item" @click="toggleCallback" :key="item.label">
<span class="p-menuitem-text">{{ item.label }}</span>
</button>
</template>
</SpeedDial>
<button v-else class="sidebar-button" :key="category.name" :title="category.name">
<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(--primary-color);
.sidebar-button.active {
background-color: rgb(25, 60, 53);
color: white;
border-left: 3px solid var(--primary-600);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
}
.sidebar-button:hover {
background-color: var(--surface-hover);
color: var(--primary-color);
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: black;
}
.create-item {
border: none;
background-color: transparent;
width: 100%;
text-align: left;
padding: 8px 12px;
cursor: pointer;
font-size: 0.85rem;
color: var(--text-color);
transition: background-color 0.2s ease;
}
.create-item:hover {
background-color: var(--surface-hover);
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);
}
.sidebar-button:active {
transform: scale(0.97);
}
#sidebar {
display: flex;
flex-direction: column;
width: 170px;
min-width: 170px;
width: 150px;
align-self: flex-start;
gap: 5px;
background-color: var(--surface-ground);
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.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(--primary-color);
background-color: var(--surface-0);
color: var(--primary-color);
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(--primary-color);
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 */
:deep(.p-speeddial) {
position: relative;
}
:deep(.p-speeddial-list) {
background-color: var(--surface-card);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--surface-border);
padding: 4px;
margin-top: 2px;
}
:deep(.p-speeddial-item) {
margin: 2px 0;
}
/* 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,198 +0,0 @@
<template>
<div class="form-section">
<h3>Address Information</h3>
<div class="form-grid">
<div class="form-field full-width">
<label for="address-title"> Address Title <span class="required">*</span> </label>
<InputText
id="address-title"
v-model="localFormData.addressTitle"
:disabled="isSubmitting || isEditMode"
placeholder="e.g., Home, Office, Site A"
class="w-full"
/>
</div>
<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,342 +0,0 @@
<template>
<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
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
v-model:visible="showCustomerSearchModal"
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>
</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"];
// 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 selectCustomer = async (customerName) => {
try {
// Fetch full customer data
const clientData = await Api.getClient(customerName);
localFormData.value.customerName = clientData.customerName;
localFormData.value.customerType = clientData.customerType;
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);
}
.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;
}
.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,824 +0,0 @@
<template>
<div class="overview-container">
<!-- Form Mode (new=true or edit mode) -->
<template v-if="isNew || editMode">
<ClientInformationForm
ref="clientInfoRef"
v-model:form-data="formData"
:is-submitting="isSubmitting"
:is-edit-mode="editMode"
@new-client-toggle="handleNewClientToggle"
@customer-selected="handleCustomerSelected"
/>
<ContactInformationForm
ref="contactInfoRef"
v-model:form-data="formData"
:is-submitting="isSubmitting"
:is-edit-mode="editMode"
:is-new-client-locked="isNewClientMode"
:available-contacts="availableContacts"
@new-contact-toggle="handleNewContactToggle"
/>
<AddressInformationForm
v-model:form-data="formData"
:is-submitting="isSubmitting"
:is-edit-mode="editMode"
/>
</template>
<!-- Display Mode (existing client view) -->
<template v-else>
<!-- Client Basic Info Card -->
<div class="info-card">
<div class="card-header">
<h3>Client Information</h3>
<Button
@click="toggleEditMode"
icon="pi pi-pencil"
label="Edit"
size="small"
severity="secondary"
/>
</div>
<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 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>
</div>
<!-- Address Info Card -->
<div class="info-card" v-if="selectedAddressData">
<h3>Address 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">
<label>City:</label>
<span>{{ selectedAddressData.city || "N/A" }}</span>
</div>
<div class="info-item">
<label>State:</label>
<span>{{ selectedAddressData.state || "N/A" }}</span>
</div>
<div class="info-item">
<label>Zip Code:</label>
<span>{{ selectedAddressData.pincode || "N/A" }}</span>
</div>
</div>
</div>
<!-- Contact Info Card -->
<div class="info-card" v-if="selectedAddressData">
<h3>Contact Information</h3>
<template v-if="contactsForAddress.length > 0">
<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>
</template>
<template v-else>
<p>No contacts available for this address.</p>
</template>
</div>
</template>
<!-- Status Cards (only for existing clients) -->
<div class="status-cards" v-if="!isNew && !editMode && selectedAddressData">
<div class="status-card">
<h4>On-Site Meeting</h4>
<Button
:label="selectedAddressData.customOnsiteMeetingScheduled || 'Not Started'"
:severity="getStatusSeverity(selectedAddressData.customOnsiteMeetingScheduled)"
@click="handleStatusClick('onsite')"
/>
</div>
<div class="status-card">
<h4>Estimate Sent</h4>
<Button
:label="selectedAddressData.customEstimateSentStatus || 'Not Started'"
:severity="getStatusSeverity(selectedAddressData.customEstimateSentStatus)"
@click="handleStatusClick('estimate')"
/>
</div>
<div class="status-card">
<h4>Job Status</h4>
<Button
:label="selectedAddressData.customJobStatus || 'Not Started'"
:severity="getStatusSeverity(selectedAddressData.customJobStatus)"
@click="handleStatusClick('job')"
/>
</div>
<div class="status-card">
<h4>Payment Received</h4>
<Button
:label="selectedAddressData.customPaymentReceivedStatus || 'Not Started'"
:severity="getStatusSeverity(selectedAddressData.customPaymentReceivedStatus)"
@click="handleStatusClick('payment')"
/>
</div>
</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>
<!-- 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>
<!-- Edit Confirmation Dialog -->
<Dialog
v-model: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 DataUtils from "../../utils";
import Api from "../../api";
import { useRouter } from "vue-router";
import { useNotificationStore } from "../../stores/notifications-primevue";
const props = defineProps({
clientData: {
type: Object,
default: () => ({}),
},
selectedAddress: {
type: String,
default: "",
},
isNew: {
type: Boolean,
default: false,
},
});
const router = useRouter();
const notificationStore = useNotificationStore();
// 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(() => {
if (!selectedAddressData.value?.customLinkedContacts || !props.clientData?.contacts) return [];
return selectedAddressData.value.customLinkedContacts
.map((link) => props.clientData.contacts.find((c) => c.name === link.contact))
.filter(Boolean);
});
// 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,
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: column;
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>

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,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,311 +0,0 @@
<template>
<!-- New Meeting Creation Modal -->
<Modal
v-model:visible="showModal"
:options="modalOptions"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<template #title>Schedule New On-Site 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,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>

File diff suppressed because it is too large Load Diff

View File

@ -1,259 +0,0 @@
<template>
<!-- Client Header -->
<div class="client-header" v-if="client.customerName">
<div class="client-info">
<h2 class="client-name">{{ client.customerName }}</h2>
<div class="address-section" v-if="addresses.length > 0">
<label class="address-label">Address:</label>
<Select
v-if="addresses.length > 1"
v-model="selectedAddress"
:options="addresses"
class="address-dropdown"
placeholder="Select an address"
/>
<span v-else class="single-address">{{ addresses[0] }}</span>
</div>
</div>
</div>
<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>
<Tab value="3">History</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>
<TabPanel value="3">
<div id="history-tab"><h3>History</h3></div>
</TabPanel>
</TabPanels>
</Tabs>
</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 Select from "primevue/select";
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";
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 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,
latitude: selectedAddressObject.value.customLatitude,
};
} 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">
.client-header {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.client-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.client-name {
margin: 0;
color: var(--text-color);
font-size: 1.75rem;
font-weight: 600;
}
.address-section {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.address-label {
font-weight: 500;
color: var(--text-color-secondary);
min-width: 70px;
}
.address-dropdown {
min-width: 300px;
flex: 1;
max-width: 500px;
}
.single-address {
color: var(--text-color);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.tab-info-alert {
background-color: #a95e46;
border-radius: 10px;
color: white;
padding-left: 5px;
padding-right: 5px;
padding-top: 2px;
padding-bottom: 2px;
}
@media (max-width: 768px) {
.client-info {
gap: 0.75rem;
}
.client-name {
font-size: 1.5rem;
}
.address-section {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.address-dropdown {
min-width: 100%;
max-width: 100%;
}
}
</style>

View File

@ -1,369 +1,43 @@
<template>
<div class="page-container">
<div>
<H2>Client Contact List</H2>
<!-- Status Chart Section -->
<div class="chart-section">
<StatusChart
:statusData="statusCounts"
:onWeekChange="handleWeekChange"
:loading="chartLoading"
/>
<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
:data="tableData"
:columns="columns"
:filters="filters"
:tableActions="tableActions"
tableName="clients"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
/>
<DataTable v-if="tableData.length > 0" :data="tableData" :columns="columns" />
</div>
</template>
<script setup>
import { onMounted, ref, watch, computed } from "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 } from "vue-router";
import { useNotificationStore } from "../../stores/notifications-primevue";
const notifications = useNotificationStore();
const loadingStore = useLoadingStore();
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
const modalStore = useModalStore();
const router = useRouter();
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
// 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,
},
{
label: "Address",
fieldName: "address",
type: "text",
sortable: true,
filterable: true,
},
{
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: "View Details",
action: (rowData) => {
router.push(`/client?client=${rowData.customerName}&address=${rowData.address}`);
},
type: "button",
style: "info",
icon: "pi pi-eye",
requiresSelection: true, // Single selection action - appears above table, enabled when exactly one row selected
layout: {
position: "center",
variant: "outlined",
},
},
// {
// 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(`/schedule-onsite?new=true&address=${address}`);
} else {
// Navigate to view appointment details
router.push('/schedule-onsite?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 },
);
onMounted(async () => {
// 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>
<style lang="css">
.page-container {
height: 100%;
}
.chart-section {
margin-bottom: 20px;
}
</style>
<style lang="css"></style>

View File

@ -1,664 +0,0 @@
<template>
<div class="error-handling-example">
<h3>PrimeVue Toast Error Handling Demo</h3>
<!-- Error Display Section -->
<div class="error-section" v-if="errorStore.hasAnyError">
<h4>Current Component Errors (for debugging):</h4>
<div class="error-list">
<div v-if="errorStore.lastError" class="error-item global-error">
<strong>Global Error:</strong> {{ errorStore.lastError.message }}
<button @click="errorStore.clearGlobalError()" class="clear-btn">Clear</button>
</div>
<div
v-for="(error, component) in errorStore.componentErrors"
:key="component"
v-if="error"
class="error-item component-error"
>
<strong>{{ component }} Error:</strong> {{ error.message }}
<button @click="errorStore.clearComponentError(component)" class="clear-btn">
Clear
</button>
</div>
</div>
</div>
<!-- Demo Buttons -->
<div class="demo-section">
<h4>Test PrimeVue Toast Notifications:</h4>
<div class="button-group">
<Button
@click="testSuccessfulApiCall"
label="Successful API Call"
class="p-button-success"
:loading="loadingStore.getComponentLoading('demo-success')"
/>
<Button
@click="testFailingApiCall"
label="Failing API Call"
class="p-button-danger"
:loading="loadingStore.getComponentLoading('demo-fail')"
/>
<Button
@click="testRetryApiCall"
label="API Call with Retry"
class="p-button-warning"
:loading="loadingStore.getComponentLoading('demo-retry')"
/>
<Button
@click="testDirectToasts"
label="Direct Toast Messages"
class="p-button-info"
/>
</div>
<div class="button-group">
<Button @click="testComponentError" label="Component Error" severity="secondary" />
<Button @click="testGlobalError" label="Global Error" severity="secondary" />
<Button @click="clearAllErrors" label="Clear All Errors" severity="secondary" />
</div>
</div>
<!-- Real API Demo with ApiWithToast -->
<div class="api-demo-section">
<h4>Real API Integration with ApiWithToast:</h4>
<div class="button-group">
<Button
@click="loadClientData"
label="Load Client Data"
class="p-button-primary"
:loading="loadingStore.getComponentLoading('clients')"
/>
<Button
@click="loadJobData"
label="Load Job Data"
class="p-button-primary"
:loading="loadingStore.getComponentLoading('jobs')"
/>
<Button
@click="createTestClient"
label="Create Test Client"
class="p-button-success"
:loading="loadingStore.getComponentLoading('form')"
/>
</div>
<div v-if="clientData.length > 0" class="data-preview">
<h5>Client Data ({{ clientData.length }} items):</h5>
<pre>{{ JSON.stringify(clientData.slice(0, 2), null, 2) }}</pre>
</div>
<div v-if="clientStatusData" class="data-preview">
<h5>Client Status Counts:</h5>
<div class="status-grid">
<div
v-for="(count, status) in clientStatusData"
:key="status"
class="status-item"
>
<strong>{{ status }}:</strong> {{ count }}
</div>
</div>
</div>
</div>
<!-- Error History -->
<div class="history-section">
<h4>Error History:</h4>
<Button
@click="showHistory = !showHistory"
:label="`${showHistory ? 'Hide' : 'Show'} History (${errorStore.errorHistory.length})`"
severity="secondary"
/>
<div v-if="errorStore.errorHistory.length === 0 && showHistory" class="no-history">
<p>
No errors in history yet. Try clicking the error buttons above to generate some
errors!
</p>
</div>
<!-- Debug info -->
<div v-if="showHistory" class="debug-info">
<p><strong>Debug Info:</strong></p>
<p>History array length: {{ errorStore.errorHistory.length }}</p>
<p>
Recent errors method returns: {{ errorStore.getRecentErrors(5).length }} items
</p>
</div>
<div v-if="showHistory && errorStore.errorHistory.length > 0" class="history-list">
<div
v-for="error in errorStore.getRecentErrors(5)"
:key="error.id"
class="history-item"
>
<div class="history-header">
<span class="error-type">{{ error.type }}</span>
<span class="error-source">{{ error.source }}</span>
<span class="error-time">{{ formatTime(error.timestamp) }}</span>
</div>
<div class="error-message">{{ error.message }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import Button from "primevue/button";
import { useErrorStore } from "@/stores/errors";
import { useLoadingStore } from "@/stores/loading";
import ApiWithToast from "@/api-toast";
const errorStore = useErrorStore();
const loadingStore = useLoadingStore();
const clientData = ref([]);
const clientStatusData = ref(null);
const showHistory = ref(false);
// Test functions using ApiWithToast
const testSuccessfulApiCall = async () => {
try {
await ApiWithToast.makeApiCall(
async () => {
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate delay
return { success: true };
},
{
componentName: "demo-success",
showSuccessToast: true,
successMessage: "API call completed successfully!",
loadingMessage: "Processing request...",
},
);
} catch (error) {
// Error toast shown automatically
console.log("Error was handled automatically");
}
};
const testFailingApiCall = async () => {
try {
await ApiWithToast.makeApiCall(
async () => {
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate delay
throw new Error("This is a simulated API failure for demo purposes");
},
{
componentName: "demo-fail",
showErrorToast: true,
customErrorMessage: "Demo API call failed as expected",
loadingMessage: "Attempting to fail...",
},
);
} catch (error) {
console.log("Error was handled by ApiWithToast");
}
};
const testRetryApiCall = async () => {
let attempts = 0;
try {
await ApiWithToast.makeApiCall(
async () => {
attempts++;
await new Promise((resolve) => setTimeout(resolve, 500));
if (attempts < 3) {
throw new Error(`Attempt ${attempts} failed - will retry`);
}
return { success: true, attempts };
},
{
componentName: "demo-retry",
retryCount: 2,
retryDelay: 1000,
showSuccessToast: true,
successMessage: `Success after ${attempts} attempts!`,
customErrorMessage: "All retry attempts failed",
loadingMessage: "Retrying operation...",
},
);
} catch (error) {
console.log("Retry test completed");
}
};
const testDirectToasts = () => {
// Test different toast types directly through error store
errorStore.setSuccess("This is a success message!");
setTimeout(() => {
errorStore.setInfo("This is an info message!");
}, 500);
setTimeout(() => {
errorStore.setWarning("This is a warning message!");
}, 1000);
setTimeout(() => {
errorStore.setGlobalError(new Error("This is an error message!"));
}, 1500);
};
const testComponentError = () => {
errorStore.setComponentError(
"demo-component",
new Error("This is a component-specific error"),
);
// Error notification will be shown automatically!
};
const testGlobalError = () => {
errorStore.setGlobalError(
new Error("This is a global error that affects the entire application"),
);
// Error notification will be shown automatically!
};
const clearAllErrors = () => {
errorStore.clearAllErrors();
errorStore.setSuccess("All errors cleared!");
};
// Real API integration using ApiWithToast
const loadClientData = async () => {
try {
const result = await ApiWithToast.getPaginatedClientDetails(
{ page: 0, pageSize: 5 },
{},
[],
{
showSuccessToast: true,
successMessage: "Client data loaded successfully!",
},
);
clientData.value = result?.data || [];
// Also load client status counts
const statusResult = await ApiWithToast.getClientStatusCounts();
clientStatusData.value = statusResult;
} catch (error) {
console.log("Client data loading failed, but error was handled automatically");
}
};
const loadJobData = async () => {
try {
const result = await ApiWithToast.getPaginatedJobDetails(
{ page: 0, pageSize: 5 },
{},
[],
{
showSuccessToast: true,
successMessage: "Job data loaded successfully!",
},
);
console.log("Job data loaded:", result);
} catch (error) {
console.log("Job data loading failed, but error was handled automatically");
}
};
const createTestClient = async () => {
const testClient = {
customer_name: `Demo Client ${Date.now()}`,
mobile_no: "555-0199",
email: `demo${Date.now()}@example.com`,
status: "Active",
};
try {
await ApiWithToast.createClient(testClient);
// Success toast shown automatically
// Reload client data to show the change
await loadClientData();
} catch (error) {
console.log("Client creation failed, but error was handled automatically");
}
};
// Utility functions
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString();
};
onMounted(() => {
// Show a welcome notification using the integrated error store
errorStore.setInfo(
"🎉 Error Store now automatically creates PrimeVue Toast notifications! No need to import both stores anymore.",
"Welcome",
);
// Add some sample errors to history for demonstration (without showing notifications)
setTimeout(() => {
errorStore.setComponentError(
"demo-component",
new Error("Sample component error from page load"),
false,
);
errorStore.setApiError(
"demo-api",
new Error("Sample API error from initialization"),
false,
);
// Clear the component errors so they don't show in the error section, but keep them in history
setTimeout(() => {
errorStore.clearComponentError("demo-component");
errorStore.clearApiError("demo-api");
}, 100);
}, 1000);
});
</script>
<style scoped>
.error-handling-example {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.info-banner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.info-banner h4 {
color: white;
margin-bottom: 1rem;
font-size: 1.5rem;
}
.info-banner p {
margin-bottom: 0.5rem;
font-size: 1rem;
line-height: 1.6;
}
.info-banner strong {
color: #ffd700;
}
.error-section {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 2rem;
border-left: 4px solid var(--orange-500);
}
.error-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.error-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-radius: 4px;
}
.global-error {
background: #fee2e2;
border-left: 4px solid #ef4444;
}
.component-error {
background: #fef3c7;
border-left: 4px solid #f59e0b;
}
.demo-section,
.api-demo-section,
.history-section {
margin-bottom: 2rem;
background: var(--surface-card);
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.button-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn.success {
background: #10b981;
color: white;
}
.btn.success:hover:not(:disabled) {
background: #059669;
}
.btn.error {
background: #ef4444;
color: white;
}
.btn.error:hover:not(:disabled) {
background: #dc2626;
}
.btn.warning {
background: #f59e0b;
color: white;
}
.btn.warning:hover:not(:disabled) {
background: #d97706;
}
.btn.info {
background: #3b82f6;
color: white;
}
.btn.info:hover:not(:disabled) {
background: #2563eb;
}
.btn.primary {
background: #6366f1;
color: white;
}
.btn.primary:hover:not(:disabled) {
background: #4f46e5;
}
.btn.secondary {
background: #6b7280;
color: white;
}
.btn.secondary:hover:not(:disabled) {
background: #4b5563;
}
.clear-btn {
background: #f3f4f6;
border: 1px solid #d1d5db;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.clear-btn:hover {
background: #e5e7eb;
}
.data-preview {
margin-top: 1rem;
padding: 1rem;
background: var(--surface-ground);
border-radius: 6px;
border: 1px solid var(--surface-border);
}
.data-preview pre {
margin: 0;
font-size: 0.75rem;
color: var(--text-color);
white-space: pre-wrap;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.status-item {
padding: 0.75rem;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 6px;
text-align: center;
}
.history-list {
margin-top: 1rem;
}
.history-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 0.5rem;
background: white;
}
.history-header {
display: flex;
gap: 1rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.error-type {
font-weight: 600;
text-transform: capitalize;
}
.error-source {
color: #3b82f6;
}
.error-message {
font-size: 0.875rem;
color: #374151;
}
h3,
h4,
h5 {
color: var(--text-color);
margin-bottom: 1rem;
}
h3 {
font-size: 1.75rem;
font-weight: 700;
text-align: center;
margin-bottom: 2rem;
}
h4 {
font-size: 1.25rem;
font-weight: 600;
color: var(--primary-color);
}
h5 {
font-size: 1rem;
font-weight: 500;
color: var(--text-color-secondary);
}
/* Keep some button styles for legacy buttons like clear-btn */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn.secondary {
background: #6b7280;
color: white;
}
.btn.secondary:hover:not(:disabled) {
background: #4b5563;
}
.no-history {
margin-top: 1rem;
padding: 1rem;
background: var(--surface-ground);
border-radius: 6px;
color: var(--text-color-secondary);
text-align: center;
font-style: italic;
}
.debug-info {
margin-top: 1rem;
padding: 0.75rem;
background: var(--yellow-50);
border: 1px solid var(--yellow-200);
border-radius: 6px;
font-size: 0.875rem;
}
.debug-info p {
margin: 0.25rem 0;
}
</style>

View File

@ -1,709 +0,0 @@
<template>
<div class="estimate-page">
<h2>{{ isNew ? 'Create Estimate' : 'View Estimate' }}</h2>
<!-- Address Section -->
<div class="address-section">
<label for="address" class="field-label">
Address
<span class="required">*</span>
</label>
<div class="address-input-group">
<InputText
id="address"
v-model="formData.address"
placeholder="Enter address to search"
:disabled="!isNew"
fluid
/>
<Button
label="Search"
icon="pi pi-search"
@click="searchAddresses"
:disabled="!formData.address.trim() || !isNew"
class="search-button"
/>
</div>
<div v-if="selectedAddress" class="verification-info">
<strong>Customer:</strong> {{ selectedAddress.customCustomerToBill }}
</div>
</div>
<!-- Contact Section -->
<div class="contact-section">
<label for="contact" class="field-label">
Contact
<span class="required">*</span>
</label>
<Select
:key="contactOptions.length"
v-model="formData.contact"
:options="contactOptions"
optionLabel="label"
optionValue="value"
placeholder="Select a contact"
:disabled="!formData.address || !isEditable"
fluid
/>
<div v-if="selectedContact" class="verification-info">
<strong>Email:</strong> {{ selectedContact.emailId || "N/A" }} <br />
<strong>Phone:</strong> {{ selectedContact.phone || "N/A" }} <br />
<strong>Primary Contact:</strong>
{{ selectedContact.isPrimaryContact ? "Yes" : "No" }}
</div>
</div>
<!-- Items Section -->
<div class="items-section">
<h3>Items</h3>
<Button
v-if="isEditable"
label="Add Item"
icon="pi pi-plus"
@click="showAddItemModal = true"
/>
<div v-for="(item, index) in selectedItems" :key="item.itemCode" class="item-row">
<span>{{ item.itemName }}</span>
<InputNumber
v-model="item.qty"
:min="1"
:disabled="!isEditable"
showButtons
buttonLayout="horizontal"
@input="updateTotal"
/>
<span>Price: ${{ (item.standardRate || 0).toFixed(2) }}</span>
<span>Total: ${{ ((item.qty || 0) * (item.standardRate || 0)).toFixed(2) }}</span>
<Button
v-if="isEditable"
icon="pi pi-trash"
@click="removeItem(index)"
severity="danger"
/>
</div>
<div class="total-section">
<strong>Total Cost: ${{ totalCost.toFixed(2) }}</strong>
</div>
<div v-if="isEditable" class="action-buttons">
<Button label="Clear Items" @click="clearItems" severity="secondary" />
<Button
label="Save Draft"
@click="saveDraft"
:disabled="selectedItems.length === 0"
/>
</div>
<div v-if="estimate">
<Button label="Send Estimate" @click="showConfirmationModal = true" :disabled="estimate.docstatus !== 0"/>
</div>
</div>
<!-- Address Search Modal -->
<Modal
:visible="showAddressModal"
@update:visible="showAddressModal = $event"
@close="showAddressModal = false"
>
<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>
<!-- Add Item Modal -->
<Modal
:visible="showAddItemModal"
@update:visible="showAddItemModal = $event"
@close="closeAddItemModal"
:options="{ showActions: false }"
>
<template #title>Add Item</template>
<div class="modal-content">
<div class="search-section">
<label for="item-search" class="field-label">Search Items</label>
<InputText
id="item-search"
v-model="itemSearchTerm"
placeholder="Search by item code or name..."
fluid
/>
</div>
<div class="tip-section">
<i class="pi pi-info-circle"></i>
<span>Tip: Hold <kbd>Ctrl</kbd> (or <kbd>Cmd</kbd> on Mac) to select multiple items</span>
</div>
<DataTable
:data="filteredItems"
:columns="itemColumns"
:tableName="'estimate-items'"
:tableActions="tableActions"
selectable
:paginator="false"
:rows="filteredItems.length"
/>
</div>
</Modal>
<!-- Confirmation Modal -->
<Modal
:visible="showConfirmationModal"
@update:visible="showConfirmationModal = $event"
@close="showConfirmationModal = false"
:options="{ showActions: false }"
>
<template #title>Confirm Send Estimate</template>
<div class="modal-content">
<h4>Does this information look correct?</h4>
<p><strong>Address:</strong> {{ formData.address }}</p>
<p>
<strong>Contact:</strong>
{{
selectedContact
? `${selectedContact.firstName} ${selectedContact.lastName}`
: ""
}}
</p>
<p>
<strong>Email:</strong>
{{ selectedContact?.emailId || "N/A" }}
</p>
<p><strong>Items:</strong></p>
<ul>
<li v-for="item in selectedItems" :key="item.itemCode">
{{ item.itemName }} - Qty: {{ item.qty || 0 }} - Total: ${{
((item.qty || 0) * (item.standardRate || 0)).toFixed(2)
}}
</li>
</ul>
<p><strong>Total:</strong> ${{ totalCost.toFixed(2) }}</p>
<p class="warning-text"><strong> Warning:</strong> After sending this estimate, it will be locked and cannot be edited.</p>
<div class="confirmation-buttons">
<Button
label="Cancel"
@click="showConfirmationModal = false"
severity="secondary"
/>
<Button label="Send Estimate" @click="confirmAndSendEstimate" :disabled="estimate.customSent !== 0" />
</div>
</div>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import Modal from "../common/Modal.vue";
import DataTable from "../common/DataTable.vue";
import InputText from "primevue/inputtext";
import InputNumber from "primevue/inputnumber";
import Button from "primevue/button";
import Select from "primevue/select";
import Api from "../../api";
import DataUtils from "../../utils";
import { useLoadingStore } from "../../stores/loading";
import { useNotificationStore } from "../../stores/notifications-primevue";
const route = useRoute();
const router = useRouter();
const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const addressQuery = route.query.address;
const isNew = route.query.new === "true" ? true : false;
const isSubmitting = ref(false);
const formData = reactive({
address: "",
addressName: "",
contact: "",
estimateName: null,
});
const selectedAddress = ref(null);
const selectedContact = ref(null);
const contacts = ref([]);
const contactOptions = ref([]);
const quotationItems = ref([]);
const selectedItems = ref([]);
const showAddressModal = ref(false);
const showAddItemModal = ref(false);
const showConfirmationModal = ref(false);
const addressSearchResults = ref([]);
const itemSearchTerm = ref("");
const estimate = ref(null);
// Computed property to determine if fields are editable
const isEditable = computed(() => {
if (isNew) return true;
if (!estimate.value) return false;
// If docstatus is 0 (draft), allow editing of contact and items
return estimate.value.docstatus === 0;
});
const itemColumns = [
{ label: "Item Code", fieldName: "itemCode", type: "text" },
{ label: "Item Name", fieldName: "itemName", type: "text" },
{ label: "Price", fieldName: "standardRate", type: "number" },
];
// Methods
const searchAddresses = async () => {
const searchTerm = formData.address.trim();
if (!searchTerm) return;
try {
const results = await Api.searchAddresses(searchTerm);
addressSearchResults.value = results;
if (results.length === 0) {
notificationStore.addNotification(
"No addresses found matching your search.",
"warning",
);
} else {
showAddressModal.value = true;
}
} catch (error) {
console.error("Error searching addresses:", error);
addressSearchResults.value = [];
notificationStore.addNotification(
"Failed to search addresses. Please try again.",
"error",
);
}
};
const selectAddress = async (address) => {
formData.address = address;
selectedAddress.value = await Api.getAddressByFullAddress(address);
formData.addressName = selectedAddress.value.name;
contacts.value = selectedAddress.value.contacts;
contactOptions.value = contacts.value.map((c) => ({
label: `${c.firstName || ""} ${c.lastName || ""}`.trim() || c.name,
value: c.name,
}));
const primary = contacts.value.find((c) => c.isPrimaryContact);
console.log("DEBUG: Selected address contacts:", contacts.value);
const existingContactName = estimate.value ? contacts.value.find((c) => c.fullName === estimate.value.partyName)?.name || "" : null;
formData.contact = estimate.value ? existingContactName : primary ? primary.name : contacts.value[0]?.name || "";
showAddressModal.value = false;
};
const addItem = (item) => {
const existing = selectedItems.value.find((i) => i.itemCode === item.itemCode);
if (!existing) {
selectedItems.value.push({ ...item, qty: 1 });
}
showAddItemModal.value = false;
};
const addSelectedItems = (selectedRows) => {
selectedRows.forEach((item) => {
const existing = selectedItems.value.find((i) => i.itemCode === item.itemCode);
if (existing) {
// Increase quantity by 1 if item already exists
existing.qty += 1;
} else {
// Add new item with quantity 1
selectedItems.value.push({ ...item, qty: 1 });
}
});
showAddItemModal.value = false;
};
const closeAddItemModal = () => {
showAddItemModal.value = false;
};
const removeItem = (index) => {
selectedItems.value.splice(index, 1);
};
const clearItems = () => {
selectedItems.value = [];
};
const updateTotal = () => {
// Computed will update
};
const saveDraft = async () => {
isSubmitting.value = true;
try {
const data = {
addressName: formData.addressName,
contactName: selectedContact.value.name,
items: selectedItems.value.map((i) => ({ itemCode: i.itemCode, qty: i.qty })),
estimateName: formData.estimateName,
};
estimate.value = await Api.createEstimate(data);
notificationStore.addSuccess(
formData.estimateName ? "Estimate updated successfully" : "Estimate created successfully",
"success"
);
// Redirect to view mode (remove new param)
router.push(`/estimate?address=${encodeURIComponent(formData.address)}`);
} catch (error) {
console.error("Error saving estimate:", error);
notificationStore.addNotification("Failed to save estimate", "error");
} finally {
isSubmitting.value = false;
}
};
const confirmAndSendEstimate = async () => {
loadingStore.setLoading(true, "Sending estimate...");
const updatedEstimate = await Api.sendEstimateEmail(estimate.value.name);
loadingStore.setLoading(false);
notificationStore.addSuccess("Estimate sent successfully", "success");
showConfirmationModal.value = false;
notificationStore.addWarning("Estimate has been locked and can no longer be edited.", "warning");
estimate.value = updatedEstimate;
};
const tableActions = [
{
label: "Add Selected Items",
action: addSelectedItems,
requiresMultipleSelection: true,
icon: "pi pi-plus",
style: "primary",
},
];
const totalCost = computed(() => {
return (selectedItems.value || []).reduce((sum, item) => {
const qty = item.qty || 0;
const rate = item.standardRate || 0;
return sum + qty * rate;
}, 0);
});
const filteredItems = computed(() => {
if (!itemSearchTerm.value.trim()) {
return quotationItems.value.map((item) => ({ ...item, id: item.itemCode }));
}
const term = itemSearchTerm.value.toLowerCase();
return quotationItems.value
.filter(
(item) =>
item.itemCode.toLowerCase().includes(term) ||
item.itemName.toLowerCase().includes(term),
)
.map((item) => ({ ...item, id: item.itemCode }));
});
watch(
() => formData.contact,
(newVal) => {
selectedContact.value = contacts.value.find((c) => c.name === newVal) || null;
},
);
// Watch for query param changes to refresh page behavior
watch(
() => route.query,
async (newQuery, oldQuery) => {
// If 'new' param or address changed, reload component state
if (newQuery.new !== oldQuery.new || newQuery.address !== oldQuery.address) {
// Reset all state
formData.address = "";
formData.addressName = "";
formData.contact = "";
formData.estimateName = null;
selectedAddress.value = null;
selectedContact.value = null;
contacts.value = [];
contactOptions.value = [];
selectedItems.value = [];
estimate.value = null;
// Reload data based on new query params
const newIsNew = newQuery.new === "true";
const newAddressQuery = newQuery.address;
if (newAddressQuery && newIsNew) {
// Creating new estimate - pre-fill address
await selectAddress(newAddressQuery);
} else if (newAddressQuery && !newIsNew) {
// Viewing existing estimate - load and populate all fields
try {
estimate.value = await Api.getEstimateFromAddress(newAddressQuery);
if (estimate.value) {
formData.estimateName = estimate.value.name;
await selectAddress(newAddressQuery);
formData.contact = estimate.value.partyName;
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.partyName) || null;
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {
const fullItem = quotationItems.value.find(qi => qi.itemCode === item.itemCode);
return {
itemCode: item.itemCode,
itemName: item.itemName,
qty: item.qty,
standardRate: item.rate || fullItem?.standardRate || 0,
};
});
}
}
} catch (error) {
console.error("Error loading estimate:", error);
notificationStore.addNotification(
"Failed to load estimate details.",
"error"
);
}
}
}
},
{ deep: true }
);
onMounted(async () => {
console.log("DEBUG: Query params:", route.query);
try {
quotationItems.value = await Api.getQuotationItems();
} catch (error) {
console.error("Error loading quotation items:", error);
}
if (addressQuery && isNew) {
// Creating new estimate - pre-fill address
await selectAddress(addressQuery);
} else if (addressQuery && !isNew) {
// Viewing existing estimate - load and populate all fields
try {
estimate.value = await Api.getEstimateFromAddress(addressQuery);
console.log("DEBUG: Loaded estimate:", estimate.value);
if (estimate.value) {
// Set the estimate name for upserting
formData.estimateName = estimate.value.name;
await selectAddress(addressQuery);
// Set the contact from the estimate
formData.contact = estimate.value.partyName;
selectedContact.value = contacts.value.find((c) => c.name === estimate.value.partyName) || null;
// Populate items from the estimate
if (estimate.value.items && estimate.value.items.length > 0) {
selectedItems.value = estimate.value.items.map(item => {
// Find the full item details from quotationItems
const fullItem = quotationItems.value.find(qi => qi.itemCode === item.itemCode);
return {
itemCode: item.itemCode,
itemName: item.itemName,
qty: item.qty,
standardRate: item.rate || fullItem?.standardRate || 0,
};
});
}
}
} catch (error) {
console.error("Error loading estimate:", error);
notificationStore.addNotification(
"Failed to load estimate details.",
"error"
);
}
}
});
</script>
<style scoped>
.estimate-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.address-section,
.contact-section {
margin-bottom: 1.5rem;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.required {
color: red;
}
.address-input-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.search-button {
flex-shrink: 0;
}
.verification-info {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #666;
}
.items-section {
margin-top: 2rem;
}
.item-row {
display: grid;
grid-template-columns: 2fr 1fr auto auto auto;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
/* When viewing (not editing), adjust grid to remove delete button column */
.estimate-page:has(h2:contains("View")) .item-row {
grid-template-columns: 2fr 1fr auto auto;
}
.total-section {
margin-top: 1rem;
font-size: 1.2rem;
text-align: right;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1rem;
}
.modal-content {
padding: 1rem;
max-height: 70vh;
overflow-y: auto;
}
.search-section {
margin-bottom: 1rem;
}
.tip-section {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background-color: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 4px;
color: #1565c0;
font-size: 0.9rem;
}
.tip-section i {
color: #2196f3;
}
.tip-section kbd {
background-color: #fff;
border: 1px solid #ccc;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 0.85em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.confirmation-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1rem;
}
.warning-text {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
color: #856404;
}
.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>
<parameter name="filePath"></parameter>

View File

@ -1,191 +0,0 @@
<template>
<div>
<h2>Estimates</h2>
<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 DataTable from "../common/DataTable.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?address=${encodeURIComponent(rowData.address)}`);
},
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 address
router.push(`/estimate?address=${encodeURIComponent(rowData.address)}`);
};
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=""></style>

View File

@ -1,370 +1,9 @@
<template>
<div class="dashboard">
<h1 class="dashboard-title">Dashboard</h1>
<div class="widgets-grid">
<!-- Calendar Widget -->
<Card class="widget-card">
<template #header>
<div class="widget-header">
<Calendar class="widget-icon" />
<h3>Service Calendar</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<div class="metric">
<span class="metric-number">8</span>
<span class="metric-label">Services Scheduled Today</span>
</div>
<div class="metric">
<span class="metric-number">15</span>
<span class="metric-label">Services This Week</span>
</div>
<Button
label="View Calendar"
size="small"
outlined
@click="navigateTo('/calendar')"
/>
</div>
</template>
</Card>
<!-- Clients Widget -->
<Card class="widget-card">
<template #header>
<div class="widget-header">
<Community class="widget-icon" />
<h3>Client Contact List</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<div class="status-row">
<Tag severity="success" value="5 Active Jobs" />
<Tag severity="warning" value="3 Pending Estimates" />
</div>
<div class="metric">
<span class="metric-number">{{ clientData.length }}</span>
<span class="metric-label">Total Client Records</span>
</div>
<Button
label="View Client List"
size="small"
outlined
@click="navigateTo('/clients')"
/>
</div>
</template>
</Card>
<!-- Jobs Widget -->
<Card class="widget-card">
<template #header>
<div class="widget-header">
<Hammer class="widget-icon" />
<h3>Job Management</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<div class="status-row">
<Tag severity="info" value="4 In Progress" />
<Tag severity="success" value="2 Completed" />
</div>
<div class="metric">
<span class="metric-number">{{
jobData.filter((job) => job.overAllStatus === "in progress").length
}}</span>
<span class="metric-label">Jobs In Progress</span>
</div>
<Button
label="View All Jobs"
size="small"
outlined
@click="navigateTo('/jobs')"
/>
</div>
</template>
</Card>
<!-- Routes Widget -->
<Card class="widget-card">
<template #header>
<div class="widget-header">
<PathArrowSolid class="widget-icon" />
<h3>Service Routes</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<div class="metric">
<span class="metric-number">6</span>
<span class="metric-label">Active Routes Today</span>
</div>
<div class="metric">
<span class="metric-number">45.2</span>
<span class="metric-label">Avg. Miles per Route</span>
</div>
<Button
label="View Routes"
size="small"
outlined
@click="navigateTo('/routes')"
/>
</div>
</template>
</Card>
<!-- Time Sheets Widget -->
<Card class="widget-card">
<template #header>
<div class="widget-header">
<Clock class="widget-icon" />
<h3>Employee Timesheets</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<div class="metric">
<span class="metric-number">32.5</span>
<span class="metric-label">Total Hours This Week</span>
</div>
<div class="status-row">
<Tag severity="success" value="5 Approved" />
<Tag severity="warning" value="2 Pending Approval" />
</div>
<Button
label="View Timesheets"
size="small"
outlined
@click="navigateTo('/timesheets')"
/>
</div>
</template>
</Card>
<!-- Warranties Widget -->
<Card class="widget-card">
<template #header>
<div class="widget-header">
<HistoricShield class="widget-icon" />
<h3>Warranty Claims</h3>
</div>
</template>
<template #content>
<div class="widget-content">
<div class="metric">
<span class="metric-number">10</span>
<span class="metric-label">Open Claims</span>
</div>
<div class="status-row">
<Tag severity="success" value="3 Completed" />
<Tag severity="warning" value="4 In Progress" />
</div>
<Button
label="View Claims"
size="small"
outlined
@click="navigateTo('/warranties')"
/>
</div>
</template>
</Card>
</div>
<!-- Quick Stats Summary -->
<div class="summary-section">
<Card>
<template #header>
<h3>Quick Stats</h3>
</template>
<template #content>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{ totalRevenue }}</span>
<span class="stat-label">Monthly Revenue</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ completedJobs }}</span>
<span class="stat-label">Jobs Completed</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ clientSatisfaction }}%</span>
<span class="stat-label">Client Satisfaction</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ avgResponseTime }}h</span>
<span class="stat-label">Avg Response Time</span>
</div>
</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 Button from "primevue/button";
import Tag from "primevue/tag";
import { Calendar, Community, Hammer, PathArrowSolid, Clock, HistoricShield } from "@iconoir/vue";
import DataUtils from "../../utils.js";
import { useNotificationStore } from "../../stores/notifications-primevue";
const router = useRouter();
// Dummy data from utils
const clientData = ref(DataUtils.dummyClientData);
const jobData = ref(DataUtils.dummyJobData);
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: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.widget-card {
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;
}
.widget-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.widget-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 20px 0;
}
.widget-icon {
color: rgb(69, 112, 101);
width: 24px;
height: 24px;
}
.widget-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.widget-content {
display: flex;
flex-direction: column;
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;
}
/* 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,138 +0,0 @@
<template>
<div>
<h2>Invoices</h2>
<DataTable
:data="tableData"
:columns="columns"
tableName="invoices"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
/>
</div>
</template>
<script setup>
import DataTable from "../common/DataTable.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";
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 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=""></style>

View File

@ -1,172 +1,9 @@
<template>
<div>
<h2>Jobs</h2>
<DataTable
:data="tableData"
:columns="columns"
tableName="jobs"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
/>
</div>
</template>
<script setup>
import DataTable from "../common/DataTable.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 { useNotificationStore } from "../../stores/notifications-primevue";
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 },
];
// 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;
}
};
// 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,
// filters: initialFilters,
// });
});
<script>
export default {};
</script>
<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,204 +0,0 @@
<template>
<div class="test-date-form">
<h2>Enhanced Date Picker Test</h2>
<p>This page demonstrates the enhanced date picker functionality in the Form component.</p>
<Form
:fields="dateFields"
:form-data="formData"
@submit="handleSubmit"
@change="handleFieldChange"
submit-button-text="Save Dates"
/>
<div v-if="Object.keys(formData).length" class="results mt-6">
<h3>Current Form Values:</h3>
<div class="results-grid">
<div v-for="(value, key) in formData" :key="key" class="result-item">
<strong>{{ formatFieldLabel(key) }}:</strong>
<span v-if="value instanceof Date" class="date-value">
{{ formatDateValue(key, value) }}
</span>
<span v-else-if="value" class="value">{{ value }}</span>
<span v-else class="empty">Not set</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import Form from "../common/Form.vue";
const formData = ref({});
const dateFields = [
// Basic date picker with US format
{
name: "birthDate",
label: "Birth Date",
type: "date",
format: "mm/dd/yyyy",
required: true,
maxDate: "today",
yearNavigator: true,
yearRange: "1920:2010",
cols: 12,
md: 6,
helpText: "Your date of birth",
},
// Date with default to today
{
name: "startDate",
label: "Project Start Date",
type: "date",
format: "YYYY-MM-DD",
defaultToToday: true,
minDate: "today",
cols: 12,
md: 6,
helpText: "When should this project start?",
},
// Date and time picker
{
name: "appointmentDateTime",
label: "Appointment Date & Time",
type: "date",
showTime: true,
hourFormat: "12",
stepMinute: 15,
defaultToNow: true,
minDate: "today",
cols: 12,
md: 6,
helpText: "Select appointment date and time",
},
// Time-only picker
{
name: "preferredTime",
label: "Preferred Contact Time",
type: "date",
timeOnly: true,
hourFormat: "12",
stepMinute: 30,
defaultValue: "09:00",
cols: 12,
md: 6,
helpText: "Best time to contact you",
},
// European date format with week numbers
{
name: "eventDate",
label: "Event Date",
type: "date",
format: "dd/mm/yyyy",
showWeek: true,
monthNavigator: true,
yearNavigator: true,
yearRange: "2024:2026",
cols: 12,
md: 6,
},
// Date with seconds
{
name: "preciseTime",
label: "Precise Timestamp",
type: "date",
showTime: true,
showSeconds: true,
hourFormat: "24",
stepSecond: 1,
cols: 12,
md: 6,
helpText: "Precise time with seconds",
},
];
const handleSubmit = (data) => {
console.log("Date form submitted:", data);
alert("Form submitted! Check console for details.");
};
const handleFieldChange = (event) => {
console.log("Field changed:", event.fieldName, "->", event.value);
};
const formatFieldLabel = (fieldName) => {
const field = dateFields.find((f) => f.name === fieldName);
return field ? field.label : fieldName;
};
const formatDateValue = (fieldName, dateValue) => {
if (!dateValue) return "Not set";
const field = dateFields.find((f) => f.name === fieldName);
if (field?.timeOnly) {
return dateValue.toLocaleTimeString();
} else if (field?.showTime) {
return dateValue.toLocaleString();
} else {
return dateValue.toLocaleDateString();
}
};
</script>
<style scoped>
.test-date-form {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.results {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
border: 1px solid #e9ecef;
}
.results-grid {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
.result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: white;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.date-value {
color: #0066cc;
font-weight: 500;
}
.value {
color: #28a745;
font-weight: 500;
}
.empty {
color: #6c757d;
font-style: italic;
}
@media (max-width: 768px) {
.result-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
</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");

View File

@ -3,18 +3,11 @@ import { createRouter, createWebHashHistory } from "vue-router";
import Calendar from "./components/pages/Calendar.vue";
import Clients from "./components/pages/Clients.vue";
import Jobs from "./components/pages/Jobs.vue";
import Invoices from "./components/pages/Invoices.vue";
import Estimates from "./components/pages/Estimates.vue";
import Create from "./components/pages/Create.vue";
import Routes from "./components/pages/Routes.vue";
import TimeSheets from "./components/pages/TimeSheets.vue";
import Warranties from "./components/pages/Warranties.vue";
import Home from "./components/pages/Home.vue";
import TestDateForm from "./components/pages/TestDateForm.vue";
import Client from "./components/pages/Client.vue";
import ErrorHandlingDemo from "./components/pages/ErrorHandlingDemo.vue";
import ScheduleOnSite from "./components/pages/ScheduleOnSite.vue";
import Estimate from "./components/pages/Estimate.vue";
const routes = [
{
@ -23,19 +16,11 @@ const routes = [
},
{ path: "/calendar", component: Calendar },
{ path: "/clients", component: Clients },
{ path: "/client", component: Client },
{ path: "/schedule-onsite", component: ScheduleOnSite },
{ path: "/jobs", component: Jobs },
{ path: "/invoices", component: Invoices },
{ path: "/estimates", component: Estimates },
{ path: "/estimate", component: Estimate },
{ path: "/routes", component: Routes },
{ path: "/create", component: Create },
{ path: "/timesheets", component: TimeSheets },
{ path: "/warranties", component: Warranties },
{ path: "/test-dates", component: TestDateForm },
{ path: "/dev/error-handling-demo", component: ErrorHandlingDemo },
{ path: "/:pathMatch(.*)*", component: Home }, // Fallback to Home for unknown routes
];
const router = createRouter({

View File

@ -1,341 +0,0 @@
import { defineStore } from "pinia";
import { useNotificationStore } from "./notifications-primevue";
/**
* Enhanced Error Store with Automatic PrimeVue Toast Notifications
*
* This store automatically creates PrimeVue Toast notifications when errors are set.
* No need to import both error and notification stores - just use this one!
*
* Usage:
* import { useErrorStore } from '@/stores/errors'
* const errorStore = useErrorStore()
*
* // These will automatically show toast notifications:
* errorStore.setGlobalError(new Error("Something went wrong"))
* errorStore.setComponentError("form", new Error("Validation failed"))
* errorStore.setApiError("fetch-users", new Error("Network error"))
*
* // Convenience methods for non-error notifications:
* errorStore.setSuccess("Operation completed!")
* errorStore.setWarning("Please check your input")
* errorStore.setInfo("Loading data...")
*/
export const useErrorStore = defineStore("errors", {
state: () => ({
// Global error state
hasError: false,
lastError: null,
// API-specific errors
apiErrors: new Map(),
// Component-specific errors
componentErrors: {
dataTable: null,
form: null,
clients: null,
jobs: null,
timesheets: null,
warranties: null,
routes: null,
},
// Error history for debugging
errorHistory: [],
// Configuration
maxHistorySize: 50,
autoNotifyErrors: true,
}),
getters: {
// Check if any error exists
hasAnyError: (state) => {
return (
state.hasError ||
state.apiErrors.size > 0 ||
Object.values(state.componentErrors).some((error) => error !== null)
);
},
// Get error for a specific component
getComponentError: (state) => (componentName) => {
return state.componentErrors[componentName];
},
// Get error for a specific API call
getApiError: (state) => (apiKey) => {
return state.apiErrors.get(apiKey);
},
// Get recent errors
getRecentErrors:
(state) =>
(limit = 10) => {
return state.errorHistory.slice(-limit).reverse();
},
},
actions: {
// Set global error
setGlobalError(error, showNotification = true) {
this.hasError = true;
this.lastError = this._normalizeError(error);
this._addToHistory(this.lastError, "global");
if (showNotification && this.autoNotifyErrors) {
const notificationStore = useNotificationStore();
notificationStore.addError(this.lastError.message, "Global Error", {
duration: 5000,
});
}
},
// Set component-specific error
setComponentError(componentName, error, showNotification = true) {
const normalizedError = error ? this._normalizeError(error) : null;
if (this.componentErrors.hasOwnProperty(componentName)) {
this.componentErrors[componentName] = normalizedError;
} else {
this.componentErrors[componentName] = normalizedError;
}
if (normalizedError) {
this._addToHistory(normalizedError, `component:${componentName}`);
if (showNotification && this.autoNotifyErrors) {
const notificationStore = useNotificationStore();
notificationStore.addError(
normalizedError.message,
`${this._formatComponentName(componentName)} Error`,
{ duration: 5000 },
);
}
}
},
// Set API-specific error
setApiError(apiKey, error, showNotification = true) {
if (error) {
const normalizedError = this._normalizeError(error);
this.apiErrors.set(apiKey, normalizedError);
this._addToHistory(normalizedError, `api:${apiKey}`);
if (showNotification && this.autoNotifyErrors) {
const notificationStore = useNotificationStore();
notificationStore.addError(normalizedError.message, "API Error", {
duration: 6000,
});
}
} else {
this.apiErrors.delete(apiKey);
}
},
// Clear specific errors
clearGlobalError() {
this.hasError = false;
this.lastError = null;
},
clearComponentError(componentName) {
if (this.componentErrors.hasOwnProperty(componentName)) {
this.componentErrors[componentName] = null;
}
},
clearApiError(apiKey) {
this.apiErrors.delete(apiKey);
},
// Clear all errors
clearAllErrors() {
this.hasError = false;
this.lastError = null;
this.apiErrors.clear();
Object.keys(this.componentErrors).forEach((key) => {
this.componentErrors[key] = null;
});
},
// Handle API call errors with automatic error management
async handleApiCall(apiKey, apiFunction, options = {}) {
const {
showNotification = true,
retryCount = 0,
retryDelay = 1000,
onSuccess = null,
onError = null,
} = options;
// Clear any existing error for this API
this.clearApiError(apiKey);
let attempt = 0;
while (attempt <= retryCount) {
try {
const result = await apiFunction();
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (error) {
attempt++;
if (attempt <= retryCount) {
// Wait before retry
await new Promise((resolve) => setTimeout(resolve, retryDelay));
continue;
}
// Final attempt failed
this.setApiError(apiKey, error, showNotification);
if (onError) {
onError(error);
}
throw error;
}
}
},
// Convenience method for handling async operations with error management
async withErrorHandling(operationKey, asyncOperation, options = {}) {
const { componentName = null, showNotification = true, rethrow = false } = options;
try {
const result = await asyncOperation();
// Clear any existing errors on success
if (componentName) {
this.clearComponentError(componentName);
}
return result;
} catch (error) {
if (componentName) {
this.setComponentError(componentName, error, showNotification);
} else {
this.setGlobalError(error, showNotification);
}
if (rethrow) {
throw error;
}
return null;
}
},
// Private helper methods
_normalizeError(error) {
if (typeof error === "string") {
return {
message: error,
type: "string_error",
timestamp: new Date().toISOString(),
};
}
if (error instanceof Error) {
return {
message: error.message,
name: error.name,
stack: error.stack,
type: "javascript_error",
timestamp: new Date().toISOString(),
};
}
// Handle API response errors
if (error && error.response) {
return {
message:
error.response.data?.message || error.response.statusText || "API Error",
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data,
type: "api_error",
timestamp: new Date().toISOString(),
};
}
// Handle network errors
if (error && error.request) {
return {
message: "Network error - please check your connection",
type: "network_error",
timestamp: new Date().toISOString(),
};
}
// Fallback for unknown error types
return {
message: error?.message || "An unknown error occurred",
originalError: error,
type: "unknown_error",
timestamp: new Date().toISOString(),
};
},
_addToHistory(normalizedError, source) {
this.errorHistory.push({
...normalizedError,
source,
id: Date.now() + Math.random(),
});
// Trim history if it exceeds max size
if (this.errorHistory.length > this.maxHistorySize) {
this.errorHistory = this.errorHistory.slice(-this.maxHistorySize);
}
},
// Success notifications (convenience methods)
setSuccess(message, title = "Success", options = {}) {
if (this.autoNotifyErrors) {
const notificationStore = useNotificationStore();
notificationStore.addSuccess(message, title, options);
}
},
setWarning(message, title = "Warning", options = {}) {
if (this.autoNotifyErrors) {
const notificationStore = useNotificationStore();
notificationStore.addWarning(message, title, options);
}
},
setInfo(message, title = "Info", options = {}) {
if (this.autoNotifyErrors) {
const notificationStore = useNotificationStore();
notificationStore.addInfo(message, title, options);
}
},
// Configuration methods
setAutoNotifyErrors(enabled) {
this.autoNotifyErrors = enabled;
},
setMaxHistorySize(size) {
this.maxHistorySize = size;
if (this.errorHistory.length > size) {
this.errorHistory = this.errorHistory.slice(-size);
}
},
// Helper method to format component names nicely
_formatComponentName(componentName) {
return componentName
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
},
},
});

View File

@ -1,209 +0,0 @@
import { defineStore } from "pinia";
import { FilterMatchMode } from "@primevue/core";
export const useFiltersStore = defineStore("filters", {
state: () => ({
// Store filters by table/component name
tableFilters: {},
// Store sorting by table/component name - now supports multiple sorts as array
tableSorting: {},
}),
actions: {
// Generic method to get filters for a specific table
getTableFilters(tableName) {
return this.tableFilters[tableName] || {};
},
// Generic method to get sorting for a specific table
getTableSorting(tableName) {
return this.tableSorting[tableName] || [];
},
// Get sorting in backend format: [["field", "asc/desc"], ["field", "asc/desc"]]
getTableSortingForBackend(tableName) {
const sorting = this.tableSorting[tableName] || [];
console.log("getTableSortingForBackend - raw sorting:", sorting);
const result = sorting.map((sort) => {
const direction = sort.order === 1 ? "asc" : "desc";
console.log("Converting sort:", {
field: sort.field,
order: sort.order,
direction,
});
return [sort.field, direction];
});
console.log("getTableSortingForBackend result:", result);
return result;
},
// Get primary sort field for compatibility with PrimeVue
getPrimarySortField(tableName) {
const sorting = this.tableSorting[tableName] || [];
return sorting.length > 0 ? sorting[0].field : null;
},
// Get primary sort order for compatibility with PrimeVue
getPrimarySortOrder(tableName) {
const sorting = this.tableSorting[tableName] || [];
if (sorting.length > 0) {
const order = sorting[0].order;
console.log("getPrimarySortOrder returning:", order, typeof order);
return order;
}
return null;
},
// Generic method to update a specific filter
updateTableFilter(tableName, fieldName, value, matchMode = null) {
if (!this.tableFilters[tableName]) {
this.tableFilters[tableName] = {};
}
if (!this.tableFilters[tableName][fieldName]) {
this.tableFilters[tableName][fieldName] = {
value: null,
matchMode: FilterMatchMode.CONTAINS,
};
}
this.tableFilters[tableName][fieldName].value = value;
// Update match mode if provided
if (matchMode) {
this.tableFilters[tableName][fieldName].matchMode = matchMode;
}
},
// Generic method to update sorting for a table (supports single sort from PrimeVue)
updateTableSorting(tableName, field, order) {
if (!this.tableSorting[tableName]) {
this.tableSorting[tableName] = [];
}
// Clear sorting if no field provided or order is null/undefined
if (!field || order === null || order === undefined) {
console.log("Clearing sort for table:", tableName);
this.tableSorting[tableName] = [];
return;
}
// Ensure order is a number (PrimeVue uses 1 for asc, -1 for desc)
let numericOrder = order;
if (typeof order === "string") {
numericOrder = order.toLowerCase() === "asc" ? 1 : -1;
} else if (typeof order === "number") {
// Ensure it's 1 or -1
numericOrder = order > 0 ? 1 : -1;
} else {
console.warn("Invalid sort order type:", typeof order, order);
return;
}
console.log("updateTableSorting called with:", {
tableName,
field,
order,
numericOrder,
});
// Replace existing sort with new single sort (PrimeVue behavior)
this.tableSorting[tableName] = [{ field, order: numericOrder }];
},
// Method to add or update a specific sort field (for multi-sort support)
addTableSort(tableName, field, order) {
if (!this.tableSorting[tableName]) {
this.tableSorting[tableName] = [];
}
// Remove existing sort for this field
this.tableSorting[tableName] = this.tableSorting[tableName].filter(
(sort) => sort.field !== field,
);
// Add new sort if field and order provided
if (field && order) {
this.tableSorting[tableName].push({ field, order });
}
},
// Method to set multiple sorts at once
setTableSorting(tableName, sortArray) {
this.tableSorting[tableName] = sortArray || [];
},
// Method to clear all filters for a table
clearTableFilters(tableName) {
if (this.tableFilters[tableName]) {
Object.keys(this.tableFilters[tableName]).forEach((key) => {
this.tableFilters[tableName][key].value = null;
});
}
},
// Method to clear sorting for a table
clearTableSorting(tableName) {
this.tableSorting[tableName] = [];
},
// Method to clear both filters and sorting for a table
clearTableState(tableName) {
this.clearTableFilters(tableName);
this.clearTableSorting(tableName);
},
// Method to initialize filters for a table if they don't exist
initializeTableFilters(tableName, columns) {
if (!this.tableFilters[tableName]) {
this.tableFilters[tableName] = {};
}
columns.forEach((column) => {
if (column.filterable && !this.tableFilters[tableName][column.fieldName]) {
this.tableFilters[tableName][column.fieldName] = {
value: null,
matchMode: FilterMatchMode.CONTAINS,
};
}
});
},
// Method to initialize sorting for a table
initializeTableSorting(tableName) {
if (!this.tableSorting[tableName]) {
this.tableSorting[tableName] = [];
}
},
// Method to get active filters count
getActiveFiltersCount(tableName) {
const filters = this.getTableFilters(tableName);
return Object.values(filters).filter(
(filter) => filter.value && filter.value.trim() !== "",
).length;
},
// Method to check if sorting is active
isSortingActive(tableName) {
const sorting = this.getTableSorting(tableName);
return sorting.length > 0;
},
// Method to get all table state (filters + sorting)
getTableState(tableName) {
return {
filters: this.getTableFilters(tableName),
sorting: this.getTableSorting(tableName),
};
},
// Legacy method for backward compatibility
setClientNameFilter(filterValue) {
this.updateTableFilter("clients", "addressTitle", filterValue);
},
// Getter for legacy compatibility
get clientNameFilter() {
return this.tableFilters?.clients?.addressTitle?.value || "";
},
},
});

View File

@ -1,138 +0,0 @@
import { defineStore } from "pinia";
export const useLoadingStore = defineStore("loading", {
state: () => ({
// Global loading state
isLoading: false,
// Component-specific loading states for more granular control
componentLoading: {
dataTable: false,
form: false,
clients: false,
jobs: false,
timesheets: false,
warranties: false,
routes: false,
api: false,
},
// Loading messages for different contexts
loadingMessage: "Loading...",
// Track loading operations with custom keys
operations: new Map(),
}),
getters: {
// Check if any loading is happening
isAnyLoading: (state) => {
return (
state.isLoading ||
Object.values(state.componentLoading).some((loading) => loading) ||
state.operations.size > 0
);
},
// Get loading state for a specific component
getComponentLoading: (state) => (componentName) => {
return state.componentLoading[componentName] || false;
},
// Check if a specific operation is loading
isOperationLoading: (state) => (operationKey) => {
return state.operations.has(operationKey);
},
},
actions: {
// Set global loading state
setLoading(isLoading, message = "Loading...") {
this.isLoading = isLoading;
this.loadingMessage = message;
},
// Set component-specific loading state
setComponentLoading(componentName, isLoading, message = "Loading...") {
if (this.componentLoading.hasOwnProperty(componentName)) {
this.componentLoading[componentName] = isLoading;
} else {
this.componentLoading[componentName] = isLoading;
}
if (isLoading) {
this.loadingMessage = message;
}
},
// Start loading for a specific operation
startOperation(operationKey, message = "Loading...") {
this.operations.set(operationKey, {
startTime: Date.now(),
message: message,
});
this.loadingMessage = message;
},
// Stop loading for a specific operation
stopOperation(operationKey) {
this.operations.delete(operationKey);
},
// Clear all loading states
clearAllLoading() {
this.isLoading = false;
Object.keys(this.componentLoading).forEach((key) => {
this.componentLoading[key] = false;
});
this.operations.clear();
this.loadingMessage = "Loading...";
},
// Convenience methods for common operations
startApiCall(apiName = "api") {
this.setComponentLoading("api", true, `Loading ${apiName}...`);
},
stopApiCall() {
this.setComponentLoading("api", false);
},
startDataTableLoading(message = "Loading data...") {
this.setComponentLoading("dataTable", true, message);
},
stopDataTableLoading() {
this.setComponentLoading("dataTable", false);
},
startFormLoading(message = "Processing...") {
this.setComponentLoading("form", true, message);
},
stopFormLoading() {
this.setComponentLoading("form", false);
},
// Async wrapper for operations
async withLoading(operationKey, asyncOperation, message = "Loading...") {
try {
this.startOperation(operationKey, message);
const result = await asyncOperation();
return result;
} finally {
this.stopOperation(operationKey);
}
},
// Async wrapper for component loading
async withComponentLoading(componentName, asyncOperation, message = "Loading...") {
try {
this.setComponentLoading(componentName, true, message);
const result = await asyncOperation();
return result;
} finally {
this.setComponentLoading(componentName, false);
}
},
},
});

View File

@ -1,232 +0,0 @@
import { defineStore } from "pinia";
export const useModalStore = defineStore("modal", {
state: () => ({
// Dynamic modal registry - can handle any number of modals
modals: {},
// Stack for modal layering (optional)
modalStack: [],
// Component registry for dynamic modals
registeredComponents: {},
// Global modal configuration
globalConfig: {
closeOnEscape: true,
closeOnOutsideClick: true,
preventBodyScroll: true
}
}),
getters: {
// Check if any modal is open
hasOpenModal: (state) => {
return Object.values(state.modals).some(modal => modal.isOpen);
},
// Get modal by ID
getModal: (state) => (modalId) => {
return state.modals[modalId] || null;
},
// Check if specific modal is open
isModalOpen: (state) => (modalId) => {
return state.modals[modalId]?.isOpen || false;
},
// Get modal data
getModalData: (state) => (modalId) => {
return state.modals[modalId]?.data || null;
},
// Get the topmost modal in stack
getTopModal: (state) => {
if (state.modalStack.length === 0) return null;
const topModalId = state.modalStack[state.modalStack.length - 1];
return state.modals[topModalId];
}
},
actions: {
// Register a modal component for dynamic loading
registerModalComponent(modalId, component) {
this.registeredComponents[modalId] = component;
},
// Initialize a modal (register it in the store)
initializeModal(modalId, config = {}) {
if (!this.modals[modalId]) {
this.modals[modalId] = {
id: modalId,
isOpen: false,
data: null,
config: {
...this.globalConfig,
...config
},
history: []
};
}
},
// Open a modal with optional data
openModal(modalId, data = null, config = {}) {
// Initialize modal if it doesn't exist
this.initializeModal(modalId, config);
// Close other modals if exclusive mode (default behavior)
if (config.exclusive !== false) {
this.closeAllModals();
}
// Set modal state
this.modals[modalId].isOpen = true;
this.modals[modalId].data = data;
this.modals[modalId].config = {
...this.modals[modalId].config,
...config
};
// Add to stack
if (!this.modalStack.includes(modalId)) {
this.modalStack.push(modalId);
}
// Track opening in history
this.modals[modalId].history.push({
action: 'opened',
timestamp: new Date(),
data: data
});
},
// Close a specific modal
closeModal(modalId) {
if (this.modals[modalId]) {
this.modals[modalId].isOpen = false;
this.modals[modalId].data = null;
// Remove from stack
const index = this.modalStack.indexOf(modalId);
if (index > -1) {
this.modalStack.splice(index, 1);
}
// Track closing in history
this.modals[modalId].history.push({
action: 'closed',
timestamp: new Date()
});
}
},
// Toggle a modal
toggleModal(modalId, data = null, config = {}) {
if (this.isModalOpen(modalId)) {
this.closeModal(modalId);
} else {
this.openModal(modalId, data, config);
}
},
// Close all modals
closeAllModals() {
Object.keys(this.modals).forEach(modalId => {
if (this.modals[modalId].isOpen) {
this.closeModal(modalId);
}
});
this.modalStack = [];
},
// Close the topmost modal
closeTopModal() {
if (this.modalStack.length > 0) {
const topModalId = this.modalStack[this.modalStack.length - 1];
this.closeModal(topModalId);
}
},
// Update modal data without opening/closing
updateModalData(modalId, data) {
if (this.modals[modalId]) {
this.modals[modalId].data = data;
}
},
// Update modal configuration
updateModalConfig(modalId, config) {
if (this.modals[modalId]) {
this.modals[modalId].config = {
...this.modals[modalId].config,
...config
};
}
},
// Remove a modal from the store (cleanup)
removeModal(modalId) {
if (this.modals[modalId]) {
this.closeModal(modalId);
delete this.modals[modalId];
}
},
// Convenience methods for common modals
// Add your specific modal methods here
// Example: Edit User Modal
openEditUser(userData = null) {
this.openModal('editUser', userData, {
closeOnEscape: true,
closeOnOutsideClick: false
});
},
closeEditUser() {
this.closeModal('editUser');
},
// Example: Confirmation Modal
openConfirmation(message, onConfirm, onCancel = null) {
this.openModal('confirmation', {
message,
onConfirm,
onCancel
}, {
closeOnEscape: false,
closeOnOutsideClick: false
});
},
closeConfirmation() {
this.closeModal('confirmation');
},
// Example: Image Gallery Modal
openImageGallery(images, currentIndex = 0) {
this.openModal('imageGallery', {
images,
currentIndex
}, {
closeOnEscape: true,
exclusive: true
});
},
closeImageGallery() {
this.closeModal('imageGallery');
},
// Create Client Modal
openCreateClient(clientData = null) {
this.openModal('createClient', clientData, {
closeOnEscape: true,
closeOnOutsideClick: true,
exclusive: true
});
},
closeCreateClient() {
this.closeModal('createClient');
}
}
});

View File

@ -1,186 +0,0 @@
import { defineStore } from "pinia";
// Global toast instance - will be set during app initialization
let toastInstance = null;
export const useNotificationStore = defineStore("notifications", {
state: () => ({
// Configuration for PrimeVue Toast
defaultLife: 4000,
position: "top-right",
}),
getters: {
// Helper to check if toast is available
isToastAvailable: () => !!toastInstance,
},
actions: {
// Set the toast instance (called from main component)
setToastInstance(toast) {
toastInstance = toast;
},
// Core method to show notifications using PrimeVue Toast
addNotification(notification) {
if (!toastInstance) {
console.warn(
"Toast instance not available. Make sure to call setToastInstance first.",
);
return;
}
const toastMessage = {
severity: this.mapTypesToSeverity(notification.type || "info"),
summary: notification.title || this.getDefaultTitle(notification.type || "info"),
detail: notification.message || "",
life: notification.persistent ? 0 : (notification.duration ?? this.defaultLife),
group: notification.group || "main",
};
toastInstance.add(toastMessage);
},
// Convenience methods for different types of notifications
addSuccess(message, title = "Success", options = {}) {
this.addNotification({
type: "success",
title,
message,
...options,
});
},
addError(message, title = "Error", options = {}) {
this.addNotification({
type: "error",
title,
message,
duration: options.duration ?? 6000, // Errors stay longer by default
...options,
});
},
addWarning(message, title = "Warning", options = {}) {
this.addNotification({
type: "warn",
title,
message,
...options,
});
},
addInfo(message, title = "Info", options = {}) {
this.addNotification({
type: "info",
title,
message,
...options,
});
},
// Show API operation notifications
showApiSuccess(operation, message = null) {
const defaultMessages = {
create: "Item created successfully",
update: "Item updated successfully",
delete: "Item deleted successfully",
fetch: "Data loaded successfully",
};
this.addSuccess(
message || defaultMessages[operation] || "Operation completed successfully",
);
},
showApiError(operation, error, message = null) {
const defaultMessages = {
create: "Failed to create item",
update: "Failed to update item",
delete: "Failed to delete item",
fetch: "Failed to load data",
};
let errorMessage = message;
if (!errorMessage) {
if (typeof error === "string") {
errorMessage = error;
} else if (error?.response?.data?.message) {
errorMessage = error.response.data.message;
} else if (error?.message) {
errorMessage = error.message;
} else {
errorMessage = defaultMessages[operation] || "Operation failed";
}
}
this.addError(errorMessage);
},
// Configuration methods
setPosition(position) {
this.position = position;
},
setDefaultLife(life) {
this.defaultLife = life;
},
// Clear all notifications
clearAll() {
if (toastInstance) {
toastInstance.removeAllGroups();
}
},
// Utility method for handling async operations with notifications
async withNotifications(operation, asyncFunction, options = {}) {
const {
loadingMessage = "Processing...",
successMessage = null,
errorMessage = null,
showLoading = true,
} = options;
try {
if (showLoading) {
this.addInfo(loadingMessage, "Loading", { persistent: true });
}
const result = await asyncFunction();
if (successMessage !== false) {
this.showApiSuccess(operation, successMessage);
}
return result;
} catch (error) {
this.showApiError(operation, error, errorMessage);
throw error;
}
},
// Helper methods
mapTypesToSeverity(type) {
const mapping = {
success: "success",
error: "error",
warn: "warn",
warning: "warn",
info: "info",
};
return mapping[type] || "info";
},
getDefaultTitle(type) {
const titles = {
success: "Success",
error: "Error",
warn: "Warning",
warning: "Warning",
info: "Information",
};
return titles[type] || "Notification";
},
},
});

View File

@ -1,396 +0,0 @@
import { defineStore } from "pinia";
export const usePaginationStore = defineStore("pagination", {
state: () => ({
// Store pagination state by table/component name
tablePagination: {
clients: {
first: 0, // Starting index for current page
page: 0, // Current page number (0-based)
rows: 10, // Items per page
totalRecords: 0, // Total number of records available
sortField: null, // Current sort field
sortOrder: null, // Sort direction (1 for asc, -1 for desc)
},
jobs: {
first: 0,
page: 0,
rows: 10,
totalRecords: 0,
sortField: null,
sortOrder: null,
},
estimates: {
first: 0,
page: 0,
rows: 10,
totalRecords: 0,
sortField: null,
sortOrder: null,
},
timesheets: {
first: 0,
page: 0,
rows: 10,
totalRecords: 0,
sortField: null,
sortOrder: null,
},
warranties: {
first: 0,
page: 0,
rows: 10,
totalRecords: 0,
sortField: null,
sortOrder: null,
},
routes: {
first: 0,
page: 0,
rows: 10,
totalRecords: 0,
sortField: null,
sortOrder: null,
},
},
// Page data cache: tableName -> { pageKey -> { data, timestamp, filterHash } }
pageCache: {},
// Cache configuration
cacheTimeout: 5 * 60 * 1000, // 5 minutes in milliseconds
maxCacheSize: 50, // Maximum number of cached pages per table
}),
getters: {
// Get pagination state for a specific table
getTablePagination: (state) => (tableName) => {
return (
state.tablePagination[tableName] || {
first: 0,
page: 0,
rows: 10,
totalRecords: 0,
sortField: null,
sortOrder: null,
}
);
},
// Calculate total pages for a table
getTotalPages: (state) => (tableName) => {
const pagination = state.tablePagination[tableName];
if (!pagination || pagination.totalRecords === 0) return 0;
return Math.ceil(pagination.totalRecords / pagination.rows);
},
// Check if there's a next page
hasNextPage: (state) => (tableName) => {
const pagination = state.tablePagination[tableName];
if (!pagination) return false;
return pagination.page + 1 < Math.ceil(pagination.totalRecords / pagination.rows);
},
// Check if there's a previous page
hasPreviousPage: (state) => (tableName) => {
const pagination = state.tablePagination[tableName];
if (!pagination) return false;
return pagination.page > 0;
},
// Get current page info for display
getPageInfo: (state) => (tableName) => {
const pagination = state.tablePagination[tableName];
if (!pagination) return { start: 0, end: 0, total: 0 };
const start = pagination.first + 1;
const end = Math.min(pagination.first + pagination.rows, pagination.totalRecords);
const total = pagination.totalRecords;
return { start, end, total };
},
},
actions: {
// Initialize pagination for a table if it doesn't exist
initializeTablePagination(tableName, options = {}) {
if (!this.tablePagination[tableName]) {
this.tablePagination[tableName] = {
first: 0,
page: 0,
rows: options.rows || 10,
totalRecords: options.totalRecords || 0,
sortField: options.sortField || null,
sortOrder: options.sortOrder || null,
};
}
},
// Update pagination state for a table
updateTablePagination(tableName, paginationData) {
if (!this.tablePagination[tableName]) {
this.initializeTablePagination(tableName);
}
// Update provided fields
Object.keys(paginationData).forEach((key) => {
if (paginationData[key] !== undefined) {
this.tablePagination[tableName][key] = paginationData[key];
}
});
// Ensure consistency between page and first
if (paginationData.page !== undefined) {
this.tablePagination[tableName].first =
paginationData.page * this.tablePagination[tableName].rows;
} else if (paginationData.first !== undefined) {
this.tablePagination[tableName].page = Math.floor(
paginationData.first / this.tablePagination[tableName].rows,
);
}
},
// Set current page
setPage(tableName, page) {
this.updateTablePagination(tableName, {
page: page,
first: page * this.getTablePagination(tableName).rows,
});
},
// Set rows per page
setRowsPerPage(tableName, rows) {
const currentPagination = this.getTablePagination(tableName);
const newPage = Math.floor(currentPagination.first / rows);
this.updateTablePagination(tableName, {
rows: rows,
page: newPage,
first: newPage * rows,
});
},
// Set total records (usually after API response)
setTotalRecords(tableName, totalRecords) {
this.updateTablePagination(tableName, {
totalRecords: totalRecords,
});
// Ensure current page is valid
const currentPagination = this.getTablePagination(tableName);
const maxPages = Math.ceil(totalRecords / currentPagination.rows);
if (currentPagination.page >= maxPages && maxPages > 0) {
this.setPage(tableName, maxPages - 1);
}
},
// Set sort information
setSorting(tableName, sortField, sortOrder) {
this.updateTablePagination(tableName, {
sortField: sortField,
sortOrder: sortOrder,
});
},
// Go to next page
nextPage(tableName) {
const pagination = this.getTablePagination(tableName);
if (this.hasNextPage(tableName)) {
this.setPage(tableName, pagination.page + 1);
}
},
// Go to previous page
previousPage(tableName) {
const pagination = this.getTablePagination(tableName);
if (this.hasPreviousPage(tableName)) {
this.setPage(tableName, pagination.page - 1);
}
},
// Go to first page
firstPage(tableName) {
this.setPage(tableName, 0);
},
// Go to last page
lastPage(tableName) {
const totalPages = this.getTotalPages(tableName);
if (totalPages > 0) {
this.setPage(tableName, totalPages - 1);
}
},
// Reset pagination to first page (useful when filters change)
resetToFirstPage(tableName) {
this.updateTablePagination(tableName, {
page: 0,
first: 0,
});
},
// Clear all pagination data for a table
clearTablePagination(tableName) {
if (this.tablePagination[tableName]) {
this.tablePagination[tableName] = {
first: 0,
page: 0,
rows: 10,
totalRecords: 0,
sortField: null,
sortOrder: null,
};
}
},
// Get formatted pagination parameters for API calls
getPaginationParams(tableName) {
const pagination = this.getTablePagination(tableName);
return {
page: pagination.page,
pageSize: pagination.rows,
offset: pagination.first,
limit: pagination.rows,
sortField: pagination.sortField,
sortOrder: pagination.sortOrder,
};
},
// Handle PrimeVue lazy pagination event
handleLazyLoad(tableName, event) {
console.log("Pagination lazy load event:", event);
const page = Math.floor(event.first / event.rows);
this.updateTablePagination(tableName, {
first: event.first,
page: page,
rows: event.rows,
sortField: event.sortField,
sortOrder: event.sortOrder,
});
return this.getPaginationParams(tableName);
},
// Cache management methods
generateCacheKey(page, pageSize, sortField, sortOrder, filters) {
const filterHash = this.hashFilters(filters);
return `${page}-${pageSize}-${sortField || "none"}-${sortOrder || 0}-${filterHash}`;
},
hashFilters(filters) {
if (!filters || Object.keys(filters).length === 0) return "no-filters";
const sortedKeys = Object.keys(filters).sort();
const filterString = sortedKeys
.map((key) => {
const filter = filters[key];
const value = filter?.value || "";
const matchMode = filter?.matchMode || "";
return `${key}:${value}:${matchMode}`;
})
.join("|");
// Simple hash function
let hash = 0;
for (let i = 0; i < filterString.length; i++) {
const char = filterString.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
},
getCachedPage(tableName, page, pageSize, sortField, sortOrder, filters) {
if (!this.pageCache[tableName]) return null;
const cacheKey = this.generateCacheKey(page, pageSize, sortField, sortOrder, filters);
const cachedEntry = this.pageCache[tableName][cacheKey];
if (!cachedEntry) return null;
// Check if cache entry is still valid (not expired)
const now = Date.now();
if (now - cachedEntry.timestamp > this.cacheTimeout) {
// Cache expired, remove it
delete this.pageCache[tableName][cacheKey];
return null;
}
console.log(`Cache HIT for ${tableName} page ${page + 1}`);
return cachedEntry.data;
},
setCachedPage(tableName, page, pageSize, sortField, sortOrder, filters, data) {
if (!this.pageCache[tableName]) {
this.pageCache[tableName] = {};
}
const cacheKey = this.generateCacheKey(page, pageSize, sortField, sortOrder, filters);
// Clean up old cache entries if we're at the limit
const cacheKeys = Object.keys(this.pageCache[tableName]);
if (cacheKeys.length >= this.maxCacheSize) {
// Remove oldest entries (simple FIFO approach)
const sortedEntries = cacheKeys
.map((key) => ({ key, timestamp: this.pageCache[tableName][key].timestamp }))
.sort((a, b) => a.timestamp - b.timestamp);
// Remove oldest 25% of entries
const toRemove = Math.floor(this.maxCacheSize * 0.25);
for (let i = 0; i < toRemove; i++) {
delete this.pageCache[tableName][sortedEntries[i].key];
}
}
this.pageCache[tableName][cacheKey] = {
data: JSON.parse(JSON.stringify(data)), // Deep clone to prevent mutations
timestamp: Date.now(),
};
console.log(
`Cache SET for ${tableName} page ${page + 1}, total cached pages: ${Object.keys(this.pageCache[tableName]).length}`,
);
},
clearTableCache(tableName) {
if (this.pageCache[tableName]) {
delete this.pageCache[tableName];
console.log(`Cache cleared for ${tableName}`);
}
},
clearExpiredCache() {
const now = Date.now();
Object.keys(this.pageCache).forEach((tableName) => {
Object.keys(this.pageCache[tableName]).forEach((cacheKey) => {
if (now - this.pageCache[tableName][cacheKey].timestamp > this.cacheTimeout) {
delete this.pageCache[tableName][cacheKey];
}
});
// If no cache entries left for this table, remove the table key
if (Object.keys(this.pageCache[tableName]).length === 0) {
delete this.pageCache[tableName];
}
});
},
getCacheStats(tableName) {
if (!this.pageCache[tableName]) {
return { totalPages: 0, totalSize: 0 };
}
const pages = Object.keys(this.pageCache[tableName]);
const totalSize = pages.reduce((size, key) => {
return size + JSON.stringify(this.pageCache[tableName][key]).length;
}, 0);
return {
totalPages: pages.length,
totalSize: Math.round(totalSize / 1024) + " KB",
};
},
},
});

View File

@ -0,0 +1,16 @@
import { defineStore } from "pinia";
export const useUserStore = defineStore("user", {
state: () => ({
username: "",
roles: [],
}),
actions: {
check_permission(role) {
if (this.roles.includes(role)) {
return true;
}
return false;
},
},
});

View File

@ -3,18 +3,6 @@
--secondary-background-color: #669084;
}
/* Fix PrimeVue overlay z-index conflicts with Vuetify modals */
/* Vuetify dialogs typically use z-index 2400+, so PrimeVue overlays need to be higher */
.p-component-overlay {
z-index: 2500 !important;
}
.p-select-overlay,
.p-autocomplete-overlay,
.p-dropdown-overlay {
z-index: 2500 !important;
}
.page-turn-button {
border-radius: 5px;
border: none;
@ -55,20 +43,3 @@
justify-content: flex-end;
gap: 5px;
}
/* Vuetify Switch and Checkbox Styling */
.v-switch {
align-self: center;
transform: scale(0.85);
transform-origin: center;
}
.v-switch .v-switch__thumb {
pointer-events: auto !important; /* Make thumb clickable */
}
.v-checkbox {
align-self: center;
transform: scale(0.75);
transform-origin: center;
}

View File

@ -1,97 +0,0 @@
import { Key } from "@iconoir/vue";
class DataUtils {
// static buildClientData(clients) {
// const address = `${client["address"]["address_line_1"] || ""} ${client["address"]["address_line_2"] || ""} ${client["address"]["city"] || ""} ${client["address"]["state"] || ""}`.trim();
// const clientName = `${client["customer"]["customer_name"] || "N/A"} ${address}`;
// return clients.map((client) => [clientName, client.]
// }
static calculateStepProgress(steps) {
if (!steps || steps.length === 0) return "0/0";
const completedSteps = steps.filter((step) => step.status === "completed").length;
return `${completedSteps}/${steps.length}`;
}
static US_STATES = [
"AL",
"AK",
"AZ",
"AR",
"CA",
"CO",
"CT",
"DE",
"FL",
"GA",
"HI",
"ID",
"IL",
"IN",
"IA",
"KS",
"KY",
"LA",
"ME",
"MD",
"MA",
"MI",
"MN",
"MS",
"MO",
"MT",
"NE",
"NV",
"NH",
"NJ",
"NM",
"NY",
"NC",
"ND",
"OH",
"OK",
"OR",
"PA",
"RI",
"SC",
"SD",
"TN",
"TX",
"UT",
"VT",
"VA",
"WA",
"WV",
"WI",
"WY",
];
static calculateFullAddress(address) {
if (!address) return "";
// Build address parts with spaces, except comma between city and state
const addressParts = [];
// Add address lines with spaces
if (address.addressLine1?.trim()) addressParts.push(address.addressLine1.trim());
if (address.addressLine2?.trim()) addressParts.push(address.addressLine2.trim());
// Add city, state with comma, and zip
const cityStateParts = [];
if (address.city?.trim()) cityStateParts.push(address.city.trim());
if (address.state?.trim()) cityStateParts.push(address.state.trim());
// Join city and state with comma, then add to address
if (cityStateParts.length > 0) {
addressParts.push(cityStateParts.join(", "));
}
// Add zip code
if (address.pincode?.trim()) addressParts.push(address.pincode.trim());
// Join all parts with spaces
return addressParts.join(" ").trim();
}
}
export default DataUtils;

View File

@ -1,130 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DataTable Actions Test</title>
</head>
<body>
<h1>DataTable Actions Implementation Test</h1>
<h2>Summary of Changes</h2>
<ul>
<li>✅ Added <code>tableActions</code> prop to DataTable component</li>
<li>✅ Added global actions section above the DataTable</li>
<li>✅ Added bulk actions section when rows are selected</li>
<li>✅ Added actions column for row-specific actions</li>
<li>✅ Implemented action handlers with row data passing</li>
<li>✅ Added multi-selection support for bulk operations</li>
<li>✅ Updated Clients component to use table actions</li>
<li>✅ Updated documentation with action configuration examples</li>
</ul>
<h2>Key Features Implemented</h2>
<h3>Global Actions</h3>
<p>Actions with <code>requiresSelection: false</code> appear above the table:</p>
<pre><code>{
label: "Add Client",
action: () => modalStore.openModal("createClient"),
icon: "pi pi-plus",
style: "primary",
requiresSelection: false
}</code></pre>
<h3>Bulk Actions</h3>
<p>Actions with <code>requiresMultipleSelection: true</code> appear when rows are selected:</p>
<pre><code>{
label: "Export Selected",
action: (selectedRows) => exportData(selectedRows),
icon: "pi pi-download",
style: "success",
requiresMultipleSelection: true
}</code></pre>
<h3>Row Actions</h3>
<p>Actions with <code>requiresSelection: true</code> (or omitted) appear in actions column:</p>
<pre><code>{
label: "View",
action: (rowData) => router.push(`/clients/${rowData.id}`),
icon: "pi pi-eye",
style: "secondary"
}</code></pre>
<h3>Action Handlers</h3>
<p>The DataTable automatically passes appropriate data to different action types:</p>
<pre><code>// Global action handler
const handleGlobalAction = (action) => {
action.action(); // No data passed
};
// Row action handler
const handleRowAction = (action, rowData) => {
action.action(rowData); // Single row data passed
};
// Bulk action handler
const handleBulkAction = (action, selectedRows) => {
action.action(selectedRows); // Array of selected rows passed
};</code></pre>
<h2>Action Types Supported</h2>
<table border="1" style="border-collapse: collapse; width: 100%;">
<tr>
<th>Action Type</th>
<th>Property</th>
<th>Data Received</th>
<th>Display Location</th>
<th>Enabled When</th>
</tr>
<tr>
<td>Global</td>
<td>Default (no special props)</td>
<td>None</td>
<td>Above table</td>
<td>Always</td>
</tr>
<tr>
<td>Single Selection</td>
<td><code>requiresSelection: true</code></td>
<td>Selected row object</td>
<td>Above table</td>
<td>Exactly one row selected</td>
</tr>
<tr>
<td>Row</td>
<td><code>rowAction: true</code></td>
<td>Individual row object</td>
<td>Actions column</td>
<td>Always (per row)</td>
</tr>
<tr>
<td>Bulk</td>
<td><code>requiresMultipleSelection: true</code></td>
<td>Array of selected rows</td>
<td>Above table (when rows selected)</td>
<td>One or more rows selected</td>
</tr>
</table>
<h2>Usage in Components</h2>
<p>Components can now pass table actions to DataTable:</p>
<pre><code>&lt;DataTable
:data="tableData"
:columns="columns"
:tableActions="tableActions"
tableName="clients"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
@lazy-load="handleLazyLoad"
/&gt;</code></pre>
<h2>Browser Compatibility</h2>
<p>✅ Vue 3 Composition API compatible<br>
✅ PrimeVue components integration<br>
✅ Reactive row data passing<br>
✅ Error handling for action execution</p>
</body>
</html>
</style>

View File

@ -8,12 +8,6 @@ export default defineConfig(({ command }) => {
return {
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
base: isDev ? "/" : "/assets/custom_ui/dist/",
build: {
@ -28,13 +22,6 @@ export default defineConfig(({ command }) => {
},
server: {
proxy: {
'/zippopotam': {
target: 'https://api.zippopotam.us',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/zippopotam/, ''),
},
},
port: 5173,
strictPort: true,
},