Compare commits

..

189 Commits

Author SHA1 Message Date
e1ad30067e update fixtures 2026-01-16 10:29:15 -06:00
c481903bac update install 2026-01-16 09:11:44 -06:00
d3818d1985 job creation working 2026-01-16 09:06:59 -06:00
bd9e00c6f1 put back install.py 2026-01-15 17:33:51 -06:00
0380dd10d8 big update 2026-01-15 17:32:06 -06:00
rocketdebris
73d235b7bc Removed dummy Action button from client list 2026-01-15 09:52:04 -05:00
4f06984a0d small fix 2026-01-15 08:37:22 -06:00
5c7e93fcc7 big update 2026-01-15 08:36:08 -06:00
d53ebf9ecd update fixtures 2026-01-15 07:14:05 -06:00
00319f856a update fixtures 2026-01-15 07:13:09 -06:00
562e386766 add fixutes and doctypes 2026-01-15 07:10:51 -06:00
841d52c61a fix live updating of table data for status 2026-01-14 14:42:52 -06:00
d496f21ab9 fix datatable menu refs 2026-01-14 14:33:48 -06:00
rocketdebris
01ff96fb74 Task Status changes, needs some TLC. 2026-01-14 15:14:34 -05:00
rocketdebris
0c52f3fc23 Added a Select component to the Actions section of the Datatable, and API methods to load the available status-es from the Task doctype to populate the select for the tasks list. 2026-01-13 16:39:52 -05:00
rocketdebris
adb7bc5930 Auto filtering out completed, cancelled, and templated tasks. 2026-01-13 13:47:38 -05:00
rocketdebris
f8694cf8d4 Fixed Job Paging. 2026-01-13 12:17:01 -05:00
rocketdebris
a3e77f1e40 Fixed Estimate datatable paging. 2026-01-13 12:13:41 -05:00
rocketdebris
84c7eb0580 Fixed pagination errors on the Task page, updated to the correct API calls to get that data. 2026-01-13 11:54:13 -05:00
rocketdebris
59e21a51f2 Fixed pagination/lazy load issues on the Tasks list. 2026-01-13 10:14:22 -05:00
rocketdebris
3e1fd039b3 Changed the Task project and address columns to link types to click and navigate to their respective information. 2026-01-13 10:01:48 -05:00
3a9bc2f6b2 quick fix 2026-01-13 08:14:04 -06:00
992672b51b big update 2026-01-13 08:11:58 -06:00
rocketdebris
6853950cc5 Added a column for Project in the tasklist. 2026-01-12 17:03:33 -05:00
rocketdebris
94e1c4dfed Added Tasks Page, Routing, SideBar entry, and API methods. 2026-01-12 15:50:56 -05:00
909dab62b5 project template in quotation 2026-01-09 17:01:43 -06:00
89bdbcfdcb add something to massage some of the data 2026-01-09 14:53:06 -06:00
2f1c975e0a merge main 2026-01-09 12:51:25 -06:00
4c8e66d155 merge main 2026-01-09 12:50:46 -06:00
rocketdebris
f7ce3a39d0 Fixes for Estimate/Sales Order creation. 2026-01-09 13:29:51 -05:00
rocketdebris
016aa08b95 Added Property Detail page and routing from Clients list. 2026-01-09 10:33:05 -05:00
rocketdebris
54280ac78f Changed the customer name and Property column types to link to support additional ways to access the forthcoming customer detail page and the current property detail page. 2026-01-09 09:51:22 -05:00
rocketdebris
1a7f0d872a Added a link type to the datatable columns. 2026-01-09 09:50:15 -05:00
37a405a1f2 fix task table select 2026-01-09 05:48:12 -06:00
rocketdebris
9803f0718c Job creation v1 2026-01-08 17:06:21 -05:00
rocketdebris
181db2c4e6 Added quick action buttons to Clients page and Client Detail Page. 2026-01-08 11:41:01 -05:00
e615b84728 update install 2026-01-07 21:27:57 -06:00
ee942e2157 fix install 2026-01-07 18:54:01 -06:00
9a128f5127 fix install 2026-01-07 18:10:14 -06:00
54ae6d14f8 add project template into estimate creation to help project generation flow 2026-01-07 16:12:31 -06:00
8f90ef09fb export fixtures 2026-01-07 10:27:41 -06:00
36e4d84d9f update fixture hooks 2026-01-07 08:54:35 -06:00
a96832c21b update creation 2026-01-07 08:50:03 -06:00
97241f14ea create tempalte functionality 2026-01-02 16:28:57 -06:00
cb59dd65ca add template get 2026-01-02 15:55:27 -06:00
rocketdebris
702e718431 Styling changes to increase reactivity of CRM UI. 2026-01-02 15:31:41 -05:00
58e69596bb add history component 2025-12-30 12:33:29 -06:00
rocketdebris
b8fea2c9ca Added a job creation method from Sales Invoice 2025-12-23 20:55:52 -05:00
rocketdebris
48431db7ee Fixed a bug where Tasks weren't loading filtered by Job. 2025-12-23 17:27:16 -05:00
rocketdebris
b2f77f2ca1 Added Job detail page with a datatable displaying all Tasks related to that Job. 2025-12-23 14:48:28 -05:00
49840b6c38 fix estimate table to view details redirect 2025-12-23 09:09:13 -06:00
9de586cf3c install calendar 2025-12-23 08:58:33 -06:00
rocketdebris
410671c397 Merge branch 'main' of https://githaven.org/CaWittMN08/custom_ui 2025-12-23 09:50:48 -05:00
rocketdebris
3b2c78e4d4 The Jobs page now reads data from Projects List, click on a row and go to a detail page. 2025-12-23 09:50:33 -05:00
c842bcff12 sales order automation 2025-12-23 08:21:12 -06:00
b8c264f779 estimate handling, percentage discounts 2025-12-23 08:12:37 -06:00
rocketdebris
9c837deb52 Added a rough and dirty Job page and the JS API methods for it. 2025-12-22 23:33:24 -05:00
rocketdebris
d4240e0cc3 Fixed an issue with responsive UI related to setting the customer response. 2025-12-22 17:28:39 -05:00
rocketdebris
d30fc77527 Added the Confirm Estimate reponse in the UI. 2025-12-22 16:35:03 -05:00
rocketdebris
20a3517d7b Shuffled the Calendar categories around, added the Company specific Calendars. 2025-12-22 07:41:12 -05:00
rocketdebris
1195ad8d4a Added appropriate dials to invoices page. 2025-12-20 23:41:41 -05:00
rocketdebris
982b627c63 Added the Jobs Todos that Courtney asked for on the Jobs page. 2025-12-20 23:41:15 -05:00
rocketdebris
0fc4e58543 Moved Charts to Estimates page from Clients page. 2025-12-20 23:40:24 -05:00
rocketdebris
176d9c7e7b Added the 15 Day Follow up Chart 2025-12-20 23:39:50 -05:00
rocketdebris
2034842d26 Removed the todo charts from Client to move them to other pages. 2025-12-20 23:39:01 -05:00
rocketdebris
276e6fd3c7 Styling to help the todo list cards fit the overall sytle better. 2025-12-20 16:55:33 -05:00
rocketdebris
1d77aa5dcf Swapped Map and History card locations so that the map is near the Address information. 2025-12-20 16:54:46 -05:00
rocketdebris
572946087f Removed unused CSS classes. 2025-12-20 15:25:49 -05:00
rocketdebris
056ee4a2ae Removed redundant History tab, now that history lives in the overview. 2025-12-20 15:25:22 -05:00
rocketdebris
290998da96 Moved cards to our card implementation. 2025-12-20 15:24:39 -05:00
rocketdebris
25f6d73fc7 Merge branch 'main' of https://githaven.org/CaWittMN08/custom_ui 2025-12-20 14:54:48 -05:00
rocketdebris
6d9342a970 Shrank Cards on the Dashboard. Implemented own Card as well. 2025-12-20 14:54:07 -05:00
9c9050c558 add sales order generation functionality 2025-12-19 16:58:38 -06:00
rocketdebris
796b835c08 Added a Balances Overview and Added an Add New Button to client communication history Card. 2025-12-16 15:59:36 -05:00
rocketdebris
b802dcd20a Added placeholder History Card to CRM Overview. 2025-12-16 15:35:21 -05:00
73f4b3b97a fix address map 2025-12-16 11:57:37 -06:00
3c75ba975e update mapping of data return for a lead 2025-12-16 11:17:14 -06:00
rocketdebris
7e397c2eab Styling changes to the CRM page. More in-line with LMN. Removed Status Buttons, moved some information around to be more cohesive. 2025-12-16 11:17:14 -06:00
e9cf1f91c4 update mapping of data return for a lead 2025-12-16 11:16:21 -06:00
e7a6bd8ed2 update mapping of data return for a lead 2025-12-16 10:52:52 -06:00
948c0b07a4 fix client query 2025-12-16 10:51:18 -06:00
rocketdebris
b86d9a8861 Styling changes to the CRM page. More in-line with LMN. Removed Status Buttons, moved some information around to be more cohesive. 2025-12-16 10:39:04 -05:00
rocketdebris
e508f265a1 Added Client Type column to the CRM table. Removed unwanted Status Buttons. 2025-12-16 09:08:13 -05:00
1a837ffcfc update sidebar layout, update theme, update client table data 2025-12-16 06:45:50 -06:00
rocketdebris
011080e0f8 Added the TodoCharts to the main Clients page as well. 2025-12-15 19:50:40 -05:00
rocketdebris
d154c28ed2 Added a TodoChart component, adding a reactive Todo/Completed items tracker. 2025-12-15 19:39:54 -05:00
d4545d753a fix client creation 2025-12-13 10:14:08 -06:00
0c1bb52f1b fix client creation 2025-12-13 08:23:03 -06:00
0d7976b140 lead/customer backend 2025-12-12 16:09:03 -06:00
ae5cb23e6e lead/customer logic for new client in backend 2025-12-12 15:55:26 -06:00
0c55c9996f add theme store and input, update some components to use theme 2025-12-12 14:21:42 -06:00
43c205e577 fix button link 2025-12-12 08:06:11 -06:00
440381265c updated calendar to be tabular, moved bid schedule there, started client view refactoring 2025-12-12 08:03:48 -06:00
c5c5ffb0fb lookup focus functionality in client table view 2025-12-11 16:31:43 -06:00
a01f72bbc1 fix sidebar speed dial buttons 2025-12-11 15:18:27 -06:00
rocketdebris
080d9ff1b4 Refactored onsite meeting to bids all over the codebase. 2025-12-10 10:25:37 -05:00
rocketdebris
6e2535763f Added the Customer/Property Lookup buttons to the sidebar, renamed On-Site Meetings to Bids and added the Note to Create New Menu. 2025-12-10 10:10:41 -05:00
8ed083fce1 lots of updates 2025-12-09 16:38:58 -06:00
02c48e6108 update doctype workflows and events for Sales Order creation from Quotation 2025-12-08 16:58:16 -06:00
483942d2ca log body 2025-12-03 14:55:25 -06:00
2473bdfe89 fixed empty email body 2025-12-03 14:54:46 -06:00
fa9d64bb1a update events 2025-12-03 11:55:57 -06:00
041e9f5461 email 2025-12-03 11:51:59 -06:00
07c1181d6e update js inject file to bust caching 2025-12-03 07:15:58 -06:00
4b55d8790e revert family member change 2025-12-02 16:54:15 -06:00
84016e797e family member bug fix 2025-12-02 16:51:13 -06:00
fe46f18d60 update bugs 2025-12-02 16:39:10 -06:00
rocketdebris
520e239741 Added button and modal for sending estimates, functionality still to come. 2025-12-02 16:01:52 -05:00
rocketdebris
60747a8766 Style changes so that icons are visible. 2025-12-02 15:07:02 -05:00
rocketdebris
8256af1ffd Added color handling for Invoice status-buttons. 2025-12-02 14:02:01 -05:00
rocketdebris
f2bfdbebf5 Added stub for handling status button clicks for invoices. 2025-12-02 14:02:01 -05:00
rocketdebris
a4022a8920 Added rudimentary currency rendering for grand total. 2025-12-02 14:02:01 -05:00
0663cd2d8c estimate view 2025-12-02 12:28:05 -06:00
rocketdebris
7c738ef9f9 Removed warning Toast for Invoices Data Table. 2025-12-02 13:23:09 -05:00
rocketdebris
69c96d1898 Added an Invoice List Page. 2025-12-02 13:18:43 -05:00
77fce34c05 switch non-working features to have notifications 2025-12-02 09:58:29 -06:00
rocketdebris
0bad4dbc95 Began implementation of the send estimate feature. 2025-12-01 16:35:21 -05:00
rocketdebris
cd89156ca0 Updated estimate creation notifications to use the error and success notifications correctly. 2025-12-01 14:30:47 -05:00
rocketdebris
2ac06d8532 Further merge conflicts. 2025-12-01 13:33:48 -05:00
rocketdebris
3fa8b1ce99 Fixed merge conflict. 2025-12-01 13:15:40 -05:00
c35689aaf8 estimate 2025-11-26 17:07:53 -06:00
afa161a0cf create estimate 2025-11-26 16:47:53 -06:00
2ea20a86e3 client creation bug fixes 2025-11-25 08:03:46 -06:00
cb33d0c3b3 get create client working 2025-11-25 06:22:55 -06:00
4a3576168a update sidebar 2025-11-25 06:22:55 -06:00
rocketdebris
a1e9de489d Added the functional Estimates page showing the list of estimates in the system. 2025-11-24 21:44:54 -05:00
rocketdebris
888b135fe0 Fixed redirect to new client page. 2025-11-21 15:21:43 -05:00
03a230b8f7 big updates 2025-11-21 12:29:31 -06:00
34f2c110d6 build out client page, edit functionality, create functionality, data massager 2025-11-19 22:25:16 -06:00
rocketdebris
f510645a31 Began adding UI to client detail page. 2025-11-17 11:50:37 -05:00
0b280cec8e add query for client details 2025-11-13 15:17:43 -06:00
172927e069 add changes 2025-11-12 15:26:39 -06:00
1af288aa62 add notifiaction handling, error handling 2025-11-12 15:13:49 -06:00
ce708f5209 clean up api utils 2025-11-12 10:47:48 -06:00
b087ea673e clean up db method files 2025-11-12 10:25:41 -06:00
a675af5cf8 fix count filtering with or statements 2025-11-12 09:00:11 -06:00
19369cd21f update to make customer to bill primary 2025-11-12 07:06:43 -06:00
3b86ed0ee7 fix filtering and sorting 2025-11-12 07:01:26 -06:00
rocketdebris
ba507f8e6a Added tabs to Client detail page. 2025-11-11 20:41:45 -05:00
254cbf37a1 remove export button for now 2025-11-11 15:53:26 -06:00
10cda824e8 fix data table 2025-11-11 15:53:26 -06:00
rocketdebris
8fa55bea70 Added Customer Name column to the Client's page and included the ability to search/filter based on it. 2025-11-11 15:32:34 -05:00
4e1fbeefea fix path params 2025-11-11 11:43:04 -06:00
f44bbbe7d4 template client page 2025-11-11 11:28:32 -06:00
df1df3f882 add table actions to datatable, client page, start writing db method for clients 2025-11-11 09:50:23 -06:00
a67e86af44 add scripts for doctype changes 2025-11-10 09:51:01 -06:00
80aae6f09b attempt chart component 2025-11-07 19:05:11 -06:00
Casey Wittrock
6025a9890a add api method 2025-11-07 15:53:15 -06:00
Casey Wittrock
3ea47162c5 add whitlist metho for getting status counts 2025-11-07 15:46:22 -06:00
rocketdebris
84bdff4613 Added Warranty Modal. 2025-11-07 16:46:12 -05:00
rocketdebris
9033ae9d79 Added the Invoice Modal. 2025-11-07 16:14:18 -05:00
b5adab5786 update styling: 2025-11-07 15:04:31 -06:00
rocketdebris
5b3a6655cd Fixed checkbox not showing up. 2025-11-07 15:35:31 -05:00
rocketdebris
20b3c1166f Added Job Modal. 2025-11-07 15:28:22 -05:00
rocketdebris
721b50d058 Bugfixed the Estimate Modal. 2025-11-07 14:13:50 -05:00
918549a603 fix markdown 2025-11-07 12:51:12 -06:00
208337155a update estimeate submit button text 2025-11-07 12:33:25 -06:00
82f9b1aac2 add date picker 2025-11-07 12:26:47 -06:00
rocketdebris
09a514ae86 Added missing api call for estimates. 2025-11-07 12:42:06 -05:00
rocketdebris
88171caee6 Added Estimate Modal and solved some merge conflicts. 2025-11-07 11:46:15 -05:00
942e9beeab hook jobs up with real data 2025-11-07 08:03:36 -06:00
f4d04e90a9 finish client table for now 2025-11-07 07:50:19 -06:00
60d3f35988 massage data 2025-11-06 18:24:33 -06:00
rocketdebris
616fa1be79 Merge conflict. 2025-11-06 16:14:07 -05:00
40c4a5a37f moving towards real data 2025-11-06 13:00:19 -06:00
rocketdebris
ac3c05cb78 Connected data for Projects/Jobs. 2025-11-06 09:39:16 -05:00
rocketdebris
b6d6a8b788 Updated the api call used by the add button in the clients page to use our new dialog. 2025-11-05 15:04:45 -05:00
9431a0502a update filter functionality 2025-11-04 09:09:56 -06:00
464c62d1e5 add global loading state, update to use real data for clients table 2025-11-04 08:33:14 -06:00
2cfe7ed8e6 update create client form 2025-11-03 01:54:55 -06:00
1f33262e90 add whitelist proxy request method for external api calls 2025-11-02 20:46:50 -06:00
697add510f switch calendar to be per foreman, added date select, added foreman select 2025-10-31 01:38:20 -05:00
5bf9b45861 add drag and drop for calendar 2025-10-30 03:48:21 -05:00
8d9bb81fe2 create form and modal components, update datatable persistant filtering 2025-10-30 03:21:41 -05:00
b70e08026d add timesheets page and update homepage modules: 2025-10-28 00:53:05 -05:00
0c921a3897 test commit 2025-10-28 00:33:51 -05:00
0ce7addff7 mergepaths 2025-10-28 00:24:14 -05:00
Casey Wittrock
2828c0f9c8 add warranties page 2025-10-28 00:16:35 -05:00
44d47db0ad build mock views 2025-10-24 14:05:10 -05:00
b0ed2c68f9 adjust view window 2025-10-24 03:51:07 -05:00
403b29a8b8 build out mock views 2025-10-24 03:40:53 -05:00
rocketdebris
6dac3bfb02 Merge branch 'ben-demo' into clients-table 2025-10-23 19:13:15 -04:00
73a3b67e0f build datatable 2025-10-23 17:08:17 -05:00
rocketdebris
be718106c3 Added Vuetify for their calendar. 2025-10-23 16:52:39 -04:00
rocketdebris
26c9e25c5b Added Home to sidebar. 2025-10-23 16:43:40 -04:00
6b0a3d9fa9 add api class 2025-10-23 14:33:08 -05:00
9f3c553740 update 2025-10-23 12:11:29 -05:00
d6c2e1cb4a fix layout 2025-10-22 11:48:24 -05:00
cdb1bb30b1 build 2025-10-22 02:21:19 -05:00
131 changed files with 42154 additions and 170 deletions

2
.gitignore vendored
View File

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

View File

View File

View File

