big updates
This commit is contained in:
parent
34f2c110d6
commit
03a230b8f7
28
custom_ui/api/db/addresses.py
Normal file
28
custom_ui/api/db/addresses.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import frappe
|
||||||
|
from custom_ui.db_utils import build_error_response, build_success_response
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_addresses(fields=["*"], filters={}):
|
||||||
|
"""Get addresses with optional filtering."""
|
||||||
|
if isinstance(fields, str):
|
||||||
|
import json
|
||||||
|
fields = json.loads(fields)
|
||||||
|
if isinstance(filters, str):
|
||||||
|
import json
|
||||||
|
filters = json.loads(filters)
|
||||||
|
if fields[0] != "*" and len(fields) == 1:
|
||||||
|
pluck = fields[0]
|
||||||
|
fields = None
|
||||||
|
print(f"Getting addresses with fields: {fields} and filters: {filters} and pluck: {pluck}")
|
||||||
|
try:
|
||||||
|
addresses = frappe.get_all(
|
||||||
|
"Address",
|
||||||
|
fields=fields,
|
||||||
|
filters=filters,
|
||||||
|
order_by="address_line1 desc",
|
||||||
|
pluck=pluck
|
||||||
|
)
|
||||||
|
return build_success_response(addresses)
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(message=str(e), title="Get Addresses Failed")
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
@ -269,7 +269,9 @@ def upsert_client(data):
|
|||||||
|
|
||||||
# Check for existing address
|
# Check for existing address
|
||||||
filters = {
|
filters = {
|
||||||
"address_title": data.get("address_title"),
|
"address_line1": data.get("address_line1"),
|
||||||
|
"city": data.get("city"),
|
||||||
|
"state": data.get("state"),
|
||||||
}
|
}
|
||||||
existing_address = frappe.db.exists("Address", filters)
|
existing_address = frappe.db.exists("Address", filters)
|
||||||
print("Existing address check:", existing_address)
|
print("Existing address check:", existing_address)
|
||||||
@ -280,10 +282,10 @@ def upsert_client(data):
|
|||||||
address_doc = frappe.get_doc({
|
address_doc = frappe.get_doc({
|
||||||
"doctype": "Address",
|
"doctype": "Address",
|
||||||
"address_line1": data.get("address_line1"),
|
"address_line1": data.get("address_line1"),
|
||||||
|
"address_line2": data.get("address_line2"),
|
||||||
"city": data.get("city"),
|
"city": data.get("city"),
|
||||||
"state": data.get("state"),
|
"state": data.get("state"),
|
||||||
"country": "United States",
|
"country": "United States",
|
||||||
"address_title": data.get("address_title"),
|
|
||||||
"pincode": data.get("pincode"),
|
"pincode": data.get("pincode"),
|
||||||
"custom_customer_to_bill": customer_doc.name
|
"custom_customer_to_bill": customer_doc.name
|
||||||
}).insert(ignore_permissions=True)
|
}).insert(ignore_permissions=True)
|
||||||
|
|||||||
130
custom_ui/api/db/onsite_meetings.py
Normal file
130
custom_ui/api/db/onsite_meetings.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import frappe
|
||||||
|
import json
|
||||||
|
from custom_ui.db_utils import build_error_response, build_success_response, process_filters, process_sorting
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_week_onsite_meetings(week_start, week_end):
|
||||||
|
"""Get On-Site Meetings scheduled within a specific week."""
|
||||||
|
try:
|
||||||
|
meetings = frappe.db.get_all(
|
||||||
|
"On-Site Meeting",
|
||||||
|
fields=["*"],
|
||||||
|
filters=[
|
||||||
|
["start_time", ">=", week_start],
|
||||||
|
["start_time", "<=", week_end]
|
||||||
|
],
|
||||||
|
order_by="start_time asc"
|
||||||
|
)
|
||||||
|
return build_success_response(meetings)
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed")
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_onsite_meetings(fields=["*"], filters={}):
|
||||||
|
"""Get paginated On-Site Meetings with filtering and sorting support."""
|
||||||
|
try:
|
||||||
|
print("DEBUG: Raw onsite meeting options received:", filters)
|
||||||
|
|
||||||
|
processed_filters = process_filters(filters)
|
||||||
|
|
||||||
|
meetings = frappe.db.get_all(
|
||||||
|
"On-Site Meeting",
|
||||||
|
fields=fields,
|
||||||
|
filters=processed_filters,
|
||||||
|
order_by="creation desc"
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_success_response(
|
||||||
|
meetings
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(message=str(e), title="Get On-Site Meetings Failed")
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_unscheduled_onsite_meetings():
|
||||||
|
"""Get On-Site Meetings that are unscheduled."""
|
||||||
|
try:
|
||||||
|
meetings = frappe.db.get_all(
|
||||||
|
"On-Site Meeting",
|
||||||
|
fields=["*"],
|
||||||
|
filters={"status": "Unscheduled"},
|
||||||
|
order_by="creation desc"
|
||||||
|
)
|
||||||
|
return build_success_response(meetings)
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(message=str(e), title="Get Unscheduled On-Site Meetings Failed")
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def create_onsite_meeting(address, notes=""):
|
||||||
|
"""Create a new On-Site Meeting with Unscheduled status."""
|
||||||
|
try:
|
||||||
|
print(f"DEBUG: Creating meeting with address='{address}', notes='{notes}'")
|
||||||
|
|
||||||
|
# Validate address parameter
|
||||||
|
if not address or address == "None" or not address.strip():
|
||||||
|
return build_error_response("Address is required and cannot be empty.", 400)
|
||||||
|
|
||||||
|
# Get the address document name from the full address string
|
||||||
|
address_name = frappe.db.get_value("Address", filters={"full_address": address}, fieldname="name")
|
||||||
|
|
||||||
|
print(f"DEBUG: Address lookup result: address_name='{address_name}'")
|
||||||
|
|
||||||
|
if not address_name:
|
||||||
|
return build_error_response(f"Address '{address}' not found in the system.", 404)
|
||||||
|
|
||||||
|
# Create the meeting with Unscheduled status
|
||||||
|
meeting = frappe.get_doc({
|
||||||
|
"doctype": "On-Site Meeting",
|
||||||
|
"address": address_name,
|
||||||
|
"notes": notes or "",
|
||||||
|
"status": "Unscheduled"
|
||||||
|
})
|
||||||
|
meeting.flags.ignore_permissions = True
|
||||||
|
meeting.insert(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Clear any auto-generated messages from Frappe
|
||||||
|
frappe.local.message_log = []
|
||||||
|
|
||||||
|
print(f"DEBUG: Meeting created successfully: {meeting.name}")
|
||||||
|
|
||||||
|
return build_success_response(meeting.as_dict())
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(message=str(e), title="Create On-Site Meeting Failed")
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def update_onsite_meeting(name, data):
|
||||||
|
"""Update an existing On-Site Meeting."""
|
||||||
|
defualts = {
|
||||||
|
"address": None,
|
||||||
|
"start_time": None,
|
||||||
|
"end_time": None,
|
||||||
|
"notes": None,
|
||||||
|
"assigned_employee": None,
|
||||||
|
"completed_by": None
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = json.loads(data)
|
||||||
|
data = {**defualts, **data}
|
||||||
|
meeting = frappe.get_doc("On-Site Meeting", name)
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is not None:
|
||||||
|
if key == "address":
|
||||||
|
value = frappe.db.get_value("Address", {"full_address": value}, "name")
|
||||||
|
elif key in ["assigned_employee", "completed_by"]:
|
||||||
|
value = frappe.db.get_value("Employee", {"employee_name": value}, "name")
|
||||||
|
meeting.set(key, value)
|
||||||
|
meeting.save()
|
||||||
|
return build_success_response(meeting.as_dict())
|
||||||
|
except frappe.DoesNotExistError:
|
||||||
|
return build_error_response(f"On-Site Meeting '{name}' does not exist.", 404)
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(str(e), 500)
|
||||||
|
|
||||||
@ -2,8 +2,11 @@ import frappe
|
|||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
print(doc.address)
|
print(doc.address)
|
||||||
if doc.address:
|
if doc.address and not doc.end_time and not doc.start_time:
|
||||||
address_name = frappe.db.get_value("Address", fieldname="name", filters={"address_line1": doc.address})
|
address_doc = frappe.get_doc("Address", doc.address)
|
||||||
address_doc = frappe.get_doc("Address", address_name)
|
address_doc.custom_onsite_meeting_scheduled = "In Progress"
|
||||||
|
address_doc.save()
|
||||||
|
if doc.status == "Completed":
|
||||||
|
address_doc = frappe.get_doc("Address", doc.address)
|
||||||
address_doc.custom_onsite_meeting_scheduled = "Completed"
|
address_doc.custom_onsite_meeting_scheduled = "Completed"
|
||||||
address_doc.save()
|
address_doc.save()
|
||||||
@ -10,11 +10,14 @@ def after_install():
|
|||||||
|
|
||||||
def after_migrate():
|
def after_migrate():
|
||||||
add_custom_fields()
|
add_custom_fields()
|
||||||
|
update_onsite_meeting_fields()
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
|
|
||||||
# Proper way to refresh metadata
|
# Proper way to refresh metadata
|
||||||
frappe.clear_cache(doctype="Address")
|
frappe.clear_cache(doctype="Address")
|
||||||
frappe.reload_doctype("Address")
|
frappe.reload_doctype("Address")
|
||||||
|
frappe.clear_cache(doctype="On-Site Meeting")
|
||||||
|
frappe.reload_doctype("On-Site Meeting")
|
||||||
|
|
||||||
update_address_fields()
|
update_address_fields()
|
||||||
build_frontend()
|
build_frontend()
|
||||||
@ -111,6 +114,36 @@ def add_custom_fields():
|
|||||||
default="Not Started",
|
default="Not Started",
|
||||||
insert_after="job_status"
|
insert_after="job_status"
|
||||||
)
|
)
|
||||||
|
],
|
||||||
|
"On-Site Meeting": [
|
||||||
|
dict(
|
||||||
|
fieldname="notes",
|
||||||
|
label="Notes",
|
||||||
|
fieldtype="Small Text",
|
||||||
|
insert_after="address"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="assigned_employee",
|
||||||
|
label="Assigned Employee",
|
||||||
|
fieldtype="Link",
|
||||||
|
options="Employee",
|
||||||
|
insert_after="notes"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="status",
|
||||||
|
label="Status",
|
||||||
|
fieldtype="Select",
|
||||||
|
options="Unscheduled\nScheduled\nCompleted\nCancelled",
|
||||||
|
default="Unscheduled",
|
||||||
|
insert_after="start_time"
|
||||||
|
),
|
||||||
|
dict(
|
||||||
|
fieldname="completed_by",
|
||||||
|
label="Completed By",
|
||||||
|
fieldtype="Link",
|
||||||
|
options="Employee",
|
||||||
|
insert_after="status"
|
||||||
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,6 +165,35 @@ def add_custom_fields():
|
|||||||
print(f"❌ Error creating custom fields: {str(e)}")
|
print(f"❌ Error creating custom fields: {str(e)}")
|
||||||
frappe.log_error(message=str(e), title="Custom Fields Creation Failed")
|
frappe.log_error(message=str(e), title="Custom Fields Creation Failed")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def update_onsite_meeting_fields():
|
||||||
|
"""Update On-Site Meeting doctype fields to make start_time and end_time optional."""
|
||||||
|
print("\n🔧 Updating On-Site Meeting doctype fields...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the doctype
|
||||||
|
doctype = frappe.get_doc("DocType", "On-Site Meeting")
|
||||||
|
|
||||||
|
# Find and update start_time and end_time fields
|
||||||
|
updated_fields = []
|
||||||
|
for field in doctype.fields:
|
||||||
|
if field.fieldname in ['start_time', 'end_time']:
|
||||||
|
if field.reqd == 1:
|
||||||
|
field.reqd = 0
|
||||||
|
updated_fields.append(field.fieldname)
|
||||||
|
|
||||||
|
if updated_fields:
|
||||||
|
# Save the doctype
|
||||||
|
doctype.save(ignore_permissions=True)
|
||||||
|
print(f"✅ Updated fields: {', '.join(updated_fields)} (set to not required)")
|
||||||
|
else:
|
||||||
|
print("✅ Fields already configured correctly")
|
||||||
|
|
||||||
|
print("🔧 On-Site Meeting field update complete.\n")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error updating On-Site Meeting fields: {str(e)}")
|
||||||
|
frappe.log_error(message=str(e), title="On-Site Meeting Field Update Failed")
|
||||||
|
# Don't raise - this is not critical enough to stop migration
|
||||||
|
|
||||||
def update_address_fields():
|
def update_address_fields():
|
||||||
addresses = frappe.get_all("Address", pluck="name")
|
addresses = frappe.get_all("Address", pluck="name")
|
||||||
@ -214,14 +276,14 @@ def update_address_fields():
|
|||||||
job_status = "Not Started"
|
job_status = "Not Started"
|
||||||
payment_received = "Not Started"
|
payment_received = "Not Started"
|
||||||
|
|
||||||
onsite_meetings = frappe.get_all("On-Site Meeting", filters={"address": address.address_title})
|
onsite_meetings = frappe.get_all("On-Site Meeting", fields=["docstatus"],filters={"address": address.address_title})
|
||||||
if onsite_meetings and onsite_meetings[0]:
|
if onsite_meetings and onsite_meetings[0]:
|
||||||
onsite_meeting = "Completed"
|
onsite_meeting = "Completed" if onsite_meetings[0]["docstatus"] == 1 else "In Progress"
|
||||||
|
|
||||||
estimates = frappe.get_all("Quotation", fields=["custom_sent"], filters={"custom_installation_address": address.address_title})
|
estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus"], filters={"custom_installation_address": address.address_title})
|
||||||
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1:
|
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["docstatus"] == 1:
|
||||||
estimate_sent = "Completed"
|
estimate_sent = "Completed"
|
||||||
elif estimates and estimates[0]:
|
elif estimates and estimates[0] and estimates[0]["docstatus"] != 1:
|
||||||
estimate_sent = "In Progress"
|
estimate_sent = "In Progress"
|
||||||
|
|
||||||
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address.address_title, "project_template": "SNW Install"})
|
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address.address_title, "project_template": "SNW Install"})
|
||||||
|
|||||||
@ -2,12 +2,24 @@ import ApiUtils from "./apiUtils";
|
|||||||
import { useErrorStore } from "./stores/errors";
|
import { useErrorStore } from "./stores/errors";
|
||||||
|
|
||||||
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
|
||||||
|
// Proxy method for external API calls
|
||||||
const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request";
|
const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request";
|
||||||
|
// Estimate methods
|
||||||
const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate";
|
const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate";
|
||||||
|
// Job methods
|
||||||
const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.get_jobs";
|
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_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job";
|
||||||
|
// Invoice methods
|
||||||
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
|
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
|
||||||
|
// Warranty methods
|
||||||
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
|
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
|
||||||
|
// On-Site Meeting methods
|
||||||
|
const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD =
|
||||||
|
"custom_ui.api.db.onsite_meetings.get_week_onsite_meetings";
|
||||||
|
const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.onsite_meetings.get_onsite_meetings";
|
||||||
|
// Address methods
|
||||||
|
const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
|
||||||
|
// Client methods
|
||||||
const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client";
|
const FRAPPE_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_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_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data";
|
||||||
@ -23,6 +35,7 @@ class Api {
|
|||||||
try {
|
try {
|
||||||
let response = await frappe.call(request);
|
let response = await frappe.call(request);
|
||||||
response = ApiUtils.toCamelCaseObject(response);
|
response = ApiUtils.toCamelCaseObject(response);
|
||||||
|
response.method = frappeMethod;
|
||||||
console.log("DEBUG: API - Request Response: ", response);
|
console.log("DEBUG: API - Request Response: ", response);
|
||||||
if (response.message.status && response.message.status === "error") {
|
if (response.message.status && response.message.status === "error") {
|
||||||
throw new Error(response.message.message);
|
throw new Error(response.message.message);
|
||||||
@ -35,6 +48,45 @@ class Api {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async searchAddresses(searchTerm) {
|
||||||
|
const filters = {
|
||||||
|
full_address: ["like", `%${searchTerm}%`],
|
||||||
|
};
|
||||||
|
return await this.getAddresses(["full_address"], filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getAddresses(fields = ["*"], filters = {}) {
|
||||||
|
return await this.request(FRAPPE_GET_ADDRESSES_METHOD, { fields, filters });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUnscheduledOnSiteMeetings() {
|
||||||
|
return await this.request(
|
||||||
|
"custom_ui.api.db.onsite_meetings.get_unscheduled_onsite_meetings",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getScheduledOnSiteMeetings(fields = ["*"], filters = {}) {
|
||||||
|
return await this.request(FRAPPE_GET_ONSITE_MEETINGS_METHOD, { fields, filters });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getWeekOnSiteMeetings(weekStart, weekEnd) {
|
||||||
|
return await this.request(FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD, { weekStart, weekEnd });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateOnSiteMeeting(name, data) {
|
||||||
|
return await this.request("custom_ui.api.db.onsite_meetings.update_onsite_meeting", {
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createOnSiteMeeting(address, notes = "") {
|
||||||
|
return await this.request("custom_ui.api.db.onsite_meetings.create_onsite_meeting", {
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static async getClientStatusCounts(params = {}) {
|
static async getClientStatusCounts(params = {}) {
|
||||||
return await this.request(FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD, params);
|
return await this.request(FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD, params);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,9 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
HistoricShield,
|
HistoricShield,
|
||||||
Developer,
|
Developer,
|
||||||
|
Neighbourhood,
|
||||||
|
Calculator,
|
||||||
|
ReceiveDollars,
|
||||||
} from "@iconoir/vue";
|
} from "@iconoir/vue";
|
||||||
import SpeedDial from "primevue/speeddial";
|
import SpeedDial from "primevue/speeddial";
|
||||||
|
|
||||||
@ -37,7 +40,7 @@ const createButtons = ref([
|
|||||||
{
|
{
|
||||||
label: "On-Site Meeting",
|
label: "On-Site Meeting",
|
||||||
command: () => {
|
command: () => {
|
||||||
router.push("/onsitemeetings/new");
|
router.push("/schedule-onsite?new=true");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -72,7 +75,10 @@ const categories = ref([
|
|||||||
{ name: "Home", icon: Home, url: "/" },
|
{ name: "Home", icon: Home, url: "/" },
|
||||||
{ name: "Calendar", icon: Calendar, url: "/calendar" },
|
{ name: "Calendar", icon: Calendar, url: "/calendar" },
|
||||||
{ name: "Clients", icon: Community, url: "/clients" },
|
{ name: "Clients", icon: Community, url: "/clients" },
|
||||||
|
{ name: "On-Site Meetings", icon: Neighbourhood, url: "/schedule-onsite" },
|
||||||
|
{ name: "Estimates", icon: Calculator, url: "/estimates" },
|
||||||
{ name: "Jobs", icon: Hammer, url: "/jobs" },
|
{ name: "Jobs", icon: Hammer, url: "/jobs" },
|
||||||
|
{ name: "Payments/Invoices", icon: ReceiveDollars, url: "/invoices" },
|
||||||
{ name: "Routes", icon: PathArrowSolid, url: "/routes" },
|
{ name: "Routes", icon: PathArrowSolid, url: "/routes" },
|
||||||
{ name: "Time Sheets", icon: Clock, url: "/timesheets" },
|
{ name: "Time Sheets", icon: Clock, url: "/timesheets" },
|
||||||
{ name: "Warranties", icon: HistoricShield, url: "/warranties" },
|
{ name: "Warranties", icon: HistoricShield, url: "/warranties" },
|
||||||
@ -81,7 +87,7 @@ const categories = ref([
|
|||||||
icon: MultiplePagesPlus,
|
icon: MultiplePagesPlus,
|
||||||
buttons: createButtons,
|
buttons: createButtons,
|
||||||
},
|
},
|
||||||
{ name: "Development", icon: Developer, buttons: developmentButtons },
|
// { name: "Development", icon: Developer, buttons: developmentButtons },
|
||||||
]);
|
]);
|
||||||
const handleCategoryClick = (category) => {
|
const handleCategoryClick = (category) => {
|
||||||
router.push(category.url);
|
router.push(category.url);
|
||||||
@ -142,8 +148,11 @@ const handleCategoryClick = (category) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button-icon {
|
.button-icon {
|
||||||
justify-self: flex-start;
|
flex-shrink: 0;
|
||||||
margin-left: 5px;
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-item {
|
.create-item {
|
||||||
@ -156,8 +165,14 @@ const handleCategoryClick = (category) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button-text {
|
.button-text {
|
||||||
margin-left: auto;
|
flex: 1;
|
||||||
margin-right: auto;
|
text-align: center;
|
||||||
|
font-size: clamp(0.6rem, 2vw, 0.9rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 8px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-button {
|
.sidebar-button {
|
||||||
@ -168,12 +183,17 @@ const handleCategoryClick = (category) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 8px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar {
|
#sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 150px;
|
width: 180px;
|
||||||
|
min-width: 180px;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background-color: #f3f3f3;
|
background-color: #f3f3f3;
|
||||||
@ -182,4 +202,32 @@ const handleCategoryClick = (category) => {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for smaller screens */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#sidebar {
|
||||||
|
width: 160px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-text {
|
||||||
|
font-size: clamp(0.55rem, 1.8vw, 0.8rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#sidebar {
|
||||||
|
width: 140px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-button {
|
||||||
|
min-height: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-text {
|
||||||
|
font-size: clamp(0.5rem, 1.5vw, 0.7rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -272,6 +272,21 @@
|
|||||||
:severity="getBadgeColor(slotProps.data[col.fieldName])"
|
:severity="getBadgeColor(slotProps.data[col.fieldName])"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="col.type === 'status-button'" #body="slotProps">
|
||||||
|
<Button
|
||||||
|
:label="slotProps.data[col.fieldName]"
|
||||||
|
:severity="getBadgeColor(slotProps.data[col.fieldName])"
|
||||||
|
size="small"
|
||||||
|
:variant="col.buttonVariant || 'filled'"
|
||||||
|
@click="handleStatusButtonClick(col, slotProps.data)"
|
||||||
|
:disabled="
|
||||||
|
loading ||
|
||||||
|
(col.disableCondition &&
|
||||||
|
col.disableCondition(slotProps.data[col.fieldName]))
|
||||||
|
"
|
||||||
|
class="status-button"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template v-if="col.type === 'date'" #body="slotProps">
|
<template v-if="col.type === 'date'" #body="slotProps">
|
||||||
<span>{{ formatDate(slotProps.data[col.fieldName]) }}</span>
|
<span>{{ formatDate(slotProps.data[col.fieldName]) }}</span>
|
||||||
</template>
|
</template>
|
||||||
@ -1044,6 +1059,17 @@ const handleBulkAction = (action, selectedRows) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle status button clicks
|
||||||
|
const handleStatusButtonClick = (column, rowData) => {
|
||||||
|
try {
|
||||||
|
if (column.onStatusClick && typeof column.onStatusClick === "function") {
|
||||||
|
column.onStatusClick(rowData[column.fieldName], rowData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error executing status button click:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getBadgeColor = (status) => {
|
const getBadgeColor = (status) => {
|
||||||
switch (status?.toLowerCase()) {
|
switch (status?.toLowerCase()) {
|
||||||
case "completed":
|
case "completed":
|
||||||
@ -1547,4 +1573,29 @@ defineExpose({
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status Button Styles */
|
||||||
|
.status-button {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-button:not(:disabled):hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-button:disabled:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,286 +1,289 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-dialog
|
<v-dialog
|
||||||
v-model="localVisible"
|
v-model="localVisible"
|
||||||
:persistent="options.persistent || false"
|
:persistent="options.persistent || false"
|
||||||
:fullscreen="options.fullscreen || false"
|
:fullscreen="options.fullscreen || false"
|
||||||
:max-width="options.maxWidth || '500px'"
|
:max-width="options.maxWidth || '500px'"
|
||||||
:width="options.width"
|
:width="options.width"
|
||||||
:height="options.height"
|
:height="options.height"
|
||||||
:attach="options.attach"
|
:attach="options.attach"
|
||||||
:transition="options.transition || 'dialog-transition'"
|
:transition="options.transition || 'dialog-transition'"
|
||||||
:scrollable="options.scrollable || false"
|
:scrollable="options.scrollable || false"
|
||||||
:retain-focus="options.retainFocus !== false"
|
:retain-focus="options.retainFocus !== false"
|
||||||
:close-on-back="options.closeOnBack !== false"
|
:close-on-back="options.closeOnBack !== false"
|
||||||
:close-on-content-click="options.closeOnContentClick || false"
|
:close-on-content-click="options.closeOnContentClick || false"
|
||||||
:overlay-color="options.overlayColor"
|
:overlay-color="options.overlayColor"
|
||||||
:overlay-opacity="options.overlayOpacity"
|
:overlay-opacity="options.overlayOpacity"
|
||||||
:z-index="options.zIndex"
|
:z-index="options.zIndex"
|
||||||
:class="options.dialogClass"
|
:class="options.dialogClass"
|
||||||
@click:outside="handleOutsideClick"
|
@click:outside="handleOutsideClick"
|
||||||
@keydown.esc="handleEscapeKey"
|
@keydown.esc="handleEscapeKey"
|
||||||
>
|
>
|
||||||
<v-card
|
<v-card
|
||||||
:class="[
|
:class="[
|
||||||
'modal-card',
|
'modal-card',
|
||||||
options.cardClass,
|
options.cardClass,
|
||||||
{
|
{
|
||||||
'elevation-0': options.flat,
|
'elevation-0': options.flat,
|
||||||
'rounded-0': options.noRadius
|
'rounded-0': options.noRadius,
|
||||||
}
|
},
|
||||||
]"
|
]"
|
||||||
:color="options.cardColor"
|
:color="options.cardColor"
|
||||||
:variant="options.cardVariant"
|
:variant="options.cardVariant"
|
||||||
:elevation="options.elevation"
|
:elevation="options.elevation"
|
||||||
>
|
>
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<v-card-title
|
<v-card-title
|
||||||
v-if="options.showHeader !== false"
|
v-if="options.showHeader !== false"
|
||||||
:class="[
|
:class="[
|
||||||
'modal-header d-flex align-center justify-space-between',
|
'modal-header d-flex align-center justify-space-between',
|
||||||
options.headerClass
|
options.headerClass,
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="modal-title">
|
<div class="modal-title">
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
{{ options.title }}
|
{{ options.title }}
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Close button -->
|
|
||||||
<v-btn
|
|
||||||
v-if="options.showCloseButton !== false && !options.persistent"
|
|
||||||
icon
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
:color="options.closeButtonColor || 'grey'"
|
|
||||||
@click="closeModal"
|
|
||||||
class="modal-close-btn"
|
|
||||||
>
|
|
||||||
<v-icon>{{ options.closeIcon || 'mdi-close' }}</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-divider v-if="options.showHeaderDivider && options.showHeader !== false" />
|
<!-- Close button -->
|
||||||
|
<v-btn
|
||||||
|
v-if="options.showCloseButton !== false && !options.persistent"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:color="options.closeButtonColor || 'grey'"
|
||||||
|
@click="closeModal"
|
||||||
|
class="modal-close-btn"
|
||||||
|
>
|
||||||
|
<v-icon>{{ options.closeIcon || "mdi-close" }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
<!-- Content Section -->
|
<v-divider v-if="options.showHeaderDivider && options.showHeader !== false" />
|
||||||
<v-card-text
|
|
||||||
:class="[
|
|
||||||
'modal-content',
|
|
||||||
options.contentClass,
|
|
||||||
{
|
|
||||||
'pa-0': options.noPadding,
|
|
||||||
'overflow-y-auto': options.scrollable
|
|
||||||
}
|
|
||||||
]"
|
|
||||||
:style="contentStyle"
|
|
||||||
>
|
|
||||||
<slot>
|
|
||||||
<!-- Default content if no slot provided -->
|
|
||||||
<div v-if="options.message" v-html="options.message"></div>
|
|
||||||
</slot>
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<!-- Actions Section -->
|
<!-- Content Section -->
|
||||||
<v-card-actions
|
<v-card-text
|
||||||
v-if="options.showActions !== false || $slots.actions"
|
:class="[
|
||||||
:class="[
|
'modal-content',
|
||||||
'modal-actions',
|
options.contentClass,
|
||||||
options.actionsClass,
|
{
|
||||||
{
|
'pa-0': options.noPadding,
|
||||||
'justify-end': options.actionsAlign === 'right',
|
'overflow-y-auto': options.scrollable,
|
||||||
'justify-center': options.actionsAlign === 'center',
|
},
|
||||||
'justify-start': options.actionsAlign === 'left',
|
]"
|
||||||
'justify-space-between': options.actionsAlign === 'space-between'
|
:style="contentStyle"
|
||||||
}
|
>
|
||||||
]"
|
<slot>
|
||||||
>
|
<!-- Default content if no slot provided -->
|
||||||
<slot name="actions" :close="closeModal" :options="options">
|
<div v-if="options.message" v-html="options.message"></div>
|
||||||
<!-- Default action buttons -->
|
</slot>
|
||||||
<v-btn
|
</v-card-text>
|
||||||
v-if="options.showCancelButton !== false && !options.persistent"
|
|
||||||
:color="options.cancelButtonColor || 'grey'"
|
<!-- Actions Section -->
|
||||||
:variant="options.cancelButtonVariant || 'text'"
|
<v-card-actions
|
||||||
@click="handleCancel"
|
v-if="options.showActions !== false || $slots.actions"
|
||||||
>
|
:class="[
|
||||||
{{ options.cancelButtonText || 'Cancel' }}
|
'modal-actions',
|
||||||
</v-btn>
|
options.actionsClass,
|
||||||
|
{
|
||||||
<v-btn
|
'justify-end': options.actionsAlign === 'right',
|
||||||
v-if="options.showConfirmButton !== false"
|
'justify-center': options.actionsAlign === 'center',
|
||||||
:color="options.confirmButtonColor || 'primary'"
|
'justify-start': options.actionsAlign === 'left',
|
||||||
:variant="options.confirmButtonVariant || 'elevated'"
|
'justify-space-between': options.actionsAlign === 'space-between',
|
||||||
:loading="options.loading"
|
},
|
||||||
@click="handleConfirm"
|
]"
|
||||||
>
|
>
|
||||||
{{ options.confirmButtonText || 'Confirm' }}
|
<slot name="actions" :close="closeModal" :options="options">
|
||||||
</v-btn>
|
<!-- Default action buttons -->
|
||||||
</slot>
|
<v-btn
|
||||||
</v-card-actions>
|
v-if="options.showCancelButton !== false"
|
||||||
</v-card>
|
:color="options.cancelButtonColor || 'grey'"
|
||||||
</v-dialog>
|
:variant="options.cancelButtonVariant || 'text'"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
{{ options.cancelButtonText || "Cancel" }}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="options.showConfirmButton !== false"
|
||||||
|
:color="options.confirmButtonColor || 'primary'"
|
||||||
|
:variant="options.confirmButtonVariant || 'elevated'"
|
||||||
|
:loading="options.loading"
|
||||||
|
@click="handleConfirm"
|
||||||
|
>
|
||||||
|
{{ options.confirmButtonText || "Confirm" }}
|
||||||
|
</v-btn>
|
||||||
|
</slot>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue'
|
import { computed, watch } from "vue";
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// Modal visibility state
|
// Modal visibility state
|
||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Options object for configuration
|
// Options object for configuration
|
||||||
options: {
|
options: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({})
|
default: () => ({}),
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'update:visible',
|
"update:visible",
|
||||||
'close',
|
"close",
|
||||||
'confirm',
|
"confirm",
|
||||||
'cancel',
|
"cancel",
|
||||||
'outside-click',
|
"outside-click",
|
||||||
'escape-key'
|
"escape-key",
|
||||||
])
|
]);
|
||||||
|
|
||||||
// Local visibility state that syncs with parent
|
// Local visibility state that syncs with parent
|
||||||
const localVisible = computed({
|
const localVisible = computed({
|
||||||
get() {
|
get() {
|
||||||
return props.visible
|
return props.visible;
|
||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
emit('update:visible', value)
|
emit("update:visible", value);
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Computed styles for content area
|
// Computed styles for content area
|
||||||
const contentStyle = computed(() => {
|
const contentStyle = computed(() => {
|
||||||
const styles = {}
|
const styles = {};
|
||||||
|
|
||||||
if (props.options.contentHeight) {
|
if (props.options.contentHeight) {
|
||||||
styles.height = props.options.contentHeight
|
styles.height = props.options.contentHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.options.contentMaxHeight) {
|
if (props.options.contentMaxHeight) {
|
||||||
styles.maxHeight = props.options.contentMaxHeight
|
styles.maxHeight = props.options.contentMaxHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.options.contentMinHeight) {
|
if (props.options.contentMinHeight) {
|
||||||
styles.minHeight = props.options.contentMinHeight
|
styles.minHeight = props.options.contentMinHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
return styles
|
return styles;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
localVisible.value = false
|
localVisible.value = false;
|
||||||
emit('close')
|
emit("close");
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
emit('confirm')
|
emit("confirm");
|
||||||
|
|
||||||
// Auto-close unless specified not to
|
// Auto-close unless specified not to
|
||||||
if (props.options.autoCloseOnConfirm !== false) {
|
if (props.options.autoCloseOnConfirm !== false) {
|
||||||
closeModal()
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
emit('cancel')
|
emit("cancel");
|
||||||
|
|
||||||
// Auto-close unless specified not to
|
// Auto-close unless specified not to
|
||||||
if (props.options.autoCloseOnCancel !== false) {
|
if (props.options.autoCloseOnCancel !== false) {
|
||||||
closeModal()
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleOutsideClick = () => {
|
const handleOutsideClick = () => {
|
||||||
emit('outside-click')
|
emit("outside-click");
|
||||||
|
|
||||||
// Close on outside click unless persistent or disabled
|
// Close on outside click unless persistent or disabled
|
||||||
if (!props.options.persistent && props.options.closeOnOutsideClick !== false) {
|
if (!props.options.persistent && props.options.closeOnOutsideClick !== false) {
|
||||||
closeModal()
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleEscapeKey = () => {
|
const handleEscapeKey = () => {
|
||||||
emit('escape-key')
|
emit("escape-key");
|
||||||
|
|
||||||
// Close on escape key unless persistent or disabled
|
// Close on escape key unless persistent or disabled
|
||||||
if (!props.options.persistent && props.options.closeOnEscape !== false) {
|
if (!props.options.persistent && props.options.closeOnEscape !== false) {
|
||||||
closeModal()
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Watch for external visibility changes
|
// Watch for external visibility changes
|
||||||
watch(() => props.visible, (newValue) => {
|
watch(
|
||||||
if (newValue && props.options.onOpen) {
|
() => props.visible,
|
||||||
props.options.onOpen()
|
(newValue) => {
|
||||||
} else if (!newValue && props.options.onClose) {
|
if (newValue && props.options.onOpen) {
|
||||||
props.options.onClose()
|
props.options.onOpen();
|
||||||
}
|
} else if (!newValue && props.options.onClose) {
|
||||||
})
|
props.options.onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.modal-card {
|
.modal-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
background-color: var(--v-theme-surface-variant);
|
background-color: var(--v-theme-surface-variant);
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-btn {
|
.modal-close-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.modal-header {
|
.modal-header {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom transitions */
|
/* Custom transitions */
|
||||||
.v-dialog--fullscreen .modal-card {
|
.v-dialog--fullscreen .modal-card {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading state */
|
/* Loading state */
|
||||||
.modal-card.loading {
|
.modal-card.loading {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
117
frontend/src/components/modals/MeetingDetailsModal.vue
Normal file
117
frontend/src/components/modals/MeetingDetailsModal.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<Modal v-model:visible="showModal" :options="modalOptions" @confirm="handleClose">
|
||||||
|
<template #title>Meeting Details</template>
|
||||||
|
<div v-if="meeting" class="meeting-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<v-icon class="mr-2">mdi-map-marker</v-icon>
|
||||||
|
<strong>Address:</strong> {{ meeting.address }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" v-if="meeting.client">
|
||||||
|
<v-icon class="mr-2">mdi-account</v-icon>
|
||||||
|
<strong>Client:</strong> {{ meeting.client }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<v-icon class="mr-2">mdi-calendar</v-icon>
|
||||||
|
<strong>Date:</strong> {{ formatDate(meeting.date) }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<v-icon class="mr-2">mdi-clock</v-icon>
|
||||||
|
<strong>Time:</strong> {{ formatTimeDisplay(meeting.scheduledTime) }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" v-if="meeting.duration">
|
||||||
|
<v-icon class="mr-2">mdi-timer</v-icon>
|
||||||
|
<strong>Duration:</strong> {{ meeting.duration }} minutes
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" v-if="meeting.notes">
|
||||||
|
<v-icon class="mr-2">mdi-note-text</v-icon>
|
||||||
|
<strong>Notes:</strong> {{ meeting.notes }}
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" v-if="meeting.status">
|
||||||
|
<v-icon class="mr-2">mdi-check-circle</v-icon>
|
||||||
|
<strong>Status:</strong> {{ meeting.status }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import Modal from "../common/Modal.vue";
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
meeting: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(["update:visible", "close"]);
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const showModal = computed({
|
||||||
|
get() {
|
||||||
|
return props.visible;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
emit("update:visible", value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal options
|
||||||
|
const modalOptions = computed(() => ({
|
||||||
|
maxWidth: "600px",
|
||||||
|
showCancelButton: false,
|
||||||
|
confirmButtonText: "Close",
|
||||||
|
confirmButtonColor: "primary",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const handleClose = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeDisplay = (time) => {
|
||||||
|
if (!time) return "";
|
||||||
|
const [hours, minutes] = time.split(":").map(Number);
|
||||||
|
const displayHour = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
|
||||||
|
const ampm = hours >= 12 ? "PM" : "AM";
|
||||||
|
return `${displayHour}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.meeting-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row strong {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
311
frontend/src/components/modals/OnSiteMeetingModal.vue
Normal file
311
frontend/src/components/modals/OnSiteMeetingModal.vue
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
<template>
|
||||||
|
<!-- New Meeting Creation Modal -->
|
||||||
|
<Modal
|
||||||
|
v-model:visible="showModal"
|
||||||
|
:options="modalOptions"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<template #title>Schedule New On-Site Meeting</template>
|
||||||
|
<div class="new-meeting-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="meeting-address">Address: <span class="required">*</span></label>
|
||||||
|
<div class="address-input-group">
|
||||||
|
<InputText
|
||||||
|
id="meeting-address"
|
||||||
|
v-model="formData.address"
|
||||||
|
class="address-input"
|
||||||
|
placeholder="Enter meeting address"
|
||||||
|
@input="validateForm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Search"
|
||||||
|
icon="pi pi-search"
|
||||||
|
size="small"
|
||||||
|
:disabled="!formData.address.trim()"
|
||||||
|
@click="searchAddress"
|
||||||
|
class="search-btn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="meeting-notes">Notes (Optional):</label>
|
||||||
|
<Textarea
|
||||||
|
id="meeting-notes"
|
||||||
|
v-model="formData.notes"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Address Search Results Modal -->
|
||||||
|
<Modal
|
||||||
|
v-model:visible="showAddressSearchModal"
|
||||||
|
:options="searchModalOptions"
|
||||||
|
@confirm="closeAddressSearch"
|
||||||
|
>
|
||||||
|
<template #title>Address Search Results</template>
|
||||||
|
<div class="address-search-results">
|
||||||
|
<div v-if="addressSearchResults.length === 0" class="no-results">
|
||||||
|
<i class="pi pi-info-circle"></i>
|
||||||
|
<p>No addresses found matching your search.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="results-list">
|
||||||
|
<div
|
||||||
|
v-for="(address, index) in addressSearchResults"
|
||||||
|
:key="index"
|
||||||
|
class="address-result-item"
|
||||||
|
@click="selectAddress(address)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-map-marker"></i>
|
||||||
|
<span>{{ address }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import Modal from "../common/Modal.vue";
|
||||||
|
import InputText from "primevue/inputtext";
|
||||||
|
import Textarea from "primevue/textarea";
|
||||||
|
import Button from "primevue/button";
|
||||||
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||||
|
import Api from "../../api";
|
||||||
|
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
initialAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(["update:visible", "confirm", "cancel"]);
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const showModal = computed({
|
||||||
|
get() {
|
||||||
|
return props.visible;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
emit("update:visible", value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAddressSearchModal = ref(false);
|
||||||
|
const addressSearchResults = ref([]);
|
||||||
|
const isFormValid = ref(false);
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const formData = ref({
|
||||||
|
address: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form validation state
|
||||||
|
|
||||||
|
// Modal options
|
||||||
|
const modalOptions = computed(() => ({
|
||||||
|
maxWidth: "500px",
|
||||||
|
persistent: true,
|
||||||
|
confirmButtonText: "Create",
|
||||||
|
cancelButtonText: "Cancel",
|
||||||
|
confirmButtonColor: "primary",
|
||||||
|
showConfirmButton: true,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonProps: {
|
||||||
|
disabled: !isFormValid.value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const searchModalOptions = computed(() => ({
|
||||||
|
maxWidth: "600px",
|
||||||
|
showCancelButton: false,
|
||||||
|
confirmButtonText: "Close",
|
||||||
|
confirmButtonColor: "primary",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const validateForm = () => {
|
||||||
|
const hasValidAddress = formData.value.address && formData.value.address.trim().length > 0;
|
||||||
|
isFormValid.value = hasValidAddress;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchAddress = async () => {
|
||||||
|
const searchTerm = formData.value.address.trim();
|
||||||
|
if (!searchTerm) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Api.searchAddresses(searchTerm);
|
||||||
|
console.info("Address search results:", results);
|
||||||
|
|
||||||
|
// Ensure results is always an array
|
||||||
|
// const safeResults = Array.isArray(results) ? results : [];
|
||||||
|
addressSearchResults.value = results;
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
notificationStore.addWarning("No addresses found matching your search criteria.");
|
||||||
|
} else {
|
||||||
|
showAddressSearchModal.value = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching addresses:", error);
|
||||||
|
addressSearchResults.value = [];
|
||||||
|
notificationStore.addError("Failed to search addresses. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAddress = (address) => {
|
||||||
|
formData.value.address = address;
|
||||||
|
showAddressSearchModal.value = false;
|
||||||
|
validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAddressSearch = () => {
|
||||||
|
showAddressSearchModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!isFormValid.value) return;
|
||||||
|
|
||||||
|
emit("confirm", { ...formData.value });
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit("cancel");
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
address: props.initialAddress || "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
validateForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for prop changes
|
||||||
|
watch(
|
||||||
|
() => props.initialAddress,
|
||||||
|
(newAddress) => {
|
||||||
|
formData.value.address = newAddress || "";
|
||||||
|
validateForm();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(isVisible) => {
|
||||||
|
if (isVisible) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial validation
|
||||||
|
validateForm();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.new-meeting-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-search-results {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results i {
|
||||||
|
font-size: 2em;
|
||||||
|
color: #f39c12;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #2196f3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result-item i {
|
||||||
|
color: #2196f3;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result-item span {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -124,26 +124,38 @@ const columns = [
|
|||||||
{
|
{
|
||||||
label: "Appt. Scheduled",
|
label: "Appt. Scheduled",
|
||||||
fieldName: "appointmentScheduledStatus",
|
fieldName: "appointmentScheduledStatus",
|
||||||
type: "status",
|
type: "status-button",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
buttonVariant: "outlined",
|
||||||
|
onStatusClick: (status, rowData) => handleAppointmentClick(status, rowData),
|
||||||
|
disableCondition: (status) => status?.toLowerCase() !== "not started",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Estimate Sent",
|
label: "Estimate Sent",
|
||||||
fieldName: "estimateSentStatus",
|
fieldName: "estimateSentStatus",
|
||||||
type: "status",
|
type: "status-button",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
buttonVariant: "outlined",
|
||||||
|
onStatusClick: (status, rowData) => handleEstimateClick(status, rowData),
|
||||||
|
disableCondition: (status) => status?.toLowerCase() !== "not started",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Payment Received",
|
label: "Payment Received",
|
||||||
fieldName: "paymentReceivedStatus",
|
fieldName: "paymentReceivedStatus",
|
||||||
type: "status",
|
type: "status-button",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
buttonVariant: "outlined",
|
||||||
|
onStatusClick: (status, rowData) => handlePaymentClick(status, rowData),
|
||||||
|
disableCondition: (status) => status?.toLowerCase() !== "not started",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Job Status",
|
label: "Job Status",
|
||||||
fieldName: "jobStatus",
|
fieldName: "jobStatus",
|
||||||
type: "status",
|
type: "status-button",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
buttonVariant: "outlined",
|
||||||
|
onStatusClick: (status, rowData) => handleJobClick(status, rowData),
|
||||||
|
disableCondition: (status) => status?.toLowerCase() !== "not started",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -335,6 +347,39 @@ const handleLazyLoad = async (event) => {
|
|||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Status button click handlers
|
||||||
|
const handleAppointmentClick = (status, rowData) => {
|
||||||
|
if (status?.toLowerCase() === "not started") {
|
||||||
|
// Navigate to schedule on-site meeting
|
||||||
|
const address = encodeURIComponent(rowData.address);
|
||||||
|
router.push(`/schedule-onsite?new=true&address=${address}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEstimateClick = (status, rowData) => {
|
||||||
|
if (status?.toLowerCase() === "not started") {
|
||||||
|
// Navigate to create quotation/estimate
|
||||||
|
const address = encodeURIComponent(rowData.address);
|
||||||
|
router.push(`/quotations?new=true&address=${address}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaymentClick = (status, rowData) => {
|
||||||
|
if (status?.toLowerCase() === "not started") {
|
||||||
|
// Navigate to payment processing
|
||||||
|
const address = encodeURIComponent(rowData.address);
|
||||||
|
router.push(`/payments?new=true&address=${address}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJobClick = (status, rowData) => {
|
||||||
|
if (status?.toLowerCase() === "not started") {
|
||||||
|
// Navigate to job creation
|
||||||
|
const address = encodeURIComponent(rowData.address);
|
||||||
|
router.push(`/jobs?new=true&address=${address}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Watch for filters change to update status counts
|
// Watch for filters change to update status counts
|
||||||
watch(
|
watch(
|
||||||
() => filtersStore.getTableFilters("clients"),
|
() => filtersStore.getTableFilters("clients"),
|
||||||
|
|||||||
1321
frontend/src/components/pages/ScheduleOnSite.vue
Normal file
1321
frontend/src/components/pages/ScheduleOnSite.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ import Home from "./components/pages/Home.vue";
|
|||||||
import TestDateForm from "./components/pages/TestDateForm.vue";
|
import TestDateForm from "./components/pages/TestDateForm.vue";
|
||||||
import Client from "./components/pages/Client.vue";
|
import Client from "./components/pages/Client.vue";
|
||||||
import ErrorHandlingDemo from "./components/pages/ErrorHandlingDemo.vue";
|
import ErrorHandlingDemo from "./components/pages/ErrorHandlingDemo.vue";
|
||||||
|
import ScheduleOnSite from "./components/pages/ScheduleOnSite.vue";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@ -20,6 +21,7 @@ const routes = [
|
|||||||
{ path: "/calendar", component: Calendar },
|
{ path: "/calendar", component: Calendar },
|
||||||
{ path: "/clients", component: Clients },
|
{ path: "/clients", component: Clients },
|
||||||
{ path: "/client", component: Client },
|
{ path: "/client", component: Client },
|
||||||
|
{ path: "/schedule-onsite", component: ScheduleOnSite },
|
||||||
{ path: "/jobs", component: Jobs },
|
{ path: "/jobs", component: Jobs },
|
||||||
{ path: "/routes", component: Routes },
|
{ path: "/routes", component: Routes },
|
||||||
{ path: "/create", component: Create },
|
{ path: "/create", component: Create },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user