Compare commits

...

47 Commits

Author SHA1 Message Date
02d1329746 doctype 2026-02-18 19:26:13 -06:00
f4864dae63 add doctype 2026-02-18 19:19:15 -06:00
4ae49f097c address customer link 2026-02-18 19:14:16 -06:00
0868e90916 revert commit 2026-02-18 18:05:20 -06:00
8464fcef87 Revert "remove duplicate item csv"
This reverts commit 15ee0459b59b207795e1a24b1ca04327be09431d.
2026-02-18 18:04:21 -06:00
15ee0459b5 remove duplicate item csv 2026-02-18 17:58:15 -06:00
6d0593a33d add csv backups 2026-02-18 17:57:24 -06:00
d87e70097b update errors 2026-02-18 17:38:47 -06:00
17fb06d6d3 update update company script 2026-02-18 17:12:06 -06:00
77694443aa remove items 2026-02-18 17:08:15 -06:00
35d02da993 update install 2026-02-18 16:47:10 -06:00
237341ccf8 update fixtures 2026-02-18 16:46:47 -06:00
d3f6cb4675 add after build hook 2026-02-18 16:13:46 -06:00
d7d62aaf44 lakdf 2026-02-18 15:55:41 -06:00
93be7bd9c4 update 2026-02-18 15:48:59 -06:00
ab5965235f updated install.py 2026-02-18 15:43:50 -06:00
15f57d57d7 update fixtures 2026-02-18 15:30:41 -06:00
2152583560 service appointment 2026-02-18 15:24:49 -06:00
bbde69f3d9 update install.py 2026-02-18 15:21:38 -06:00
a3663e17b3 update fixtures 2026-02-18 15:20:11 -06:00
b07e9fd4b4 update fixtures 2026-02-18 15:18:08 -06:00
0e519e3b7d update fixtures 2026-02-18 15:16:00 -06:00
0183909f8e fix install.py 2026-02-18 15:13:04 -06:00
b9c1abf15a update bid fixture 2026-02-18 15:11:21 -06:00
ac19e9c6bb update fixtures 2026-02-18 15:08:13 -06:00
7c4d1c43ff add pre-built routes 2026-02-18 15:06:31 -06:00
ee9ce9ece5 fix bid meeting note requirement 2026-02-18 15:05:06 -06:00
22d102dee8 add backflow test form 2026-02-18 14:51:36 -06:00
d1d2f44c25 resolve conflicts 2026-02-18 14:07:10 -06:00
bd487adca2 fix hooks 2026-02-18 13:53:38 -06:00
33966decd9 update fixtures 2026-02-18 13:46:26 -06:00
beb873a9b1 update fixtures and install.py 2026-02-18 11:09:05 -06:00
772fcb86c2 update fixtures and install.py 2026-02-18 11:08:37 -06:00
321d402b81 update fields 2026-02-18 09:58:53 -06:00
ae0365a42a fix migrate hook 2026-02-18 08:14:30 -06:00
a6bb81bbf8 fix fixtures 2026-02-18 07:13:30 -06:00
1610905a43 update 2026-02-18 06:56:19 -06:00
e2746b83bb update 2026-02-15 08:00:18 -06:00
8c818f8dde update 2026-02-15 07:06:51 -06:00
8ebd77540c added ability to link address/contacts if they already exist 2026-02-14 08:47:53 -06:00
cf577f3ac7 fix estimate search filtering 2026-02-13 17:30:59 -06:00
49617c39c4 update estimate to have remarks 2026-02-13 14:26:47 -06:00
caa4bc2dca multiple bid meetings for one cell, final invoice flow. 2026-02-13 11:22:32 -06:00
b3e6e4f6a2 fix estimate row rendering bug 2026-02-12 08:10:56 -06:00
rocketdebris
07af3c52ea Added a rudimentary script for a partial backup for a clean slate. 2026-02-11 13:52:15 -05:00
rocketdebris
69286e8977 Exported fixtures for clean slate. 2026-02-11 13:51:46 -05:00
rocketdebris
a24aac3262 Make sure the service appointment doctype is added to code so we can migrate new clean slate. 2026-02-11 11:10:04 -05:00
68 changed files with 44498 additions and 939 deletions

1
clean_slate_backup.sh Normal file
View File

@ -0,0 +1 @@
bench --site snw-erp.localhost backup --exclude 'Customer,Address,Lead,Quotation,Project,Sales Order,Sales Invoice,On-Site Meeting,Service Address 2,Project Task Link,Bid Meeting Note,Service Appointment,Customer Contact Link,Customer Address Link,Customer Company Link,Address Task Link,Customer Task Link,Address Company Link,Lead Quotation Link,Lead Contact Link,Lead Address Link,Customer Sales Order Link,Customer Quotation Link,Customer Project Link,Customer On-Site Meeting Link,Address Contact Link,Lead On-Site Meeting Link,Contact Address Link,Address Sales Order Link,Address On-Site Meeting Link,Address Quotation Link,Address Project Link,Lead Companies Link,Lead Company Link'

BIN
csv/Item (1).xlsx Normal file

Binary file not shown.

View File

@ -0,0 +1,147 @@
"name","item_code","uom","price_list","price_list_rate","packing_unit","item_name","brand","item_description","customer","supplier","batch_no","buying","selling","currency","valid_from","lead_time_days","valid_upto","note","reference"
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
"","","","","","","","","","","","","","","","","","","",""
1 name item_code uom price_list price_list_rate packing_unit item_name brand item_description customer supplier batch_no buying selling currency valid_from lead_time_days valid_upto note reference
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147

View File

@ -0,0 +1,3 @@
"ID","Item Code","UOM","Price List","Rate","Packing Unit","Item Name","Brand","Item Description","Customer","Supplier","Batch No","Buying","Selling","Currency","Valid From","Lead Time in days","Valid Upto","Note","Reference"
1 ID Item Code UOM Price List Rate Packing Unit Item Name Brand Item Description Customer Supplier Batch No Buying Selling Currency Valid From Lead Time in days Valid Upto Note Reference

166
csv/Item Price.csv Normal file
View File