@ -0,0 +1,109 @@
import frappe
import json
from custom_ui.db_utils import build_error_response, build_success_response
from custom_ui.services import ClientService, AddressService
@frappe.whitelist()
def get_address_by_full_address(full_address):
"""Get address by full_address, including associated contacts."""
print(f"DEBUG: get_address_by_full_address called with full_address: {full_address}")
try:
address = AddressService.get_address_by_full_address(full_address)
return build_success_response(AddressService.build_full_dict(address))
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_address(address_name):
"""Get a specific address by name."""
try:
address = AddressService.get_or_throw(address_name)
return build_success_response(address.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
# @frappe.whitelist() #### DEPRECATED FUNCTION
# def get_contacts_for_address(address_name):
# """Get contacts linked to a specific address."""
# try:
# address = AddressService.get_or_throw(address_name)
# contacts = []
# for contact_link in address.custom_linked_contacts:
# contact = frappe.get_doc("Contact", contact_link.contact)
# contacts.append(contact.as_dict())
# return build_success_response(contacts)
# except Exception as e:
# return build_error_response(str(e), 500)
@frappe.whitelist()
def get_addresses(fields=["*"], filters={}):
"""Get addresses with optional filtering."""
if isinstance(fields, str):
fields = json.loads(fields)
if isinstance(filters, str):
filters = json.loads(filters)
if fields[0] != "*" and len(fields) == 1:
pluck = fields[0]
fields = None
print(f"Getting addresses with fields: {fields} and filters: {filters} and pluck: {pluck}")
try:
addresses = frappe.get_all(
"Address",
fields=fields,
filters=filters,
order_by="address_line1 desc",
pluck=pluck
)
return build_success_response(addresses)
except Exception as e:
frappe.log_error(message=str(e), title="Get Addresses Failed")
return build_error_response(str(e), 500)
def update_address(address_data):
"""Update an existing address."""
if isinstance(address_data, str):
address_data = json.loads(address_data)
address_doc = check_and_get_address_by_name(address_data.get("name"))
for key, value in address_data.items():
setattr(address_doc, key, value)
address_doc.save(ignore_permissions=True)
return address_doc
def address_exists(address_line1, address_line2, city, state, pincode):
"""Check if an address with the given details already exists."""
filters = {
"address_line1": address_line1,
"address_line2": address_line2,
"city": city,
"state": state,
"pincode": pincode
}
return frappe.db.exists("Address", filters) is not None
def calculate_address_title(customer_name, address_data):
return f"{customer_name} - {address_data.get('address_line1', '')}, {address_data.get('city', '')} - {address_data.get('type', '')}"
def create_address_links(address_doc, client_doc, contact_docs):
print("#####DEBUG: Linking customer to address.")
print("#####DEBUG: Client Doc:", client_doc.as_dict(), "Address Doc:", address_doc.as_dict(), "Contact Docs:", [c.as_dict() for c in contact_docs])
address_doc.append("links", {
"link_doctype": client_doc.doctype,
"link_name": client_doc.name
})
setattr(address_doc, "custom_customer_to_bill" if client_doc.doctype == "Customer" else "lead_name", client_doc.name)
# Address -> Contact
print("#####DEBUG: Linking contacts to address.")
address_doc.custom_contact = next((c.name for c in contact_docs if c.is_primary_contact), contact_docs[0].name)
for contact_doc in contact_docs:
address_doc.append("custom_linked_contacts", {
"contact": contact_doc.name,
"email": contact_doc.email_id,
"phone": contact_doc.phone,
"role": contact_doc.role
})
address_doc.append("links", {
"link_doctype": "Contact",
"link_name": contact_doc.name
})
address_doc.save(ignore_permissions=True)

View File

@ -0,0 +1,171 @@
import frappe
import json
from custom_ui.db_utils import build_error_response, build_success_response, process_filters, process_sorting
from custom_ui.services import DbService, ClientService, AddressService, ContactService
@frappe.whitelist()
def get_week_bid_meetings(week_start, week_end):
"""Get On-Site Meetings scheduled within a specific week."""
try:
meetings = frappe.db.get_all(
"On-Site Meeting",
fields=["*"],
filters=[
["start_time", ">=", week_start],
["start_time", "<=", week_end]
],
order_by="start_time asc"
)
for meeting in meetings:
address_doc = AddressService.get_or_throw(meeting["address"])
meeting["address"] = address_doc.as_dict()
contact_doc = ContactService.get_or_throw(meeting["contact"]) if meeting.get("contact") else None
meeting["contact"] = contact_doc.as_dict() if contact_doc else None
return build_success_response(meetings)
except Exception as e:
frappe.log_error(message=str(e), title="Get Week On-Site Meetings Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_bid_meetings(fields=["*"], filters={}):
"""Get paginated On-Site Meetings with filtering and sorting support."""
try:
print("DEBUG: Raw bid meeting options received:", filters)
processed_filters = process_filters(filters)
meetings = frappe.db.get_all(
"On-Site Meeting",
fields=fields,
filters=processed_filters,
order_by="creation desc"
)
for meeting in meetings:
address_doc = frappe.get_doc("Address", meeting["address"])
meeting["address"] = address_doc.as_dict()
return build_success_response(
meetings
)
except Exception as e:
frappe.log_error(message=str(e), title="Get On-Site Meetings Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_unscheduled_bid_meetings():
"""Get On-Site Meetings that are unscheduled."""
try:
meetings = frappe.db.get_all(
"On-Site Meeting",
fields=["*"],
filters={"status": "Unscheduled"},
order_by="creation desc"
)
for meeting in meetings:
address_doc = AddressService.get_or_throw(meeting["address"])
meeting["address"] = address_doc.as_dict()
# client_doc = ClientService.get_client_doctype(meeting["party_name"])
# meeting["client"] = client_doc.as_dict() if client_doc else None
contact_doc = ContactService.get_or_throw(meeting["contact"]) if meeting.get("contact") else None
meeting["contact"] = contact_doc.as_dict() if contact_doc else None
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 get_bid_meeting(name):
"""Get a specific On-Site Meeting by name."""
try:
meeting = frappe.get_doc("On-Site Meeting", name)
meeting_dict = meeting.as_dict()
# Get the full address data
if meeting_dict.get("address"):
address_doc = AddressService.get_or_throw(meeting_dict["address"])
meeting_dict["address"] = address_doc.as_dict()
if meeting_dict.get("contact"):
contact_doc = ContactService.get_or_throw(meeting_dict["contact"])
meeting_dict["contact"] = contact_doc.as_dict()
return build_success_response(meeting_dict)
except frappe.DoesNotExistError:
return build_error_response(f"On-Site Meeting '{name}' does not exist.", 404)
except Exception as e:
frappe.log_error(message=str(e), title="Get On-Site Meeting Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def create_bid_meeting(data):
"""Create a new On-Site Meeting with Unscheduled status."""
if isinstance(data, str):
data = json.loads(data)
try:
print(f"DEBUG: Creating meeting with data='{data}'")
address_doc = DbService.get_or_throw("Address", data.get("address"))
# Create the meeting with Unscheduled status
meeting = frappe.get_doc({
"doctype": "On-Site Meeting",
"address": address_doc.name,
"notes": data.get("notes") or "",
"status": "Unscheduled",
"company": data.get("company"),
"contact": data.get("contact"),
"party_type": address_doc.customer_type,
"party_name": address_doc.customer_name,
"project_template": data.get("project_template")
})
meeting.insert(ignore_permissions=True)
# ClientService.append_link(address_doc.customer_name, "onsite_meetings", "onsite_meeting", meeting.name)
# AddressService.append_link(address_doc.name, "onsite_meetings", "onsite_meeting", meeting.name)
meeting.flags.ignore_permissions = True
frappe.db.commit()
# Clear any auto-generated messages from Frappe
frappe.local.message_log = []
print(f"DEBUG: Meeting created successfully: {meeting.name}")
return build_success_response(meeting.as_dict())
except Exception as e:
frappe.log_error(message=str(e), title="Create On-Site Meeting Failed")
return build_error_response(str(e), 500)
@frappe.whitelist()
def update_bid_meeting(name, data):
"""Update an existing On-Site Meeting."""
try:
if isinstance(data, str):
data = json.loads(data)
meeting = frappe.get_doc("On-Site Meeting", name)
# Only update fields that are explicitly provided in the data
for key, value in data.items():
print(f"DEBUG: Updating field '{key}' to value '{value}'")
if key == "address" and value is not None:
# Convert full address to address name
value = frappe.db.get_value("Address", {"full_address": value}, "name")
meeting.set(key, value)
elif key in ["assigned_employee", "completed_by"] and value is not None:
# Convert employee name to employee ID
value = frappe.db.get_value("Employee", {"employee_name": value}, "name")
meeting.set(key, value)
else:
# For all other fields, set the value as-is (including None to clear fields)
meeting.set(key, value)
print(f"DEBUG: Field '{key}' updated to '{meeting.get(key)}'")
meeting.save()
frappe.db.commit()
return build_success_response(meeting.as_dict())
except frappe.DoesNotExistError:
return build_error_response(f"On-Site Meeting '{name}' does not exist.", 404)
except Exception as e:
return build_error_response(str(e), 500)

482
custom_ui/api/db/clients.py Normal file
View File

@ -0,0 +1,482 @@
import frappe, json
from custom_ui.db_utils import build_error_response, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, map_lead_client, build_address_title
from erpnext.crm.doctype.lead.lead import make_customer
from custom_ui.api.db.addresses import address_exists
from custom_ui.api.db.contacts import check_and_get_contact, create_contact, create_contact_links
from custom_ui.services import AddressService, ContactService, ClientService
# ===============================================================================
# CLIENT MANAGEMENT API METHODS
# ===============================================================================
@frappe.whitelist()
def get_client_status_counts(weekly=False, week_start_date=None, week_end_date=None):
"""Get counts of clients by status categories with optional weekly filtering."""
# Build base filters for date range if weekly filtering is enabled
try:
base_filters = {}
if weekly and week_start_date and week_end_date:
# Assuming you have a date field to filter by - adjust the field name as needed
# Common options: creation, modified, custom_date_field, etc.
base_filters["creation"] = ["between", [week_start_date, week_end_date]]
# Helper function to merge base filters with status filters
def get_filters(status_field, status_value):
filters = {status_field: status_value}
filters.update(base_filters)
return filters
onsite_meeting_scheduled_status_counts = {
"label": "On-Site Meeting Scheduled",
"not_started": frappe.db.count("Address", filters=get_filters("custom_onsite_meeting_scheduled", "Not Started")),
"in_progress": frappe.db.count("Address", filters=get_filters("custom_onsite_meeting_scheduled", "In Progress")),
"completed": frappe.db.count("Address", filters=get_filters("custom_onsite_meeting_scheduled", "Completed"))
}
estimate_sent_status_counts = {
"label": "Estimate Sent",
"not_started": frappe.db.count("Address", filters=get_filters("custom_estimate_sent_status", "Not Started")),
"in_progress": frappe.db.count("Address", filters=get_filters("custom_estimate_sent_status", "In Progress")),
"completed": frappe.db.count("Address", filters=get_filters("custom_estimate_sent_status", "Completed"))
}
job_status_counts = {
"label": "Job Status",
"not_started": frappe.db.count("Address", filters=get_filters("custom_job_status", "Not Started")),
"in_progress": frappe.db.count("Address", filters=get_filters("custom_job_status", "In Progress")),
"completed": frappe.db.count("Address", filters=get_filters("custom_job_status", "Completed"))
}
payment_received_status_counts = {
"label": "Payment Received",
"not_started": frappe.db.count("Address", filters=get_filters("custom_payment_received_status", "Not Started")),
"in_progress": frappe.db.count("Address", filters=get_filters("custom_payment_received_status", "In Progress")),
"completed": frappe.db.count("Address", filters=get_filters("custom_payment_received_status", "Completed"))
}
status_dicts = [
onsite_meeting_scheduled_status_counts,
estimate_sent_status_counts,
job_status_counts,
payment_received_status_counts
]
categories = []
for status_dict in status_dicts:
category = {
"label": status_dict["label"],
"statuses": [
{
"color": "red",
"label": "Not Started",
"count": status_dict["not_started"]
},
{
"color": "yellow",
"label": "In Progress",
"count": status_dict["in_progress"]
},
{
"color": "green",
"label": "Completed",
"count": status_dict["completed"]
}
]
}
categories.append(category)
return build_success_response(categories)
except frappe.ValidationError as ve:
return build_error_response(str(ve), 400)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_client(client_name):
"""Get detailed information for a specific client including address, customer, and projects."""
print("DEBUG: get_client called with client_name:", client_name)
try:
clientData = {"addresses": [], "contacts": [], "jobs": [], "sales_invoices": [], "payment_entries": [], "tasks": []}
customer = check_and_get_client_doc(client_name)
if not customer:
return build_error_response(f"Client with name '{client_name}' does not exist.", 404)
print("DEBUG: Retrieved customer/lead document:", customer.as_dict())
clientData = {**clientData, **customer.as_dict()}
if customer.doctype == "Lead":
clientData.update(map_lead_client(clientData))
links = []
if customer.doctype == "Customer":
for address_link in customer.custom_select_address:
address_doc = frappe.get_doc("Address", address_link.address_name)
clientData["addresses"].append(address_doc.as_dict())
for contact_link in customer.custom_add_contacts:
contact_doc = frappe.get_doc("Contact", contact_link.contact)
clientData["contacts"].append(contact_doc.as_dict())
else:
links = frappe.get_all(
"Dynamic Link",
filters={
"link_doctype": "Lead",
"link_name": customer.name,
"parenttype": ["in", ["Address", "Contact"]],
},
fields=[
"parenttype as link_doctype",
"parent as link_name",
]
)
print("DEBUG: Retrieved links from lead:", links)
for link in links:
print("DEBUG: Processing link:", link)
linked_doc = frappe.get_doc(link["link_doctype"], link["link_name"])
if link["link_doctype"] == "Contact":
clientData["contacts"].append(linked_doc.as_dict())
elif link["link_doctype"] == "Address":
clientData["addresses"].append(linked_doc.as_dict())
# TODO: Continue getting other linked docs like jobs, invoices, etc.
print("DEBUG: Final client data prepared:", clientData)
return build_success_response(clientData)
except frappe.ValidationError as ve:
return build_error_response(str(ve), 400)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_client_v2(client_name):
"""Get detailed information for a specific client including address, customer, and projects."""
print("DEBUG: get_client_v2 called with client_name:", client_name)
try:
clientData = {"addresses": [], "jobs": [], "payment_entries": [], "tasks": []}
customer = check_and_get_client_doc(client_name)
if not customer:
return build_error_response(f"Client with name '{client_name}' does not exist.", 404)
print("DEBUG: Retrieved customer/lead document:", customer.as_dict())
clientData = {**clientData, **customer.as_dict()}
clientData["contacts"] = [ContactService.get_or_throw(link.contact) for link in clientData["contacts"]]
clientData["addresses"] = [AddressService.get_or_throw(link.address) for link in clientData["properties"]]
if clientData["doctype"] == "Lead":
clientData["customer_name"] = customer.custom_customer_name
# TODO: Continue getting other linked docs like jobs, invoices, etc.
print("DEBUG: Final client data prepared:", clientData)
return build_success_response(clientData)
except frappe.ValidationError as ve:
return build_error_response(str(ve), 400)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_clients_table_data(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated client table data with filtering and sorting support."""
try:
print("DEBUG: Raw client table query received:", {
"filters": filters,
"sortings": sortings,
"page": page,
"page_size": page_size
})
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
print("DEBUG: Processed filters:", processed_filters)
print("DEBUG: Processed sortings:", processed_sortings)
# Handle count with proper OR filter support
if is_or:
count = frappe.db.sql(*get_count_or_filters("Address", processed_filters))[0][0]
else:
count = frappe.db.count("Address", filters=processed_filters)
print("DEBUG: Count of addresses matching filters:", count)
address_names = frappe.db.get_all(
"Address",
fields=["name"],
filters=processed_filters if not is_or else None,
or_filters=processed_filters if is_or else None,
limit=page_size,
start=(page - 1) * page_size,
order_by=processed_sortings
)
addresses = [frappe.get_doc("Address", addr["name"]).as_dict() for addr in address_names]
tableRows = []
for address in addresses:
is_lead = address.customer_type == "Lead"
print("##########IS LEAD:", is_lead)
tableRow = {}
links = address.links
# customer_links = [link for link in links if link.link_doctype == "Customer"] if links else None
customer_name = address.get("customer_name")
# if not customer_links:
# customer_links = [link for link in links if link.link_doctype == "Lead"] if links else None
# is_lead = True if customer_links else False
# if not customer_name and not customer_links:
# customer_name = frappe.get_value("Lead", address.get("customer_name"), "custom_customer_name")
if is_lead:
# print("DEBUG: No customer to bill. Customer links found:", customer_links)
customer_name = frappe.get_value("Lead", address.get("customer_name"), "custom_customer_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']}"
)
print("########IS LEAD @TABLE ROW:", is_lead)
tableRow["client_type"] = "Lead" if is_lead else "Customer"
# tableRow["appointment_scheduled_status"] = address.custom_onsite_meeting_scheduled
# tableRow["estimate_sent_status"] = address.custom_estimate_sent_status
# tableRow["job_status"] = address.custom_job_status
tableRow["payment_received_status"] = address.custom_payment_received_status
tableRows.append(tableRow)
tableDataDict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(tableDataDict)
except frappe.ValidationError as ve:
return build_error_response(str(ve), 400)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def update_client_info(client_name, data):
"""Update client information for a given client."""
try:
data = json.loads(data)
print("DEBUG: update_client_info called with client_name:", client_name, "and data:", data)
client_doc = check_and_get_client_doc(client_name)
if not client_doc:
return build_error_response(f"Client with name '{client_name}' does not exist.", 404)
address_updates = data.get("addresses", [])
contact_updates = data.get("contacts", [])
customer_updates = data.get("customer", {})
# Update addresses
if address_updates:
for addr_data in address_updates:
update_address(addr_data)
# Update contacts
if contact_updates:
for contact_data in contact_updates:
update_contact(contact_data)
# Update customer/lead
if customer_updates:
for field, value in customer_updates.items():
if hasattr(client_doc, field):
setattr(client_doc, field, value)
client_doc.save(ignore_permissions=True)
frappe.local.message_log = []
return get_client(client_name)
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 a client (customer and address)."""
try:
data = json.loads(data)
print("#####DEBUG: Create client data received:", data)
customer_name = data.get("customer_name")
contacts = data.get("contacts", [])
addresses = data.get("addresses", [])
# Check for existing address
client_doc = check_and_get_client_doc(customer_name)
if client_doc:
return build_error_response(f"Client with name '{customer_name}' already exists.", 400)
for address in addresses:
if address_exists(
address.get("address_line1"),
address.get("address_line2"),
address.get("city"),
address.get("state"),
address.get("pincode")
):
return build_error_response("This address already exists. Please use a different address or search for the address to find the associated client.", 400)
# Handle customer creation/update
print("#####DEBUG: Creating new lead.")
customer_type = data.get("customer_type", "Individual")
primary_contact = find_primary_contact_or_throw(contacts)
lead_data = {
"first_name": primary_contact.get("first_name"),
"last_name": primary_contact.get("last_name"),
"email_id": primary_contact.get("email"),
"phone": primary_contact.get("phone_number"),
"custom_customer_name": customer_name,
"customer_type": customer_type,
"companies": [{ "company": data.get("company_name")
}]
}
if customer_type == "Company":
lead_data["company_name"] = data.get("customer_name")
client_doc = create_lead(lead_data)
print(f"#####DEBUG: {client_doc.doctype}:", client_doc.as_dict())
#Handle contact creation
contact_docs = []
for contact_data in contacts:
if isinstance(contact_data, str):
contact_data = json.loads(contact_data)
print("#####DEBUG: Processing contact data:", contact_data)
contact_doc = check_and_get_contact(
contact_data.get("first_name"),
contact_data.get("last_name"),
contact_data.get("email"),
contact_data.get("phone_number")
)
if not contact_doc:
print("#####DEBUG: No existing contact found. Creating new contact.")
contact_doc = ContactService.create({
"first_name": contact_data.get("first_name"),
"last_name": contact_data.get("last_name"),
"role": contact_data.get("contact_role", "Other"),
"custom_email": contact_data.get("email"),
"is_primary_contact": 1 if contact_data.get("is_primary", False) else 0,
"customer_type": "Lead",
"customer_name": client_doc.name,
"email_ids": [{
"email_id": contact_data.get("email"),
"is_primary": 1
}],
"phone_nos": [{
"phone": contact_data.get("phone_number"),
"is_primary_mobile_no": 1,
"is_primary_phone": 1
}]
})
ContactService.link_contact_to_customer(contact_doc, "Lead", client_doc.name)
contact_docs.append(contact_doc)
# Link all contacts to client after creating them
client_doc.reload()
for idx, contact_data in enumerate(contacts):
if isinstance(contact_data, str):
contact_data = json.loads(contact_data)
contact_doc = contact_docs[idx]
client_doc.append("contacts", {
"contact": contact_doc.name
})
if contact_data.get("is_primary", False):
client_doc.primary_contact = contact_doc.name
client_doc.save(ignore_permissions=True)
# Handle address creation
address_docs = []
for address in addresses:
is_billing = True if address.get("is_billing_address") else False
print("#####DEBUG: Creating address with data:", address)
address_doc = AddressService.create_address({
"address_title": AddressService.build_address_title(customer_name, address),
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
"address_type": "Billing" if is_billing else "Service",
"city": address.get("city"),
"state": address.get("state"),
"country": "United States",
"pincode": address.get("pincode"),
"customer_type": "Lead",
"customer_name": client_doc.name,
"companies": [{ "company": data.get("company_name") }]
})
AddressService.link_address_to_customer(address_doc, "Lead", client_doc.name)
address_doc.reload()
if is_billing:
client_doc.custom_billing_address = address_doc.name
client_doc.save(ignore_permissions=True)
for contact_to_link_idx in address.get("contacts", []):
contact_doc = contact_docs[contact_to_link_idx]
AddressService.link_address_to_contact(address_doc, contact_doc.name)
address_doc.reload()
ContactService.link_contact_to_address(contact_doc, address_doc.name)
primary_contact = contact_docs[address.get("primary_contact)", 0)]
AddressService.set_primary_contact(address_doc.name, primary_contact.name)
address_docs.append(address_doc)
# Link all addresses to client after creating them
client_doc.reload()
for address_doc in address_docs:
client_doc.append("properties", {
"address": address_doc.name
})
client_doc.save(ignore_permissions=True)
frappe.local.message_log = []
return build_success_response({
"customer": client_doc.as_dict(),
"address": [address_doc.as_dict() for address_doc in address_docs],
"contacts": [contact_doc.as_dict() for contact_doc in contact_docs]
})
except frappe.ValidationError as ve:
return build_error_response(str(ve), 400)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_client_names(search_term):
"""Search for client names matching the search term."""
try:
search_pattern = f"%{search_term}%"
client_names = frappe.db.get_all(
"Customer",
filters={"customer_name": ["like", search_pattern]},
pluck="name")
return build_success_response(client_names)
except Exception as e:
return build_error_response(str(e), 500)
def check_if_customer(client_name):
"""Check if the given client name corresponds to a Customer."""
return frappe.db.exists("Customer", client_name) is not None
def check_and_get_client_doc(client_name):
"""Check if a client exists as Customer or Lead and return the document."""
print("DEBUG: Checking for existing client with name:", client_name)
customer = None
if check_if_customer(client_name):
print("DEBUG: Client found as Customer.")
customer = frappe.get_doc("Customer", client_name)
else:
print("DEBUG: Client not found as Customer. Checking Lead.")
lead_name = frappe.db.get_all("Lead", pluck="name", filters={"company_name": client_name})
if not lead_name:
lead_name = frappe.db.get_all("Lead", pluck="name", filters={"lead_name": client_name})
if not lead_name:
lead_name = frappe.db.get_all("Lead", pluck="name", filters={"custom_customer_name": client_name})
if lead_name:
print("DEBUG: Client found as Lead.")
customer = frappe.get_doc("Lead", lead_name[0])
return customer
def convert_lead_to_customer(lead_name):
lead = frappe.get_doc("Lead", lead_name)
customer = make_customer(lead)
customer.insert(ignore_permissions=True)
def create_lead(lead_data):
lead = frappe.get_doc({
"doctype": "Lead",
**lead_data
})
lead.insert(ignore_permissions=True)
return lead
def get_customer_or_lead(client_name):
if check_if_customer(client_name):
return frappe.get_doc("Customer", client_name)
else:
lead_name = frappe.db.get_all("Lead", pluck="name", filters={"lead_name": client_name})[0]
return frappe.get_doc("Lead", lead_name)
def find_primary_contact_or_throw(contacts):
for contact in contacts:
if contact.get("is_primary"):
print("#####DEBUG: Primary contact found:", contact)
return contact
raise ValueError("No primary contact found in contacts list.")

View File

@ -0,0 +1,56 @@
import frappe
def existing_contact_name(first_name: str, last_name: str, email: str, phone: str) -> str:
"""Check if a contact exists based on provided details."""
filters = {
"first_name": first_name,
"last_name": last_name,
"email_id": email,
"phone": phone
}
existing_contacts = frappe.db.get_all("Contact", pluck="name", filters=filters)
return existing_contacts[0] if existing_contacts else None
def get_contact(contact_name: str):
"""Retrieve a contact document by name."""
contact = frappe.get_doc("Contact", contact_name)
print("Retrieved existing contact:", contact.as_dict())
return contact
def check_and_get_contact(first_name: str, last_name: str, email: str, phone: str):
"""Check if a contact exists and return the contact document if found."""
contact_name = existing_contact_name(first_name, last_name, email, phone)
if contact_name:
return get_contact(contact_name)
return None
def check_and_get_contact_by_name(contact_name: str):
"""Check if a contact exists by name and return the contact document if found."""
if frappe.db.exists("Contact", contact_name):
return get_contact(contact_name)
return None
def create_contact(contact_data: dict):
"""Create a new contact."""
contact = frappe.get_doc({
"doctype": "Contact",
**contact_data
})
contact.insert(ignore_permissions=True)
print("Created new contact:", contact.as_dict())
return contact
def create_contact_links(contact_docs, client_doc, address_doc):
print("#####DEBUG: Linking contacts to client and address.")
for contact_doc in contact_docs:
contact_doc.address = address_doc.name
contact_doc.append("links", {
"link_doctype": client_doc.doctype,
"link_name": client_doc.name
})
contact_doc.append("links", {
"link_doctype": "Address",
"link_name": address_doc.name
})
contact_doc.custom_customer = client_doc.name
contact_doc.save(ignore_permissions=True)

View File

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

View File

@ -0,0 +1,490 @@
import frappe, json
from frappe.utils.pdf import get_pdf
from custom_ui.api.db.general import get_doc_history
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
from werkzeug.wrappers import Response
from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
from custom_ui.services import DbService, ClientService, AddressService, ContactService
# ===============================================================================
# ESTIMATES & INVOICES API METHODS
# ===============================================================================
@frappe.whitelist()
def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated estimate table data with filtering and sorting support."""
print("DEBUG: Raw estimate options received:", filters, sortings, page, page_size)
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
if is_or:
count = frappe.db.sql(*get_count_or_filters("Quotation", processed_filters))[0][0]
else:
count = frappe.db.count("Quotation", filters=processed_filters)
print(f"DEBUG: Number of estimates returned: {count}")
estimates = frappe.db.get_all(
"Quotation",
fields=["*"],
filters=processed_filters if not is_or else None,
or_filters=processed_filters if is_or else None,
limit=page_size,
start=page * page_size,
order_by=processed_sortings
)
tableRows = []
for estimate in estimates:
full_address = frappe.db.get_value("Address", estimate.get("custom_job_address"), "full_address")
tableRow = {}
tableRow["id"] = estimate["name"]
tableRow["address"] = full_address
tableRow["quotation_to"] = estimate.get("quotation_to", "")
tableRow["customer"] = estimate.get("party_name", "")
tableRow["status"] = estimate.get("custom_current_status", "")
tableRow["date"] = estimate.get("transaction_date", "")
tableRow["order_type"] = estimate.get("order_type", "")
tableRow["items"] = estimate.get("items", "")
tableRows.append(tableRow)
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(table_data_dict)
@frappe.whitelist()
def get_quotation_items():
"""Get all available quotation items."""
try:
items = frappe.get_all("Item", fields=["*"], filters={"item_group": "SNW-S"})
return build_success_response(items)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_estimate(estimate_name):
"""Get detailed information for a specific estimate."""
try:
estimate = frappe.get_doc("Quotation", estimate_name)
est_dict = estimate.as_dict()
address_name = estimate.custom_job_address or estimate.customer_address
if address_name:
# Fetch Address Doc
address_doc = frappe.get_doc("Address", address_name).as_dict()
est_dict["full_address"] = address_doc.get("full_address")
# Logic from get_address_by_full_address to populate customer and contacts
customer_exists = frappe.db.exists("Customer", address_doc.get("custom_customer_to_bill"))
doctype = "Customer" if customer_exists else "Lead"
name = ""
if doctype == "Customer":
name = address_doc.get("custom_customer_to_bill")
else:
lead_links = address_doc.get("links", [])
lead_name = [link.link_name for link in lead_links if link.link_doctype == "Lead"]
name = lead_name[0] if lead_name else ""
if name:
address_doc["customer"] = frappe.get_doc(doctype, name).as_dict()
contacts = []
if address_doc.get("custom_linked_contacts"):
for contact_link in address_doc.get("custom_linked_contacts"):
contact_doc = frappe.get_doc("Contact", contact_link.contact)
contacts.append(contact_doc.as_dict())
address_doc["contacts"] = contacts
est_dict["address_details"] = address_doc
est_dict["history"] = get_doc_history("Quotation", estimate_name)
return build_success_response(est_dict)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_estimate_items():
items = frappe.db.get_all("Quotation Item", fields=["*"])
return build_success_response(items)
@frappe.whitelist()
def get_estimate_from_address(full_address):
address_name = frappe.db.get_value("Address", {"full_address": full_address}, "name")
quotation_name = frappe.db.get_value("Quotation", {"custom_job_address": address_name}, "name")
quotation_doc = frappe.get_doc("Quotation", quotation_name)
return build_success_response(quotation_doc.as_dict())
# quotation = frappe.db.sql("""
# SELECT q.name, q.custom_job_address
# FROM `tabQuotation` q
# JOIN `tabAddress` a
# ON q.custom_job_address = a.name
# WHERE a.full_address =%s
# """, (full_address,), as_dict=True)
# if quotation:
# return build_success_response(quotation)
# else:
# return build_error_response("No quotation found for the given address.", 404)
# @frappe.whitelist()
# def send_estimate_email(estimate_name):
# print("DEBUG: Queuing email send job for estimate:", estimate_name)
# frappe.enqueue(
# "custom_ui.api.db.estimates.send_estimate_email_job",
# estimate_name=estimate_name,
# queue="long", # or "default"
# timeout=600,
# )
# return build_success_response("Email queued for sending.")
@frappe.whitelist()
def send_estimate_email(estimate_name):
# def send_estimate_email_job(estimate_name):
try:
print("DEBUG: Sending estimate email for:", estimate_name)
quotation = frappe.get_doc("Quotation", estimate_name)
if not DbService.exists("Contact", quotation.contact_person):
return build_error_response("No email found for the customer.", 400)
party = ContactService.get_or_throw(quotation.contact_person)
email = quotation.contact_email or None
if not email:
if (getattr(party, 'email_id', None)):
email = party.email_id
elif (getattr(party, 'email_ids', None) and len(party.email_ids) > 0):
primary = next((e for e in party.email_ids if e.is_primary), None)
email = primary.email_id if primary else party.email_ids[0].email_id
if not email and quotation.custom_job_address:
address = frappe.get_doc("Address", quotation.custom_job_address)
email = getattr(address, 'email_id', None)
if not email:
return build_error_response("No email found for the customer or address.", 400)
# email = "casey@shilohcode.com"
template_name = "Quote with Actions - SNW"
template = frappe.get_doc("Email Template", template_name)
message = frappe.render_template(template.response, {"name": quotation.name})
subject = frappe.render_template(template.subject, {"doc": quotation})
print("DEBUG: Message: ", message)
print("DEBUG: Subject: ", subject)
html = frappe.get_print("Quotation", quotation.name, print_format="Quotation - SNW - Standard", letterhead=True)
print("DEBUG: Generated HTML for PDF.")
pdf = get_pdf(html)
print("DEBUG: Generated PDF for email attachment.")
frappe.sendmail(
recipients=email,
subject=subject,
content=message,
doctype="Quotation",
name=quotation.name,
read_receipt=1,
print_letterhead=1,
attachments=[{"fname": f"{quotation.name}.pdf", "fcontent": pdf}]
)
print(f"DEBUG: Email sent to {email} successfully.")
quotation.custom_current_status = "Submitted"
quotation.custom_sent = 1
quotation.save()
quotation.submit()
frappe.db.commit()
updated_quotation = frappe.get_doc("Quotation", estimate_name)
return build_success_response(updated_quotation.as_dict())
except Exception as e:
print(f"DEBUG: Error in send_estimate_email: {str(e)}")
return build_error_response(str(e), 500)
@frappe.whitelist()
def manual_response(name, response):
"""Update the response for an estimate in the UI."""
print("DEBUG: RESPONSE_RECEIVED:", name, response)
try:
if not frappe.db.exists("Quotation", name):
raise Exception("Estimate not found.")
estimate = frappe.get_doc("Quotation", name)
if estimate.docstatus != 1:
raise Exception("Estimate must be submitted to update response.")
accepted = True if response == "Accepted" else False
new_status = "Estimate Accepted" if accepted else "Lost"
estimate.custom_response = response
estimate.custom_current_status = new_status
# estimate.custom_current_status = new_status
# estimate.status = "Ordered" if accepted else "Closed"
estimate.flags.ignore_permissions = True
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
estimate.save()
return build_success_response(estimate.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist(allow_guest=True)
def update_response(name, response):
"""Update the response for a given estimate."""
print("DEBUG: RESPONSE RECEIVED:", name, response)
try:
if not frappe.db.exists("Quotation", name):
raise Exception("Estimate not found.")
estimate = frappe.get_doc("Quotation", name)
if estimate.docstatus != 1:
raise Exception("Estimate must be submitted to update response.")
accepted = True if response == "Accepted" else False
new_status = "Estimate Accepted" if accepted else "Lost"
estimate.custom_response = response
estimate.custom_current_status = new_status
estimate.custom_followup_needed = 1 if response == "Requested call" else 0
# estimate.status = "Ordered" if accepted else "Closed"
estimate.flags.ignore_permissions = True
print("DEBUG: Updating estimate with response:", response, "and status:", new_status)
estimate.save()
if accepted:
template = "custom_ui/templates/estimates/accepted.html"
# if check_if_customer(estimate.party_name):
# print("DEBUG: Party is already a customer:", estimate.party_name)
# else:
# print("DEBUG: Converting lead to customer for party:", estimate.party_name)
# convert_lead_to_customer(estimate.party_name)
elif response == "Requested call":
template = "custom_ui/templates/estimates/request-call.html"
else:
template = "custom_ui/templates/estimates/rejected.html"
html = frappe.render_template(template, {"doc": estimate})
return Response(html, mimetype="text/html")
except Exception as e:
template = "custom_ui/templates/estimates/error.html"
html = frappe.render_template(template, {"error": str(e)})
return Response(html, mimetype="text/html")
@frappe.whitelist()
def get_estimate_templates(company):
"""Get available estimate templates."""
filters = {"is_active": 1}
if company:
filters["company"] = company
try:
print("DEBUG: Fetching estimate templates for company:", company)
templates = frappe.get_all("Quotation Template", fields=["*"], filters=filters)
result = []
if not templates:
print("DEBUG: No templates found.")
return build_success_response(result)
print(f"DEBUG: Found {len(templates)} templates.")
for template in templates:
print("DEBUG: Processing template:", template)
items = frappe.get_all("Quotation Template Item",
fields=["item_code", "item_name", "description", "quantity", "discount_percentage", "rate"],
filters={"parent": template.name},
order_by="idx")
# Map fields to camelCase as requested
mapped_items = []
for item in items:
mapped_items.append({
"item_code": item.item_code,
"item_name": item.item_name,
"description": item.description,
"quantity": item.quantity,
"discount_percentage": item.discount_percentage,
"rate": item.rate
})
result.append({
"name": template.name,
"template_name": template.template_name,
"active": template.is_active,
"description": template.description,
"items": mapped_items,
"project_template": template.project_template,
})
return build_success_response(result)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def create_estimate_template(data):
"""Create a new estimate template."""
try:
print("DEBUG: Creating estimate template with data:", data)
data = json.loads(data) if isinstance(data, str) else data
doc_data = {
"doctype": "Quotation Template",
"is_active": 1,
"description": data.get("description"),
"company": data.get("company"),
"items": [],
"template_name": data.get("template_name"),
"custom_project_template": data.get("project_template", ""),
"source_quotation": data.get("source_quotation", "")
}
new_template = frappe.get_doc(doc_data)
for item in data.get("items", []):
new_template.append("items", {
"item_code": item.get("item_code"),
"item_name": item.get("item_name"),
"description": item.get("description"),
"qty": item.get("qty") or item.get("quantity"),
"rate": item.get("standard_rate") or item.get("rate"),
"discount_percentage": item.get("discount_percentage")
})
new_template.insert()
return build_success_response(new_template.name)
except Exception as e:
return build_error_response(str(e), 500)
# @frappe.whitelist()
# def create_template(data):
# """Create a new estimate template."""
# try:
# data = json.loads(data) if isinstance(data, str) else data
# print("DEBUG: Creating estimate template with data:", data)
# new_template = frappe.get_doc({
# "doctype": "Quotation Template",
# "template_name": data.get("templateName"),
# "is_active": data.get("active", 1),
# "description": data.get("description", ""),
# "company": data.get("company", ""),
# "source_quotation": data.get("source_quotation", "")
# })
# for item in data.get("items", []):
# item = json.loads(item) if isinstance(item, str) else item
# new_template.append("items", {
# "item_code": item.get("itemCode"),
# "item_name": item.get("itemName"),
# "description": item.get("description"),
# "qty": item.get("quantity"),
# "discount_percentage": item.get("discountPercentage"),
# "rate": item.get("rate")
# })
# new_template.insert()
# print("DEBUG: New estimate template created with name:", new_template.name)
# return build_success_response(new_template.as_dict())
# except Exception as e:
# return build_error_response(str(e), 500)
@frappe.whitelist()
def upsert_estimate(data):
"""Create or update an estimate."""
try:
data = json.loads(data) if isinstance(data, str) else data
print("DEBUG: Upsert estimate data:", data)
address_doc = AddressService.get_or_throw(data.get("address_name"))
estimate_name = data.get("estimate_name")
project_template = data.get("project_template", None)
# If estimate_name exists, update existing estimate
if estimate_name:
print(f"DEBUG: Updating existing estimate: {estimate_name}")
estimate = frappe.get_doc("Quotation", estimate_name)
# Update fields
# estimate.custom_installation_address = data.get("address")
# estimate.custom_job_address = data.get("address_name")
# estimate.party_name = data.get("customer")
# estimate.contact_person = data.get("contact_name")
estimate.custom_requires_half_payment = data.get("requires_half_payment", 0)
estimate.custom_project_template = project_template
estimate.custom_quotation_template = data.get("quotation_template", None)
# estimate.company = data.get("company")
# estimate.contact_email = data.get("contact_email")
# estimate.quotation_to = client_doctype
# estimate.customer_name = data.get("customer")
# estimate.customer_address = data.get("address_name")
# estimate.letter_head = data.get("company")
# estimate.from_onsite_meeting = data.get("onsite_meeting", None)
# Clear existing items and add new ones
estimate.items = []
for item in data.get("items", []):
item = json.loads(item) if isinstance(item, str) else item
estimate.append("items", {
"item_code": item.get("item_code"),
"qty": item.get("qty"),
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
})
estimate.save()
estimate_dict = estimate.as_dict()
estimate_dict["history"] = get_doc_history("Quotation", estimate_name)
print(f"DEBUG: Estimate updated: {estimate.name}")
return build_success_response(estimate_dict)
# Otherwise, create new estimate
else:
print("DEBUG: Creating new estimate")
print("DEBUG: Retrieved address name:", data.get("address_name"))
client_doc = ClientService.get_client_or_throw(address_doc.customer_name)
# billing_address = next((addr for addr in address_doc if addr.address_type == "Billing"), None)
# if billing_address:
# print("DEBUG: Found billing address:", billing_address.name)
# else:
# print("DEBUG: No billing address found for client:", client_doc.name)
new_estimate = frappe.get_doc({
"doctype": "Quotation",
"custom_requires_half_payment": data.get("requires_half_payment", 0),
"custom_job_address": data.get("address_name"),
"custom_current_status": "Draft",
"contact_email": data.get("contact_email"),
"party_name": data.get("contact_name"),
"quotation_to": "Contact",
"company": data.get("company"),
"actual_customer_name": client_doc.name,
"customer_type": address_doc.customer_type,
"customer_address": client_doc.custom_billing_address,
"contact_person": data.get("contact_name"),
"letter_head": data.get("company"),
"custom_project_template": data.get("project_template", None),
"custom_quotation_template": data.get("quotation_template", None),
"from_onsite_meeting": data.get("onsite_meeting", None)
})
for item in data.get("items", []):
item = json.loads(item) if isinstance(item, str) else item
new_estimate.append("items", {
"item_code": item.get("item_code"),
"qty": item.get("qty"),
"discount_amount": item.get("discount_amount") or item.get("discountAmount", 0),
"discount_percentage": item.get("discount_percentage") or item.get("discountPercentage", 0)
})
# Iterate through every field and print it out, I need to see if there is any field that is a Dynamic link saying Customer
for fieldname, value in new_estimate.as_dict().items():
print(f"DEBUG: Field '{fieldname}': {value}")
new_estimate.insert()
# AddressService.append_link(data.get("address_name"), "quotations", "quotation", new_estimate.name)
# ClientService.append_link(data.get("customer"), "quotations", "quotation", new_estimate.name)
print("DEBUG: New estimate created with name:", new_estimate.name)
return build_success_response(new_estimate.as_dict())
except Exception as e:
print(f"DEBUG: Error in upsert_estimate: {str(e)}")
return build_error_response(str(e), 500)
def get_estimate_history(estimate_name):
"""Get the history of changes for a specific estimate."""
pass
# return history
# @frappe.whitelist()
# def get_estimate_counts():
# """Get specific counts of estimates based on their status."""
# try:
# counts = {
# "total_estimates": frappe.db.count("Quotation"),
# "ready_to_"
# }

View File

@ -0,0 +1,59 @@
import frappe
from custom_ui.db_utils import build_history_entries
def get_doc_history(doctype, docname):
"""Get the history of changes for a specific document."""
# Fetch comments
comments = frappe.get_all(
"Comment",
filters={
"reference_doctype": doctype,
"reference_name": docname
},
fields=["*"],
order_by="creation desc"
)
versions = frappe.get_all(
"Version",
filters={"docname": docname, "ref_doctype": doctype},
fields=["*"],
order_by="creation desc"
)
history_entries = build_history_entries(comments, versions)
print(f"DEBUG: Retrieved history for {doctype} {docname}: {history_entries}")
return history_entries
def get_docs_history(doctypes_with_names):
"""Get history for multiple documents."""
all_history = {}
for doctype, docname in doctypes_with_names:
history = get_doc_history(doctype, docname)
all_history[f"{doctype}:{docname}"] = history
return all_history
def search_any_field(doctype, text):
meta = frappe.get_meta(doctype)
like = f"%{text}%"
conditions = []
# 1⃣ Explicitly include `name`
conditions.append("`name` LIKE %s")
# 2⃣ Include searchable DocFields
for field in meta.fields:
if field.fieldtype in ("Data", "Small Text", "Text", "Link"):
conditions.append(f"`{field.fieldname}` LIKE %s")
query = f"""
SELECT name
FROM `tab{doctype}`
WHERE {" OR ".join(conditions)}
LIMIT 20
"""
return frappe.db.sql(
query,
[like] * len(conditions),
as_dict=True
)

View File

@ -0,0 +1,105 @@
import frappe, json
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
# ===============================================================================
# INVOICES API METHODS
# ===============================================================================
@frappe.whitelist()
def get_invoice_table_data(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated invoice table data with filtering and sorting support."""
print("DEBUG: Raw invoice options received:", filters, sortings, page, page_size)
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
if is_or:
count = frappe.db.sql(*get_count_or_filters("Sales Invoice", processed_filters))[0][0]
else:
count = frappe.db.count("Sales Invoice", filters=processed_filters)
print(f"DEBUG: Number of invoice returned: {count}")
invoices = frappe.db.get_all(
"Sales Invoice",
fields=["*"],
filters=processed_filters if not is_or else None,
or_filters=processed_filters if is_or else None,
limit=page_size,
start=(page - 1) * page_size,
order_by=processed_sortings
)
tableRows = []
for invoice in invoices:
tableRow = {}
tableRow["id"] = invoice["name"]
tableRow["address"] = invoice.get("custom_installation_address", "")
tableRow["customer"] = invoice.get("customer", "")
tableRow["grand_total"] = f"${invoice.get('grand_total', '')}0"
tableRow["status"] = invoice.get("status", "")
tableRow["items"] = invoice.get("items", "")
tableRows.append(tableRow)
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(table_data_dict)
@frappe.whitelist()
def get_invoice(invoice_name):
"""Get detailed information for a specific invoice."""
try:
invoice = frappe.get_doc("Sales Invoice", invoice_name)
return build_success_response(invoice.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_invoice_items():
items = frappe.db.get_all("Sales Invoice Item", fields=["*"])
return build_success_response(items)
@frappe.whitelist()
def get_invoice_from_address(full_address):
invoice = frappe.db.sql("""
SELECT i.name, i.custom_installation_address
FROM `tabSalesInvoice` i
JOIN `tabAddress` a
ON i.custom_installation_address = a.name
WHERE a.full_address =%s
""", (full_address,), as_dict=True)
if invoice:
return build_success_response(invoice)
else:
return build_error_response("No invoice found for the given address.", 404)
@frappe.whitelist()
def upsert_invoice(data):
"""Create or update an invoice."""
print("DOIFJSEOFJISLFK")
try:
data = json.loads(data) if isinstance(data, str) else data
print("DEBUG: Retrieved address name:", data.get("address_name"))
new_invoice = frappe.get_doc({
"doctype": "Sales Invoice",
"custom_installation_address": data.get("address_name"),
"contact_email": data.get("contact_email"),
"party_name": data.get("contact_name"),
"customer_name": data.get("customer_name"),
})
for item in data.get("items", []):
item = json.loads(item) if isinstance(item, str) else item
new_invoice.append("items", {
"item_code": item.get("item_code"),
"qty": item.get("qty"),
})
new_invoice.insert()
print("DEBUG: New invoice created with name:", new_invoice.name)
return build_success_response(new_invoice.as_dict())
except Exception as e:
return build_error_response(str(e), 500)

221
custom_ui/api/db/jobs.py Normal file
View File

@ -0,0 +1,221 @@
import frappe, json
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
from custom_ui.services import AddressService, ClientService
# ===============================================================================
# JOB MANAGEMENT API METHODS
# ===============================================================================
@frappe.whitelist()
def get_job_templates(company=None):
"""Get list of job (project) templates."""
filters = {}
if company:
filters["company"] = company
try:
templates = frappe.get_all("Project Template", fields=["*"], filters=filters)
return build_success_response(templates)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def create_job_from_sales_order(sales_order_name):
"""Create a Job (Project) from a given Sales Order"""
try:
sales_order = frappe.get_doc("Sales Order", sales_order_name)
project_template = frappe.get_doc("Project Template", "SNW Install")
new_job = frappe.get_doc({
"doctype": "Project",
"custom_address": sales_order.custom_job_address,
# "custom_installation_address": sales_order.custom_installation_address,
"project_name": sales_order.custom_job_address,
"project_template": project_template,
"custom_warranty_duration_days": 90,
"sales_order": sales_order,
"custom_company": sales_order.company
})
new_job.insert()
return build_success_response(new_job.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_job(job_id=""):
"""Get particular Job from DB"""
print("DEBUG: Loading Job from database:", job_id)
try:
project = frappe.get_doc("Project", job_id)
address_doc = AddressService.get_or_throw(project.job_address)
project = project.as_dict()
project["job_address"] = address_doc
project["client"] = ClientService.get_client_or_throw(project.customer)
return build_success_response(project)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_job_task_table_data(filters={}, sortings={}, page=1, page_size=10):
"""Get paginated job tasks table data with filtering and sorting support."""
print("DEBUG: raw task options received:", filters, sortings, page, page_size)
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
print("DEBUG: Processed Filters:", processed_filters)
if is_or:
count = frappe.db.sql(*get_count_or_filters("Task", filters))[0][0]
else:
count = frappe.db.count("Task", filters=filters)
print(f"DEBUG: Number of tasks returned: {count}")
tasks = frappe.db.get_all(
"Task",
fields=["*"],
filters=filters,
limit=page_size,
start=(page - 1) * page_size,
order_by=processed_sortings
)
tableRows = []
for task in tasks:
tableRow = {}
tableRow["id"] = task["name"]
tableRow["subject"] = task["subject"]
tableRow["address"] = task.get("custom_property", "")
tableRow["status"] = task.get("status", "")
tableRows.append(tableRow)
table_data_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(table_data_dict)
@frappe.whitelist()
def get_job_task_list(job_id=""):
if job_id:
try:
tasks = frappe.get_all('Task', filters={"project": job_id})
task_docs = {task_id: frappe.get_doc(task_id) for task_id in tasks}
return build_success_response(task_docs)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_jobs_table_data(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated job table data with filtering and sorting support."""
print("DEBUG: Raw job options received:", filters, sortings, page, page_size)
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
# Handle count with proper OR filter support
if is_or:
count = frappe.db.sql(*get_count_or_filters("Project", processed_filters))[0][0]
else:
count = frappe.db.count("Project", filters=processed_filters)
projects = frappe.db.get_all(
"Project",
fields=["*"],
filters=processed_filters if not is_or else None,
or_filters=processed_filters if is_or else None,
limit=page_size,
start=page * page_size,
order_by=processed_sortings
)
tableRows = []
for project in projects:
tableRow = {}
tableRow["id"] = project["name"]
tableRow["name"] = project["name"]
tableRow["installation_address"] = project.get("custom_installation_address", "")
tableRow["customer"] = project.get("customer", "")
tableRow["status"] = project.get("status", "")
tableRow["percent_complete"] = project.get("percent_complete", 0)
tableRows.append(tableRow)
data_table_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(data_table_dict)
@frappe.whitelist()
def upsert_job(data):
"""Create or update a job (project)."""
try:
if isinstance(data, str):
data = json.loads(data)
project_id = data.get("id")
if not project_id:
return {"status": "error", "message": "Project ID is required"}
project = frappe.get_doc("Project", project_id)
if "scheduledDate" in data:
project.expected_start_date = data["scheduledDate"]
if "foreman" in data:
project.custom_install_crew = data["foreman"]
project.save()
return {"status": "success", "data": project.as_dict()}
except Exception as e:
return {"status": "error", "message": str(e)}
@frappe.whitelist()
def get_install_projects(start_date=None, end_date=None):
"""Get install projects for the calendar."""
try:
filters = {"project_template": "SNW Install"}
# If date range provided, we could filter, but for now let's fetch all open/active ones
# or maybe filter by status not Closed/Completed if we want active ones.
# The user said "unscheduled" are those with status "Open" (and no date).
projects = frappe.get_all("Project", fields=["*"], filters=filters)
calendar_events = []
for project in projects:
# Determine status
status = "unscheduled"
if project.get("expected_start_date"):
status = "scheduled"
# Map to calendar event format
event = {
"id": project.name,
"serviceType": project.project_name, # Using project name as service type/title
"customer": project.customer,
"status": status,
"scheduledDate": project.expected_start_date,
"scheduledTime": "08:00", # Default time if not specified? Project doesn't seem to have time.
"duration": 480, # Default 8 hours?
"foreman": project.get("custom_install_crew"),
"crew": [], # Need to map crew
"estimatedCost": project.estimated_costing,
"priority": project.priority.lower() if project.priority else "medium",
"notes": project.notes,
"address": project.custom_installation_address
}
calendar_events.append(event)
return {"status": "success", "data": calendar_events}
except Exception as e:
return {"status": "error", "message": str(e)}
@frappe.whitelist()
def get_project_templates_for_company(company_name):
"""Get project templates for a specific company."""
try:
templates = frappe.get_all(
"Project Template",
fields=["*"],
filters={"company": company_name}
)
return build_success_response(templates)
except Exception as e:
return build_error_response(str(e), 500),

View File

@ -0,0 +1,17 @@
import frappe
from custom_ui.db_utils import build_success_response, build_error_response
from erpnext.selling.doctype.quotation.quotation import make_sales_order
@frappe.whitelist()
def create_sales_order_from_estimate(estimate_name):
"""Create a Sales Order from a given Estimate (Quotation)."""
try:
estimate = frappe.get_doc("Quotation", estimate_name)
if estimate.custom_current_status != "Estimate Accepted":
raise Exception("Estimate must be accepted to create a Sales Order.")
new_sales_order = make_sales_order(estimate_name)
new_sales_order.custom_requires_half_payment = estimate.requires_half_payment
new_sales_order.insert()
return build_success_response(new_sales_order.as_dict())
except Exception as e:
return build_error_response(str(e), 500)

83
custom_ui/api/db/tasks.py Normal file
View File

@ -0,0 +1,83 @@
import frappe
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
from custom_ui.services import DbService
@frappe.whitelist()
def set_task_status(task_name, new_status):
"""Set the status of a specific task."""
try:
task = DbService.get_or_throw("Task", task_name)
task.status = new_status
task.save()
return build_success_response(f"Task {task_name} status updated to {new_status}.")
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_job_task_list(job_id=""):
if job_id:
try:
tasks = frappe.get_all('Task', filters={"project": job_id})
task_docs = {task_id: frappe.get_doc(task_id) for task_id in tasks}
return build_success_response(task_docs)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_task_status_options():
print("DEBUG: Getting task status options")
try:
task_doctype = frappe.get_doc("DocType", "Task")
status_index = 0
for i, field in enumerate(task_doctype.fields):
if field.fieldname == "status":
status_index = i
break
options = task_doctype.fields[status_index].options.split()
print("DEBUG: Task Status options retreived", options)
return build_success_response(options)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_tasks_table_data(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated task table data with filtering and sorting support."""
try:
print("DEBUG: Raw task options received:", filters, sortings, page, page_size)
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
processed_filters['status'] = ["not in", ["Template", "Completed", "Cancelled"]]
print("Processed filters:", processed_filters)
count = frappe.db.count("Task", filters=processed_filters)
tasks = frappe.db.get_all(
"Task",
fields=["*"],
filters=processed_filters,
limit=page_size,
start=page * page_size,
order_by=processed_sortings
)
tableRows = []
for task in tasks:
tableRow = {}
tableRow["id"] = task["name"]
tableRow["subject"] = task["subject"]
tableRow["project"] = task["project"]
tableRow["address"] = task.get("custom_property", "")
tableRow["status"] = task.get("status", "")
tableRows.append(tableRow)
data_table_dict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(data_table_dict)
except frappe.ValidationError as ve:
return build_error_response(str(ve), 400)
except Exception as e:
return build_error_response(str(e), 500)

View File

@ -0,0 +1,96 @@
import frappe, json, re
from datetime import datetime, date
from custom_ui.db_utils import build_error_response, build_success_response, process_query_conditions, build_datatable_dict, get_count_or_filters
# ===============================================================================
# WARRANTY MANAGEMENT API METHODS
# ===============================================================================
@frappe.whitelist()
def get_warranty_claims(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated warranty claims table data with filtering and sorting support."""
try:
print("DEBUG: Raw warranty options received:", filters, sortings, page, page_size)
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
# Handle count with proper OR filter support
if is_or:
count = frappe.db.sql(*get_count_or_filters("Warranty Claim", processed_filters))[0][0]
else:
count = frappe.db.count("Warranty Claim", filters=processed_filters)
warranty_claims = frappe.db.get_all(
"Warranty Claim",
fields=["*"],
filters=processed_filters if not is_or else None,
or_filters=processed_filters if is_or else None,
limit=page_size,
start=(page - 1) * page_size,
order_by=processed_sortings
)
tableRows = []
for warranty in warranty_claims:
tableRow = {}
tableRow["id"] = warranty["name"]
tableRow["warrantyId"] = warranty["name"]
tableRow["customer"] = warranty.get("customer_name", "")
tableRow["serviceAddress"] = warranty.get("service_address", warranty.get("address_display", ""))
# Extract a brief description from the complaint HTML
complaint_text = warranty.get("complaint", "")
if complaint_text:
# Simple HTML stripping for display - take first 100 chars
clean_text = re.sub('<.*?>', '', complaint_text)
clean_text = clean_text.strip()
if len(clean_text) > 100:
clean_text = clean_text[:100] + "..."
tableRow["issueDescription"] = clean_text
else:
tableRow["issueDescription"] = ""
tableRow["status"] = warranty.get("status", "")
tableRow["complaintDate"] = warranty.get("complaint_date", "")
tableRow["complaintRaisedBy"] = warranty.get("complaint_raised_by", "")
tableRow["fromCompany"] = warranty.get("from_company", "")
tableRow["territory"] = warranty.get("territory", "")
tableRow["resolutionDate"] = warranty.get("resolution_date", "")
tableRow["warrantyStatus"] = warranty.get("warranty_amc_status", "")
# Add priority based on status and date (can be customized)
if warranty.get("status") == "Open":
# Calculate priority based on complaint date
if warranty.get("complaint_date"):
complaint_date = warranty.get("complaint_date")
if isinstance(complaint_date, str):
complaint_date = datetime.strptime(complaint_date, "%Y-%m-%d").date()
elif isinstance(complaint_date, datetime):
complaint_date = complaint_date.date()
days_old = (date.today() - complaint_date).days
if days_old > 7:
tableRow["priority"] = "High"
elif days_old > 3:
tableRow["priority"] = "Medium"
else:
tableRow["priority"] = "Low"
else:
tableRow["priority"] = "Medium"
else:
tableRow["priority"] = "Low"
tableRows.append(tableRow)
tableDataDict = build_datatable_dict(data=tableRows, count=count, page=page, page_size=page_size)
return build_success_response(tableDataDict)
except frappe.ValidationError as ve:
return build_error_response(str(ve), 400)
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def upsert_warranty(data):
"""Create or update a warranty claim."""
# TODO: Implement warranty creation/update logic
pass

33
custom_ui/api/proxy.py Normal file
View File

@ -0,0 +1,33 @@
import frappe
import requests
from urllib.parse import urlparse
from custom_ui.db_utils import build_success_response, build_error_response
import logging
allowed_hosts = ["api.zippopotam.us", "nominatim.openstreetmap.org"] # Update this list with trusted domains as needed
@frappe.whitelist(allow_guest=True)
def request(url, method="GET", data=None, headers=None):
"""
Generic proxy for external API requests.
WARNING: Only allow requests to trusted domains.
"""
parsed_url = urlparse(url)
if parsed_url.hostname not in allowed_hosts:
frappe.throw(f"Requests to {parsed_url.hostname} are not allowed.", frappe.PermissionError)
try:
resp = requests.request(
method=method.upper(),
url=url,
json=frappe.parse_json(data) if data else None,
headers=frappe.parse_json(headers) if headers else None,
timeout=10
)
resp.raise_for_status()
try:
return build_success_response(resp.json())
except ValueError:
return build_success_response({"text": resp.text})
except requests.exceptions.RequestException as e:
frappe.log_error(message=str(e), title="Proxy Request Failed")
frappe.throw("Failed to fetch data from external API.")

View File

@ -3,6 +3,51 @@ import os
import subprocess import subprocess
import frappe import frappe
from custom_ui.utils import create_module from custom_ui.utils import create_module
from custom_ui.api.db.general import search_any_field
@click.command("update-data")
@click.option("--site", default=None, help="Site to update data for")
def update_data(site):
address_names = frappe.get_all("Address", pluck="name")
total_addresses = len(address_names)
updated_addresses = 0
updated_contacts = 0
updated_customers = 0
total_updated_fields = 0
skipped = 0
for address_name in address_names:
should_update = False
address = frappe.get_doc("Address", address_name)
customer_name = address.custom_customer_to_bill
customer_links = [link for link in address.get("links", []) if link.link_doctype == "Customer"]
# lead_links = [link for link in address.get("links", []) if link.link_doctype == "Lead"]
contact_links = [link for link in address.get("links", []) if link.link_doctype == "Contact"] + address.get("custom_linked_contacts", [])
if frappe.db.exists("Customer", customer_name):
customer = frappe.get_doc("Customer", customer_name)
else:
lead_names = frappe.get_all("Lead", filters={"lead_name": customer_name}, pluck="name")
customer_name = lead_names[0] if lead_names else None
customer = frappe.get_doc("Lead", customer_name) if customer_name else None
if not customer_links and customer and customer.doctype == "Customer":
address.append("links", {
"link_doctype": customer.doctype,
"link_name": customer.name
})
updated_addresses += 1
should_update = True
elif not lead_links and customer and customer.doctype == "Lead":
address.append("links", {
"link_doctype": customer.doctype,
"link_name": customer.name
})
updated_addresses += 1
should_update = True
@click.command("build-frontend") @click.command("build-frontend")
@click.option("--site", default=None, help="Site to build frontend for") @click.option("--site", default=None, help="Site to build frontend for")
@ -29,6 +74,7 @@ def build_frontend(site):
click.echo("\n✅ Frontend build completed successfully.\n") click.echo("\n✅ Frontend build completed successfully.\n")
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
click.echo(f"\n❌ Frontend build failed: {e}\n") click.echo(f"\n❌ Frontend build failed: {e}\n")
exit(1)
else: else:
frappe.log_error(message="No frontend directory found for custom_ui", title="Frontend Build Skipped") 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") click.echo(f"\n⚠️ Frontend directory does not exist. Skipping build. Path was {frontend_path}\n")
@ -54,5 +100,5 @@ def build_frontend(site):
def create_module_command(): def create_module_command():
create_module() create_module()
click.echo("✅ Custom UI module created or already exists.") click.echo("✅ Custom UI module created or already exists.")
commands = [build_frontend, create_module_command] commands = [build_frontend, create_module_command]

View File

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

View File

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

231
custom_ui/db_utils.py Normal file
View File

@ -0,0 +1,231 @@
import json
from frappe.utils import strip_html
def map_field_name(frontend_field):
field_mapping = {
"customer_name": "custom_customer_to_bill",
"address": "address_line1",
"appointment_scheduled_status": "custom_onsite_meeting_scheduled",
"estimate_sent_status": "custom_estimate_sent_status",
"payment_received_status": "custom_payment_received_status",
"job_status": "custom_job_status",
"installation_address": "custom_installation_address",
"warranty_id": "name",
"customer": "customer_name",
"fromCompany": "from_company",
"warranty_status": "warranty_amc_status"
}
return field_mapping.get(frontend_field, frontend_field)
def process_filters(filters):
processed_filters = {}
if filters:
filters = json.loads(filters) if isinstance(filters, str) else filters
for field_name, filter_obj in filters.items():
if isinstance(filter_obj, dict) and "value" in filter_obj:
if filter_obj["value"] is not None and filter_obj["value"] != "":
# Map frontend field names to backend field names
address_fields = ["address_line1", "address_line2", "city", "state", "pincode"] if field_name == "address" else []
mapped_field_name = map_field_name(field_name)
# Handle different match modes
match_mode = filter_obj.get("match_mode", "contains")
if isinstance(match_mode, str):
match_mode = match_mode.lower()
# Special handling for address to search accross multiple fields
if address_fields:
address_filters = []
for addr_field in address_fields:
if match_mode in ("contains", "contains"):
address_filters.append([addr_field, "like", f"%{filter_obj['value']}%"])
elif match_mode in ("startswith", "starts_with"):
address_filters.append([addr_field, "like", f"{filter_obj['value']}%"])
elif match_mode in ("endswith", "ends_with"):
address_filters.append([addr_field, "like", f"%{filter_obj['value']}"])
elif match_mode in ("equals", "equals"):
address_filters.append([addr_field, "=", filter_obj["value"]])
else:
address_filters.append([addr_field, "like", f"%{filter_obj['value']}%"])
processed_filters = address_filters
continue # Skip the rest of the loop for address field
customer_name_fields = ["custom_customer_to_bill", "lead_name"] if field_name == "customer_name" else []
if customer_name_fields:
customer_name_filters = []
for cust_field in customer_name_fields:
if match_mode in ("contains", "contains"):
customer_name_filters.append([cust_field, "like", f"%{filter_obj['value']}%"])
elif match_mode in ("startswith", "starts_with"):
customer_name_filters.append([cust_field, "like", f"{filter_obj['value']}%"])
elif match_mode in ("endswith", "ends_with"):
customer_name_filters.append([cust_field, "like", f"%{filter_obj['value']}"])
elif match_mode in ("equals", "equals"):
customer_name_filters.append([cust_field, "=", filter_obj["value"]])
else:
customer_name_filters.append([cust_field, "like", f"%{filter_obj['value']}%"])
processed_filters = customer_name_filters
continue # Skip the rest of the loop for customer_name field
if match_mode in ("contains", "contains"):
processed_filters[mapped_field_name] = ["like", f"%{filter_obj['value']}%"]
elif match_mode in ("startswith", "starts_with"):
processed_filters[mapped_field_name] = ["like", f"{filter_obj['value']}%"]
elif match_mode in ("endswith", "ends_with"):
processed_filters[mapped_field_name] = ["like", f"%{filter_obj['value']}"]
elif match_mode in ("equals", "equals"):
processed_filters[mapped_field_name] = filter_obj["value"]
else:
# Default to contains
processed_filters[mapped_field_name] = ["like", f"%{filter_obj['value']}%"]
print("DEBUG: Processed filters:", processed_filters)
return processed_filters
def process_sorting(sortings):
sortings = json.loads(sortings) if isinstance(sortings, str) else sortings
order_by = ""
print("DEBUG: Original sorting:", sortings)
if sortings and len(sortings) > 0:
for sorting in sortings:
mapped_field = map_field_name(sorting[0].strip())
sort_direction = sorting[1].strip().lower()
order_by += f"{mapped_field} {sort_direction}, "
order_by = order_by.rstrip(", ")
else:
order_by = "modified desc"
print("DEBUG: Processed sorting:", order_by)
return order_by
def process_query_conditions(filters, sortings, page, page_size):
processed_filters = process_filters(filters)
processed_sortings = process_sorting(sortings)
is_or_filters = isinstance(processed_filters, list)
page_int = int(page)
page_size_int = int(page_size)
return processed_filters, processed_sortings, is_or_filters, page_int, page_size_int
def build_datatable_dict(data, count, page, page_size):
return {
"pagination": {
"total": count,
"page": page,
"page_size": page_size,
"total_pages": (count + page_size - 1) // page_size
},
"data": data
}
def get_count_or_filters(doctype, or_filters):
where_clauses = []
values = []
for field, operator, val in or_filters:
if operator.lower() == "like":
where_clauses.append(f"`{field}` LIKE %s")
else:
where_clauses.append(f"`{field}` {operator} %s")
values.append(val)
where_sql = " OR ".join(where_clauses)
sql = f"SELECT COUNT(*) FROM `tab{doctype}` WHERE {where_sql}"
return sql, values
def build_error_response(message, status_code=400):
return {
"status": "error",
"message": message,
"status_code": status_code
}
def build_success_response(data):
return {
"status": "success",
"data": data
}
def build_full_address(doc):
first_parts = [
doc.address_line1,
doc.address_line2,
doc.city
]
second_parts = [
doc.state,
doc.pincode
]
first = " ".join([p for p in first_parts if p])
second = " ".join([p for p in second_parts if p])
if first and second:
return f"{first}, {second}"
return first or second or ""
def build_address_title(customer_name, address_data):
title_parts = [customer_name]
if address_data.get("address_line1"):
title_parts.append(address_data["address_line1"])
if address_data.get("type"):
title_parts.append(address_data["type"])
return " - ".join(title_parts)
def map_lead_client(client_data):
mappings = {
"custom_customer_name": "customer_name",
"customer_type": "customer_type",
"territory": "territory",
"company_name": "company"
}
for lead_field, client_field in mappings.items():
if lead_field in client_data:
print(f"DEBUG: Mapping field {lead_field} to {client_field} with value {client_data[lead_field]}")
client_data[client_field] = client_data[lead_field]
client_data["customer_group"] = ""
print("####DEBUG: Mapped client data:", client_data)
return client_data
def map_lead_update(client_data):
mappings = {
"customer_name": "lead_name",
"customer_type": "customer_type",
"territory": "territory",
"company": "company_name"
}
for client_field, lead_field in mappings.items():
if client_field in client_data:
print(f"DEBUG: Mapping field {client_field} to {lead_field} with value {client_data[client_field]}")
client_data[lead_field] = client_data[client_field]
return client_data
def map_comment_to_history_entry(comment):
return {
"type": comment.get("comment_type", "Comment"),
"user": comment.get("owner"),
"timestamp": comment.get("creation"),
"message": strip_html(comment.get("content", ""))
}
def map_version_data_to_history_entry(changed_data, creation, owner):
field, old, new = changed_data
return {
"type": "Field Change",
"timestamp": creation,
"user": owner,
"message": f"Changed '{field}' from '{old}' to '{new}'"
}
def build_history_entries(comments, versions):
history = []
for comment in comments:
history.append(map_comment_to_history_entry(comment))
for version in versions:
data = json.loads(version.get("data", "[]"))
for changed_data in data.get("changed", []):
entry = map_version_data_to_history_entry(
changed_data,
version.get("creation"),
version.get("owner")
)
if entry:
history.append(entry)
# Sort by timestamp descending
history.sort(key=lambda x: x["timestamp"], reverse=True)
return history

View File

View File

@ -0,0 +1,8 @@
import frappe
from custom_ui.db_utils import build_full_address
def before_insert(doc, method):
print("DEBUG: Before Insert Triggered for Address")
if not doc.full_address:
doc.full_address = build_full_address(doc)

View File

View File

@ -0,0 +1,87 @@
import frappe
from erpnext.selling.doctype.quotation.quotation import make_sales_order
from custom_ui.services import DbService, AddressService, ClientService
def after_insert(doc, method):
print("DEBUG: After insert hook triggered for Quotation:", doc.name)
AddressService.append_link_v2(
doc.custom_job_address, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template}
)
AddressService.append_link_v2(
doc.custom_job_address, "links", {"link_doctype": "Quotation", "link_name": doc.name}
)
ClientService.append_link_v2(
doc.actual_customer_name, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template}
)
template = doc.custom_project_template or "Other"
if template == "Other":
print("WARN: No project template specified.")
if template == "SNW Install":
print("DEBUG: SNW Install template detected, updating custom address field.")
AddressService.update_value(
doc.custom_job_address,
"estimate_sent_status",
"In Progress"
)
def before_insert(doc, method):
print("DEBUG: Before insert hook triggered for Quotation:", doc)
print("DEBUG: CHECKING CUSTOMER TYPE")
print(doc.customer_type)
print("DEBUG: CHECKING CUSTOMER NAME")
print(doc.actual_customer_name)
print("Quotation_to:", doc.quotation_to)
# print("Party_type:", doc.party_type)
if doc.custom_project_template == "SNW Install":
print("DEBUG: Quotation uses SNW Install template, making sure no duplicate linked estimates.")
address_doc = AddressService.get_or_throw(doc.custom_job_address)
if "SNW Install" in [link.project_template for link in address_doc.quotations]:
raise frappe.ValidationError("An Estimate with project template 'SNW Install' is already linked to this address.")
def before_submit(doc, method):
print("DEBUG: Before submit hook triggered for Quotation:", doc.name)
if doc.custom_project_template == "SNW Install":
print("DEBUG: Quotation uses SNW Install template.")
if doc.custom_sent == 1:
print("DEBUG: Current status is 'Estimate Sent', updating Address status to 'Sent'.")
AddressService.update_value(
doc.custom_job_address,
"estimate_sent_status",
"Completed"
)
def on_update_after_submit(doc, method):
print("DEBUG: on_update_after_submit hook triggered for Quotation:", doc.name)
print("DEBUG: Current custom_current_status:", doc.custom_current_status)
if doc.custom_current_status == "Estimate Accepted":
doc.custom_current_status = "Won"
print("DEBUG: Quotation marked as Won, updating current status.")
if doc.customer_type == "Lead":
print("DEBUG: Customer is a Lead, converting to Customer and updating Quotation.")
new_customer = ClientService.convert_lead_to_customer(doc.actual_customer_name, update_quotations=False)
doc.actual_customer_name = new_customer.name
doc.customer_type = "Customer"
new_customer.reload()
ClientService.append_link_v2(
new_customer.name, "quotations", {"quotation": doc.name}
)
doc.save()
print("DEBUG: Creating Sales Order from accepted Estimate")
new_sales_order = make_sales_order(doc.name)
new_sales_order.custom_requires_half_payment = doc.requires_half_payment
new_sales_order.customer = doc.actual_customer_name
# new_sales_order.custom_installation_address = doc.custom_installation_address
# new_sales_order.custom_job_address = doc.custom_job_address
new_sales_order.payment_schedule = []
print("DEBUG: Setting payment schedule for Sales Order")
new_sales_order.set_payment_schedule()
print("DEBUG: Inserting Sales Order:", new_sales_order.as_dict())
new_sales_order.delivery_date = new_sales_order.transaction_date
# backup = new_sales_order.customer_address
# new_sales_order.customer_address = None
new_sales_order.insert()
print("DEBUG: Submitting Sales Order")
# new_sales_order.customer_address = backup
new_sales_order.submit()
frappe.db.commit()
print("DEBUG: Sales Order created successfully:", new_sales_order.name)

28
custom_ui/events/jobs.py Normal file
View File

@ -0,0 +1,28 @@
import frappe
from custom_ui.services import AddressService, ClientService
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Project")
print("DEBUG: Linking Project to address and Customer")
AddressService.append_link_v2(
doc.job_address, "projects", {"project": doc.name, "project_template": doc.project_template}
)
AddressService.append_link_v2(
doc.job_address, "links", {"link_doctype": "Project", "link_name": doc.name}
)
ClientService.append_link_v2(
doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
)
if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
AddressService.update_value(
doc.job_address,
"job_status",
"In Progress"
)
def before_insert(doc, method):
# This is where we will add logic to set tasks and other properties of a job based on it's project_template
pass

View File

@ -0,0 +1,41 @@
import frappe
from custom_ui.services import DbService, AddressService, ClientService
def before_insert(doc, method):
print("DEBUG: Before Insert Triggered for On-Site Meeting")
if doc.project_template == "SNW Install":
address_doc = AddressService.get_or_throw(doc.address)
# Address.onsite_meetings is a child table with two fields: onsite_meeting (Link) and project_template (Link). Iterate through to see if there is already an SNW Install meeting linked.
for link in address_doc.onsite_meetings:
if link.project_template == "SNW Install":
raise frappe.ValidationError("An On-Site Meeting with project template 'SNW Install' is already linked to this address.")
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for On-Site Meeting")
print("DEBUG: Linking bid meeting to customer and address")
AddressService.append_link_v2(doc.address, "onsite_meetings", {"onsite_meeting": doc.name, "project_template": doc.project_template})
AddressService.append_link_v2(doc.address, "links", {"link_doctype": "On-Site Meeting", "link_name": doc.name})
ClientService.append_link(doc.party_name, "onsite_meetings", "onsite_meeting", doc.name)
if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "In Progress")
def before_save(doc, method):
print("DEBUG: Before Save Triggered for On-Site Meeting")
if doc.status != "Scheduled" and doc.start_time and doc.end_time and doc.status != "Completed":
print("DEBUG: Meeting has start and end time, setting status to Scheduled")
doc.status = "Scheduled"
if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install")
if doc.status == "Completed":
print("DEBUG: Meeting marked as Completed, updating Address status")
current_status = AddressService.get_value(doc.address, "onsite_meeting_scheduled")
if current_status != doc.status:
AddressService.update_value(doc.address, "onsite_meeting_scheduled", "Completed")
def validate_address_link(doc, method):
print("DEBUG: Validating Address link for On-Site Meeting")
if doc.onsite_meeting:
meeting = DbService.get_or_throw("On-Site Meeting", doc.onsite_meeting)
doc.project_template = meeting.project_template

View File

@ -0,0 +1,89 @@
import frappe
from custom_ui.services import DbService, AddressService, ClientService
def before_insert(doc, method):
print("DEBUG: before_insert hook triggered for Sales Order:", doc.name)
if doc.custom_project_template == "SNW Install":
print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
address_doc = AddressService.get_or_throw(doc.custom_job_address)
if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
def on_submit(doc, method):
print("DEBUG: Info from Sales Order")
print(doc.custom_installation_address)
print(doc.company)
print(doc.transaction_date)
print(doc.customer)
print(doc.custom_job_address)
print(doc.custom_project_template)
# Create Invoice and Project from Sales Order
try:
print("Creating Project from Sales Order", doc.name)
if doc.custom_project_template or doc.project_template:
new_job = frappe.get_doc({
"doctype": "Project",
"custom_job_address": doc.custom_job_address,
"project_name": f"{doc.custom_project_template} - {doc.custom_job_address}",
"project_template": doc.custom_project_template,
"custom_warranty_duration_days": 90,
"customer": doc.customer,
"job_address": doc.custom_job_address,
"sales_order": doc.name
})
# attatch the job to the sales_order links
new_job.insert()
frappe.db.commit()
except Exception as e:
print("ERROR creating Project from Sales Order:", str(e))
def after_insert(doc, method):
print("DEBUG: after_insert hook triggered for Sales Order:", doc.name)
AddressService.append_link_v2(
doc.custom_job_address, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
)
AddressService.append_link_v2(
doc.custom_job_address, "links", {"link_doctype": "Sales Order", "link_name": doc.name}
)
ClientService.append_link_v2(
doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
)
def create_sales_invoice_from_sales_order(doc, method):
try:
print("DEBUG: after_submit hook triggered for Sales Order:", doc.name)
invoice_ammount = doc.grand_total / 2 if doc.requires_half_payment else doc.grand_total
items = []
for so_item in doc.items:
# proportionally reduce rate if half-payment
rate = so_item.rate / 2 if doc.requires_half_payment else so_item.rate
qty = so_item.qty # usually full qty, but depends on half-payment rules
items.append({
"item_code": so_item.item_code,
"qty": qty,
"rate": rate,
"income_account": so_item.income_account,
"cost_center": so_item.cost_center,
"so_detail": so_item.name # links item to Sales Order
})
invoice = frappe.get_doc({
"doctype": "Sales Invoice",
"customer": doc.customer,
"company": doc.company,
"posting_date": frappe.utils.nowdate(),
"due_date": frappe.utils.nowdate(), # or calculate from payment terms
"currency": doc.currency,
"update_stock": 0,
"items": items,
"sales_order": doc.name, # link invoice to Sales Order
"ignore_pricing_rule": 1,
"payment_schedule": doc.payment_schedule if not half_payment else [] # optional
})
invoice.insert()
invoice.submit()
frappe.db.commit()
return invoice
except Exception as e:
print("ERROR creating Sales Invoice from Sales Order:", str(e))
frappe.log_error(f"Error creating Sales Invoice from Sales Order {doc.name}: {str(e)}", "Sales Order after_submit Error")

7
custom_ui/events/task.py Normal file
View File

@ -0,0 +1,7 @@
import frappe
def before_insert(doc, method):
"""Set values before inserting a Task."""
project_doc = frappe.get_doc("Project", doc.project)
if project_doc.custom_installation_address:
doc.custom_property = project_doc.custom_installation_address

View File

@ -0,0 +1 @@
[]

File diff suppressed because it is too large Load Diff

View File

@ -158,13 +158,101 @@ add_to_apps_screen = [
# --------------- # ---------------
# Hook on document methods and events # Hook on document methods and events
# doc_events = { doc_events = {
# "*": { "On-Site Meeting": {
# "on_update": "method", "after_insert": "custom_ui.events.onsite_meeting.after_insert",
# "on_cancel": "method", "before_save": "custom_ui.events.onsite_meeting.before_save",
# "on_trash": "method" "before_insert": "custom_ui.events.onsite_meeting.before_insert"
# } },
# } "Address": {
"before_insert": "custom_ui.events.address.before_insert"
},
"Quotation": {
"before_insert": "custom_ui.events.estimate.before_insert",
"after_insert": "custom_ui.events.estimate.after_insert",
# "before_save": "custom_ui.events.estimate.before_save",
"before_submit": "custom_ui.events.estimate.before_submit",
"on_update_after_submit": "custom_ui.events.estimate.on_update_after_submit"
},
"Sales Order": {
"before_insert": "custom_ui.events.sales_order.before_insert",
"after_insert": "custom_ui.events.sales_order.after_insert",
"on_submit": "custom_ui.events.sales_order.on_submit"
},
"Project": {
"before_insert": "custom_ui.events.jobs.before_insert",
"after_insert": "custom_ui.events.jobs.after_insert"
},
"Task": {
"before_insert": "custom_ui.events.task.before_insert"
}
}
fixtures = [
{
"dt": "DocType",
"filters": [
["name", "in", [
"Quotation Template",
"Quotation Template Item",
"Customer Company Link",
"Customer Address Link",
"Customer Contact Link",
# New link doctypes
"Customer Project Link",
"Customer Quotation Link",
"Customer Sales Order Link",
"Customer On-Site Meeting Link",
"Lead Address Link",
"Lead Contact Link",
"Lead Companies Link",
"Lead Quotation Link",
"Lead On-Site Meeting Link",
"Address Project Link",
"Address Quotation Link",
"Address On-Site Meeting Link",
"Address Sales Order Link",
"Address Contact Link",
"Address Company Link",
"Contact Address Link",
]]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Quotation"],
["fieldname", "in", [
"custom_quotation_template",
"custom_project_template"
]]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Sales Order"],
["fieldname", "=", "custom_project_template"]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Lead"],
["fieldname", "=", "custom_customer_name"]
]
},
{
"dt": "Custom Field",
"filters": [
["dt", "=", "Project Template"],
["fieldname", "=", "company"]
]
}
]
# Scheduled Tasks # Scheduled Tasks
# --------------- # ---------------

View File

@ -1,14 +1,38 @@
from curses import meta
import os import os
import subprocess import subprocess
import sys
import frappe import frappe
from .utils import create_module from .utils import create_module
def after_install(): def after_install():
create_module() create_module()
add_custom_fields()
frappe.db.commit()
# Proper way to refresh metadata
frappe.clear_cache(doctype="Address")
frappe.reload_doctype("Address")
frappe.clear_cache(doctype="On-Site Meeting")
frappe.reload_doctype("On-Site Meeting")
update_onsite_meeting_fields()
update_address_fields()
build_frontend() build_frontend()
def after_migrate(): def after_migrate():
build_frontend() add_custom_fields()
update_onsite_meeting_fields()
frappe.db.commit()
# Proper way to refresh metadata for all doctypes with custom fields
doctypes_to_refresh = ["Lead", "Address", "Contact", "On-Site Meeting", "Quotation", "Sales Order", "Project Template"]
for doctype in doctypes_to_refresh:
frappe.clear_cache(doctype=doctype)
frappe.reload_doctype(doctype)
update_address_fields()
# build_frontend()
def build_frontend(): def build_frontend():
app_package_path = frappe.get_app_path("custom_ui") app_package_path = frappe.get_app_path("custom_ui")
@ -40,4 +64,837 @@ def build_frontend():
print("\n✅ Frontend build completed successfully.\n") print("\n✅ Frontend build completed successfully.\n")
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
frappe.log_error(message=str(e), title="Frontend Build Failed") frappe.log_error(message=str(e), title="Frontend Build Failed")
print(f"\n❌ Frontend build failed: {e}\n") print(f"\n❌ Frontend build failed: {e}\n")
def add_custom_fields():
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
print("\n🔧 Adding custom fields to doctypes...")
try:
address_meta = frappe.get_meta("Address")
address_type_params = address_meta.get_field("address_type")
if address_type_params and "Service" not in (address_type_params.options or ""):
print(" Adding 'Service' to Address type options...")
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
make_property_setter("Address", "address_type", "options", (address_type_params.options or "") + "\nService", "DocField")
print(" ✅ Added 'Service' to Address address_type options.")
except Exception as e:
print(f" ⚠️ Failed to update Address address_type: {e}")
custom_fields = {
"Customer": [
dict(
fieldname="companies",
label="Companies",
fieldtype="Table",
options="Customer Company Link",
insert_after="customer_type"
),
dict(
fieldname="quotations",
label="Quotations",
fieldtype="Table",
options="Customer Quotation Link",
insert_after="companies"
),
dict(
fieldname="onsite_meetings",
label="On-Site Meetings",
fieldtype="Table",
options="Customer On-Site Meeting Link",
insert_after="quotations"
),
dict(
fieldname="projects",
label="Projects",
fieldtype="Table",
options="Customer Project Link",
insert_after="onsite_meetings"
),
dict(
fieldname="sales_orders",
label="Sales Orders",
fieldtype="Table",
options="Customer Sales Order Link",
insert_after="projects"
),
dict(
fieldname="from_lead",
label="From Lead",
fieldtype="Link",
options="Lead",
insert_after="customer_name"
),
dict(
fieldname="properties",
label="Properties",
fieldtype="Table",
options="Customer Address Link",
insert_after="customer_name"
),
dict(
fieldname="contacts",
label="Contacts",
fieldtype="Table",
options="Customer Contact Link",
insert_after="properties"
),
dict(
fieldname="primary_contact",
label="Primary Contact",
fieldtype="Link",
options="Contact",
insert_after="contacts"
)
],
"Lead": [
dict(
fieldname="onsite_meetings",
label="On-Site Meetings",
fieldtype="Table",
options="Lead On-Site Meeting Link",
insert_after="quotations"
),
dict(
fieldname="custom_billing_address",
label="Custom Address",
fieldtype="Link",
options="Address",
insert_after="customer_name"
),
dict(
fieldname="quotations",
label="Quotations",
fieldtype="Table",
options="Lead Quotation Link",
insert_after="companies"
),
dict(
fieldname="companies",
label="Companies",
fieldtype="Table",
options="Lead Company Link",
insert_after="customer_type"
),
dict(
fieldname="customer_type",
label="Customer Type",
fieldtype="Select",
options="Individual\nCompany\nPartnership",
insert_after="lead_name"
),
dict(
fieldname="properties",
label="Properties",
fieldtype="Table",
options="Lead Address Link",
insert_after="customer_name"
),
dict(
fieldname="contacts",
label="Contacts",
fieldtype="Table",
options="Lead Contact Link",
insert_after="properties"
),
dict(
fieldname="primary_contact",
label="Primary Contact",
fieldtype="Link",
options="Contact",
insert_after="contacts"
)
],
"Address": [
dict(
fieldname="primary_contact",
label="Primary Contact",
fieldtype="Link",
options="Contact",
insert_after="address_title"
),
dict(
fieldname="projects",
label="Projects",
fieldtype="Table",
options="Address Project Link",
insert_after="onsite_meetings"
),
dict(
fieldname="sales_orders",
label="Sales Orders",
fieldtype="Table",
options="Address Sales Order Link",
insert_after="projects"
),
dict(
fieldname="onsite_meetings",
label="On-Site Meetings",
fieldtype="Table",
options="Address On-Site Meeting Link",
insert_after="quotations"
),
dict(
fieldname="quotations",
label="Quotations",
fieldtype="Table",
options="Address Quotation Link",
insert_after="companies"
),
dict(
fieldname="full_address",
label="Full Address",
fieldtype="Data",
insert_after="country"
),
dict(
fieldname="latitude",
label="Latitude",
fieldtype="Float",
precision=8,
insert_after="full_address"
),
dict(
fieldname="longitude",
label="Longitude",
fieldtype="Float",
precision=8,
insert_after="latitude"
),
dict(
fieldname="onsite_meeting_scheduled",
label="On-Site Meeting Scheduled",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="longitude"
),
dict(
fieldname="estimate_sent_status",
label="Estimate Sent Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="onsite_meeting_scheduled"
),
dict(
fieldname="job_status",
label="Job Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="estimate_sent_status"
),
dict(
fieldname="payment_received_status",
label="Payment Received Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="job_status"
),
dict(
fieldname="lead_name",
label="Lead Name",
fieldtype="Data",
insert_after="custom_customer_to_bill"
),
dict(
fieldname="customer_type",
label="Customer Type",
fieldtype="Select",
options="Customer\nLead",
insert_after="lead_name"
),
dict(
fieldname="customer_name",
label="Customer Name",
fieldtype="Dynamic Link",
options="customer_type",
insert_after="customer_type"
),
dict(
fieldname="contacts",
label="Contacts",
fieldtype="Table",
options="Address Contact Link",
insert_after="customer_name"
),
dict(
fieldname="companies",
label="Companies",
fieldtype="Table",
options="Address Company Link",
insert_after="contacts"
)
],
"Contact": [
dict(
fieldname="role",
label="Role",
fieldtype="Select",
options="Owner\nProperty Manager\nTenant\nBuilder\nNeighbor\nFamily Member\nRealtor\nOther",
insert_after="designation"
),
dict(
fieldname="email",
label="Email",
fieldtype="Data",
insert_after="last_name",
options="Email"
),
dict(
fieldname="customer_type",
label="Customer Type",
fieldtype="Select",
options="Customer\nLead",
insert_after="email"
),
dict(
fieldname="customer_name",
label="Customer Name",
fieldtype="Dynamic Link",
options="customer_type",
insert_after="customer_type"
),
dict(
fieldname="addresses",
label="Addresses",
fieldtype="Table",
options="Contact Address Link",
insert_after="customer_name"
)
],
"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"
),
dict(
fieldname="company",
label="Company",
fieldtype="Link",
options="Company",
insert_after="assigned_employee"
),
dict(
fieldname="party_type",
label="Party Type",
fieldtype="Select",
options="Customer\nLead",
insert_after="company"
),
dict(
fieldname="party_name",
label="Party Name",
fieldtype="Dynamic Link",
options="party_type",
insert_after="party_type"
),
dict(
fieldname="contact",
label="Contact",
fieldtype="Link",
options="Contact",
insert_after="party_name"
),
dict(
fieldname="project_template",
label="Project Template",
fieldtype="Link",
options="Project Template",
insert_after="company"
)
],
"Quotation": [
dict(
fieldname="requires_half_payment",
label="Requires Half Payment",
fieldtype="Check",
default=0,
insert_after="custom_installation_address"
),
dict(
fieldname="custom_quotation_template",
label="Quotation Template",
fieldtype="Link",
options="Quotation Template",
insert_after="company",
description="The template used for generating this quotation."
),
dict(
fieldname="custom_project_template",
label="Project Template",
fieldtype="Link",
options="Project Template",
insert_after="custom_quotation_template",
description="The project template to use when creating a project from this quotation.",
allow_on_submit=1
),
dict(
fieldname="custom_job_address",
label="Job Address",
fieldtype="Link",
options="Address",
insert_after="custom_installation_address",
description="The address where the job will be performed.",
allow_on_submit=1
),
dict(
fieldname="from_onsite_meeting",
label="From On-Site Meeting",
fieldtype="Link",
options="On-Site Meeting",
insert_after="custom_job_address"
),
dict(
fieldname="from_onsite_meeting",
label="From On-Site Meeting",
fieldtype="Link",
options="On-Site Meeting",
insert_after="custom_job_address"
),
dict(
fieldname="actual_customer_name",
label="Customer",
fieldtype="Dynamic Link",
options="customer_type",
insert_after="from_onsite_meeting",
allow_on_submit=1
),
dict(
fieldname="customer_type",
label="Customer Type",
fieldtype="Select",
options="Customer\nLead",
insert_after="customer_name",
allow_on_submit=1
)
],
"Sales Order": [
dict(
fieldname="requires_half_payment",
label="Requires Half Payment",
fieldtype="Check",
default=0,
insert_after="custom_installation_address"
),
dict(
fieldname="custom_project_template",
label="Project Template",
fieldtype="Link",
options="Project Template",
description="The project template to use when creating a project from this sales order.",
insert_after="custom_installation_address",
allow_on_submit=1
),
dict(
fieldname="custom_job_address",
label="Job Address",
fieldtype="Link",
options="Address",
insert_after="custom_installation_address",
description="The address where the job will be performed.",
allow_on_submit=1
)
],
"Project": [
dict(
fieldname="job_address",
label="Job Address",
fieldtype="Link",
options="Address",
insert_after="project_name",
description="The address where the job is being performed."
),
dict(
fieldname="customer",
label="Customer",
fieldtype="Link",
options="Customer",
insert_after="job_address",
description="The customer for whom the project is being executed."
)
],
"Project Template": [
dict(
fieldname="company",
label="Company",
fieldtype="Link",
options="Company",
insert_after="project_type",
description="The company associated with this project template."
)
]
}
print("🔧 Custom fields to check per doctype:")
for key, value in custom_fields.items():
print(f"{key}: {len(value)} fields")
print(f" Total fields to check: {sum(len(v) for v in custom_fields.values())}\n")
missing_fields = []
fields_to_update = []
for doctype, field_options in custom_fields.items():
meta = frappe.get_meta(doctype)
for field_spec in field_options:
fieldname = field_spec["fieldname"]
if not meta.has_field(fieldname):
missing_fields.append(f"{doctype}: {fieldname}")
else:
# Field exists, check if specs match
custom_field_name = f"{doctype}-{fieldname}"
if frappe.db.exists("Custom Field", custom_field_name):
custom_field_doc = frappe.get_doc("Custom Field", custom_field_name)
needs_update = False
# Compare important properties
for key, desired_value in field_spec.items():
if key == "fieldname":
continue
current_value = getattr(custom_field_doc, key, None)
if current_value != desired_value:
needs_update = True
break
if needs_update:
fields_to_update.append((doctype, fieldname, field_spec))
if missing_fields:
print("\n❌ Missing custom fields:")
for entry in missing_fields:
print(f"{entry}")
print("\n🔧 Creating missing custom fields...")
missing_field_specs = build_missing_field_specs(custom_fields, missing_fields)
create_custom_fields(missing_field_specs)
print("✅ Missing custom fields created.")
if fields_to_update:
print("\n🔧 Updating custom fields with mismatched specs:")
for doctype, fieldname, field_spec in fields_to_update:
print(f"{doctype}: {fieldname}")
custom_field_name = f"{doctype}-{fieldname}"
custom_field_doc = frappe.get_doc("Custom Field", custom_field_name)
# Update all properties from field_spec
for key, value in field_spec.items():
if key != "fieldname":
setattr(custom_field_doc, key, value)
custom_field_doc.save(ignore_permissions=True)
frappe.db.commit()
print("✅ Custom fields updated.")
if not missing_fields and not fields_to_update:
print("✅ All custom fields verified.")
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():
quotations = frappe.get_all("Quotation", pluck="name")
addresses = frappe.get_all("Address", pluck="name")
sales_orders = frappe.get_all("Sales Order", pluck="name")
tasks = frappe.get_all("Task", pluck="name")
total_addresses = len(addresses)
total_quotations = len(quotations)
total_sales_orders = len(sales_orders)
total_tasks = len(tasks)
total_doctypes = total_addresses + total_quotations + total_sales_orders + total_tasks
combined_doctypes = []
for sales_order in sales_orders:
combined_doctypes.append({"doctype": "Sales Order", "name": sales_order})
for quotation in quotations:
combined_doctypes.append({"doctype": "Quotation", "name": quotation})
for address in addresses:
combined_doctypes.append({"doctype": "Address", "name": address})
for task in tasks:
combined_doctypes.append({"doctype": "Task", "name": task})
print(f"\n📍 Updating field values for {total_addresses} addresses, {total_quotations} quotations, {total_sales_orders} sales orders, and {total_tasks} tasks...")
# Field update counters
field_counters = {
'quotation_addresses_updated': 0,
'quotation_project_templates_updated': 0,
'sales_order_addresses_updated': 0,
'sales_order_project_templates_updated': 0,
'full_address': 0,
'custom_onsite_meeting_scheduled': 0,
'custom_estimate_sent_status': 0,
'custom_job_status': 0,
'custom_payment_received_status': 0,
'address_linked_to_customer': 0,
'total_field_updates': 0,
'addresses_updated': 0,
'quotations_updated': 0,
'sales_orders_updated': 0,
'customers_updated': 0,
'contacts_updated': 0,
'tasks_updated': 0
}
onsite_meta = frappe.get_meta("On-Site Meeting")
onsite_status_field = "custom_status" if onsite_meta.has_field("custom_status") else "status"
for index, doc in enumerate(combined_doctypes, 1):
# Calculate progress
progress_percentage = int((index / total_doctypes) * 100)
bar_length = 30
filled_length = int(bar_length * index // total_doctypes)
bar = '' * filled_length + '' * (bar_length - filled_length)
# Print a three-line, refreshing progress block without adding new lines each loop
progress_line = f"📊 Progress: [{bar}] {progress_percentage:3d}% ({index}/{total_doctypes})"
counters_line = f" Fields updated: {field_counters['total_field_updates']} | DocTypes updated: {field_counters['addresses_updated'] + field_counters['quotations_updated'] + field_counters['sales_orders_updated'] + field_counters['customers_updated']}"
detail_line = f" Processing: {doc['name'][:40]}..."
if index == 1:
# First render: write the three lines
sys.stdout.write(
f"\r\033[K{progress_line}\n"
f"\033[K{counters_line}\n"
f"\033[K{detail_line}"
)
else:
# Move cursor up 3 lines, then rewrite each line in place
sys.stdout.write("\033[2F")
sys.stdout.write(f"\r\033[K{progress_line}\n")
sys.stdout.write(f"\033[K{counters_line}\n")
sys.stdout.write(f"\033[K{detail_line}")
if index == total_doctypes:
sys.stdout.write("\n")
sys.stdout.flush()
if doc['doctype'] == "Quotation" or doc['doctype'] == "Sales Order":
dict_field = doc['doctype'].lower().replace(" ", "_")
quotation_doc = frappe.get_doc(doc['doctype'], doc['name'])
custom_installation_address = getattr(quotation_doc, 'custom_installation_address', None)
custom_job_address = getattr(quotation_doc, 'custom_job_address', None)
custom_project_template = getattr(quotation_doc, 'custom_project_template', None)
updates = {}
if custom_installation_address and not custom_job_address:
updates['custom_job_address'] = custom_installation_address
field_counters[f"{dict_field}_addresses_updated"] += 1
field_counters['total_field_updates'] += 1
if custom_installation_address and not custom_project_template:
updates['custom_project_template'] = "SNW Install"
field_counters[f"{dict_field}_project_templates_updated"] += 1
field_counters['total_field_updates'] += 1
if updates:
frappe.db.set_value(doc['doctype'], doc['name'], updates)
field_counters[f"{dict_field}s_updated"] += 1
if doc['doctype'] == "Address":
address_doc = frappe.get_doc("Address", doc['name'])
updates = {}
# Use getattr with default values instead of direct attribute access
if not getattr(address_doc, 'full_address', None):
address_parts_1 = [
address_doc.address_line1 or "",
address_doc.address_line2 or "",
address_doc.city or "",
]
address_parts_2 = [
address_doc.state or "",
address_doc.pincode or "",
]
full_address = ", ".join([
" ".join(filter(None, address_parts_1)),
" ".join(filter(None, address_parts_2))
]).strip()
updates['full_address'] = full_address
field_counters['full_address'] += 1
field_counters['total_field_updates'] += 1
onsite_meeting = "Not Started"
estimate_sent = "Not Started"
job_status = "Not Started"
payment_received = "Not Started"
onsite_meetings = frappe.get_all("On-Site Meeting", fields=[onsite_status_field], filters={"address": address_doc.address_title})
if onsite_meetings and onsite_meetings[0]:
status_value = onsite_meetings[0].get(onsite_status_field)
onsite_meeting = "Completed" if status_value == "Completed" else "In Progress"
estimates = frappe.get_all("Quotation", fields=["custom_sent", "docstatus", "custom_response"], filters={"custom_job_address": address_doc.address_title})
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]:
estimate_sent = "Completed"
elif estimates and estimates[0] and not (estimates[0]["custom_sent"] == 1 and estimates[0]["custom_response"]):
estimate_sent = "In Progress"
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address_doc.address_title, "project_template": "SNW Install"})
if jobs and jobs[0] and jobs[0]["status"] == "Completed":
job_status = "Completed"
elif jobs and jobs[0]:
job_status = "In Progress"
sales_invoices = frappe.get_all("Sales Invoice", fields=["outstanding_amount"], filters={"custom_installation_address": address_doc.address_title})
# payments = frappe.get_all("Payment Entry", filters={"custom_installation_address": address_doc.address_title})
if sales_invoices and sales_invoices[0] and sales_invoices[0]["outstanding_amount"] == 0:
payment_received = "Completed"
elif sales_invoices and sales_invoices[0]:
payment_received = "In Progress"
customer_name = getattr(address_doc, 'custom_customer_to_bill', None)
links = address_doc.get("links", [])
customer_links = [link for link in links if link.link_doctype == "Customer"]
needs_link_update = False
if customer_name and frappe.db.exists("Customer", customer_name):
customer_doc = frappe.get_doc("Customer", customer_name)
# Check if address needs link update
if not customer_links:
needs_link_update = True
if not address_doc.name in [link.address_name for link in customer_doc.get("custom_select_address", [])]:
customer_doc.append("custom_select_address", {
"address_name": address_doc.name
})
customer_doc.save(ignore_permissions=True)
field_counters['customers_updated'] += 1
field_counters['total_field_updates'] += 1
if getattr(address_doc, 'custom_onsite_meeting_scheduled', None) != onsite_meeting:
updates['custom_onsite_meeting_scheduled'] = onsite_meeting
field_counters['custom_onsite_meeting_scheduled'] += 1
field_counters['total_field_updates'] += 1
if getattr(address_doc, 'custom_estimate_sent_status', None) != estimate_sent:
updates['custom_estimate_sent_status'] = estimate_sent
field_counters['custom_estimate_sent_status'] += 1
field_counters['total_field_updates'] += 1
if getattr(address_doc, 'custom_job_status', None) != job_status:
updates['custom_job_status'] = job_status
field_counters['custom_job_status'] += 1
field_counters['total_field_updates'] += 1
if getattr(address_doc, 'custom_payment_received_status', None) != payment_received:
updates['custom_payment_received_status'] = payment_received
field_counters['custom_payment_received_status'] += 1
field_counters['total_field_updates'] += 1
if updates:
frappe.db.set_value("Address", doc['name'], updates)
field_counters['addresses_updated'] += 1
# Handle address links after db.set_value to avoid timestamp mismatch
if needs_link_update:
# Reload the document to get the latest version
address_doc = frappe.get_doc("Address", doc['name'])
address_doc.append("links", {
"link_doctype": "Customer",
"link_name": customer_name
})
address_doc.save(ignore_permissions=True)
field_counters['address_linked_to_customer'] += 1
field_counters['total_field_updates'] += 1
if doc['doctype'] == "Task":
property = frappe.get_value("Task", doc["name"], "custom_property")
project = frappe.get_value("Task", doc["name"], "project")
project_address = frappe.get_value("Project", project, "custom_installation_address")
alt_project_address = frappe.get_value("Project", project, "custom_address")
if (project_address or alt_project_address) and not property:
frappe.db.set_value("Task", doc["name"], "custom_property", project_address if project_address else alt_project_address)
field_counters['tasks_updated'] += 1
field_counters['total_field_updates'] += 1
# Commit every 100 records to avoid long transactions
if index % 100 == 0:
frappe.db.commit()
# Print completion summary
print(f"\n\n✅ DocType field value update completed!")
print(f"📊 Summary:")
print(f" • Total DocTypes processed: {total_doctypes:,}")
print(f" • Addresses updated: {field_counters['addresses_updated']:,}")
print(f" • Quotations updated: {field_counters['quotations_updated']:,}")
print(f" • Sales Orders updated: {field_counters['sales_orders_updated']:,}")
print(f" • Customers updated: {field_counters['customers_updated']:,}")
print(f" • Total field updates: {field_counters['total_field_updates']:,}")
print(f" • Tasks Updated: {field_counters['tasks_updated']:,}")
print(f"\n📝 Field-specific updates:")
print(f" • Full Address: {field_counters['full_address']:,}")
print(f" • On-Site Meeting Status: {field_counters['custom_onsite_meeting_scheduled']:,}")
print(f" • Estimate Sent Status: {field_counters['custom_estimate_sent_status']:,}")
print(f" • Job Status: {field_counters['custom_job_status']:,}")
print(f" • Payment Received Status: {field_counters['custom_payment_received_status']:,}")
print(f" • Quotation Addresses Updated: {field_counters['quotation_addresses_updated']:,}")
print(f" • Quotation Project Templates Updated: {field_counters['quotation_project_templates_updated']:,}")
print(f" • Sales Order Addresses Updated: {field_counters['sales_order_addresses_updated']:,}")
print(f" • Sales Order Project Templates Updated: {field_counters['sales_order_project_templates_updated']:,}")
print("📍 DocType field value updates complete.\n")
def build_missing_field_specs(custom_fields, missing_fields):
missing_field_specs = {}
for entry in missing_fields:
doctype, fieldname = entry.split(": ")
missing_field_specs.setdefault(doctype, [])
for field_spec in custom_fields.get(doctype, []):
if field_spec["fieldname"] == fieldname:
missing_field_specs[doctype].append(field_spec)
break
return missing_field_specs

