diff --git a/custom_ui/api/db/addresses.py b/custom_ui/api/db/addresses.py new file mode 100644 index 0000000..17da89e --- /dev/null +++ b/custom_ui/api/db/addresses.py @@ -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) \ No newline at end of file diff --git a/custom_ui/api/db/clients.py b/custom_ui/api/db/clients.py index f21b811..a9d6baf 100644 --- a/custom_ui/api/db/clients.py +++ b/custom_ui/api/db/clients.py @@ -269,7 +269,9 @@ def upsert_client(data): # Check for existing address 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) print("Existing address check:", existing_address) @@ -280,10 +282,10 @@ def upsert_client(data): address_doc = frappe.get_doc({ "doctype": "Address", "address_line1": data.get("address_line1"), + "address_line2": data.get("address_line2"), "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) diff --git a/custom_ui/api/db/onsite_meetings.py b/custom_ui/api/db/onsite_meetings.py new file mode 100644 index 0000000..d5fc705 --- /dev/null +++ b/custom_ui/api/db/onsite_meetings.py @@ -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) + \ No newline at end of file diff --git a/custom_ui/events/onsite_meeting.py b/custom_ui/events/onsite_meeting.py index 8c7bca0..839a7e4 100644 --- a/custom_ui/events/onsite_meeting.py +++ b/custom_ui/events/onsite_meeting.py @@ -2,8 +2,11 @@ import frappe def after_insert(doc, method): print(doc.address) - if doc.address: - address_name = frappe.db.get_value("Address", fieldname="name", filters={"address_line1": doc.address}) - address_doc = frappe.get_doc("Address", address_name) + if doc.address and not doc.end_time and not doc.start_time: + address_doc = frappe.get_doc("Address", doc.address) + address_doc.custom_onsite_meeting_scheduled = "In Progress" + address_doc.save() + if doc.status == "Completed": + address_doc = frappe.get_doc("Address", doc.address) address_doc.custom_onsite_meeting_scheduled = "Completed" address_doc.save() \ No newline at end of file diff --git a/custom_ui/install.py b/custom_ui/install.py index 69bdc72..cdcc50d 100644 --- a/custom_ui/install.py +++ b/custom_ui/install.py @@ -10,11 +10,14 @@ def after_install(): def after_migrate(): add_custom_fields() + update_onsite_meeting_fields() frappe.db.commit() # Proper way to refresh metadata frappe.clear_cache(doctype="Address") frappe.reload_doctype("Address") + frappe.clear_cache(doctype="On-Site Meeting") + frappe.reload_doctype("On-Site Meeting") update_address_fields() build_frontend() @@ -111,6 +114,36 @@ def add_custom_fields(): default="Not Started", 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)}") frappe.log_error(message=str(e), title="Custom Fields Creation Failed") raise + +def update_onsite_meeting_fields(): + """Update On-Site Meeting doctype fields to make start_time and end_time optional.""" + print("\nšŸ”§ Updating On-Site Meeting doctype fields...") + + try: + # Get the doctype + doctype = frappe.get_doc("DocType", "On-Site Meeting") + + # Find and update start_time and end_time fields + updated_fields = [] + for field in doctype.fields: + if field.fieldname in ['start_time', 'end_time']: + if field.reqd == 1: + field.reqd = 0 + updated_fields.append(field.fieldname) + + if updated_fields: + # Save the doctype + doctype.save(ignore_permissions=True) + print(f"āœ… Updated fields: {', '.join(updated_fields)} (set to not required)") + else: + print("āœ… Fields already configured correctly") + + print("šŸ”§ On-Site Meeting field update complete.\n") + except Exception as e: + print(f"āŒ Error updating On-Site Meeting fields: {str(e)}") + frappe.log_error(message=str(e), title="On-Site Meeting Field Update Failed") + # Don't raise - this is not critical enough to stop migration def update_address_fields(): addresses = frappe.get_all("Address", pluck="name") @@ -214,14 +276,14 @@ def update_address_fields(): job_status = "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]: - 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}) - if estimates and estimates[0] and estimates[0]["custom_sent"] == 1: + estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus"], filters={"custom_installation_address": address.address_title}) + if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["docstatus"] == 1: estimate_sent = "Completed" - elif estimates and estimates[0]: + elif estimates and estimates[0] and estimates[0]["docstatus"] != 1: estimate_sent = "In Progress" jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address.address_title, "project_template": "SNW Install"}) diff --git a/frontend/src/api.js b/frontend/src/api.js index c03e35c..7b6a5b2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2,12 +2,24 @@ import ApiUtils from "./apiUtils"; import { useErrorStore } from "./stores/errors"; const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us"; +// Proxy method for external API calls const FRAPPE_PROXY_METHOD = "custom_ui.api.proxy.request"; +// Estimate methods const FRAPPE_UPSERT_ESTIMATE_METHOD = "custom_ui.api.db.estimates.upsert_estimate"; +// Job methods const FRAPPE_GET_JOBS_METHOD = "custom_ui.api.db.get_jobs"; const FRAPPE_UPSERT_JOB_METHOD = "custom_ui.api.db.jobs.upsert_job"; +// Invoice methods const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice"; +// Warranty methods const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims"; +// On-Site Meeting methods +const FRAPPE_GET_WEEK_ONSITE_MEETINGS_METHOD = + "custom_ui.api.db.onsite_meetings.get_week_onsite_meetings"; +const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.onsite_meetings.get_onsite_meetings"; +// Address methods +const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses"; +// Client methods const FRAPPE_UPSERT_CLIENT_METHOD = "custom_ui.api.db.clients.upsert_client"; const FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD = "custom_ui.api.db.clients.get_client_status_counts"; const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_clients_table_data"; @@ -23,6 +35,7 @@ class Api { try { let response = await frappe.call(request); response = ApiUtils.toCamelCaseObject(response); + response.method = frappeMethod; console.log("DEBUG: API - Request Response: ", response); if (response.message.status && response.message.status === "error") { throw new Error(response.message.message); @@ -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 = {}) { return await this.request(FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD, params); } diff --git a/frontend/src/components/SideBar.vue b/frontend/src/components/SideBar.vue index 29fa250..e28927c 100644 --- a/frontend/src/components/SideBar.vue +++ b/frontend/src/components/SideBar.vue @@ -12,6 +12,9 @@ import { Clock, HistoricShield, Developer, + Neighbourhood, + Calculator, + ReceiveDollars, } from "@iconoir/vue"; import SpeedDial from "primevue/speeddial"; @@ -37,7 +40,7 @@ const createButtons = ref([ { label: "On-Site Meeting", command: () => { - router.push("/onsitemeetings/new"); + router.push("/schedule-onsite?new=true"); }, }, { @@ -72,7 +75,10 @@ const categories = ref([ { name: "Home", icon: Home, url: "/" }, { name: "Calendar", icon: Calendar, url: "/calendar" }, { name: "Clients", icon: Community, url: "/clients" }, + { name: "On-Site Meetings", icon: Neighbourhood, url: "/schedule-onsite" }, + { name: "Estimates", icon: Calculator, url: "/estimates" }, { name: "Jobs", icon: Hammer, url: "/jobs" }, + { name: "Payments/Invoices", icon: ReceiveDollars, url: "/invoices" }, { name: "Routes", icon: PathArrowSolid, url: "/routes" }, { name: "Time Sheets", icon: Clock, url: "/timesheets" }, { name: "Warranties", icon: HistoricShield, url: "/warranties" }, @@ -81,7 +87,7 @@ const categories = ref([ icon: MultiplePagesPlus, buttons: createButtons, }, - { name: "Development", icon: Developer, buttons: developmentButtons }, + // { name: "Development", icon: Developer, buttons: developmentButtons }, ]); const handleCategoryClick = (category) => { router.push(category.url); @@ -142,8 +148,11 @@ const handleCategoryClick = (category) => { } .button-icon { - justify-self: flex-start; - margin-left: 5px; + flex-shrink: 0; + width: 20px; + height: 20px; + margin-left: 8px; + margin-right: 8px; } .create-item { @@ -156,8 +165,14 @@ const handleCategoryClick = (category) => { } .button-text { - margin-left: auto; - margin-right: auto; + flex: 1; + 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 { @@ -168,12 +183,17 @@ const handleCategoryClick = (category) => { display: flex; width: 100%; align-items: center; + min-height: 44px; + height: 44px; + padding: 8px 0; + box-sizing: border-box; } #sidebar { display: flex; flex-direction: column; - width: 150px; + width: 180px; + min-width: 180px; align-self: flex-start; gap: 10px; background-color: #f3f3f3; @@ -182,4 +202,32 @@ const handleCategoryClick = (category) => { margin-top: 10px; 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); + } +} diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 1c86910..34cafd8 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -272,6 +272,21 @@ :severity="getBadgeColor(slotProps.data[col.fieldName])" /> + @@ -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) => { switch (status?.toLowerCase()) { case "completed": @@ -1547,4 +1573,29 @@ defineExpose({ 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; +} diff --git a/frontend/src/components/common/Modal.vue b/frontend/src/components/common/Modal.vue index a5b07bf..58c40d6 100644 --- a/frontend/src/components/common/Modal.vue +++ b/frontend/src/components/common/Modal.vue @@ -1,286 +1,289 @@ diff --git a/frontend/src/components/modals/MeetingDetailsModal.vue b/frontend/src/components/modals/MeetingDetailsModal.vue new file mode 100644 index 0000000..de53081 --- /dev/null +++ b/frontend/src/components/modals/MeetingDetailsModal.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/modals/OnSiteMeetingModal.vue b/frontend/src/components/modals/OnSiteMeetingModal.vue new file mode 100644 index 0000000..f3e50bc --- /dev/null +++ b/frontend/src/components/modals/OnSiteMeetingModal.vue @@ -0,0 +1,311 @@ +