@ -0,0 +1,166 @@
"Data Import Template"
"Table:","Item Price"
""
""
"Notes:"
"Please do not change the template headings."
"First data column must be blank."
"If you are uploading new records, leave the ""name"" (ID) column blank."
"If you are uploading new records, ""Naming Series"" becomes mandatory, if present."
"Only mandatory fields are necessary for new records. You can delete non-mandatory columns if you wish."
"For updating, you can update only selective columns."
"You can only upload upto 5000 records in one go. (may be less in some cases)"
""
"DocType:","Item Price","","","","","","","","","","","","","","","","","","","","",""
"Column Labels:","ID","Item Code","UOM","Price List","Rate","Created On","Created By","Packing Unit","Item Name","Brand","Item Description","Customer","Supplier","Batch No","Buying","Selling","Currency","Valid From","Lead Time in days","Valid Upto","Note","Reference"
"Column Name:","name","item_code","uom","price_list","price_list_rate","creation","owner","packing_unit","item_name","brand","item_description","customer","supplier","batch_no","buying","selling","currency","valid_from","lead_time_days","valid_upto","note","reference"
"Mandatory:","Yes","Yes","Yes","Yes","Yes","No","No","No","No","No","No","No","No","No","No","No","No","No","No","No","No","No"
"Type:","Data","Link","Link","Link","Currency","Datetime","Link","Int","Data","Link","Text","Link","Link","Link","Check","Check","Link","Date","Int","Date","Text","Data"
"Info:","","Valid Item","Valid UOM","Valid Price List","","mm-dd-yyyy","Valid User","Integer","","Valid Brand","","Valid Customer","Valid Supplier","Valid Batch","0 or 1","0 or 1","Valid Currency","mm-dd-yyyy","Integer","mm-dd-yyyy","",""
"Start entering data below this line"
"","""028bea2a8e""","SERV-0004","Each","Standard Buying",65.0,"2025-03-05 06:40:31.079375","Administrator",0,"Start Up","","<div><p>Start Up Sprinklers</p></div>","","","",1,0,"USD","03-05-2025",0,"","",""
"","""04d409e574""","DL-0045","Yard","Standard Selling",54.0,"2024-12-11 10:26:06.281663","lbrown@sprinklersnorthwest.com",0,"Large Nugget","","Large Nugget","","","",0,1,"USD","",0,"","",""
"","""0751d07aa2""","DL-0033","Yard","Standard Selling",30.0,"2024-12-11 10:26:05.956546","lbrown@sprinklersnorthwest.com",0,"Granite A-Rock","","Granite A-Rock","","","",0,1,"USD","",0,"","",""
"","""0986e9be33""","DL-0019","Yard","Standard Selling",42.0,"2024-12-11 10:26:05.591816","lbrown@sprinklersnorthwest.com",0,"3/4"" Minus Basalt","","3/4"" Minus Basalt","","","",0,1,"USD","",0,"","",""
"","""0e00cf42b2""","BLDR - Sample Package 1","Each","Commerical Installation",1000.0,"2025-02-07 11:13:35.048005","kris@sprinklersnorthwest.com",0,"BLDR - Sample Package 1","","BLDR - Sample Package 1","","","",0,1,"USD","02-07-2025",0,"","",""
"","""0eface13a2""","DL-0020","Yard","Standard Selling",97.0,"2024-12-11 10:26:05.618573","lbrown@sprinklersnorthwest.com",0,"Red Valley","","Red Valley","","","",0,1,"USD","",0,"","",""
"","""10a6ba6d26""","DL-0008","Yard","Standard Selling",65.0,"2024-12-11 10:26:05.296632","lbrown@sprinklersnorthwest.com",0,"Mt Grey","","Mt Grey","","","",0,1,"USD","",0,"","",""
"","""1683a6757e""","DL-0029","Yard","Standard Selling",37.0,"2024-12-11 10:26:05.846856","lbrown@sprinklersnorthwest.com",0,"Curb Sand","","Curb Sand","","","",0,1,"USD","",0,"","",""
"","""1706c2e18c""","BLDR-Woodridge 4 Falcon Ridge Gallery","Each","Standard Selling",12300.0,"2025-02-21 12:49:14.903409","kris@sprinklersnorthwest.com",0,"Woodridge 4 Falcon Ridge Gallery Landscape Installation ","","Woodridge 4 Falcon Ridge Gallery Landscape Installation ","","","",0,1,"USD","02-21-2025",0,"","",""
"","""17ed4513b6""","BLDR-Front Yard Landscape Installation","Each","Standard Selling",4500.0,"2025-01-28 14:40:09.616449","kris@sprinklersnorthwest.com",0,"Builder Landscape Installation","","Builder Landscape Installation","","","",0,1,"USD","01-28-2025",0,"","",""
"","""18802dcb0d""","SNW-I-0017","Each","Commerical Installation",500.0,"2025-02-13 11:39:45.976852","courtney@sprinklersnorthwest.com",0,"Plant Install","","<div><p>Installation of designated plant package to jobsite.</p></div>","","","",0,1,"USD","02-13-2025",0,"","",""
"","""19008402d9""","SNW-I-0025","Each","Standard Selling",750.0,"2025-08-15 21:02:02.922642","courtney@sprinklersnorthwest.com",0,"Bark & Weed Barrier","","Bark &amp; Weed Barrier to be installed in all designated areas.","","","",0,1,"USD","08-15-2025",0,"","",""
"","""1a4044e8fd""","DL-0024","Yard","Standard Selling",95.0,"2024-12-11 10:26:05.725117","lbrown@sprinklersnorthwest.com",0,"Champaign","","Champaign","","","",0,1,"USD","",0,"","",""
"","""1aea533e41""","DL-0037","Yard","Standard Selling",38.0,"2024-12-11 10:26:06.063979","lbrown@sprinklersnorthwest.com",0,"1/2 Shale","","1/2 Shale","","","",0,1,"USD","",0,"","",""
"","""1d81cfcf5d""","DL-0006","Yard","Standard Selling",37.0,"2024-12-11 10:26:05.245640","lbrown@sprinklersnorthwest.com",0,"Container Blend","","Sand/Bartek","","","",0,1,"USD","",0,"","",""
"","""1e73c035f1""","SNW-I-0006","Each","Standard Buying",185.0,"2025-01-16 13:45:08.778857","kris@sprinklersnorthwest.com",0,"Equipment Operator","","Equipment Operator","","","",1,0,"USD","01-16-2025",0,"","",""
"","""1e914222e1""","DL-0040","Yard","Standard Selling",37.0,"2024-12-11 10:26:06.150211","lbrown@sprinklersnorthwest.com",0,"Sand & Gravel","","Sand &amp; Gravel","","","",0,1,"USD","",0,"","",""
"","""298d8d530a""","BLDR-Woodridge 4 Falcon Ridge Gallery","Each","Standard Selling",12300.0,"2025-02-24 11:50:07.853732","kris@sprinklersnorthwest.com",0,"Woodridge 4 Falcon Ridge Gallery Landscape Installation ","","Woodridge 4 Falcon Ridge Gallery Landscape Installation ","","","",0,1,"USD","02-24-2025",0,"","",""
"","""2e5b7ddad0""","SERV-0005","Each","Residential",60.0,"2025-02-12 15:35:49.280265","courtney@sprinklersnorthwest.com",0,"Additional Backflow Test","","<div><p>Price Per Additional Backflow Test (when more than one backflow is required to be tested)</p></div>","","","",0,1,"USD","02-12-2025",0,"","",""
"","""2f2f841aa7""","BLDR-Monogram Front Yard","Each","Standard Selling",4300.0,"2025-01-28 12:50:39.208311","kris@sprinklersnorthwest.com",0,"Monogram Front Yard Package ","","<div><p>Installation of front yard builder package. </p></div>","","","",0,1,"USD","01-28-2025",0,"","",""
"","""2fb9dd4af5""","DL-0046","Yard","Standard Selling",42.0,"2024-12-11 10:26:06.308121","lbrown@sprinklersnorthwest.com",0,"Dark Fines","","Dark Fines","","","",0,1,"USD","",0,"","",""
"","""30ef963682""","SNW-I-0050","Each","Standard Selling",1800.0,"2024-12-12 09:53:24.191598","lbrown@sprinklersnorthwest.com",0,"Pump Truck","","Concrete pump truck with extendable arm that allows for Precise placement of concrete.","","","",0,1,"USD","",0,"","",""
"","""3111b26fbe""","SNW-I-0030","Each","Standard Selling",5.5,"2024-12-12 09:53:23.880263","lbrown@sprinklersnorthwest.com",0,"Metal Edging","","Metal Edging to be installed to designated areas.","","","",0,1,"USD","",0,"","",""
"","""33f4fce6ec""","SERV-0012","Each","Residential",120.0,"2025-02-14 12:37:16.541406","courtney@sprinklersnorthwest.com",0,"Startup/Backflow","","<div><p>Start Up System And Perform Backflow Test.</p></div>","","","",0,1,"USD","02-14-2025",0,"","",""
"","""39444b3676""","DL-0001","Yard","Standard Buying",28.0,"2025-01-16 13:45:08.755303","kris@sprinklersnorthwest.com",0,"Topsoil","","Topsoil","","","",1,0,"USD","01-16-2025",0,"","",""
"","""3c63f24be4""","SNW-I-0054","Each","Standard Selling",500.0,"2025-01-24 10:14:28.850325","courtney@sprinklersnorthwest.com",0,"Drip Zone install","","Installation of drip zone system to cover all desiganated areas.","","","",0,1,"USD","01-24-2025",0,"","",""
"","""3eca1d3e99""","SERV-0012","Each","Commerical Installation",120.0,"2025-02-12 14:46:29.599224","courtney@sprinklersnorthwest.com",0,"Startup/Backflow","","<div><p>Start Up System And Perform Backflow Test.</p></div>","","","",0,1,"USD","02-12-2025",0,"","",""
"","""4046cb9970""","DL-0013","Yard","Standard Selling",85.0,"2024-12-11 10:26:05.432515","lbrown@sprinklersnorthwest.com",0,"Lg. Green Omak","","Lg. Green Omak","","","",0,1,"USD","",0,"","",""
"","""41d8f86449""","SNW-I-0025","Each","Standard Buying",42.0,"2025-08-18 14:18:15.914070","courtney@sprinklersnorthwest.com",0,"Bark & Weed Barrier","","Bark &amp; Weed Barrier to be installed in all designated areas.","","","",1,0,"USD","08-18-2025",0,"","",""
"","""41e307e76a""","BLDR-Meadowlane Greens","Each","Standard Selling",11300.0,"2025-02-21 12:41:18.296600","kris@sprinklersnorthwest.com",0,"Meadowlane Greens Full Yard Landscape Package ","","Meadowlane Greens Full Yard Landscape Package ","","","",0,1,"USD","02-21-2025",0,"","",""
"","""46656517ae""","SERV-0017","Each","Residential",160.0,"2025-02-14 12:55:02.906307","courtney@sprinklersnorthwest.com",0,"Startup/Backflow/Winterize","","<div><p>Startup/ Backflow Test/ Prepay Fall Winterization</p></div>","","","",0,1,"USD","02-14-2025",0,"","",""
"","""46c6400432""","BLDR-Lennar Plant Package","Each","Standard Selling",700.0,"2025-01-27 13:00:44.563964","kris@sprinklersnorthwest.com",0,"BLDR-Lennar Plant Package ","","<div><p>Plant package for Lennar Homes</p></div>","","","",0,1,"USD","01-27-2025",0,"","",""
"","""471671e8ff""","DL-0015","Yard","Standard Selling",42.0,"2024-12-11 10:26:05.485870","lbrown@sprinklersnorthwest.com",0,"2-4"" Basalt Cobble","","2-4"" Basalt Cobble","","","",0,1,"USD","",0,"","",""
"","""48076b1740""","DL-0004","Yard","Standard Selling",43.0,"2024-12-11 10:26:05.192354","lbrown@sprinklersnorthwest.com",0,"3-1 Mix","","Topsoil/Bartek/Sandyloam","","","",0,1,"USD","",0,"","",""
"","""48520c1901""","DL-0032","Yard","Standard Selling",42.0,"2024-12-11 10:26:05.929739","lbrown@sprinklersnorthwest.com",0,"Sandy Loam","","Sandy Loam","","","",0,1,"USD","",0,"","",""
"","""49a186d603""","DL-0018","Yard","Standard Buying",42.0,"2025-08-18 14:18:15.877139","courtney@sprinklersnorthwest.com",0,"3/4"" Basalt","","3/4"" Basalt A-Rock","","","",1,0,"USD","08-18-2025",0,"","",""
"","""4b858992df""","DL-0005","Yard","Standard Selling",31.0,"2024-12-11 10:26:05.219701","lbrown@sprinklersnorthwest.com",0,"Garden Mix","","Dark fines/Topsoil","","","",0,1,"USD","",0,"","",""
"","""4db08a4b3d""","DL-0003","Yard","Standard Selling",5.0,"2024-12-11 10:26:05.165505","lbrown@sprinklersnorthwest.com",0,"Dirty Drain Rock","","Drain rock material","","","",0,1,"USD","",0,"","",""
"","""4e0cedd652""","SBE075","Each","Standard Selling",1.0,"2025-05-08 11:57:41.979246","peter@shilohcode.com",0,"Rain Bird Spiral Barb Elbow 3/4 in. x 1/2 in. MIPT x Barb","","Rain Bird Spiral Barb Elbow 3/4 in. x 1/2 in. MIPT x Barb","","","",0,1,"USD","05-08-2025",0,"","",""
"","""4e2d24b4d8""","BLDR - Sample Package 1","Each","Standard Selling",4500.0,"2025-01-28 15:08:22.865509","kris@sprinklersnorthwest.com",0,"BLDR - Sample Package 1","","BLDR - Sample Package 1","","","",0,1,"USD","01-28-2025",0,"","",""
"","""4e7a5baf93""","DL-0050","Hour","Standard Buying",135.0,"2025-01-17 10:15:24.745401","kris@sprinklersnorthwest.com",0,"12-15 Yard Dump ","","12-15 Yard Dump ","","","",1,0,"USD","01-17-2025",0,"","",""
"","""4l0jeavd8r""","SNW-I-0027","Each","Standard Selling",700.0,"2025-01-03 09:33:47.591569","courtney@sprinklersnorthwest.com",0,"Hydroseeding","","Hydroseeding to be applied onto all designated areas.","","","",0,1,"USD","01-03-2025",0,"","",""
"","""4l0jkasmia""","SNW-I-0018","Yard","Markup Price List",60.0,"2025-01-03 09:33:47.575336","courtney@sprinklersnorthwest.com",0,"Topsoil Application","","Topsoil Application","","","",0,1,"USD","01-03-2025",0,"12-31-2025","",""
"","""4l0k6masv9""","SNW-I-0017","Each","Standard Selling",65.0,"2025-01-03 09:33:47.613707","courtney@sprinklersnorthwest.com",0,"Plant Install","","<div><p>Installation of designated plant package to jobsite.</p></div>","","","",0,1,"USD","01-03-2025",0,"","",""
"","""4l0kcta6un""","PSMS147060","Each","Standard Selling",25.0,"2025-01-03 09:33:47.627881","courtney@sprinklersnorthwest.com",0,"Paver Leiden Multi Stone Jamestown Blend","","(Sold by the layer)","","","",0,1,"USD","01-03-2025",0,"","",""
"","""520a2496e9""","BLDR- Small Plant Package","Each","Standard Selling",250.0,"2025-01-27 12:52:42.784151","kris@sprinklersnorthwest.com",0,"BLDR- Small Plant Package","","BLDR- Small Plant Package","","","",0,1,"USD","01-27-2025",0,"","",""
"","""52kh3l1c6o""","SNW-I-DOOT","Nos","Standard Selling",30.0,"2026-02-05 12:37:21.999118","casey@shilohcode.com",0,"Doot","","Blah","","","",0,1,"USD","02-05-2026",0,"","",""
"","""544bd7e4a4""","DL-0042","Yard","Standard Selling",79.0,"2024-12-11 10:26:06.204691","lbrown@sprinklersnorthwest.com",0,"1.5"" Rainbow","","1.5"" Rainbow","","","",0,1,"USD","",0,"","",""
"","""54a631121f""","BLDR - Sample Package 1","Each","Standard Buying",4500.0,"2025-02-03 15:24:57.624602","courtney@sprinklersnorthwest.com",0,"BLDR - Sample Package 1","","BLDR - Sample Package 1","","","",1,0,"USD","02-03-2025",0,"","",""
"","""5a94b4899b""","SERV-0003","Each","Standard Buying",55.0,"2025-03-05 06:40:31.128339","Administrator",0,"Service Call Minimum","","<div><p>Service Call Minimum (half hour or less)</p></div>","","","",1,0,"USD","03-05-2025",0,"","",""
"","""5d53679192""","DL-0028","Yard","Standard Selling",38.0,"2024-12-11 10:26:05.820107","lbrown@sprinklersnorthwest.com",0,"Pea Gravel","","Pea Gravel","","","",0,1,"USD","",0,"","",""
"","""5df9a9a9e8""","DL-0007","Yard","Standard Selling",49.0,"2024-12-11 10:26:05.270449","lbrown@sprinklersnorthwest.com",0,"Bartek","","Compost soil 90% Forestry 10%Organics","","","",0,1,"USD","",0,"","",""
"","""5e1c628bc8""","SERV-0002","Each","Residential",55.0,"2025-02-14 12:37:16.576546","courtney@sprinklersnorthwest.com",0,"Fall System Winterize","","<div><p>Winterization of irrigation system</p></div>","","","",0,1,"USD","02-14-2025",0,"","",""
"","""5ie73htc9k""","Tan Vinyl 1.5x5.5x8 Rail","Each","Standard Selling",18.74,"2024-12-18 15:44:51.995881","courtney@sprinklersnorthwest.com",0,"Tan Vinyl 1.5x5.5x8 Rail","","Tan Vinyl 1.5x5.5x8 Rail","","","",0,1,"USD","12-18-2024",0,"","",""
"","""5ie77ll7ff""","Veka Almond 7/8""X6""X61.5"" T&G","Nos","Standard Selling",15.63,"2024-12-18 15:44:51.981282","courtney@sprinklersnorthwest.com",0,"Veka Almond 7/8""X6""X61.5"" T&G","","Veka Almond 7/8""X6""X61.5"" T&amp;G","","","",0,1,"USD","12-18-2024",0,"","",""
"","""5ie7f8g3ve""","Tan Vinyl 11.3"" T&G","Each","Standard Selling",14.07,"2024-12-18 15:44:51.957833","courtney@sprinklersnorthwest.com",0,"Tan Vinyl 11.3"" T&G","","Tan Vinyl 11.3"" T&amp;G","","","",0,1,"USD","12-18-2024",0,"","",""
"","""5ie8edsmhd""","Labor","Nos","Standard Selling",165.0,"2024-12-18 15:44:52.023891","courtney@sprinklersnorthwest.com",0,"Labor","","Labor","","","",0,1,"USD","12-18-2024",0,"","",""
"","""5ie8sk3ho1""","Vinyl Gate Hinge","Per Set","Standard Selling",30.0,"2024-12-18 15:44:52.009456","courtney@sprinklersnorthwest.com",0,"Vinyl Gate Hinge","","Vinyl Gate Hinge","","","",0,1,"USD","12-18-2024",0,"","",""
"","""61025f73b4""","SNW-I-0034","Hour","Commerical Installation",150.0,"2025-02-13 11:39:46.018748","courtney@sprinklersnorthwest.com",0,"General Labor","","General Labor","","","",0,1,"USD","02-13-2025",0,"","",""
"","""681a353a50""","DL-0011","Yard","Standard Selling",42.0,"2024-12-11 10:26:05.374863","lbrown@sprinklersnorthwest.com",0,"4"" Cobble","","4"" Cobble","","","",0,1,"USD","",0,"","",""
"","""6892aeec3d""","DL-0022","Yard","Standard Selling",95.0,"2024-12-11 10:26:05.671388","lbrown@sprinklersnorthwest.com",0,"China White","","China White","","","",0,1,"USD","",0,"","",""
"","""698b85bbdb""","SERV-0005","Each","Standard Buying",60.0,"2025-02-12 15:58:17.080872","courtney@sprinklersnorthwest.com",0,"Additional Backflow Test","","<div><p>Price Per Additional Backflow Test (when more than one backflow is required to be tested)</p></div>","","","",1,0,"USD","02-12-2025",0,"","",""
"","""6e91bea59c""","DL-0021","Yard","Standard Selling",65.0,"2024-12-11 10:26:05.645251","lbrown@sprinklersnorthwest.com",0,"Purple Rage","","Purple Rage","","","",0,1,"USD","",0,"","",""
"","""7545a350fb""","DL-0002","Yard","Standard Selling",20.0,"2024-12-11 10:26:05.135602","lbrown@sprinklersnorthwest.com",0,"Daniels Base","","Base Material","","","",0,1,"USD","",0,"","",""
"","""763ce91518""","BLDR-Hayden Canyon Gold Front","Each","Commerical Installation",9800.0,"2025-02-07 10:44:32.604088","kris@sprinklersnorthwest.com",0,"BLDR-Hayden Canyon Gold Front","","BLDR-Hayden Canyon Gold Front","","","",0,1,"USD","02-07-2025",0,"","",""
"","""7e05f0c35f""","White Vinyl 1.5x5.5x8 Rail","Each","Standard Selling",12.0,"2025-01-24 11:28:06.610161","courtney@sprinklersnorthwest.com",0,"White Vinyl 1.5x5.5x8 Rail","","White Vinyl 1.5x5.5x8 Rail","","","",0,1,"USD","01-24-2025",0,"","",""
"","""7f8a67ac25""","DL-0034","Yard","Standard Selling",30.0,"2024-12-11 10:26:05.983444","lbrown@sprinklersnorthwest.com",0,"Granite B-Rock","","Granite B-Rock","","","",0,1,"USD","",0,"","",""
"","""8229b74ad9""","BLDR-Front Yard Irrigation Installation","Each","Standard Selling",2500.0,"2025-01-27 15:11:18.074654","kris@sprinklersnorthwest.com",0,"BLDR-Front Yard Irrigation Installation ","","<div><p>Front yard Irrigation Installation: </p><p>3-4 Irrigation zones including 1 drip zone</p><p>All turf zones zoned with head to head coverage. </p><p>Drip zone to be installed underground with water going to each plant.</p></div>","","","",0,1,"USD","01-27-2025",0,"","",""
"","""86b976de4f""","SNW-I-0018","Yard","Standard Buying",16.0,"2025-08-18 14:18:15.815276","courtney@sprinklersnorthwest.com",0,"Topsoil Application","","Topsoil Application","","","",1,0,"USD","08-18-2025",0,"","",""
"","""88426a107u""","BLDR-WOW","Nos","Standard Selling",11300.0,"2026-02-05 12:42:46.854232","casey@shilohcode.com",0,"Wow","","wow","","","",0,1,"USD","02-05-2026",0,"","",""
"","""8suh4u50ra""","VHS-0018","Each","Standard Buying",45.0,"2024-12-17 13:39:00.893309","courtney@sprinklersnorthwest.com",0,"Paver Spikes","","Spikes","","","",1,0,"USD","12-17-2024",0,"","",""
"","""903e0cd87e""","DL-0038","Yard","Standard Selling",38.0,"2024-12-11 10:26:06.090089","lbrown@sprinklersnorthwest.com",0,"3/4 Shale","","3/4 Shale","","","",0,1,"USD","",0,"","",""
"","""92bc03f7ec""","BLDR-Meadowlane Greens","Each","Commerical Installation",11300.0,"2025-02-24 14:37:05.766490","kris@sprinklersnorthwest.com",0,"Meadowlane Greens Full Yard Landscape Package ","","Meadowlane Greens Full Yard Landscape Package ","","","",0,1,"USD","02-24-2025",0,"","",""
"","""9743f3e2a2""","DL-0041","Yard","Standard Selling",79.0,"2024-12-11 10:26:06.177971","lbrown@sprinklersnorthwest.com",0,"3/4 Rainbow","","3/4 Rainbow","","","",0,1,"USD","",0,"","",""
"","""97c9841ad7""","SERV-0001","","Standard Buying",0.0,"2025-01-31 10:37:59.732968","kris@sprinklersnorthwest.com",0,"Backflow Test Only","","<div><p>Backflow Test Only</p></div>","","","",1,0,"USD","01-31-2025",0,"","",""
"","""99b4ecbde4""","DL-0035","Yard","Standard Selling",65.0,"2024-12-11 10:26:06.008990","lbrown@sprinklersnorthwest.com",0,"Iron Mt","","Iron Mt","","","",0,1,"USD","",0,"","",""
"","""9a509a80a6""","DL-0031","Yard","Standard Selling",32.0,"2024-12-11 10:26:05.900179","lbrown@sprinklersnorthwest.com",0,"Course Sand","","Course Sand","","","",0,1,"USD","",0,"","",""
"","""a09261b096""","SNW-I-0056","","Standard Selling",485.0,"2025-01-22 14:23:57.953335","kris@sprinklersnorthwest.com",0,"Backflow Installation","","Installation of Backflow","","","",0,1,"USD","01-22-2025",0,"","",""
"","""a102ba8219""","SERV-0005","Each","Commerical Installation",30.0,"2025-02-13 11:13:58.044160","courtney@sprinklersnorthwest.com",0,"Additional Backflow Test","","<div><p>Price Per Additional Backflow Test (when more than one backflow is required to be tested)</p></div>","","","",0,1,"USD","02-13-2025",0,"","",""
"","""a1fe8dc563""","SNW-I-0012","Each","Standard Selling",500.0,"2025-01-29 11:22:05.752885","courtney@sprinklersnorthwest.com",0,"Excavator","","Excavator","","","",0,1,"USD","01-29-2025",0,"","",""
"","""a29c769843""","DL-0050","Hour","Standard Selling",270.0,"2025-01-27 11:29:59.079130","courtney@sprinklersnorthwest.com",0,"12-15 Yard Dump ","","12-15 Yard Dump ","","","",0,1,"USD","01-27-2025",0,"","",""
"","""a30f9fd663""","SNW-I-0058","Each","Standard Selling",800.0,"2025-01-24 10:14:28.881529","courtney@sprinklersnorthwest.com",0,"Rotor zone install","","Rotor zone to be installed to all designated areas with head to head coverage.","","","",0,1,"USD","01-24-2025",0,"","",""
"","""a913677251""","SERV-0001","Each","Commerical Installation",60.0,"2025-02-13 11:13:58.011002","courtney@sprinklersnorthwest.com",0,"Backflow Test Only","","<div><p>Backflow Test Only</p></div>","","","",0,1,"USD","02-13-2025",0,"","",""
"","""a944e3ac05""","SNW-I-0028","Each","Standard Selling",7.25,"2024-12-12 09:53:23.813831","lbrown@sprinklersnorthwest.com",0,"Curbing","","Concrete curbing to be installed to all designated areas. (100"" minimum)","","","",0,1,"USD","",0,"","",""
"","""a9b4f75d88""","DL-0039","Yard","Standard Selling",38.0,"2024-12-11 10:26:06.121909","lbrown@sprinklersnorthwest.com",0,"2-3"" Shale","","2-3"" Shale","","","",0,1,"USD","",0,"","",""
"","""aade78bb71""","SNW-I-0036","Each","Standard Selling",4.25,"2024-12-12 09:53:23.979191","lbrown@sprinklersnorthwest.com",0,"Plastic Edging","","Plastic edging to be installed to all designated areas.","","","",0,1,"USD","",0,"","",""
"","""aae3510267""","DL-0009","Yard","Standard Selling",61.0,"2024-12-11 10:26:05.323949","lbrown@sprinklersnorthwest.com",0,"Small Speckled Granite","","Small Speckled Granite","","","",0,1,"USD","",0,"","",""
"","""aee6df53a9""","DL-0016","Yard","Standard Selling",42.0,"2024-12-11 10:26:05.513666","lbrown@sprinklersnorthwest.com",0,"4-8"" Basalt Cobble","","4-8"" Basalt Cobble","","","",0,1,"USD","",0,"","",""
"","""b19def7015""","DL-0012","Yard","Standard Selling",85.0,"2024-12-11 10:26:05.403703","lbrown@sprinklersnorthwest.com",0,"Sm. Green Omak","","Sm. Green Omak","","","",0,1,"USD","",0,"","",""
"","""b1aca5ed08""","BLDR-Lennar Package","Each","Commerical Installation",9800.0,"2025-02-07 12:10:17.843290","kris@sprinklersnorthwest.com",0,"BLDR-Lennar Package","","BLD","","","",0,1,"USD","02-07-2025",0,"","",""
"","""b687960f3d""","DL-0030","Yard","Standard Selling",37.0,"2024-12-11 10:26:05.873711","lbrown@sprinklersnorthwest.com",0,"Fine Sand","","Fine Sand","","","",0,1,"USD","",0,"","",""
"","""bdec0ddb6a""","DL-0026","Yard","Standard Selling",42.0,"2024-12-11 10:26:05.777881","lbrown@sprinklersnorthwest.com",0,"1.5"" PHD","","1.5"" PHD","","","",0,1,"USD","",0,"","",""
"","""bkne7f8o3s""","BLDR-BIG-BIG-BUILDERS","Nos","Standard Selling",12700.0,"2026-02-05 12:48:34.347978","casey@shilohcode.com",0,"Big Big Builders","","Big Builders","","","",0,1,"USD","02-05-2026",0,"","",""
"","""c211b2c087""","SNW-I-0018","Yard","Standard Selling",65.0,"2025-01-17 13:24:41.574304","kris@sprinklersnorthwest.com",0,"Topsoil Application","","Topsoil Application","","","",0,1,"USD","01-17-2025",0,"","",""
"","""c2b6ee2472""","DL-0018","Yard","Standard Selling",42.0,"2024-12-11 10:26:05.566110","lbrown@sprinklersnorthwest.com",0,"3/4"" Basalt","","3/4"" Basalt A-Rock","","","",0,1,"USD","",0,"","",""
"","""c4db4ab59e""","DL-0001","Yard","Standard Selling",16.0,"2024-12-11 10:26:05.062927","lbrown@sprinklersnorthwest.com",0,"Topsoil","","Topsoil","","","",0,1,"USD","",0,"","",""
"","""c4f5ba1700""","SNW-I-0024","Each","Standard Selling",2250.0,"2025-01-27 11:29:59.038039","courtney@sprinklersnorthwest.com",0,"French Drain","","French Drain","","","",0,1,"USD","01-27-2025",0,"","",""
"","""c50e8f6577""","Dump Fees","Each","Standard Selling",1000.0,"2025-01-29 11:27:26.128108","courtney@sprinklersnorthwest.com",0,"Dump Fees","","<div><p>Dump Fees</p></div>","","","",0,1,"USD","01-29-2025",0,"","",""
"","""c82018d25a""","DL-0044","Yard","Standard Selling",66.0,"2024-12-11 10:26:06.256209","lbrown@sprinklersnorthwest.com",0,"Small Nugget","","Small Nugget","","","",0,1,"USD","",0,"","",""
"","""cb8d088bd0""","DL-0017","Yard","Standard Selling",42.0,"2024-12-11 10:26:05.540059","lbrown@sprinklersnorthwest.com",0,"2"" Basalt","","2"" Basalt","","","",0,1,"USD","",0,"","",""
"","""cj0snnhj1f""","SNW-I-0034","Each","Standard Selling",35.0,"2024-12-17 17:00:31.603919","courtney@sprinklersnorthwest.com",0,"General Labor","","General Labor","","","",0,1,"USD","12-17-2024",0,"","",""
"","""d07dc72e6b""","SNW-I-0037","Each","Standard Selling",0.9,"2024-12-12 09:53:24.007122","lbrown@sprinklersnorthwest.com",0,"Sod Install","","Sod to be installed to designated areas as specified.","","","",0,1,"USD","",0,"","",""
"","""d68759f4ae""","BLDR-Hayden Canyon Townhomes Bronze","Each","Standard Selling",4400.0,"2025-02-11 08:30:41.276902","kris@sprinklersnorthwest.com",0,"BLDR-Hayden Canyon Townhomes Bronze ","","BLDR-Hayden Canyon Townhomes Bronze ","","","",0,1,"USD","02-11-2025",0,"","",""
"","""d72eb32f2e""","DL-0048","Yard","Standard Selling",42.0,"2024-12-11 10:26:06.361540","lbrown@sprinklersnorthwest.com",0,"Shredded","","Shredded","","","",0,1,"USD","",0,"","",""
"","""dc1fd7d474""","BLDR-Medium Plant Package","Each","Standard Selling",450.0,"2025-01-27 12:55:08.839894","kris@sprinklersnorthwest.com",0,"BLDR-Medium Plant Package ","","BLDR-Medium Plant Package ","","","",0,1,"USD","01-27-2025",0,"","",""
"","""dc4e2eb529""","BLDR-Tangled Ridge","Each","Standard Selling",11350.0,"2025-02-11 08:49:27.936095","kris@sprinklersnorthwest.com",0,"BLDR-Tangled Ridge","","BLDR-Tangled Ridge","","","",0,1,"USD","02-11-2025",0,"","",""
"","""dd451a1ae6""","DL-0036","Yard","Standard Selling",90.0,"2024-12-11 10:26:06.036579","lbrown@sprinklersnorthwest.com",0,"Decomp Granite","","Decomp Granite","","","",0,1,"USD","",0,"","",""
"","""dd73018ac8""","DL-0051","","Standard Buying",150.0,"2025-01-17 10:21:14.010729","kris@sprinklersnorthwest.com",0,"20-24 Yard Dump","","20-24 Yard Dump","","","",1,0,"USD","01-17-2025",0,"","",""
"","""de1rmums4j""","SNW-I-0055","Each","Standard Selling",1450.0,"2025-01-08 14:03:56.387517","kris@sprinklersnorthwest.com",0,"POC Installation","","installation of point of conection.","","","",0,1,"USD","01-08-2025",0,"","",""
"","""de1spo8bcn""","SNW-I-0005","Each","Standard Selling",625.0,"2025-01-08 14:03:56.419385","kris@sprinklersnorthwest.com",0,"Grading And Labor","","Grading And Labor","","","",0,1,"USD","01-08-2025",0,"","",""
"","""de9e25ff8b""","DL-0049","Hour","Standard Buying",95.0,"2025-01-17 09:27:12.011460","kris@sprinklersnorthwest.com",0,"Small 6 yard Dump Truck ","","Small 6 yard Dump Truck ","","Daniels Landscape Supplies","",1,0,"USD","01-17-2025",0,"","","Daniels Landscape Supplies"
"","""dp1ssltjgs""","SNW-I-DOOTER","Nos","Standard Selling",2030.0,"2026-02-05 12:52:12.960575","casey@shilohcode.com",0,"Dooter","","Doot doot","","","",0,1,"USD","02-05-2026",0,"","",""
"","""e0cf8f09de""","DL-0047","Yard","Standard Selling",46.0,"2024-12-11 10:26:06.333886","lbrown@sprinklersnorthwest.com",0,"Bright Fines","","Bright Fines","","","",0,1,"USD","",0,"","",""
"","""e39d799e7e""","DL-0014","Yard","Standard Selling",143.0,"2024-12-11 10:26:05.459391","lbrown@sprinklersnorthwest.com",0,"Scoria","","Scoria","","","",0,1,"USD","",0,"","",""
"","""e543944bcf""","SNW-I-0029","Each","Standard Selling",8.25,"2024-12-12 09:53:23.852907","lbrown@sprinklersnorthwest.com",0,"Curbing Color","","Color to be added to curbing to be installed.","","","",0,1,"USD","",0,"","",""
"","""e60d37ea58""","DL-0010","Yard","Standard Selling",61.0,"2024-12-11 10:26:05.349308","lbrown@sprinklersnorthwest.com",0,"Large Speckled Granite","","Large Speckled Granite","","","",0,1,"USD","",0,"","",""
"","""e884241450""","DL-0043","Yard","Standard Selling",79.0,"2024-12-11 10:26:06.230380","lbrown@sprinklersnorthwest.com",0,"2-4"" Rainbow","","2-4"" Rainbow","","","",0,1,"USD","",0,"","",""
"","""e8f24ceaea""","SERV-0001","Each","Residential",85.0,"2025-01-31 10:10:11.531811","kris@sprinklersnorthwest.com",0,"Backflow Test Only","","<div><p>Backflow Test Only</p></div>","","","",0,1,"USD","01-31-2025",0,"","",""
"","""ee7c270ecf""","BLDR-Tangled Ridge","Each","Commerical Installation",11350.0,"2025-02-11 10:10:52.269052","kris@sprinklersnorthwest.com",0,"BLDR-Tangled Ridge","","BLDR-Tangled Ridge","","","",0,1,"USD","02-11-2025",0,"","",""
"","""ef1377ef15""","SERV-0015","Each","Residential",140.0,"2025-04-17 11:20:53.896920","peter@shilohcode.com",0,"Spring Fertilize","","<div><p>Spring Fertilizer Application</p></div>","","","",0,1,"USD","04-17-2025",0,"","",""
"","""f24fec277c""","DL-0023","Yard","Standard Selling",97.0,"2024-12-11 10:26:05.698538","lbrown@sprinklersnorthwest.com",0,"Chewelah Yellow","","Chewelah Yellow","","","",0,1,"USD","",0,"","",""
"","""f6a5e1b7dd""","DL-0025","Yard","Standard Selling",37.0,"2024-12-11 10:26:05.751945","lbrown@sprinklersnorthwest.com",0,"3/4 Washed Round","","3/4 Washed Round","","","",0,1,"USD","",0,"","",""
"","""f6c2996a29""","SNW-I-0006","Each","Standard Selling",185.0,"2025-01-16 13:19:57.699427","kris@sprinklersnorthwest.com",0,"Equipment Operator","","Equipment Operator","","","",0,1,"USD","01-16-2025",0,"","",""
"","""fe3a6a4f3d""","SNW-I-0001","Each","Commerical Installation",125.0,"2025-02-13 11:39:46.054895","courtney@sprinklersnorthwest.com",0,"Excavation","","Excavation","","","",0,1,"USD","02-13-2025",0,"","",""
"","""gp961regkt""","SERV-0002","Each","Standard Selling",55.0,"2024-12-09 08:56:16.696237","lbrown@sprinklersnorthwest.com",1,"Fall System Winterize","","<div><p>Winterization of irrigation system</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""hbvha1kiqv""","SERV-0003","Each","Standard Selling",55.0,"2024-12-09 09:28:11.295470","lbrown@sprinklersnorthwest.com",0,"Service Call Minimum","","<div><p>Service Call Minimum (half hour or less)</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""hghbpap5of""","SERV-0004","Each","Standard Selling",65.0,"2024-12-09 09:35:57.943100","lbrown@sprinklersnorthwest.com",0,"Start Up","","<div><p>Start Up Sprinklers</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""hjeg7705nm""","SERV-0005","Each","Standard Selling",30.0,"2024-12-09 09:40:56.064168","lbrown@sprinklersnorthwest.com",0,"Additional Backflow Test","","<div><p>Price Per Additional Backflow Test (when more than one backflow is required to be tested)</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""hpfpp4ph0o""","SERV-0006","Each","Standard Selling",70.0,"2024-12-09 09:51:14.538879","lbrown@sprinklersnorthwest.com",0,"Additional Service Time","","<div><p>Charge $70 per 15min when service call runs over the allowed time.</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""iafjtq1heh""","SERV-0007","Each","Standard Selling",75.0,"2024-12-09 10:20:14.734917","lbrown@sprinklersnorthwest.com",0,"Outlying Backflow Test","","<div><p>Backflow Test Outlying Area</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""idmm50lt36""","SERV-0008","Each","Standard Selling",65.0,"2024-12-09 10:25:44.599739","lbrown@sprinklersnorthwest.com",0,"Outlying Winterize","","<div><p>Fall Winterization-Outlying Area</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""ihndqvf7ev""","SERV-0001","Each","Standard Selling",65.0,"2024-12-09 10:32:36.553612","lbrown@sprinklersnorthwest.com",0,"Backflow Test Only","","<div><p>Backflow Test Only</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""isb7rsh7iv""","SERV-0009","Each","Standard Selling",75.0,"2024-12-09 10:50:43.976626","lbrown@sprinklersnorthwest.com",0,"Outlying Startup","","<div><p>Spring Start Up-Outlying Area</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""ivnthihgun""","SERV-0010","Each","Standard Selling",130.0,"2024-12-09 10:56:31.729945","lbrown@sprinklersnorthwest.com",0,"Outlying Startup/Backflow","","<div><p>Outlying Areas Spring Turn on/Backflow Test</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""j4uviejsud""","SERV-0011","Each","Standard Selling",70.0,"2024-12-09 11:05:26.384150","lbrown@sprinklersnorthwest.com",0,"Service Call","","<div><p>Service Call (Billed at $70 Per Hour Per Tech)</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""jai9q37en1""","SERV-0012","Each","Standard Selling",120.0,"2024-12-09 11:15:00.105518","lbrown@sprinklersnorthwest.com",0,"Startup/Backflow","","<div><p>Start Up System And Perform Backflow Test.</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""jflu3b6fqe""","SERV-0013","Each","Standard Selling",170.0,"2024-12-09 11:23:43.830160","lbrown@sprinklersnorthwest.com",0,"Stoneridge Startup/Backflow/Winterize","","<div><p>Stoneridge Spring turn on/ Backflow test/ Prepay Fall Winterization.</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""jhifdikd83""","BLDR-BIG-TEST-KIT","Nos","Standard Selling",11700.0,"2026-02-05 13:02:03.478033","casey@shilohcode.com",0,"Big Test Kit","","This is just a test","","","",0,1,"USD","02-05-2026",0,"","",""
"","""jmqt2928nj""","SERV-0014","Each","Standard Selling",225.0,"2024-12-09 11:35:56.560417","lbrown@sprinklersnorthwest.com",0,"Mid Season Check Adjustments","","<div><p>Midseason Check adjustments</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""jv3k43j5vj""","SERV-0015","Each","Standard Selling",350.0,"2024-12-09 11:50:03.620159","lbrown@sprinklersnorthwest.com",1,"Spring Fertilize","","<div><p>Spring Fertilizer Application</p></div>","","","",0,1,"USD","12-09-2024",0,"","",""
"","""ntvjsadrkq""","BLDR-TEST-HOUSES-PACKAGE","Nos","Standard Selling",900.0,"2026-02-11 11:09:13.536751","casey@shilohcode.com",0,"Test houses package","","Test package","","","",0,1,"USD","02-11-2026",0,"","",""
"","""ost4kd4fiu""","SNW-I-0053","Each","Standard Selling",2200.0,"2024-12-31 12:36:38.804512","courtney@sprinklersnorthwest.com",0,"Installation Sprinkler System","","Installation of sprinkler system zoned with head to head coverage in all turf areas.","","","",0,1,"USD","12-31-2024",0,"","",""
"","""ou2r02dsg4""","SNW-I-BOB-THE-BUILDER-PACKAGE","Nos","Standard Selling",1400.0,"2026-02-05 12:16:38.605960","casey@shilohcode.com",0,"Bob The Builder Package","","This is a test","","","",0,1,"USD","02-05-2026",0,"","",""
"","""q6etqmej10""","VHS-0018","Each","Standard Selling",45.0,"2024-12-09 17:30:17.300988","courtney@sprinklersnorthwest.com",0,"Paver Spikes","","Spikes","","","",0,1,"USD","12-09-2024",0,"","",""
"","""skl3m36h2m""","BLDR- Full Yard Sprinkler Installation","Each","Standard Selling",8000.0,"2026-02-06 08:24:27.790550","casey@shilohcode.com",0,"BLDR- Full Yard Sprinkler Installation","","BLDR- Full Yard Sprinkler Installation","","","",0,1,"USD","02-06-2026",0,"","",""
"","""upoveng8h9""","BLDR-BIG-BUILDER-INSTALL-PACKAGE","Nos","Standard Selling",2000.0,"2026-02-05 12:26:39.275545","casey@shilohcode.com",0,"Big Builder Install Package","","This is a test","","","",0,1,"USD","02-05-2026",0,"","",""
Can't render this file because it has a wrong number of fields in line 2.