View File

@ -0,0 +1,6 @@
from .address_service import AddressService
from .contact_service import ContactService
from .db_service import DbService
from .client_service import ClientService
from .estimate_service import EstimateService
from .onsite_meeting_service import OnSiteMeetingService

View File

@ -0,0 +1,223 @@
import frappe
from frappe.model.document import Document
import requests
from .contact_service import ContactService, DbService
class AddressService:
@staticmethod
def build_address_title(customer_name, address_data) -> str:
"""Build a title for the address based on its fields."""
print(f"DEBUG: Building address title for customer '{customer_name}' with address data: {address_data}")
is_billing = address_data.get("is_billing_address", False)
address_type = "Billing" if is_billing else "Service"
return f"{customer_name} - {address_data.get('address_line1', '')} {address_data.get('city')} - {address_type}"
@staticmethod
def build_full_dict(
address_doc: Document,
included_links: list = ["contacts", "on-site meetings", "quotations", "sales orders", "projects", "companies"]) -> frappe._dict:
"""Build a full dictionary representation of an address, including all links. Can optionally exclude links."""
print(f"DEBUG: Building full dict for Address {address_doc.name}")
address_dict = address_doc.as_dict()
if "contacts" in included_links:
address_dict["contacts"] = [ContactService.get_or_throw(link.contact).as_dict() for link in address_doc.contacts]
if "on-site meetings" in included_links:
address_dict["onsite_meetings"] = [DbService.get_or_throw("On-Site Meeting", link.onsite_meeting).as_dict() for link in address_doc.onsite_meetings]
if "quotations" in included_links:
address_dict["quotations"] = [DbService.get_or_throw("Quotation", link.quotation).as_dict() for link in address_doc.quotations]
if "sales orders" in included_links:
address_dict["sales_orders"] = [DbService.get_or_throw("Sales Order", link.sales_order).as_dict() for link in address_doc.sales_orders]
if "projects" in included_links:
address_dict["projects"] = [DbService.get_or_throw("Project", link.project).as_dict() for link in address_doc.projects]
if "companies" in included_links:
address_dict["companies"] = [DbService.get_or_throw("Company", link.company).as_dict() for link in address_doc.companies]
print(f"DEBUG: Built full dict for Address {address_doc.name}: {address_dict}")
return address_dict
@staticmethod
def get_address_by_full_address(full_address: str) -> Document:
"""Retrieve an address document by its full_address field. Returns None if not found."""
print(f"DEBUG: Retrieving Address document with full_address: {full_address}")
address_name = frappe.db.get_value("Address", {"full_address": full_address})
if address_name:
address_doc = DbService.get_or_throw("Address", address_name)
print("DEBUG: Address document found.")
return address_doc
print("DEBUG: Address document not found.")
return None
@staticmethod
def exists(address_name: str) -> bool:
"""Check if an address with the given name exists."""
print(f"DEBUG: Checking existence of Address with name: {address_name}")
result = frappe.db.exists("Address", address_name) is not None
print(f"DEBUG: Address existence: {result}")
return result
@staticmethod
def get(address_name: str) -> Document:
"""Retrieve an address document by name. Returns None if not found."""
print(f"DEBUG: Retrieving Address document with name: {address_name}")
if AddressService.exists(address_name):
address_doc = DbService.get_or_throw("Address", address_name)
print("DEBUG: Address document found.")
return address_doc
print("DEBUG: Address document not found.")
return None
@staticmethod
def get_or_throw(address_name: str) -> Document:
"""Retrieve an address document by name or throw an error if not found."""
address_doc = AddressService.get(address_name)
if address_doc:
return address_doc
raise ValueError(f"Address with name {address_name} does not exist.")
@staticmethod
def update_value(doc_name: str, fieldname: str, value, save: bool = True) -> Document:
"""Update a specific field value of a document."""
print(f"DEBUG: Updating Address {doc_name}, setting {fieldname} to {value}")
if AddressService.exists(doc_name) is False:
raise ValueError(f"Address with name {doc_name} does not exist.")
if save:
print("DEBUG: Saving updated Address document.")
address_doc = AddressService.get_or_throw(doc_name)
setattr(address_doc, fieldname, value)
address_doc.save(ignore_permissions=True)
else:
print("DEBUG: Not saving Address document as save=False.")
frappe.db.set_value("Address", doc_name, fieldname, value)
print(f"DEBUG: Updated Address {doc_name}: set {fieldname} to {value}")
return address_doc
@staticmethod
def get_value(doc_name: str, fieldname: str) -> any:
"""Get a specific field value of a document. Returns None if document does not exist."""
print(f"DEBUG: Getting value of field {fieldname} from Address {doc_name}")
if not AddressService.exists(doc_name):
print("DEBUG: Value cannot be retrieved; Address does not exist.")
return None
value = frappe.db.get_value("Address", doc_name, fieldname)
print(f"DEBUG: Retrieved value: {value}")
return value
@staticmethod
def get_value_or_throw(doc_name: str, fieldname: str) -> any:
"""Get a specific field value of a document or throw an error if document does not exist."""
value = AddressService.get_value(doc_name, fieldname)
if value is not None:
return value
raise ValueError(f"Address with name {doc_name} does not exist.")
@staticmethod
def create(address_data: dict) -> Document:
"""Create a new address."""
print("DEBUG: Creating new Address with data:", address_data)
address = frappe.get_doc({
"doctype": "Address",
**address_data
})
address.insert(ignore_permissions=True)
print("DEBUG: Created new Address:", address.as_dict())
return address
@staticmethod
def link_address_to_customer(address_doc: Document, customer_type: str, customer_name: str):
"""Link an address to a customer or lead."""
print(f"DEBUG: Linking Address {address_doc.name} to {customer_type} {customer_name}")
address_doc.customer_type = customer_type
address_doc.customer_name = customer_name
address_doc.append("links", {
"link_doctype": customer_type,
"link_name": customer_name
})
address_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_doc.name} to {customer_type} {customer_name}")
@staticmethod
def link_address_to_contact(address_doc: Document, contact_name: str):
"""Link an address to a contact."""
print(f"DEBUG: Linking Address {address_doc.name} to Contact {contact_name}")
address_doc.append("contacts", {
"contact": contact_name
})
address_doc.append("links", {
"link_doctype": "Contact",
"link_name": contact_name
})
address_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_doc.name} to Contact {contact_name}")
@staticmethod
def create_address(address_data: dict) -> Document:
"""Create a new address."""
address = frappe.get_doc({
"doctype": "Address",
**address_data
})
address.insert(ignore_permissions=True)
return address
@staticmethod
def set_primary_contact(address_name: str, contact_name: str):
"""Set the primary contact for an address."""
print(f"DEBUG: Setting primary contact for Address {address_name} to Contact {contact_name}")
frappe.db.set_value("Address", address_name, "primary_contact", contact_name)
print(f"DEBUG: Set primary contact for Address {address_name} to Contact {contact_name}")
@staticmethod
def append_link(address_name: str, field: str, link_doctype: str, link_name: str):
"""Set a link field for an address."""
print(f"DEBUG: Setting link field {field} for Address {address_name} to {link_doctype} {link_name}")
address_doc = AddressService.get_or_throw(address_name)
address_doc.append(field, {
link_doctype.lower(): link_name
})
address_doc.save(ignore_permissions=True)
print(f"DEBUG: Set link field {field} for Address {address_name} to {link_doctype} {link_name}")
@staticmethod
def append_link_v2(address_name: str, field: str, link: dict):
"""Set a link field for an address using a link dictionary."""
print(f"DEBUG: Setting link field {field} for Address {address_name} with link data {link}")
address_doc = AddressService.get_or_throw(address_name)
print("DEBUG: Appending link:", link)
address_doc.append(field, link)
print("DEBUG: Saving address document after appending link.")
address_doc.save(ignore_permissions=True)
print(f"DEBUG: Set link field {field} for Address {address_name} with link data {link}")
@staticmethod
def get_county_and_set(address_doc: Document, save: bool = False):
"""Get the county from the address document and set it if not already set."""
if not address_doc.county:
print(f"DEBUG: Getting county for Address {address_doc.name}")
# Example logic to determine county from address fields
# This is a placeholder; actual implementation may vary
url = "https://geocoding.geo.cencus.gov/geocoder/geographies/coordinates"
params = {
"x": address_doc.longitude,
"y": address_doc.latitude,
"benchmark": "Public_AR_Current",
"vintage": "Current_Current",
"format": "json"
}
r = requests.get(url, params=params, timeout=10)
data = r.json()
try:
county = data['result']['geographies']['Counties'][0]['NAME']
county_fips = data['result']['geographies']['Counties'][0]['GEOID']
except (KeyError, IndexError):
return None
county_info = {
"county": county,
"county_fips": county_fips
}
AddressService.update_value(address_doc.name, "county", county_info, save)
AddressService.update_value(address_doc.name, "county_fips", county_fips, save)

