add notifiaction handling, error handling
This commit is contained in:
parent
ce708f5209
commit
1af288aa62
@ -1,5 +1,5 @@
|
||||
import frappe, json
|
||||
from custom_ui.db_utils import process_query_conditions, build_datatable_response, get_count_or_filters
|
||||
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
|
||||
@ -9,222 +9,257 @@ from custom_ui.db_utils import process_query_conditions, build_datatable_respons
|
||||
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
|
||||
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]]
|
||||
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
|
||||
# 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"]
|
||||
}
|
||||
]
|
||||
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"))
|
||||
}
|
||||
categories.append(category)
|
||||
|
||||
return categories
|
||||
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."""
|
||||
address = frappe.get_doc("Address", client_name)
|
||||
customer_name = address.custom_customer_to_bill if address.custom_customer_to_bill else [link.link_name for link in address.links if link.link_doctype == "Customer"][0] if address.links else None
|
||||
project_names = frappe.db.get_all("Project", fields=["name"], or_filters=[
|
||||
["custom_installation_address", "=", address.address_title],
|
||||
["custom_address", "=", address.address_title]
|
||||
], limit_page_length=100)
|
||||
contacts = []
|
||||
onsite_meetings = []
|
||||
quotations = []
|
||||
sales_orders = []
|
||||
projects = [frappe.get_doc("Project", proj["name"]) for proj in project_names]
|
||||
sales_invoices = []
|
||||
payment_entries = []
|
||||
jobs = []
|
||||
for project in projects:
|
||||
job = []
|
||||
jobs.append(job)
|
||||
customer = frappe.get_doc("Customer", customer_name)
|
||||
# get all associated data as needed
|
||||
return {
|
||||
"address": address,
|
||||
"customer": customer,
|
||||
"contacts": contacts,
|
||||
"jobs": jobs,
|
||||
"sales_invoices": sales_invoices,
|
||||
"payment_entries": payment_entries,
|
||||
"sales_orders": sales_orders,
|
||||
"quotations": quotations,
|
||||
"onsite_meetings": onsite_meetings,
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
address = frappe.get_doc("Address", client_name)
|
||||
customer_name = address.custom_customer_to_bill if address.custom_customer_to_bill else [link.link_name for link in address.links if link.link_doctype == "Customer"][0] if address.links else None
|
||||
if not customer_name:
|
||||
raise Exception(f"No customer linked to address {client_name}. Suggested fix: Ensure the address is linked to a customer via the ERPnext UI.")
|
||||
project_names = frappe.db.get_all("Project", fields=["name"], or_filters=[
|
||||
["custom_installation_address", "=", address.address_title],
|
||||
["custom_address", "=", address.address_title]
|
||||
], limit_page_length=100)
|
||||
# contacts = [] # currently not needed as the customer doctype comes with contacts
|
||||
onsite_meetings = frappe.db.get_all(
|
||||
"On-Site Meeting",
|
||||
fields=["*"],
|
||||
filters={"address": address.address_title}
|
||||
)
|
||||
quotations = frappe.db.get_all(
|
||||
"Quotation",
|
||||
fields=["*"],
|
||||
filters={"custom_installation_address": address.address_title}
|
||||
)
|
||||
sales_orders = []
|
||||
projects = [frappe.get_doc("Project", proj["name"]) for proj in project_names]
|
||||
sales_invoices = []
|
||||
payment_entries = frappe.db.get_all(
|
||||
doctype="Payment Entry",
|
||||
fields=["*"],
|
||||
filters={"party": customer_name})
|
||||
payment_orders = []
|
||||
jobs = []
|
||||
for project in projects:
|
||||
job = []
|
||||
jobs.append(job)
|
||||
customer = frappe.get_doc("Customer", customer_name)
|
||||
# get all associated data as needed
|
||||
return build_success_response({
|
||||
"address": address,
|
||||
"customer": customer,
|
||||
# "contacts": [], # currently not needed as the customer doctype comes with contacts
|
||||
"jobs": jobs,
|
||||
"sales_invoices": sales_invoices,
|
||||
"payment_entries": payment_entries,
|
||||
"sales_orders": sales_orders,
|
||||
"quotations": quotations,
|
||||
"onsite_meetings": onsite_meetings,
|
||||
})
|
||||
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."""
|
||||
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)
|
||||
|
||||
# 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']}"
|
||||
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)
|
||||
|
||||
# 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
|
||||
)
|
||||
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)
|
||||
return build_datatable_response(data=tableRows, count=count, page=page, page_size=page_size)
|
||||
|
||||
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)."""
|
||||
data = json.loads(data)
|
||||
|
||||
# Handle customer creation/update
|
||||
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")
|
||||
try:
|
||||
|
||||
data = json.loads(data)
|
||||
|
||||
# Handle customer creation/update
|
||||
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", customer)
|
||||
|
||||
print("Customer:", customer_doc.as_dict())
|
||||
|
||||
# Check for existing address
|
||||
filters = {
|
||||
"address_title": data.get("address_title"),
|
||||
}
|
||||
existing_address = frappe.db.exists("Address", filters)
|
||||
print("Existing address check:", existing_address)
|
||||
if existing_address:
|
||||
frappe.throw(f"Address already exists for customer {data.get('customer_name')}.", frappe.ValidationError)
|
||||
|
||||
# Create address
|
||||
address_doc = frappe.get_doc({
|
||||
"doctype": "Address",
|
||||
"address_line1": data.get("address_line1"),
|
||||
"city": data.get("city"),
|
||||
"state": data.get("state"),
|
||||
"country": "United States",
|
||||
"address_title": data.get("address_title"),
|
||||
"pincode": data.get("pincode"),
|
||||
"custom_customer_to_bill": customer_doc.name
|
||||
}).insert(ignore_permissions=True)
|
||||
else:
|
||||
customer_doc = frappe.get_doc("Customer", customer)
|
||||
|
||||
print("Customer:", customer_doc.as_dict())
|
||||
|
||||
# Check for existing address
|
||||
filters = {
|
||||
"address_title": data.get("address_title"),
|
||||
}
|
||||
existing_address = frappe.db.exists("Address", filters)
|
||||
print("Existing address check:", existing_address)
|
||||
if existing_address:
|
||||
frappe.throw(f"Address already exists for customer {data.get('customer_name')}.", frappe.ValidationError)
|
||||
|
||||
# Create address
|
||||
address_doc = frappe.get_doc({
|
||||
"doctype": "Address",
|
||||
"address_line1": data.get("address_line1"),
|
||||
"city": data.get("city"),
|
||||
"state": data.get("state"),
|
||||
"country": "United States",
|
||||
"address_title": data.get("address_title"),
|
||||
"pincode": data.get("pincode"),
|
||||
"custom_customer_to_bill": customer_doc.name
|
||||
}).insert(ignore_permissions=True)
|
||||
|
||||
# Link address to customer
|
||||
link = {
|
||||
"link_doctype": "Customer",
|
||||
"link_name": customer_doc.name
|
||||
}
|
||||
address_doc.append("links", link)
|
||||
address_doc.save(ignore_permissions=True)
|
||||
|
||||
# Link address to customer
|
||||
link = {
|
||||
"link_doctype": "Customer",
|
||||
"link_name": customer_doc.name
|
||||
}
|
||||
address_doc.append("links", link)
|
||||
address_doc.save(ignore_permissions=True)
|
||||
|
||||
return {
|
||||
"customer": customer_doc,
|
||||
"address": address_doc,
|
||||
"success": True
|
||||
}
|
||||
return build_success_response({
|
||||
"customer": customer_doc.as_dict(),
|
||||
"address": address_doc.as_dict()
|
||||
})
|
||||
except frappe.ValidationError as ve:
|
||||
return build_error_response(str(ve), 400)
|
||||
except Exception as e:
|
||||
return build_error_response(str(e), 500)
|
||||
34
custom_ui/api/db/customers.py
Normal file
34
custom_ui/api/db/customers.py
Normal file
@ -0,0 +1,34 @@
|
||||
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)
|
||||
@ -29,6 +29,7 @@ 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")
|
||||
|
||||
@ -89,7 +89,7 @@ def process_query_conditions(filters, sortings, page, page_size):
|
||||
return processed_filters, processed_sortings, is_or_filters, page_int, page_size_int
|
||||
|
||||
|
||||
def build_datatable_response(data, count, page, page_size):
|
||||
def build_datatable_dict(data, count, page, page_size):
|
||||
return {
|
||||
"pagination": {
|
||||
"total": count,
|
||||
@ -112,4 +112,17 @@ def get_count_or_filters(doctype, or_filters):
|
||||
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
|
||||
}
|
||||
|
||||
131
frontend/documentation/INTEGRATED_ERROR_STORE.md
Normal file
131
frontend/documentation/INTEGRATED_ERROR_STORE.md
Normal file
@ -0,0 +1,131 @@
|
||||
# 🎉 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!
|
||||
296
frontend/documentation/SIMPLE_API_TOAST.md
Normal file
296
frontend/documentation/SIMPLE_API_TOAST.md
Normal file
@ -0,0 +1,296 @@
|
||||
# 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.
|
||||
357
frontend/documentation/components/NotificationDisplay.md
Normal file
357
frontend/documentation/components/NotificationDisplay.md
Normal file
@ -0,0 +1,357 @@
|
||||
# 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
699
frontend/documentation/stores/errors.md
Normal file
699
frontend/documentation/stores/errors.md
Normal file
@ -0,0 +1,699 @@
|
||||
# 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
|
||||
},
|
||||
);
|
||||
};
|
||||
```
|
||||
609
frontend/documentation/stores/notifications.md
Normal file
609
frontend/documentation/stores/notifications.md
Normal file
@ -0,0 +1,609 @@
|
||||
# Notifications Store
|
||||
|
||||
The notifications store provides centralized state management for application-wide notifications. It handles the creation, management, and lifecycle of toast-style notifications with support for multiple types, positions, and interactive actions.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Location**: `src/stores/notifications.js`
|
||||
- **Type**: Pinia Store
|
||||
- **Purpose**: Global notification state management
|
||||
- **Integration**: Used by NotificationDisplay component
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
```javascript
|
||||
// Import in your component
|
||||
import { useNotificationStore } from "@/stores/notifications";
|
||||
|
||||
// Use in component
|
||||
const notificationStore = useNotificationStore();
|
||||
```
|
||||
|
||||
## State Structure
|
||||
|
||||
### Core State Properties
|
||||
|
||||
```javascript
|
||||
state: {
|
||||
notifications: [], // Array of active notifications
|
||||
defaultDuration: 4000, // Default auto-dismiss time (ms)
|
||||
maxNotifications: 5, // Maximum concurrent notifications
|
||||
position: 'top-right', // Default position
|
||||
nextId: 1 // Auto-incrementing ID counter
|
||||
}
|
||||
```
|
||||
|
||||
### Notification Object Structure
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 1, // Unique identifier
|
||||
type: 'success', // 'success' | 'error' | 'warning' | 'info'
|
||||
title: 'Operation Complete', // Notification title
|
||||
message: 'Data saved successfully', // Main message
|
||||
duration: 4000, // Auto-dismiss time (0 = no auto-dismiss)
|
||||
persistent: false, // If true, won't auto-dismiss
|
||||
actions: [], // Array of action buttons
|
||||
data: null, // Additional data for handlers
|
||||
timestamp: '2025-11-12T10:30:00Z', // Creation timestamp
|
||||
dismissed: false, // Whether notification is dismissed
|
||||
seen: false // Whether user has interacted with it
|
||||
}
|
||||
```
|
||||
|
||||
## Getters
|
||||
|
||||
### `getNotificationsByType(type)`
|
||||
|
||||
Get all notifications of a specific type.
|
||||
|
||||
```javascript
|
||||
const errorNotifications = notificationStore.getNotificationsByType("error");
|
||||
```
|
||||
|
||||
### `activeNotifications`
|
||||
|
||||
Get all non-dismissed notifications.
|
||||
|
||||
```javascript
|
||||
const active = notificationStore.activeNotifications;
|
||||
```
|
||||
|
||||
### `activeCount`
|
||||
|
||||
Get count of active notifications.
|
||||
|
||||
```javascript
|
||||
const count = notificationStore.activeCount;
|
||||
```
|
||||
|
||||
### `hasErrorNotifications`
|
||||
|
||||
Check if there are any active error notifications.
|
||||
|
||||
```javascript
|
||||
const hasErrors = notificationStore.hasErrorNotifications;
|
||||
```
|
||||
|
||||
### `hasSuccessNotifications`
|
||||
|
||||
Check if there are any active success notifications.
|
||||
|
||||
```javascript
|
||||
const hasSuccess = notificationStore.hasSuccessNotifications;
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
### Core Notification Methods
|
||||
|
||||
#### `addNotification(notification)`
|
||||
|
||||
Add a new notification with full configuration options.
|
||||
|
||||
```javascript
|
||||
const notificationId = notificationStore.addNotification({
|
||||
type: "warning",
|
||||
title: "Confirm Action",
|
||||
message: "This will permanently delete the item.",
|
||||
persistent: true,
|
||||
actions: [
|
||||
{
|
||||
label: "Delete",
|
||||
variant: "danger",
|
||||
handler: () => performDelete(),
|
||||
},
|
||||
{
|
||||
label: "Cancel",
|
||||
variant: "secondary",
|
||||
},
|
||||
],
|
||||
data: { itemId: 123 },
|
||||
});
|
||||
```
|
||||
|
||||
#### Convenience Methods
|
||||
|
||||
```javascript
|
||||
// Quick success notification
|
||||
notificationStore.addSuccess("Operation completed!");
|
||||
notificationStore.addSuccess("Custom message", "Custom Title", {
|
||||
duration: 6000,
|
||||
});
|
||||
|
||||
// Quick error notification
|
||||
notificationStore.addError("Something went wrong!");
|
||||
notificationStore.addError("Custom error", "Error Title", { persistent: true });
|
||||
|
||||
// Quick warning notification
|
||||
notificationStore.addWarning("Please confirm this action");
|
||||
|
||||
// Quick info notification
|
||||
notificationStore.addInfo("New feature available");
|
||||
```
|
||||
|
||||
### Notification Management
|
||||
|
||||
#### `dismissNotification(id)`
|
||||
|
||||
Mark a notification as dismissed (hides it but keeps in history).
|
||||
|
||||
```javascript
|
||||
notificationStore.dismissNotification(notificationId);
|
||||
```
|
||||
|
||||
#### `removeNotification(id)`
|
||||
|
||||
Completely remove a notification from the store.
|
||||
|
||||
```javascript
|
||||
notificationStore.removeNotification(notificationId);
|
||||
```
|
||||
|
||||
#### `markAsSeen(id)`
|
||||
|
||||
Mark a notification as seen (user has interacted with it).
|
||||
|
||||
```javascript
|
||||
notificationStore.markAsSeen(notificationId);
|
||||
```
|
||||
|
||||
#### `updateNotification(id, updates)`
|
||||
|
||||
Update an existing notification's properties.
|
||||
|
||||
```javascript
|
||||
notificationStore.updateNotification(notificationId, {
|
||||
type: "success",
|
||||
message: "Updated message",
|
||||
persistent: false,
|
||||
});
|
||||
```
|
||||
|
||||
### Bulk Operations
|
||||
|
||||
#### `clearType(type)`
|
||||
|
||||
Remove all notifications of a specific type.
|
||||
|
||||
```javascript
|
||||
notificationStore.clearType("error"); // Remove all error notifications
|
||||
```
|
||||
|
||||
#### `clearAll()`
|
||||
|
||||
Remove all notifications.
|
||||
|
||||
```javascript
|
||||
notificationStore.clearAll();
|
||||
```
|
||||
|
||||
#### `clearDismissed()`
|
||||
|
||||
Remove all dismissed notifications from history.
|
||||
|
||||
```javascript
|
||||
notificationStore.clearDismissed();
|
||||
```
|
||||
|
||||
### Loading Notifications
|
||||
|
||||
#### `showLoadingNotification(message, title)`
|
||||
|
||||
Show a persistent loading notification that can be updated later.
|
||||
|
||||
```javascript
|
||||
const loadingId = notificationStore.showLoadingNotification(
|
||||
"Uploading file...",
|
||||
"Please Wait",
|
||||
);
|
||||
```
|
||||
|
||||
#### `updateToSuccess(id, message, title)`
|
||||
|
||||
Update a loading notification to success and auto-dismiss.
|
||||
|
||||
```javascript
|
||||
notificationStore.updateToSuccess(
|
||||
loadingId,
|
||||
"File uploaded successfully!",
|
||||
"Upload Complete",
|
||||
);
|
||||
```
|
||||
|
||||
#### `updateToError(id, message, title)`
|
||||
|
||||
Update a loading notification to error and auto-dismiss.
|
||||
|
||||
```javascript
|
||||
notificationStore.updateToError(
|
||||
loadingId,
|
||||
"Upload failed. Please try again.",
|
||||
"Upload Failed",
|
||||
);
|
||||
```
|
||||
|
||||
### API Integration Helpers
|
||||
|
||||
#### `showApiSuccess(operation, customMessage)`
|
||||
|
||||
Show standardized success notifications for API operations.
|
||||
|
||||
```javascript
|
||||
// Uses default messages based on operation
|
||||
notificationStore.showApiSuccess("create"); // "Item created successfully"
|
||||
notificationStore.showApiSuccess("update"); // "Item updated successfully"
|
||||
notificationStore.showApiSuccess("delete"); // "Item deleted successfully"
|
||||
notificationStore.showApiSuccess("fetch"); // "Data loaded successfully"
|
||||
|
||||
// With custom message
|
||||
notificationStore.showApiSuccess("create", "New client added successfully!");
|
||||
```
|
||||
|
||||
#### `showApiError(operation, error, customMessage)`
|
||||
|
||||
Show standardized error notifications for API operations.
|
||||
|
||||
```javascript
|
||||
// Automatically extracts error message from different error formats
|
||||
notificationStore.showApiError("create", apiError);
|
||||
notificationStore.showApiError("update", "Network timeout occurred");
|
||||
notificationStore.showApiError("delete", errorObject, "Custom error message");
|
||||
```
|
||||
|
||||
### Configuration Methods
|
||||
|
||||
#### `setPosition(position)`
|
||||
|
||||
Set the global notification position.
|
||||
|
||||
```javascript
|
||||
// Available positions:
|
||||
notificationStore.setPosition("top-right"); // Default
|
||||
notificationStore.setPosition("top-left");
|
||||
notificationStore.setPosition("top-center");
|
||||
notificationStore.setPosition("bottom-right");
|
||||
notificationStore.setPosition("bottom-left");
|
||||
notificationStore.setPosition("bottom-center");
|
||||
```
|
||||
|
||||
#### `setDefaultDuration(duration)`
|
||||
|
||||
Set the default auto-dismiss duration (milliseconds).
|
||||
|
||||
```javascript
|
||||
notificationStore.setDefaultDuration(5000); // 5 seconds
|
||||
```
|
||||
|
||||
#### `setMaxNotifications(max)`
|
||||
|
||||
Set maximum number of concurrent notifications.
|
||||
|
||||
```javascript
|
||||
notificationStore.setMaxNotifications(3);
|
||||
```
|
||||
|
||||
### Advanced Workflow Helper
|
||||
|
||||
#### `withNotifications(operation, asyncFunction, options)`
|
||||
|
||||
Wrap an async operation with automatic loading/success/error notifications.
|
||||
|
||||
```javascript
|
||||
const result = await notificationStore.withNotifications(
|
||||
"save",
|
||||
async () => {
|
||||
return await saveDataToApi();
|
||||
},
|
||||
{
|
||||
loadingMessage: "Saving changes...",
|
||||
successMessage: "Changes saved successfully!",
|
||||
errorMessage: null, // Use default error handling
|
||||
showLoading: true,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Action Button Configuration
|
||||
|
||||
### Action Object Structure
|
||||
|
||||
```javascript
|
||||
{
|
||||
label: 'Button Text', // Required: Button label
|
||||
variant: 'primary', // Optional: 'primary' | 'danger' | 'secondary'
|
||||
icon: 'mdi mdi-check', // Optional: Icon class
|
||||
handler: (notification) => {}, // Optional: Click handler function
|
||||
dismissAfter: true // Optional: Auto-dismiss after click (default: true)
|
||||
}
|
||||
```
|
||||
|
||||
### Action Examples
|
||||
|
||||
```javascript
|
||||
notificationStore.addNotification({
|
||||
type: "warning",
|
||||
title: "Unsaved Changes",
|
||||
message: "You have unsaved changes. What would you like to do?",
|
||||
persistent: true,
|
||||
actions: [
|
||||
{
|
||||
label: "Save",
|
||||
variant: "primary",
|
||||
icon: "mdi mdi-content-save",
|
||||
handler: (notification) => {
|
||||
saveChanges();
|
||||
// Access notification data if needed
|
||||
console.log("Saving from notification:", notification.data);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Discard",
|
||||
variant: "danger",
|
||||
icon: "mdi mdi-delete",
|
||||
handler: () => {
|
||||
discardChanges();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Keep Editing",
|
||||
variant: "secondary",
|
||||
dismissAfter: false, // Don't dismiss, let user continue editing
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Basic Notifications
|
||||
|
||||
```javascript
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
// Simple success
|
||||
notificationStore.addSuccess("Data saved successfully!");
|
||||
|
||||
// Simple error
|
||||
notificationStore.addError("Failed to connect to server");
|
||||
|
||||
// Custom duration
|
||||
notificationStore.addWarning("Session expires in 5 minutes", "Warning", {
|
||||
duration: 8000,
|
||||
});
|
||||
```
|
||||
|
||||
### API Operation Notifications
|
||||
|
||||
```javascript
|
||||
// Method 1: Manual handling
|
||||
try {
|
||||
await apiCall();
|
||||
notificationStore.addSuccess("Operation completed successfully!");
|
||||
} catch (error) {
|
||||
notificationStore.addError(error.message, "Operation Failed");
|
||||
}
|
||||
|
||||
// Method 2: Using API helpers
|
||||
try {
|
||||
await apiCall();
|
||||
notificationStore.showApiSuccess("create");
|
||||
} catch (error) {
|
||||
notificationStore.showApiError("create", error);
|
||||
}
|
||||
|
||||
// Method 3: Using workflow helper
|
||||
await notificationStore.withNotifications("create", async () => {
|
||||
return await apiCall();
|
||||
});
|
||||
```
|
||||
|
||||
### Loading States
|
||||
|
||||
```javascript
|
||||
// Show loading notification
|
||||
const loadingId = notificationStore.showLoadingNotification("Processing...");
|
||||
|
||||
try {
|
||||
const result = await longRunningOperation();
|
||||
notificationStore.updateToSuccess(loadingId, "Operation completed!");
|
||||
} catch (error) {
|
||||
notificationStore.updateToError(loadingId, "Operation failed");
|
||||
}
|
||||
```
|
||||
|
||||
### Interactive Notifications
|
||||
|
||||
```javascript
|
||||
// Confirmation dialog
|
||||
notificationStore.addNotification({
|
||||
type: "warning",
|
||||
title: "Delete Confirmation",
|
||||
message: `Are you sure you want to delete "${itemName}"?`,
|
||||
persistent: true,
|
||||
actions: [
|
||||
{
|
||||
label: "Yes, Delete",
|
||||
variant: "danger",
|
||||
handler: async () => {
|
||||
const deleteId =
|
||||
notificationStore.showLoadingNotification("Deleting...");
|
||||
try {
|
||||
await deleteItem(itemId);
|
||||
notificationStore.updateToSuccess(
|
||||
deleteId,
|
||||
"Item deleted successfully",
|
||||
);
|
||||
refreshData();
|
||||
} catch (error) {
|
||||
notificationStore.updateToError(deleteId, "Failed to delete item");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Cancel",
|
||||
variant: "secondary",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Multi-step workflow
|
||||
notificationStore.addNotification({
|
||||
type: "info",
|
||||
title: "Export Complete",
|
||||
message: "Your data has been exported successfully.",
|
||||
actions: [
|
||||
{
|
||||
label: "Download",
|
||||
variant: "primary",
|
||||
icon: "mdi mdi-download",
|
||||
handler: () => downloadFile(),
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
variant: "secondary",
|
||||
icon: "mdi mdi-email",
|
||||
handler: () => emailFile(),
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
variant: "secondary",
|
||||
icon: "mdi mdi-eye",
|
||||
handler: () => viewFile(),
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Integration with Vue Components
|
||||
|
||||
### In Composition API
|
||||
|
||||
```javascript
|
||||
import { useNotificationStore } from "@/stores/notifications";
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await submitForm();
|
||||
notificationStore.addSuccess("Form submitted successfully!");
|
||||
} catch (error) {
|
||||
notificationStore.addError("Failed to submit form", "Submission Error");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### In Options API
|
||||
|
||||
```javascript
|
||||
import { mapActions, mapGetters } from "pinia";
|
||||
import { useNotificationStore } from "@/stores/notifications";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters(useNotificationStore, [
|
||||
"activeCount",
|
||||
"hasErrorNotifications",
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useNotificationStore, ["addSuccess", "addError", "clearAll"]),
|
||||
|
||||
async saveData() {
|
||||
try {
|
||||
await this.apiCall();
|
||||
this.addSuccess("Data saved!");
|
||||
} catch (error) {
|
||||
this.addError(error.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's
|
||||
|
||||
- ✅ Use appropriate notification types for different scenarios
|
||||
- ✅ Keep messages concise and actionable
|
||||
- ✅ Use loading notifications for long-running operations
|
||||
- ✅ Provide clear action buttons for next steps
|
||||
- ✅ Set appropriate durations (longer for errors, shorter for success)
|
||||
- ✅ Use persistent notifications for critical actions requiring user input
|
||||
|
||||
### Don'ts
|
||||
|
||||
- ❌ Don't overwhelm users with too many notifications
|
||||
- ❌ Don't use persistent notifications for simple confirmations
|
||||
- ❌ Don't make notification messages too long
|
||||
- ❌ Don't forget to handle loading state cleanup
|
||||
- ❌ Don't use success notifications for every small action
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- The store automatically limits concurrent notifications
|
||||
- Dismissed notifications are kept in history for debugging
|
||||
- Use `clearDismissed()` periodically to prevent memory leaks
|
||||
- Action handlers should be lightweight to avoid blocking the UI
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Testing
|
||||
|
||||
```javascript
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { useNotificationStore } from "@/stores/notifications";
|
||||
|
||||
describe("Notifications Store", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("adds notifications correctly", () => {
|
||||
const store = useNotificationStore();
|
||||
const id = store.addSuccess("Test message");
|
||||
|
||||
expect(store.activeNotifications).toHaveLength(1);
|
||||
expect(store.activeNotifications[0].message).toBe("Test message");
|
||||
expect(store.activeNotifications[0].type).toBe("success");
|
||||
});
|
||||
|
||||
it("dismisses notifications", () => {
|
||||
const store = useNotificationStore();
|
||||
const id = store.addInfo("Test");
|
||||
|
||||
store.dismissNotification(id);
|
||||
expect(store.activeNotifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
@ -1,4 +1,5 @@
|
||||
<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";
|
||||
@ -8,6 +9,19 @@ 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>
|
||||
@ -37,6 +51,9 @@ import ScrollPanel from "primevue/scrollpanel";
|
||||
|
||||
<!-- Global Loading Overlay -->
|
||||
<GlobalLoadingOverlay />
|
||||
|
||||
<!-- Global Notifications -->
|
||||
<Toast ref="toast" />
|
||||
</IconoirProvider>
|
||||
</template>
|
||||
|
||||
|
||||
265
frontend/src/api-enhanced.js
Normal file
265
frontend/src/api-enhanced.js
Normal file
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 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;
|
||||
241
frontend/src/api-toast.js
Normal file
241
frontend/src/api-toast.js
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -3,12 +3,14 @@ import ApiUtils from "./apiUtils";
|
||||
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
||||
const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request";
|
||||
const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate";
|
||||
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.get_jobs";
|
||||
const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job";
|
||||
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
|
||||
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 {
|
||||
static async request(frappeMethod, args = {}) {
|
||||
@ -19,10 +21,12 @@ class Api {
|
||||
let response = await frappe.call(request);
|
||||
response = ApiUtils.toCamelCaseObject(response);
|
||||
console.log("DEBUG: API - Request Response: ", response);
|
||||
return response.message;
|
||||
if (response.message.status && response.message.status === "error") {
|
||||
throw new Error(response.message.message);
|
||||
}
|
||||
return response.message.data;
|
||||
} catch (error) {
|
||||
console.error("DEBUG: API - Request Error: ", error);
|
||||
// Re-throw the error so calling code can handle it
|
||||
console.error("ERROR: API - Request Error: ", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -145,7 +149,7 @@ class Api {
|
||||
|
||||
console.log("DEBUG: API - Sending job options to backend:", options);
|
||||
|
||||
const result = await this.request("custom_ui.api.db.get_jobs", { options });
|
||||
const result = await this.request(FRAPPE_GET_JOBS_METHOD, { options });
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -238,11 +242,8 @@ class Api {
|
||||
return doc;
|
||||
}
|
||||
|
||||
static async getCustomerNames() {
|
||||
const customers = await this.getDocsList("Customer", ["name"]);
|
||||
const customerNames = customers.map((customer) => customer.name);
|
||||
console.log("DEBUG: API - Fetched Customer Names: ", customerNames);
|
||||
return customerNames;
|
||||
static async getCustomerNames(type) {
|
||||
return await this.request(FRAPPE_GET_CLIENT_NAMES_METHOD, { type });
|
||||
}
|
||||
|
||||
static async getCompanyNames() {
|
||||
|
||||
@ -11,24 +11,21 @@ import {
|
||||
PathArrowSolid,
|
||||
Clock,
|
||||
HistoricShield,
|
||||
Developer,
|
||||
} from "@iconoir/vue";
|
||||
import SpeedDial from "primevue/speeddial";
|
||||
|
||||
const router = useRouter();
|
||||
const modalStore = useModalStore();
|
||||
const categories = [
|
||||
{ name: "Home", icon: Home, url: "/" },
|
||||
{ name: "Calendar", icon: Calendar, url: "/calendar" },
|
||||
{ name: "Clients", icon: Community, url: "/clients" },
|
||||
{ name: "Jobs", icon: Hammer, url: "/jobs" },
|
||||
{ name: "Routes", icon: PathArrowSolid, url: "/routes" },
|
||||
{ name: "Time Sheets", icon: Clock, url: "/timesheets" },
|
||||
{ name: "Warranties", icon: HistoricShield, url: "/warranties" },
|
||||
|
||||
const developmentButtons = ref([
|
||||
{
|
||||
name: "Create New",
|
||||
icon: MultiplePagesPlus,
|
||||
label: "Error Handling Demo",
|
||||
command: () => {
|
||||
router.push("/dev/error-handling-demo");
|
||||
},
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const createButtons = ref([
|
||||
{
|
||||
@ -70,6 +67,22 @@ const createButtons = ref([
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const categories = ref([
|
||||
{ name: "Home", icon: Home, url: "/" },
|
||||
{ name: "Calendar", icon: Calendar, url: "/calendar" },
|
||||
{ name: "Clients", icon: Community, url: "/clients" },
|
||||
{ name: "Jobs", icon: Hammer, url: "/jobs" },
|
||||
{ name: "Routes", icon: PathArrowSolid, url: "/routes" },
|
||||
{ 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);
|
||||
};
|
||||
@ -94,7 +107,7 @@ const handleCategoryClick = (category) => {
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SpeedDial :model="createButtons" direction="down" type="linear" radius="50">
|
||||
<SpeedDial :model="category.buttons" direction="down" type="linear" radius="50">
|
||||
<template #button="{ toggleCallback }">
|
||||
<button
|
||||
class="sidebar-button"
|
||||
|
||||
438
frontend/src/components/common/NotificationDisplay.vue
Normal file
438
frontend/src/components/common/NotificationDisplay.vue
Normal file
@ -0,0 +1,438 @@
|
||||
<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>
|
||||
@ -35,20 +35,44 @@ import Tab from "primevue/tab";
|
||||
import TabPanels from "primevue/tabpanels";
|
||||
import TabPanel from "primevue/tabpanel";
|
||||
import Api from "../../api";
|
||||
import ApiWithToast from "../../api-toast";
|
||||
import { useLoadingStore } from "../../stores/loading";
|
||||
import { useErrorStore } from "../../stores/errors";
|
||||
|
||||
const loadingStore = useLoadingStore();
|
||||
const errorStore = useErrorStore();
|
||||
const clientNames = ref([]);
|
||||
const client = ref({});
|
||||
const { clientName } = defineProps({
|
||||
clientName: { type: String, required: true },
|
||||
});
|
||||
|
||||
const getClientNames = async (type) => {
|
||||
loadingStore.setLoading(true);
|
||||
try {
|
||||
const names = await Api.getCustomerNames(type);
|
||||
clientNames.value = names;
|
||||
} catch (error) {
|
||||
errorStore.addError(error.message || "Error fetching client names");
|
||||
} finally {
|
||||
loadingStore.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getClient = async (name) => {
|
||||
loadingStore.setLoading(true);
|
||||
const clientData = await ApiWithToast.makeApiCall(() => Api.getClient(name));
|
||||
client.value = clientData || {};
|
||||
loadingStore.setLoading(false);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (clientName === "new") {
|
||||
// Logic for creating a new client
|
||||
console.log("Creating a new client");
|
||||
} else {
|
||||
// Logic for fetching and displaying existing client data
|
||||
const clientData = await Api.getClient(clientName);
|
||||
client.value = clientData;
|
||||
await getClient(clientName);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
664
frontend/src/components/pages/ErrorHandlingDemo.vue
Normal file
664
frontend/src/components/pages/ErrorHandlingDemo.vue
Normal file
@ -0,0 +1,664 @@
|
||||
<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>
|
||||
@ -10,6 +10,7 @@ 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";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@ -25,6 +26,8 @@ const routes = [
|
||||
{ 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({
|
||||
|
||||
341
frontend/src/stores/errors.js
Normal file
341
frontend/src/stores/errors.js
Normal file
@ -0,0 +1,341 @@
|
||||
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(" ");
|
||||
},
|
||||
},
|
||||
});
|
||||
186
frontend/src/stores/notifications-primevue.js
Normal file
186
frontend/src/stores/notifications-primevue.js
Normal file
@ -0,0 +1,186 @@
|
||||
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";
|
||||
},
|
||||
},
|
||||
});
|
||||
272
frontend/src/stores/notifications.js
Normal file
272
frontend/src/stores/notifications.js
Normal file
@ -0,0 +1,272 @@
|
||||
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: {
|
||||
// Add a new notification
|
||||
addNotification(notification) {
|
||||
const newNotification = {
|
||||
id: this.nextId++,
|
||||
type: notification.type || "info", // info, success, warning, error
|
||||
title: notification.title || "",
|
||||
message: notification.message || "",
|
||||
duration: notification.duration ?? this.defaultDuration,
|
||||
persistent: notification.persistent || false, // If true, won't auto-dismiss
|
||||
actions: notification.actions || [], // Array of action buttons
|
||||
data: notification.data || null, // Any additional data
|
||||
timestamp: new Date().toISOString(),
|
||||
dismissed: false,
|
||||
seen: false,
|
||||
};
|
||||
|
||||
// Add to beginning of array (newest first)
|
||||
this.notifications.unshift(newNotification);
|
||||
|
||||
// Trim notifications if we exceed max count
|
||||
if (this.notifications.length > this.maxNotifications * 2) {
|
||||
// Keep twice the max to maintain some history
|
||||
this.notifications = this.notifications.slice(0, this.maxNotifications * 2);
|
||||
}
|
||||
|
||||
// Auto-dismiss if not persistent
|
||||
if (!newNotification.persistent && newNotification.duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.dismissNotification(newNotification.id);
|
||||
}, newNotification.duration);
|
||||
}
|
||||
|
||||
return newNotification.id;
|
||||
},
|
||||
|
||||
// Convenience methods for different types of notifications
|
||||
addSuccess(message, title = "Success", options = {}) {
|
||||
return this.addNotification({
|
||||
type: "success",
|
||||
title,
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
addError(message, title = "Error", options = {}) {
|
||||
return this.addNotification({
|
||||
type: "error",
|
||||
title,
|
||||
message,
|
||||
duration: options.duration ?? 6000, // Errors stay longer by default
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
addWarning(message, title = "Warning", options = {}) {
|
||||
return this.addNotification({
|
||||
type: "warning",
|
||||
title,
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
addInfo(message, title = "Info", options = {}) {
|
||||
return this.addNotification({
|
||||
type: "info",
|
||||
title,
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
// Dismiss a specific notification
|
||||
dismissNotification(id) {
|
||||
const notification = this.notifications.find((n) => n.id === id);
|
||||
if (notification) {
|
||||
notification.dismissed = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Remove a notification completely
|
||||
removeNotification(id) {
|
||||
const index = this.notifications.findIndex((n) => n.id === id);
|
||||
if (index !== -1) {
|
||||
this.notifications.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
// Mark notification as seen
|
||||
markAsSeen(id) {
|
||||
const notification = this.notifications.find((n) => n.id === id);
|
||||
if (notification) {
|
||||
notification.seen = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear all notifications of a specific type
|
||||
clearType(type) {
|
||||
this.notifications = this.notifications.filter((n) => n.type !== type);
|
||||
},
|
||||
|
||||
// Clear all notifications
|
||||
clearAll() {
|
||||
this.notifications = [];
|
||||
},
|
||||
|
||||
// Clear all dismissed notifications
|
||||
clearDismissed() {
|
||||
this.notifications = this.notifications.filter((n) => !n.dismissed);
|
||||
},
|
||||
|
||||
// Update notification content
|
||||
updateNotification(id, updates) {
|
||||
const notification = this.notifications.find((n) => n.id === id);
|
||||
if (notification) {
|
||||
Object.assign(notification, updates);
|
||||
}
|
||||
},
|
||||
|
||||
// Show a loading notification that can be updated
|
||||
showLoadingNotification(message, title = "Loading...") {
|
||||
return this.addNotification({
|
||||
type: "info",
|
||||
title,
|
||||
message,
|
||||
persistent: true,
|
||||
actions: [],
|
||||
});
|
||||
},
|
||||
|
||||
// Update a loading notification to success
|
||||
updateToSuccess(id, message, title = "Success") {
|
||||
this.updateNotification(id, {
|
||||
type: "success",
|
||||
title,
|
||||
message,
|
||||
persistent: false,
|
||||
duration: this.defaultDuration,
|
||||
});
|
||||
|
||||
// Auto-dismiss after updating
|
||||
setTimeout(() => {
|
||||
this.dismissNotification(id);
|
||||
}, this.defaultDuration);
|
||||
},
|
||||
|
||||
// Update a loading notification to error
|
||||
updateToError(id, message, title = "Error") {
|
||||
this.updateNotification(id, {
|
||||
type: "error",
|
||||
title,
|
||||
message,
|
||||
persistent: false,
|
||||
duration: 6000,
|
||||
});
|
||||
|
||||
// Auto-dismiss after updating
|
||||
setTimeout(() => {
|
||||
this.dismissNotification(id);
|
||||
}, 6000);
|
||||
},
|
||||
|
||||
// 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",
|
||||
};
|
||||
|
||||
return this.addSuccess(
|
||||
message || defaultMessages[operation] || "Operation completed successfully",
|
||||
"Success",
|
||||
);
|
||||
},
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
return this.addError(errorMessage, "Operation Failed");
|
||||
},
|
||||
|
||||
// Configuration methods
|
||||
setPosition(position) {
|
||||
this.position = position;
|
||||
},
|
||||
|
||||
setDefaultDuration(duration) {
|
||||
this.defaultDuration = duration;
|
||||
},
|
||||
|
||||
setMaxNotifications(max) {
|
||||
this.maxNotifications = max;
|
||||
},
|
||||
|
||||
// Utility method for handling async operations with notifications
|
||||
async withNotifications(operation, asyncFunction, options = {}) {
|
||||
const {
|
||||
loadingMessage = "Processing...",
|
||||
successMessage = null,
|
||||
errorMessage = null,
|
||||
showLoading = true,
|
||||
} = options;
|
||||
|
||||
let loadingId = null;
|
||||
|
||||
try {
|
||||
if (showLoading) {
|
||||
loadingId = this.showLoadingNotification(loadingMessage);
|
||||
}
|
||||
|
||||
const result = await asyncFunction();
|
||||
|
||||
if (loadingId) {
|
||||
this.updateToSuccess(
|
||||
loadingId,
|
||||
successMessage || `${operation} completed successfully`,
|
||||
);
|
||||
} else if (successMessage !== false) {
|
||||
this.showApiSuccess(operation, successMessage);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (loadingId) {
|
||||
this.updateToError(loadingId, errorMessage || error.message);
|
||||
} else {
|
||||
this.showApiError(operation, error, errorMessage);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user