BIN
csv/Item Price.xlsx Normal file

Binary file not shown.

2314
csv/Item-import-ready.csv Normal file

File diff suppressed because it is too large Load Diff

1
csv/Item-template.csv Normal file
View File

@ -0,0 +1 @@
"Item Code","Item Group","Default Unit of Measure","Series","Item Name","Markup Percentage","Disabled","Allow Alternative Item","Maintain Stock","Has Variants","Opening Stock","Valuation Rate","Standard Selling Rate","Is Fixed Asset","Auto Create Assets on Purchase","Create Grouped Asset","Asset Category","Asset Naming Series","Over Delivery/Receipt Allowance (%)","Over Billing Allowance (%)","Image","Description","Brand","Shelf Life In Days","End of Life","Default Material Request Type","Valuation Method","Warranty Period (in days)","Weight Per Unit","Weight UOM","Allow Negative Stock","Has Batch No","Automatically Create New Batch","Batch Number Series","Has Expiry Date","Retain Sample","Max Sample Quantity","Has Serial No","Serial Number Series","Variant Of","Variant Based On","Enable Deferred Expense","No of Months (Expense)","Enable Deferred Revenue","No of Months (Revenue)","Default Purchase Unit of Measure","Minimum Order Qty","Safety Stock","Allow Purchase","Lead Time in days","Last Purchase Rate","Is Customer Provided Item","Customer","Delivered by Supplier (Drop Ship)","Country of Origin","Customs Tariff Number","Default Sales Unit of Measure","Grant Commission","Allow Sales","Max Discount (%)","Inspection Required before Purchase","Quality Inspection Template","Inspection Required before Delivery","Include Item In Manufacturing","Supply Raw Materials for Purchase","Default BOM","Customer Code","Default Item Manufacturer","Default Manufacturer Part No","Total Projected Qty","ID (Barcodes)","Barcode (Barcodes)","Barcode Type (Barcodes)","UOM (Barcodes)","ID (Reorder level based on Warehouse)","Check in (group) (Reorder level based on Warehouse)","Material Request Type (Reorder level based on Warehouse)","Re-order Level (Reorder level based on Warehouse)","Re-order Qty (Reorder level based on Warehouse)","Request for (Reorder level based on Warehouse)","ID (UOMs)","Conversion Factor (UOMs)","UOM (UOMs)","ID (Variant Attributes)","Attribute (Variant Attributes)","Attribute Value (Variant Attributes)","Disabled (Variant Attributes)","From Range (Variant Attributes)","Increment (Variant Attributes)","Numeric Values (Variant Attributes)","To Range (Variant Attributes)","Variant Of (Variant Attributes)","ID (Item Defaults)","Company (Item Defaults)","Default Buying Cost Center (Item Defaults)","Default Discount Account (Item Defaults)","Default Expense Account (Item Defaults)","Default Income Account (Item Defaults)","Default Price List (Item Defaults)","Default Provisional Account (Item Defaults)","Default Selling Cost Center (Item Defaults)","Default Supplier (Item Defaults)","Default Warehouse (Item Defaults)","Deferred Expense Account (Item Defaults)","Deferred Revenue Account (Item Defaults)","ID (Supplier Items)","Supplier (Supplier Items)","Supplier Part Number (Supplier Items)","ID (Customer Items)","Customer Group (Customer Items)","Customer Name (Customer Items)","Ref Code (Customer Items)","ID (Taxes)","Item Tax Template (Taxes)","Maximum Net Rate (Taxes)","Minimum Net Rate (Taxes)","Tax Category (Taxes)","Valid From (Taxes)"
1 Item Code Item Group Default Unit of Measure Series Item Name Markup Percentage Disabled Allow Alternative Item Maintain Stock Has Variants Opening Stock Valuation Rate Standard Selling Rate Is Fixed Asset Auto Create Assets on Purchase Create Grouped Asset Asset Category Asset Naming Series Over Delivery/Receipt Allowance (%) Over Billing Allowance (%) Image Description Brand Shelf Life In Days End of Life Default Material Request Type Valuation Method Warranty Period (in days) Weight Per Unit Weight UOM Allow Negative Stock Has Batch No Automatically Create New Batch Batch Number Series Has Expiry Date Retain Sample Max Sample Quantity Has Serial No Serial Number Series Variant Of Variant Based On Enable Deferred Expense No of Months (Expense) Enable Deferred Revenue No of Months (Revenue) Default Purchase Unit of Measure Minimum Order Qty Safety Stock Allow Purchase Lead Time in days Last Purchase Rate Is Customer Provided Item Customer Delivered by Supplier (Drop Ship) Country of Origin Customs Tariff Number Default Sales Unit of Measure Grant Commission Allow Sales Max Discount (%) Inspection Required before Purchase Quality Inspection Template Inspection Required before Delivery Include Item In Manufacturing Supply Raw Materials for Purchase Default BOM Customer Code Default Item Manufacturer Default Manufacturer Part No Total Projected Qty ID (Barcodes) Barcode (Barcodes) Barcode Type (Barcodes) UOM (Barcodes) ID (Reorder level based on Warehouse) Check in (group) (Reorder level based on Warehouse) Material Request Type (Reorder level based on Warehouse) Re-order Level (Reorder level based on Warehouse) Re-order Qty (Reorder level based on Warehouse) Request for (Reorder level based on Warehouse) ID (UOMs) Conversion Factor (UOMs) UOM (UOMs) ID (Variant Attributes) Attribute (Variant Attributes) Attribute Value (Variant Attributes) Disabled (Variant Attributes) From Range (Variant Attributes) Increment (Variant Attributes) Numeric Values (Variant Attributes) To Range (Variant Attributes) Variant Of (Variant Attributes) ID (Item Defaults) Company (Item Defaults) Default Buying Cost Center (Item Defaults) Default Discount Account (Item Defaults) Default Expense Account (Item Defaults) Default Income Account (Item Defaults) Default Price List (Item Defaults) Default Provisional Account (Item Defaults) Default Selling Cost Center (Item Defaults) Default Supplier (Item Defaults) Default Warehouse (Item Defaults) Deferred Expense Account (Item Defaults) Deferred Revenue Account (Item Defaults) ID (Supplier Items) Supplier (Supplier Items) Supplier Part Number (Supplier Items) ID (Customer Items) Customer Group (Customer Items) Customer Name (Customer Items) Ref Code (Customer Items) ID (Taxes) Item Tax Template (Taxes) Maximum Net Rate (Taxes) Minimum Net Rate (Taxes) Tax Category (Taxes) Valid From (Taxes)

2333
csv/Item.csv Normal file

File diff suppressed because it is too large Load Diff

2328
csv/Item2.csv Normal file

File diff suppressed because it is too large Load Diff

58
csv/data.py Normal file
View File

@ -0,0 +1,58 @@
import csv
# FILES
export_file = "Item Price.csv" # ERPNext export
output_file = "Item Price-import-ready.csv" # clean import CSV
# Template columns (exact Column Name values for ERPNext import)
template_columns = [
"name","item_code","uom","price_list","price_list_rate","packing_unit",
"item_name","brand","item_description","customer","supplier","batch_no",
"buying","selling","currency","valid_from","lead_time_days","valid_upto",
"note","reference"
]
# Which row has the Column Name row in ERPNext export? Usually 20th (0-index 19)
COLUMN_NAME_ROW = 19
DATA_START_ROW = 21 # 0-indexed row where actual data starts
def clean_cell(cell):
# Remove extra quotes around the data
if cell.startswith('"""') and cell.endswith('"""'):
return cell[3:-3]
elif cell.startswith('"') and cell.endswith('"'):
return cell[1:-1]
return cell
# Read the export
with open(export_file, newline='', encoding='utf-8') as f:
reader = list(csv.reader(f))
export_columns = [clean_cell(c) for c in reader[COLUMN_NAME_ROW]]
data_rows = reader[DATA_START_ROW-1:]
# Build column index map
col_indexes = []
for col in template_columns:
if col in export_columns:
col_indexes.append(export_columns.index(col))
else:
col_indexes.append(None) # fill missing columns with empty string
# Write clean CSV
with open(output_file, "w", newline='', encoding='utf-8') as f_out:
writer = csv.writer(f_out, quoting=csv.QUOTE_ALL)
# Header row: template
writer.writerow(template_columns)
for row in data_rows:
clean_row = []
for idx in col_indexes:
if idx is not None and idx < len(row):
clean_row.append(clean_cell(row[idx]))
else:
clean_row.append("")
writer.writerow(clean_row)
print(f"Clean Item Price CSV written to {output_file}")

BIN
csv/item.xlsx Normal file

Binary file not shown.

View File

@ -3,6 +3,32 @@ import json
from custom_ui.db_utils import build_error_response, build_success_response
from custom_ui.services import ClientService, AddressService, ContactService
@frappe.whitelist()
def check_addresses_exist(addresses):
"""Check if any of the provided addresses already exist in the system."""
if isinstance(addresses, str):
addresses = json.loads(addresses)
print(f"DEBUG: check_addresses_exist called with addresses: {addresses}")
existing_addresses = []
for address in addresses:
filters = {
"doctype": "Address",
"address_line1": address.get("address_line1"),
"city": address.get("city"),
# "state": address.get("state"),
"pincode": address.get("pincode")
}
if address.get("address_line2"):
filters["address_line2"] = address.get("address_line2")
print(f"DEBUG: Checking existence for address with filters: {filters}")
if frappe.db.exists(filters):
print("DEBUG: Address exists:", filters)
existing_addresses.append(address)
else:
print("DEBUG: Address does not exist:", filters)
return build_success_response(existing_addresses)
@frappe.whitelist()
def get_address_by_full_address(full_address):
"""Get address by full_address, including associated contacts."""

View File