View File

@ -0,0 +1,155 @@
import frappe
from frappe.model.document import Document
from .db_service import DbService
from erpnext.crm.doctype.lead.lead import make_customer
from .address_service import AddressService
from .contact_service import ContactService
from .estimate_service import EstimateService
from .onsite_meeting_service import OnSiteMeetingService
class ClientService:
@staticmethod
def get_client_or_throw(client_name: str) -> Document:
"""Retrieve a Client document (Customer or Lead) or throw an error if it does not exist."""
doctype = ClientService.get_client_doctype(client_name)
return DbService.get_or_throw(doctype, client_name)
@staticmethod
def get_client_doctype(client_name: str) -> str:
"""Determine if the client is a Customer or Lead."""
if DbService.exists("Customer", client_name):
return "Customer"
elif DbService.exists("Lead", client_name):
return "Lead"
else:
raise ValueError(f"Client with name {client_name} does not exist as Customer or Lead.")
@staticmethod
def set_primary_contact(client_name: str, contact_name: str):
"""Set the primary contact for a client (Customer or Lead)."""
print(f"DEBUG: Setting primary contact for client {client_name} to contact {contact_name}")
client_doctype = ClientService.get_client_doctype(client_name)
frappe.db.set_value(client_doctype, client_name, "primary_contact", contact_name)
print(f"DEBUG: Set primary contact for client {client_name} to contact {contact_name}")
@staticmethod
def append_link(client_name: str, field: str, link_doctype: str, link_name: str):
"""Set a link field for a client (Customer or Lead)."""
print(f"DEBUG: Setting link field {field} for client {client_name} to {link_doctype} {link_name}")
client_doctype = ClientService.get_client_doctype(client_name)
client_doc = frappe.get_doc(client_doctype, client_name)
client_doc.append(field, {
link_doctype.lower(): link_name
})
client_doc.save(ignore_permissions=True)
print(f"DEBUG: Set link field {field} for client {client_doc.get('name')} to {link_doctype} {link_name}")
@staticmethod
def append_link_v2(client_name: str, field: str, link: dict):
"""Set a link field for a client (Customer or Lead) using a link dictionary."""
print(f"DEBUG: Setting link field {field} for client {client_name} with link data {link}")
client_doctype = ClientService.get_client_doctype(client_name)
client_doc = DbService.get_or_throw(client_doctype, client_name)
print("DEBUG: Appending link:", link)
client_doc.append(field, link)
print("DEBUG: Saving client document after appending link.")
client_doc.save(ignore_permissions=True)
print(f"DEBUG: Set link field {field} for client {client_doc.get('name')} with link data {link}")
@staticmethod
def convert_lead_to_customer(
lead_name: str,
update_quotations: bool = True,
update_addresses: bool = True,
update_contacts: bool = True,
update_onsite_meetings: bool = True,
update_companies: bool = True
) -> Document:
"""Convert a Lead to a Customer."""
print(f"DEBUG: Converting Lead {lead_name} to Customer")
try:
lead_doc = DbService.get_or_throw("Lead", lead_name)
print(f"DEBUG: Retrieved Lead document: {lead_doc.name}")
print("DEBUG: RUNNING make_customer()")
customer_doc = make_customer(lead_doc.name)
print(f"DEBUG: make_customer() returned document type: {type(customer_doc)}")
print(f"DEBUG: Customer doc name: {customer_doc.name if hasattr(customer_doc, 'name') else 'NO NAME'}")
customer_doc.custom_billing_address = lead_doc.custom_billing_address
print("DEBUG: Calling customer_doc.insert()")
customer_doc.insert(ignore_permissions=True)
print(f"DEBUG: Customer inserted successfully: {customer_doc.name}")
frappe.db.commit()
print("DEBUG: Database committed after customer insert")
print("DEBUG: CREATED CUSTOMER:", customer_doc.as_dict())
if update_addresses:
print("DEBUG: Lead_doc addresses:", lead_doc.get("addresses", []))
print(f"DEBUG: Updating addresses. Count: {len(lead_doc.get('properties', []))}")
for address in lead_doc.get("properties", []):
try:
print(f"DEBUG: Processing address: {address.get('address')}")
ClientService.append_link_v2(customer_doc.name, "properties", {"address": address.get("address")})
address_doc = AddressService.get_or_throw(address.get("address"))
AddressService.link_address_to_customer(address_doc, "Customer", customer_doc.name)
print(f"DEBUG: Linked address {address.get('address')} to customer")
except Exception as e:
print(f"ERROR: Failed to link address {address.get('address')}: {str(e)}")
frappe.log_error(f"Address linking error: {str(e)}", "convert_lead_to_customer")
if update_contacts:
print(f"DEBUG: Updating contacts. Count: {len(lead_doc.get('contacts', []))}")
for contact in lead_doc.get("contacts", []):
try:
print(f"DEBUG: Processing contact: {contact.get('contact')}")
ClientService.append_link_v2(customer_doc.name, "contacts", {"contact": contact.get("contact")})
contact_doc = ContactService.get_or_throw(contact.get("contact"))
ContactService.link_contact_to_customer(contact_doc, "Customer", customer_doc.name)
print(f"DEBUG: Linked contact {contact.get('contact')} to customer")
except Exception as e:
print(f"ERROR: Failed to link contact {contact.get('contact')}: {str(e)}")
frappe.log_error(f"Contact linking error: {str(e)}", "convert_lead_to_customer")
if update_quotations:
print(f"DEBUG: Updating quotations. Count: {len(lead_doc.get('quotations', []))}")
for quotation in lead_doc.get("quotations", []):
try:
print(f"DEBUG: Processing quotation: {quotation.get('quotation')}")
ClientService.append_link_v2(customer_doc.name, "quotations", {"quotation": quotation.get("quotation")})
quotation_doc = EstimateService.get_or_throw(quotation.get("quotation"))
EstimateService.link_estimate_to_customer(quotation_doc, "Customer", customer_doc.name)
print(f"DEBUG: Linked quotation {quotation.get('quotation')} to customer")
except Exception as e:
print(f"ERROR: Failed to link quotation {quotation.get('quotation')}: {str(e)}")
frappe.log_error(f"Quotation linking error: {str(e)}", "convert_lead_to_customer")
if update_onsite_meetings:
print(f"DEBUG: Updating onsite meetings. Count: {len(lead_doc.get('onsite_meetings', []))}")
for meeting in lead_doc.get("onsite_meetings", []):
try:
print(f"DEBUG: Processing onsite meeting: {meeting.get('onsite_meeting')}")
meeting_doc = DbService.get_or_throw("On-Site Meeting",meeting.get("onsite_meeting"))
ClientService.append_link_v2(customer_doc.name, "onsite_meetings", {"onsite_meeting": meeting.get("onsite_meeting")})
OnSiteMeetingService.link_onsite_meeting_to_customer(meeting_doc, "Customer", customer_doc.name)
print(f"DEBUG: Linked onsite meeting {meeting.get('onsite_meeting')} to customer")
except Exception as e:
print(f"ERROR: Failed to link onsite meeting {meeting.get('onsite_meeting')}: {str(e)}")
frappe.log_error(f"Onsite meeting linking error: {str(e)}", "convert_lead_to_customer")
if update_companies:
print(f"DEBUG: Updating companies. Count: {len(lead_doc.get('companies', []))}")
for company in lead_doc.get("companies", []):
try:
print(f"DEBUG: Processing company: {company.get('company')}")
ClientService.append_link_v2(customer_doc.name, "companies", {"company": company.get("company")})
print(f"DEBUG: Linked company {company.get('company')} to customer")
except Exception as e:
print(f"ERROR: Failed to link company {company.get('company')}: {str(e)}")
frappe.log_error(f"Company linking error: {str(e)}", "convert_lead_to_customer")
print(f"DEBUG: Converted Lead {lead_name} to Customer {customer_doc.name}")
return customer_doc
except Exception as e:
print(f"ERROR: Exception in convert_lead_to_customer: {str(e)}")
frappe.log_error(f"convert_lead_to_customer failed: {str(e)}", "ClientService")
raise

View File

@ -0,0 +1,49 @@
import frappe
from frappe.model.document import Document
from .db_service import DbService
class ContactService:
@staticmethod
def create(data: dict) -> Document:
"""Create a new contact."""
print("DEBUG: Creating new Contact with data:", data)
contact = frappe.get_doc({
"doctype": "Contact",
**data
})
contact.insert(ignore_permissions=True)
print("DEBUG: Created new Contact:", contact.as_dict())
return contact
@staticmethod
def link_contact_to_customer(contact_doc: Document, customer_type: str, customer_name: str):
"""Link a contact to a customer or lead."""
print(f"DEBUG: Linking Contact {contact_doc.name} to {customer_type} {customer_name}")
contact_doc.customer_type = customer_type
contact_doc.customer_name = customer_name
contact_doc.append("links", {
"link_doctype": customer_type,
"link_name": customer_name
})
contact_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Contact {contact_doc.name} to {customer_type} {customer_name}")
@staticmethod
def link_contact_to_address(contact_doc: Document, address_name: str):
"""Link an address to a contact."""
print(f"DEBUG: Linking Address {address_name} to Contact {contact_doc.name}")
contact_doc.append("addresses", {
"address": address_name
})
contact_doc.append("links", {
"link_doctype": "Address",
"link_name": address_name
})
contact_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_name} to Contact {contact_doc.name}")
@staticmethod
def get_or_throw(contact_name: str) -> Document:
"""Retrieve a Contact document or throw an error if it does not exist."""
return DbService.get_or_throw("Contact", contact_name)

View File

@ -0,0 +1,91 @@
import frappe
class DbService:
@staticmethod
def exists(doctype: str, name: str) -> bool:
"""Check if a document exists by doctype and name."""
result = frappe.db.exists(doctype, name) is not None
print(f"DEBUG: {doctype} existence for {name}: {result}")
return result
@staticmethod
def get(doctype: str, name: str):
"""Retrieve a document by doctype and name. Returns None if not found."""
print(f"DEBUG: Retrieving {doctype} document with name: {name}")
if DbService.exists(doctype, name):
doc = frappe.get_doc(doctype, name)
print(f"DEBUG: {doctype} document found.")
return doc
print(f"DEBUG: {doctype} document not found.")
return None
@staticmethod
def get_or_throw(doctype: str, name: str):
"""Retrieve a document by doctype and name or throw an error if not found."""
doc = DbService.get(doctype, name)
if doc:
return doc
raise ValueError(f"{doctype} document with name {name} does not exist.")
@staticmethod
def get_value(doctype: str, name: str, fieldname: str):
"""Get a specific field value of a document. Returns None if document does not exist."""
print(f"DEBUG: Getting value of field {fieldname} from {doctype} {name}")
if not DbService.exists(doctype, name):
print("DEBUG: Value cannot be retrieved; document does not exist.")
return None
value = frappe.db.get_value(doctype, name, fieldname)
print(f"DEBUG: Retrieved value: {value}")
return value
@staticmethod
def get_value_or_throw(doctype: str, name: str, fieldname: str):
"""Get a specific field value of a document or throw an error if document does not exist."""
value = DbService.get_value(doctype, name, fieldname)
if value is not None:
return value
raise ValueError(f"{doctype} document with name {name} does not exist.")
@staticmethod
def create(doctype: str, data: dict):
"""Create a new document of the specified doctype."""
print(f"DEBUG: Creating new {doctype} document with data: {data}")
doc = frappe.get_doc({
"doctype": doctype,
**data
})
doc.insert(ignore_permissions=True)
print(f"DEBUG: Created new {doctype} document with name: {doc.name}")
return doc
@staticmethod
def set_value(doctype: str, name: str, fieldname: str, value: any, save: bool = True):
"""Set a specific field value of a document."""
print(f"DEBUG: Setting value of field {fieldname} in {doctype} {name} to {value}")
if save:
print("DEBUG: Saving updated document.")
doc = DbService.get_or_throw(doctype, name)
setattr(doc, fieldname, value)
doc.save(ignore_permissions=True)
return doc
else:
print("DEBUG: Not saving document as save=False.")
frappe.db.set_value(doctype, name, fieldname, value)
return None
@staticmethod
def update(doctype: str, name: str, update_data: dict, save: bool = True):
"""Update an existing document of the specified doctype."""
print(f"DEBUG: Updating {doctype} {name}")
doc = DbService.get_or_throw(doctype, name)
for key, value in update_data.items():
setattr(doc, key, value)
if save:
doc.save(ignore_permissions=True)
else:
DbService.set_value(doctype, name, key, value, save=False)
print(f"DEBUG: Updated {doctype} document: {doc.as_dict()}")
return doc

View File

@ -0,0 +1,96 @@
import frappe
class EstimateService:
@staticmethod
def exists(estimate_name: str) -> bool:
"""Check if a Quotation document exists by name."""
print(f"DEBUG: Checking existence of Quotation document with name: {estimate_name}")
result = frappe.db.exists("Quotation", estimate_name) is not None
print(f"DEBUG: Quotation document existence: {result}")
return result
@staticmethod
def get(estimate_name: str) -> frappe._dict:
"""Retrieve a Quotation document by name. Returns None if not found."""
print(f"DEBUG: Retrieving Quotation document with name: {estimate_name}")
if EstimateService.exists(estimate_name):
estimate_doc = frappe.get_doc("Quotation", estimate_name)
print("DEBUG: Quotation document found.")
return estimate_doc
print("DEBUG: Quotation document not found.")
return None
@staticmethod
def get_or_throw(estimate_name: str) -> frappe._dict:
"""Retrieve a Quotation document by name or throw an error if not found."""
estimate_doc = EstimateService.get(estimate_name)
if estimate_doc:
return estimate_doc
raise ValueError(f"Quotation document with name {estimate_name} does not exist.")
@staticmethod
def update_value(docname: str, fieldname: str, value, save: bool = True) -> None:
"""Update a specific field value of an Quotation document."""
print(f"DEBUG: Updating Quotation {docname}, setting {fieldname} to {value}")
if save:
print("DEBUG: Saving updated Quotation document.")
estimate_doc = EstimateService.get_or_throw(docname)
setattr(estimate_doc, fieldname, value)
estimate_doc.save(ignore_permissions=True)
else:
print("DEBUG: Not saving Quotation document as save=False.")
frappe.db.set_value("Quotation", docname, fieldname, value)
print(f"DEBUG: Updated Quotation {docname}: set {fieldname} to {value}")
@staticmethod
def get_value(docname: str, fieldname: str) -> any:
"""Get a specific field value of a Quotation document. Returns None if document does not exist."""
print(f"DEBUG: Getting value of field {fieldname} from Quotation {docname}")
if not EstimateService.exists(docname):
print("DEBUG: Value cannot be retrieved; Quotation document does not exist.")
return None
value = frappe.db.get_value("Quotation", docname, fieldname)
print(f"DEBUG: Retrieved value: {value}")
return value
@staticmethod
def get_value_or_throw(docname: str, fieldname: str) -> any:
"""Get a specific field value of a Quotation document or throw an error if document does not exist."""
value = EstimateService.get_value(docname, fieldname)
if value is not None:
return value
raise ValueError(f"Quotation document with name {docname} does not exist.")
@staticmethod
def update(estimate_name: str, update_data: dict) -> frappe._dict:
"""Update an existing Quotation document."""
print(f"DEBUG: Updating Quotation {estimate_name} with data: {update_data}")
estimate_doc = EstimateService.get_or_throw(estimate_name)
for key, value in update_data.items():
setattr(estimate_doc, key, value)
estimate_doc.save(ignore_permissions=True)
print(f"DEBUG: Updated Quotation document: {estimate_doc.as_dict()}")
return estimate_doc
@staticmethod
def create(estimate_data: dict) -> frappe._dict:
"""Create a new Quotation document."""
print(f"DEBUG: Creating new Quotation with data: {estimate_data}")
estimate_doc = frappe.get_doc({
"doctype": "Quotation",
**estimate_data
})
estimate_doc.insert(ignore_permissions=True)
print(f"DEBUG: Created Quotation document: {estimate_doc.as_dict()}")
return estimate_doc
@staticmethod
def link_estimate_to_customer(estimate_doc: frappe._dict, customer_type: str, customer_name: str) -> None:
"""Link a Quotation document to a client document."""
print(f"DEBUG: Linking Quotation {estimate_doc.name} to {customer_type} {customer_name}")
estimate_doc.customer_type = customer_type
estimate_doc.customer = customer_name
estimate_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Quotation {estimate_doc.name} to {customer_type} {customer_name}")

View File

@ -0,0 +1,38 @@
import frappe
class OnSiteMeetingService:
@staticmethod
def exists(onsite_meeting_name: str) -> bool:
"""Check if an OnSite Meeting document exists by name."""
result = frappe.db.exists("OnSite Meeting", onsite_meeting_name) is not None
print(f"DEBUG: OnSite Meeting existence for {onsite_meeting_name}: {result}")
return result
@staticmethod
def get(onsite_meeting_name: str) -> frappe._dict:
"""Retrieve an OnSite Meeting document by name. Returns None if not found."""
print(f"DEBUG: Retrieving OnSite Meeting document with name: {onsite_meeting_name}")
if OnSiteMeetingService.exists(onsite_meeting_name):
onsite_meeting_doc = frappe.get_doc("OnSite Meeting", onsite_meeting_name)
print("DEBUG: OnSite Meeting document found.")
return onsite_meeting_doc
print("DEBUG: OnSite Meeting document not found.")
return None
@staticmethod
def get_or_throw(onsite_meeting_name: str) -> frappe._dict:
"""Retrieve an OnSite Meeting document or throw an error if not found."""
onsite_meeting_doc = OnSiteMeetingService.get(onsite_meeting_name)
if not onsite_meeting_doc:
raise ValueError(f"OnSite Meeting with name {onsite_meeting_name} does not exist.")
return onsite_meeting_doc
@staticmethod
def link_onsite_meeting_to_customer(onsite_meeting_doc, customer_type, customer_name):
"""Link an onsite meeting to a customer or lead."""
print(f"DEBUG: Linking Onsite Meeting {onsite_meeting_doc.name} to {customer_type} {customer_name}")
onsite_meeting_doc.party_type = customer_type
onsite_meeting_doc.party_name = customer_name
onsite_meeting_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Onsite Meeting {onsite_meeting_doc.name} to {customer_type} {customer_name}")