@ -9,6 +9,120 @@ from custom_ui.services import AddressService, ContactService, ClientService
# CLIENT MANAGEMENT API METHODS
# ===============================================================================
@frappe.whitelist()
def add_addresses_contacts(client_name, company_name, addresses=[], contacts=[]):
if isinstance(addresses, str):
addresses = json.loads(addresses)
if isinstance(contacts, str):
contacts = json.loads(contacts)
print(f"DEBUG: add_addresses_contacts called with client_name: {client_name}, addresses: {addresses}, contacts: {contacts}")
try:
client_doc = ClientService.get_client_or_throw(client_name)
if contacts:
contact_docs = [frappe.get_doc("Contact", contact.contact) for contact in client_doc.contacts]
for contact in contacts:
contact_doc = None
if frappe.db.exists("Contact", {"email_id": contact.get("email")}):
contact_doc = frappe.get_doc("Contact", {"email_id": contact.get("email")})
else:
contact_doc = ContactService.create({
"first_name": contact.get("first_name"),
"last_name": contact.get("last_name"),
"email_id": contact.get("email"),
"role": contact.get("role"),
"phone": contact.get("phone"),
"custom_email": contact.get("email"),
"is_primary_contact": 0,
"customer_type": client_doc.doctype,
"customer_name": client_doc.name,
"email_ids": [{
"email": contact.get("email"),
"is_primary": 1
}],
"phone_nos": [{
"phone": contact.get("phone"),
"is_primary_phone": 1,
"is_primary_mobile_no": 1
}]
})
contact_doc.insert()
ClientService.append_link_v2(client_doc.name, "contacts", {"contact": contact_doc.name})
ContactService.link_contact_to_customer(contact_doc, client_doc.doctype, client_doc.name)
contact_docs.append(contact_doc)
address_docs = [frappe.get_doc("Address", link.address) for link in client_doc.properties]
for address in addresses:
address_doc = None
if frappe.db.exists("Address", {
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
"city": address.get("city"),
# "state": address.get("state"),
"pincode": address.get("pincode")
}):
address_doc = frappe.get_doc("Address", {
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
"city": address.get("city"),
# "state": address.get("state"),
"pincode": address.get("pincode")
})
else:
address_doc = AddressService.create({
"address_title": AddressService.build_address_title(customer_name=client_name, address_data=address),
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
"city": address.get("city"),
"state": address.get("state"),
"pincode": address.get("pincode"),
"country": "United States",
"address_type": "Service",
"custom_billing_address": 0,
"is_primary_address": 0,
"is_service_address": 1,
"customer_type": client_doc.doctype,
"customer_name": client_doc.name
})
address_doc.insert()
if company_name not in [company.company for company in address_doc.companies]:
address_doc.append("companies", {"company": company_name})
address_doc.save(ignore_permissions=True)
AddressService.link_address_to_customer(address_doc, client_doc.doctype, client_doc.name)
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)
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)
ClientService.append_link_v2(client_doc.name, "properties", {"address": address_doc.name})
address_docs.append(address_doc)
return build_success_response({
"contacts": [contact.as_dict() for contact in contact_docs],
"addresses": [address.as_dict() for address in address_docs],
"message": "Addresses and contacts added successfully."
})
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def check_client_exists(client_name):
"""Check if a client exists as either a Customer or a Lead.
Additionally, return a list of potential matches based on the client name."""
print("DEBUG: check_client_exists called with client_name:", client_name)
try:
exact_customer_match = frappe.db.exists("Customer", client_name)
exact_lead_match = frappe.db.exists("Lead", {"custom_customer_name": client_name})
customer_matches = frappe.get_all("Customer", pluck="name", filters={"name": ["like", f"%{client_name}%"]})
lead_matches = frappe.get_all("Lead", pluck="custom_customer_name", filters={"custom_customer_name": ["like", f"%{client_name}%"]})
# remove duplicates from potential matches between customers and leads
return build_success_response({
"exact_match": exact_customer_match or exact_lead_match,
"potential_matches": list(set(customer_matches + lead_matches))
})
except Exception as e:
return build_error_response(str(e), 500)
@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."""
@ -363,15 +477,15 @@ def upsert_client(data):
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)
# 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
@ -444,25 +558,36 @@ def upsert_client(data):
# Handle address creation
address_docs = []
for address in addresses:
is_billing = True if address.get("is_billing_address") else False
is_service = True if address.get("is_service_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_exists = frappe.db.exists("Address", {
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
"address_type": "Billing" if is_billing else "Service",
"custom_billing_address": is_billing,
"is_service_address": is_service,
"is_primary_address": is_billing,
"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") }]
"pincode": address.get("pincode")
})
address_doc = None
if address_exists:
address_doc = frappe.get_doc("Address", address_exists)
else:
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",
"custom_billing_address": is_billing,
"is_service_address": is_service,
"is_primary_address": is_billing,
"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:

View File

@ -1,4 +1,23 @@
import frappe
import json
from custom_ui.db_utils import build_error_response, build_success_response
@frappe.whitelist()
def check_contacts_exist(contacts):
"""Check if any of the provided contacts already exist in the system."""
if isinstance(contacts, str):
contacts = json.loads(contacts)
print(f"DEBUG: check_contacts_exist called with contacts: {contacts}")
existing_contacts = []
for contact in contacts:
if frappe.db.exists("Contact", {
"first_name": contact.get("first_name"),
"last_name": contact.get("last_name"),
"email_id": contact.get("email"),
"phone": contact.get("phone_number")
}):
existing_contacts.append(contact)
return build_success_response(existing_contacts)
def existing_contact_name(first_name: str, last_name: str, email: str, phone: str) -> str:
"""Check if a contact exists based on provided details."""

View File

@ -15,6 +15,13 @@ def get_estimate_table_data_v2(filters={}, sortings=[], page=1, page_size=10):
"""Get paginated estimate table data with filtering and sorting."""
print("DEBUG: Raw estimate options received:", filters, sortings, page, page_size)
filters, sortings, page, page_size = DbUtils.process_datatable_request(filters, sortings, page, page_size)
if filters.get("address"):
# change the key from address to custom_job_address
filters["custom_job_address"] = filters.pop("address")
filters["custom_job_address"] = ["like", f"%{filters['custom_job_address']['value']}%"]
if filters.get("customer"):
filters["actual_customer_name"] = ["like", f"%{filters.pop('customer')['value']}%"]
print("DEBUG: Processed estimate options - Filters:", filters, "Sortings:", sortings, "Page:", page, "Page Size:", page_size)
sortings = "modified desc" if not sortings else sortings
count = frappe.db.count("Quotation", filters=filters)
print(f"DEBUG: Number of estimates returned: {count}")
@ -145,7 +152,7 @@ def get_estimate(estimate_name):
est_dict["address_details"] = address_doc
est_dict["history"] = get_doc_history("Quotation", estimate_name)
est_dict["items"] = [ItemService.get_full_dict(item.item_code) for item in estimate.items]
est_dict["items"] = [{**ItemService.get_full_dict(item.item_code), **item.as_dict()} for item in estimate.items]
return build_success_response(est_dict)
except Exception as e:
@ -462,6 +469,7 @@ def upsert_estimate(data):
estimate.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.remarks = data.get("remarks", "")
# estimate.company = data.get("company")
# estimate.contact_email = data.get("contact_email")
# estimate.quotation_to = client_doctype
@ -515,7 +523,8 @@ def upsert_estimate(data):
"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("from_onsite_meeting", None)
"from_onsite_meeting": data.get("from_onsite_meeting", None),
"remarks": data.get("remarks", "")
})
for item in data.get("items", []):
item = json.loads(item) if isinstance(item, str) else item
@ -534,7 +543,7 @@ def upsert_estimate(data):
# ClientService.append_link(data.get("customer"), "quotations", "quotation", new_estimate.name)
print("DEBUG: New estimate created with name:", new_estimate.name)
dict = new_estimate.as_dict()
dict["items"] = [ItemService.get_full_dict(item.item_code) for item in new_estimate.items]
dict["items"] = [{**item.as_dict(), **ItemService.get_full_dict(item.item_code)} for item in new_estimate.items]
return build_success_response(dict)
except Exception as e:
print(f"DEBUG: Error in upsert_estimate: {str(e)}")

View File

@ -1,6 +1,7 @@
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 erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from custom_ui.services import SalesOrderService, EmailService
# ===============================================================================
# INVOICES API METHODS
@ -10,15 +11,40 @@ from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
@frappe.whitelist()
def create_invoice_for_job(job_name):
"""Create the invoice from a sales order of a job."""
print("DEBUG: create_invoice_for_job called with job_name:", job_name)
try:
project = frappe.get_doc("Project", job_name)
sales_order = project.sales_order
invoice = make_sales_invoice(sales_order)
invoice.save()
sales_order = frappe.get_value("Project", job_name, "sales_order")
if not sales_order:
return build_error_response("No sales order found for this job.", 404)
invoice = SalesOrderService.create_sales_invoice_from_sales_order(sales_order)
return build_success_response(invoice.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def submit_and_send_invoice(invoice_name):
"""Submit the invoice and send email to customer."""
print("DEBUG: submit_and_send_invoice called with invoice_name:", invoice_name)
try:
invoice_doc = frappe.get_doc("Sales Invoice", invoice_name)
if invoice_doc.docstatus == 0:
print("DEBUG: Submitting invoice:", invoice_name)
invoice_doc.submit()
else:
print("DEBUG: Invoice already submitted:", invoice_name)
# Send invoice email to customer
try:
print("DEBUG: Preparing to send invoice email for", invoice_name)
EmailService.send_invoice_email(invoice_name)
print("DEBUG: Invoice email sent successfully for", invoice_name)
frappe.set_value("Project", invoice_doc.project, "invoice_status", "Invoice Sent")
except Exception as e:
print(f"ERROR: Failed to send invoice email: {str(e)}")
# Don't raise the exception - we don't want to block the invoice submission
frappe.log_error(f"Failed to send invoice email for {invoice_name}: {str(e)}", "Invoice Email Error")
return build_success_response(invoice_doc.as_dict())
except Exception as e:
return build_error_response(str(e), 500)
@frappe.whitelist()
def get_invoices_late_count():

View File

@ -61,7 +61,7 @@ def get_unscheduled_service_appointments(companies):
return build_error_response(str(e), 500)
@frappe.whitelist()
def update_service_appointment_scheduled_dates(service_appointment_name: str, start_date, end_date, crew_lead_name, start_time=None, end_time=None):
def update_service_appointment_scheduled_dates(service_appointment_name: str, start_date, end_date, crew_lead_name, skipped_days=[], start_time=None, end_time=None):
"""Update scheduled dates for a Service Appointment."""
print(f"DEBUG: Updating scheduled dates for Service Appointment {service_appointment_name} to start: {start_date}, end: {end_date}, crew lead: {crew_lead_name}, start time: {start_time}, end time: {end_time}")
try:
@ -71,7 +71,8 @@ def update_service_appointment_scheduled_dates(service_appointment_name: str, st
start_date,
end_date,
start_time,
end_time
end_time,
skip_days=skipped_days
)
return build_success_response(updated_service_appointment.as_dict())
except Exception as e:

View File

@ -4,6 +4,7 @@ from datetime import datetime
from frappe.utils.data import flt
from custom_ui.services import DbService, StripeService, PaymentService
from custom_ui.models import PaymentData
from werkzeug.wrappers import Response
@frappe.whitelist(allow_guest=True)
def half_down_stripe_payment(sales_order):
@ -36,7 +37,11 @@ def invoice_stripe_payment(sales_invoice):
if si.docstatus != 1:
frappe.throw("Sales Invoice must be submitted to proceed with payment.")
if si.outstanding_amount <= 0:
frappe.throw("This invoice has already been paid.")
html = frappe.render_template("custom_ui/templates/invoices/already_paid.html", {
"invoice_number": si.name,
"company": frappe.get_doc("Company", si.company)
})
return Response(html, content_type='text/html')
stripe_session = StripeService.create_checkout_session(
company=si.company,
amount=si.outstanding_amount,

View File

@ -4,6 +4,7 @@ import subprocess
import frappe
from custom_ui.utils import create_module
from custom_ui.api.db.general import search_any_field
from custom_ui.install import create_companies, create_project_templates, create_task_types, create_tasks, create_bid_meeting_note_form_templates
@click.command("update-data")
@click.option("--site", default=None, help="Site to update data for")
@ -100,5 +101,9 @@ def build_frontend(site):
def create_module_command():
create_module()
click.echo("✅ Custom UI module created or already exists.")
def setup_custom_ui():
pass
commands = [build_frontend, create_module_command]

View File

@ -0,0 +1,37 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-02-18 05:53:09.935695",
"custom": 1,
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"company"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-02-18 13:24:00.502036",
"modified_by": "casey@shilohcode.com",
"module": "Custom UI",
"name": "Address Company Link",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,63 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-02-18 05:53:10.533385",
"custom": 1,
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer_type",
"customer_name",
"relation",
"is_active"
],
"fields": [
{
"default": "Customer",
"fieldname": "customer_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Customer Type",
"options": "Customer\nLead",
"reqd": 1
},
{
"fieldname": "customer_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Customer Name",
"options": "customer_type",
"reqd": 1
},
{
"fieldname": "relation",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Relation",
"options": "Owner\nBuilder",
"reqd": 1
},
{
"default": "1",
"fieldname": "is_active",
"fieldtype": "Check",
"label": "Active"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-02-18 05:53:10.573936",
"modified_by": "Administrator",
"module": "Custom UI",
"name": "Address Customer Link",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -1,7 +1,8 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-01-30 07:21:48.230423",
"creation": "2026-02-18 05:53:08.779777",
"custom": 1,
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@ -29,7 +30,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-30 07:52:45.496993",
"modified": "2026-02-18 05:53:08.816451",
"modified_by": "Administrator",
"module": "Custom UI",
"name": "Address Task Link",

View File

@ -0,0 +1,104 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-02-18 05:53:07.884371",
"custom": 1,
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"section_break_m7ec",
"property_address",
"technician",
"test_date",
"result",
"test_report",
"notes",
"water_district_submitted",
"amended_from"
],
"fields": [
{
"fieldname": "section_break_m7ec",
"fieldtype": "Section Break"
},
{
"fieldname": "property_address",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Property Address",
"options": "Address",
"reqd": 1
},
{
"fieldname": "technician",
"fieldtype": "Link",
"label": "Technician",
"options": "Employee"
},
{
"fieldname": "test_date",
"fieldtype": "Date",
"label": "Test Date"
},
{
"fieldname": "result",
"fieldtype": "Select",
"label": "Result",
"options": "Pass\nFail"
},
{
"fieldname": "test_report",
"fieldtype": "Attach",
"label": "Test Report"
},
{
"fieldname": "notes",
"fieldtype": "Text",
"label": "Notes"
},
{
"default": "0",
"fieldname": "water_district_submitted",
"fieldtype": "Check",
"label": "Water District Submitted"
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Backflow Test Form",
"print_hide": 1,
"read_only": 1,
"search_index": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-02-18 10:48:33.908921",
"modified_by": "casey@shilohcode.com",
"module": "Custom UI",
"name": "Backflow Test Form",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -25,7 +25,7 @@
"in_list_view": 1,
"label": "Project Template",
"options": "Project Template",
"reqd": 1
"reqd": 0
},
{
"fieldname": "notes",

View File

@ -0,0 +1,123 @@
{
"actions": [],
"allow_auto_repeat": 1,
"allow_events_in_timeline": 1,
"allow_import": 1,
"allow_rename": 1,
"autoname": "naming_series:",
"color": "Blue",
"creation": "2026-02-18 05:53:07.971371",
"custom": 1,
"default_view": "List",
"doctype": "DocType",
"document_type": "Other",
"engine": "InnoDB",
"field_order": [
"naming_series",
"route_name",
"service_date",
"route_description",
"areazone",
"crew_leader",
"assigned_technicians",
"assigned_addresses",
"route_map"
],
"fields": [
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Route Number",
"options": "Route - .#####"
},
{
"fieldname": "route_name",
"fieldtype": "Data",
"label": "Route Name"
},
{
"fieldname": "service_date",
"fieldtype": "Date",
"label": "Service Date"
},
{
"fieldname": "route_description",
"fieldtype": "Small Text",
"label": "Route Description"
},
{
"fieldname": "areazone",
"fieldtype": "Link",
"label": "Area/Zone",
"options": "Territory"
},
{
"fieldname": "crew_leader",
"fieldtype": "Link",
"label": "Crew Leader",
"options": "Employee"
},
{
"fieldname": "assigned_technicians",
"fieldtype": "Table",
"label": "Assigned Technicians",
"options": "Route Technician Assignment"
},
{
"allow_bulk_edit": 1,
"allow_in_quick_entry": 1,
"columns": 5,
"fieldname": "assigned_addresses",
"fieldtype": "Table",
"label": "Assigned Addresses",
"options": "Assigned Address"
},
{
"fieldname": "route_map",
"fieldtype": "HTML",
"label": "Route Map"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-18 11:06:00.945057",
"modified_by": "casey@shilohcode.com",
"module": "Custom UI",
"name": "Pre-Built Routes",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "route_name, service_date",
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -1,8 +0,0 @@
// Copyright (c) 2026, Shiloh Code LLC and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Service Address 2", {
// refresh(frm) {
// },
// });

View File

@ -1,7 +1,8 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-01-30 07:01:57.571003",
"creation": "2026-02-18 05:53:10.416092",
"custom": 1,
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
@ -19,7 +20,9 @@
"customer",
"company",
"service_address",
"foreman"
"foreman",
"ready_to_schedule",
"skip_days"
],
"fields": [
{
@ -113,12 +116,24 @@
"fieldtype": "Link",
"label": "Foreman",
"options": "Employee"
},
{
"default": "1",
"fieldname": "ready_to_schedule",
"fieldtype": "Check",
"label": "Ready To Schedule"
},
{
"fieldname": "skip_days",
"fieldtype": "Table",
"label": "Skip Days",
"options": "Skip Day"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-01-30 07:15:39.410145",
"modified": "2026-02-18 05:53:10.467828",
"modified_by": "Administrator",
"module": "Custom UI",
"name": "Service Address 2",

View File

@ -1,9 +0,0 @@
# Copyright (c) 2026, Shiloh Code LLC and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class ServiceAddress2(Document):
pass

View File

@ -1,9 +0,0 @@
# Copyright (c) 2026, Shiloh Code LLC and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestServiceAddress2(FrappeTestCase):
pass

View File

@ -2,7 +2,7 @@
"actions": [],
"allow_rename": 1,
"autoname": "format:SA-{MM}-{YYYY}-{####}",
"creation": "2026-01-30 07:01:56.861733",
"creation": "2026-02-11 05:12:39.498845",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
@ -110,7 +110,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-01-30 07:18:16.297996",
"modified": "2026-02-13 03:39:36.214833",
"modified_by": "Administrator",
"module": "Custom UI",
"name": "Service Appointment",

View File

@ -75,9 +75,9 @@ def on_update_after_submit(doc, method):
new_sales_order.customer_address = doc.customer_address
# 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 = []
new_sales_order.set("payment_schedule", [])
print("DEBUG: Setting payment schedule for Sales Order")
new_sales_order.set_payment_schedule()
# 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

View File

@ -31,6 +31,8 @@ def after_insert(doc, method):
"project_template": doc.project_template
})
doc.service_appointment = service_apt.name
if doc.requires_half_payment:
service_apt.ready_to_schedule = 0
doc.save(ignore_permissions=True)
print("DEBUG: Created Service Appointment:", service_apt.name)
except Exception as e:
@ -63,6 +65,9 @@ def before_insert(doc, method):
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Project:", doc.name)
print("DEBUG: Checking status: ", doc.status)
if doc.percent_complete == 100.0 and doc.invoice_status == "Not Ready":
print("DEBUG: Project is 100% complete and invoice status is Not Ready, setting invoice status to Ready to Invoice")
doc.invoice_status = "Ready to Invoice"
if doc.expected_start_date and doc.expected_end_date:
print("DEBUG: Project has expected start and end dates, marking as scheduled")
doc.is_scheduled = 1
@ -82,11 +87,18 @@ def before_save(doc, method):
def after_save(doc, method):
print("DEBUG: After Save Triggered for Project:", doc.name)
if doc.status == "Completed":
print("DEBUG: Project marked as Completed. Generating and sending final invoice.")
sales_order_status = frappe.get_value("Sales Order", doc.sales_order, "billing_status")
if sales_order_status == "Not Billed":
SalesOrderService.create_sales_invoice_from_sales_order(doc.sales_order)
if doc.ready_to_schedule and doc.service_appointment:
service_apt_ready_to_schedule = frappe.get_value("Service Address 2", doc.service_appointment, "ready_to_schedule")
if not service_apt_ready_to_schedule:
print("DEBUG: Project is ready to schedule, setting Service Appointment to ready to schedule.")
service_apt_doc = frappe.get_doc("Service Address 2", doc.service_appointment)
service_apt_doc.ready_to_schedule = 1
service_apt_doc.save(ignore_permissions=True)
# if doc.status == "Completed":
# sales_order_status = frappe.get_value("Sales Order", doc.sales_order, "billing_status")
# if sales_order_status == "Not Billed":
# SalesOrderService.create_sales_invoice_from_sales_order(doc.sales_order)
if doc.ready_to_schedule:
service_apt_ready_to_schedule = frappe.get_value("Service Address 2", doc.service_appointment, "ready_to_schedule")
if not service_apt_ready_to_schedule:

View File

@ -1,22 +1,23 @@
import frappe
from custom_ui.services.email_service import EmailService
from custom_ui.services import ProjectService
def on_submit(doc, method):
print("DEBUG: On Submit Triggered for Sales Invoice:", doc.name)
# Send invoice email to customer
try:
print("DEBUG: Preparing to send invoice email for", doc.name)
EmailService.send_invoice_email(doc.name)
print("DEBUG: Invoice email sent successfully for", doc.name)
frappe.set_value("Project", doc.project, "invoice_status", "Invoice Sent")
except Exception as e:
print(f"ERROR: Failed to send invoice email: {str(e)}")
# Don't raise the exception - we don't want to block the invoice submission
frappe.log_error(f"Failed to send invoice email for {doc.name}: {str(e)}", "Invoice Email Error")
# # Send invoice email to customer
# try:
# print("DEBUG: Preparing to send invoice email for", doc.name)
# EmailService.send_invoice_email(doc.name)
# print("DEBUG: Invoice email sent successfully for", doc.name)
# frappe.set_value("Project", doc.project, "invoice_status", "Invoice Sent")
# except Exception as e:
# print(f"ERROR: Failed to send invoice email: {str(e)}")
# # Don't raise the exception - we don't want to block the invoice submission
# frappe.log_error(f"Failed to send invoice email for {doc.name}: {str(e)}", "Invoice Email Error")
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Sales Invoice:", doc.name)
# Additional logic can be added here if needed after invoice creation
frappe.set_value("Project", doc.project, "invoice_status", "Invoice Created")

View File

@ -1,5 +1,5 @@
import frappe
from custom_ui.services import DbService, AddressService, ClientService
from custom_ui.services import DbService, AddressService, ClientService, EmailService
def before_save(doc, method):
@ -76,7 +76,6 @@ def after_insert(doc, method):
if doc.requires_half_payment:
try:
print("DEBUG: Sales Order requires half payment, preparing to send down payment email")
from custom_ui.services.email_service import EmailService
# Use EmailService to send the down payment email
EmailService.send_downpayment_email(doc.name)

View File

@ -4,6 +4,13 @@ from custom_ui.services import AddressService, ClientService, TaskService
def before_insert(doc, method):
"""Set values before inserting a Task."""
print("DEBUG: Before Insert Triggered for Task")
if doc.type:
task_type_weight = frappe.get_value("Task Type", doc.type, "weight") or 0
print(f"DEBUG: Setting Task weight to {task_type_weight} based on Task Type {doc.type}")
doc.task_weight = task_type_weight
if doc.status == "Template":
print("DEBUG: Task is a Template, skipping project linking")
return
project_doc = frappe.get_doc("Project", doc.project)
doc.project_template = project_doc.project_template
doc.customer = project_doc.customer
@ -12,6 +19,9 @@ def before_insert(doc, method):
def after_insert(doc, method):
print("DEBUG: After Insert Triggered for Task")
if doc.status == "Template":
print("DEBUG: Task is a Template, skipping linking to Customer and Address")
return
print("DEBUG: Linking Task to Customer and Address")
AddressService.append_link_v2(
doc.custom_property, "tasks", {"task": doc.name, "project_template": doc.project_template }
@ -27,25 +37,32 @@ def after_insert(doc, method):
def before_save(doc, method):
print("DEBUG: Before Save Triggered for Task:", doc.name)
task_type_weight = frappe.get_value("Task Type", doc.type, "weight") or 0
if doc.task_weight != task_type_weight:
print(f"DEBUG: Updating Task weight from {doc.task_weight} to {task_type_weight}")
doc.task_weight = task_type_weight
event = TaskService.determine_event(doc)
if event:
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.fire_task_triggers(task_names, event, current_triggering_dict=doc.as_dict())
if doc.type:
task_type_weight = frappe.get_value("Task Type", doc.type, "weight") or 0
if doc.task_weight != task_type_weight:
print(f"DEBUG: Updating Task weight from {doc.task_weight} to {task_type_weight}")
doc.task_weight = task_type_weight
if doc.status != "Template":
event = TaskService.determine_event(doc)
if event:
task_names = [task.name for task in TaskService.get_tasks_by_project(doc.project)]
TaskService.fire_task_triggers(task_names, event, current_triggering_dict=doc.as_dict())
def after_save(doc, method):
print("DEBUG: After Save Triggered for Task:", doc.name)
if doc.status == "Template":
print("DEBUG: Task is a Template, skipping after save logic")
return
if doc.project and doc.status == "Completed":
print("DEBUG: Task is completed, checking if project has calculated 100% Progress.")
project_doc = frappe.get_doc("Project", doc.project)
if project_doc.percent_complete == 100:
print("DEBUG: Current Project percent_complete:", project_doc.percent_complete)
if project_doc.percent_complete == 100.0:
print("DEBUG: Project percent_complete is 100%, checking if we need to update project status or invoice status.")
project_update_required = False
if project_doc.status == "Completed" and project_doc.customCompletionDate is None:
if project_doc.status == "Completed" and project_doc.custom_completion_date is None:
print("DEBUG: Project is marked as Completed but customCompletionDate is not set, updating customCompletionDate.")
project_doc.customCompletionDate = frappe.utils.nowdate()
project_doc.custom_completion_date = frappe.utils.nowdate()
project_update_required = True
if project_doc.invoice_status == "Not Ready":
project_doc.invoice_status = "Ready to Invoice"

View File

@ -0,0 +1,153 @@
[
{
"company": "Sprinklers Northwest",
"docstatus": 0,
"doctype": "Bid Meeting Note Form",
"fields": [
{
"column": 1,
"conditional_on_field": null,
"conditional_on_value": null,
"default_value": null,
"doctype_for_select": null,
"doctype_label_field": null,
"help_text": "Indicate if a locate is needed for this project.",
"include_options": 0,
"label": "Locate Needed",
"options": null,
"order": 0,
"parent": "SNW Install Bid Meeting Notes",
"parentfield": "fields",
"parenttype": "Bid Meeting Note Form",
"read_only": 0,
"required": 0,
"row": 1,
"type": "Check"
},
{
"column": 2,
"conditional_on_field": null,
"conditional_on_value": null,
"default_value": null,
"doctype_for_select": null,
"doctype_label_field": null,
"help_text": "Indicate if a permit is needed for this project.",
"include_options": 0,
"label": "Permit Needed",
"options": null,
"order": 0,
"parent": "SNW Install Bid Meeting Notes",
"parentfield": "fields",
"parenttype": "Bid Meeting Note Form",
"read_only": 0,
"required": 0,
"row": 1,
"type": "Check"
},
{
"column": 3,
"conditional_on_field": null,
"conditional_on_value": null,
"default_value": null,
"doctype_for_select": null,
"doctype_label_field": null,
"help_text": "Indicate if a backflow test is required after installation.",
"include_options": 0,
"label": "Back Flow Test Required",
"options": null,
"order": 0,
"parent": "SNW Install Bid Meeting Notes",
"parentfield": "fields",
"parenttype": "Bid Meeting Note Form",
"read_only": 0,
"required": 0,
"row": 1,
"type": "Check"
},
{
"column": 1,
"conditional_on_field": null,
"conditional_on_value": null,
"default_value": null,
"doctype_for_select": null,
"doctype_label_field": null,
"help_text": null,
"include_options": 0,
"label": "Machine Access",
"options": null,
"order": 0,
"parent": "SNW Install Bid Meeting Notes",
"parentfield": "fields",
"parenttype": "Bid Meeting Note Form",
"read_only": 0,
"required": 0,
"row": 2,
"type": "Check"
},
{
"column": 2,
"conditional_on_field": "Machine Access",
"conditional_on_value": null,
"default_value": null,
"doctype_for_select": null,
"doctype_label_field": null,
"help_text": null,
"include_options": 1,
"label": "Machines",
"options": "MT, Skip Steer, Excavator-E-50, Link Belt, Tre?, Forks, Auger, Backhoe, Loader, Duzer",
"order": 0,
"parent": "SNW Install Bid Meeting Notes",
"parentfield": "fields",
"parenttype": "Bid Meeting Note Form",
"read_only": 0,
"required": 0,
"row": 2,
"type": "Multi-Select"
},
{
"column": 0,
"conditional_on_field": null,
"conditional_on_value": null,
"default_value": null,
"doctype_for_select": null,
"doctype_label_field": null,
"help_text": null,
"include_options": 0,
"label": "Materials Required",
"options": null,
"order": 0,
"parent": "SNW Install Bid Meeting Notes",
"parentfield": "fields",
"parenttype": "Bid Meeting Note Form",
"read_only": 0,
"required": 0,
"row": 3,
"type": "Check"
},
{
"column": 0,
"conditional_on_field": "Materials Required",
"conditional_on_value": null,
"default_value": null,
"doctype_for_select": "Item",
"doctype_label_field": "itemName",
"help_text": null,
"include_options": 0,
"label": "Materials",
"options": null,
"order": 0,
"parent": "SNW Install Bid Meeting Notes",
"parentfield": "fields",
"parenttype": "Bid Meeting Note Form",
"read_only": 0,
"required": 0,
"row": 4,
"type": "Multi-Select w/ Quantity"
}
],
"modified": "2026-02-18 05:52:37.304228",
"name": "SNW Install Bid Meeting Notes",
"notes": null,
"title": "SNW Install Bid Meeting Notes"
}
]

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
[
{
"docstatus": 0,
"doctype": "Email Template",
"modified": "2026-01-24 07:18:15.939258",
"name": "Customer Invoice",
"response": "<div class=\"ql-editor read-mode\"><p>-- Copywriting goes here --</p><p>-- Customized Payment Link goes here --</p><p>-- In the meantime --</p><p>Invoice number: {{ name }}</p><p>Amount: {{ grand_total }}</p><p>https://sprinklersnorthwest.com/product/bill-pay/</p></div>",
"response_html": null,
"subject": "Your Invoice is Ready",
"use_html": 0
}
]

View File

@ -1,12 +1,13 @@
[
{
"bid_meeting_note_form": null,
"calendar_color": null,
"bid_meeting_note_form": "SNW Install Bid Meeting Notes",
"calendar_color": "#c1dec5",
"company": "Sprinklers Northwest",
"custom__complete_method": "Task Weight",
"docstatus": 0,
"doctype": "Project Template",
"item_groups": null,
"modified": "2026-01-29 09:51:46.681553",
"item_groups": "SNW-I, SNW-S, SNW-LS",
"modified": "2026-02-16 03:59:53.719382",
"name": "SNW Install",
"project_type": "External",
"tasks": [
@ -51,26 +52,20 @@
"parenttype": "Project Template",
"subject": "Curbing",
"task": "TASK-2025-00006"
}
]
},
{
"bid_meeting_note_form": null,
"calendar_color": null,
"company": "Sprinklers Northwest",
"docstatus": 0,
"doctype": "Project Template",
"item_groups": null,
"modified": "2026-01-08 10:36:39.245470",
"name": "Other",
"project_type": null,
"tasks": [
},
{
"parent": "Other",
"parent": "SNW Install",
"parentfield": "tasks",
"parenttype": "Project Template",
"subject": "Primary Job",
"task": "TASK-2025-00004"
"subject": "15-Day QA",
"task": "TASK-2025-00007"
},
{
"parent": "SNW Install",
"parentfield": "tasks",
"parenttype": "Project Template",
"subject": "Permit Close-out",
"task": "TASK-2025-00008"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -56,25 +56,6 @@
"script": "\r\n\r\n\r\n# Helper function to calculate the legal start date (2 business days after the locate date)\r\ndef calculate_legal_start_date(locate_date):\r\n # Add 2 business days to the locate date (exclude weekends)\r\n current_date = getdate(locate_date)\r\n while True:\r\n current_date = add_days(current_date, 1)\r\n if current_date.weekday() < 5: # Weekdays are 0-4 (Monday to Friday)\r\n if current_date.weekday() == 4: # If it's Friday, skip to Monday\r\n current_date = add_days(current_date, 2)\r\n break\r\n return current_date\r\n\r\n# Helper function to calculate the expiration date based on the state\r\ndef calculate_expiration_date(state, locate_date):\r\n expiration_days = 45 if state == 'Washington' else 28\r\n expiration_date = add_days(locate_date, expiration_days)\r\n return expiration_date\r\n \r\n \r\n\r\n # Calculate the legal start date and expiration date\r\n legal_start_date = calculate_legal_start_date(locate_date)\r\n expiration_date = calculate_expiration_date(state, locate_date)\r\n\r\n ",
"script_type": "DocType Event"
},
{
"allow_guest": 0,
"api_method": null,
"cron_format": null,
"disabled": 1,
"docstatus": 0,
"doctype": "Server Script",
"doctype_event": "Before Save",
"enable_rate_limit": 0,
"event_frequency": "All",
"modified": "2024-12-18 11:41:19.792616",
"module": "ERPNext Integrations",
"name": "Automate Job queue creation",
"rate_limit_count": 5,
"rate_limit_seconds": 86400,
"reference_doctype": "Sales Order",
"script": "# Trigger: Before Save\r\nif doc.company == \"Lowe Fencing\":\r\n # Check if a Fencing Job Queue entry already exists for this Sales Order\r\n existing_job = frappe.db.exists(\"Fencing Job Queue\", {\"sales_order\": doc.name})\r\n \r\n if not existing_job:\r\n # Create a new Fencing Job Queue entry\r\n new_job = frappe.get_doc({\r\n \"doctype\": \"Fencing Job Queue\",\r\n \"sales_order\": doc.name,\r\n \"customer\": doc.customer,\r\n \"job_description\": doc.get(\"job_description\", \"No description provided\"), # Modify if necessary\r\n \"status\": \"Pending\"\r\n })\r\n new_job.insert()\r\n frappe.msgprint(f\"Fencing Job Queue entry created for Sales Order {doc.name}.\")\r\n",
"script_type": "DocType Event"
},
{
"allow_guest": 0,
"api_method": null,

View File

@ -1,49 +1,4 @@
[
{
"act_end_date": null,
"act_start_date": null,
"actual_time": 0.0,
"closing_date": null,
"color": null,
"company": "Sprinklers Northwest",
"completed_by": null,
"completed_on": null,
"custom_foreman": "HR-EMP-00014",
"custom_property": null,
"customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
"description": null,
"docstatus": 0,
"doctype": "Task",
"duration": 0,
"exp_end_date": null,
"exp_start_date": null,
"expected_time": 0.0,
"is_group": 0,
"is_milestone": 0,
"is_template": 1,
"issue": null,
"modified": "2025-05-13 06:34:23.580282",
"name": "TASK-2025-00007",
"old_parent": "",
"parent_task": null,
"priority": "Low",
"progress": 0.0,
"project": null,
"project_template": null,
"review_date": null,
"start": 0,
"status": "Template",
"subject": "15-Day QA",
"task_weight": 0.0,
"template_task": null,
"total_billing_amount": 0.0,
"total_costing_amount": 0.0,
"total_expense_claim": 0.0,
"type": "QA"
},
{
"act_end_date": null,
"act_start_date": null,
@ -104,7 +59,7 @@
"department": null,
"depends_on": [],
"depends_on_tasks": "",
"description": null,
"description": "Utility locate request prior to installation start.",
"docstatus": 0,
"doctype": "Task",
"duration": 0,
@ -115,7 +70,7 @@
"is_milestone": 0,
"is_template": 1,
"issue": null,
"modified": "2025-04-24 14:57:03.402721",
"modified": "2026-02-12 12:31:42.805351",
"name": "TASK-2025-00002",
"old_parent": "",
"parent_task": null,
@ -127,12 +82,12 @@
"start": 0,
"status": "Template",
"subject": "811/Locate call in",
"task_weight": 0.0,
"task_weight": 7.0,
"template_task": null,
"total_billing_amount": 0.0,
"total_costing_amount": 0.0,
"total_expense_claim": 0.0,
"type": "Admin"
"type": "811/Locate"
},
{
"act_end_date": null,
@ -323,7 +278,7 @@
"company": "Sprinklers Northwest",
"completed_by": null,
"completed_on": null,
"custom_foreman": null,
"custom_foreman": "HR-EMP-00014",
"custom_property": null,
"customer": null,
"department": null,
@ -340,7 +295,52 @@
"is_milestone": 0,
"is_template": 1,
"issue": null,
"modified": "2025-05-10 05:06:24.653035",
"modified": "2026-01-23 02:29:45.172285",
"name": "TASK-2025-00007",
"old_parent": "",
"parent_task": null,
"priority": "Low",
"progress": 0.0,
"project": null,
"project_template": null,
"review_date": null,
"start": 0,
"status": "Template",
"subject": "15-Day QA",
"task_weight": 0.0,
"template_task": null,
"total_billing_amount": 0.0,
"total_costing_amount": 0.0,
"total_expense_claim": 0.0,
"type": "15 Day Warranty Follow-Up"
},
{
"act_end_date": null,
"act_start_date": null,
"actual_time": 0.0,
"closing_date": null,
"color": null,
"company": "Sprinklers Northwest",
"completed_by": null,
"completed_on": null,
"custom_foreman": null,
"custom_property": null,
"customer": null,
"department": null,
"depends_on": [],
"depends_on_tasks": "",
"description": "Task tracking for the main service job.",
"docstatus": 0,
"doctype": "Task",
"duration": 0,
"exp_end_date": null,
"exp_start_date": null,
"expected_time": 0.0,
"is_group": 0,
"is_milestone": 0,
"is_template": 1,
"issue": null,
"modified": "2026-02-12 12:32:42.996899",
"name": "TASK-2025-00004",
"old_parent": "",
"parent_task": null,
@ -352,11 +352,11 @@
"start": 0,
"status": "Template",
"subject": "Primary Job",
"task_weight": 0.0,
"task_weight": 25.0,
"template_task": null,
"total_billing_amount": 0.0,
"total_costing_amount": 0.0,
"total_expense_claim": 0.0,
"type": "Labor"
"type": "Main Job"
}
]

View File

@ -0,0 +1,527 @@
[
{
"base_date": "Start",
"calculate_from": "Project",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Collect half down payment on project creation.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 12:59:50.869932",
"name": "1/2 Down Payment",
"no_due_date": 0,
"offset_days": 0,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "1/2 Down Payment",
"trigger": "Created",
"triggering_doctype": "Project",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Start",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Stage machinery one day before installation start.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:00:47.389623",
"name": "Machine Staging",
"no_due_date": 0,
"offset_days": 1,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Machine Staging",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Send final invoice within 5 days of job completion.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:01:14.573788",
"name": "Final Invoice",
"no_due_date": 0,
"offset_days": 5,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Final Invoice",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Backflow test after job completion if quoted.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:01:34.781732",
"name": "Backflow Test",
"no_due_date": 0,
"offset_days": 0,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Backflow Test",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Schedule permit inspection 5 days after completion.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:01:44.550215",
"name": "Schedule Permit Inspection",
"no_due_date": 0,
"offset_days": 5,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Schedule Permit Inspection",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "30-day warranty check after completion.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:02:08.165742",
"name": "30 Day Warranty Check",
"no_due_date": 0,
"offset_days": 30,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "30 Day Warranty Check",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Payment reminder sent 30 days after completion.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:02:21.758218",
"name": "30 Day Payment Reminder",
"no_due_date": 0,
"offset_days": 30,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "30 Day Payment Reminder",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Late payment notification at 60 days post completion.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:02:33.693892",
"name": "60 Day Late Payment Notice",
"no_due_date": 0,
"offset_days": 60,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "60 Day Late Payment Notice",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Lien notice if payment is still late after 80 days.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:02:42.742371",
"name": "80 Day Lien Notice",
"no_due_date": 0,
"offset_days": 80,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "80 Day Lien Notice",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "One-year warranty call or walk-through.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:02:51.999530",
"name": "365 Day Warranty Call / Walk",
"no_due_date": 0,
"offset_days": 365,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "365 Day Warranty Call / Walk",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Start",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Locate utilities for digging",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:00:26.110307",
"name": "Locate",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Locate",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Start",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Utility locate request prior to installation start.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-02-08 01:46:40.122526",
"name": "811/Locate",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "811/Locate",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
"weight": 7.0,
"work_type": "Admin"
},
{
"base_date": "Project Start",
"calculate_from": "Service Appointment",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": null,
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2024-12-20 16:48:23.131743",
"name": "Topshot",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 1,
"task_type_calculate_from": null,
"title": null,
"trigger": null,
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Completion",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "15-day warranty follow-up after completion.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-01-26 13:01:56.949793",
"name": "15 Day Warranty Follow-Up",
"no_due_date": 0,
"offset_days": 15,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "15 Day Warranty Follow-Up",
"trigger": "Completed",
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Project Start",
"calculate_from": "Service Appointment",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": null,
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2024-12-17 14:18:26.429960",
"name": "Subcontractor",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 1,
"task_type_calculate_from": null,
"title": null,
"trigger": null,
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "End",
"calculate_from": "Service Address 2",
"custom_completion_trigger": "Completed",
"custom_completion_trigger_doctype": "Service Address 2",
"custom_target_percent": 0,
"days": 0,
"description": "Task tracking for the main service job.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-02-08 02:35:42.343305",
"name": "Main Job",
"no_due_date": 0,
"offset_days": 0,
"offset_direction": "After",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Main Job",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
"weight": 25.0,
"work_type": "Labor"
},
{
"base_date": "Start",
"calculate_from": "Service Address 2",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": "Permits required prior to installation start.",
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2026-02-08 01:48:15.012387",
"name": "Permit",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 0,
"task_type_calculate_from": null,
"title": "Permit",
"trigger": "Scheduled",
"triggering_doctype": "Service Address 2",
"weight": 7.0,
"work_type": "Admin"
},
{
"base_date": "Project Start",
"calculate_from": "Service Appointment",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": null,
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2025-05-10 03:44:23.619864",
"name": "QA",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 1,
"task_type_calculate_from": null,
"title": null,
"trigger": null,
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Project Start",
"calculate_from": "Service Appointment",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": null,
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2025-04-24 14:56:51.838933",
"name": "Admin",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 1,
"task_type_calculate_from": null,
"title": null,
"trigger": null,
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Project Start",
"calculate_from": "Service Appointment",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": null,
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2024-04-16 11:55:50.883266",
"name": "Scheduling",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 1,
"task_type_calculate_from": null,
"title": null,
"trigger": null,
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
},
{
"base_date": "Project Start",
"calculate_from": "Service Appointment",
"custom_completion_trigger": null,
"custom_completion_trigger_doctype": null,
"custom_target_percent": 0,
"days": 0,
"description": null,
"docstatus": 0,
"doctype": "Task Type",
"logic_key": null,
"modified": "2024-04-19 12:51:25.954969",
"name": "Labor",
"no_due_date": 0,
"offset_days": 7,
"offset_direction": "Before",
"skip_holidays": 1,
"skip_weekends": 1,
"task_type_calculate_from": null,
"title": null,
"trigger": null,
"triggering_doctype": "Service Address 2",
"weight": 0.0,
"work_type": "Admin"
}
]

View File

@ -9,7 +9,8 @@ app_license = "mit"
after_install = "custom_ui.install.after_install"
after_migrate = "custom_ui.install.after_migrate"
after_uninstall = "custom_ui.install.after_uninstall"
after_build = "custom_ui.commands.build_frontend"
# after_uninstall = "custom_ui.install.after_uninstall"
# on_session_creation = "custom_ui.utils.on_login_redirect"
# on_login = "custom_ui.utils.on_login_redirect"
page_js = {
@ -196,7 +197,8 @@ doc_events = {
"Task": {
"before_insert": "custom_ui.events.task.before_insert",
"after_insert": "custom_ui.events.task.after_insert",
"before_save": "custom_ui.events.task.before_save"
"before_save": "custom_ui.events.task.before_save",
"on_update": "custom_ui.events.task.after_save"
},
"Bid Meeting Note Form": {
"after_insert": "custom_ui.events.general.attach_bid_note_form_to_project_template"
@ -210,28 +212,52 @@ doc_events = {
"on_submit": "custom_ui.events.payments.on_submit"
},
"Sales Invoice": {
"on_submit": "custom_ui.events.sales_invoice.on_submit"
"on_submit": "custom_ui.events.sales_invoice.on_submit",
"after_insert": "custom_ui.events.sales_invoice.after_insert"
}
}
# custom_ui/hooks.py (or a separate utils.py in the app)
def remove_fencing_job_queue_links(doc):
"""Remove links to the deleted 'Fencing Job Queue' doctype"""
links = doc.get("links", [])
doc["links"] = [l for l in links if l.get("link_doctype") != "Fencing Job Queue"]
fixtures = [
{
"dt": "Custom Field",
"filters": [["module", "in", ["Custom UI"]]]
},
{
"dt": "Email Template",
"filters": [
["name", "in", ["Customer Invoice"]]
]
"filters": [["module", "in", ["Custom UI"]], ["dt", "not in", ["Service Appointment"]]]
},
# {
# "dt": "Email Template",
# "filters": [
# ["name", "in", ["Customer Invoice"]]
# ]
# },
# {
# "dt": "DocType",
# "filters": [
# ["custom", "=", 1],
# ["name", "!=", "Fencing Job Queue"],
# ["name", "!=", "Locate Log"], # <-- skip the deleted/removed doctype
# ]
# },
{ "dt": "Task Type" },
{
"dt": "Task",
"filters": [["status", "=", "Template"]]
},
{
"dt": "Project Template"
"filters": [
["is_template", "=", 1]
]
},
{ "dt": "Project Template" },
{ "dt": "Bid Meeting Note Form" },
# { "dt": "Holiday List" },
# These don't have reliable flags → export all
{"dt": "Custom Field"},
{"dt": "Property Setter",
"filters": [["name", "not in", ["Service Appointment-service_address", "Service Appointment-customer", "Service Appointment-project", "Service Appointment-project_template", "Service Appointment"]],]},
{"dt": "Client Script"},
{"dt": "Server Script"},
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
import frappe
class DBRestoreService:
@staticmethod
def massage_customer_address_contact_links():
"""Fixes the links between Customer, Address, and Contacts from legacy data that may have been imported without proper linking."""
# use emojis in print statments to make it more fun and visually distinct in the logs
print("DEBUG: 🛠️ Starting to massage customer, address, and contact links")
all_addresses = frappe.get_all("Address", pluck="name")
print(f"DEBUG: Found {len(all_addresses)} addresses to process")
all_customers = frappe.get_all("Customer", pluck="name")
print(f"DEBUG: Found {len(all_customers)} customers to process")
all_leads = frappe.get_all("Lead", pluck="name")
print(f"DEBUG: Found {len(all_leads)} leads to process")
all_contacts = frappe.get_all("Contact", pluck="name")
print(f"DEBUG: Found {len(all_contacts)} contacts to process")
# query all customer doctypes that don't have an empty array for custom_select_address. This field is a child table so get_all cannot be used. We need to use a custom sql query
# the child table is a doctype called "Custom "
# print(f"DEBUG: Found {len(customers_with_addresses)} customers with addresses to process")

View File

@ -8,7 +8,8 @@ class SalesOrderService:
def create_sales_invoice_from_sales_order(sales_order_name):
try:
sales_order_doc = frappe.get_doc("Sales Order", sales_order_name)
sales_invoice = make_sales_invoice(sales_order_doc.name)
sales_invoice = make_sales_invoice(sales_order_doc.name, ignore_permissions=True)
print("DEBUG: Sales Invoice created from Sales Order:", sales_invoice.name)
sales_invoice.project = sales_order_doc.project
sales_invoice.posting_date = today()
sales_invoice.due_date = today()
@ -21,8 +22,9 @@ class SalesOrderService:
sales_invoice.calculate_taxes_and_totals()
sales_invoice.insert()
sales_invoice.submit()
return sales_invoice.name
print("DEBUG: Sales Invoice: ", sales_invoice.as_dict())
# sales_invoice.submit()
return sales_invoice
except Exception as e:
print("ERROR creating Sales Invoice from Sales Order:", str(e))
return None

View File

@ -1,5 +1,6 @@
import frappe
from custom_ui.services import ContactService, AddressService, ClientService, DbService
import json
class ServiceAppointmentService:
@ -23,13 +24,17 @@ class ServiceAppointmentService:
service_appointment["service_address"] = AddressService.get_or_throw(service_appointment["service_address"]).as_dict()
service_appointment["customer"] = ClientService.get_client_or_throw(service_appointment["customer"]).as_dict()
service_appointment["project"] = DbService.get_or_throw("Project", service_appointment["project"]).as_dict()
service_appointment["color"] = frappe.get_value("Project Template", service_appointment["project_template"], "calendar_color")
return service_appointment
@staticmethod
def update_scheduled_dates(service_appointment_name: str, crew_lead_name: str,start_date, end_date, start_time=None, end_time=None):
def update_scheduled_dates(service_appointment_name: str, crew_lead_name: str = None, start_date=None, end_date=None, start_time=None, end_time=None, skip_days=[]):
"""Update the scheduled start and end dates of a Service Appointment."""
print(f"DEBUG: Updating scheduled dates for Service Appointment {service_appointment_name} to start: {start_date}, end: {end_date}")
if isinstance(skip_days, str):
skip_days = json.loads(skip_days)
print(f"DEBUG: Parsed skip_days: {skip_days}")
service_appointment = DbService.get_or_throw("Service Address 2", service_appointment_name)
service_appointment.expected_start_date = start_date
service_appointment.expected_end_date = end_date
@ -38,6 +43,22 @@ class ServiceAppointmentService:
service_appointment.expected_start_time = start_time
if end_time:
service_appointment.expected_end_time = end_time
if skip_days:
print(f"DEBUG: Updating skip days for Service Appointment {service_appointment_name}. Current skip days: {[skip_day.date for skip_day in service_appointment.skip_days]}, New skip days: {skip_days}")
# Compare skip_days with the current skip_days and remove/add as needed
current_skip_days = [skip_day.date for skip_day in service_appointment.skip_days]
# Remove skip days that are no longer needed
for skip_day in current_skip_days:
if skip_day not in skip_days:
skip_day_doc = service_appointment.skip_days.find(lambda d: d.date == skip_day)
if skip_day_doc:
service_appointment.skip_days.remove(skip_day_doc.name)
print(f"DEBUG: Removed skip day {skip_day} from Service Appointment {service_appointment_name}")
# Add new skip days
for skip_day in skip_days:
if skip_day not in current_skip_days:
service_appointment.append("skip_days", {"date": skip_day["date"]})
print(f"DEBUG: Added new skip day {skip_day} for Service Appointment {service_appointment_name}")
service_appointment.save()
print(f"DEBUG: Updated scheduled dates for Service Appointment {service_appointment_name}")
return service_appointment

View File

@ -56,14 +56,17 @@ class StripeService:
Returns:
stripe.checkout.Session object
"""
print(f"DEBUG: Creating Stripe Checkout Session for company with details - Company: {company}, Amount: {amount}, Service: {service}, Order Num: {order_num}, Currency: {currency}, For Advance Payment: {for_advance_payment}, Sales Invoice: {sales_invoice}")
stripe.api_key = StripeService.get_api_key(company)
# Determine payment description
if for_advance_payment:
description = f"Advance payment for {company}{' - ' + service if service else ''}"
else:
print("DEBUG: Creating description for full payment")
description = f"Invoice payment for {company}{' - ' + service if service else ''}"
if sales_invoice:
print(f"DEBUG: Sales Invoice provided for full payment: {sales_invoice}")
description = f"Invoice {sales_invoice} - {company}"
# Use custom line items if provided and not an advance payment, otherwise create default line item
@ -88,7 +91,7 @@ class StripeService:
if for_advance_payment:
metadata["sales_order"] = order_num
else:
metadata["sales_invoice"] = sales_invoice or order_num
metadata["sales_invoice"] = sales_invoice if sales_invoice else order_num
if sales_invoice:
# Check if there's a related sales order
invoice_doc = frappe.get_doc("Sales Invoice", sales_invoice)