View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quotation Accepted</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: #333;
}
.container {
background-color: white;
padding: 50px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
text-align: center;
max-width: 500px;
width: 100%;
position: relative;
overflow: hidden;
}
.container::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
opacity: 0.1;
transform: rotate(45deg);
z-index: -1;
}
h1 {
color: #1976d2;
margin-bottom: 20px;
font-size: 2.5em;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.checkmark {
font-size: 3em;
color: #ff9800;
margin-bottom: 20px;
animation: bounce 1s ease-in-out;
}
p {
color: #424242;
font-size: 1.2em;
margin-bottom: 15px;
line-height: 1.6;
}
.highlight {
color: #2196f3;
font-weight: 600;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
</style>
</head>
<body>
<div class="container">
<div class="checkmark"></div>
<h1>Thank You, {{ doc.party_name or doc.customer }}!</h1>
<p>You <span class="highlight">accepted the Quote</span>! You will receive a payment link shortly.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: #333;
}
.container {
background-color: white;
padding: 50px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
text-align: center;
max-width: 500px;
width: 100%;
position: relative;
overflow: hidden;
}
.container::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
opacity: 0.1;
transform: rotate(45deg);
z-index: -1;
}
h1 {
color: #1976d2;
margin-bottom: 20px;
font-size: 2.5em;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.icon {
font-size: 3em;
color: #ff9800;
margin-bottom: 20px;
}
p {
color: #424242;
font-size: 1.2em;
margin-bottom: 15px;
line-height: 1.6;
}
.error-details {
color: #d32f2f;
font-size: 1em;
margin-top: 20px;
padding: 10px;
background-color: #ffebee;
border-left: 4px solid #d32f2f;
border-radius: 4px;
}
.highlight {
color: #2196f3;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">⚠️</div>
<h1>Oops! Something went wrong.</h1>
<p>We're sorry, but an error occurred. Please try again later or contact support if the problem persists.</p>
<p class="error-details">Error: {{ error or "An unknown error occurred." }}</p>
</div>
</body>
</html>

View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quotation Rejected</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: #333;
}
.container {
background-color: white;
padding: 50px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
text-align: center;
max-width: 500px;
width: 100%;
position: relative;
overflow: hidden;
}
.container::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
opacity: 0.1;
transform: rotate(45deg);
z-index: -1;
}
h1 {
color: #1976d2;
margin-bottom: 20px;
font-size: 2.5em;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.icon {
font-size: 3em;
color: #ff9800;
margin-bottom: 20px;
}
p {
color: #424242;
font-size: 1.2em;
margin-bottom: 15px;
line-height: 1.6;
}
.highlight {
color: #2196f3;
font-weight: 600;
}
.contact-info {
background-color: #f9f9f9;
padding: 15px;
border-radius: 10px;
margin-top: 20px;
border-left: 4px solid #1976d2;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📞</div>
<h1>We're Sorry, {{ doc.party_name or doc.customer }}</h1>
<p>We understand that our quote didn't meet your needs this time. We'd still love to discuss how we can help with your project!</p>
<p>Please don't hesitate to reach out:</p>
<div class="contact-info">
<p><strong>Phone:</strong> [Your Company Phone Number]</p>
<p><strong>Email:</strong> [Your Company Email]</p>
<p><strong>Website:</strong> [Your Company Website]</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Call Requested</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: #333;
}
.container {
background-color: white;
padding: 50px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
text-align: center;
max-width: 500px;
width: 100%;
position: relative;
overflow: hidden;
}
.container::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
opacity: 0.1;
transform: rotate(45deg);
z-index: -1;
}
h1 {
color: #1976d2;
margin-bottom: 20px;
font-size: 2.5em;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.icon {
font-size: 3em;
color: #ff9800;
margin-bottom: 20px;
animation: bounce 1s ease-in-out;
}
p {
color: #424242;
font-size: 1.2em;
margin-bottom: 15px;
line-height: 1.6;
}
.highlight {
color: #2196f3;
font-weight: 600;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📞</div>
<h1>Thank You, {{ doc.party_name or doc.customer }}!</h1>
<p>Thank you for your response! Someone from our team will <span class="highlight">reach out to you soon</span> to discuss your project.</p>
</div>
</body>
</html>

2
frontend/.gitignore vendored
View File

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

View File

@ -0,0 +1,90 @@
# Calendar View Update Summary
## Overview
Updated the Calendar.vue component from a weekly view to a daily view with foremen as columns and 30-minute time slots as rows.
## Key Changes Made
### 1. Layout Structure
- **Before**: Weekly calendar with 7 day columns
- **After**: Daily calendar with 10 foreman columns
### 2. Header Changes
- Changed from "Sprinkler Service Calendar" to "Daily Schedule - Sprinkler Service"
- Navigation changed from week-based (previousWeek/nextWeek) to day-based (previousDay/nextDay)
- Display shows full day name instead of week range
### 3. Grid Structure
- **Columns**: Now shows foremen names instead of days of the week
- **Rows**: Still uses 30-minute time slots from 7 AM to 7 PM
- Grid template updated from `repeat(7, 1fr)` to `repeat(10, 1fr)` for 10 foremen
### 4. Foremen Data
Added 10 foremen to the system:
- Mike Thompson
- Sarah Johnson
- David Martinez
- Chris Wilson
- Lisa Anderson
- Robert Thomas
- Maria White
- James Clark
- Patricia Lewis
- Kevin Walker
### 5. Event Scheduling Logic
- Events now filter by foreman name instead of day
- Drag and drop updated to assign services to specific foremen
- Time slot conflict detection now checks per foreman instead of per day
- Preview slots updated to show foreman-specific scheduling
### 6. Visual Updates
- Foreman headers show name and job count for the day
- CSS classes renamed from `day-column` to `foreman-column`
- Updated styling to accommodate wider layout for 10 columns
- Maintained all existing drag-and-drop visual feedback
### 7. Functionality Preserved
- All existing drag-and-drop functionality
- Service priority handling
- Unscheduled services panel
- Event details modal
- Time slot highlighting for current time
## Technical Implementation Details
### Data Flow
1. `currentDate` (string) - tracks the currently viewed date
2. `foremen` (array) - contains foreman ID and name pairs
3. Services filter by `foreman` name and `scheduledDate` matching `currentDate`
4. Grid renders 10 columns × ~24 time slots (30-min intervals)
### Key Methods Updated
- `getEventsForTimeSlot(foremanName, time, date)` - filters by foreman and date
- `isTimeSlotOccupied(foremanName, startTime, duration)` - checks conflicts per foreman
- `getOccupiedSlots(foremanId, startTime, duration)` - preview slots per foreman
- `handleDragOver/handleDrop` - updated to work with foreman IDs
- Navigation: `previousDay()`, `nextDay()`, `goToToday()`
### CSS Grid Layout
```css
.calendar-header-row, .time-row {
grid-template-columns: 80px repeat(10, 1fr);
}
```
This provides a time column (80px) plus 10 equal-width foreman columns.
## Benefits of New Design
1. **Better resource allocation** - See all foremen's schedules at once
2. **Easier scheduling** - Drag services directly to specific foremen
3. **Conflict prevention** - Visual feedback for time conflicts per foreman
4. **Daily focus** - Concentrate on optimizing a single day's schedule
5. **Scalable** - Easy to add/remove foremen by updating the foremen array
## Usage
- Use left/right arrows to navigate between days
- Drag unscheduled services from the right panel to specific foreman time slots
- Services automatically get assigned to the foreman and time slot where dropped
- Current time slot is highlighted across all foremen columns
- Each foreman header shows their job count for the selected day

View File

@ -0,0 +1,490 @@
# Server-Side Pagination Implementation Guide
## Overview
This implementation provides server-side pagination with persistent state management for large datasets (5000+ records). It combines PrimeVue's lazy loading capabilities with Pinia stores for state persistence.
## Architecture
### Stores
1. **`usePaginationStore`** - Manages pagination state (page, pageSize, totalRecords, sorting)
2. **`useFiltersStore`** - Manages filter state (existing, enhanced for pagination)
3. **`useLoadingStore`** - Manages loading states (existing, works with pagination)
### Components
1. **`DataTable`** - Enhanced with lazy loading support
2. **`Api`** - Updated with pagination and filtering parameters
## Key Features
**Server-side pagination** - Only loads current page data
**Persistent state** - Page and filter state survive navigation
**Real-time filtering** - Filters reset to page 1 and re-query server
**Sorting support** - Server-side sorting with state persistence
**Loading states** - Integrated with existing loading system
**Performance** - Handles 5000+ records efficiently
## Usage
### Basic Paginated DataTable
```vue
<template>
<DataTable
:data="tableData"
:columns="columns"
tableName="clients"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
:onLazyLoad="handleLazyLoad"
@lazy-load="handleLazyLoad"
/>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { usePaginationStore } from "@/stores/pagination";
import { useFiltersStore } from "@/stores/filters";
import Api from "@/api";
const paginationStore = usePaginationStore();
const filtersStore = useFiltersStore();
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const handleLazyLoad = async (event) => {
try {
isLoading.value = true;
const paginationParams = {
page: event.page || 0,
pageSize: event.rows || 10,
sortField: event.sortField,
sortOrder: event.sortOrder,
};
const filters = {};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (key !== "global" && event.filters[key]?.value) {
filters[key] = event.filters[key];
}
});
}
const result = await Api.getPaginatedData(paginationParams, filters);
tableData.value = result.data;
totalRecords.value = result.totalRecords;
paginationStore.setTotalRecords("tableName", result.totalRecords);
} catch (error) {
console.error("Error loading data:", error);
tableData.value = [];
totalRecords.value = 0;
} finally {
isLoading.value = false;
}
};
onMounted(async () => {
// Initialize stores
paginationStore.initializeTablePagination("tableName", { rows: 10 });
filtersStore.initializeTableFilters("tableName", columns);
// Load initial data
const pagination = paginationStore.getTablePagination("tableName");
const filters = filtersStore.getTableFilters("tableName");
await handleLazyLoad({
page: pagination.page,
rows: pagination.rows,
first: pagination.first,
sortField: pagination.sortField,
sortOrder: pagination.sortOrder,
filters: filters,
});
});
</script>
```
## API Implementation
### Required API Method Structure
```javascript
// In your API class
static async getPaginatedData(paginationParams = {}, filters = {}) {
const {
page = 0,
pageSize = 10,
sortField = null,
sortOrder = null
} = paginationParams;
// Build database query with pagination
const offset = page * pageSize;
const limit = pageSize;
// Apply filters to query
const whereClause = buildWhereClause(filters);
// Apply sorting
const orderBy = sortField ? `${sortField} ${sortOrder === -1 ? 'DESC' : 'ASC'}` : '';
// Execute queries
const [data, totalCount] = await Promise.all([
db.query(`SELECT * FROM table ${whereClause} ${orderBy} LIMIT ${limit} OFFSET ${offset}`),
db.query(`SELECT COUNT(*) FROM table ${whereClause}`)
]);
return {
data: data,
totalRecords: totalCount[0].count
};
}
```
### Frappe Framework Implementation
```javascript
static async getPaginatedClientDetails(paginationParams = {}, filters = {}) {
const { page = 0, pageSize = 10, sortField = null, sortOrder = null } = paginationParams;
// Build Frappe filters
let frappeFilters = {};
Object.keys(filters).forEach(key => {
if (filters[key] && filters[key].value) {
switch (key) {
case 'fullName':
frappeFilters.address_line1 = ['like', `%${filters[key].value}%`];
break;
// Add other filter mappings
}
}
});
// Get total count and paginated data
const [totalCount, records] = await Promise.all([
this.getDocCount("DocType", frappeFilters),
this.getDocsList("DocType", ["*"], frappeFilters, page, pageSize)
]);
// Process and return data
const processedData = records.map(record => ({
id: record.name,
// ... other fields
}));
return {
data: processedData,
totalRecords: totalCount
};
}
```
## DataTable Props
### New Props for Pagination
```javascript
const props = defineProps({
// Existing props...
// Server-side pagination
lazy: {
type: Boolean,
default: false, // Set to true for server-side pagination
},
totalRecords: {
type: Number,
default: 0, // Total records from server
},
onLazyLoad: {
type: Function,
default: null, // Lazy load handler function
},
});
```
### Events
- **`@lazy-load`** - Emitted when pagination/filtering/sorting changes
- **`@page-change`** - Emitted when page changes
- **`@sort-change`** - Emitted when sorting changes
- **`@filter-change`** - Emitted when filters change
## Pagination Store Methods
### Basic Usage
```javascript
const paginationStore = usePaginationStore();
// Initialize pagination for a table
paginationStore.initializeTablePagination("clients", {
rows: 10,
totalRecords: 0,
});
// Update pagination after API response
paginationStore.setTotalRecords("clients", 1250);
// Navigate pages
paginationStore.setPage("clients", 2);
paginationStore.nextPage("clients");
paginationStore.previousPage("clients");
// Get pagination parameters for API calls
const params = paginationStore.getPaginationParams("clients");
// Returns: { page: 2, pageSize: 10, offset: 20, limit: 10, sortField: null, sortOrder: null }
// Get page information for display
const info = paginationStore.getPageInfo("clients");
// Returns: { start: 21, end: 30, total: 1250 }
```
### Advanced Methods
```javascript
// Handle PrimeVue lazy load events
const params = paginationStore.handleLazyLoad("clients", primeVueEvent);
// Set sorting
paginationStore.setSorting("clients", "name", 1); // 1 for ASC, -1 for DESC
// Change rows per page
paginationStore.setRowsPerPage("clients", 25);
// Reset to first page (useful when filters change)
paginationStore.resetToFirstPage("clients");
// Get computed properties
const totalPages = paginationStore.getTotalPages("clients");
const hasNext = paginationStore.hasNextPage("clients");
const hasPrevious = paginationStore.hasPreviousPage("clients");
```
## Filter Integration
Filters work seamlessly with pagination:
```javascript
// When a filter changes, pagination automatically resets to page 1
const handleFilterChange = (fieldName, value) => {
// Update filter
filtersStore.updateTableFilter("clients", fieldName, value);
// Pagination automatically resets to page 1 in DataTable component
// New API call is triggered with updated filters
};
```
## State Persistence
Both pagination and filter states persist across:
- Component re-mounts
- Page navigation
- Browser refresh (if using localStorage)
### Persistence Configuration
```javascript
// In your store, you can add persistence
import { defineStore } from "pinia";
export const usePaginationStore = defineStore("pagination", {
// ... store definition
persist: {
enabled: true,
strategies: [
{
key: "pagination-state",
storage: localStorage, // or sessionStorage
paths: ["tablePagination"],
},
],
},
});
```
## Performance Considerations
### Database Optimization
1. **Indexes** - Ensure filtered and sorted columns are indexed
2. **Query Optimization** - Use efficient WHERE clauses
3. **Connection Pooling** - Handle concurrent requests efficiently
### Frontend Optimization
1. **Debounced Filtering** - Avoid excessive API calls
2. **Loading States** - Provide user feedback during requests
3. **Error Handling** - Gracefully handle API failures
4. **Memory Management** - Clear data when not needed
### Recommended Page Sizes
- **Small screens**: 5-10 records
- **Desktop**: 10-25 records
- **Large datasets**: 25-50 records
- **Avoid**: 100+ records per page
## Error Handling
```javascript
const handleLazyLoad = async (event) => {
try {
isLoading.value = true;
const result = await Api.getPaginatedData(params, filters);
// Success handling
tableData.value = result.data;
totalRecords.value = result.totalRecords;
} catch (error) {
console.error("Pagination error:", error);
// Reset to safe state
tableData.value = [];
totalRecords.value = 0;
// Show user-friendly error
showErrorToast("Failed to load data. Please try again.");
// Optionally retry with fallback parameters
if (event.page > 0) {
paginationStore.setPage(tableName, 0);
// Retry with page 0
}
} finally {
isLoading.value = false;
}
};
```
## Migration from Client-Side
### Before (Client-side)
```javascript
// Old approach - loads all data
onMounted(async () => {
const data = await Api.getAllClients(); // 5000+ records
tableData.value = data;
});
```
### After (Server-side)
```javascript
// New approach - loads only current page
onMounted(async () => {
paginationStore.initializeTablePagination("clients");
await handleLazyLoad({
page: 0,
rows: 10,
// ... other params
});
});
```
## Testing
### Unit Tests
```javascript
import { usePaginationStore } from "@/stores/pagination";
describe("Pagination Store", () => {
it("should initialize pagination correctly", () => {
const store = usePaginationStore();
store.initializeTablePagination("test", { rows: 20 });
const pagination = store.getTablePagination("test");
expect(pagination.rows).toBe(20);
expect(pagination.page).toBe(0);
});
it("should handle page navigation", () => {
const store = usePaginationStore();
store.setTotalRecords("test", 100);
store.setPage("test", 2);
expect(store.getTablePagination("test").page).toBe(2);
expect(store.hasNextPage("test")).toBe(true);
});
});
```
### Integration Tests
```javascript
// Test lazy loading with mock API
const mockLazyLoad = vi.fn().mockResolvedValue({
data: [{ id: 1, name: "Test" }],
totalRecords: 50,
});
// Test component with mocked API
const wrapper = mount(DataTableComponent, {
props: {
lazy: true,
onLazyLoad: mockLazyLoad,
},
});
// Verify API calls
expect(mockLazyLoad).toHaveBeenCalledWith({
page: 0,
rows: 10,
// ... expected parameters
});
```
## Troubleshooting
### Common Issues
1. **Infinite Loading**
- Check API endpoint returns correct totalRecords
- Verify pagination parameters are calculated correctly
2. **Filters Not Working**
- Ensure filter parameters are passed to API correctly
- Check database query includes WHERE clauses
3. **Page State Not Persisting**
- Verify store persistence is configured
- Check localStorage/sessionStorage permissions
4. **Performance Issues**
- Add database indexes for filtered/sorted columns
- Optimize API query efficiency
- Consider reducing page size
### Debug Information
```javascript
// Add debug logging to lazy load handler
const handleLazyLoad = async (event) => {
console.log("Lazy Load Event:", {
page: event.page,
rows: event.rows,
sortField: event.sortField,
sortOrder: event.sortOrder,
filters: event.filters,
timestamp: new Date().toISOString(),
});
// ... rest of implementation
};
```
This implementation provides a robust, performant solution for handling large datasets with persistent pagination and filtering state.

View File

@ -0,0 +1,278 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Updated DataTable Actions Behavior Test</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
line-height: 1.6;
}
.test-case {
margin: 20px 0;
padding: 15px;
border-left: 4px solid #007bff;
background-color: #f8f9fa;
}
.example {
background-color: #f1f1f1;
padding: 10px;
margin: 10px 0;
border-radius: 4px;
}
table {
border-collapse: collapse;
width: 100%;
margin: 10px 0;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
}
.code {
background-color: #f5f5f5;
padding: 2px 5px;
border-radius: 3px;
font-family: monospace;
}
</style>
</head>
<body>
<h1>Updated DataTable Actions Behavior Test</h1>
<h2>✅ New Action Behavior Summary</h2>
<div class="test-case">
<h3>Action Type Changes:</h3>
<ul>
<li>
<strong>Global Actions</strong>: Default behavior - always available above
table
</li>
<li>
<strong>Single Selection Actions</strong> (<span class="code"
>requiresSelection: true</span
>): Above table, enabled only when exactly one row selected
</li>
<li>
<strong>Row Actions</strong> (<span class="code">rowAction: true</span>): In
actions column, available per row
</li>
<li>
<strong>Bulk Actions</strong> (<span class="code"
>requiresMultipleSelection: true</span
>): Above table when rows selected
</li>
</ul>
</div>
<div class="test-case">
<h3>Updated Action Types Matrix:</h3>
<table>
<tr>
<th>Action Type</th>
<th>Property</th>
<th>Location</th>
<th>Enabled When</th>
<th>Data Received</th>
</tr>
<tr>
<td>Global</td>
<td>None (default)</td>
<td>Above table</td>
<td>Always</td>
<td>None</td>
</tr>
<tr>
<td>Single Selection</td>
<td><span class="code">requiresSelection: true</span></td>
<td>Above table</td>
<td>Exactly 1 row selected</td>
<td>Selected row object</td>
</tr>
<tr>
<td>Row Action</td>
<td><span class="code">rowAction: true</span></td>
<td>Actions column</td>
<td>Always (per row)</td>
<td>Individual row object</td>
</tr>
<tr>
<td>Bulk</td>
<td><span class="code">requiresMultipleSelection: true</span></td>
<td>Above table (when selected)</td>
<td>1+ rows selected</td>
<td>Array of selected rows</td>
</tr>
</table>
</div>
<div class="test-case">
<h3>Implementation Changes Made:</h3>
<ol>
<li>
<strong>Computed Properties Updated</strong>:
<ul>
<li>
<span class="code">globalActions</span>: Actions with no special
properties
</li>
<li>
<span class="code">singleSelectionActions</span>: Actions with
<span class="code">requiresSelection: true</span>
</li>
<li>
<span class="code">rowActions</span>: Actions with
<span class="code">rowAction: true</span>
</li>
<li>
<span class="code">bulkActions</span>: Actions with
<span class="code">requiresMultipleSelection: true</span>
</li>
</ul>
</li>
<li>
<strong>Template Updates</strong>:
<ul>
<li>Global Actions section now includes single selection actions</li>
<li>
Single selection actions are disabled unless exactly one row is
selected
</li>
<li>Visual feedback shows selection state</li>
<li>
Actions column only shows
<span class="code">rowAction: true</span> actions
</li>
</ul>
</li>
<li>
<strong>New Handler Added</strong>:
<ul>
<li>
<span class="code">handleSingleSelectionAction</span>: Passes selected
row data to action
</li>
</ul>
</li>
</ol>
</div>
<div class="test-case">
<h3>Example Configuration (Clients.vue):</h3>
<div class="example">
<pre>
const tableActions = [
// Global action - always available
{
label: "Add Client",
action: () => modalStore.openModal("createClient"),
icon: "pi pi-plus",
style: "primary"
},
// Single selection action - enabled when exactly one row selected
{
label: "View Details",
action: (rowData) => router.push(`/clients/${rowData.id}`),
icon: "pi pi-eye",
style: "info",
requiresSelection: true
},
// Bulk action - enabled when rows selected
{
label: "Export Selected",
action: (selectedRows) => exportData(selectedRows),
icon: "pi pi-download",
style: "success",
requiresMultipleSelection: true
},
// Row actions - appear in each row
{
label: "Edit",
action: (rowData) => editClient(rowData),
icon: "pi pi-pencil",
style: "secondary",
rowAction: true
},
{
label: "Quick View",
action: (rowData) => showPreview(rowData),
icon: "pi pi-search",
style: "info",
rowAction: true
}
];</pre
>
</div>
</div>
<div class="test-case">
<h3>User Experience Improvements:</h3>
<ul>
<li>
<strong>Clearer Action Organization</strong>: Actions are logically grouped by
their purpose
</li>
<li>
<strong>Better Visual Feedback</strong>: Users see why certain actions are
disabled
</li>
<li>
<strong>More Flexible Layout</strong>: Actions can be placed where they make
most sense
</li>
<li>
<strong>Reduced Clutter</strong>: Row actions only show contextual actions for
that specific row
</li>
<li>
<strong>Intuitive Behavior</strong>: Single selection actions work like "View
Details" - need one item selected
</li>
</ul>
</div>
<div class="test-case">
<h3>Action Flow Examples:</h3>
<ul>
<li>
<strong>Adding New Item</strong>: Global action → Always available → No data
needed
</li>
<li>
<strong>Viewing Item Details</strong>: Single selection action → Select one row
→ View details of selected item
</li>
<li>
<strong>Editing Item</strong>: Row action → Click edit in specific row → Edit
that item
</li>
<li>
<strong>Bulk Operations</strong>: Bulk action → Select multiple rows → Operate
on all selected
</li>
</ul>
</div>
<h2>✅ Testing Checklist</h2>
<ul>
<li>[ ] Global actions are always enabled and visible above table</li>
<li>[ ] Single selection actions are disabled when no rows selected</li>
<li>[ ] Single selection actions are disabled when multiple rows selected</li>
<li>[ ] Single selection actions are enabled when exactly one row is selected</li>
<li>[ ] Single selection actions receive correct row data</li>
<li>[ ] Row actions appear in each row's actions column</li>
<li>[ ] Row actions receive correct individual row data</li>
<li>[ ] Bulk actions appear when rows are selected</li>
<li>[ ] Bulk actions receive array of selected row data</li>
<li>[ ] Visual feedback shows selection state appropriately</li>
</ul>
</body>
</html>

View File

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

View File

@ -0,0 +1,194 @@
# Global Loading State Usage Guide
This document explains how to use the global loading state system in your Vue app.
## Overview
The loading system provides multiple ways to handle loading states:
1. **Global Loading Overlay** - Shows over the entire app
2. **Component-specific Loading** - For individual components like DataTable and Form
3. **Operation-specific Loading** - For tracking specific async operations
## Loading Store
### Basic Usage
```javascript
import { useLoadingStore } from "../../stores/loading";
const loadingStore = useLoadingStore();
// Set global loading
loadingStore.setLoading(true, "Processing...");
// Set component-specific loading
loadingStore.setComponentLoading("dataTable", true, "Loading data...");
// Use async wrapper
const data = await loadingStore.withLoading(
"fetchUsers",
() => Api.getUsers(),
"Fetching user data...",
);
```
### Available Methods
- `setLoading(isLoading, message?)` - Global loading state
- `setComponentLoading(componentName, isLoading, message?)` - Component loading
- `startOperation(operationKey, message?)` - Start tracked operation
- `stopOperation(operationKey)` - Stop tracked operation
- `withLoading(operationKey, asyncFn, message?)` - Async wrapper
- `withComponentLoading(componentName, asyncFn, message?)` - Component async wrapper
### Convenience Methods
- `startApiCall(apiName?)` - Quick API loading
- `stopApiCall()` - Stop API loading
- `startDataTableLoading(message?)` - DataTable loading
- `stopDataTableLoading()` - Stop DataTable loading
- `startFormLoading(message?)` - Form loading
- `stopFormLoading()` - Stop Form loading
## DataTable Component
The DataTable component automatically integrates with the loading store:
```vue
<template>
<DataTable
:data="tableData"
:columns="columns"
tableName="clients"
:loading="customLoading"
loadingMessage="Custom loading message..."
emptyMessage="No clients found"
/>
</template>
<script setup>
// DataTable will automatically show loading when:
// 1. props.loading is true
// 2. Global loading store has loading for 'dataTable'
// 3. Global loading store has loading for props.tableName
// 4. Any global loading (if useGlobalLoading is true)
// You can also control it directly:
const tableRef = ref();
tableRef.value?.startLoading("Custom loading...");
tableRef.value?.stopLoading();
</script>
```
## Form Component
The Form component also integrates with loading:
```vue
<template>
<Form
:fields="formFields"
formName="userForm"
:loading="customLoading"
loadingMessage="Saving user..."
@submit="handleSubmit"
/>
</template>
<script setup>
// Form will disable all inputs and show loading buttons when:
// 1. props.loading is true
// 2. Global loading store has loading for 'form'
// 3. Global loading store has loading for props.formName
// 4. Internal isSubmitting is true
// Control directly:
const formRef = ref();
formRef.value?.startLoading("Processing...");
formRef.value?.stopLoading();
</script>
```
## API Integration Example
```javascript
// In your page component
import { useLoadingStore } from "../../stores/loading";
const loadingStore = useLoadingStore();
// Method 1: Manual control
const loadData = async () => {
try {
loadingStore.startDataTableLoading("Loading clients...");
const data = await Api.getClients();
tableData.value = data;
} finally {
loadingStore.stopDataTableLoading();
}
};
// Method 2: Using wrapper (recommended)
const loadData = async () => {
const data = await loadingStore.withComponentLoading(
"clients",
() => Api.getClients(),
"Loading clients...",
);
tableData.value = data;
};
// Method 3: For global overlay
const performGlobalAction = async () => {
const result = await loadingStore.withLoading(
"globalOperation",
() => Api.performHeavyOperation(),
"Processing your request...",
);
return result;
};
```
## Global Loading Overlay
The `GlobalLoadingOverlay` component shows automatically when global loading is active:
```vue
<!-- Already added to App.vue -->
<GlobalLoadingOverlay />
<!-- Customizable props -->
<GlobalLoadingOverlay
:globalOnly="false" <!-- Show for any loading, not just global -->
:minDisplayTime="500" <!-- Minimum display time in ms -->
/>
```
## Best Practices
1. **Use component-specific loading** for individual components
2. **Use global loading** for app-wide operations (login, navigation, etc.)
3. **Use operation tracking** for multiple concurrent operations
4. **Always use try/finally** when manually controlling loading
5. **Prefer async wrappers** over manual start/stop calls
6. **Provide meaningful loading messages** to users
## Error Handling
```javascript
const loadData = async () => {
try {
const data = await loadingStore.withComponentLoading(
"clients",
() => Api.getClients(),
"Loading clients...",
);
tableData.value = data;
} catch (error) {
console.error("Failed to load clients:", error);
// Show error message to user
// Loading state is automatically cleared by the wrapper
}
};
```

View File

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

View File

@ -0,0 +1,958 @@
# DataTable Component Documentation
## Overview
A feature-rich data table component built with PrimeVue's DataTable. This component provides advanced functionality including server-side pagination, sorting, manual filtering with apply buttons, row selection, page data caching, and customizable column types with persistent state management.
## Basic Usage
```vue
<template>
<DataTable
:columns="tableColumns"
:data="tableData"
:tableActions="tableActions"
table-name="my-table"
@row-click="handleRowClick"
/>
</template>
<script setup>
import { ref } from "vue";
import DataTable from "./components/common/DataTable.vue";
const tableColumns = ref([
{
fieldName: "name",
label: "Name",
sortable: true,
filterable: true,
},
{
fieldName: "status",
label: "Status",
type: "status",
sortable: true,
filterable: true,
},
]);
const tableData = ref([
{ id: 1, name: "John Doe", status: "completed" },
{ id: 2, name: "Jane Smith", status: "in progress" },
]);
const tableActions = ref([
{
label: "Add Item",
action: () => console.log("Add clicked"),
icon: "pi pi-plus",
style: "primary",
// Global action - always available
},
{
label: "View Details",
action: (rowData) => console.log("View:", rowData),
icon: "pi pi-eye",
style: "info",
requiresSelection: true,
// Single selection action - enabled when exactly one row selected
},
{
label: "Edit",
action: (rowData) => console.log("Edit:", rowData),
icon: "pi pi-pencil",
style: "secondary",
rowAction: true,
// Row action - appears in each row's actions column
},
]);
const handleRowClick = (event) => {
console.log("Row clicked:", event.data);
};
</script>
```
## Props
### `columns` (Array) - Required
- **Description:** Array of column configuration objects that define the table structure
- **Type:** `Array<Object>`
- **Required:** `true`
### `data` (Array) - Required
- **Description:** Array of data objects to display in the table
- **Type:** `Array<Object>`
- **Required:** `true`
### `tableName` (String) - Required
- **Description:** Unique identifier for the table, used for persistent filter state management
- **Type:** `String`
- **Required:** `true`
### `totalRecords` (Number)
- **Description:** Total number of records available on the server (for lazy loading)
- **Type:** `Number`
- **Default:** `0`
### `onLazyLoad` (Function)
- **Description:** Custom pagination event handler for server-side data loading
- **Type:** `Function`
- **Default:** `null`
### `filters` (Object)
- **Description:** Initial filter configuration object (used for non-lazy tables)
- **Type:** `Object`
- **Default:** `{ global: { value: null, matchMode: FilterMatchMode.CONTAINS } }`
### `tableActions` (Array)
- **Description:** Array of action objects that define interactive buttons for the table. Actions can be global (always available), single-selection (enabled when exactly one row is selected), row-specific (displayed per row), or bulk (for multiple selected rows).
- **Type:** `Array<Object>`
- **Default:** `[]`
## Server-Side Pagination & Lazy Loading
When `lazy` is set to `true`, the DataTable operates in server-side mode with the following features:
### Automatic Caching
- **Page Data Caching:** Previously loaded pages are cached to prevent unnecessary API calls
- **Cache Duration:** 5 minutes default expiration time
- **Cache Size:** Maximum 50 pages per table with automatic cleanup
- **Smart Cache Keys:** Based on page, sorting, and filter combinations
### Manual Filter Controls
- **Apply Button:** Filters are applied manually via button click to prevent excessive API calls
- **Clear Button:** Quick reset of all active filters
- **Enter Key Support:** Apply filters by pressing Enter in any filter field
- **Visual Feedback:** Shows active filters and pending changes
### Quick Page Navigation
- **Page Dropdown:** Jump directly to any page number
- **Page Info Display:** Shows current record range and totals
- **Persistent State:** Page selection survives component re-mounts
## Column Configuration
Each column object in the `columns` array supports the following properties:
### Basic Properties
- **`fieldName`** (String, required) - The field name in the data object
- **`label`** (String, required) - Display label for the column header
- **`sortable`** (Boolean, default: `false`) - Enables sorting for this column
- **`filterable`** (Boolean, default: `false`) - Enables row-level filtering for this column
### Column Types
- **`type`** (String) - Defines special rendering behavior for the column
#### Available Types:
##### `'status'` Type
Renders values as colored tags/badges:
```javascript
{
fieldName: 'status',
label: 'Status',
type: 'status',
sortable: true,
filterable: true
}
```
**Status Colors:**
- `'completed'` → Success (green)
- `'in progress'` → Warning (yellow/orange)
- `'not started'` → Danger (red)
- Other values → Info (blue)
##### `'button'` Type
Renders values as clickable buttons:
```javascript
{
fieldName: 'action',
label: 'Action',
type: 'button'
}
```
## Table Actions Configuration
Table actions allow you to add interactive buttons to your DataTable. Actions can be either global (displayed above the table) or row-specific (displayed in an actions column).
### Action Object Properties
Each action object in the `tableActions` array supports the following properties:
#### Basic Properties
- **`label`** (String, required) - Display text for the button
- **`action`** (Function, required) - Function to execute when button is clicked
- **`icon`** (String, optional) - PrimeVue icon class (e.g., 'pi pi-plus')
- **`style`** (String, optional) - Button severity: 'primary', 'secondary', 'success', 'info', 'warning', 'danger'
- **`size`** (String, optional) - Button size: 'small', 'normal', 'large'
- **`requiresSelection`** (Boolean, default: false) - When true, action appears above table but is only enabled when exactly one row is selected
- **`requiresMultipleSelection`** (Boolean, default: false) - Determines if action is for bulk operations on selected rows
- **`rowAction`** (Boolean, default: false) - When true, action appears in each row's actions column
- **`layout`** (Object, optional) - Layout configuration for action positioning and styling
#### Layout Configuration
The `layout` property allows you to control where and how actions are displayed:
##### For Top-Level Actions (Global and Single Selection)
```javascript
layout: {
position: "left" | "center" | "right", // Where to position in action bar
variant: "filled" | "outlined" | "text" // Visual style variant
}
```
##### For Row Actions
```javascript
layout: {
priority: "primary" | "secondary" | "dropdown", // Display priority in row
variant: "outlined" | "text" | "compact" | "icon-only" // Visual style
}
```
##### For Bulk Actions
```javascript
layout: {
position: "left" | "center" | "right", // Where to position in bulk action bar
variant: "filled" | "outlined" | "text" // Visual style variant
}
```
#### Action Types
##### Global Actions (default behavior)
Global actions are displayed above the table and are always available:
```javascript
{
label: "Add New Item",
action: () => {
// Global action - no row data
console.log("Opening create modal");
},
icon: "pi pi-plus",
style: "primary"
// No requiresSelection, requiresMultipleSelection, or rowAction properties
}
```
##### Single Selection Actions (`requiresSelection: true`)
Single selection actions are displayed above the table but are only enabled when exactly one row is selected. They receive the selected row data as a parameter:
```javascript
{
label: "View Details",
action: (rowData) => {
// Single selection action - receives selected row data
console.log("Viewing:", rowData.name);
router.push(`/items/${rowData.id}`);
},
icon: "pi pi-eye",
style: "info",
requiresSelection: true
}
```
##### Row Actions (`rowAction: true`)
Row actions are displayed in an "Actions" column for each row and receive that row's data as a parameter:
```javascript
{
label: "Edit",
action: (rowData) => {
// Row action - receives individual row data
console.log("Editing:", rowData.name);
openEditModal(rowData);
},
icon: "pi pi-pencil",
style: "secondary",
rowAction: true
}
```
##### Bulk Actions (`requiresMultipleSelection: true`)
Bulk actions are displayed above the table when rows are selected and receive an array of selected row data:
```javascript
{
label: "Delete Selected",
action: (selectedRows) => {
// Bulk action - receives array of selected row data
console.log("Deleting:", selectedRows.length, "items");
selectedRows.forEach(row => deleteItem(row.id));
},
icon: "pi pi-trash",
style: "danger",
requiresMultipleSelection: true
}
```
### Example Table Actions Configuration
```javascript
const tableActions = [
// Global action - shows above table, always available
{
label: "Add Client",
action: () => modalStore.openModal("createClient"),
icon: "pi pi-plus",
style: "primary",
},
// Single selection action - shows above table, enabled when exactly one row selected
{
label: "View Details",
action: (rowData) => router.push(`/clients/${rowData.id}`),
icon: "pi pi-eye",
style: "info",
requiresSelection: true,
},
// Bulk action - shows when rows selected
{
label: "Delete Selected",
action: (selectedRows) => {
if (confirm(`Delete ${selectedRows.length} clients?`)) {
selectedRows.forEach((row) => deleteClient(row.id));
}
},
icon: "pi pi-trash",
style: "danger",
requiresMultipleSelection: true,
},
// Row actions - show in each row's actions column
{
label: "Edit",
action: (rowData) => editClient(rowData),
icon: "pi pi-pencil",
style: "secondary",
rowAction: true,
},
{
label: "Quick View",
action: (rowData) => showQuickPreview(rowData),
icon: "pi pi-search",
style: "info",
rowAction: true,
},
];
```
## Events
### `rowClick`
- **Description:** Emitted when a button-type column is clicked
- **Payload:** PrimeVue slot properties object containing row data
- **Usage:** `@row-click="handleRowClick"`
### `lazy-load`
- **Description:** Emitted when lazy loading is triggered (pagination, sorting, filtering)
- **Payload:** Event object with page, sorting, and filter information
- **Usage:** `@lazy-load="handleLazyLoad"`
### `page-change`
- **Description:** Emitted when page changes
- **Payload:** PrimeVue page event object
### `sort-change`
- **Description:** Emitted when sorting changes
- **Payload:** PrimeVue sort event object
### `filter-change`
- **Description:** Emitted when filters are applied
- **Payload:** PrimeVue filter event object
```javascript
const handleRowClick = (slotProps) => {
console.log("Clicked row data:", slotProps.data);
console.log("Row index:", slotProps.index);
};
const handleLazyLoad = async (event) => {
// event contains: page, rows, sortField, sortOrder, filters
console.log("Lazy load event:", event);
// Load data from API based on event parameters
const result = await Api.getData({
page: event.page,
pageSize: event.rows,
sortField: event.sortField,
sortOrder: event.sortOrder,
filters: event.filters,
});
// Update component data
tableData.value = result.data;
totalRecords.value = result.totalRecords;
};
```
## Features
### Pagination
- **Rows per page options:** 5, 10, 20, 50
- **Default rows per page:** 10
- **Built-in pagination controls**
### Sorting
- **Multiple column sorting** support
- **Removable sort** - click to remove sort from a column
- **Sort indicators** in column headers
### Filtering
- **Manual filter application** with Apply/Clear buttons
- **Text-based search** for filterable columns
- **Persistent filter state** across component re-renders and page navigation
- **Visual filter feedback** showing active filters and pending changes
- **Enter key support** for quick filter application
### Selection
- **Multiple row selection** with checkboxes
- **Meta key selection** (Ctrl/Cmd + click for individual selection)
- **Unique row identification** using `dataKey="id"`
### Scrolling
- **Vertical scrolling** with fixed height (70vh)
- **Horizontal scrolling** for wide tables
- **Fixed headers** during scroll
### State Management
- **Persistent filters** using Pinia store (`useFiltersStore`)
- **Automatic filter initialization** on component mount
- **Cross-component filter synchronization**
### Table Actions
- **Global actions** displayed above the table for general operations
- **Row-specific actions** in dedicated actions column with row data access
- **Bulk actions** for selected rows with multi-selection support
- **Customizable button styles** with PrimeVue severity levels
- **Icon support** using PrimeVue icons
- **Automatic action handling** with error catching
- **Disabled state** during loading operations
- **Dynamic bulk action visibility** based on row selection
## Usage Examples
### Server-Side Paginated Table (Recommended for Large Datasets)
```vue
<script setup>
import { ref } from "vue";
import DataTable from "./components/common/DataTable.vue";
import Api from "./api.js";
const columns = [
{ fieldName: "id", label: "ID", sortable: true },
{ fieldName: "name", label: "Name", sortable: true, filterable: true },
{ fieldName: "email", label: "Email", filterable: true },
];
const tableData = ref([]);
const totalRecords = ref(0);
const isLoading = ref(false);
const handleLazyLoad = async (event) => {
try {
isLoading.value = true;
// Convert PrimeVue event to API parameters
const params = {
page: event.page,
pageSize: event.rows,
sortField: event.sortField,
sortOrder: event.sortOrder,
};
// Convert filters
const filters = {};
if (event.filters) {
Object.keys(event.filters).forEach((key) => {
if (event.filters[key]?.value) {
filters[key] = event.filters[key];
}
});
}
// API call with caching support
const result = await Api.getPaginatedData(params, filters);
tableData.value = result.data;
totalRecords.value = result.totalRecords;
} catch (error) {
console.error("Error loading data:", error);
tableData.value = [];
totalRecords.value = 0;
} finally {
isLoading.value = false;
}
};
</script>
<template>
<DataTable
:data="tableData"
:columns="columns"
tableName="myTable"
:lazy="true"
:totalRecords="totalRecords"
:loading="isLoading"
:onLazyLoad="handleLazyLoad"
@lazy-load="handleLazyLoad"
/>
</template>
```
### Interactive Table with Actions
```vue
<script setup>
import { useRouter } from "vue-router";
import { useModalStore } from "./stores/modal";
const router = useRouter();
const modalStore = useModalStore();
const columns = [
{ fieldName: "name", label: "Name", sortable: true, filterable: true },
{ fieldName: "status", label: "Status", type: "status", sortable: true },
{ fieldName: "email", label: "Email", filterable: true },
];
const data = [
{ id: 1, name: "John Doe", status: "completed", email: "john@example.com" },
{
id: 2,
name: "Jane Smith",
status: "in progress",
email: "jane@example.com",
},
];
const tableActions = [
// Global action
{
label: "Add User",
action: () => {
modalStore.openModal("createUser");
},
icon: "pi pi-plus",
style: "primary",
},
// Single selection action
{
label: "View Details",
action: (rowData) => {
router.push(`/users/${rowData.id}`);
},
icon: "pi pi-eye",
style: "info",
requiresSelection: true,
},
// Bulk actions
{
label: "Export Selected",
action: (selectedRows) => {
exportUsers(selectedRows);
},
icon: "pi pi-download",
style: "success",
requiresMultipleSelection: true,
},
{
label: "Delete Selected",
action: (selectedRows) => {
if (confirm(`Delete ${selectedRows.length} users?`)) {
bulkDeleteUsers(selectedRows.map((row) => row.id));
}
},
icon: "pi pi-trash",
style: "danger",
requiresMultipleSelection: true,
},
// Row actions
{
label: "Edit",
action: (rowData) => {
modalStore.openModal("editUser", rowData);
},
icon: "pi pi-pencil",
style: "secondary",
rowAction: true,
},
{
label: "Quick Actions",
action: (rowData) => {
showQuickActionsMenu(rowData);
},
icon: "pi pi-ellipsis-v",
style: "info",
rowAction: true,
},
];
const deleteUser = async (userId) => {
// API call to delete user
await Api.deleteUser(userId);
};
const bulkDeleteUsers = async (userIds) => {
// API call to delete multiple users
await Api.bulkDeleteUsers(userIds);
};
const exportUsers = (users) => {
// Export selected users to CSV/Excel
const csv = generateCSV(users);
downloadFile(csv, "users.csv");
};
const refreshData = () => {
// Refresh table data
location.reload();
};
</script>
<template>
<DataTable
:columns="columns"
:data="data"
:tableActions="tableActions"
table-name="users-table"
/>
</template>
```
### Basic Client-Side Table
```vue
<script setup>
const columns = [
{ fieldName: "id", label: "ID", sortable: true },
{ fieldName: "name", label: "Name", sortable: true, filterable: true },
{ fieldName: "email", label: "Email", filterable: true },
];
const data = [
{ id: 1, name: "John Doe", email: "john@example.com" },
{ id: 2, name: "Jane Smith", email: "jane@example.com" },
];
</script>
<template>
<DataTable :data="data" :columns="columns" tableName="basicTable" />
</template>
```
### Status Table
```vue
<script setup>
const columns = [
{ fieldName: "task", label: "Task", sortable: true, filterable: true },
{
fieldName: "status",
label: "Status",
type: "status",
sortable: true,
filterable: true,
},
{ fieldName: "assignee", label: "Assignee", filterable: true },
];
const data = [
{ id: 1, task: "Setup project", status: "completed", assignee: "John" },
{ id: 2, task: "Write tests", status: "in progress", assignee: "Jane" },
{ id: 3, task: "Deploy app", status: "not started", assignee: "Bob" },
];
</script>
<template>
<DataTable :columns="columns" :data="data" table-name="tasks-table" />
</template>
```
### Interactive Table with Buttons
```vue
<script setup>
const columns = [
{ fieldName: "name", label: "Name", sortable: true, filterable: true },
{ fieldName: "status", label: "Status", type: "status", sortable: true },
{ fieldName: "action", label: "Action", type: "button" },
];
const data = [
{ id: 1, name: "Project A", status: "completed", action: "View Details" },
{ id: 2, name: "Project B", status: "in progress", action: "Edit" },
];
const handleRowClick = (slotProps) => {
const { data, index } = slotProps;
console.log(`Action clicked for ${data.name} at row ${index}`);
// Handle the action (navigate, open modal, etc.)
};
</script>
<template>
<DataTable
:columns="columns"
:data="data"
table-name="projects-table"
@row-click="handleRowClick"
/>
</template>
```
### Custom Filters
```vue
<script setup>
import { FilterMatchMode } from "@primevue/core";
const customFilters = {
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
name: { value: "John", matchMode: FilterMatchMode.STARTS_WITH },
};
</script>
<template>
<DataTable
:columns="columns"
:data="data"
:filters="customFilters"
table-name="filtered-table"
/>
</template>
```
### Layout-Aware Actions Example
```vue
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useModalStore } from "@/stores/modal";
const router = useRouter();
const modalStore = useModalStore();
const columns = [
{ fieldName: "name", label: "Client Name", sortable: true, filterable: true },
{ fieldName: "email", label: "Email", filterable: true },
{ fieldName: "status", label: "Status", type: "status", sortable: true },
];
const data = ref([
{ id: 1, name: "Acme Corp", email: "contact@acme.com", status: "completed" },
{
id: 2,
name: "Tech Solutions",
email: "info@tech.com",
status: "in progress",
},
]);
const tableActions = [
// Left-positioned action with filled style
{
label: "Quick Export",
icon: "pi pi-download",
action: () => exportAllClients(),
severity: "success",
layout: { position: "left", variant: "outlined" },
},
// Center-positioned bulk action
{
label: "Archive Selected",
icon: "pi pi-archive",
action: (selectedRows) => archiveClients(selectedRows),
severity: "warning",
requiresMultipleSelection: true,
layout: { position: "center", variant: "outlined" },
},
// Right-positioned main action
{
label: "Create Client",
icon: "pi pi-plus",
action: () => modalStore.openModal("createClient"),
severity: "info",
layout: { position: "right", variant: "filled" },
},
// Single selection action
{
label: "Edit Details",
icon: "pi pi-pencil",
action: (rowData) => router.push(`/clients/${rowData.id}/edit`),
severity: "secondary",
requiresSelection: true,
layout: { position: "left", variant: "text" },
},
// Primary row action - most important
{
label: "View",
icon: "pi pi-eye",
action: (rowData) => router.push(`/clients/${rowData.id}`),
severity: "info",
rowAction: true,
layout: { priority: "primary", variant: "outlined" },
},
// Secondary row action - less important
{
label: "Contact",
icon: "pi pi-phone",
action: (rowData) => initiateContact(rowData),
severity: "success",
rowAction: true,
layout: { priority: "secondary", variant: "text" },
},
// Dropdown row action - additional options
{
label: "More",
icon: "pi pi-ellipsis-v",
action: (rowData) => showMoreOptions(rowData),
rowAction: true,
layout: { priority: "dropdown", variant: "icon-only" },
},
];
const exportAllClients = () => {
// Export logic
};
const archiveClients = (clients) => {
// Archive logic
};
const initiateContact = (client) => {
// Contact logic
};
const showMoreOptions = (client) => {
// More options logic
};
</script>
<template>
<DataTable
:columns="columns"
:data="data"
:tableActions="tableActions"
table-name="clients-with-layout"
/>
</template>
```
## Store Integration
The component integrates with a Pinia store (`useFiltersStore`) for persistent filter state:
### Store Methods Used
- `initializeTableFilters(tableName, columns)` - Initialize filters for a table
- `getTableFilters(tableName)` - Get current filters for a table
- `updateTableFilter(tableName, fieldName, value, matchMode)` - Update a specific filter
### Filter Persistence
- Filters are automatically saved when changed
- Filters persist across component re-mounts
- Each table maintains separate filter state based on `tableName`
## Styling
The component uses PrimeVue's default DataTable styling with:
- **Scrollable layout** with fixed 70vh height
- **Responsive design** that adapts to container width
- **Consistent spacing** and typography
- **Accessible color schemes** for status badges
## Performance Considerations
### Large Datasets
- **Virtual scrolling** is not implemented - consider for datasets > 1000 rows
- **Client-side pagination** may impact performance with very large datasets
- **Debounced filtering** helps with real-time search performance
### Memory Management
- **Filter state persistence** may accumulate over time
- Consider implementing filter cleanup for unused tables
- **Component re-rendering** is optimized through computed properties
## Best Practices
1. **Use unique `tableName`** for each table instance to avoid filter conflicts
2. **Define clear column labels** for better user experience
3. **Enable sorting and filtering** on searchable/comparable columns
4. **Use appropriate column types** (`status`, `button`) for better UX
5. **Handle `rowClick` events** for interactive functionality
6. **Consider data structure** - ensure `id` field exists for selection
7. **Test with various data sizes** to ensure performance
8. **Use consistent status values** for proper badge coloring
## Accessibility
The component includes:
- **Keyboard navigation** support via PrimeVue
- **Screen reader compatibility** with proper ARIA labels
- **High contrast** status badges for visibility
- **Focus management** for interactive elements
- **Semantic HTML structure** for assistive technologies
## Browser Support
Compatible with all modern browsers that support:
- Vue 3 Composition API
- ES6+ features
- CSS Grid and Flexbox
- PrimeVue components
## Dependencies
- **Vue 3** with Composition API
- **PrimeVue** DataTable, Column, Tag, Button, InputText components
- **@primevue/core** for FilterMatchMode
- **Pinia** store for state management (`useFiltersStore`)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,268 @@
# Dynamic Modal Component Documentation
## Overview
A flexible and customizable modal component built with Vuetify's v-dialog. This component provides extensive configuration options and supports slot-based content rendering.
## Basic Usage
```vue
<template>
<Modal
v-model:visible="isModalVisible"
:options="modalOptions"
@close="handleClose"
@confirm="handleConfirm"
>
<p>Your modal content goes here</p>
</Modal>
</template>
<script setup>
import { ref } from 'vue'
import Modal from './components/Modal.vue'
const isModalVisible = ref(false)
const modalOptions = {
title: 'My Modal',
maxWidth: '500px'
}
const handleClose = () => {
console.log('Modal closed')
}
const handleConfirm = () => {
console.log('Modal confirmed')
}
</script>
```
## Props
### `visible` (Boolean)
- **Default:** `false`
- **Description:** Controls the visibility state of the modal
- **Usage:** Use with `v-model:visible` for two-way binding
### `options` (Object)
- **Default:** `{}`
- **Description:** Configuration object for customizing modal behavior and appearance
## Options Object Properties
### Dialog Configuration
- **`persistent`** (Boolean, default: `false`) - Prevents closing when clicking outside or pressing escape
- **`fullscreen`** (Boolean, default: `false`) - Makes the modal fullscreen
- **`maxWidth`** (String, default: `'500px'`) - Maximum width of the modal
- **`width`** (String) - Fixed width of the modal
- **`height`** (String) - Fixed height of the modal
- **`attach`** (String) - Element to attach the modal to
- **`transition`** (String, default: `'dialog-transition'`) - CSS transition name
- **`scrollable`** (Boolean, default: `false`) - Makes the modal content scrollable
- **`retainFocus`** (Boolean, default: `true`) - Retains focus within the modal
- **`closeOnBack`** (Boolean, default: `true`) - Closes modal on browser back button
- **`closeOnContentClick`** (Boolean, default: `false`) - Closes modal when clicking content
- **`closeOnOutsideClick`** (Boolean, default: `true`) - Closes modal when clicking outside
- **`closeOnEscape`** (Boolean, default: `true`) - Closes modal when pressing escape key
### Styling Options
- **`overlayColor`** (String) - Color of the backdrop overlay
- **`overlayOpacity`** (Number) - Opacity of the backdrop overlay
- **`zIndex`** (Number) - Z-index of the modal
- **`dialogClass`** (String) - Additional CSS classes for the dialog
- **`cardClass`** (String) - Additional CSS classes for the card
- **`cardColor`** (String) - Background color of the card
- **`cardVariant`** (String) - Vuetify card variant
- **`elevation`** (Number) - Shadow elevation of the card
- **`flat`** (Boolean) - Removes elevation
- **`noRadius`** (Boolean) - Removes border radius
### Header Configuration
- **`title`** (String) - Modal title text
- **`showHeader`** (Boolean, default: `true`) - Shows/hides the header
- **`showHeaderDivider`** (Boolean) - Shows divider below header
- **`headerClass`** (String) - Additional CSS classes for header
- **`showCloseButton`** (Boolean, default: `true`) - Shows/hides close button
- **`closeButtonColor`** (String, default: `'grey'`) - Color of close button
- **`closeIcon`** (String, default: `'mdi-close'`) - Icon for close button
### Content Configuration
- **`message`** (String) - Default message content (HTML supported)
- **`contentClass`** (String) - Additional CSS classes for content area
- **`contentHeight`** (String) - Fixed height of content area
- **`contentMaxHeight`** (String) - Maximum height of content area
- **`contentMinHeight`** (String) - Minimum height of content area
- **`noPadding`** (Boolean) - Removes padding from content area
### Actions Configuration
- **`showActions`** (Boolean, default: `true`) - Shows/hides action buttons
- **`actionsClass`** (String) - Additional CSS classes for actions area
- **`actionsAlign`** (String) - Alignment of action buttons (`'left'`, `'center'`, `'right'`, `'space-between'`)
### Button Configuration
- **`showConfirmButton`** (Boolean, default: `true`) - Shows/hides confirm button
- **`confirmButtonText`** (String, default: `'Confirm'`) - Text for confirm button
- **`confirmButtonColor`** (String, default: `'primary'`) - Color of confirm button
- **`confirmButtonVariant`** (String, default: `'elevated'`) - Variant of confirm button
- **`showCancelButton`** (Boolean, default: `true`) - Shows/hides cancel button
- **`cancelButtonText`** (String, default: `'Cancel'`) - Text for cancel button
- **`cancelButtonColor`** (String, default: `'grey'`) - Color of cancel button
- **`cancelButtonVariant`** (String, default: `'text'`) - Variant of cancel button
- **`loading`** (Boolean) - Shows loading state on confirm button
### Behavior Configuration
- **`autoCloseOnConfirm`** (Boolean, default: `true`) - Auto-closes modal after confirm
- **`autoCloseOnCancel`** (Boolean, default: `true`) - Auto-closes modal after cancel
- **`onOpen`** (Function) - Callback function when modal opens
- **`onClose`** (Function) - Callback function when modal closes
## Events
- **`update:visible`** - Emitted when visibility state changes
- **`close`** - Emitted when modal is closed
- **`confirm`** - Emitted when confirm button is clicked
- **`cancel`** - Emitted when cancel button is clicked
- **`outside-click`** - Emitted when clicking outside the modal
- **`escape-key`** - Emitted when escape key is pressed
## Slots
### Default Slot
```vue
<Modal>
<p>Your content here</p>
</Modal>
```
### Title Slot
```vue
<Modal>
<template #title>
<v-icon class="mr-2">mdi-account</v-icon>
Custom Title
</template>
</Modal>
```
### Actions Slot
```vue
<Modal>
<template #actions="{ close, options }">
<v-btn @click="customAction">Custom Action</v-btn>
<v-btn @click="close">Close</v-btn>
</template>
</Modal>
```
## Usage Examples
### Basic Modal
```vue
const basicOptions = {
title: 'Information',
maxWidth: '400px'
}
```
### Confirmation Modal
```vue
const confirmOptions = {
title: 'Confirm Action',
persistent: false,
confirmButtonText: 'Delete',
confirmButtonColor: 'error',
cancelButtonText: 'Keep'
}
```
### Form Modal
```vue
const formOptions = {
title: 'Add New Item',
maxWidth: '600px',
persistent: true,
confirmButtonText: 'Save',
loading: isLoading.value
}
```
### Fullscreen Modal
```vue
const fullscreenOptions = {
fullscreen: true,
showActions: false,
scrollable: true
}
```
### Custom Styled Modal
```vue
const customOptions = {
maxWidth: '500px',
cardColor: 'primary',
elevation: 12,
overlayOpacity: 0.8,
transition: 'scale-transition'
}
```
## Advanced Usage
### With Reactive Options
```vue
<script setup>
import { ref, computed } from 'vue'
const loading = ref(false)
const formValid = ref(false)
const modalOptions = computed(() => ({
title: 'Dynamic Title',
loading: loading.value,
confirmButtonText: formValid.value ? 'Save' : 'Validate First',
persistent: !formValid.value
}))
</script>
```
### Multiple Modals
```vue
<template>
<!-- Each modal can have different configurations -->
<Modal v-model:visible="modal1" :options="options1">
Content 1
</Modal>
<Modal v-model:visible="modal2" :options="options2">
Content 2
</Modal>
</template>
```
## Best Practices
1. **Use persistent modals for forms** to prevent accidental data loss
2. **Set appropriate maxWidth** for different screen sizes
3. **Use loading states** for async operations
4. **Provide clear button labels** that describe the action
5. **Use slots for complex content** instead of the message option
6. **Handle all events** to provide good user feedback
7. **Test keyboard navigation** and accessibility features
## Responsive Behavior
The modal automatically adjusts for mobile devices:
- Reduced padding on smaller screens
- Appropriate font sizes
- Touch-friendly button sizes
- Proper viewport handling
## Accessibility
The component includes:
- Proper ARIA attributes
- Keyboard navigation support
- Focus management
- Screen reader compatibility
- High contrast support

View File

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

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

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

View File

@ -0,0 +1,84 @@
# Using the Theme Store
This guide shows how to read and react to the company theme in Vue components.
## Imports
```js
import { useThemeStore } from "@/stores/theme";
```
## Reading theme tokens
```js
const themeStore = useThemeStore();
// Access current theme object
console.log(themeStore.currentTheme.primary);
```
Theme tokens exposed as CSS variables (runtime-applied to `:root`):
- `--theme-primary`: main brand color
- `--theme-primary-strong`: deeper shade of primary for borders/active states
- `--theme-secondary`: secondary accent color
- `--theme-accent`: softer accent/highlight
- `--theme-gradient-start` / `--theme-gradient-end`: primary gradient stops
- `--theme-secondary-gradient-start` / `--theme-secondary-gradient-end`: secondary gradient stops
- `--theme-surface`: default card/background surface
- `--theme-surface-alt`: alternate surface
- `--theme-border`: standard border color
- `--theme-hover`: hover surface color
- `--theme-text`: primary text color
- `--theme-text-muted`: muted/secondary text
- `--theme-text-dark`: dark text color (use on light surfaces)
- `--theme-text-light`: light text color (use on dark/gradient surfaces)
- Backward-compat (mapped to the above): `--primary-color`, `--primary-600`, `--surface-card`, `--surface-border`, `--surface-hover`, `--text-color`
## Applying theme in components
### Option 1: Use CSS vars in `<style>`
```vue
<style scoped>
.button-primary {
background: var(--theme-primary);
color: white;
}
.card-surface {
background: var(--surface-card);
border: 1px solid var(--surface-border);
}
.banner-gradient {
background: linear-gradient(135deg, var(--theme-gradient-start), var(--theme-gradient-end));
}
</style>
```
### Option 2: Use reactive values in script
```vue
<script setup>
import { useThemeStore } from "@/stores/theme";
const themeStore = useThemeStore();
const styles = computed(() => ({
background: `linear-gradient(135deg, ${themeStore.currentTheme.primaryGradientStart}, ${themeStore.currentTheme.primaryGradientEnd})`,
color: themeStore.currentTheme.text,
}));
</script>
<template>
<div :style="styles">Themed box</div>
</template>
```
## Reacting to company changes
The app already applies themes on company change. If you need side-effects in a component:
```js
watch(() => themeStore.currentTheme, (t) => {
console.log("theme changed", t.primary);
});
```
## Adding a new company theme
1) Open `src/stores/theme.js` and add a new entry to `themeMap` with all keys: `primary`, `primaryStrong`, `secondary`, `accent`, `primaryGradientStart/End`, `secondaryGradientStart/End`, `surface`, `surfaceAlt`, `border`, `hover`, `text`, `textMuted`.
2) No further changes are needed; selecting that company will apply the theme.
## Quick reference for gradients
- Primary gradient: `--theme-gradient-start``--theme-gradient-end`
- Secondary gradient: `--theme-secondary-gradient-start``--theme-secondary-gradient-end`
Use primary for main brand moments; secondary for supporting UI accents.

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,30 +1,136 @@
<script setup> <script setup>
import HelloWorld from './components/HelloWorld.vue' import { IconoirProvider } from "@iconoir/vue";
import SideBar from "./components/SideBar.vue";
import { watchEffect, onMounted, ref } from "vue";
import { useCompanyStore } from "@/stores/company";
import { useThemeStore } from "@/stores/theme";
import CreateClientModal from "./components/modals/CreateClientModal.vue";
import CreateEstimateModal from "./components/modals/CreateEstimateModal.vue";
import CreateJobModal from "./components/modals/CreateJobModal.vue";
import CreateInvoiceModal from "./components/modals/CreateInvoiceModal.vue";
import CreateWarrantyModal from "./components/modals/CreateWarrantyModal.vue";
import GlobalLoadingOverlay from "./components/common/GlobalLoadingOverlay.vue";
import ScrollPanel from "primevue/scrollpanel";
import Toast from "primevue/toast";
import { useNotificationStore } from "@/stores/notifications-primevue";
// Get the notification store and create a ref for the toast
const notificationStore = useNotificationStore();
const toast = ref();
const companyStore = useCompanyStore();
const themeStore = useThemeStore();
const sidebarCollapsed = ref(false);
// Connect the toast instance to the store when component mounts
onMounted(() => {
if (toast.value) {
notificationStore.setToastInstance(toast.value);
}
// Apply initial theme
themeStore.applyTheme(companyStore.selectedCompany);
});
// Reactively apply theme when company changes
watchEffect(() => {
themeStore.applyTheme(companyStore.selectedCompany);
});
</script> </script>
<template> <template>
<div> <IconoirProvider
<a href="https://vite.dev" target="_blank"> :icon-props="{
<img src="/vite.svg" class="logo" alt="Vite logo" /> color: 'white',
</a> 'stroke-width': 2,
<a href="https://vuejs.org/" target="_blank"> width: '1.2em',
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" /> height: '1.2em',
</a> }"
</div> >
<HelloWorld msg="Vite + Vue" /> <div id="snw-ui">
<div class="sidebar-column" :class="{ collapsed: sidebarCollapsed }">
<SideBar @toggle="sidebarCollapsed = $event" />
</div>
<div id="display-content">
<ScrollPanel style="width: 100%; height: 100%">
<RouterView />
</ScrollPanel>
</div>
</div>
<!-- Global Modals -->
<CreateClientModal />
<CreateEstimateModal />
<CreateJobModal />
<CreateInvoiceModal />
<CreateWarrantyModal />
<!-- Global Loading Overlay -->
<GlobalLoadingOverlay />
<!-- Global Notifications -->
<Toast ref="toast" />
</IconoirProvider>
</template> </template>
<style scoped> <style lang="css">
.logo { #snw-ui {
height: 6em; display: flex;
padding: 1.5em; flex-direction: row;
will-change: filter; border-radius: 10px;
transition: filter 300ms; padding: 10px;
border: 4px solid var(--theme-border);
max-width: 2500px;
width: 100%;
margin: 10px auto;
height: 90vh;
background: var(--theme-background-gradient);
} }
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa); .sidebar-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 170px;
min-width: 170px;
transition: width 0.3s ease, min-width 0.3s ease;
} }
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa); .sidebar-column.collapsed {
width: 56px;
min-width: 56px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.sidebar-column {
width: 140px;
min-width: 140px;
}
.sidebar-column.collapsed {
width: 56px;
min-width: 56px;
}
}
@media (max-width: 480px) {
.sidebar-column {
width: 120px;
min-width: 120px;
}
.sidebar-column.collapsed {
width: 56px;
min-width: 56px;
}
}
#display-content {
flex-grow: 1;
margin-left: auto;
margin-right: auto;
max-width: 100%;
min-width: 80%;
height: 100%;
} }
</style> </style>

View 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
View 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;

666
frontend/src/api.js Normal file
View File

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

53
frontend/src/apiUtils.js Normal file
View File

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

View File

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

View File

@ -1,38 +0,0 @@
<script setup>
import { ref } from "vue";
import SideBar from "./SideBar.vue";
defineProps({
msg: String,
});
const count = ref(0);
</script>
<template>
<SideBar />
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p></p>
<p>
Learn more about IDE Support for Vue in the
<a href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support" target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Testing cache update</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,463 @@
<template>
<div class="form-section">
<div class="section-header">
<i class="pi pi-map-marker" style="color: var(--primary-color); font-size: 1.2rem;"></i>
<h3>Property Address Information</h3>
</div>
<div class="form-grid">
<div
v-for="(address, index) in localFormData.addresses"
:key="index"
class="address-item"
>
<div class="address-header">
<div class="address-title">
<i class="pi pi-home" style="font-size: 0.9rem; color: var(--primary-color);"></i>
<h4>Address {{ index + 1 }}</h4>
</div>
<Button
v-if="localFormData.addresses.length > 1"
@click="removeAddress(index)"
size="small"
severity="danger"
icon="pi pi-trash"
class="remove-btn"
/>
</div>
<div class="address-fields">
<div class="form-field full-width">
<label :for="`address-line1-${index}`">
<i class="pi pi-map" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Address Line 1 <span class="required">*</span>
</label>
<InputText
:id="`address-line1-${index}`"
v-model="address.addressLine1"
:disabled="isSubmitting"
placeholder="Street address"
class="w-full"
@input="formatAddressLine(index, 'addressLine1', $event)"
/>
</div>
<div class="form-field full-width">
<label :for="`address-line2-${index}`"><i class="pi pi-map" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Address Line 2</label>
<InputText
:id="`address-line2-${index}`"
v-model="address.addressLine2"
:disabled="isSubmitting"
placeholder="Apt, suite, unit, etc."
class="w-full"
@input="formatAddressLine(index, 'addressLine2', $event)"
/>
</div>
<div class="form-field full-width checkbox-row">
<input
type="checkbox"
:id="`isBilling-${index}`"
v-model="address.isBillingAddress"
:disabled="isSubmitting"
@change="handleBillingChange(index)"
style="margin-top: 0"
/>
<label :for="`isBilling-${index}`"><i class="pi pi-dollar" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Is Billing Address</label>
</div>
<div class="form-row">
<div class="form-field">
<label :for="`zipcode-${index}`">
<i class="pi pi-hashtag" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Zip Code <span class="required">*</span>
</label>
<InputText
:id="`zipcode-${index}`"
v-model="address.pincode"
:disabled="isSubmitting"
@input="handleZipcodeInput(index, $event)"
maxlength="5"
placeholder="12345"
class="w-full"
/>
</div>
<div class="form-field">
<label :for="`city-${index}`">
<i class="pi pi-building" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>City <span class="required">*</span>
</label>
<InputText
:id="`city-${index}`"
v-model="address.city"
:disabled="isSubmitting || address.zipcodeLookupDisabled"
placeholder="City"
class="w-full"
/>
</div>
<div class="form-field">
<label :for="`state-${index}`">
<i class="pi pi-flag" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>State <span class="required">*</span>
</label>
<InputText
:id="`state-${index}`"
v-model="address.state"
:disabled="isSubmitting || address.zipcodeLookupDisabled"
placeholder="State"
class="w-full"
/>
</div>
</div>
<div class="form-row">
<div class="form-field">
<label :for="`contacts-${index}`"><i class="pi pi-users" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Assigned Contacts</label>
<MultiSelect
:id="`contacts-${index}`"
v-model="address.contacts"
:options="contactOptions"
optionLabel="label"
optionValue="value"
:disabled="isSubmitting || contactOptions.length === 0"
placeholder="Select contacts"
class="w-full"
display="chip"
/>
</div>
<div class="form-field">
<label :for="`primaryContact-${index}`"><i class="pi pi-star-fill" style="font-size: 0.75rem; margin-right: 0.25rem;"></i>Primary Contact</label>
<Select
:id="`primaryContact-${index}`"
v-model="address.primaryContact"
:options="contactOptions"
optionLabel="label"
optionValue="value"
:disabled="isSubmitting || contactOptions.length === 0"
placeholder="Select primary contact"
class="w-full"
/>
</div>
</div>
</div>
</div>
<div class="form-field full-width">
<Button label="Add another address" @click="addAddress" :disabled="isSubmitting" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import InputText from "primevue/inputtext";
import Select from "primevue/select";
import MultiSelect from "primevue/multiselect";
import Button from "primevue/button";
import Api from "../../api";
import { useNotificationStore } from "../../stores/notifications-primevue";
const props = defineProps({
formData: {
type: Object,
required: true,
},
isSubmitting: {
type: Boolean,
default: false,
},
isEditMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:formData"]);
const notificationStore = useNotificationStore();
const localFormData = computed({
get: () => {
if (!props.formData.addresses || props.formData.addresses.length === 0) {
props.formData.addresses = [
{
addressLine1: "",
addressLine2: "",
isBillingAddress: true,
pincode: "",
city: "",
state: "",
contacts: [],
primaryContact: null,
zipcodeLookupDisabled: true,
},
];
}
return props.formData;
},
set: (value) => emit("update:formData", value),
});
const contactOptions = computed(() => {
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
return [];
}
return localFormData.value.contacts.map((contact, index) => ({
label: `${contact.firstName || ""} ${contact.lastName || ""}`.trim() || `Contact ${index + 1}`,
value: index,
}));
});
onMounted(() => {
if (!localFormData.value.addresses || localFormData.value.addresses.length === 0) {
localFormData.value.addresses = [
{
addressLine1: "",
addressLine2: "",
isBillingAddress: true,
pincode: "",
city: "",
state: "",
contacts: [],
primaryContact: null,
zipcodeLookupDisabled: true,
},
];
}
});
const addAddress = () => {
localFormData.value.addresses.push({
addressLine1: "",
addressLine2: "",
isBillingAddress: false,
pincode: "",
city: "",
state: "",
contacts: [],
primaryContact: null,
zipcodeLookupDisabled: true,
});
};
const removeAddress = (index) => {
if (localFormData.value.addresses.length > 1) {
localFormData.value.addresses.splice(index, 1);
}
};
const formatAddressLine = (index, field, event) => {
const value = event.target.value;
if (!value) return;
// Capitalize first letter of each word
const formatted = value
.split(' ')
.map(word => {
if (!word) return word;
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join(' ');
localFormData.value.addresses[index][field] = formatted;
};
const handleBillingChange = (selectedIndex) => {
// If the selected address is now checked as billing
if (localFormData.value.addresses[selectedIndex].isBillingAddress) {
// Uncheck all other addresses
localFormData.value.addresses.forEach((addr, idx) => {
if (idx !== selectedIndex) {
addr.isBillingAddress = false;
}
});
// Auto-select all contacts
if (contactOptions.value.length > 0) {
localFormData.value.addresses[selectedIndex].contacts = contactOptions.value.map(
(opt) => opt.value,
);
}
// Auto-select primary contact
if (localFormData.value.contacts && localFormData.value.contacts.length > 0) {
const primaryIndex = localFormData.value.contacts.findIndex((c) => c.isPrimary);
if (primaryIndex !== -1) {
localFormData.value.addresses[selectedIndex].primaryContact = primaryIndex;
} else {
// Fallback to first contact if no primary found
localFormData.value.addresses[selectedIndex].primaryContact = 0;
}
}
}
};
const handleZipcodeInput = async (index, event) => {
const input = event.target.value;
// Only allow digits
const digitsOnly = input.replace(/\D/g, "");
// Limit to 5 digits
if (digitsOnly.length > 5) {
return;
}
localFormData.value.addresses[index].pincode = digitsOnly;
// Reset city/state if zipcode is not complete
if (digitsOnly.length < 5 && localFormData.value.addresses[index].zipcodeLookupDisabled) {
localFormData.value.addresses[index].city = "";
localFormData.value.addresses[index].state = "";
localFormData.value.addresses[index].zipcodeLookupDisabled = false;
}
// Fetch city/state when 5 digits entered
if (digitsOnly.length === 5) {
try {
console.log("DEBUG: Looking up city/state for zip code:", digitsOnly);
const places = await Api.getCityStateByZip(digitsOnly);
console.log("DEBUG: Retrieved places:", places);
if (places && places.length > 0) {
// Auto-populate city and state
localFormData.value.addresses[index].city = places[0]["city"];
localFormData.value.addresses[index].state = places[0]["state"];
localFormData.value.addresses[index].zipcodeLookupDisabled = true;
notificationStore.addSuccess(`Found: ${places[0]["city"]}, ${places[0]["state"]}`);
}
} catch (error) {
// Enable manual entry if lookup fails
localFormData.value.addresses[index].zipcodeLookupDisabled = false;
notificationStore.addWarning(
"Could not find city/state for this zip code. Please enter manually.",
);
}
}
};
</script>
<style scoped>
.form-section {
background: var(--surface-card);
border-radius: 6px;
padding: 0.75rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
}
.form-section:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border);
}
.section-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.1rem;
font-weight: 600;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.address-item {
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
background: var(--surface-section);
transition: all 0.2s ease;
}
.address-item:hover {
border-color: var(--primary-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.address-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--surface-border);
}
.address-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.address-header h4 {
margin: 0;
color: var(--text-color);
font-size: 0.95rem;
font-weight: 600;
}
.remove-btn {
margin-left: auto;
}
.address-fields {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.form-row {
display: flex;
gap: 0.625rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
flex: 1;
}
.form-field.full-width {
width: 100%;
}
.form-field label {
font-weight: 500;
color: var(--text-color);
font-size: 0.85rem;
display: flex;
align-items: center;
}
.required {
color: var(--red-500);
}
.w-full {
width: 100% !important;
}
.form-field.checkbox-row {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.form-field.checkbox-row label {
margin-bottom: 0;
cursor: pointer;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -0,0 +1,245 @@
<template>
<div class="install-status-card">
<h4>SNW Install</h4>
<div class="status-items">
<div
class="status-item"
:class="getStatusClass(onsiteMeetingStatus)"
@click="handleBidMeetingClick"
>
<span class="status-label">Meeting</span>
<span class="status-badge">{{ onsiteMeetingStatus }}</span>
</div>
<div
class="status-item"
:class="getStatusClass(estimateSentStatus)"
@click="handleEstimateClick"
>
<span class="status-label">Estimate</span>
<span class="status-badge">{{ estimateSentStatus }}</span>
</div>
<div
class="status-item"
:class="getStatusClass(jobStatus)"
@click="handleJobClick"
>
<span class="status-label">Job</span>
<span class="status-badge">{{ jobStatus }}</span>
</div>
<div
class="status-item"
:class="getStatusClass(paymentStatus)"
@click="handlePaymentClick"
>
<span class="status-label">Payment</span>
<span class="status-badge">{{ paymentStatus }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from "vue-router";
import { useNotificationStore } from "../../stores/notifications-primevue";
const props = defineProps({
onsiteMeetingStatus: {
type: String,
default: "Not Started",
},
estimateSentStatus: {
type: String,
default: "Not Started",
},
jobStatus: {
type: String,
default: "Not Started",
},
paymentStatus: {
type: String,
default: "Not Started",
},
fullAddress: {
type: String,
required: true,
},
bidMeeting: {
type: String,
default: "",
},
estimate: {
type: String,
default: "",
},
job: {
type: String,
default: "",
},
payment: {
type: String,
default: "",
},
});
const router = useRouter();
const notificationStore = useNotificationStore();
const getStatusClass = (status) => {
switch (status) {
case "Not Started":
return "status-not-started";
case "In Progress":
return "status-in-progress";
case "Completed":
return "status-completed";
default:
return "";
}
};
const handleBidMeetingClick = () => {
if (props.onsiteMeetingStatus === "Not Started") {
router.push(`/calendar?tab=bid&new=true&address=${encodeURIComponent(props.fullAddress)}&template=SNW%20Install`);
} else {
router.push(`/calendar?tab=bid&name=${encodeURIComponent(props.bidMeeting)}`);
}
};
const handleEstimateClick = () => {
if (props.estimateSentStatus === "Not Started") {
router.push(`/estimate?new=true&address=${encodeURIComponent(props.fullAddress)}&template=SNW%20Install`);
} else {
router.push(`/estimate?name=${encodeURIComponent(props.estimate)}`);
}
};
const handleJobClick = () => {
if (props.jobStatus === "Not Started") {
notificationStore.addWarning(
"The job will be created automatically once a quotation has been accepted by the customer."
);
} else {
router.push(`/job?name=${encodeURIComponent(props.job)}`);
}
};
const handlePaymentClick = () => {
if (props.paymentStatus === "Not Started") {
notificationStore.addWarning(
"An Invoice will be automatically created once the job has been completed."
);
} else {
notificationStore.addWarning("Page coming soon.");
}
};
</script>
<style scoped>
.install-status-card {
background: var(--surface-card);
border-radius: 6px;
padding: 0.75rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
min-width: 240px;
max-width: 280px;
}
.install-status-card h4 {
margin: 0 0 0.5rem 0;
color: var(--text-color);
font-size: 0.9rem;
font-weight: 600;
}
.status-items {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.6rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.status-item:hover {
transform: translateX(2px);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.status-label {
font-weight: 500;
color: var(--text-color);
font-size: 0.8rem;
}
.status-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 10px;
white-space: nowrap;
}
/* Status color variants */
.status-not-started {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.15);
}
.status-not-started .status-badge {
background: #ef4444;
color: white;
}
.status-not-started:hover {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.25);
}
.status-in-progress {
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.15);
}
.status-in-progress .status-badge {
background: #3b82f6;
color: white;
}
.status-in-progress:hover {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
}
.status-completed {
background: rgba(34, 197, 94, 0.08);
border-color: rgba(34, 197, 94, 0.15);
}
.status-completed .status-badge {
background: #22c55e;
color: white;
}
.status-completed:hover {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.25);
}
@media (max-width: 768px) {
.install-status-card {
max-width: 100%;
}
}
</style>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,260 @@
<template>
<div class="doc-history-container">
<div class="history-header" @click="toggleHistory">
<div class="header-content">
<i :class="isOpen ? 'pi pi-chevron-down' : 'pi pi-chevron-right'" class="toggle-icon"></i>
<span class="header-title">History - {{ doctype }}</span>
</div>
<span class="history-count" v-if="events.length">{{ events.length }} events</span>
</div>
<transition name="slide-fade">
<div v-if="isOpen" class="history-content">
<div v-if="events.length === 0" class="no-history">
No history available.
</div>
<div v-else class="history-list">
<div v-for="(group, groupIndex) in groupedEvents" :key="groupIndex" class="history-group">
<div class="history-group-header" @click="toggleGroup(group.timestamp)">
<i :class="expandedGroups[group.timestamp] ? 'pi pi-chevron-down' : 'pi pi-chevron-right'" class="group-toggle-icon"></i>
<span class="history-date">{{ formatDate(group.timestamp) }}</span>
<span class="history-user">{{ group.user }}</span>
<span class="history-types-summary">({{ group.typesDisplay }})</span>
</div>
<div v-if="expandedGroups[group.timestamp]" class="history-group-items">
<div v-for="(event, index) in group.events" :key="index" class="history-item">
<div class="history-meta">
<span class="history-type">{{ event.type }}</span>
</div>
<div class="history-message">
{{ event.message }}
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
events: {
type: Array,
default: () => []
},
doctype: {
type: String,
default: 'Document'
}
});
const isOpen = ref(false);
const expandedGroups = ref({});
const groupedEvents = computed(() => {
const groups = {};
props.events.forEach(event => {
const key = event.timestamp;
if (!groups[key]) {
groups[key] = {
timestamp: event.timestamp,
user: event.user,
events: [],
types: new Set()
};
}
groups[key].events.push(event);
groups[key].types.add(event.type);
});
// Sort descending by timestamp
return Object.values(groups).map(group => ({
...group,
typesDisplay: Array.from(group.types).join(', ')
})).sort((a, b) => {
if (a.timestamp < b.timestamp) return 1;
if (a.timestamp > b.timestamp) return -1;
return 0;
});
});
const toggleHistory = () => {
isOpen.value = !isOpen.value;
};
const toggleGroup = (timestamp) => {
expandedGroups.value[timestamp] = !expandedGroups.value[timestamp];
};
const formatDate = (timestamp) => {
if (!timestamp) return '';
try {
// Handle Frappe/Python timestamp format if needed, but standard Date constructor usually handles ISO-like strings
const date = new Date(timestamp);
return date.toLocaleString();
} catch (e) {
return timestamp;
}
};
</script>
<style scoped>
.doc-history-container {
border: 1px solid #e5e7eb;
border-radius: 6px;
background-color: #fff;
margin-top: 1rem;
overflow: hidden;
width: 100%;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: #f9fafb;
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
}
.history-header:hover {
background-color: #f3f4f6;
}
.header-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-title {
font-weight: 600;
color: #374151;
}
.toggle-icon {
font-size: 0.875rem;
color: #6b7280;
}
.history-count {
font-size: 0.75rem;
color: #6b7280;
background-color: #e5e7eb;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
}
.history-content {
border-top: 1px solid #e5e7eb;
max-height: 400px;
overflow-y: auto;
}
.history-list {
display: flex;
flex-direction: column;
}
.history-group {
border-bottom: 1px solid #e5e7eb;
}
.history-group:last-child {
border-bottom: none;
}
.history-group-header {
padding: 0.5rem 1rem;
background-color: #f9fafb;
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #6b7280;
border-bottom: 1px solid #f3f4f6;
font-weight: 500;
cursor: pointer;
align-items: center;
}
.history-group-header:hover {
background-color: #f3f4f6;
}
.group-toggle-icon {
font-size: 0.75rem;
color: #9ca3af;
}
.history-types-summary {
color: #9ca3af;
font-style: italic;
}
.history-group-items {
display: flex;
flex-direction: column;
background-color: #fff;
}
.history-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.history-item:last-child {
border-bottom: none;
}
.history-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.75rem;
color: #6b7280;
align-items: center;
}
.history-type {
font-weight: 600;
color: #4b5563;
background-color: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.history-message {
font-size: 0.875rem;
color: #1f2937;
line-height: 1.5;
white-space: pre-wrap;
}
.no-history {
padding: 2rem;
text-align: center;
color: #6b7280;
font-style: italic;
}
/* Transition */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease-out;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,550 @@
<template>
<div>
<!-- New Meeting Creation Modal -->
<Modal
:visible="showModal"
@update:visible="showModal = $event"
:options="modalOptions"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<template #title>Schedule New Bid Meeting</template>
<div class="new-meeting-form">
<div class="form-group">
<label for="meeting-address">Address: <span class="required">*</span></label>
<div class="address-input-group">
<InputText
id="meeting-address"
v-model="formData.address"
class="address-input"
placeholder="Enter meeting address"
@input="validateForm"
/>
<Button
label="Search"
icon="pi pi-search"
size="small"
:disabled="!formData.address.trim()"
@click="searchAddress"
class="search-btn"
/>
</div>
</div>
<div class="form-group">
<label for="meeting-contact">Contact: <span class="required">*</span></label>
<Select
id="meeting-contact"
v-model="formData.contact"
:options="availableContacts"
optionLabel="label"
optionValue="value"
:disabled="!formData.addressName || availableContacts.length === 0"
placeholder="Select a contact"
class="w-full"
@change="validateForm"
>
<template #option="slotProps">
<div class="contact-option">
<div class="contact-name">{{ slotProps.option.displayName }}</div>
<div class="contact-details">
<span v-if="slotProps.option.role" class="contact-role">{{ slotProps.option.role }}</span>
<span v-if="slotProps.option.email" class="contact-email">{{ slotProps.option.email }}</span>
<span v-if="slotProps.option.phone" class="contact-phone">{{ slotProps.option.phone }}</span>
</div>
</div>
</template>
</Select>
</div>
<div class="form-group">
<label for="meeting-project-template">Project Template (Optional):</label>
<Select
id="meeting-project-template"
v-model="formData.projectTemplate"
:options="availableProjectTemplates"
optionLabel="label"
optionValue="value"
placeholder="Select a project template"
class="w-full"
showClear
/>
</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
:visible="showAddressSearchModal"
@update:visible="showAddressSearchModal = $event"
: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>{{ typeof address === 'string' ? address : (address.fullAddress || address.name) }}</span>
</div>
</div>
</div>
</Modal>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { useRoute } from "vue-router";
import Modal from "../common/Modal.vue";
import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import Button from "primevue/button";
import Select from "primevue/select";
import { useNotificationStore } from "../../stores/notifications-primevue";
import { useCompanyStore } from "../../stores/company";
import Api from "../../api";
const notificationStore = useNotificationStore();
const companyStore = useCompanyStore();
const route = useRoute();
// 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 availableContacts = ref([]);
const availableProjectTemplates = ref([]);
const selectedAddressDetails = ref(null);
const isFormValid = ref(false);
// Form data
const formData = ref({
address: "",
addressName: "",
contact: "",
projectTemplate: "",
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;
const hasValidAddressName = formData.value.addressName && formData.value.addressName.trim().length > 0;
const hasValidContact = formData.value.contact && formData.value.contact.trim().length > 0;
isFormValid.value = hasValidAddress && hasValidAddressName && hasValidContact;
};
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);
// Store full address objects instead of just strings
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 = async (addressData) => {
// Get the address string for the API call
const addressString = typeof addressData === 'string' ? addressData : (addressData.fullAddress || addressData.name);
// Set the display address immediately
formData.value.address = addressString;
showAddressSearchModal.value = false;
try {
// Fetch the full address details with contacts
const fullAddressDetails = await Api.getAddressByFullAddress(addressString);
console.info("Fetched address details:", fullAddressDetails);
// Store the fetched address details
selectedAddressDetails.value = fullAddressDetails;
// Set the address name for the API request
formData.value.addressName = fullAddressDetails.name;
// Populate contacts from the fetched address
if (fullAddressDetails.contacts && Array.isArray(fullAddressDetails.contacts)) {
availableContacts.value = fullAddressDetails.contacts.map(contact => ({
label: contact.fullName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
value: contact.name,
displayName: contact.fullName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
role: contact.role || contact.designation || '',
email: contact.email || contact.emailId || '',
phone: contact.phone || contact.mobileNo || ''
}));
// Auto-select primary contact if available, otherwise first contact if only one
if (fullAddressDetails.primaryContact) {
formData.value.contact = fullAddressDetails.primaryContact;
} else if (availableContacts.value.length === 1) {
formData.value.contact = availableContacts.value[0].value;
} else {
formData.value.contact = "";
}
} else {
availableContacts.value = [];
formData.value.contact = "";
notificationStore.addWarning("No contacts found for this address.");
}
validateForm();
} catch (error) {
console.error("Error fetching address details:", error);
notificationStore.addError("Failed to fetch address details. Please try again.");
// Reset on error
formData.value.addressName = "";
availableContacts.value = [];
formData.value.contact = "";
selectedAddressDetails.value = null;
validateForm();
}
};
const closeAddressSearch = () => {
showAddressSearchModal.value = false;
};
const fetchProjectTemplates = async () => {
try {
const company = companyStore.currentCompany;
if (!company) {
console.warn("No company selected, cannot fetch project templates");
return;
}
const templates = await Api.getJobTemplates(company);
console.info("Fetched project templates:", templates);
if (templates && Array.isArray(templates)) {
availableProjectTemplates.value = templates.map(template => ({
label: template.name,
value: template.name
}));
} else {
availableProjectTemplates.value = [];
}
} catch (error) {
console.error("Error fetching project templates:", error);
availableProjectTemplates.value = [];
notificationStore.addWarning("Failed to load project templates.");
}
};
const handleConfirm = () => {
if (!isFormValid.value) return;
// Send only the necessary data (addressName and contact, not full address)
const confirmData = {
address: formData.value.addressName,
contact: formData.value.contact,
projectTemplate: formData.value.projectTemplate || null,
notes: formData.value.notes,
};
console.log("BidMeetingModal - Emitting confirm with data:", confirmData);
emit("confirm", confirmData);
resetForm();
};
const handleCancel = () => {
showModal.value = false;
resetForm();
};
const resetForm = () => {
formData.value = {
address: props.initialAddress || "",
addressName: "",
contact: "",
projectTemplate: "",
notes: "",
};
availableContacts.value = [];
validateForm();
};
// Watch for prop changes
watch(
() => props.initialAddress,
(newAddress) => {
formData.value.address = newAddress || "";
validateForm();
},
{ immediate: true },
);
watch(
() => companyStore.currentCompany,
async (newCompany) => {
if (newCompany && props.visible) {
await fetchProjectTemplates();
}
},
);
watch(
() => props.visible,
async (isVisible) => {
if (isVisible) {
resetForm();
// Fetch project templates
await fetchProjectTemplates();
// Auto-select template from query parameter if provided
if (route.query.template) {
const templateName = decodeURIComponent(route.query.template);
const templateExists = availableProjectTemplates.value.some(
t => t.value === templateName
);
if (templateExists) {
formData.value.projectTemplate = templateName;
}
}
// If there's an initial address, automatically search and fetch it
if (formData.value.address && formData.value.address.trim()) {
try {
const results = await Api.searchAddresses(formData.value.address.trim());
console.info("Auto-search results for initial address:", results);
if (results.length === 1) {
// Auto-select if only one result
await selectAddress(results[0]);
} else if (results.length > 1) {
// Try to find exact match
const exactMatch = results.find(addr => {
const addrString = typeof addr === 'string' ? addr : (addr.fullAddress || addr.name);
return addrString === formData.value.address;
});
if (exactMatch) {
await selectAddress(exactMatch);
} else {
// Show search results if multiple matches
addressSearchResults.value = results;
showAddressSearchModal.value = true;
}
} else {
notificationStore.addWarning("No addresses found for the provided address.");
}
} catch (error) {
console.error("Error auto-searching address:", error);
notificationStore.addError("Failed to load address details.");
}
}
}
},
);
// 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;
}
.contact-option {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
}
.contact-name {
font-weight: 500;
color: #333;
}
.contact-details {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.85em;
color: #666;
}
.contact-role {
color: #2196f3;
font-weight: 500;
}
.contact-email,
.contact-phone {
color: #666;
}
.contact-email::before {
content: "📧 ";
}
.contact-phone::before {
content: "📞 ";
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,381 @@
<template>
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
<template #title>
<div class="modal-header">
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
Meeting Details
</div>
</template>
<div v-if="meeting" class="meeting-details">
<!-- Status Badge -->
<div class="status-section">
<div class="status-badge" :class="`status-${meeting.status?.toLowerCase()}`">
<i class="pi pi-circle-fill"></i>
{{ meeting.status }}
</div>
</div>
<!-- Key Information Grid -->
<div class="info-grid">
<!-- Customer Name -->
<div class="info-card" v-if="customerName">
<div class="info-label">
<i class="pi pi-user"></i>
Customer
</div>
<div class="info-value">{{ customerName }}</div>
<div class="info-meta" v-if="meeting.partyType">{{ meeting.partyType }}</div>
</div>
<!-- Project Template -->
<div class="info-card" v-if="meeting.projectTemplate">
<div class="info-label">
<i class="pi pi-folder"></i>
Project Type
</div>
<div class="info-value">{{ meeting.projectTemplate }}</div>
</div>
<!-- Scheduled Time -->
<div class="info-card" v-if="meeting.startTime">
<div class="info-label">
<i class="pi pi-clock"></i>
Scheduled
</div>
<div class="info-value">{{ formatDateTime(meeting.startTime) }}</div>
<div class="info-meta" v-if="meeting.endTime">Duration: {{ calculateDuration(meeting.startTime, meeting.endTime) }} min</div>
</div>
<!-- Assigned Employee -->
<div class="info-card" v-if="meeting.assignedEmployee">
<div class="info-label">
<i class="pi pi-user-edit"></i>
Assigned To
</div>
<div class="info-value">{{ meeting.assignedEmployee }}</div>
</div>
</div>
<!-- Address Section -->
<div class="section-divider">
<i class="pi pi-map-marker"></i>
<span>Location</span>
</div>
<div class="address-section">
<div class="address-text">
<strong>{{ addressText }}</strong>
<div class="meeting-id">ID: {{ meeting.name }}</div>
</div>
<div v-if="hasCoordinates" class="map-container">
<iframe
:src="mapUrl"
width="100%"
height="200"
frameborder="0"
style="border: 1px solid var(--surface-border); border-radius: 6px;"
></iframe>
</div>
</div>
<!-- Contact Information -->
<div class="section-divider" v-if="contactInfo">
<i class="pi pi-phone"></i>
<span>Contact Information</span>
</div>
<div class="contact-section" v-if="contactInfo">
<div class="contact-item">
<i class="pi pi-user"></i>
<span class="contact-label">Name:</span>
<span class="contact-value">{{ contactInfo.fullName }}</span>
</div>
<div class="contact-item" v-if="contactInfo.role">
<i class="pi pi-briefcase"></i>
<span class="contact-label">Role:</span>
<span class="contact-value">{{ contactInfo.role }}</span>
</div>
<div class="contact-item" v-if="contactInfo.phone">
<i class="pi pi-phone"></i>
<span class="contact-label">Phone:</span>
<a :href="`tel:${contactInfo.phone}`" class="contact-value contact-link">{{ contactInfo.phone }}</a>
</div>
<div class="contact-item" v-if="contactInfo.email">
<i class="pi pi-envelope"></i>
<span class="contact-label">Email:</span>
<a :href="`mailto:${contactInfo.email}`" class="contact-value contact-link">{{ contactInfo.email }}</a>
</div>
</div>
<!-- Notes -->
<div v-if="meeting.notes" class="notes-section">
<div class="section-divider">
<i class="pi pi-file-edit"></i>
<span>Notes</span>
</div>
<div class="notes-content">{{ meeting.notes }}</div>
</div>
<!-- Additional Info -->
<div class="additional-info" v-if="meeting.company || meeting.completedBy">
<div class="info-item" v-if="meeting.company">
<i class="pi pi-building"></i>
<span>{{ meeting.company }}</span>
</div>
<div class="info-item" v-if="meeting.completedBy">
<i class="pi pi-check-circle"></i>
<span>Completed by: {{ meeting.completedBy }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<v-btn
v-if="meeting.status !== 'Completed' && meeting.status !== 'Unscheduled'"
@click="handleMarkComplete"
color="success"
variant="elevated"
:loading="isUpdating"
>
<v-icon left>mdi-check</v-icon>
Mark as Completed
</v-btn>
<v-btn
v-if="meeting.status === 'Completed'"
@click="handleCreateEstimate"
color="primary"
variant="elevated"
>
<v-icon left>mdi-file-document-outline</v-icon>
Create Estimate
</v-btn>
</div>
</div>
</Modal>
</template>
<script setup>
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import Modal from "../common/Modal.vue";
import Api from "../../api";
import { useNotificationStore } from "../../stores/notifications-primevue";
const router = useRouter();
const notificationStore = useNotificationStore();
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
meeting: {
type: Object,
default: null,
},
});
// Emits
const emit = defineEmits(["update:visible", "close", "meetingUpdated"]);
// Local state
const isUpdating = ref(false);
const showModal = computed({
get() {
return props.visible;
},
set(value) {
emit("update:visible", value);
},
});
// Modal options
const modalOptions = computed(() => ({
maxWidth: "800px",
showCancelButton: false,
confirmButtonText: "Close",
confirmButtonColor: "primary",
}));
// Computed properties for data extraction
const customerName = computed(() => {
if (props.meeting?.address?.customerName) {
return props.meeting.address.customerName;
}
if (props.meeting?.partyName) {
return props.meeting.partyName;
}
return null;
});
const addressText = computed(() => {
return props.meeting?.address?.fullAddress || props.meeting?.address || "";
});
const hasCoordinates = computed(() => {
const lat = props.meeting?.address?.customLatitude || props.meeting?.address?.latitude;
const lon = props.meeting?.address?.customLongitude || props.meeting?.address?.longitude;
return lat && lon && parseFloat(lat) !== 0 && parseFloat(lon) !== 0;
});
const mapUrl = computed(() => {
if (!hasCoordinates.value) return "";
const lat = parseFloat(props.meeting?.address?.customLatitude || props.meeting?.address?.latitude);
const lon = parseFloat(props.meeting?.address?.customLongitude || props.meeting?.address?.longitude);
const zoom = 15;
// Using OpenStreetMap embed with marker
return `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.01},${lat - 0.01},${lon + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lon}`;
});
const contactInfo = computed(() => {
console.log('=== CONTACT DEBUG ===');
console.log('Full meeting object:', props.meeting);
console.log('Meeting contact value:', props.meeting?.contact);
console.log('Contact type:', typeof props.meeting?.contact);
const contact = props.meeting?.contact;
if (!contact) {
console.log('No contact found - returning null');
return null;
}
// Handle both string and object contact
if (typeof contact === 'string') {
console.log('Contact is a string:', contact);
return { fullName: contact };
}
// Log the contact object to see what properties are available
console.log('Contact object keys:', Object.keys(contact));
console.log('Contact object:', contact);
const contactData = {
fullName: contact.name || contact.fullName || contact.contactName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim() || '',
phone: contact.phone || contact.mobileNo || contact.mobile || contact.phoneNos?.[0]?.phone || '',
email: contact.emailId || contact.email || contact.emailAddress || contact.emailIds?.[0]?.emailId || '',
role: contact.role || contact.designation || '',
};
console.log('Extracted contact data:', contactData);
return contactData;
});
// Methods
const handleClose = () => {
emit("close");
};
const handleMarkComplete = async () => {
if (!props.meeting?.name) return;
try {
isUpdating.value = true;
await Api.updateBidMeeting(props.meeting.name, {
status: "Completed",
});
notificationStore.addNotification({
type: "success",
title: "Meeting Completed",
message: "The meeting has been marked as completed.",
duration: 4000,
});
// Emit event to refresh the calendar
emit("meetingUpdated");
handleClose();
} catch (error) {
console.error("Error marking meeting as complete:", error);
notificationStore.addNotification({
type: "error",
title: "Error",
message: "Failed to update meeting status.",
duration: 5000,
});
} finally {
isUpdating.value = false;
}
};
const handleCreateEstimate = () => {
if (!props.meeting) return;
const addressText = props.meeting.address?.fullAddress || props.meeting.address || "";
const template = props.meeting.projectTemplate || "";
const fromMeeting = props.meeting.name || "";
const contactName = props.meeting.contact?.name || "";
router.push({
path: "/estimate",
query: {
new: "true",
address: addressText,
"from-meeting": fromMeeting,
template: template,
contact: contactName,
},
});
};
const calculateDuration = (startTime, endTime) => {
if (!startTime || !endTime) return 0;
const start = new Date(startTime);
const end = new Date(endTime);
const diffMs = end - start;
return Math.round(diffMs / (1000 * 60)); // Convert to minutes
};
const formatDateTime = (dateString) => {
if (!dateString) return "";
return new Date(dateString).toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
};
</script>
<style scoped>
.meeting-details {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 0;
}
.detail-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.detail-row:last-of-type {
border-bottom: none;
}
.detail-row strong {
min-width: 120px;
color: #666;
font-size: 0.9em;
}
.detail-value {
flex: 1;
color: #333;
word-break: break-word;
}
.action-buttons {
display: flex;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 2px solid #e0e0e0;
justify-content: center;
}
</style>

View File

@ -0,0 +1,82 @@
<template>
<Modal
:visible="visible"
@update:visible="$emit('update:visible', $event)"
@close="$emit('update:visible', false)"
:options="{ showActions: false }"
>
<template #title>Save As Template</template>
<div class="modal-content">
<div class="field-group">
<label for="templateName" class="field-label">Template Name</label>
<InputText id="templateName" v-model="templateData.templateName" fluid />
</div>
<div class="field-group">
<label for="templateDescription" class="field-label">Description</label>
<InputText id="templateDescription" v-model="templateData.description" fluid />
</div>
<div class="confirmation-buttons">
<Button label="Cancel" @click="$emit('update:visible', false)" severity="secondary" />
<Button label="Submit" @click="submit" :disabled="!templateData.templateName" />
</div>
</div>
</Modal>
</template>
<script setup>
import { reactive, watch } from "vue";
import Modal from "../common/Modal.vue";
import InputText from "primevue/inputtext";
import Button from "primevue/button";
const props = defineProps({
visible: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["update:visible", "save"]);
const templateData = reactive({
templateName: "",
description: "",
});
watch(
() => props.visible,
(newVal) => {
if (newVal) {
templateData.templateName = "";
templateData.description = "";
}
},
);
const submit = () => {
emit("save", { ...templateData });
};
</script>
<style scoped>
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.field-group {
margin-bottom: 1rem;
}
.confirmation-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1rem;
}
.modal-content {
padding: 1rem;
}
</style>

View File

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

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