View File

@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice Already Paid</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Roboto', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.message-container {
text-align: center;
background-color: #fff;
padding: 50px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 90%;
position: relative;
}
.message-icon {
font-size: 5rem;
color: #00b894;
margin-bottom: 30px;
animation: checkmarkAnimation 1.5s ease-out;
}
@keyframes checkmarkAnimation {
0% {
transform: scale(0) rotate(-180deg);
opacity: 0;
}
50% {
transform: scale(1.2) rotate(0deg);
opacity: 1;
}
70% {
transform: scale(0.9) rotate(0deg);
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
.message-title {
font-size: 2.5rem;
margin-bottom: 20px;
color: #333;
font-weight: 700;
}
.message-text {
font-size: 1.2rem;
line-height: 1.6;
margin-bottom: 20px;
color: #666;
font-weight: 400;
}
.invoice-number {
background-color: #e3f2fd;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #2196f3;
font-size: 1.1rem;
font-weight: 600;
color: #1976d2;
}
.contact-section {
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin-top: 30px;
text-align: left;
}
.contact-section h3 {
margin: 0 0 10px 0;
color: #333;
font-size: 1.3rem;
font-weight: 600;
}
.contact-section > p {
margin: 0 0 15px 0;
color: #666;
font-size: 0.95rem;
}
.contact-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.contact-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 0;
border-bottom: 1px solid #e9ecef;
}
.contact-row:last-child {
border-bottom: none;
}
.contact-label {
font-weight: 500;
color: #495057;
flex-shrink: 0;
}
.contact-value {
font-weight: 400;
color: #6c757d;
text-align: right;
}
.contact-value a {
color: #007bff;
text-decoration: none;
}
.contact-value a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="message-container">
<div class="message-icon"></div>
<h1 class="message-title">Invoice Already Paid</h1>
<p class="message-text">This invoice has already been paid in full.</p>
{% if invoice_number %}
<div class="invoice-number">
Invoice #{{ invoice_number }}
</div>
{% endif %}
{% if company %}
<div class="contact-section">
<h3>Have Questions?</h3>
<p>We're here to help! Contact us if you need assistance.</p>
<div class="contact-details">
{% if company.company_name %}
<div class="contact-row">
<span class="contact-label">Company:</span>
<span class="contact-value">{{ company.company_name }}</span>
</div>
{% endif %}
{% if company.phone_no %}
<div class="contact-row">
<span class="contact-label">Phone:</span>
<span class="contact-value">{{ company.phone_no }}</span>
</div>
{% endif %}
{% if company.email %}
<div class="contact-row">
<span class="contact-label">Email:</span>
<span class="contact-value"><a href="mailto:{{ company.email }}">{{ company.email }}</a></span>
</div>
{% endif %}
{% if company.website %}
<div class="contact-row">
<span class="contact-label">Website:</span>
<span class="contact-value"><a href="{{ company.website }}" target="_blank">{{ company.website }}</a></span>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</body>
</html>

View File

@ -5,4 +5,5 @@ services:
ports:
- "8025:8025" # MailHog web UI
- "1025:1025" # SMTP server
restart: unless-stopped
restart: unless-stopped

View File

@ -46,6 +46,7 @@ const FRAPPE_GET_INVOICES_METHOD = "custom_ui.api.db.invoices.get_invoice_table_
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
const FRAPPE_GET_INVOICES_LATE_METHOD = "custom_ui.api.db.invoices.get_invoices_late_count";
const FRAPPE_CREATE_INVOICE_FOR_JOB = "custom_ui.api.db.invoices.create_invoice_for_job";
const FRAPPE_SUBMIT_AND_SEND_INVOICE_METHOD = "custom_ui.api.db.invoices.submit_and_send_invoice";
// Warranty methods
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
// On-Site Meeting methods
@ -55,7 +56,10 @@ const FRAPPE_GET_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.g
const FRAPPE_GET_ONSITE_MEETINGS_METHOD = "custom_ui.api.db.bid_meetings.get_bid_meetings";
const FRAPPE_SUBMIT_BID_MEETING_NOTE_FORM_METHOD = "custom_ui.api.db.bid_meetings.submit_bid_meeting_note_form";
// Address methods
const FRAPPE_CHECK_ADDRESSES_EXIST_METHOD = "custom_ui.api.db.addresses.check_addresses_exist";
const FRAPPE_GET_ADDRESSES_METHOD = "custom_ui.api.db.addresses.get_addresses";
// Contact methods
const FRAPPE_CHECK_CONTACTS_EXIST_METHOD = "custom_ui.api.db.contacts.check_contacts_exist";
// 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";
@ -63,6 +67,8 @@ const FRAPPE_GET_CLIENT_TABLE_DATA_METHOD = "custom_ui.api.db.clients.get_client
const FRAPPE_GET_CLIENT_TABLE_DATA_V2_METHOD = "custom_ui.api.db.clients.get_clients_table_data_v2";
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";
const FRAPPE_CHECK_CLIENT_EXISTS_METHOD = "custom_ui.api.db.clients.check_client_exists";
const FRAPPE_ADD_ADDRESSES_CONTACTS_METHOD = "custom_ui.api.db.clients.add_addresses_contacts";
// Employee methods
const FRAPPE_GET_EMPLOYEES_METHOD = "custom_ui.api.db.employees.get_employees";
const FRAPPE_GET_EMPLOYEES_ORGANIZED_METHOD = "custom_ui.api.db.employees.get_employees_organized";
@ -76,7 +82,7 @@ const FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD = "custom_ui.api.
const FRAPPE_UPDATE_SERVICE_APPOINTMENT_STATUS_METHOD = "custom_ui.api.db.service_appointments.update_service_appointment_status";
class Api {
// ============================================================================
// CORE REQUEST METHOPD
// CORE REQUEST METHOD
// ============================================================================
static async request(frappeMethod, args = {}) {
@ -104,6 +110,10 @@ class Api {
// CLIENT METHODS
// ============================================================================
static async checkCustomerExists(clientName) {
return await this.request(FRAPPE_CHECK_CLIENT_EXISTS_METHOD, { clientName });
}
static async getClientStatusCounts(params = {}) {
return await this.request(FRAPPE_GET_CLIENT_STATUS_COUNTS_METHOD, params);
}
@ -174,6 +184,10 @@ class Api {
return result;
}
static async addAddressesAndContacts(clientName, companyName, addresses = [], contacts = []) {
return await this.request(FRAPPE_ADD_ADDRESSES_CONTACTS_METHOD, { clientName, companyName, addresses, contacts });
}
// ============================================================================
// ON-SITE MEETING METHODS
// ============================================================================
@ -467,12 +481,13 @@ class Api {
return await this.request(FRAPPE_GET_SERVICE_APPOINTMENTS_METHOD, { companies, filters });
}
static async updateServiceAppointmentScheduledDates(serviceAppointmentName, startDate, endDate, crewLeadName, startTime = null, endTime = null) {
static async updateServiceAppointmentScheduledDates(serviceAppointmentName, startDate = null, endDate = null, crewLeadName = null, skippedDays = [],startTime = null, endTime = null) {
return await this.request(FRAPPE_UPDATE_SERVICE_APPOINTMENT_SCHEDULED_DATES_METHOD, {
serviceAppointmentName,
startDate,
endDate,
crewLeadName,
skippedDays,
startTime,
endTime
})
@ -574,8 +589,12 @@ class Api {
}
static async sendInvoice(invoiceName) {
return await this.request(FRAPPE_SUBMIT_AND_SEND_INVOICE_METHOD, { invoiceName });
}
static async createInvoice(invoiceData) {
const payload = DataUtils.toSnakeCaseObject(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;
@ -639,10 +658,22 @@ class Api {
return result;
}
// ============================================================================
// CONTACT METHODS
// ============================================================================
static async checkContactsExist(contacts) {
return await this.request(FRAPPE_CHECK_CONTACTS_EXIST_METHOD, { contacts });
}
// ============================================================================
// ADDRESS METHODS
// ============================================================================
static async checkAddressesExist(addresses) {
return await this.request(FRAPPE_CHECK_ADDRESSES_EXIST_METHOD, { addresses });
}
static async getAddressByFullAddress(fullAddress) {
return await this.request("custom_ui.api.db.addresses.get_address_by_full_address", {
full_address: fullAddress,

View File

@ -101,11 +101,13 @@
dragOverSlot?.date === day.date &&
dragOverSlot?.time === timeSlot.time,
weekend: day.isWeekend,
'has-multiple-meetings': getMeetingsForTimeSlot(day.date, timeSlot.time).length > 1,
}"
@click="selectTimeSlot(day.date, timeSlot.time)"
@dragover="handleDragOver($event, day.date, timeSlot.time)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, day.date, timeSlot.time)"
:ref="el => dayCells[`${day.date}-${timeSlot.time}`] = el"
>
<!-- Meetings in this time slot -->
<div
@ -119,7 +121,7 @@
:draggable="meeting.status !== 'Completed'"
@dragstart="handleMeetingDragStart($event, meeting)"
@dragend="handleDragEnd($event)"
@click.stop="showMeetingDetails(meeting)"
@drop="handleDrop($event, day.date, timeSlot.time)"
>
<div class="event-time">
{{ formatTimeDisplay(meeting.scheduledTime) }}
@ -135,6 +137,24 @@
</div>
</div>
<!-- Slot Popup for Multiple Meetings -->
<div v-if="showSlotPopup" class="slot-popup-overlay" @click="showSlotPopup = false">
<div class="slot-popup" :style="popupStyle">
<div class="popup-header">
{{ formatTimeDisplay(popupPosition.time) }} - {{ formatDate(popupPosition.date) }}
</div>
<div
v-for="meeting in popupMeetings"
:key="meeting.id"
class="popup-meeting-item"
@click.stop="showMeetingDetails(meeting); showSlotPopup = false"
>
<div class="meeting-address">{{ meeting.address?.fullAddress || meeting.address }}</div>
<div class="meeting-client">{{ meeting.client }}</div>
</div>
</div>
</div>
<!-- Right Sidebar for Unscheduled Meetings -->
<div class="sidebar sidebar-right" :class="{ collapsed: isSidebarCollapsed }">
<div class="sidebar-header">
@ -274,6 +294,13 @@ const draggedMeeting = ref(null);
const originalMeetingForReschedule = ref(null); // Store original meeting data for reschedules
const isUnscheduledDragOver = ref(false);
// Slot popup state
const showSlotPopup = ref(false);
const popupMeetings = ref([]);
const popupPosition = ref({});
const popupStyle = ref({});
const dayCells = ref({});
// Sidebar state
const isSidebarCollapsed = ref(false);
@ -313,7 +340,7 @@ const weekDays = computed(() => {
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
days.push({
date: day.toISOString().split("T")[0],
date: `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
dayName: day.toLocaleDateString("en-US", { weekday: "short" }),
displayDate: day.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
isToday,
@ -472,7 +499,25 @@ const getAddressText = (address) => {
};
const selectTimeSlot = (date, time) => {
console.log("Selected time slot:", date, time);
const meetingsInSlot = getMeetingsForTimeSlot(date, time);
if (meetingsInSlot.length === 1) {
showMeetingDetails(meetingsInSlot[0]);
} else if (meetingsInSlot.length > 1) {
// Show popup with list of meetings
const cellKey = `${date}-${time}`;
const cell = dayCells.value[cellKey];
if (cell) {
const rect = cell.getBoundingClientRect();
popupStyle.value = {
top: `${rect.bottom + window.scrollY + 5}px`,
left: `${rect.left + window.scrollX}px`,
};
}
popupMeetings.value = meetingsInSlot;
popupPosition.value = { date, time };
showSlotPopup.value = true;
}
// If no meetings, do nothing for now
};
const showMeetingDetails = (meeting) => {
@ -964,15 +1009,8 @@ const loadWeekMeetings = async () => {
meetings.value = apiResult
.map((meeting) => {
// Extract date and time from startTime
const startDateTime = meeting.startTime
? new Date(meeting.startTime)
: null;
const date = startDateTime
? startDateTime.toISOString().split("T")[0]
: null;
const time = startDateTime
? `${startDateTime.getHours().toString().padStart(2, "0")}:${startDateTime.getMinutes().toString().padStart(2, "0")}`
: null;
const date = meeting.startTime ? meeting.startTime.split(' ')[0] : null;
const time = meeting.startTime ? meeting.startTime.split(' ')[1].substring(0,5) : null;
// Return the full meeting object with calendar-specific fields added
return {
@ -1108,8 +1146,8 @@ const findAndDisplayMeetingByName = async () => {
// Parse the start time to get date and time
const startDateTime = new Date(meetingData.startTime);
const meetingDate = startDateTime.toISOString().split("T")[0];
const meetingTime = `${startDateTime.getHours().toString().padStart(2, "0")}:${startDateTime.getMinutes().toString().padStart(2, "0")}`;
const meetingDate = meetingData.startTime.split(' ')[0];
const meetingTime = meetingData.startTime.split(' ')[1].substring(0,5);
// Navigate to the week containing this meeting
currentWeekStart.value = new Date(
@ -1636,24 +1674,60 @@ watch(
font-size: 0.9em;
}
/* Responsive design */
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
}
.day-column.has-multiple-meetings {
border: 2px solid #ff9800;
background-color: #fff3e0;
}
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #e0e0e0;
padding-right: 0;
padding-bottom: 20px;
max-height: 200px;
}
.slot-popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
background: transparent;
}
.calendar-header-row,
.time-row {
grid-template-columns: 60px repeat(7, 1fr);
}
.slot-popup {
position: absolute;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
padding: 8px;
min-width: 250px;
max-width: 300px;
z-index: 1001;
}
.popup-header {
font-weight: bold;
margin-bottom: 8px;
font-size: 0.9em;
}
.popup-meeting-item {
padding: 4px 0;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.popup-meeting-item:hover {
background-color: #f5f5f5;
}
.popup-meeting-item:last-child {
border-bottom: none;
}
.meeting-address {
font-size: 0.8em;
font-weight: 500;
}
.meeting-client {
font-size: 0.7em;
color: #666;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
v-for="(address, index) in localFormData.addresses"
:key="index"
class="address-item"
:class="{ 'existing-highlight': isExistingAddress(address) }"
>
<div class="address-header">
<div class="address-title">
@ -169,6 +170,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
existingAddresses: {
type: Array,
default: () => [],
},
contactOptions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:formData"]);
@ -200,7 +209,7 @@ const localFormData = computed({
const contactOptions = computed(() => {
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
return [];
return props.contactOptions;
}
return localFormData.value.contacts.map((contact, index) => ({
label: `${contact.firstName || ""} ${contact.lastName || ""}`.trim() || `Contact ${index + 1}`,
@ -226,28 +235,6 @@ onMounted(() => {
];
}
});
const addAddress = () => {
localFormData.value.addresses.push({
addressLine1: "",
addressLine2: "",
isBillingAddress: false,
isServiceAddress: true,
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;
@ -310,48 +297,53 @@ const handleServiceChange = (selectedIndex) => {
}
};
const getFullAddress = (address) => {
return `${address.addressLine1 || ''} ${address.addressLine2 || ''} ${address.city || ''} ${address.state || ''} ${address.pincode || ''}`.trim().replace(/\s+/g, ' ');
};
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) {
const value = event.target.value;
localFormData.value.addresses[index].pincode = value;
if (value.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"]}`);
const zipInfo = await Api.getCityStateByZip(value);
console.log("Zipcode lookup result:", zipInfo);
if (zipInfo && zipInfo.length > 0) {
localFormData.value.addresses[index].city = zipInfo[0].city;
localFormData.value.addresses[index].state = zipInfo[0].state;
localFormData.value.addresses[index].zipcodeLookupDisabled = false;
} else {
throw new Error("No data returned");
}
} 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.",
);
console.error("Zipcode lookup failed:", error);
localFormData.value.addresses[index].zipcodeLookupDisabled = true;
localFormData.value.addresses[index].city = '';
localFormData.value.addresses[index].state = '';
notificationStore.addError("Invalid zipcode or lookup failed");
}
} else {
localFormData.value.addresses[index].zipcodeLookupDisabled = true;
localFormData.value.addresses[index].city = '';
localFormData.value.addresses[index].state = '';
}
};
const normalizeAddressString = (s = '') => {
return (s || '')
.toString()
.replace(/,/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
};
const isExistingAddress = (address) => {
const fullAddr = getFullAddress(address);
const normFull = normalizeAddressString(fullAddr);
if (!props.existingAddresses || props.existingAddresses.length === 0) return false;
return props.existingAddresses.some((ea) => normalizeAddressString(ea) === normFull);
};
</script>
<style scoped>
@ -479,13 +471,8 @@ const handleZipcodeInput = async (index, event) => {
cursor: pointer;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
.form-grid {
grid-template-columns: 1fr;
}
.address-item.existing-highlight {
border-color: var(--red-500);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
</style>

View File

@ -28,13 +28,12 @@
class="w-full"
/>
<Button
label="Check"
size="small"
icon="pi pi-check-circle"
class="check-btn"
@click="checkCustomerExists"
:disabled="isSubmitting"
/>
>
<i class="pi pi-check-circle"></i> Check
</Button>
<Button
v-if="!isNewClient && !isEditMode"
@click="searchCustomers"
@ -52,7 +51,7 @@
<Dialog
:visible="showCustomerSearchModal"
@update:visible="showCustomerSearchModal = $event"
header="Select Customer"
header="Potential Matches"
:modal="true"
class="search-dialog"
>
@ -61,15 +60,18 @@
<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 v-else>
<p class="potential-matches-message">Here are potential matches for your search. Click on a customer to view their details.</p>
<div class="results-list">
<div
v-for="(customerName, index) in customerSearchResults"
:key="index"
class="result-item"
@click="router.push(`/client?client=${encodeURIComponent(customerName)}`)"
>
<strong>{{ customerName }}</strong>
<i class="pi pi-chevron-right"></i>
</div>
</div>
</div>
</div>
@ -77,12 +79,30 @@
<Button label="Cancel" severity="secondary" @click="showCustomerSearchModal = false" />
</template>
</Dialog>
<!-- Exact Match Modal -->
<Dialog
:visible="showExactMatchModal"
@update:visible="showExactMatchModal = $event"
header="Customer Already Exists"
:modal="true"
>
<p>The customer "{{ exactMatchClient }}" already exists.</p>
<template #footer>
<Button label="Cancel" severity="secondary" @click="showExactMatchModal = false" />
<Button label="Go to Customer" @click="goToCustomer(exactMatchClient)" />
</template>
</Dialog>
</div>
</template><script setup>
</template>
<script setup>
import { ref, watch, computed } from "vue";
import { useRouter } from "vue-router";
import InputText from "primevue/inputtext";
import Select from "primevue/select";
import Dialog from "primevue/dialog";
import Button from "primevue/button";
import Api from "../../api";
import { useNotificationStore } from "../../stores/notifications-primevue";
@ -104,6 +124,7 @@ const props = defineProps({
const emit = defineEmits(["update:formData", "newClientToggle", "customerSelected"]);
const notificationStore = useNotificationStore();
const router = useRouter();
const localFormData = computed({
get: () => props.formData,
@ -114,6 +135,8 @@ const isNewClient = ref(true);
const showCustomerSearchModal = ref(false);
const customerSearchResults = ref([]);
const customerTypeOptions = ["Individual", "Partnership", "Company"];
const showExactMatchModal = ref(false);
const exactMatchClient = ref(null);
const mapContactsFromClient = (contacts = []) => {
if (!Array.isArray(contacts) || contacts.length === 0) {
@ -191,58 +214,26 @@ const checkCustomerExists = async () => {
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);
const result = await Api.checkCustomerExists(searchTerm);
if (result.exactMatch) {
exactMatchClient.value = result.exactMatch;
showExactMatchModal.value = true;
} else if (result.potentialMatches && result.potentialMatches.length > 0) {
customerSearchResults.value = result.potentialMatches;
showCustomerSearchModal.value = true;
} else {
notificationStore.addError(message);
notificationStore.addInfo("No matching customers found.");
}
} catch (error) {
console.error("Error checking customer exists:", error);
notificationStore.addError("Failed to check customer existence. Please try again.");
}
};
const selectCustomer = async (customerName) => {
try {
// Fetch full customer data
const clientData = await Api.getClient(customerName);
localFormData.value.customerName = clientData.customerName;
localFormData.value.customerType = clientData.customerType;
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.");
}
const goToCustomer = (clientName) => {
router.push(`/client?client=${encodeURIComponent(clientName)}`);
showExactMatchModal.value = false;
};
defineExpose({
@ -330,7 +321,8 @@ defineExpose({
}
.search-dialog {
max-width: 500px;
max-width: 800px;
width: 90vw;
}
.search-results {
@ -374,15 +366,14 @@ defineExpose({
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;
.potential-matches-message {
margin-bottom: 1rem;
padding: 0.75rem;
background-color: var(--surface-section);
border: 1px solid var(--surface-border);
border-radius: 4px;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.iconoir-btn {
@ -412,6 +403,9 @@ defineExpose({
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
height: 100%;
background-color: var(--primary-color);
color: white;
border: none;
}
.search-btn {

View File

@ -9,6 +9,7 @@
v-for="(contact, index) in localFormData.contacts"
:key="index"
class="contact-item"
:class="{ 'existing-highlight': isExistingContact(contact) }"
>
<div class="contact-header">
<div class="contact-title">
@ -139,29 +140,33 @@ const props = defineProps({
type: Boolean,
default: false,
},
existingContacts: {
type: Array,
default: () => [],
},
});
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;
},
get: () => props.formData,
set: (value) => emit("update:formData", value),
});
// Ensure at least one contact always exists
if (!localFormData.value.contacts || localFormData.value.contacts.length === 0) {
localFormData.value.contacts = [
{
firstName: "",
lastName: "",
phoneNumber: "",
email: "",
contactRole: "",
isPrimary: true,
},
];
}
const roleOptions = ref([
{ label: "Owner", value: "Owner" },
{ label: "Property Manager", value: "Property Manager" },
@ -278,7 +283,14 @@ const handlePhoneKeydown = (event, index) => {
}
};
defineExpose({});
const getFullName = (contact) => {
return `${contact.firstName || ''} ${contact.lastName || ''}`.trim();
};
const isExistingContact = (contact) => {
const fullName = getFullName(contact);
return props.existingContacts.includes(fullName);
};
</script>
<style scoped>
@ -406,15 +418,8 @@ defineExpose({});
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;
}
.contact-item.existing-highlight {
border-color: var(--red-500);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
</style>

View File

@ -0,0 +1,99 @@
<template>
<Dialog :visible="visible" @update:visible="val => emit('update:visible', val)" modal :closable="false" :style="{ width: '700px', maxWidth: '95vw' }">
<template #header>
<span class="modal-title">Add Contact/Address</span>
</template>
<div class="modal-body">
<ContactInformationForm
:formData="contactFormData.value"
@update:formData="val => contactFormData.value = val"
:isSubmitting="isSubmitting"
:existingContacts="existingContacts"
/>
<AddressInformationForm
:formData="addressFormData.value"
@update:formData="val => addressFormData.value = val"
:isSubmitting="isSubmitting"
:contactOptions="allContactOptions"
:existingAddresses="existingAddresses"
/>
</div>
<template #footer>
<Button label="Cancel" @click="close" severity="secondary" />
<Button label="Create" @click="create" severity="primary" :loading="isSubmitting" />
</template>
</Dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import ContactInformationForm from '../clientSubPages/ContactInformationForm.vue';
import AddressInformationForm from '../clientSubPages/AddressInformationForm.vue';
const props = defineProps({
visible: Boolean,
clientContacts: { type: Array, default: () => [] },
existingContacts: { type: Array, default: () => [] },
existingAddresses: { type: Array, default: () => [] },
isSubmitting: { type: Boolean, default: false },
});
const emit = defineEmits(['update:visible', 'created']);
const contactFormData = ref({ contacts: [] });
const addressFormData = ref({ addresses: [], contacts: [] });
// Keep addressFormData.contacts in sync with new contacts
watch(
() => contactFormData.value.contacts,
(newContacts) => {
addressFormData.value.contacts = newContacts || [];
},
{ deep: true }
);
// All contact options = clientContacts + new contacts
const allContactOptions = computed(() => {
const clientOpts = (props.clientContacts || []).map((c, idx) => ({
label: `${c.firstName || ''} ${c.lastName || ''}`.trim() || `Contact ${idx + 1}`,
value: `client-${idx}`,
...c,
}));
const newOpts = (contactFormData.value.contacts || []).map((c, idx) => ({
label: `${c.firstName || ''} ${c.lastName || ''}`.trim() || `Contact ${idx + 1}`,
value: `new-${idx}`,
...c,
}));
return [...clientOpts, ...newOpts];
});
function close() {
emit('update:visible', false);
}
function create() {
// Dummy create handler
console.log('Create clicked', {
contacts: contactFormData.value.contacts,
addresses: addressFormData.value.addresses,
});
emit('created', {
contacts: contactFormData.value.contacts,
addresses: addressFormData.value.addresses,
});
close();
}
</script>
<style scoped>
.modal-title {
font-size: 1.2rem;
font-weight: 600;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 0.5rem 0;
}
</style>

View File

@ -1,116 +1,123 @@
<template>
<div class="general-client-info">
<div class="info-grid">
<!-- Lead Badge -->
<div v-if="isLead" class="lead-badge-container">
<Badge value="LEAD" severity="warn" size="large" />
<div class="action-buttons">
<v-btn
size="small"
variant="outlined"
color="primary"
@click="addAddress"
>
<v-icon left size="small">mdi-map-marker-plus</v-icon>
Add Address
</v-btn>
<v-btn
size="small"
variant="outlined"
color="primary"
@click="addContact"
>
<v-icon left size="small">mdi-account-plus</v-icon>
Add Contact
</v-btn>
</div>
</div>
<!-- Client Name (only show for Company type) -->
<div v-if="clientData.customerType === 'Company'" class="info-section">
<label>Company Name</label>
<span class="info-value large">{{ displayClientName }}</span>
</div>
<!-- Client Type -->
<div class="info-section">
<label>Client Type</label>
<span class="info-value">{{ clientData.customerType || "N/A" }}</span>
</div>
<!-- Associated Companies -->
<div v-if="associatedCompanies.length > 0" class="info-section">
<label>Associated Companies</label>
<div class="companies-list">
<Tag
v-for="company in associatedCompanies"
:key="company"
:value="company"
severity="info"
/>
</div>
</div>
<!-- Billing Address -->
<div v-if="billingAddress" class="info-section">
<label>Billing Address</label>
<span class="info-value">{{ billingAddress }}</span>
</div>
<!-- Primary Contact Info -->
<div v-if="primaryContact" class="info-section primary-contact">
<label>{{ clientData.customerType === 'Individual' ? 'Contact Information' : 'Primary Contact' }}</label>
<div class="contact-details">
<div class="contact-item">
<i class="pi pi-user"></i>
<span>{{ primaryContact.fullName || primaryContact.name || "N/A" }}</span>
</div>
<div class="contact-item">
<i class="pi pi-envelope"></i>
<span>{{ primaryContact.emailId || primaryContact.customEmail || "N/A" }}</span>
</div>
<div class="contact-item">
<i class="pi pi-phone"></i>
<span>{{ primaryContact.phone || primaryContact.mobileNo || "N/A" }}</span>
<div>
<div class="general-client-info">
<div class="info-grid">
<!-- Add Contact/Address Button (always visible) -->
<div class="lead-badge-container">
<template v-if="isLead">
<Badge value="LEAD" severity="warn" size="large" />
</template>
<div class="action-buttons">
<Button size="small" variant="outlined" color="primary" @click="openAddModal">
<v-icon left size="small">mdi-account-plus</v-icon>
Add Contact/Address
</Button>
</div>
</div>
</div>
<!-- Statistics -->
<div class="info-section stats">
<label>Overview</label>
<div class="stats-grid">
<div class="stat-item">
<i class="pi pi-map-marker"></i>
<span class="stat-value">{{ addressCount }}</span>
<span class="stat-label">Addresses</span>
</div>
<div class="stat-item">
<i class="pi pi-users"></i>
<span class="stat-value">{{ contactCount }}</span>
<span class="stat-label">Contacts</span>
</div>
<div class="stat-item">
<i class="pi pi-briefcase"></i>
<span class="stat-value">{{ projectCount }}</span>
<span class="stat-label">Projects</span>
<!-- Client Name (only show for Company type) -->
<div v-if="clientData.customerType === 'Company'" class="info-section">
<label>Company Name</label>
<span class="info-value large">{{ displayClientName }}</span>
</div>
<!-- Client Type -->
<div class="info-section">
<label>Client Type</label>
<span class="info-value">{{ clientData.customerType || "N/A" }}</span>
</div>
<!-- Associated Companies -->
<div v-if="associatedCompanies.length > 0" class="info-section">
<label>Associated Companies</label>
<div class="companies-list">
<Tag
v-for="company in associatedCompanies"
:key="company"
:value="company"
severity="info"
/>
</div>
</div>
</div>
<!-- Creation Date -->
<div class="info-section">
<label>Created</label>
<span class="info-value">{{ formattedCreationDate }}</span>
<!-- Billing Address -->
<div v-if="billingAddress" class="info-section">
<label>Billing Address</label>
<span class="info-value">{{ billingAddress }}</span>
</div>
<!-- Primary Contact Info -->
<div v-if="primaryContact" class="info-section primary-contact">
<label>{{ clientData.customerType === 'Individual' ? 'Contact Information' : 'Primary Contact' }}</label>
<div class="contact-details">
<div class="contact-item">
<i class="pi pi-user"></i>
<span>{{ primaryContact.fullName || primaryContact.name || "N/A" }}</span>
</div>
<div class="contact-item">
<i class="pi pi-envelope"></i>
<span>{{ primaryContact.emailId || primaryContact.customEmail || "N/A" }}</span>
</div>
<div class="contact-item">
<i class="pi pi-phone"></i>
<span>{{ primaryContact.phone || primaryContact.mobileNo || "N/A" }}</span>
</div>
</div>
</div>
<!-- Statistics -->
<div class="info-section stats">
<label>Overview</label>
<div class="stats-grid">
<div class="stat-item">
<i class="pi pi-map-marker"></i>
<span class="stat-value">{{ addressCount }}</span>
<span class="stat-label">Addresses</span>
</div>
<div class="stat-item">
<i class="pi pi-users"></i>
<span class="stat-value">{{ contactCount }}</span>
<span class="stat-label">Contacts</span>
</div>
<div class="stat-item">
<i class="pi pi-briefcase"></i>
<span class="stat-value">{{ projectCount }}</span>
<span class="stat-label">Projects</span>
</div>
</div>
</div>
<!-- Creation Date -->
<div class="info-section">
<label>Created</label>
<span class="info-value">{{ formattedCreationDate }}</span>
</div>
</div>
</div>
<AddContactAddressModal
:visible="showAddModal"
@update:visible="showAddModal = $event"
:clientContacts="clientData.contacts || []"
:existingContacts="clientData.contacts?.map(c => c.fullName || c.name) || []"
:existingAddresses="clientData.addresses?.map(a => a.addressLine1) || []"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import Badge from "primevue/badge";
import Tag from "primevue/tag";
import AddContactAddressModal from './AddContactAddressModal.vue';
import Button from 'primevue/button';
import { ref } from 'vue';
const showAddModal = ref(false);
const openAddModal = () => {
showAddModal.value = true;
};
const props = defineProps({
clientData: {
@ -166,16 +173,8 @@ const formattedCreationDate = computed(() => {
});
});
// Placeholder methods for adding address and contact
const addAddress = () => {
console.log("Add Address modal would open here");
// TODO: Open add address modal
};
const addContact = () => {
console.log("Add Contact modal would open here");
// TODO: Open add contact modal
};
</script>
<style scoped>

View File

@ -1,64 +1,124 @@
<template>
<div class="client-page">
<!-- Client Header -->
<GeneralClientInfo
v-if="client.customerName"
:client-data="client"
/>
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
<!-- New Client Form -->
<div v-if="isNew">
<ClientInformationForm
:formData="client"
:is-submitting="isSubmitting"
@update:formData="handleClientUpdate"
@newClientToggle="handleNewClientToggle"
@customerSelected="handleCustomerSelected"
/>
<ContactInformationForm
:formData="client"
:is-submitting="isSubmitting"
:existing-contacts="existingContacts.map(contact => `${contact.firstName || ''} ${contact.lastName || ''}`.trim() || contact.email || 'Unknown Contact')"
@update:formData="handleClientUpdate"
/>
<AddressInformationForm
:formData="client"
:is-submitting="isSubmitting"
:existing-addresses="existingAddresses.map(addr => DataUtils.calculateFullAddress(addr))"
@update:formData="handleClientUpdate"
/>
</div>
<!-- Address Selector (only shows if multiple addresses) -->
<AddressSelector
v-if="!isNew && client.addresses && client.addresses.length > 1"
:addresses="client.addresses"
:selected-address-idx="selectedAddressIdx"
:contacts="client.contacts"
@update:selected-address-idx="handleAddressChange"
/>
<!-- Existing Client View -->
<div v-else>
<!-- Client Header -->
<GeneralClientInfo
v-if="client.customerName"
:client-data="client"
/>
<AdditionalInfoBar :address="client.addresses[selectedAddressIdx]" v-if="client.customerName" />
<!-- Main Content Tabs -->
<Tabs value="0" class="overview-tabs">
<TabList>
<Tab value="0">Overview</Tab>
<Tab value="1">Projects</Tab>
<Tab value="2">Financials</Tab>
</TabList>
<TabPanels>
<!-- Overview Tab -->
<TabPanel value="0">
<Overview
:selected-address="selectedAddressData"
:all-contacts="client.contacts"
:edit-mode="editMode"
:is-new="isNew"
:full-address="fullAddress"
:client="client"
@edit-mode-enabled="enableEditMode"
@update:address-contacts="handleAddressContactsUpdate"
@update:primary-contact="handlePrimaryContactUpdate"
@update:client="handleClientUpdate"
/>
</TabPanel>
<!-- Address Selector (only shows if multiple addresses) -->
<AddressSelector
v-if="!isNew && client.addresses && client.addresses.length > 1"
:addresses="client.addresses"
:selected-address-idx="selectedAddressIdx"
:contacts="client.contacts"
@update:selected-address-idx="handleAddressChange"
/>
<!-- Projects Tab -->
<TabPanel value="1">
<div class="coming-soon-section">
<i class="pi pi-wrench"></i>
<h3>Projects</h3>
<p>Section coming soon</p>
</div>
</TabPanel>
<!-- Main Content Tabs -->
<Tabs value="0" class="overview-tabs">
<TabList>
<Tab value="0">Overview</Tab>
<Tab value="1">Projects</Tab>
<Tab value="2">Financials</Tab>
</TabList>
<TabPanels>
<!-- Overview Tab -->
<TabPanel value="0">
<Overview
:selected-address="selectedAddressData"
:all-contacts="client.contacts"
:edit-mode="editMode"
:is-new="isNew"
:full-address="fullAddress"
:client="client"
@edit-mode-enabled="enableEditMode"
@update:address-contacts="handleAddressContactsUpdate"
@update:primary-contact="handlePrimaryContactUpdate"
@update:client="handleClientUpdate"
/>
</TabPanel>
<!-- Financials Tab -->
<TabPanel value="2">
<div class="coming-soon-section">
<i class="pi pi-dollar"></i>
<h3>Financials</h3>
<p>Section coming soon</p>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Projects Tab -->
<TabPanel value="1">
<div class="coming-soon-section">
<i class="pi pi-wrench"></i>
<h3>Projects</h3>
<p>Section coming soon</p>
</div>
</TabPanel>
<!-- Financials Tab -->
<TabPanel value="2">
<div class="coming-soon-section">
<i class="pi pi-dollar"></i>
<h3>Financials</h3>
<p>Section coming soon</p>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
<!-- Existing Addresses/Contacts Modal -->
<Dialog
:visible="showExistingModal"
@update:visible="showExistingModal = $event"
header="Existing Addresses and Contacts Found"
:modal="true"
class="existing-modal"
>
<div class="modal-content">
<p>The following addresses and/or contacts already exist in the system:</p>
<div v-if="existingAddresses && existingAddresses.length > 0" class="existing-section">
<h4>Existing Addresses:</h4>
<ul>
<li v-for="addr in existingAddresses" :key="addr">
{{ addr.addressLine1 }} {{ addr.addressLine2 }}, {{ addr.city }}, {{ addr.state }} {{ addr.pincode }}
</li>
</ul>
</div>
<div v-if="existingContacts && existingContacts.length > 0" class="existing-section">
<h4>Existing Contacts:</h4>
<ul>
<li v-for="contact in existingContacts" :key="contact">
{{ contact.firstName }} {{ contact.lastName }}
</li>
</ul>
</div>
<p>Would you like to link these existing addresses/contacts with this new client, or cancel the creation?</p>
</div>
<template #footer>
<Button label="Cancel" severity="secondary" @click="cancelExisting" />
<Button label="Continue and Link" @click="continueWithExisting" />
</template>
</Dialog>
<!-- Form Actions (for edit mode or new client) -->
<div class="form-actions" v-if="editMode || isNew">
@ -84,6 +144,7 @@ import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import Api from "../../api";
import { useRoute, useRouter } from "vue-router";
import { useLoadingStore } from "../../stores/loading";
@ -94,6 +155,9 @@ import AddressSelector from "../clientView/AddressSelector.vue";
import GeneralClientInfo from "../clientView/GeneralClientInfo.vue";
import AdditionalInfoBar from "../clientView/AdditionalInfoBar.vue";
import Overview from "../clientView/Overview.vue";
import ClientInformationForm from "../clientSubPages/ClientInformationForm.vue";
import AddressInformationForm from "../clientSubPages/AddressInformationForm.vue";
import ContactInformationForm from "../clientSubPages/ContactInformationForm.vue";
const route = useRoute();
const router = useRouter();
@ -128,6 +192,9 @@ const nextVisitDate = ref(null); // Placeholder, update as needed
// Tab and edit state
const editMode = ref(false);
const isSubmitting = ref(false);
const showExistingModal = ref(false);
const existingAddresses = ref([]);
const existingContacts = ref([]);
const selectedAddressIdx = computed({
get: () => addresses.value.indexOf(selectedAddress.value),
@ -154,17 +221,17 @@ const fullAddress = computed(() => {
return DataUtils.calculateFullAddress(selectedAddressData.value);
});
const getClientNames = async (type) => {
loadingStore.setLoading(true);
try {
const names = await Api.getClientNames(type);
clientNames.value = names;
} catch (error) {
console.error("Error fetching client names in Client.vue: ", error.message || error);
} finally {
loadingStore.setLoading(false);
}
};
// const getClientNames = async (type) => {
// loadingStore.setLoading(true);
// try {
// const names = await Api.getClientNames(type);
// clientNames.value = names;
// } catch (error) {
// console.error("Error fetching client names in Client.vue: ", error.message || error);
// } finally {
// loadingStore.setLoading(false);
// }
// };
const getClient = async (name) => {
loadingStore.setLoading(true);
@ -315,6 +382,21 @@ const handleSubmit = async () => {
isSubmitting.value = true;
try {
if (isNew.value) {
const clientExists = await Api.checkCustomerExists(client.value.customerName);
if (clientExists.exactMatch) {
notificationStore.addError("A client with this name already exists. Please choose a different name.");
return;
}
const addressesExist = await Api.checkAddressesExist(client.value.addresses);
const contactsExist = await Api.checkContactsExist(client.value.contacts);
console.log("Address existence check:", addressesExist);
console.log("Contact existence check:", contactsExist);
if (addressesExist.length > 0 || contactsExist.length > 0) {
// existingAddresses.value = Array.isArray(addressesExist) ? addressesExist : [];
// existingContacts.value = Array.isArray(contactsExist) ? contactsExist : [];
showExistingModal.value = true;
return;
}
const createdClient = await Api.createClient(client.value);
console.log("Created client:", createdClient);
notificationStore.addSuccess("Client created successfully!");
@ -350,6 +432,35 @@ const handlePrimaryContactUpdate = (contactName) => {
const handleClientUpdate = (newClientData) => {
client.value = { ...client.value, ...newClientData };
};
const cancelExisting = () => {
showExistingModal.value = false;
// TODO: Highlight existing addresses/contacts with red outline
};
const continueWithExisting = async () => {
showExistingModal.value = false;
try {
const createdClient = await Api.createClient(client.value);
console.log("Created client:", createdClient);
notificationStore.addSuccess("Client created successfully!");
const strippedName = createdClient.name.split("-#-")[0].trim();
// Navigate to the created client
router.push('/client?client=' + encodeURIComponent(strippedName));
} catch (error) {
console.error("Error creating client:", error);
notificationStore.addError("Failed to create client");
}
};
const handleNewClientToggle = (isNewClient) => {
// Handle toggle if needed
};
const handleCustomerSelected = (clientData) => {
// Handle customer selected from search
client.value = { ...client.value, ...clientData };
};
</script>
<style lang="css">
.tab-info-alert {
@ -420,4 +531,32 @@ const handleClientUpdate = (newClientData) => {
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.existing-modal {
max-width: 600px;
}
.modal-content {
padding: 1rem 0;
}
.existing-section {
margin-bottom: 1rem;
}
.existing-section h4 {
margin: 0 0 0.5rem 0;
color: var(--text-color);
font-size: 1rem;
}
.existing-section ul {
margin: 0;
padding-left: 1.5rem;
}
.existing-section li {
margin-bottom: 0.25rem;
color: var(--text-color-secondary);
}
</style>

View File

@ -90,7 +90,17 @@
<span>Loading available items...</span>
</div>
</div>
<div class="remarks-section">
<label for="remarks" class="field-label">Remarks</label>
<Textarea
id="remarks"
v-model="formData.remarks"
placeholder="Additional notes or instructions for the estimate"
:disabled="!isEditable"
:rows="6"
fluid
/>
</div>
<!-- Items Section -->
<div class="items-section">
<div class="items-header">
@ -422,6 +432,7 @@ import Button from "primevue/button";
import Select from "primevue/select";
import Tooltip from "primevue/tooltip";
import Drawer from "primevue/drawer";
import Textarea from "primevue/textarea";
import Api from "../../api";
import DataUtils from "../../utils";
import { useLoadingStore } from "../../stores/loading";
@ -784,6 +795,7 @@ const saveDraft = async () => {
requiresHalfPayment: formData.requiresHalfPayment,
projectTemplate: formData.projectTemplate,
fromOnsiteMeeting: formData.fromOnsiteMeeting,
remarks: formData.remarks,
company: company.currentCompany
};
estimate.value = await Api.createEstimate(data);
@ -878,12 +890,7 @@ const isPackageItem = (item) => {
};
const onTabClick = () => {
console.log('Bid notes tab clicked');
console.log('Current showDrawer value:', showDrawer.value);
console.log('bidMeeting:', bidMeeting.value);
console.log('bidMeeting?.bidNotes:', bidMeeting.value?.bidNotes);
showDrawer.value = true;
console.log('Set showDrawer to true');
};
const totalCost = computed(() => {
@ -910,17 +917,11 @@ watch(() => formData.projectTemplate, async (newValue) => {
isLoadingQuotationItems.value = true;
try {
quotationItems.value = await Api.getItemsByProjectTemplate(newValue);
console.log("DEBUG: quotationItems after API call:", quotationItems.value);
console.log("DEBUG: quotationItems type:", typeof quotationItems.value);
console.log("DEBUG: quotationItems keys length:", quotationItems.value ? Object.keys(quotationItems.value).length : 0);
console.log("DEBUG: hasQuotationItems computed value:", hasQuotationItems.value);
} catch (error) {
console.error("Error fetching items by project template:", error);
notificationStore.addNotification("Failed to load items for selected project template", "error");
quotationItems.value = {};
} finally {
isLoadingQuotationItems.value = false;
console.log("DEBUG: Loading finished, isLoadingQuotationItems:", isLoadingQuotationItems.value);
}
})
@ -1015,6 +1016,7 @@ watch(
});
}
formData.requiresHalfPayment = Boolean(estimate.value.requiresHalfPayment || estimate.value.custom_requires_half_payment);
formData.remarks = estimate.value.remarks;
// If estimate has fromOnsiteMeeting, fetch bid meeting
if (estimate.value.fromOnsiteMeeting) {
try {
@ -1172,6 +1174,7 @@ onMounted(async () => {
});
}
formData.requiresHalfPayment = Boolean(estimate.value.requiresHalfPayment || estimate.value.custom_requires_half_payment);
formData.remarks = estimate.value.remarks;
// If estimate has fromOnsiteMeeting, fetch bid meeting
if (estimate.value.fromOnsiteMeeting) {
try {

View File

@ -26,10 +26,30 @@
<span class="btn-icon">📧</span>
Email Customer
</button>
<button class="btn btn-primary" @click="createInvoiceForJob()">
<button
class="btn btn-primary"
@click="createInvoiceForJob()"
:disabled="!canCreateInvoice"
>
<span class="btn-icon">💰</span>
Create Invoice
</button>
<button
class="btn btn-secondary"
@click="viewInvoice()"
:disabled="!canViewInvoice"
>
<span class="btn-icon">👁</span>
View Invoice
</button>
<button
class="btn btn-secondary"
@click="sendInvoice()"
:disabled="!canSendInvoice"
>
<span class="btn-icon">📤</span>
{{ sendInvoiceButtonText }}
</button>
</div>
</div>
@ -446,6 +466,26 @@ const filteredTasks = computed(() => {
return job.value.tasks;
});
const canCreateInvoice = computed(() => {
const status = job.value?.invoiceStatus;
return status === 'Ready to Invoice';
});
const canViewInvoice = computed(() => {
const status = job.value?.invoiceStatus;
return status === 'Invoice Created' || status === 'Invoice Sent';
});
const canSendInvoice = computed(() => {
const status = job.value?.invoiceStatus;
return status === 'Invoice Created' || status === 'Invoice Sent';
});
const sendInvoiceButtonText = computed(() => {
const status = job.value?.invoiceStatus;
return status === 'Invoice Sent' ? 'Resend Invoice' : 'Send Invoice';
});
const getProgressOffset = (percent) => {
const circumference = 2 * Math.PI * 45;
const offset = circumference - (percent / 100) * circumference;
@ -536,7 +576,9 @@ const initializeMap = async () => {
const createInvoiceForJob = async () => {
if (!job.value) return;
try {
await Api.createInvoiceForJob(job.value.name);
const invoice = await Api.createInvoiceForJob(job.value.name);
job.value.invoiceStatus = "Invoice Created";
job.value.invoice = invoice;
notifications.addSuccess("Invoice created successfully");
} catch (error) {
console.error("Error creating invoice:", error);
@ -544,6 +586,19 @@ const createInvoiceForJob = async () => {
}
};
const viewInvoice = () => {
// Placeholder method for viewing invoice
console.log("View Invoice clicked");
notifications.addInfo("View Invoice functionality - Coming soon!");
};
const sendInvoice = async () => {
// Placeholder method for sending invoice
await Api.sendInvoice(job.value.invoice.name)
job.value.invoiceStatus = "Invoice Sent";
console.log("Send Invoice clicked");
};
const navigateToCalendar = () => {
router.push('/calendar?tab=projects');
};
@ -771,8 +826,20 @@ onMounted(async () => {
border: 1px solid #ced4da;
}
.btn-secondary:hover {
background: #f8f9fa;
.btn:disabled {
background: #e9ecef;
color: #adb5bd;
cursor: not-allowed;
opacity: 0.6;
transform: none;
box-shadow: none;
}
.btn:disabled:hover {
background: #e9ecef;
color: #adb5bd;
transform: none;
box-shadow: none;
}
.btn-icon {
@ -989,24 +1056,24 @@ onMounted(async () => {
}
.financial-details {
display: flex;
flex-direction: column;
gap: 6px;
display: grid;
grid-template-columns: 1fr auto;
gap: 6px 12px;
margin-top: 12px;
padding-top: 0;
border-top: none;
align-items: center;
}
.financial-item {
display: flex;
justify-content: space-between;
align-items: center;
display: contents;
}
.item-label {
font-size: 12px;
color: #6c757d;
font-weight: 500;
justify-self: start;
}
.item-value {
@ -1014,6 +1081,7 @@ onMounted(async () => {
font-weight: 600;
padding: 2px 8px;
border-radius: 3px;
justify-self: end;
}
.item-value.paid {

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB