Compare commits

...

203 Commits

Author SHA1 Message Date
Frappe PR Bot
cf849f7dff chore(release): Bumped to Version 1.53.1 2025-09-25 16:33:11 +00:00
Shariq Ansari
3e9fdb8d20
Merge pull request #1287 from frappe/main-hotfix 2025-09-25 22:02:44 +05:30
Shariq Ansari
260755fd2e
Merge pull request #1286 from frappe/mergify/bp/main-hotfix/pr-1279 2025-09-25 21:57:28 +05:30
Shariq Ansari
85df56b464
Merge pull request #1285 from frappe/mergify/bp/main-hotfix/pr-1284 2025-09-25 21:53:53 +05:30
Shariq Ansari
a9e956b5fc chore: Norwegian Bokmal translations
(cherry picked from commit 9e92282e25cd1d3e5c33b87b02ad6dfdc09158cc)
2025-09-25 16:23:42 +00:00
Shariq Ansari
905eb63c5f chore: Serbian (Latin) translations
(cherry picked from commit 0138716e070b8098fd37c3ee67496c7a0d1ff473)
2025-09-25 16:23:42 +00:00
Shariq Ansari
3eee437014 chore: Serbian (Cyrillic) translations
(cherry picked from commit 589c95e263bf70fda044ef841a623ccda510c744)
2025-09-25 16:23:42 +00:00
Shariq Ansari
4498de2041 chore: Norwegian Bokmal translations
(cherry picked from commit a4e2663b2418c935c8279fab97934dff0d2221b7)
2025-09-25 16:23:41 +00:00
Shariq Ansari
bfb0d25765 chore: Norwegian Bokmal translations
(cherry picked from commit f91ac266ca921427fa2d38af4e8d8f847d46e111)
2025-09-25 16:23:41 +00:00
Shariq Ansari
9dd6ffa1e1 chore: Danish translations
(cherry picked from commit 415d5410ba707a59a6cc6a7cf8d0bafc8a6a7e50)
2025-09-25 16:23:41 +00:00
Shariq Ansari
05322e805f chore: Esperanto translations
(cherry picked from commit 9fc20a3078922b45440551343f7fb032d17d814b)
2025-09-25 16:23:40 +00:00
Shariq Ansari
8dc6db3218 chore: Croatian translations
(cherry picked from commit 43dadfe746f3ade875bf5420edca1b3bc92cc372)
2025-09-25 16:23:40 +00:00
Shariq Ansari
1b63384eac chore: Thai translations
(cherry picked from commit 56071d5e0dd520d96b24ce92d9079823921402aa)
2025-09-25 16:23:40 +00:00
Shariq Ansari
37bbbb6b4b chore: Persian translations
(cherry picked from commit b0e79d0aef3a3fe637c6d0d2d734118b095a52a5)
2025-09-25 16:23:40 +00:00
Shariq Ansari
6f0244c2b6 chore: Vietnamese translations
(cherry picked from commit 5848649955c084076afda007e21df1b0940394f6)
2025-09-25 16:23:39 +00:00
Shariq Ansari
e08bc9cd20 chore: Chinese Simplified translations
(cherry picked from commit 3c5ee979f86d9a0cdbd6717c91253622e47e3f8d)
2025-09-25 16:23:39 +00:00
Shariq Ansari
2043157567 chore: Turkish translations
(cherry picked from commit 2acd7476c8e1f92f2747a84666badafe5b204910)
2025-09-25 16:23:39 +00:00
Shariq Ansari
8093a422cd chore: Russian translations
(cherry picked from commit 66c586582836bcbd3210a4cd2e254edf3fc9b9f5)
2025-09-25 16:23:38 +00:00
Shariq Ansari
4bef919d38 chore: Portuguese translations
(cherry picked from commit 103a137af12912bcf388b06a14c84abadbad8979)
2025-09-25 16:23:38 +00:00
Shariq Ansari
874947b8ae chore: Dutch translations
(cherry picked from commit 52cc70d704a284bd41696e67e2c1fb24142dc6ee)
2025-09-25 16:23:38 +00:00
Shariq Ansari
ca90c0406e chore: Hungarian translations
(cherry picked from commit d72dcee7b663d35c83a5658815a1cdd529b445c3)
2025-09-25 16:23:38 +00:00
Shariq Ansari
6bf27d852b chore: Czech translations
(cherry picked from commit b473b27f9ace8d76023b13ec1cf57e81cc487835)
2025-09-25 16:23:37 +00:00
Shariq Ansari
793cb76789 chore: Arabic translations
(cherry picked from commit 8805560144baa224c6f3ac5758e9d3d288e60f45)
2025-09-25 16:23:37 +00:00
Shariq Ansari
ff07054ca3 chore: Spanish translations
(cherry picked from commit 3f0c4e9614d8081344dd8ef213bed525c69a4b1a)
2025-09-25 16:23:37 +00:00
Shariq Ansari
2ee5269d3e chore: French translations
(cherry picked from commit 2b13c3f27704a8b50facb7636f4a82810e843bd6)
2025-09-25 16:23:36 +00:00
Shariq Ansari
e27954da52 chore: German translations
(cherry picked from commit b6fa3bf32b3c21e82917061da94f450e81f48323)
2025-09-25 16:23:36 +00:00
Shariq Ansari
2faa0d0f04 chore: Serbian (Latin) translations
(cherry picked from commit ae9e59aa0002112fa22192d15df43491b1145c8c)
2025-09-25 16:23:36 +00:00
Shariq Ansari
f1664eec2f chore: Bosnian translations
(cherry picked from commit 4533becc620ea06bc61043eb58e8350b90445950)
2025-09-25 16:23:36 +00:00
Shariq Ansari
7f2efea7cb chore: Indonesian translations
(cherry picked from commit 57bd9fe70a6f09d336b8b17dd1215481f2f5d03d)
2025-09-25 16:23:35 +00:00
Shariq Ansari
d2cc6b7c2e chore: Portuguese, Brazilian translations
(cherry picked from commit 013c21a5d1b564632b0383893d4b45512afa9e4c)
2025-09-25 16:23:35 +00:00
Shariq Ansari
1afb6d6827 chore: Swedish translations
(cherry picked from commit 9a780039e589e369f76f4fe9b4e0dff0902bd859)
2025-09-25 16:23:35 +00:00
Shariq Ansari
ccaf136830 chore: Serbian (Cyrillic) translations
(cherry picked from commit 1bd62289dc720edc9515a3677ac377732ae1dc60)
2025-09-25 16:23:34 +00:00
Shariq Ansari
58a41d1b11 chore: Polish translations
(cherry picked from commit f7382f40ac78be3804f34866162f30329f742d76)
2025-09-25 16:23:34 +00:00
Shariq Ansari
777f3ac06c chore: Italian translations
(cherry picked from commit 3c870ce042a7d165ad914f90cf84fd5473ad8243)
2025-09-25 16:23:34 +00:00
Shariq Ansari
105c78e264
Merge pull request #1283 from frappe/mergify/bp/main-hotfix/pr-1282
fix: add validation for mandatory fields in useDocument (backport #1282)
2025-09-25 21:53:09 +05:30
Shariq Ansari
46cc1d2924 build(deps): bump frappeui to 0.1.201
(cherry picked from commit 171060df8ade73f6b9ef4b51945c76604c759299)
2025-09-25 16:22:33 +00:00
Shariq Ansari
ff4ca9fe66 fix: add validation for mandatory fields in useDocument
(cherry picked from commit dbcda4c548270f4b030d819857b1f393fdaadecb)
2025-09-25 16:18:30 +00:00
Shariq Ansari
4989dc0921
Merge pull request #1277 from shariquerik/backport-1125
fix: Bulk Delete "Reference Doctype must be set first" Error (backport #1125)
2025-09-22 16:12:16 +05:30
Shariq Ansari
1e613ebcd1 fix: Bulk Delete 'Reference Doctype must be set first' Error backport (#1125) 2025-09-22 16:04:53 +05:30
Frappe PR Bot
4f8f195d77 chore(release): Bumped to Version 1.53.0 2025-09-22 09:55:37 +00:00
Shariq Ansari
af64b86a04
Merge pull request #1276 from frappe/main-hotfix 2025-09-22 15:25:13 +05:30
Shariq Ansari
7e9bc0524e
Merge pull request #1275 from frappe/mergify/bp/main-hotfix/pr-1266 2025-09-22 15:18:12 +05:30
Shariq Ansari
9d0a0d1d32
Merge pull request #1273 from frappe/mergify/bp/main-hotfix/pr-1272 2025-09-22 15:17:51 +05:30
Shariq Ansari
9c84a8be7f
Merge pull request #1274 from frappe/mergify/bp/main-hotfix/pr-1262 2025-09-22 15:17:42 +05:30
frappe-pr-bot
d24537489e chore: update POT file
(cherry picked from commit 625e472303a4d759024a744bd93bc8d721537a0a)
2025-09-22 09:41:24 +00:00
Shariq Ansari
bd89b3b356 chore: Norwegian Bokmal translations
(cherry picked from commit ce632c69c1e634dc0feed03eec3f4c76370e7dcf)
2025-09-22 09:41:23 +00:00
Shariq Ansari
988fb90ddb chore: Norwegian Bokmal translations
(cherry picked from commit 93ed6fcdddac8a903e470ee35d1cbcca7e9ba5cf)
2025-09-22 09:41:23 +00:00
Shariq Ansari
8018b1766c chore: Norwegian Bokmal translations
(cherry picked from commit e3eff7f78de04f49bb78a3e1401048a42d4bf2eb)
2025-09-22 09:41:23 +00:00
Shariq Ansari
9c4c2a0aca chore: Norwegian Bokmal translations
(cherry picked from commit 394da5e0024dfce029f90346ff695dc7f7a67e5f)
2025-09-22 09:41:23 +00:00
Shariq Ansari
803e639961 build(deps): bump frappeui to 0.1.200
(cherry picked from commit 96c0c99939b30880ddf27b91f3f6b18c95ef3409)
2025-09-22 09:40:21 +00:00
Shariq Ansari
fabd362b2a
Merge pull request #1260 from frappe/mergify/bp/main-hotfix/pr-1256 2025-09-18 15:44:32 +05:30
Shariq Ansari
7dd98733f1
chore: resolved conflict 2025-09-18 15:38:44 +05:30
Shariq Ansari
ee4b7721b0
chore: resolved conflict 2025-09-18 15:37:11 +05:30
Shariq Ansari
95bc551254
chore: resolved conflict 2025-09-18 15:35:03 +05:30
Shariq Ansari
2546bdabb1 refactor: adjust padding and improve layout for Settings component
(cherry picked from commit d687a2eb56142c49b2b776dec55ad533556421f6)
2025-09-18 09:59:25 +00:00
Shariq Ansari
af248964c6 refactor: adjust padding and improve layout for Currency and Forecasting settings components
(cherry picked from commit 1044adc494b0b5b7f347aaf0080914a28be56eb9)
2025-09-18 09:59:25 +00:00
Shariq Ansari
8749f7bfd0 refactor: update styling and improve layout for assignment rules components
(cherry picked from commit 9f95a3a2b2132357708db03b9b4922f356150ff4)
2025-09-18 09:59:25 +00:00
Shariq Ansari
d18618b856 refactor: replace EmailMultiSelect with FormControl for inviting users by email
(cherry picked from commit 69f80903118965f500199f1b3368deb195882699)

# Conflicts:
#	frontend/src/components/Settings/InviteUserPage.vue
2025-09-18 09:59:24 +00:00
Shariq Ansari
ce4af4907a refactor: remove TemplateOption component usage and simplify dropdown options in multiple components
(cherry picked from commit ac34ac9b87b9c671be75401a545e09e0e83ac378)

# Conflicts:
#	frontend/src/components/Settings/Users.vue
2025-09-18 09:59:24 +00:00
Shariq Ansari
84d24a384b refactor: update Vite configuration to support dynamic loading of frappe-ui in development mode
(cherry picked from commit 129f8a00b66d87529c21ef085e66dc7864a3776e)
2025-09-18 09:59:24 +00:00
Shariq Ansari
29d86859d4 revert: create dynamic alias to use components from frontend vue apps
(cherry picked from commit 6328b6941bb620e47cebe82519df3f1453f355ae)
2025-09-18 09:59:23 +00:00
Shariq Ansari
be452fee58 refactor: reduce gap in Brand logo and Favicon sections for improved layout
(cherry picked from commit fbc9e37036d5720948381ca891714d78af001433)
2025-09-18 09:59:23 +00:00
Shariq Ansari
100eec0677 refactor: remove icon-left from Update button in multiple settings components
(cherry picked from commit 149901f6054f000d503ef8940037c13bd2e344f3)
2025-09-18 09:59:23 +00:00
Shariq Ansari
a4d3852c0e feat: Auto update expected deal value based on products value
(cherry picked from commit 7e21a5fee206723cf714f7a8732bb8929aaa57df)
2025-09-18 09:59:23 +00:00
Shariq Ansari
0399fc32be refactor: add ForecastingSettings component and remove GeneralSettingsPage component
(cherry picked from commit f4ff6bbdf306a89b78aab82df972bf55e1e0d82d)
2025-09-18 09:59:22 +00:00
Shariq Ansari
a79192ef4d fix: add auto-update expected deal value checkbox in FCRM settings
(cherry picked from commit 915023317310178d67306bb6d8b48e224e5644ae)
2025-09-18 09:59:22 +00:00
Shariq Ansari
e10ec543a7 refactor: CurrencySettings component
(cherry picked from commit 186584c1ac79170afa4ef8860bff9d3d3d91cadc)
2025-09-18 09:59:22 +00:00
Shariq Ansari
1962b9a103 refactor: update BrandSettings component to improve logo and favicon handling
(cherry picked from commit 3752c611576778f028ba78d6c1ae533a88c6040b)
2025-09-18 09:59:21 +00:00
Shariq Ansari
a3abaa57ec refactor: HomeActions component
(cherry picked from commit a6ecc5cfeda51781286d013cf3a738b8a3adfdc5)
2025-09-18 09:59:21 +00:00
Shariq Ansari
1bf3f7a38c refactor: BrandSettings component
(cherry picked from commit 84e0fe30a9667468ea9ddbb0bc768e3da62620d3)
2025-09-18 09:59:20 +00:00
Shariq Ansari
af81750388 refactor: enhance Settings component structure
(cherry picked from commit 03acea69b130cfdbdc00994c8d9930ad948cb64c)

# Conflicts:
#	frontend/src/components/Settings/Settings.vue
2025-09-18 09:59:20 +00:00
Shariq Ansari
7330a3c2a5 refactor: clean up ImageUploader component and improve label handling
(cherry picked from commit e19f75083147faaf988bcbef448e9d1ddbb9179a)
2025-09-18 09:59:19 +00:00
Shariq Ansari
9e8a4024a5
Merge pull request #1255 from frappe/mergify/bp/main-hotfix/pr-1252
fix: paddings and labels (backport #1252)
2025-09-18 15:27:27 +05:30
Shariq Ansari
7ef00965fa
Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-1252 2025-09-18 15:21:52 +05:30
Shariq Ansari
97925aae15
Merge pull request #1259 from frappe/mergify/bp/main-hotfix/pr-1257 2025-09-18 15:17:19 +05:30
Shariq Ansari
0b75228722 chore: Portuguese translations
(cherry picked from commit fca831b92e633f7a7f6cdd09f66d83bac19e2590)
2025-09-18 09:42:12 +00:00
Shariq Ansari
a360fa774b
Merge pull request #1258 from frappe/mergify/bp/main-hotfix/pr-1206 2025-09-18 15:11:28 +05:30
Shariq Ansari
4601b56ee1
chore: resolved conflict 2025-09-18 15:05:35 +05:30
Pratik Badhe
d985a44291 feat: add assignment rule
(cherry picked from commit 0c5684905f44af211189bf674735b046858a5b86)

# Conflicts:
#	frontend/components.d.ts
#	yarn.lock
2025-09-18 09:19:54 +00:00
Pratik Badhe
ce66705e9c revert: yarn.lock file
(cherry picked from commit 41ef219d0abd0c0036d7697dc5a8b8ab78a81344)
2025-09-17 06:47:25 +00:00
Pratik Badhe
004923419c fix: paddings and labels
(cherry picked from commit db577afc568b56b49846773b16b638e0cf1444fa)

# Conflicts:
#	frontend/src/components/Settings/AssignmentRules/AssigneeRules.vue
#	frontend/src/components/Settings/AssignmentRules/AssignmentRuleView.vue
#	frontend/src/components/Settings/AssignmentRules/AssignmentRules.vue
2025-09-17 06:47:25 +00:00
Shariq Ansari
49ed1ac174
Merge pull request #1254 from frappe/mergify/bp/main-hotfix/pr-1253 2025-09-17 11:55:38 +05:30
Shariq Ansari
a7dd1e9bf6 chore: Norwegian Bokmal translations
(cherry picked from commit b6e3cdfc378c9ab6322e6148d28387e5d399310c)
2025-09-17 06:08:01 +00:00
Shariq Ansari
4cfd0022f4 chore: Danish translations
(cherry picked from commit c0171c0555fe7091102b12cd2ccd8ec714dbe34d)
2025-09-17 06:08:00 +00:00
Shariq Ansari
fc3d8cd94d chore: Esperanto translations
(cherry picked from commit 6f154e191a816c118ffc190a36277ea9235eedc3)
2025-09-17 06:08:00 +00:00
Shariq Ansari
5473a93b5e chore: Croatian translations
(cherry picked from commit 552e500a31bca989d7b4554cfc0aa0015635098c)
2025-09-17 06:08:00 +00:00
Shariq Ansari
57d306ea1f chore: Thai translations
(cherry picked from commit bf6940a6ff7cd972c384a050068aaa2835bc371a)
2025-09-17 06:07:59 +00:00
Shariq Ansari
7c324bd07f chore: Persian translations
(cherry picked from commit dc9b07b02a5ea4443967d3261781d2786e7ff810)
2025-09-17 06:07:59 +00:00
Shariq Ansari
09421217b4 chore: Vietnamese translations
(cherry picked from commit 0a45094c33db76bea8606c02b2b0985e6e35e34f)
2025-09-17 06:07:59 +00:00
Shariq Ansari
630dfcd0e7 chore: Chinese Simplified translations
(cherry picked from commit 247d8e043e548d30ffc4494544cdf7e95bec94e1)
2025-09-17 06:07:58 +00:00
Shariq Ansari
b999a375b3 chore: Turkish translations
(cherry picked from commit 73a1ecd418e0ca1ba40442aa0338b7f6853d0650)
2025-09-17 06:07:58 +00:00
Shariq Ansari
b5f5a3b5d5 chore: Russian translations
(cherry picked from commit 77e7bb011b5357465f6565eeddddd4ec160c1d3c)
2025-09-17 06:07:57 +00:00
Shariq Ansari
814d39572a chore: Portuguese translations
(cherry picked from commit 9233e77ab8376301a61c56d4ee072fb3c4caea4b)
2025-09-17 06:07:57 +00:00
Shariq Ansari
6fc4c0699a chore: Dutch translations
(cherry picked from commit 32e5d56ef19da5c0577334992a43d3c0db761700)
2025-09-17 06:07:57 +00:00
Shariq Ansari
50ea06a568 chore: Hungarian translations
(cherry picked from commit cea6b6c6b4bfe87e6c533aadba632f60081853bc)
2025-09-17 06:07:56 +00:00
Shariq Ansari
daf0ddaab4 chore: Czech translations
(cherry picked from commit 2d636d7ffb252e52352e0883747c6fb158d42774)
2025-09-17 06:07:56 +00:00
Shariq Ansari
70107a4836 chore: Arabic translations
(cherry picked from commit 9d9caf2856b5fd1c1f5bc88024292a2dea3dd1f3)
2025-09-17 06:07:56 +00:00
Shariq Ansari
f2e6380d3e chore: Spanish translations
(cherry picked from commit 4ff4f3c5b53fe6ea60623894770ecad3b2289441)
2025-09-17 06:07:56 +00:00
Shariq Ansari
eed1e23b5b chore: French translations
(cherry picked from commit 8167e1388da8693ab2d14dba876d7092a40d1875)
2025-09-17 06:07:55 +00:00
Shariq Ansari
1bb75dd911 chore: German translations
(cherry picked from commit 8e3cf3846aad4c72ff8d1b53a662d95565ce227a)
2025-09-17 06:07:55 +00:00
Shariq Ansari
1086ce406f chore: Serbian (Latin) translations
(cherry picked from commit 2d8ada04c869cc4f0652c3d2e2946820e1b89335)
2025-09-17 06:07:55 +00:00
Shariq Ansari
39c5497363 chore: Bosnian translations
(cherry picked from commit 4fba2353cffcab5e223318b74830390046ee1deb)
2025-09-17 06:07:54 +00:00
Shariq Ansari
4bc31431e7 chore: Indonesian translations
(cherry picked from commit f251d83e97cdca93447de491e228e0648f16c7f0)
2025-09-17 06:07:54 +00:00
Shariq Ansari
fe290877e4 chore: Portuguese, Brazilian translations
(cherry picked from commit 283b34662e6da88c68273cd64c708e63a7aabadb)
2025-09-17 06:07:54 +00:00
Shariq Ansari
e4bdc0586e chore: Swedish translations
(cherry picked from commit 9bcfcf4ac722caf126cf311079c3220590e8c91d)
2025-09-17 06:07:53 +00:00
Shariq Ansari
695f9e1303 chore: Serbian (Cyrillic) translations
(cherry picked from commit d80bbcd33d99872ffe28cc334c3c523ac775506e)
2025-09-17 06:07:53 +00:00
Shariq Ansari
98747bdc2a chore: Polish translations
(cherry picked from commit 42ee5ea64df69c172cd683aa9e1169969267158b)
2025-09-17 06:07:53 +00:00
Shariq Ansari
16ed1ad060 chore: Italian translations
(cherry picked from commit 1472a7f33d50686df2ff5c978682d3a693b5d674)
2025-09-17 06:07:52 +00:00
Shariq Ansari
ff312d964b
Merge pull request #1251 from frappe/main-hotfix 2025-09-16 15:23:38 +05:30
Shariq Ansari
49d7af5548 build(deps): bump frappeui to 0.1.197 2025-09-16 14:51:32 +05:30
Shariq Ansari
a30d21c346
Merge pull request #1250 from frappe/mergify/bp/main-hotfix/pr-1242 2025-09-16 14:25:58 +05:30
Shariq Ansari
f459bd57ba
Merge pull request #1249 from frappe/mergify/bp/main-hotfix/pr-1245 2025-09-16 14:25:50 +05:30
Shariq Ansari
b4d89e1a5a chore: Danish translations
(cherry picked from commit 2e5f4a9d22f615ff8f57afd4cccb763fb3b0d1da)
2025-09-16 08:50:23 +00:00
Shariq Ansari
25bc9d8acb chore: Persian translations
(cherry picked from commit 100d01334a4fa5d9b9c686b27a6c794fc1e8cd35)
2025-09-16 08:50:22 +00:00
Shariq Ansari
568477f9c7 chore: Portuguese translations
(cherry picked from commit 03ab96d94f49599ccc0a2ca4fe4045540d59403a)
2025-09-16 08:50:22 +00:00
Shariq Ansari
9c04ade20d chore: Norwegian Bokmal translations
(cherry picked from commit 6f640f5eee07558b133fe1acea6b26e9f23783c1)
2025-09-16 08:50:22 +00:00
Shariq Ansari
7940211fad chore: Norwegian Bokmal translations
(cherry picked from commit 376917bc75086608fcd774289d119c6a6adb2d32)
2025-09-16 08:50:21 +00:00
Shariq Ansari
df0968ed67 chore: Portuguese translations
(cherry picked from commit 54753d3274a5e75dd684efafca91517f04104620)
2025-09-16 08:50:21 +00:00
Shariq Ansari
89462e63cb chore: Turkish translations
(cherry picked from commit 3c3108a9c19bab39ca67bb7efbc8e299ccfcc5ea)
2025-09-16 08:50:21 +00:00
Shariq Ansari
23c53ffa9a chore: Norwegian Bokmal translations
(cherry picked from commit e0cfae1eb3fae321bf187c2e8bae9875e7409bb8)
2025-09-16 08:50:21 +00:00
Shariq Ansari
ae3df8d391 chore: Norwegian Bokmal translations
(cherry picked from commit fa03245effea3cabe2e536c99ec4ef65460d80c4)
2025-09-16 08:50:20 +00:00
Shariq Ansari
2d51933ceb chore: Serbian (Latin) translations
(cherry picked from commit f253392ba74c28f78357fe70d886db6eaf73128f)
2025-09-16 08:50:20 +00:00
Shariq Ansari
a7958dc2a2 chore: Serbian (Cyrillic) translations
(cherry picked from commit 6e608d845b99b959bf18232abf0235a87b2b274f)
2025-09-16 08:50:20 +00:00
frappe-pr-bot
1c4f78b01c chore: update POT file
(cherry picked from commit 1627cf1e5488f596e463b6253855cf8eff17064f)
2025-09-16 08:49:19 +00:00
Shariq Ansari
2722ef6cad
Merge pull request #1241 from frappe/mergify/bp/main-hotfix/pr-1240 2025-09-09 14:30:02 +05:30
Shariq Ansari
ac7d3907c2 chore: Norwegian Bokmal translations
(cherry picked from commit ffd2452675e3710e5e64535d6b29faff736c1607)
2025-09-09 08:57:46 +00:00
Frappe PR Bot
17b7c6ecef chore(release): Bumped to Version 1.52.11 2025-09-08 14:20:41 +00:00
Shariq Ansari
8d7a155d78
Merge pull request #1239 from frappe/main-hotfix 2025-09-08 19:49:39 +05:30
Shariq Ansari
6dc85ad1b2
Merge branch 'main' into main-hotfix 2025-09-08 19:42:43 +05:30
Shariq Ansari
ac98c1a090
Merge pull request #1238 from frappe/mergify/bp/main-hotfix/pr-1237 2025-09-08 14:53:07 +05:30
Shariq Ansari
90a6bde438 chore: Norwegian Bokmal translations
(cherry picked from commit 097c58b991eac4253b637f535d13132bf6d21dc5)
2025-09-08 05:50:47 +00:00
Shariq Ansari
5fea7bf0e2 chore: Danish translations
(cherry picked from commit 64bf702b62d55eed433106bfc6b00887a58c29cb)
2025-09-08 05:50:46 +00:00
Shariq Ansari
1031e9c4ec chore: Esperanto translations
(cherry picked from commit 7e27e9f45ef72862626dd756cbf3cf50730ddce2)
2025-09-08 05:50:46 +00:00
Shariq Ansari
c6e4b9b5d3 chore: Croatian translations
(cherry picked from commit ee19b344ec333b53e0a585d99465cf908d58318e)
2025-09-08 05:50:46 +00:00
Shariq Ansari
f2a4b9ec56 chore: Thai translations
(cherry picked from commit eb70553fea56eb054e28676287323367b1aea3e4)
2025-09-08 05:50:46 +00:00
Shariq Ansari
14a1af4455 chore: Persian translations
(cherry picked from commit 9e4d268b504220f3a98a1411b9d0c0961e01f9b5)
2025-09-08 05:50:45 +00:00
Shariq Ansari
e3ab227124 chore: Vietnamese translations
(cherry picked from commit d92c25ab4ea769593ed4252a4058a85713b7d7ba)
2025-09-08 05:50:45 +00:00
Shariq Ansari
5f6cc26126 chore: Chinese Simplified translations
(cherry picked from commit 9975be4ff7984ccd663edf2544374319ff15e965)
2025-09-08 05:50:45 +00:00
Shariq Ansari
8051fd1f99 chore: Turkish translations
(cherry picked from commit 2442f6f2adc51a5a00a96798a20d0d856d24d85e)
2025-09-08 05:50:44 +00:00
Shariq Ansari
1fc98f619c chore: Russian translations
(cherry picked from commit e39a20f652a5900598eed1cb70c049a13ac38bec)
2025-09-08 05:50:44 +00:00
Shariq Ansari
5921af8c0b chore: Portuguese translations
(cherry picked from commit dcca47f3cad050cd573f40b648fd70f0baf6a704)
2025-09-08 05:50:44 +00:00
Shariq Ansari
fe505d33d2 chore: Dutch translations
(cherry picked from commit df8aaea3745a6f83baca39860afaaac3b91045a0)
2025-09-08 05:50:44 +00:00
Shariq Ansari
300f0b24e2 chore: Hungarian translations
(cherry picked from commit c3ac80afa8c74217a100c7b5d0ee24e1b738f62d)
2025-09-08 05:50:43 +00:00
Shariq Ansari
b7c26e35e9 chore: Czech translations
(cherry picked from commit 8d59359ef5bf29a4b46163f1fa118aa13d6ddf2b)
2025-09-08 05:50:43 +00:00
Shariq Ansari
4c7c8b915a chore: Arabic translations
(cherry picked from commit 53202f4a5b07eba3afc408736abc077eb4461a44)
2025-09-08 05:50:43 +00:00
Shariq Ansari
d80feb1e77 chore: Spanish translations
(cherry picked from commit 792db2725229ab4372b0349995acea0ecc313ee9)
2025-09-08 05:50:43 +00:00
Shariq Ansari
2a8c7307f0 chore: French translations
(cherry picked from commit c9bcbcf1d0a4e15ca65a5f77ef046156531b9128)
2025-09-08 05:50:42 +00:00
Shariq Ansari
0683d93621 chore: German translations
(cherry picked from commit 79a0c02f03b092f4259f63ec92d7372ee1cdafb6)
2025-09-08 05:50:42 +00:00
Shariq Ansari
5ce2fcd368 chore: Serbian (Latin) translations
(cherry picked from commit 2a5dbfb75d382c73ad94725030ae509049abe3bc)
2025-09-08 05:50:42 +00:00
Shariq Ansari
5372fdcaf1 chore: Bosnian translations
(cherry picked from commit b7443ff1bb90770e15af51b42a37e0dc35fb2d8a)
2025-09-08 05:50:41 +00:00
Shariq Ansari
9f46bbd25d chore: Indonesian translations
(cherry picked from commit 5ee9d5787a542b27d2059f76edc19c4657c7886b)
2025-09-08 05:50:41 +00:00
Shariq Ansari
87e82ce39e chore: Portuguese, Brazilian translations
(cherry picked from commit 426dc836ca4eb324ee1929bec06c646ea04095ab)
2025-09-08 05:50:41 +00:00
Shariq Ansari
443aeddca0 chore: Swedish translations
(cherry picked from commit d5287103797eebe59f2f821eb5ad9dafbc8cdadc)
2025-09-08 05:50:41 +00:00
Shariq Ansari
dc7d5f57b8 chore: Serbian (Cyrillic) translations
(cherry picked from commit 0a4cfa6055da2820f5ca9a52f25979f0e3cfd829)
2025-09-08 05:50:40 +00:00
Shariq Ansari
1cbd633e5b chore: Polish translations
(cherry picked from commit 1ca85157abc1f4ea412f34163469d3451fbab998)
2025-09-08 05:50:40 +00:00
Shariq Ansari
cf58a634e9 chore: Italian translations
(cherry picked from commit d8162a1dc48705ab7d9b89f0f9dacf1422a80c02)
2025-09-08 05:50:40 +00:00
Frappe PR Bot
b118e75bec chore(release): Bumped to Version 1.52.10 2025-09-07 14:02:13 +00:00
Shariq Ansari
2b1048fd83
Merge pull request #1236 from frappe/mergify/bp/main/pr-1234
fix: Organization is not getting renamed (backport #1234)
2025-09-07 19:31:08 +05:30
Shariq Ansari
7468f19b48
Merge pull request #1235 from frappe/mergify/bp/main-hotfix/pr-1234
fix: Organization is not getting renamed (backport #1234)
2025-09-07 19:30:57 +05:30
Shariq Ansari
f994dd36a9 fix: remove unnecessary class from Button component in TaskModal and add click.stop to Notes dropdown
(cherry picked from commit 30d95a6582cf8d5ba8e06e6d94e78b54c82e57a3)
2025-09-07 13:55:59 +00:00
Shariq Ansari
dc98613296 fix: add 'interactjs' to optimizeDeps include list in vite.config.js
(cherry picked from commit 24b580150af1e51db30c3c9fac8138bc62dd8a05)
2025-09-07 13:55:58 +00:00
Shariq Ansari
f132a46206 fix: update organization logo handling and add beforeFieldChange functionality to rename organization
(cherry picked from commit 1b7af2096f6bfc18094ce727e3e43754c279e51b)
2025-09-07 13:55:58 +00:00
Shariq Ansari
cfeac13c9c fix: remove unnecessary class from Button component in TaskModal and add click.stop to Notes dropdown
(cherry picked from commit 30d95a6582cf8d5ba8e06e6d94e78b54c82e57a3)
2025-09-07 13:55:55 +00:00
Shariq Ansari
b82dc0473a fix: add 'interactjs' to optimizeDeps include list in vite.config.js
(cherry picked from commit 24b580150af1e51db30c3c9fac8138bc62dd8a05)
2025-09-07 13:55:55 +00:00
Shariq Ansari
8f0e8f3f52 fix: update organization logo handling and add beforeFieldChange functionality to rename organization
(cherry picked from commit 1b7af2096f6bfc18094ce727e3e43754c279e51b)
2025-09-07 13:55:54 +00:00
Shariq Ansari
7a9da275da
Merge pull request #1233 from frappe/mergify/bp/main-hotfix/pr-1230 2025-09-07 18:07:30 +05:30
Shariq Ansari
cc25feea09 chore: Norwegian Bokmal translations
(cherry picked from commit 891d78c3b655a473c7215002f1c0fb35f92de89f)
2025-09-07 12:30:47 +00:00
Shariq Ansari
b64058bd10 chore: Norwegian Bokmal translations
(cherry picked from commit 4baee8351b911d47fad88aaa528254766e424e19)
2025-09-07 12:30:47 +00:00
Shariq Ansari
b6af543243
Merge pull request #1232 from frappe/mergify/bp/main/pr-1231 2025-09-07 17:59:11 +05:30
Shariq Ansari
7bcee291c7
chore: resolved conflict 2025-09-07 17:58:17 +05:30
frappe-pr-bot
570a45eeaf chore: update POT file
(cherry picked from commit 591076bf2778d5f98e4023665fcd8b84e7747b2f)

# Conflicts:
#	crm/locale/main.pot
2025-09-07 12:20:39 +00:00
Shariq Ansari
dcadd3d0c1
Merge pull request #1228 from frappe/mergify/bp/main-hotfix/pr-1224 2025-09-05 16:18:50 +05:30
Shariq Ansari
8d4975554d chore: Portuguese translations
(cherry picked from commit 023d949577a74af8ebe0d61b95da73ad988a64e0)
2025-09-05 10:48:09 +00:00
Shariq Ansari
4bf8c8d0b8
Merge pull request #1227 from frappe/mergify/bp/main-hotfix/pr-1226
fix: if contact email is updated it is updating previously opened contact (backport #1226)
2025-09-05 16:15:01 +05:30
Frappe PR Bot
7b8cc6caa3 chore(release): Bumped to Version 1.52.9 2025-08-26 07:03:49 +00:00
Shariq Ansari
d1e66cd5bb
Merge pull request #1187 from frappe/main-hotfix 2025-08-26 12:32:46 +05:30
Frappe PR Bot
066371bd76 chore(release): Bumped to Version 1.52.8 2025-08-19 08:12:22 +00:00
Shariq Ansari
f61a40698d
Merge pull request #1166 from frappe/mergify/bp/main/pr-1164
fix: More fixes (backport #1164)
2025-08-19 13:39:38 +05:30
Shariq Ansari
e6781ea4bb fix: remove read-only attribute from TwiML SID field
(cherry picked from commit 247a7c4da63e8aa639383469ad11d433421f4c71)
2025-08-19 08:08:46 +00:00
Shariq Ansari
4592dfcd13 fix: add immediate execution to watch for assignees updates
(cherry picked from commit f2d87fa801b2c906e182f85bf8e7b29f52ccab4a)
2025-08-19 08:08:45 +00:00
Frappe PR Bot
bc35d6b98f chore(release): Bumped to Version 1.52.7 2025-08-19 06:58:06 +00:00
Shariq Ansari
3fbd40b591
Merge pull request #1163 from frappe/mergify/bp/main/pr-1161
fix: More fixes (backport #1161)
2025-08-19 12:27:16 +05:30
Shariq Ansari
5212a61388 fix: update Dropdown styling in SLASection component
(cherry picked from commit edd0ec5f68eac0bfd9005adbbbd95fedd0c8e337)
2025-08-19 06:56:52 +00:00
Shariq Ansari
0d1c057cf3 fix: align action buttons in GridFieldsEditorModal
(cherry picked from commit a76bd2cab2b4dd2a8a9038f6890f3b9294a375a2)
2025-08-19 06:56:52 +00:00
Shariq Ansari
992b47e531 fix: convert to deal modals's convert button
(cherry picked from commit 25d9d562e6b150d94ba1b3065945fd0c12f10c43)
2025-08-19 06:56:51 +00:00
Frappe PR Bot
55dabaf877 chore(release): Bumped to Version 1.52.6 2025-08-19 05:46:20 +00:00
Shariq Ansari
006c7efc06
Merge pull request #1160 from frappe/mergify/bp/main/pr-1158
fix: reject button is rotated (backport #1158)
2025-08-19 11:14:02 +05:30
Shariq Ansari
9e19e54f75 fix: reject button is rotated
(cherry picked from commit afa96c330bb123067b15526c22d46c6e932ace01)
2025-08-19 05:43:30 +00:00
Frappe PR Bot
295b0f4c2a chore(release): Bumped to Version 1.52.5 2025-08-19 05:19:06 +00:00
Shariq Ansari
7945527fd6
Merge pull request #1157 from frappe/mergify/bp/main/pr-1155
fix: remove unnecessary cache from dashboardItems resource (backport #1155)
2025-08-19 10:48:17 +05:30
Shariq Ansari
696531f392 fix: remove unnecessary cache from dashboardItems resource
(cherry picked from commit c2a1a1b1d2de8a1640e077cc47a377311f5203d8)
2025-08-19 05:18:05 +00:00
Frappe PR Bot
ec467ae126 chore(release): Bumped to Version 1.52.4 2025-08-18 20:56:34 +00:00
Shariq Ansari
579fe78e6f
Merge pull request #1154 from frappe/mergify/bp/main/pr-1152
fix: More fixes (backport #1152)
2025-08-19 02:25:32 +05:30
Shariq Ansari
daaf015462 fix: prevent adding a column with undefined value in ColumnSettings
(cherry picked from commit 5f0bb46bf4f3c5c99a4120409d84366cb2b969c8)
2025-08-18 20:50:30 +00:00
Shariq Ansari
edb68fe08b refactor: note/task modal
(cherry picked from commit f4551a92c553a8c26d9bcd36b1ae21d6a69c53d7)
2025-08-18 20:50:29 +00:00
Shariq Ansari
7e736b2892 fix: minor fix
(cherry picked from commit e9c197f46e9a23b8a3e118cf1af22218168f4cb3)
2025-08-18 20:50:29 +00:00
Shariq Ansari
3b18f3a86a fix: grid settings/edit-row button alignment
(cherry picked from commit 3a756630f3699c3f86aa74eff174b9a7e8a21623)
2025-08-18 20:50:29 +00:00
Shariq Ansari
f6cf935c9c refactor: update layout structure for CRM Deal-Data Fields to show products table
(cherry picked from commit a77bfd2acae6e6973d8a9e85c73b0e163a94ea88)
2025-08-18 20:50:28 +00:00
Shariq Ansari
1dfbcd1055 fix: ensure reactive access to document title in Lead component
(cherry picked from commit 1cebc1fed858d3e54bace0e46731e4fd0aff0502)
2025-08-18 20:50:28 +00:00
Shariq Ansari
3326230062 refactor: improve layout and structure of quick filter components
(cherry picked from commit 1a90876500afe5f104f8ff7d263cca942b3506f7)
2025-08-18 20:50:28 +00:00
Shariq Ansari
de4471876f patch: create default loast reasons
(cherry picked from commit c4065b95b8f16d7550ddaff543467fb92e59d17c)
2025-08-18 20:50:28 +00:00
Frappe PR Bot
9c3ddeaf7d chore(release): Bumped to Version 1.52.3 2025-08-18 17:46:55 +00:00
Shariq Ansari
de14eb3ffb
Merge pull request #1151 from frappe/main-hotfix 2025-08-18 23:15:56 +05:30
80 changed files with 29606 additions and 11830 deletions

1
.gitignore vendored
View File

@ -7,6 +7,5 @@ dev-dist
tags
node_modules
crm/public/frontend
frontend/yarn.lock
crm/www/crm.html
build

View File

@ -1,4 +1,4 @@
__version__ = "1.52.2"
__version__ = "1.53.1"
__title__ = "Frappe CRM"

View File

@ -0,0 +1,32 @@
import frappe
@frappe.whitelist()
def get_assignment_rules_list():
assignment_rules = []
for docname in frappe.get_all(
"Assignment Rule", filters={"document_type": ["in", ["CRM Lead", "CRM Deal"]]}
):
doc = frappe.get_value(
"Assignment Rule",
docname,
fieldname=[
"name",
"description",
"disabled",
"priority",
],
as_dict=True,
)
users_exists = bool(frappe.db.exists("Assignment Rule User", {"parent": docname.name}))
assignment_rules.append({**doc, "users_exists": users_exists})
return assignment_rules
@frappe.whitelist()
def duplicate_assignment_rule(docname, new_name):
doc = frappe.get_doc("Assignment Rule", docname)
doc.name = new_name
doc.assignment_rule_name = new_name
doc.insert()
return doc

View File

@ -750,7 +750,11 @@ def getCounts(d, doctype):
@frappe.whitelist()
def get_linked_docs_of_document(doctype, docname):
doc = frappe.get_doc(doctype, docname)
try:
doc = frappe.get_doc(doctype, docname)
except frappe.DoesNotExistError:
return []
linked_docs = get_linked_docs(doc)
dynamic_linked_docs = get_dynamic_linked_docs(doc)
@ -759,7 +763,14 @@ def get_linked_docs_of_document(doctype, docname):
docs_data = []
for doc in linked_docs:
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
if not doc.get("reference_doctype") or not doc.get("reference_docname"):
continue
try:
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
except (frappe.DoesNotExistError, frappe.ValidationError):
continue
title = data.get("title")
if data.doctype == "CRM Call Log":
title = f"Call from {data.get('from')} to {data.get('to')}"
@ -767,6 +778,9 @@ def get_linked_docs_of_document(doctype, docname):
if data.doctype == "CRM Deal":
title = data.get("organization")
if data.doctype == "CRM Notification":
title = data.get("message")
docs_data.append(
{
"doc": data.doctype,
@ -779,25 +793,51 @@ def get_linked_docs_of_document(doctype, docname):
def remove_doc_link(doctype, docname):
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"reference_doctype": None,
"reference_docname": None,
}
)
linked_doc_data.save(ignore_permissions=True)
if not doctype or not docname:
return
try:
linked_doc_data = frappe.get_doc(doctype, docname)
if doctype == "CRM Notification":
delete_notification_type = {
"notification_type_doctype": "",
"notification_type_doc": "",
}
delete_references = {
"reference_doctype": "",
"reference_name": "",
}
if linked_doc_data.get("notification_type_doctype") == linked_doc_data.get("reference_doctype"):
delete_references.update(delete_notification_type)
linked_doc_data.update(delete_references)
else:
linked_doc_data.update(
{
"reference_doctype": "",
"reference_docname": "",
}
)
linked_doc_data.save(ignore_permissions=True)
except (frappe.DoesNotExistError, frappe.ValidationError):
pass
def remove_contact_link(doctype, docname):
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"contact": None,
"contacts": [],
}
)
linked_doc_data.save(ignore_permissions=True)
if not doctype or not docname:
return
try:
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"contact": None,
"contacts": [],
}
)
linked_doc_data.save(ignore_permissions=True)
except (frappe.DoesNotExistError, frappe.ValidationError):
pass
@frappe.whitelist()
@ -806,13 +846,19 @@ def remove_linked_doc_reference(items, remove_contact=None, delete=False):
items = frappe.parse_json(items)
for item in items:
if remove_contact:
remove_contact_link(item["doctype"], item["docname"])
else:
remove_doc_link(item["doctype"], item["docname"])
if not item.get("doctype") or not item.get("docname"):
continue
if delete:
frappe.delete_doc(item["doctype"], item["docname"])
try:
if remove_contact:
remove_contact_link(item["doctype"], item["docname"])
else:
remove_doc_link(item["doctype"], item["docname"])
if delete:
frappe.delete_doc(item["doctype"], item["docname"])
except (frappe.DoesNotExistError, frappe.ValidationError):
# Skip if document doesn't exist or has validation errors
continue
return "success"
@ -821,19 +867,40 @@ def remove_linked_doc_reference(items, remove_contact=None, delete=False):
def delete_bulk_docs(doctype, items, delete_linked=False):
from frappe.desk.reportview import delete_bulk
if not doctype:
frappe.throw("Doctype is required")
if not items:
frappe.throw("Items are required")
items = frappe.parse_json(items)
if not isinstance(items, list):
frappe.throw("Items must be a list")
for doc in items:
linked_docs = get_linked_docs_of_document(doctype, doc)
for linked_doc in linked_docs:
remove_linked_doc_reference(
[
{
"doctype": linked_doc["reference_doctype"],
"docname": linked_doc["reference_docname"],
}
],
remove_contact=doctype == "Contact",
delete=delete_linked,
try:
if not frappe.db.exists(doctype, doc):
frappe.log_error(f"Document {doctype} {doc} does not exist", "Bulk Delete Error")
continue
linked_docs = get_linked_docs_of_document(doctype, doc)
for linked_doc in linked_docs:
if not linked_doc.get("reference_doctype") or not linked_doc.get("reference_docname"):
continue
remove_linked_doc_reference(
[
{
"doctype": linked_doc["reference_doctype"],
"docname": linked_doc["reference_docname"],
}
],
remove_contact=doctype == "Contact",
delete=delete_linked,
)
except Exception as e:
frappe.log_error(
f"Error processing linked docs for {doctype} {doc}: {str(e)}", "Bulk Delete Error"
)
if len(items) > 10:

View File

@ -25,7 +25,7 @@ class CRMDeal(Document):
add_status_change_log(self)
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
self.closed_date = frappe.utils.nowdate()
self.validate_forcasting_fields()
self.validate_forecasting_fields()
self.validate_lost_reason()
self.update_exchange_rate()
@ -151,9 +151,21 @@ class CRMDeal(Document):
if not self.probability or self.probability == 0:
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
def validate_forcasting_fields(self):
def update_expected_deal_value(self):
"""
Update the expected deal value based on the net total or total.
"""
if (
frappe.db.get_single_value("FCRM Settings", "auto_update_expected_deal_value")
and (self.net_total or self.total)
and self.expected_deal_value
):
self.expected_deal_value = self.net_total or self.total
def validate_forecasting_fields(self):
self.update_closed_date()
self.update_default_probability()
self.update_expected_deal_value()
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
if not self.expected_deal_value or self.expected_deal_value == 0:
frappe.throw(_("Expected Deal Value is required."), frappe.MandatoryError)

View File

@ -8,6 +8,7 @@
"defaults_tab",
"restore_defaults",
"enable_forecasting",
"auto_update_expected_deal_value",
"currency_tab",
"currency",
"exchange_rate_provider_section",
@ -105,12 +106,19 @@
{
"fieldname": "column_break_vqck",
"fieldtype": "Column Break"
},
{
"default": "1",
"description": "Automatically update \"Expected Deal Value\" based on the total value of associated products in a deal",
"fieldname": "auto_update_expected_deal_value",
"fieldtype": "Check",
"label": "Auto Update Expected Deal Value"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-29 11:26:50.420614",
"modified": "2025-09-16 17:33:26.406549",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Settings",

View File

@ -25,6 +25,8 @@ def after_install(force=False):
add_standard_dropdown_items()
add_default_scripts()
create_default_manager_dashboard(force)
create_assignment_rule_custom_fields()
add_assignment_rule_property_setters()
frappe.db.commit()
@ -421,3 +423,80 @@ def add_default_scripts():
for doctype in ["CRM Lead", "CRM Deal"]:
create_product_details_script(doctype)
create_forecasting_script()
def add_assignment_rule_property_setters():
"""Add a property setter to the Assignment Rule DocType for assign_condition and unassign_condition."""
default_fields = {
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"doc_type": "Assignment Rule",
"property_type": "Data",
"is_system_generated": 1,
}
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-assign_condition-depends_on"}):
frappe.get_doc(
{
**default_fields,
"name": "Assignment Rule-assign_condition-depends_on",
"field_name": "assign_condition",
"property": "depends_on",
"value": "eval: !doc.assign_condition_json",
}
).insert()
else:
frappe.db.set_value(
"Property Setter",
{"name": "Assignment Rule-assign_condition-depends_on"},
"value",
"eval: !doc.assign_condition_json",
)
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-unassign_condition-depends_on"}):
frappe.get_doc(
{
**default_fields,
"name": "Assignment Rule-unassign_condition-depends_on",
"field_name": "unassign_condition",
"property": "depends_on",
"value": "eval: !doc.unassign_condition_json",
}
).insert()
else:
frappe.db.set_value(
"Property Setter",
{"name": "Assignment Rule-unassign_condition-depends_on"},
"value",
"eval: !doc.unassign_condition_json",
)
def create_assignment_rule_custom_fields():
if not frappe.get_meta("Assignment Rule").has_field("assign_condition_json"):
click.secho("* Installing Custom Fields in Assignment Rule")
create_custom_fields(
{
"Assignment Rule": [
{
"description": "Autogenerated field by CRM App",
"fieldname": "assign_condition_json",
"fieldtype": "Code",
"label": "Assign Condition JSON",
"insert_after": "assign_condition",
"depends_on": "eval: doc.assign_condition_json",
},
{
"description": "Autogenerated field by CRM App",
"fieldname": "unassign_condition_json",
"fieldtype": "Code",
"label": "Unassign Condition JSON",
"insert_after": "unassign_condition",
"depends_on": "eval: doc.unassign_condition_json",
},
],
}
)
frappe.clear_cache(doctype="Assignment Rule")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -15,4 +15,5 @@ crm.patches.v1_0.move_twilio_agent_to_telephony_agent
crm.patches.v1_0.create_default_scripts # 13-06-2025
crm.patches.v1_0.update_deal_status_probabilities
crm.patches.v1_0.update_deal_status_type
crm.patches.v1_0.create_default_lost_reasons
crm.patches.v1_0.create_default_lost_reasons
crm.patches.v1_0.add_fields_in_assignment_rule

View File

@ -0,0 +1,9 @@
from crm.install import (
add_assignment_rule_property_setters,
create_assignment_rule_custom_fields,
)
def execute():
create_assignment_rule_custom_fields()
add_assignment_rule_property_setters()

@ -1 +1 @@
Subproject commit 136f2715c2bd22b7390a2a02f1849a147d16b191
Subproject commit c9a0fc937cc897864857271b3708a0c675379015

3
frontend/.gitignore vendored
View File

@ -2,4 +2,5 @@ node_modules
.DS_Store
dist
dist-ssr
*.local
*.local
components.d.ts

View File

@ -33,7 +33,7 @@ declare module 'vue' {
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
BrandSettings: typeof import('./src/components/Settings/General/BrandSettings.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
@ -63,7 +63,7 @@ declare module 'vue' {
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
CurrencySettings: typeof import('./src/components/Settings/General/CurrencySettings.vue')['default']
CurrencySettings: typeof import('./src/components/Settings/CurrencySettings.vue')['default']
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
DashboardGrid: typeof import('./src/components/Dashboard/DashboardGrid.vue')['default']
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
@ -127,11 +127,10 @@ declare module 'vue' {
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
Filter: typeof import('./src/components/Filter.vue')['default']
FilterIcon: typeof import('./src/components/Icons/FilterIcon.vue')['default']
ForecastingSettings: typeof import('./src/components/Settings/ForecastingSettings.vue')['default']
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
GeneralSettings: typeof import('./src/components/Settings/General/GeneralSettings.vue')['default']
GeneralSettingsPage: typeof import('./src/components/Settings/General/GeneralSettingsPage.vue')['default']
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
@ -142,7 +141,7 @@ declare module 'vue' {
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
HomeActions: typeof import('./src/components/Settings/General/HomeActions.vue')['default']
HomeActions: typeof import('./src/components/Settings/HomeActions.vue')['default']
Icon: typeof import('./src/components/Icon.vue')['default']
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
@ -229,6 +228,7 @@ declare module 'vue' {
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
SortBy: typeof import('./src/components/SortBy.vue')['default']
SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default']
SparkleIcon: typeof import('./src/components/Icons/SparkleIcon.vue')['default']
SquareAsterisk: typeof import('./src/components/Icons/SquareAsterisk.vue')['default']
StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default']
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default']

View File

@ -13,7 +13,7 @@
"@tiptap/extension-paragraph": "^2.12.0",
"@twilio/voice-sdk": "^2.10.2",
"@vueuse/integrations": "^10.3.0",
"frappe-ui": "^0.1.189",
"frappe-ui": "^0.1.201",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -13,7 +13,7 @@
</div>
</div>
<div>
<div class="text-ink-gray-5">
<div class="text-ink-gray-5 text-base">
{{
__('Are you sure you want to delete {0} items?', [
props.items?.length,
@ -53,7 +53,7 @@
</div>
</div>
<div>
<div class="text-ink-gray-5">
<div class="text-ink-gray-5 text-base">
{{
confirmDeleteInfo.delete
? __(

View File

@ -0,0 +1,454 @@
<template>
<div
class="flex gap-2"
:class="[
{
'items-center': !props.isGroup,
},
]"
>
<div
class="flex gap-2 w-full"
:class="[
{
'items-center justify-between': !props.isGroup,
},
]"
>
<div :class="'text-end text-base text-gray-600'">
<div v-if="props.itemIndex == 0" class="min-w-[66px] text-start">
{{ __('Where') }}
</div>
<div v-else class="min-w-[66px] flex items-start">
<Button
variant="subtle"
class="w-max"
@click="toggleConjunction"
icon-right="refresh-cw"
:disabled="props.itemIndex > 2"
:label="conjunction"
/>
</div>
</div>
<div v-if="!props.isGroup" class="flex items-center gap-2 w-full">
<div id="fieldname" class="w-full">
<Autocomplete
:options="filterableFields.data"
v-model="props.condition[0]"
:placeholder="__('Field')"
@update:modelValue="updateField"
/>
</div>
<div id="operator">
<FormControl
v-if="!props.condition[0]"
disabled
type="text"
:placeholder="__('operator')"
class="w-[100px]"
/>
<FormControl
v-else
:disabled="!props.condition[0]"
type="select"
v-model="props.condition[1]"
@change="updateOperator"
:options="getOperators()"
class="w-max min-w-[100px]"
/>
</div>
<div id="value" class="w-full">
<FormControl
v-if="!props.condition[0]"
disabled
type="text"
:placeholder="__('condition')"
class="w-full"
/>
<component
v-else
:is="getValueControl()"
v-model="props.condition[2]"
@change="updateValue"
:placeholder="__('condition')"
/>
</div>
</div>
<CFConditions
v-if="props.isGroup && !(props.level == 2 || props.level == 4)"
:conditions="props.condition"
:isChild="true"
:level="props.level"
:disableAddCondition="props.disableAddCondition"
/>
<Button
variant="outline"
v-if="props.isGroup && (props.level == 2 || props.level == 4)"
@click="show = true"
:label="__('Open nested conditions')"
/>
</div>
<div :class="'w-max'">
<Dropdown placement="right" :options="dropdownOptions">
<Button variant="ghost" icon="more-horizontal" />
</Dropdown>
</div>
</div>
<Dialog
v-model="show"
:options="{ size: '3xl', title: __('Nested conditions') }"
>
<template #body-content>
<CFConditions
:conditions="props.condition"
:isChild="true"
:level="props.level"
:disableAddCondition="props.disableAddCondition"
/>
</template>
</Dialog>
</template>
<script setup>
import {
Autocomplete,
Button,
DatePicker,
DateRangePicker,
DateTimePicker,
Dialog,
Dropdown,
FormControl,
Rating,
} from 'frappe-ui'
import { computed, defineEmits, h, ref } from 'vue'
import GroupIcon from '~icons/lucide/group'
import UnGroupIcon from '~icons/lucide/ungroup'
import CFConditions from './CFConditions.vue'
import { filterableFields } from './filterableFields'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const emit = defineEmits([
'remove',
'unGroupConditions',
'toggleConjunction',
'turnIntoGroup',
])
const props = defineProps({
condition: {
type: Array,
required: true,
},
isChild: {
type: Boolean,
default: false,
},
itemIndex: {
type: Number,
},
level: {
type: Number,
default: 0,
},
isGroup: {
type: Boolean,
default: false,
},
conjunction: {
type: String,
},
disableAddCondition: {
type: Boolean,
default: false,
},
})
const dropdownOptions = computed(() => {
const options = []
if (!props.isGroup && props.level < 4) {
options.push({
label: __('Turn into a group'),
icon: () => h(GroupIcon),
onClick: () => {
emit('turnIntoGroup')
},
})
}
if (props.isGroup) {
options.push({
label: __('Ungroup conditions'),
icon: () => h(UnGroupIcon),
onClick: () => {
emit('unGroupConditions')
},
})
}
options.push({
label: __('Remove'),
icon: 'trash-2',
variant: 'red',
onClick: () => emit('remove'),
condition: () => !props.isGroup,
})
options.push({
label: __('Remove group'),
icon: 'trash-2',
variant: 'red',
onClick: () => emit('remove'),
condition: () => props.isGroup,
})
return options
})
const typeCheck = ['Check']
const typeLink = ['Link', 'Dynamic Link']
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
const typeSelect = ['Select']
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
const typeDate = ['Date', 'Datetime']
const typeRating = ['Rating']
function toggleConjunction() {
emit('toggleConjunction', props.conjunction)
}
const updateField = (field) => {
props.condition[0] = field?.fieldname
resetConditionValue()
}
const resetConditionValue = () => {
props.condition[2] = ''
}
function getValueControl() {
const [field, operator] = props.condition
if (!field) return null
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
if (!fieldData) return null
const { fieldtype, options } = fieldData
if (operator == 'is') {
return h(FormControl, {
type: 'select',
options: [
{
label: 'Set',
value: 'set',
},
{
label: 'Not Set',
value: 'not set',
},
],
})
} else if (['like', 'not like', 'in', 'not in'].includes(operator)) {
return h(FormControl, { type: 'text' })
} else if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
const _options =
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
return h(FormControl, {
type: 'select',
options: _options.map((o) => ({
label: o,
value: o,
})),
})
} else if (typeLink.includes(fieldtype)) {
if (fieldtype == 'Dynamic Link') {
return h(FormControl, { type: 'text' })
}
return h(Link, {
class: 'form-control',
doctype: options,
value: props.condition[2],
})
} else if (typeNumber.includes(fieldtype)) {
return h(FormControl, { type: 'number' })
} else if (typeDate.includes(fieldtype) && operator == 'between') {
return h(DateRangePicker, { value: props.condition[2], iconLeft: '' })
} else if (typeDate.includes(fieldtype)) {
return h(fieldtype == 'Date' ? DatePicker : DateTimePicker, {
value: props.condition[2],
iconLeft: '',
})
} else if (typeRating.includes(fieldtype)) {
return h(Rating, {
modelValue: props.condition[2] || 0,
class: 'truncate',
'update:modelValue': (v) => updateValue(v),
})
} else {
return h(FormControl, { type: 'text' })
}
}
function updateValue(value) {
value = value.target ? value.target.value : value
if (props.condition[1] === 'between') {
props.condition[2] = [value.split(',')[0], value.split(',')[1]]
} else {
props.condition[2] = value + ''
}
}
function getSelectOptions(options) {
return options.split('\n')
}
function updateOperator(event) {
let oldOperatorValue = event.target._value
let newOperatorValue = event.target.value
props.condition[1] = event.target.value
if (!isSameTypeOperator(oldOperatorValue, newOperatorValue)) {
props.condition[2] = getDefaultValue(props.condition[0])
}
resetConditionValue()
}
function getOperators() {
let options = []
const field = props.condition[0]
if (!field) return options
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
if (!fieldData) return options
const { fieldtype, fieldname } = fieldData
if (typeString.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (fieldname === '_assign') {
options = [
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'Is', value: 'is' },
]
}
if (typeNumber.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
{ label: '<', value: '<' },
{ label: '>', value: '>' },
{ label: '<=', value: '<=' },
{ label: '>=', value: '>=' },
],
)
}
if (typeSelect.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeLink.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeCheck.includes(fieldtype)) {
options.push(...[{ label: 'Equals', value: '==' }])
}
if (['Duration'].includes(fieldtype)) {
options.push(
...[
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeDate.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Is', value: 'is' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
{ label: 'Between', value: 'between' },
],
)
}
if (typeRating.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Is', value: 'is' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
],
)
}
const op = options.find((o) => o.value == props.condition[1])
props.condition[1] = op?.value || options[0].value
return options
}
function getDefaultValue(field) {
if (typeSelect.includes(field.fieldtype)) {
return getSelectOptions(field.options)[0]
}
if (typeCheck.includes(field.fieldtype)) {
return 'Yes'
}
if (typeDate.includes(field.fieldtype)) {
return null
}
if (typeRating.includes(field.fieldtype)) {
return 0
}
return ''
}
function isSameTypeOperator(oldOperator, newOperator) {
let textOperators = ['==', '!=', 'in', 'not in', '>', '<', '>=', '<=']
if (
textOperators.includes(oldOperator) &&
textOperators.includes(newOperator)
)
return true
return false
}
</script>

View File

@ -0,0 +1,142 @@
<template>
<div class="rounded-lg border border-outline-gray-2 p-3 flex flex-col gap-4 w-full">
<template v-for="(condition, i) in props.conditions" :key="condition.field">
<CFCondition
v-if="Array.isArray(condition)"
:condition="condition"
:isChild="props.isChild"
:itemIndex="i"
@remove="removeCondition(condition)"
@unGroupConditions="unGroupConditions(condition)"
:level="props.level + 1"
@toggleConjunction="toggleConjunction"
:isGroup="isGroupCondition(condition[0])"
:conjunction="getConjunction()"
@turnIntoGroup="turnIntoGroup(condition)"
:disableAddCondition="props.disableAddCondition"
/>
</template>
<div v-if="props.isChild" class="flex">
<Dropdown v-slot="{ open }" :options="dropdownOptions">
<Button
:disabled="props.disableAddCondition"
:label="__('Add condition')"
icon-left="plus"
:icon-right="open ? 'chevron-up' : 'chevron-down'"
/>
</Dropdown>
</div>
</div>
</template>
<script setup>
import { Button, Dropdown } from 'frappe-ui'
import { computed, watch } from 'vue'
import CFCondition from './CFCondition.vue'
import { filterableFields } from './filterableFields'
const props = defineProps({
conditions: {
type: Array,
required: true,
},
isChild: {
type: Boolean,
default: false,
},
level: {
type: Number,
default: 0,
},
disableAddCondition: {
type: Boolean,
default: false,
},
doctype: {
type: String,
required: true,
},
})
const getConjunction = () => {
let conjunction = 'and'
props.conditions.forEach((condition) => {
if (typeof condition == 'string') {
conjunction = condition
}
})
return conjunction
}
const turnIntoGroup = (condition) => {
props.conditions.splice(props.conditions.indexOf(condition), 1, [condition])
}
const isGroupCondition = (condition) => {
return Array.isArray(condition)
}
const dropdownOptions = computed(() => {
const options = [
{
label: __('Add condition'),
onClick: () => {
const conjunction = getConjunction()
props.conditions.push(conjunction, ['', '', ''])
},
},
]
if (props.level < 3) {
options.push({
label: __('Add condition group'),
onClick: () => {
const conjunction = getConjunction()
props.conditions.push(conjunction, [[]])
},
})
}
return options
})
function removeCondition(condition) {
const conditionIndex = props.conditions.indexOf(condition)
if (conditionIndex == 0) {
props.conditions.splice(conditionIndex, 2)
} else {
props.conditions.splice(conditionIndex - 1, 2)
}
}
function unGroupConditions(condition) {
const conjunction = getConjunction()
const newConditions = condition.map((c) => {
if (typeof c == 'string') {
return conjunction
}
return c
})
const index = props.conditions.indexOf(condition)
if (index !== -1) {
props.conditions.splice(index, 1, ...newConditions)
}
}
function toggleConjunction(conjunction) {
for (let i = 0; i < props.conditions.length; i++) {
if (typeof props.conditions[i] == 'string') {
props.conditions[i] = conjunction == 'and' ? 'or' : 'and'
}
}
}
watch(
() => props.doctype,
(doctype) => {
filterableFields.submit({
doctype,
})
},
{ immediate: true },
)
</script>

View File

@ -0,0 +1,17 @@
import { createResource } from 'frappe-ui'
export const filterableFields = createResource({
url: 'crm.api.doc.get_filterable_fields',
transform: (data) => {
data = data
.filter((field) => !field.fieldname.startsWith('_'))
.map((field) => {
return {
label: field.label,
value: field.fieldname,
...field,
}
})
return data
},
})

View File

@ -1,7 +1,6 @@
<template>
<FileUploader
:file-types="image_type"
class="text-base"
@success="
(file) => {
$emit('upload', file.file_url)
@ -10,21 +9,28 @@
>
<template v-slot="{ progress, uploading, openFileSelector }">
<div class="flex items-end space-x-1">
<Button @click="openFileSelector">
{{
<Button
@click="openFileSelector"
:iconLeft="uploading ? 'cloud-upload' : ImageUpIcon"
:label="
uploading
? `Uploading ${progress}%`
? __('Uploading {0}%', [progress])
: image_url
? 'Change'
: 'Upload'
}}
</Button>
<Button v-if="image_url" @click="$emit('remove')">Remove</Button>
? __('Change')
: __('Upload')
"
/>
<Button
v-if="image_url"
:label="__('Remove')"
@click="$emit('remove')"
/>
</div>
</template>
</FileUploader>
</template>
<script setup>
import ImageUpIcon from '~icons/lucide/image-up'
import { FileUploader, Button } from 'frappe-ui'
const prop = defineProps({
@ -33,10 +39,6 @@ const prop = defineProps({
type: String,
default: 'image/*',
},
label: {
type: String,
default: '',
},
})
const emit = defineEmits(['upload', 'remove'])
</script>

View File

@ -2,7 +2,7 @@
<Dialog v-model="show" :options="{ size: 'xl' }">
<template #body v-if="!confirmDeleteInfo.show">
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-4 flex items-center justify-between">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{
@ -32,11 +32,12 @@
{
label: 'Document',
key: 'title',
width: '19rem',
},
{
label: 'Master',
key: 'reference_doctype',
width: '30%',
width: '12rem',
},
]"
@selectionsChanged="

View File

@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-settings2-icon lucide-settings-2"
>
<path d="M14 17H5" />
<path d="M19 7h-9" />
<circle cx="17" cy="17" r="3" />
<circle cx="7" cy="7" r="3" />
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.37543 1.93494L4.21632 2.21494C4.35232 2.26027 4.44388 2.38738 4.44388 2.53138C4.44388 2.67538 4.35143 2.80249 4.21543 2.84783L3.37454 3.12783L3.09365 3.9696C3.04921 4.10472 2.92121 4.19716 2.7781 4.19716C2.63499 4.19716 2.50787 4.1056 2.46254 3.9696L2.18165 3.12783L1.34076 2.84783C1.20476 2.80249 1.11232 2.67538 1.11232 2.53138C1.11232 2.38738 1.20476 2.26027 1.34076 2.21494L2.18165 1.93494L2.46254 1.09316C2.55321 0.82116 3.00387 0.82116 3.09454 1.09316L3.37543 1.93494ZM8.44852 1.33394C8.3643 1.16325 8.19046 1.05518 8.00012 1.05518C7.80978 1.05518 7.63595 1.16325 7.55173 1.33394L5.67697 5.13368L1.48388 5.74214C1.29552 5.76947 1.13901 5.90137 1.08017 6.08238C1.02133 6.26339 1.07036 6.46211 1.20665 6.59497L4.24065 9.55281L3.52421 13.7284C3.49203 13.916 3.56913 14.1056 3.7231 14.2174C3.87706 14.3293 4.08119 14.3441 4.24966 14.2555L8.11188 12.2253C8.35631 12.0968 8.4503 11.7945 8.32181 11.5501C8.19333 11.3057 7.89102 11.2117 7.64659 11.3402L4.68114 12.899L5.2707 9.46284C5.29853 9.30065 5.24477 9.13514 5.12693 9.02027L2.63025 6.58626L6.08082 6.08555C6.24373 6.06191 6.38457 5.95959 6.45741 5.81196L8.00012 2.6852L9.54284 5.81196C9.61568 5.95959 9.75652 6.06191 9.91943 6.08555L13.37 6.58625L11.6235 8.2887C11.4258 8.48146 11.4218 8.79802 11.6145 8.99575C11.8073 9.19349 12.1239 9.19752 12.3216 9.00476L14.7936 6.59498C14.9299 6.46212 14.9789 6.2634 14.9201 6.08239C14.8612 5.90138 14.7047 5.76947 14.5164 5.74214L10.3233 5.13368L8.44852 1.33394ZM13.4744 11.9911L12.3517 11.6168L11.9775 10.4942C11.8557 10.1315 11.2557 10.1315 11.1339 10.4942L10.7597 11.6168L9.63702 11.9911C9.45569 12.0515 9.33302 12.2213 9.33302 12.4124C9.33302 12.6035 9.45569 12.7733 9.63702 12.8337L10.7597 13.2079L11.1339 14.3306C11.1944 14.5119 11.365 14.6346 11.5561 14.6346C11.7472 14.6346 11.917 14.5119 11.9784 14.3306L12.3526 13.2079L13.4752 12.8337C13.6566 12.7733 13.7792 12.6035 13.7792 12.4124C13.7792 12.2213 13.6566 12.0515 13.4752 11.9911H13.4744ZM13.3333 2.88883C13.3333 3.25702 13.0349 3.5555 12.6667 3.5555C12.2985 3.5555 12 3.25702 12 2.88883C12 2.52064 12.2985 2.22217 12.6667 2.22217C13.0349 2.22217 13.3333 2.52064 13.3333 2.88883Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -26,13 +26,14 @@
<ListRowItem
:item="item"
@click="listViewRef.toggleRow(row['reference_docname'])"
class="!w-full"
>
<template #default="{ label }">
<div
v-if="column.key === 'title'"
class="truncate text-base flex gap-2"
class="truncate text-base flex gap-2 w-full"
>
<span>
<span class="max-w-[90%] truncate">
{{ label }}
</span>
<FeatherIcon
@ -102,6 +103,7 @@ const listViewRef = ref(null)
const viewLinkedDoc = (doc) => {
let page = ''
let id = ''
let openDesk = false
switch (doc.reference_doctype) {
case 'CRM Lead':
page = 'leads'
@ -123,6 +125,11 @@ const viewLinkedDoc = (doc) => {
page = 'organizations'
id = doc.reference_docname
break
case 'CRM Notification':
page = 'crm-notification'
id = doc.reference_docname
openDesk = true
break
case 'FCRM Note':
page = 'notes'
id = `view?open=${doc.reference_docname}`
@ -130,7 +137,11 @@ const viewLinkedDoc = (doc) => {
default:
break
}
window.open(`/crm/${page}/${id}`)
let base = '/crm'
if (openDesk) {
base = '/app'
}
window.open(`${base}/${page}/${id}`)
}
const getDoctypeName = (doctype) => {

View File

@ -47,7 +47,7 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
<Button :label="_task.status" class="justify-between w-full">
<Button :label="_task.status">
<template #prefix>
<TaskStatusIcon :status="_task.status" />
</template>
@ -88,7 +88,7 @@
/>
</div>
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
<Button :label="_task.priority" class="justify-between w-full">
<Button :label="_task.priority">
<template #prefix>
<TaskPriorityIcon :priority="_task.priority" />
</template>

View File

@ -0,0 +1,159 @@
<template>
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__('Assignee Rules')
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{
__('Choose how {0} are assigned among salespeople.', [documentType])
}}
</span>
</div>
<div class="mt-8 flex items-center justify-between gap-2">
<div>
<div class="text-base font-medium text-ink-gray-8">
{{
__('{0} Routing', [
assignmentRuleData.documentType == 'CRM Lead'
? __('Lead')
: __('Deal'),
])
}}
</div>
<div class="text-p-sm text-ink-gray-6 mt-1">
{{
__('Choose how {0} are assigned among the selected assignees.', [
documentType,
])
}}
</div>
</div>
<div>
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-outline-gray-2 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-40"
@click="togglePopover()"
>
<div>
{{
documentRoutingOptions.find(
(option) => option.value == assignmentRuleData.rule,
)?.label
}}
</div>
<FeatherIcon name="chevron-down" class="size-4" />
</div>
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-7 mt-1 w-48 bg-white shadow-xl rounded"
>
<div
v-for="option in documentRoutingOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-gray-50 text-sm flex items-center justify-between rounded"
@click="
() => {
assignmentRuleData.rule = option.value
togglePopover()
}
"
>
<span>
{{ option.label }}
</span>
<FeatherIcon
v-if="assignmentRuleData.rule == option.value"
name="check"
class="size-4"
/>
</div>
</div>
</template>
</Popover>
</div>
</div>
<div class="mt-7 flex items-center justify-between gap-2">
<div>
<div class="text-base font-medium text-ink-gray-8">
{{ __('Assignees') }}
</div>
<div class="text-p-sm text-ink-gray-6 mt-1">
{{ __('Select the assignees for {0}.', [documentType]) }}
</div>
</div>
<AssigneeSearch @addAssignee="validateAssignmentRule('users')" />
</div>
<div class="mt-4 flex flex-wrap gap-2">
<div
v-for="user in users"
:key="user.name"
class="flex items-center gap-2 text-sm bg-surface-gray-2 rounded-md p-1 w-max px-2 select-none"
>
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
<div class="text-ink-gray-7">
{{ user.full_name }}
</div>
<Tooltip
v-if="user.email == assignmentRuleData.lastUser"
:text="__('Last user assigned by this rule')"
:hover-delay="0.35"
:placement="'top'"
>
<div
class="text-xs rounded-full select-none bg-blue-600 text-white p-0.5 px-2"
>
{{ __('Last') }}
</div>
</Tooltip>
<Button variant="ghost" icon="x" @click="removeAssignedUser(user)" />
</div>
</div>
<ErrorMessage :message="assignmentRuleErrors.users" />
</div>
</template>
<script setup>
import { Avatar, Button, ErrorMessage, Popover, Tooltip } from 'frappe-ui'
import AssigneeSearch from './AssigneeSearch.vue'
import { computed, inject } from 'vue'
import { usersStore } from '@/stores/users'
const { getUser } = usersStore()
const assignmentRuleData = inject('assignmentRuleData')
const assignmentRuleErrors = inject('assignmentRuleErrors')
const validateAssignmentRule = inject('validateAssignmentRule')
const documentType = computed(() =>
assignmentRuleData.value.documentType == 'CRM Lead'
? __('leads')
: __('deals'),
)
const documentRoutingOptions = [
{
label: 'Auto-rotate',
value: 'Round Robin',
},
{
label: 'Assign by workload',
value: 'Load Balancing',
},
]
const removeAssignedUser = (user) => {
assignmentRuleData.value.users = assignmentRuleData.value.users.filter(
(u) => u.user !== user.name,
)
validateAssignmentRule('users')
}
const users = computed(() => {
const _users = []
assignmentRuleData.value.users.forEach((user) => {
_users.push(getUser(user.user))
})
return _users
})
</script>

View File

@ -0,0 +1,166 @@
<template>
<Combobox :multiple="true">
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<Button
variant="subtle"
icon-left="plus"
@click="togglePopover()"
:label="__('Add Assignee')"
/>
</template>
<template #body="{ togglePopover }">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl w-60">
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="(e) => debouncedQuery(e.target.value)"
:value="query"
autocomplete="off"
:placeholder="__('Search')"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="query = ''"
>
<FeatherIcon name="x" class="w-4" />
</button>
</div>
<ComboboxOptions class="my-2 max-h-64 overflow-y-auto px-1.5" static>
<ComboboxOption
v-show="usersList.length > 0"
v-for="user in usersList"
:key="user.username"
:value="user"
as="template"
v-slot="{ active }"
@click="
(e) => {
e.stopPropagation()
addAssignee(user)
}
"
>
<li
class="flex items-center rounded p-1.5 w-full text-base"
:class="{ 'bg-gray-100': active }"
>
<div class="flex gap-2 items-center w-full select-none">
<Avatar
:shape="'circle'"
:image="user.user_image"
:label="user.full_name"
size="lg"
/>
<div class="flex flex-col gap-1">
<div class="font-semibold text-ink-gray-7">
{{ user.full_name }}
</div>
<div class="text-ink-gray-6">{{ user.email }}</div>
</div>
</div>
</li>
</ComboboxOption>
<li
v-if="usersList.length == 0"
class="mt-1.5 rounded-md p-1.5 text-base text-gray-600"
>
{{ __('No results found') }}
</li>
</ComboboxOptions>
<div class="border-t p-1.5 pb-0.5 *:w-full">
<Button
variant="ghost"
icon-left="plus"
class="w-full"
:label="__('Invite agent')"
@click="
() => {
inviteAgent()
togglePopover()
}
"
/>
</div>
</div>
</template>
</Popover>
</Combobox>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from '@headlessui/vue'
import { useDebounceFn } from '@vueuse/core'
import { Avatar, Popover } from 'frappe-ui'
import { computed, inject, ref } from 'vue'
import { usersStore } from '@/stores/users'
import { globalStore } from '@/stores/global'
import { activeSettingsPage } from '@/composables/settings'
const emit = defineEmits(['addAssignee'])
const query = ref('')
const { users } = usersStore()
const { $dialog } = globalStore()
const assignmentRuleData = inject('assignmentRuleData')
const debouncedQuery = useDebounceFn((val) => {
query.value = val
}, 300)
const usersList = computed(() => {
let filteredUsers =
users.data?.crmUsers?.filter((user) => user.name !== 'Administrator') || []
return filteredUsers
.filter(
(user) =>
user.name?.includes(query.value) ||
user.full_name?.includes(query.value),
)
.filter((user) => {
return !assignmentRuleData.value.users.some((u) => u.user === user.email)
})
})
const addAssignee = (user) => {
const userExists = assignmentRuleData.value.users.some(
(u) => u.user === user.user,
)
if (!userExists) {
assignmentRuleData.value.users.push({
full_name: user.full_name,
email: user.email,
user_image: user.user_image,
user: user.email,
})
emit('addAssignee', user)
}
}
const inviteAgent = () => {
$dialog({
title: __('Invite agent'),
message: __(
'You will be redirected to invite user page, unsaved changes will be lost.',
),
variant: 'solid',
actions: [
{
label: __('Go to invite page'),
variant: 'solid',
onClick: (close) => {
activeSettingsPage.value = 'Invite User'
close()
},
},
],
})
}
</script>

View File

@ -0,0 +1,193 @@
<template>
<div
class="flex p-3 items-center justify-between cursor-pointer hover:bg-surface-menu-bar rounded"
>
<div class="w-7/12" @click="updateStep('view', data)">
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
<div
v-if="data.description && data.description.length > 0"
class="text-p-base w-full text-ink-gray-5 mt-0.5 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{{ data.description }}
</div>
</div>
<div class="w-3/12">
<Select
class="w-max -ml-2 bg-transparent border-0 text-ink-gray-6 focus-visible:!ring-0 bg-none"
:options="priorityOptions"
v-model="data.priority"
@update:modelValue="onPriorityChange"
/>
</div>
<div class="flex justify-between items-center w-2/12">
<Switch
size="sm"
:modelValue="!data.disabled"
@update:modelValue="onToggle"
/>
<Dropdown placement="right" :options="dropdownOptions">
<Button
icon="more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
</Dropdown>
</div>
</div>
<Dialog
:options="{ title: __('Duplicate Assignment Rule') }"
v-model="duplicateDialog.show"
>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
:label="__('New Assignment Rule Name')"
type="text"
v-model="duplicateDialog.name"
/>
</div>
</template>
<template #actions>
<div class="flex gap-2 justify-end">
<Button
variant="subtle"
:label="__('Close')"
@click="duplicateDialog.show = false"
/>
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Button,
createResource,
Dialog,
Dropdown,
FormControl,
Select,
Switch,
toast,
} from 'frappe-ui'
import { inject, ref } from 'vue'
const assignmentRulesList = inject('assignmentRulesList')
const updateStep = inject('updateStep')
const props = defineProps({
data: {
type: Object,
required: true,
},
})
const priorityOptions = [
{ label: 'Low', value: '0' },
{ label: 'Low-Medium', value: '1' },
{ label: 'Medium', value: '2' },
{ label: 'Medium-High', value: '3' },
{ label: 'High', value: '4' },
]
const duplicateDialog = ref({
show: false,
name: '',
})
const isConfirmingDelete = ref(false)
const deleteAssignmentRule = () => {
createResource({
url: 'frappe.client.delete',
params: {
doctype: 'Assignment Rule',
name: props.data.name,
},
onSuccess: () => {
assignmentRulesList.reload()
isConfirmingDelete.value = false
toast.success(__('Assignment rule deleted'))
},
auto: true,
})
}
const dropdownOptions = [
{
label: __('Duplicate'),
onClick: () => {
duplicateDialog.value = {
show: true,
name: props.data.name + ' (Copy)',
}
},
icon: 'copy',
},
{
label: __('Delete'),
icon: 'trash-2',
onClick: (e) => {
e.preventDefault()
e.stopImmediatePropagation()
isConfirmingDelete.value = true
},
condition: () => !isConfirmingDelete.value,
},
{
label: __('Confirm Delete'),
icon: 'trash-2',
theme: 'red',
onClick: () => deleteAssignmentRule(),
condition: () => isConfirmingDelete.value,
},
]
const duplicate = () => {
createResource({
url: 'crm.api.assignment_rule.duplicate_assignment_rule',
params: {
docname: props.data.name,
new_name: duplicateDialog.value.name,
},
onSuccess: (data) => {
assignmentRulesList.reload()
toast.success(__('Assignment rule duplicated'))
duplicateDialog.value.show = false
duplicateDialog.value.name = ''
updateStep('view', data)
},
auto: true,
})
}
const onPriorityChange = () => {
setAssignmentRuleValue('priority', props.data.priority)
}
const onToggle = () => {
if (!props.data.users_exists && props.data.disabled) {
toast.error(__('Cannot enable rule without adding users in it'))
return
}
setAssignmentRuleValue('disabled', !props.data.disabled, 'status')
}
const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
createResource({
url: 'frappe.client.set_value',
params: {
doctype: 'Assignment Rule',
name: props.data.name,
fieldname: key,
value: value,
},
onSuccess: () => {
assignmentRulesList.reload()
toast.success(__('Assignment rule {0} updated', [fieldName || key]))
},
auto: true,
})
}
</script>

View File

@ -0,0 +1,19 @@
<template>
<AssignmentRules v-if="step.screen === 'list'" />
<AssignmentRuleView v-else-if="step.screen === 'view'" />
</template>
<script setup>
import { ref, provide } from 'vue'
import AssignmentRules from './AssignmentRules.vue'
import AssignmentRuleView from './AssignmentRuleView.vue'
const step = ref({ screen: 'list', data: null })
provide('step', step)
provide('updateStep', updateStep)
function updateStep(newStep, data) {
step.value = { screen: newStep, data }
}
</script>

View File

@ -0,0 +1,782 @@
<template>
<div
v-if="!getAssignmentRuleData.loading"
class="flex flex-col h-full gap-6 px-6 py-8 text-ink-gray-8"
>
<div class="flex items-center justify-between px-2 w-full">
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="
assignmentRuleData.assignmentRuleName || __('New Assignment Rule')
"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
/>
<Badge
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div>
<div class="flex items-center gap-4">
<div
class="flex items-center justify-between gap-2"
@click="assignmentRuleData.disabled = !assignmentRuleData.disabled"
>
<Switch size="sm" :model-value="!assignmentRuleData.disabled" />
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
</div>
<Button
:disabled="Boolean(!isDirty && step.data)"
:label="__('Save')"
theme="gray"
variant="solid"
@click="saveAssignmentRule()"
:loading="isLoading || getAssignmentRuleData.loading"
/>
</div>
</div>
<div class="overflow-y-auto px-2">
<div class="grid grid-cols-2 gap-5">
<div>
<FormControl
:type="'text'"
size="sm"
variant="subtle"
:placeholder="__('Name')"
:label="__('Name')"
v-model="assignmentRuleData.assignmentRuleName"
required
maxlength="50"
@change="validateAssignmentRule('assignmentRuleName')"
/>
<ErrorMessage
:message="assignmentRuleErrors.assignmentRuleName"
class="mt-2"
/>
</div>
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Priority')" />
<Popover>
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-outline-gray-2 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
@click="togglePopover()"
>
<div>
{{
priorityOptions.find(
(option) => option.value == assignmentRuleData.priority,
)?.label
}}
</div>
<FeatherIcon name="chevron-down" class="size-4" />
</div>
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute w-full bg-white shadow-2xl rounded"
>
<div
v-for="option in priorityOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
@click="
() => {
assignmentRuleData.priority = option.value
togglePopover()
}
"
>
{{ option.label }}
<FeatherIcon
v-if="assignmentRuleData.priority == option.value"
name="check"
class="size-4"
/>
</div>
</div>
</template>
</Popover>
</div>
<div>
<FormControl
:type="'textarea'"
size="sm"
variant="subtle"
:placeholder="__('Description')"
:label="__('Description')"
required
maxlength="250"
@change="validateAssignmentRule('description')"
v-model="assignmentRuleData.description"
/>
<ErrorMessage
:message="assignmentRuleErrors.description"
class="mt-2"
/>
</div>
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Apply on')" />
<Select
:options="[
{
label: 'Lead',
value: 'CRM Lead',
},
{
label: 'Deal',
value: 'CRM Deal',
},
]"
v-model="assignmentRuleData.documentType"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__('Assignment condition')
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
{{
__('Choose which {0} are affected by this assignment rule.', [
documentType,
])
}}
<a
class="font-medium underline"
href="https://docs.frappe.io/crm/assignment-rule"
target="_blank"
>{{ __('Learn about conditions') }}</a
>
</span>
<div v-if="isOldSla && step.data">
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap items-center"
>
<span>{{ __('Old Condition') }}</span>
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.assignCondition }}</code>
</div>
</template>
</Popover>
</div>
</div>
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
v-if="!useNewUI && assignmentRuleData.assignCondition"
>
<span class="text-p-sm">
{{ __('Conditions for this rule were created from') }}
<a :href="deskUrl" target="_blank" class="underline">{{
__('desk')
}}</a>
{{
__(
'which are not compatible with this UI, you will need to recreate the conditions here if you want to manage and add new conditions from this UI.',
)
}}
</span>
<Button
:label="__('I understand, add conditions')"
variant="subtle"
theme="gray"
@click="useNewUI = true"
/>
</div>
<AssignmentRulesSection
:conditions="assignmentRuleData.assignConditionJson"
name="assignCondition"
:errors="assignmentRuleErrors.assignConditionError"
:doctype="assignmentRuleData.documentType"
v-else
/>
<div class="flex justify-end">
<ErrorMessage
:message="assignmentRuleErrors.assignCondition"
class="mt-2"
/>
</div>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__('Unassignment condition')
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
{{
__(
'Choose which {0} are affected by this un-assignment rule.',
[documentType],
)
}}
<a
class="font-medium underline"
href="https://docs.frappe.io/crm/assignment-rule"
target="_blank"
>{{ __('Learn about conditions') }}</a
>
</span>
<div
v-if="
isOldSla && step.data && assignmentRuleData.unassignCondition
"
>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap items-center"
>
<span> {{ __('Old Condition') }} </span>
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.unassignCondition }}</code>
</div>
</template>
</Popover>
</div>
</div>
</div>
<div class="mt-5">
<div
v-if="!useNewUI && assignmentRuleData.unassignCondition"
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
>
<span class="text-p-sm">
{{ __('Conditions for this rule were created from') }}
<a :href="deskUrl" target="_blank" class="underline">
{{ __('desk') }}
</a>
{{
__(
'which are not compatible with this UI, you will need to recreate the conditions here if you want to manage and add new conditions from this UI.',
)
}}
</span>
<Button
:label="__('I understand, add conditions')"
variant="subtle"
theme="gray"
@click="useNewUI = true"
/>
</div>
<AssignmentRulesSection
v-else
:conditions="assignmentRuleData.unassignConditionJson"
name="unassignCondition"
:errors="assignmentRuleErrors.unassignConditionError"
:doctype="assignmentRuleData.documentType"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__('Assignment Schedule')
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{
__('Choose the days of the week when this rule should be active.')
}}
</span>
</div>
<div class="mt-6">
<AssignmentSchedule />
</div>
</div>
<hr class="my-8" />
<AssigneeRules />
</div>
</div>
<div v-else class="flex items-center h-full justify-center">
<LoadingIndicator class="w-4" />
</div>
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
:message="showConfirmDialog.message"
:onConfirm="showConfirmDialog.onConfirm"
:onCancel="() => (showConfirmDialog.show = false)"
/>
</template>
<script setup>
import {
Badge,
Button,
call,
createResource,
ErrorMessage,
FormControl,
FormLabel,
LoadingIndicator,
Popover,
Select,
Switch,
toast,
} from 'frappe-ui'
import {
onMounted,
onUnmounted,
ref,
inject,
watch,
provide,
computed,
} from 'vue'
import AssignmentRulesSection from './AssignmentRulesSection.vue'
import AssignmentSchedule from './AssignmentSchedule.vue'
import AssigneeRules from './AssigneeRules.vue'
import ConfirmDialog from 'frappe-ui/src/components/ConfirmDialog.vue'
import { globalStore } from '@/stores/global'
import { disableSettingModalOutsideClick } from '@/composables/settings'
import { convertToConditions, validateConditions } from '@/utils'
const isDirty = ref(false)
const initialData = ref(null)
const isLoading = ref(false)
const updateStep = inject('updateStep')
const step = inject('step')
const { $dialog } = globalStore()
const showConfirmDialog = ref({
show: false,
title: '',
message: '',
onConfirm: () => {},
})
const useNewUI = ref(true)
const isOldSla = ref(false)
const documentType = computed(() =>
assignmentRuleData.value.documentType == 'CRM Lead'
? __('leads')
: __('deals'),
)
const deskUrl = `${window.location.origin}/app/assignment-rule/${step.value.data?.name}`
const defaultAssignmentDays = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]
const assignmentRuleData = ref({
assignCondition: '',
unassignCondition: '',
assignConditionJson: [],
unassignConditionJson: [],
rule: 'Round Robin',
priority: 1,
users: [],
disabled: false,
description: '',
name: '',
assignmentRuleName: '',
assignmentDays: defaultAssignmentDays,
documentType: 'CRM Lead',
})
const validateAssignmentRule = (key, skipConditionCheck = false) => {
const validateField = (field) => {
if (key && field !== key) return
switch (field) {
case 'assignmentRuleName':
if (assignmentRuleData.value.assignmentRuleName?.length == 0) {
assignmentRuleErrors.value.assignmentRuleName = __('Name is required')
} else {
assignmentRuleErrors.value.assignmentRuleName = ''
}
break
case 'description':
assignmentRuleErrors.value.description =
assignmentRuleData.value.description?.length > 0
? ''
: __('Description is required')
break
case 'assignCondition':
if (skipConditionCheck) {
break
}
assignmentRuleErrors.value.assignCondition =
assignmentRuleData.value.assignConditionJson?.length > 0
? ''
: __('Assign condition is required')
if (!validateConditions(assignmentRuleData.value.assignConditionJson)) {
assignmentRuleErrors.value.assignConditionError = __(
'Assign conditions are invalid',
)
} else {
assignmentRuleErrors.value.assignConditionError = ''
}
break
case 'unassignCondition':
if (skipConditionCheck) {
break
}
if (
assignmentRuleData.value.unassignConditionJson?.length > 0 &&
!validateConditions(assignmentRuleData.value.unassignConditionJson)
) {
assignmentRuleErrors.value.unassignConditionError = __(
'Unassign conditions are invalid',
)
} else {
assignmentRuleErrors.value.unassignConditionError = ''
}
break
case 'users':
assignmentRuleErrors.value.users =
assignmentRuleData.value.users?.length > 0
? ''
: __('Users are required')
break
case 'assignmentDays':
assignmentRuleErrors.value.assignmentDays =
assignmentRuleData.value.assignmentDays?.length > 0
? ''
: __('Assignment days are required')
break
default:
break
}
}
if (key) {
validateField(key)
} else {
Object.keys(assignmentRuleErrors.value).forEach(validateField)
}
return assignmentRuleErrors.value
}
const resetAssignmentRuleData = () => {
assignmentRuleData.value = {
assignCondition: '',
unassignCondition: '',
assignConditionJson: [],
unassignConditionJson: [],
rule: 'Round Robin',
priority: 1,
users: [],
disabled: false,
description: '',
name: '',
assignmentRuleName: '',
assignmentDays: defaultAssignmentDays,
documentType: 'CRM Lead',
}
}
const assignmentRuleErrors = ref({
assignmentRuleName: '',
assignCondition: '',
assignConditionError: '',
unassignConditionError: '',
users: '',
description: '',
assignmentDays: '',
})
const resetAssignmentRuleErrors = () => {
Object.keys(assignmentRuleErrors.value).forEach((key) => {
assignmentRuleErrors.value[key] = ''
})
}
provide('assignmentRuleData', assignmentRuleData)
provide('assignmentRuleErrors', assignmentRuleErrors)
provide('validateAssignmentRule', validateAssignmentRule)
provide('resetAssignmentRuleData', resetAssignmentRuleData)
provide('resetAssignmentRuleErrors', resetAssignmentRuleErrors)
const getAssignmentRuleData = createResource({
url: 'frappe.client.get',
params: {
doctype: 'Assignment Rule',
name: step.value.data?.name,
},
auto: Boolean(step.value.data),
onSuccess(data) {
assignmentRuleData.value = {
assignCondition: data.assign_condition,
unassignCondition: data.unassign_condition,
assignConditionJson: JSON.parse(data.assign_condition_json || '[]'),
unassignConditionJson: JSON.parse(data.unassign_condition_json || '[]'),
rule: data.rule,
priority: data.priority,
users: data.users,
disabled: data.disabled,
description: data.description,
name: data.name,
assignmentRuleName: data.name,
assignmentDays: data.assignment_days.map((day) => day.day),
documentType: data.document_type,
}
initialData.value = JSON.stringify(assignmentRuleData.value)
const conditionsAvailable =
assignmentRuleData.value.assignCondition?.length > 0
const conditionsJsonAvailable =
assignmentRuleData.value.assignConditionJson?.length > 0
if (conditionsAvailable && !conditionsJsonAvailable) {
useNewUI.value = false
isOldSla.value = true
} else {
useNewUI.value = true
isOldSla.value = false
}
},
})
if (!step.value.data) {
initialData.value = JSON.stringify(assignmentRuleData.value)
}
const goBack = () => {
if (isDirty.value && !showConfirmDialog.value.show) {
$dialog({
title: __('Unsaved changes'),
message: __(
'Are you sure you want to go back? Unsaved changes will be lost.',
),
variant: 'solid',
actions: [
{
label: __('Go back'),
variant: 'solid',
onClick: (close) => {
updateStep('list', null)
close()
},
},
],
})
return
}
updateStep('list', null)
showConfirmDialog.value.show = false
}
const saveAssignmentRule = () => {
const validationErrors = validateAssignmentRule(undefined, !useNewUI.value)
if (Object.values(validationErrors).some((error) => error)) {
toast.error(
__('Invalid fields, check if all are filled in and values are correct.'),
)
return
}
if (step.value.data) {
if (isOldSla.value && useNewUI.value) {
showConfirmDialog.value = {
show: true,
title: __('Confirm overwrite'),
message: __(
'Your old condition will be overwritten. Are you sure you want to save?',
),
onConfirm: () => {
updateAssignmentRule()
showConfirmDialog.value.show = false
},
}
return
}
updateAssignmentRule()
} else {
createAssignmentRule()
}
}
const createAssignmentRule = () => {
isLoading.value = true
createResource({
url: 'frappe.client.insert',
params: {
doc: {
doctype: 'Assignment Rule',
document_type: assignmentRuleData.value.documentType,
rule: assignmentRuleData.value.rule,
priority: assignmentRuleData.value.priority,
users: assignmentRuleData.value.users,
disabled: assignmentRuleData.value.disabled,
description: assignmentRuleData.value.description,
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
day: day,
})),
name: assignmentRuleData.value.assignmentRuleName,
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
assign_condition: convertToConditions({
conditions: assignmentRuleData.value.assignConditionJson,
}),
unassign_condition: convertToConditions({
conditions: assignmentRuleData.value.unassignConditionJson,
}),
assign_condition_json: JSON.stringify(
assignmentRuleData.value.assignConditionJson,
),
unassign_condition_json: JSON.stringify(
assignmentRuleData.value.unassignConditionJson,
),
},
},
auto: true,
onSuccess(data) {
getAssignmentRuleData
.submit({
doctype: 'Assignment Rule',
name: data.name,
})
.then(() => {
isLoading.value = false
toast.success(__('Assignment rule created'))
})
updateStep('view', data)
},
onError: () => {
isLoading.value = false
},
})
}
const priorityOptions = [
{ label: 'Low', value: '0' },
{ label: 'Low-Medium', value: '1' },
{ label: 'Medium', value: '2' },
{ label: 'Medium-High', value: '3' },
{ label: 'High', value: '4' },
]
const updateAssignmentRule = async () => {
isLoading.value = true
await call('frappe.client.set_value', {
doctype: 'Assignment Rule',
name: assignmentRuleData.value.name,
fieldname: {
rule: assignmentRuleData.value.rule,
priority: assignmentRuleData.value.priority,
users: assignmentRuleData.value.users,
disabled: assignmentRuleData.value.disabled,
description: assignmentRuleData.value.description,
document_type: assignmentRuleData.value.documentType,
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
day: day,
})),
assign_condition: useNewUI.value
? convertToConditions({
conditions: assignmentRuleData.value.assignConditionJson,
})
: assignmentRuleData.value.assignCondition,
unassign_condition: useNewUI.value
? convertToConditions({
conditions: assignmentRuleData.value.unassignConditionJson,
})
: assignmentRuleData.value.unassignCondition,
assign_condition_json: useNewUI.value
? JSON.stringify(assignmentRuleData.value.assignConditionJson)
: null,
unassign_condition_json: useNewUI.value
? JSON.stringify(assignmentRuleData.value.unassignConditionJson)
: null,
},
}).catch((er) => {
const error =
er?.messages?.[0] ||
__('Some error occurred while updating assignment rule')
toast.error(error)
isLoading.value = false
})
if (
assignmentRuleData.value.name !==
assignmentRuleData.value.assignmentRuleName
) {
await call('frappe.client.rename_doc', {
doctype: 'Assignment Rule',
old_name: assignmentRuleData.value.name,
new_name: assignmentRuleData.value.assignmentRuleName,
}).catch(async (er) => {
const error =
er?.messages?.[0] ||
__('Some error occurred while renaming assignment rule')
toast.error(error)
// Reset assignment rule to previous state
await getAssignmentRuleData.reload()
isLoading.value = false
})
await getAssignmentRuleData.submit({
doctype: 'Assignment Rule',
name: assignmentRuleData.value.assignmentRuleName,
})
} else {
getAssignmentRuleData.reload()
}
isLoading.value = false
toast.success(__('Assignment rule updated'))
}
watch(
assignmentRuleData,
(newVal) => {
if (!initialData.value) return
isDirty.value = JSON.stringify(newVal) != initialData.value
if (isDirty.value) {
disableSettingModalOutsideClick.value = true
} else {
disableSettingModalOutsideClick.value = false
}
},
{ deep: true },
)
const beforeUnloadHandler = (event) => {
if (!isDirty.value) return
event.preventDefault()
event.returnValue = true
}
onMounted(() => {
addEventListener('beforeunload', beforeUnloadHandler)
})
onUnmounted(() => {
resetAssignmentRuleErrors()
resetAssignmentRuleData()
removeEventListener('beforeunload', beforeUnloadHandler)
disableSettingModalOutsideClick.value = false
})
</script>

View File

@ -0,0 +1,52 @@
<template>
<div class="flex h-full flex-col gap-6 p-6 text-ink-gray-8">
<!-- Header -->
<div class="flex justify-between px-2 pt-2">
<div class="flex flex-col gap-1 w-9/12">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Assignment rules') }}
</h2>
<p class="text-p-base text-ink-gray-6">
{{
__(
'Assignment rules automatically assign lead/deal to the right sales user based on predefined conditions',
)
}}
</p>
</div>
<div class="flex item-center space-x-2 w-3/12 justify-end">
<Button
:label="__('New')"
icon-left="plus"
variant="solid"
@click="goToNew()"
/>
</div>
</div>
<!-- Assignment rules list -->
<div class="overflow-y-auto">
<AssignmentRulesList />
</div>
</div>
</template>
<script setup>
import AssignmentRulesList from './AssignmentRulesList.vue'
import { createResource } from 'frappe-ui'
import { inject, provide } from 'vue'
const updateStep = inject('updateStep')
const assignmentRulesListData = createResource({
url: 'crm.api.assignment_rule.get_assignment_rules_list',
cache: ['assignmentRules', 'get_assignment_rules_list'],
auto: true,
})
provide('assignmentRulesList', assignmentRulesListData)
const goToNew = () => {
updateStep('view', null)
}
</script>

View File

@ -0,0 +1,43 @@
<template>
<div
v-if="assignmentRulesList.loading && !assignmentRulesList.data"
class="flex items-center justify-center mt-12"
>
<LoadingIndicator class="w-4" />
</div>
<div v-else>
<div
v-if="assignmentRulesList.data?.length === 0"
class="flex items-center justify-center rounded-md border border-outline-gray-2 p-4"
>
<div class="text-sm text-ink-gray-7">
{{ __('No items in the list') }}
</div>
</div>
<div v-else>
<div class="flex items-center py-2 px-4 text-sm text-ink-gray-5">
<div class="w-7/12">{{ __('Assignment rule') }}</div>
<div class="w-3/12">{{ __('Priority') }}</div>
<div class="w-2/12">{{ __('Enabled') }}</div>
</div>
<div class="h-px border-t mx-4 border-outline-gray-modals" />
<div class="overflow-y-auto px-2">
<template
v-for="(assignmentRule, i) in assignmentRulesList.data"
:key="assignmentRule.name"
>
<AssignmentRuleListItem :data="assignmentRule" />
<hr v-if="assignmentRulesList.data.length !== i + 1" class="mx-2" />
</template>
</div>
</div>
</div>
</template>
<script setup>
import { LoadingIndicator } from 'frappe-ui'
import { inject } from 'vue'
import AssignmentRuleListItem from './AssignmentRuleListItem.vue'
const assignmentRulesList = inject('assignmentRulesList')
</script>

View File

@ -0,0 +1,96 @@
<template>
<CFConditions
v-if="props.conditions.length > 0"
:conditions="props.conditions"
:level="0"
:disableAddCondition="props.errors !== ''"
:doctype="props.doctype"
/>
<div
v-if="props.conditions.length == 0"
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-outline-gray-2 text-gray-600 rounded-md"
@click="
() => {
props.conditions.push(['', '', ''])
validateAssignmentRule(props.name)
}
"
>
<FeatherIcon name="plus" class="h-4" />
{{ __('Add a condition') }}
</div>
<div class="flex items-center justify-between mt-2">
<div class="" v-if="props.conditions.length > 0">
<Dropdown v-slot="{ open }" :options="dropdownOptions">
<Button
:disabled="props.errors !== ''"
:icon-right="open ? 'chevron-up' : 'chevron-down'"
:label="__('Add condition')"
/>
</Dropdown>
</div>
<ErrorMessage v-if="props.conditions.length > 0" :message="props.errors" />
</div>
</template>
<script setup>
import { Button, Dropdown, ErrorMessage, FeatherIcon } from 'frappe-ui'
import { watchDebounced } from '@vueuse/core'
import { validateConditions } from '@/utils'
import CFConditions from '../../ConditionsFilter/CFConditions.vue'
import { inject } from 'vue'
const props = defineProps({
conditions: Array,
name: String,
errors: String,
doctype: String,
})
const validateAssignmentRule = inject('validateAssignmentRule')
const getConjunction = () => {
let conjunction = 'and'
props.conditions.forEach((condition) => {
if (typeof condition == 'string') {
conjunction = condition
}
})
return conjunction
}
const dropdownOptions = [
{
label: __('Add condition'),
onClick: () => {
addCondition()
},
},
{
label: __('Add condition group'),
onClick: () => {
const conjunction = getConjunction()
props.conditions.push(conjunction, [[]])
},
},
]
const addCondition = () => {
const isValid = validateConditions(props.conditions)
if (!isValid) {
return
}
const conjunction = getConjunction()
props.conditions.push(conjunction, ['', '', ''])
}
watchDebounced(
() => [...props.conditions],
() => {
validateAssignmentRule(props.name)
},
{ deep: true, debounce: 300 },
)
</script>

View File

@ -0,0 +1,84 @@
<template>
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
<div
class="grid p-2 px-4 items-center"
style="grid-template-columns: 3fr 1fr"
>
<div
v-for="column in columns"
:key="column.key"
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ __(column.label) }}
</div>
</div>
<hr />
<AssignmentScheduleItem
v-for="(day, index) in days"
:key="day.day"
:data="day"
:isLast="index === days.length - 1"
/>
</div>
<ErrorMessage :message="assignmentRuleErrors.assignmentDays" class="mt-2" />
</template>
<script setup>
import AssignmentScheduleItem from './AssignmentScheduleItem.vue'
import { ErrorMessage } from 'frappe-ui'
import { onMounted, ref, inject } from 'vue'
const assignmentRuleData = inject('assignmentRuleData')
const assignmentRuleErrors = inject('assignmentRuleErrors')
const columns = [
{
label: 'Days',
key: 'day',
},
{
label: 'Active',
key: 'active',
},
]
const days = ref([
{
day: 'Monday',
active: false,
},
{
day: 'Tuesday',
active: false,
},
{
day: 'Wednesday',
active: false,
},
{
day: 'Thursday',
active: false,
},
{
day: 'Friday',
active: false,
},
{
day: 'Saturday',
active: false,
},
{
day: 'Sunday',
active: false,
},
])
onMounted(() => {
assignmentRuleData.value.assignmentDays.forEach((day) => {
const workDay = days.value.find((d) => d.day === day)
if (workDay) {
workDay.active = true
}
})
})
</script>

View File

@ -0,0 +1,42 @@
<template>
<div
class="grid py-3.5 px-4 items-center"
style="grid-template-columns: 3fr 1fr"
>
<div class="text-ink-gray-7 font-medium">{{ __(data.day) }}</div>
<div class="flex justify-start">
<Switch v-model="data.active" @update:model-value="toggleDay" />
</div>
</div>
<hr v-if="!isLast" />
</template>
<script setup>
import { Switch } from 'frappe-ui'
import { inject } from 'vue'
const assignmentRuleData = inject('assignmentRuleData')
const props = defineProps({
data: {
type: Object,
required: true,
},
isLast: {
type: Boolean,
default: false,
},
})
const toggleDay = (isActive) => {
const dayIndex = assignmentRuleData.value.assignmentDays.findIndex(
(d) => d === props.data.day,
)
if (isActive && dayIndex === -1) {
assignmentRuleData.value.assignmentDays.push(props.data.day)
} else {
assignmentRuleData.value.assignmentDays.splice(dayIndex, 1)
}
}
</script>

View File

@ -1,27 +1,18 @@
<template>
<div class="flex h-full flex-col gap-6 px-6 py-8 text-ink-gray-8">
<!-- Header -->
<div class="flex px-2 justify-between">
<div class="flex items-center gap-1 -ml-4 w-9/12">
<Button
variant="ghost"
icon-left="chevron-left"
:label="__('Brand settings')"
size="md"
@click="() => emit('updateStep', 'general-settings')"
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
/>
<Badge
v-if="settings.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<div class="flex justify-between px-2 text-ink-gray-8">
<div class="flex flex-col gap-1">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Brand settings') }}
</h2>
<p class="text-p-base text-ink-gray-6">
{{ __('Configure your brand name, logo, and favicon') }}
</p>
</div>
<div class="flex item-center space-x-2 w-3/12 justify-end">
<Button
:label="__('Update')"
icon-left="plus"
variant="solid"
:disabled="!settings.isDirty"
:loading="settings.loading"
@ -36,35 +27,30 @@
<FormControl
type="text"
class="w-1/2"
size="md"
v-model="settings.doc.brand_name"
:label="__('Brand name')"
:placeholder="__('Enter brand name')"
/>
</div>
<!-- logo -->
<div class="flex flex-col justify-between gap-4">
<span class="text-base font-semibold text-ink-gray-8">
{{ __('Logo') }}
</span>
<div class="flex flex-1 gap-5">
<div class="flex items-center flex-1 gap-5">
<div
class="flex items-center justify-center rounded border border-outline-gray-modals px-10 py-2"
class="flex items-center justify-center rounded border border-outline-gray-modals size-20"
>
<img
:src="settings.doc?.brand_logo || '/assets/crm/images/logo.png'"
v-if="settings.doc?.brand_logo"
:src="settings.doc?.brand_logo"
alt="Logo"
class="size-8 rounded"
/>
<ImageIcon v-else class="size-5 text-ink-gray-4" />
</div>
<div class="flex flex-1 flex-col gap-2">
<ImageUploader
label="Favicon"
image_type="image/ico"
:image_url="settings.doc?.brand_logo"
@upload="(url) => (settings.doc.brand_logo = url)"
@remove="() => (settings.doc.brand_logo = '')"
/>
<span class="text-p-sm text-ink-gray-6">
<div class="flex flex-1 flex-col gap-1">
<span class="text-base font-medium">{{ __('Brand logo') }}</span>
<span class="text-p-base text-ink-gray-6">
{{
__(
'Appears in the left sidebar. Recommended size is 32x32 px in PNG or SVG',
@ -72,33 +58,34 @@
}}
</span>
</div>
<div>
<ImageUploader
image_type="image/ico"
:image_url="settings.doc?.brand_logo"
@upload="(url) => (settings.doc.brand_logo = url)"
@remove="() => (settings.doc.brand_logo = '')"
/>
</div>
</div>
</div>
<!-- favicon -->
<div class="flex flex-col justify-between gap-4">
<span class="text-base font-semibold text-ink-gray-8">
{{ __('Favicon') }}
</span>
<div class="flex flex-1 gap-5">
<div class="flex items-center flex-1 gap-5">
<div
class="flex items-center justify-center rounded border border-outline-gray-modals px-10 py-2"
class="flex items-center justify-center rounded border border-outline-gray-modals size-20"
>
<img
:src="settings.doc?.favicon || '/assets/crm/images/logo.png'"
v-if="settings.doc?.favicon"
:src="settings.doc?.favicon"
alt="Favicon"
class="size-8 rounded"
/>
<ImageIcon v-else class="size-5 text-ink-gray-4" />
</div>
<div class="flex flex-1 flex-col gap-2">
<ImageUploader
label="Favicon"
image_type="image/ico"
:image_url="settings.doc?.favicon"
@upload="(url) => (settings.doc.favicon = url)"
@remove="() => (settings.doc.favicon = '')"
/>
<span class="text-p-sm text-ink-gray-6">
<div class="flex flex-1 flex-col gap-1">
<span class="text-base font-medium">{{ __('Favicon') }}</span>
<span class="text-p-base text-ink-gray-6">
{{
__(
'Appears next to the title in your browser tab. Recommended size is 32x32 px in PNG or ICO',
@ -106,20 +93,25 @@
}}
</span>
</div>
<div>
<ImageUploader
image_type="image/ico"
:image_url="settings.doc?.favicon"
@upload="(url) => (settings.doc.favicon = url)"
@remove="() => (settings.doc.favicon = '')"
/>
</div>
</div>
</div>
</div>
<div v-if="errorMessage">
<ErrorMessage :message="__(errorMessage)" />
</div>
</div>
</template>
<script setup>
import ImageIcon from '~icons/lucide/image'
import ImageUploader from '@/components/Controls/ImageUploader.vue'
import { FormControl, ErrorMessage } from 'frappe-ui'
import { FormControl } from 'frappe-ui'
import { getSettings } from '@/stores/settings'
import { showSettings } from '@/composables/settings'
import { ref } from 'vue'
const { _settings: settings, setupBrand } = getSettings()
@ -131,7 +123,4 @@ function updateSettings() {
},
})
}
const emit = defineEmits(['updateStep'])
const errorMessage = ref('')
</script>

View File

@ -1,27 +1,20 @@
<template>
<div class="flex h-full flex-col gap-6 px-6 py-8 text-ink-gray-8">
<!-- Header -->
<div class="flex px-2 justify-between">
<div class="flex items-center gap-1 -ml-4 w-9/12">
<Button
variant="ghost"
icon-left="chevron-left"
:label="__('Currency & Exchange rate provider')"
size="md"
@click="() => emit('updateStep', 'general-settings')"
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
/>
<Badge
v-if="settings.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<div class="flex justify-between px-2 text-ink-gray-8">
<div class="flex flex-col gap-1">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Currency & Exchange rate provider') }}
</h2>
<p class="text-p-base text-ink-gray-6">
{{
__('Configure the currency and exchange rate provider for your CRM')
}}
</p>
</div>
<div class="flex item-center space-x-2 w-3/12 justify-end">
<Button
:label="__('Update')"
icon-left="plus"
variant="solid"
:disabled="!settings.isDirty"
:loading="settings.loading"
@ -32,7 +25,7 @@
<!-- Fields -->
<div class="flex flex-1 flex-col overflow-y-auto">
<div class="flex items-center justify-between gap-8 p-3">
<div class="flex items-center justify-between gap-8 py-3 px-2">
<div class="flex flex-col">
<div class="text-p-base font-medium text-ink-gray-7 truncate">
{{ __('Currency') }}
@ -61,7 +54,7 @@
</div>
</div>
<div class="h-px border-t mx-2 border-outline-gray-modals" />
<div class="flex items-center justify-between gap-8 p-3">
<div class="flex items-center justify-between gap-8 py-3 px-2">
<div class="flex flex-col">
<div class="text-p-base font-medium text-ink-gray-7 truncate">
{{ __('Exchange rate provider') }}
@ -131,17 +124,15 @@
</div>
</template>
<script setup>
import { ErrorMessage, toast } from 'frappe-ui'
import { ErrorMessage, FormControl, toast } from 'frappe-ui'
import { getSettings } from '@/stores/settings'
import { globalStore } from '@/stores/global'
import { showSettings } from '@/composables/settings'
import { ref } from 'vue'
import FormControl from 'frappe-ui/src/components/FormControl/FormControl.vue'
const { _settings: settings } = getSettings()
const { $dialog } = globalStore()
const emit = defineEmits(['updateStep'])
const errorMessage = ref('')
function updateSettings() {

View File

@ -9,10 +9,14 @@
:label="__(template.name)"
size="md"
@click="() => emit('updateStep', 'template-list')"
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
/>
</div>
<div class="flex item-center space-x-2 w-3/12 justify-end">
<div class="flex item-center space-x-4 w-3/12 justify-end">
<div class="flex items-center space-x-2">
<Switch size="sm" v-model="template.enabled" />
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
</div>
<Button
:label="__('Update')"
icon-left="plus"
@ -26,13 +30,6 @@
<!-- Fields -->
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
<div
class="flex justify-between items-center cursor-pointer border-b py-3"
@click="() => (template.enabled = !template.enabled)"
>
<div class="text-base text-ink-gray-7">{{ __('Enabled') }}</div>
<Switch v-model="template.enabled" @click.stop />
</div>
<div class="flex sm:flex-row flex-col gap-4">
<div class="flex-1">
<FormControl

View File

@ -148,7 +148,6 @@
</div>
</template>
<script setup>
import { TemplateOption } from '@/utils'
import {
TextInput,
FormControl,
@ -223,43 +222,28 @@ function getDropdownOptions(template) {
let options = [
{
label: __('Duplicate'),
component: (props) =>
TemplateOption({
option: __('Duplicate'),
icon: 'copy',
active: props.active,
onClick: () => emit('updateStep', 'new-template', { ...template }),
}),
icon: 'copy',
onClick: () => emit('updateStep', 'new-template', { ...template }),
},
{
label: __('Delete'),
component: (props) =>
TemplateOption({
option: __('Delete'),
icon: 'trash-2',
active: props.active,
onClick: (e) => {
e.preventDefault()
e.stopPropagation()
confirmDelete.value = true
},
}),
icon: 'trash-2',
onClick: (e) => {
e.preventDefault()
e.stopPropagation()
confirmDelete.value = true
},
condition: () => !confirmDelete.value,
},
{
label: __('Confirm Delete'),
component: (props) =>
TemplateOption({
option: __('Confirm Delete'),
icon: 'trash-2',
active: props.active,
theme: 'danger',
onClick: () => deleteTemplate(template),
}),
icon: 'trash-2',
theme: 'red',
onClick: () => deleteTemplate(template),
condition: () => confirmDelete.value,
},
]
return options.filter((option) => option.condition?.() || true)
return options
}
</script>

View File

@ -11,10 +11,14 @@
"
size="md"
@click="() => emit('updateStep', 'template-list')"
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
/>
</div>
<div class="flex item-center space-x-2 w-3/12 justify-end">
<div class="flex item-center space-x-4 w-3/12 justify-end">
<div class="flex items-center space-x-2">
<Switch size="sm" v-model="template.enabled" />
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
</div>
<Button
:label="templateData?.name ? __('Duplicate') : __('Create')"
icon-left="plus"
@ -26,13 +30,6 @@
<!-- Fields -->
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
<div
class="flex justify-between items-center cursor-pointer border-b py-3"
@click="() => (template.enabled = !template.enabled)"
>
<div class="text-base text-ink-gray-7">{{ __('Enabled') }}</div>
<Switch v-model="template.enabled" @click.stop />
</div>
<div class="flex sm:flex-row flex-col gap-4">
<div class="flex-1">
<FormControl

View File

@ -0,0 +1,93 @@
<template>
<div class="flex h-full flex-col gap-6 py-8 px-6 text-ink-gray-8">
<div class="flex flex-col gap-1 px-2">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Forecasting') }}
</h2>
<p class="text-p-base text-ink-gray-6">
{{
__(
'Configure forecasting feature to help predict sales performance and growth',
)
}}
</p>
</div>
<div class="flex-1 flex flex-col overflow-y-auto">
<div class="flex items-center justify-between py-3 px-2">
<div class="flex flex-col">
<div class="text-p-base font-medium text-ink-gray-7 truncate">
{{ __('Enable forecasting') }}
</div>
<div class="text-p-sm text-ink-gray-5 truncate">
{{
__(
'Makes "Expected Closure Date" and "Expected Deal Value" mandatory for deal value forecasting',
)
}}
</div>
</div>
<div>
<Switch
size="sm"
v-model="settings.doc.enable_forecasting"
@click.stop="toggleForecasting"
/>
</div>
</div>
<div class="h-px border-t mx-2 border-outline-gray-modals" />
<div class="flex items-center justify-between py-3 px-2">
<div class="flex flex-col">
<div class="text-p-base font-medium text-ink-gray-7 truncate">
{{ __('Auto update expected deal value') }}
</div>
<div class="text-p-sm text-ink-gray-5 truncate">
{{
__(
'Automatically update "Expected Deal Value" based on the total value of associated products in a deal',
)
}}
</div>
</div>
<div>
<Switch
size="sm"
v-model="settings.doc.auto_update_expected_deal_value"
@click.stop="autoUpdateExpectedDealValue"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { getSettings } from '@/stores/settings'
import { Switch, toast } from 'frappe-ui'
const { _settings: settings } = getSettings()
function toggleForecasting() {
settings.save.submit(null, {
onSuccess: () => {
toast.success(
settings.doc.enable_forecasting
? __('Forecasting enabled successfully')
: __('Forecasting disabled successfully'),
)
},
})
}
function autoUpdateExpectedDealValue() {
settings.save.submit(null, {
onSuccess: () => {
toast.success(
settings.doc.auto_update_expected_deal_value
? __('Auto update of expected deal value enabled')
: __('Auto update of expected deal value disabled'),
)
},
})
}
</script>

View File

@ -1,105 +0,0 @@
<template>
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
<div class="flex flex-col gap-1">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('General') }}
</h2>
<p class="text-p-base text-ink-gray-6">
{{ __('Configure general settings for your CRM') }}
</p>
</div>
<div class="flex-1 flex flex-col overflow-y-auto">
<div
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
@click="toggleForecasting()"
>
<div class="flex flex-col">
<div class="text-p-base font-medium text-ink-gray-7 truncate">
{{ __('Enable forecasting') }}
</div>
<div class="text-p-sm text-ink-gray-5 truncate">
{{
__(
'Makes "Expected Closure Date" and "Expected Deal Value" mandatory for deal value forecasting',
)
}}
</div>
</div>
<div>
<Switch
size="sm"
v-model="settings.doc.enable_forecasting"
@click.stop="toggleForecasting(settings.doc.enable_forecasting)"
/>
</div>
</div>
<div class="h-px border-t mx-2 border-outline-gray-modals" />
<template v-for="(setting, i) in settingsList" :key="setting.name">
<li
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
@click="() => emit('updateStep', setting.name)"
>
<div class="flex flex-col">
<div class="text-p-base font-medium text-ink-gray-7 truncate">
{{ __(setting.label) }}
</div>
<div class="text-p-sm text-ink-gray-5 truncate">
{{ __(setting.description) }}
</div>
</div>
<div>
<FeatherIcon name="chevron-right" class="text-ink-gray-7 size-4" />
</div>
</li>
<div
v-if="settingsList.length !== i + 1"
class="h-px border-t mx-2 border-outline-gray-modals"
/>
</template>
</div>
</div>
</template>
<script setup>
import { getSettings } from '@/stores/settings'
import { Switch, toast } from 'frappe-ui'
const emit = defineEmits(['updateStep'])
const { _settings: settings } = getSettings()
const settingsList = [
{
name: 'currency-settings',
label: 'Currency & Exchange rate provider',
description:
'Configure the currency and exchange rate provider for your CRM',
},
{
name: 'brand-settings',
label: 'Brand settings',
description: 'Configure your brand name, logo and favicon',
},
{
name: 'home-actions',
label: 'Home actions',
description: 'Configure actions that appear on the home dropdown',
},
]
function toggleForecasting(value) {
settings.doc.enable_forecasting =
value !== undefined ? value : !settings.doc.enable_forecasting
settings.save.submit(null, {
onSuccess: () => {
toast.success(
settings.doc.enable_forecasting
? __('Forecasting enabled successfully')
: __('Forecasting disabled successfully'),
)
},
})
}
</script>

View File

@ -1,34 +0,0 @@
<template>
<component :is="getComponent(step)" :data="data" @updateStep="updateStep" />
</template>
<script setup>
import GeneralSettings from './GeneralSettings.vue'
import CurrencySettings from './CurrencySettings.vue'
import BrandSettings from './BrandSettings.vue'
import HomeActions from './HomeActions.vue'
import { ref } from 'vue'
const step = ref('general-settings')
const data = ref(null)
function updateStep(newStep, _data) {
step.value = newStep
data.value = _data
}
function getComponent(step) {
switch (step) {
case 'general-settings':
return GeneralSettings
case 'currency-settings':
return CurrencySettings
case 'brand-settings':
return BrandSettings
case 'home-actions':
return HomeActions
default:
return null
}
}
</script>

View File

@ -1,21 +1,18 @@
<template>
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
<!-- Header -->
<div class="flex justify-between">
<div class="flex gap-1 -ml-4 w-9/12">
<Button
variant="ghost"
icon-left="chevron-left"
:label="__('Home actions')"
size="md"
@click="() => emit('updateStep', 'general-settings')"
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
/>
<div class="flex justify-between text-ink-gray-8">
<div class="flex flex-col gap-1">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Home actions') }}
</h2>
<p class="text-p-base text-ink-gray-6">
{{ __('Configure actions that appear on the home dropdown') }}
</p>
</div>
<div class="flex item-center space-x-2 w-3/12 justify-end">
<Button
:label="__('Update')"
icon-left="plus"
variant="solid"
:disabled="!document.isDirty"
:loading="document.loading"
@ -25,7 +22,7 @@
</div>
<!-- Fields -->
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
<div class="flex flex-1 flex-col overflow-y-auto">
<Grid
v-model="document.doc.dropdown_items"
doctype="CRM Dropdown Item"

View File

@ -1,6 +1,6 @@
<template>
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
<div class="flex justify-between">
<div class="flex h-full flex-col gap-6 py-8 px-6 text-ink-gray-8">
<div class="flex px-2 justify-between">
<div class="flex flex-col gap-1 w-9/12">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Send invites to') }}
@ -23,26 +23,21 @@
/>
</div>
</div>
<div class="flex-1 flex flex-col gap-8 overflow-y-auto">
<div class="flex-1 flex flex-col px-2 gap-8 overflow-y-auto">
<div>
<label class="block text-xs text-ink-gray-5 mb-1.5">
{{ __('Invite by email') }}
</label>
<div
class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded"
>
<MultiSelectUserInput
class="flex-1"
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
:placeholder="__('john@doe.com')"
v-model="invitees"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
:fetchUsers="false"
/>
</div>
<FormControl
type="textarea"
label="Invite by email"
placeholder="user1@example.com, user2@example.com, ..."
@input="updateInvitees($event.target.value)"
:debounce="100"
:disabled="inviteByEmail.loading"
:description="
__(
'You can invite multiple users by comma separating their email addresses',
)
"
/>
<div
v-if="userExistMessage || inviteeExistMessage"
class="text-xs text-ink-red-3 mt-1.5"
@ -100,15 +95,9 @@
</div>
</template>
<script setup>
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
import { validateEmail, convertArrayToString } from '@/utils'
import { usersStore } from '@/stores/users'
import {
createListResource,
createResource,
FormControl,
Tooltip,
} from 'frappe-ui'
import { createListResource, createResource, FormControl } from 'frappe-ui'
import { useOnboarding } from 'frappe-ui/frappe'
import { ref, computed } from 'vue'
@ -208,6 +197,15 @@ const pendingInvitations = createListResource({
doctype: 'CRM Invitation',
filters: { status: 'Pending' },
fields: ['name', 'email', 'role'],
pageLength: 999,
auto: true,
})
function updateInvitees(value) {
const emails = value
.split(',')
.map((email) => email.trim())
.filter((email) => validateEmail(email))
invitees.value = emails
}
</script>

View File

@ -3,34 +3,37 @@
v-model="showSettings"
:options="{ size: '5xl' }"
@close="activeSettingsPage = ''"
:disableOutsideClickToClose="disableSettingModalOutsideClick"
>
<template #body>
<div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex flex-col p-2 w-52 shrink-0 bg-surface-gray-2">
<h1 class="px-2 pt-2 mb-3 text-lg font-semibold text-ink-gray-8">
<div class="flex flex-col p-1 w-52 shrink-0 bg-surface-gray-2">
<h1 class="px-3 pt-3 pb-2 text-lg font-semibold text-ink-gray-8">
{{ __('Settings') }}
</h1>
<div v-for="tab in tabs">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-ink-gray-5 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
<nav class="space-y-1">
<SidebarLink
v-for="i in tab.items"
:icon="i.icon"
:label="__(i.label)"
class="w-full"
:class="
activeTab?.label == i.label
? 'bg-surface-selected shadow-sm hover:bg-surface-selected'
: 'hover:bg-surface-gray-3'
"
@click="activeSettingsPage = i.label"
/>
</nav>
<div class="flex flex-col overflow-y-auto">
<template v-for="tab in tabs" :key="tab.label">
<div
v-if="!tab.hideLabel"
class="py-[7px] px-2 my-1 flex cursor-pointer gap-1.5 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
<nav class="space-y-1 px-1">
<SidebarLink
v-for="i in tab.items"
:icon="i.icon"
:label="__(i.label)"
class="w-full"
:class="
activeTab?.label == i.label
? 'bg-surface-selected shadow-sm hover:bg-surface-selected'
: 'hover:bg-surface-gray-3'
"
@click="activeSettingsPage = i.label"
/>
</nav>
</template>
</div>
</div>
<div class="flex flex-col flex-1 overflow-y-auto bg-surface-modal">
@ -41,17 +44,24 @@
</Dialog>
</template>
<script setup>
import CircleDollarSignIcon from '~icons/lucide/circle-dollar-sign'
import TrendingUpDownIcon from '~icons/lucide/trending-up-down'
import SparkleIcon from '@/components/Icons/SparkleIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
import SettingsIcon2 from '@/components/Icons/SettingsIcon2.vue'
import Users from '@/components/Settings/Users.vue'
import GeneralSettingsPage from '@/components/Settings/General/GeneralSettingsPage.vue'
import InviteUserPage from '@/components/Settings/InviteUserPage.vue'
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
import BrandSettings from '@/components/Settings/BrandSettings.vue'
import HomeActions from '@/components/Settings/HomeActions.vue'
import ForecastingSettings from '@/components/Settings/ForecastingSettings.vue'
import CurrencySettings from '@/components/Settings/CurrencySettings.vue'
import EmailTemplatePage from '@/components/Settings/EmailTemplate/EmailTemplatePage.vue'
import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
import EmailConfig from '@/components/Settings/EmailConfig.vue'
@ -61,9 +71,11 @@ import {
isWhatsappInstalled,
showSettings,
activeSettingsPage,
disableSettingModalOutsideClick,
} from '@/composables/settings'
import { Dialog, Avatar } from 'frappe-ui'
import { ref, markRaw, computed, watch, h } from 'vue'
import AssignmentRulePage from './AssignmentRules/AssignmentRulePage.vue'
const { isManager, isTelephonyAgent, getUser } = usersStore()
@ -72,7 +84,7 @@ const user = computed(() => getUser() || {})
const tabs = computed(() => {
let _tabs = [
{
label: __('Settings'),
label: __('Personal Settings'),
hideLabel: true,
items: [
{
@ -85,12 +97,32 @@ const tabs = computed(() => {
}),
component: markRaw(ProfileSettings),
},
],
},
{
label: __('System Configuration'),
items: [
{
label: __('General'),
icon: 'settings',
component: markRaw(GeneralSettingsPage),
condition: () => isManager(),
label: __('Forecasting'),
component: markRaw(ForecastingSettings),
icon: TrendingUpDownIcon,
},
{
label: __('Currency & Exchange Rate'),
icon: CircleDollarSignIcon,
component: markRaw(CurrencySettings),
},
{
label: __('Brand Settings'),
icon: SparkleIcon,
component: markRaw(BrandSettings),
},
],
condition: () => isManager(),
},
{
label: __('User Management'),
items: [
{
label: __('Users'),
icon: 'user',
@ -103,6 +135,12 @@ const tabs = computed(() => {
component: markRaw(InviteUserPage),
condition: () => isManager(),
},
],
condition: () => isManager(),
},
{
label: __('Email Settings'),
items: [
{
label: __('Email Accounts'),
icon: Email2Icon,
@ -116,6 +154,27 @@ const tabs = computed(() => {
},
],
},
{
label: __('Automation & Rules'),
items: [
{
label: __('Assignment rules'),
icon: markRaw(h(SettingsIcon2, { class: 'rotate-90' })),
component: markRaw(AssignmentRulePage),
},
],
},
{
label: __('Customization'),
items: [
{
label: __('Home Actions'),
component: markRaw(HomeActions),
icon: 'home',
},
],
condition: () => isManager(),
},
{
label: __('Integrations', null, 'FCRM'),
items: [

View File

@ -169,8 +169,16 @@
import AddExistingUserModal from '@/components/Modals/AddExistingUserModal.vue'
import { activeSettingsPage } from '@/composables/settings'
import { usersStore } from '@/stores/users'
import { TemplateOption, DropdownOption } from '@/utils'
import { Avatar, TextInput, toast, call, FeatherIcon, Tooltip } from 'frappe-ui'
import { DropdownOption } from '@/utils'
import {
Dropdown,
Avatar,
TextInput,
toast,
call,
FeatherIcon,
Tooltip,
} from 'frappe-ui'
import { ref, computed, onMounted } from 'vue'
const { users, isAdmin, isManager } = usersStore()
@ -208,29 +216,19 @@ function getMoreOptions(user) {
let options = [
{
label: __('Remove'),
component: (props) =>
TemplateOption({
option: __('Remove'),
icon: 'trash-2',
active: props.active,
onClick: (e) => {
e.preventDefault()
e.stopPropagation()
confirmRemove.value = true
},
}),
icon: 'trash-2',
onClick: (e) => {
e.preventDefault()
e.stopPropagation()
confirmRemove.value = true
},
condition: () => !confirmRemove.value,
},
{
label: __('Confirm Remove'),
component: (props) =>
TemplateOption({
option: __('Confirm Remove'),
icon: 'trash-2',
active: props.active,
theme: 'danger',
onClick: () => removeUser(user, true),
}),
icon: 'trash-2',
theme: 'red',
onClick: () => removeUser(user, true),
condition: () => confirmRemove.value,
},
]
@ -242,38 +240,35 @@ function getDropdownOptions(user) {
let options = [
{
label: __('Admin'),
component: (props) =>
component: () =>
DropdownOption({
option: __('Admin'),
icon: 'shield',
active: props.active,
selected: user.role === 'System Manager',
onClick: () => updateRole(user, 'System Manager'),
}),
onClick: () => updateRole(user, 'System Manager'),
condition: () => isAdmin(),
},
{
label: __('Manager'),
component: (props) =>
component: () =>
DropdownOption({
option: __('Manager'),
icon: 'briefcase',
active: props.active,
selected: user.role === 'Sales Manager',
onClick: () => updateRole(user, 'Sales Manager'),
}),
onClick: () => updateRole(user, 'Sales Manager'),
condition: () => isManager(),
},
{
label: __('Sales User'),
component: (props) =>
component: () =>
DropdownOption({
option: __('Sales User'),
icon: 'user-check',
active: props.active,
selected: user.role === 'Sales User',
onClick: () => updateRole(user, 'Sales User'),
}),
onClick: () => updateRole(user, 'Sales User'),
},
]

View File

@ -41,4 +41,7 @@ export const mobileSidebarOpened = ref(false)
export const isMobileView = computed(() => window.innerWidth < 768)
export const showSettings = ref(false)
export const disableSettingModalOutsideClick = ref(false)
export const activeSettingsPage = ref('')

View File

@ -1,7 +1,8 @@
import { getScript } from '@/data/script'
import { globalStore } from '@/stores/global'
import { getMeta } from '@/stores/meta'
import { showSettings, activeSettingsPage } from '@/composables/settings'
import { runSequentially, parseAssignees } from '@/utils'
import { runSequentially, parseAssignees, evaluateExpression } from '@/utils'
import { createDocumentResource, createResource, toast } from 'frappe-ui'
import { ref, reactive } from 'vue'
@ -11,6 +12,7 @@ const assigneesCache = {}
export function useDocument(doctype, docname) {
const { setupScript, scripts } = getScript(doctype)
const meta = getMeta(doctype)
documentsCache[doctype] = documentsCache[doctype] || {}
@ -37,6 +39,7 @@ export function useDocument(doctype, docname) {
}
},
setValue: {
validate,
onSuccess: () => {
triggerOnSave()
toast.success(__('Document updated successfully'))
@ -152,6 +155,42 @@ export function useDocument(doctype, docname) {
return []
}
function validate(d) {
checkMandatory(d.doc || d.fieldname)
}
function checkMandatory(doc) {
let fields = meta?.getFields() || []
if (!fields || fields.length === 0) return
let missingFields = []
fields.forEach((df) => {
let parent = meta?.doctypeMeta?.[df.parent] || null
if (evaluateExpression(df.mandatory_depends_on, doc, parent)) {
const value = doc[df.fieldname]
if (
value === undefined ||
value === null ||
(typeof value === 'string' && value.trim() === '') ||
(Array.isArray(value) && value.length === 0)
) {
missingFields.push(df.label || df.fieldname)
}
}
})
if (missingFields.length > 0) {
toast.error(
__('Mandatory fields required: {0}', [missingFields.join(', ')]),
)
throw new Error(
__('Mandatory fields required: {0}', [missingFields.join(', ')]),
)
}
}
async function triggerOnLoad() {
const handler = async function () {
await (this.onLoad?.() || this.on_load?.() || this.onload?.())
@ -280,6 +319,7 @@ export function useDocument(doctype, docname) {
assignees: assigneesCache[doctype][docname || ''],
scripts,
error,
validate,
getControllers,
triggerOnLoad,
triggerOnBeforeCreate,

View File

@ -45,12 +45,12 @@
onClick: () => deleteNote(note.name),
},
]"
@click.stop
>
<Button
icon="more-horizontal"
variant="ghosted"
class="hover:bg-surface-white"
@click.stop
/>
</Dropdown>
</div>

View File

@ -30,14 +30,14 @@
:image="organization.doc.organization_logo"
/>
<component
:is="organization.doc.image ? Dropdown : 'div'"
:is="organization.doc.organization_logo ? Dropdown : 'div'"
v-bind="
organization.doc.image
organization.doc.organization_logo
? {
options: [
{
icon: 'upload',
label: organization.doc.image
label: organization.doc.organization_logo
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
@ -105,6 +105,7 @@
doctype="CRM Organization"
:docname="organization.doc.name"
@reload="sections.reload"
@beforeFieldChange="beforeFieldChange"
/>
</div>
</Resizer>
@ -180,6 +181,7 @@ import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import DeleteLinkedDocModal from '@/components/DeleteLinkedDocModal.vue'
import { showAddressModal, addressProps } from '@/composables/modals'
import { useDocument } from '@/data/document'
import { getSettings } from '@/stores/settings'
@ -189,21 +191,19 @@ import { statusesStore } from '@/stores/statuses'
import { getView } from '@/utils/view'
import { formatDate, timeAgo, validateIsImageFile } from '@/utils'
import {
Tooltip,
Breadcrumbs,
Avatar,
FileUploader,
Dropdown,
Tabs,
call,
createListResource,
usePageMeta,
createResource,
toast,
call,
} from 'frappe-ui'
import { h, computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import DeleteLinkedDocModal from '@/components/DeleteLinkedDocModal.vue'
import { useRoute, useRouter } from 'vue-router'
const props = defineProps({
organizationId: {
@ -218,6 +218,7 @@ const { getDealStatus } = statusesStore()
const { doctypeMeta } = getMeta('CRM Organization')
const route = useRoute()
const router = useRouter()
const errorTitle = ref('')
const errorMessage = ref('')
@ -277,14 +278,27 @@ async function deleteOrganization() {
showDeleteLinkedDocModal.value = true
}
async function changeOrganizationImage(file) {
await call('frappe.client.set_value', {
doctype: 'CRM Organization',
name: props.organizationId,
fieldname: 'organization_logo',
value: file?.file_url || '',
function changeOrganizationImage(file) {
organization.setValue.submit({
organization_logo: file?.file_url || null,
})
organization.reload()
}
function beforeFieldChange(data) {
if (data?.hasOwnProperty('organization_name')) {
call('frappe.client.rename_doc', {
doctype: 'CRM Organization',
old_name: props.organizationId,
new_name: data.organization_name,
}).then(() => {
router.push({
name: 'Organization',
params: { organizationId: data.organization_name },
})
})
} else {
organization.save.submit()
}
}
function website(url) {

View File

@ -421,6 +421,36 @@ export function evaluateDependsOnValue(expression, doc) {
return out
}
export function evaluateExpression(expression, doc, parent) {
if (!expression) return false
if (!doc) return false
let out = null
if (typeof expression === 'boolean') {
out = expression
} else if (typeof expression === 'function') {
out = expression(doc)
} else if (expression.substr(0, 5) == 'eval:') {
try {
out = _eval(expression.substr(5), { doc, parent })
if (parent && parent.istable && expression.includes('is_submittable')) {
out = true
}
} catch (e) {
out = true
}
} else {
let value = doc[expression]
if (Array.isArray(value)) {
out = !!value.length
} else {
out = !!value
}
}
return out
}
export function convertSize(size) {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let unitIndex = 0
@ -462,23 +492,12 @@ export function runSequentially(functions) {
}, Promise.resolve())
}
export function DropdownOption({
active,
option,
theme,
icon,
onClick,
selected,
}) {
export function DropdownOption({ option, icon, selected }) {
return h(
'button',
{
class: [
active ? 'bg-surface-gray-2' : 'text-ink-gray-8',
'group flex w-full justify-between items-center rounded-md px-2 py-2 text-sm',
theme == 'danger' ? 'text-ink-red-3 hover:bg-ink-red-1' : '',
],
onClick: !selected ? onClick : null,
class:
'group flex w-full text-ink-gray-8 justify-between items-center rounded-md px-2 py-2 text-sm hover:bg-surface-gray-2',
},
[
h('div', { class: 'flex gap-2' }, [
@ -501,31 +520,167 @@ export function DropdownOption({
)
}
export function TemplateOption({ active, option, theme, icon, onClick }) {
return h(
'button',
{
class: [
active ? 'bg-surface-gray-2 text-ink-gray-8' : 'text-ink-gray-7',
'group flex w-full gap-2 items-center rounded-md px-2 py-2 text-sm',
theme == 'danger' ? 'text-ink-red-3 hover:bg-ink-red-1' : '',
],
onClick: onClick,
},
[
icon
? h(FeatherIcon, {
name: icon,
class: ['h-4 w-4 shrink-0'],
'aria-hidden': true,
})
: null,
h('span', { class: 'whitespace-nowrap' }, option),
],
)
}
export function copy(obj) {
if (!obj) return obj
return JSON.parse(JSON.stringify(obj))
}
export const convertToConditions = ({ conditions, fieldPrefix }) => {
if (!conditions || conditions.length === 0) {
return ''
}
const processCondition = (condition) => {
if (typeof condition === 'string') {
return condition.toLowerCase()
}
if (Array.isArray(condition)) {
// Nested condition group
if (Array.isArray(condition[0])) {
const nestedStr = convertToConditions({
conditions: condition,
fieldPrefix,
})
return `(${nestedStr})`
}
// Simple condition: [fieldname, operator, value]
const [field, operator, value] = condition
const fieldAccess = fieldPrefix ? `${fieldPrefix}.${field}` : field
const operatorMap = {
equals: '==',
'=': '==',
'==': '==',
'!=': '!=',
'not equals': '!=',
'<': '<',
'<=': '<=',
'>': '>',
'>=': '>=',
in: 'in',
'not in': 'not in',
like: 'like',
'not like': 'not like',
is: 'is',
'is not': 'is not',
between: 'between',
}
let op = operatorMap[operator.toLowerCase()] || operator
if (
(op === '==' || op === '!=') &&
(String(value).toLowerCase() === 'yes' ||
String(value).toLowerCase() === 'no')
) {
let checkVal = String(value).toLowerCase() === 'yes'
if (op === '!=') {
checkVal = !checkVal
}
return checkVal ? fieldAccess : `not ${fieldAccess}`
}
if (op === 'is' && String(value).toLowerCase() === 'set') {
return fieldAccess
}
if (
(op === 'is' && String(value).toLowerCase() === 'not set') ||
(op === 'is not' && String(value).toLowerCase() === 'set')
) {
return `not ${fieldAccess}`
}
if (op === 'like') {
return `(${fieldAccess} and "${value}" in ${fieldAccess})`
}
if (op === 'not like') {
return `(${fieldAccess} and "${value}" not in ${fieldAccess})`
}
if (
op === 'between' &&
typeof value === 'string' &&
value.includes(',')
) {
const [start, end] = value.split(',').map((v) => v.trim())
return `(${fieldAccess} >= "${start}" and ${fieldAccess} <= "${end}")`
}
let valueStr = ''
if (op === 'in' || op === 'not in') {
let items
if (Array.isArray(value)) {
items = value.map((v) => `"${String(v).trim()}"`)
} else if (typeof value === 'string') {
items = value.split(',').map((v) => `"${v.trim()}"`)
} else {
items = [`"${String(value).trim()}"`]
}
valueStr = `[${items.join(', ')}]`
return `(${fieldAccess} and ${fieldAccess} ${op} ${valueStr})`
}
if (typeof value === 'string') {
valueStr = `"${value.replace(/"/g, '\\"')}"`
} else if (typeof value === 'number' || typeof value === 'boolean') {
valueStr = String(value)
} else if (value === null || value === undefined) {
return op === '==' || op === 'is' ? `not ${fieldAccess}` : fieldAccess
} else {
valueStr = `"${String(value).replace(/"/g, '\\"')}"`
}
return `${fieldAccess} ${op} ${valueStr}`
}
return ''
}
const parts = conditions.map(processCondition)
return parts.join(' ')
}
export function validateConditions(conditions) {
if (!Array.isArray(conditions)) return false
// Handle simple condition [field, operator, value]
if (
conditions.length === 3 &&
typeof conditions[0] === 'string' &&
typeof conditions[1] === 'string'
) {
return conditions[0] !== '' && conditions[1] !== '' && conditions[2] !== ''
}
// Iterate through conditions and logical operators
for (let i = 0; i < conditions.length; i++) {
const item = conditions[i]
// Skip logical operators (they will be validated by their position)
if (item === 'and' || item === 'or') {
// Ensure logical operators are not at start/end and not consecutive
if (
i === 0 ||
i === conditions.length - 1 ||
conditions[i - 1] === 'and' ||
conditions[i - 1] === 'or'
) {
return false
}
continue
}
// Handle nested conditions (arrays)
if (Array.isArray(item)) {
if (!validateConditions(item)) {
return false
}
} else if (item !== undefined && item !== null) {
return false
}
}
return conditions.length > 0
}

View File

@ -2,136 +2,122 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import path from 'path'
import fs from 'fs'
import frappeui from 'frappe-ui/vite'
import { VitePWA } from 'vite-plugin-pwa'
function appPath(app) {
const root = path.resolve(__dirname, '../..') // points to apps
const frontendPaths = [
// Standard frontend structure: appname/frontend/src
path.join(root, app, 'frontend', 'src'),
// Desk-based apps: appname/desk/src
path.join(root, app, 'desk', 'src'),
// Alternative frontend structures
path.join(root, app, 'client', 'src'),
path.join(root, app, 'ui', 'src'),
// Direct src structure: appname/src
path.join(root, app, 'src'),
]
return frontendPaths.find((srcPath) => fs.existsSync(srcPath)) || null
}
function hasApp(app) {
return fs.existsSync(appPath(app))
}
// List of frontend apps used in this project
let apps = []
const alias = [
// Default "@" for this app
{
find: '@',
replacement: path.resolve(__dirname, 'src'),
},
// App-specific aliases like @helpdesk, @hrms, etc.
...apps.map((app) =>
hasApp(app)
? { find: `@${app}`, replacement: appPath(app) }
: { find: `@${app}`, replacement: `virtual:${app}` },
),
]
const defineFlags = Object.fromEntries(
apps.map((app) => [
`__HAS_${app.toUpperCase()}__`,
JSON.stringify(hasApp(app)),
]),
)
const virtualStubPlugin = {
name: 'virtual-empty-modules',
resolveId(id) {
if (id.startsWith('virtual:')) return '\0' + id
},
load(id) {
if (id.startsWith('\0virtual:')) {
return 'export default {}; export const missing = true;'
}
},
}
console.log('Generated app aliases:', alias)
// https://vitejs.dev/config/
export default defineConfig({
define: defineFlags,
plugins: [
frappeui({
frappeProxy: true,
lucideIcons: true,
jinjaBootData: true,
buildConfig: {
indexHtmlPath: '../crm/www/crm.html',
emptyOutDir: true,
sourcemap: true,
},
}),
vue(),
vueJsx(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true,
},
manifest: {
display: 'standalone',
name: 'Frappe CRM',
short_name: 'Frappe CRM',
start_url: '/crm',
description:
'Modern & 100% Open-source CRM tool to supercharge your sales operations',
icons: [
{
src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable',
},
{
src: '/assets/crm/manifest/manifest-icon-512.maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/assets/crm/manifest/manifest-icon-512.maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
}),
virtualStubPlugin,
],
resolve: { alias },
optimizeDeps: {
include: [
'feather-icons',
'showdown',
'tailwind.config.js',
'prosemirror-state',
'prosemirror-view',
'lowlight',
export default defineConfig(async ({ mode }) => {
const isDev = mode === 'development'
const frappeui = await importFrappeUIPlugin(isDev)
const config = {
plugins: [
frappeui({
frappeProxy: true,
lucideIcons: true,
jinjaBootData: true,
buildConfig: {
indexHtmlPath: '../crm/www/crm.html',
emptyOutDir: true,
sourcemap: true,
},
}),
vue(),
vueJsx(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true,
},
manifest: {
display: 'standalone',
name: 'Frappe CRM',
short_name: 'Frappe CRM',
start_url: '/crm',
description:
'Modern & 100% Open-source CRM tool to supercharge your sales operations',
icons: [
{
src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable',
},
{
src: '/assets/crm/manifest/manifest-icon-512.maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/assets/crm/manifest/manifest-icon-512.maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
}),
],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
optimizeDeps: {
include: [
'feather-icons',
'showdown',
'tailwind.config.js',
'prosemirror-state',
'prosemirror-view',
'lowlight',
'interactjs',
],
},
}
// Add local frappe-ui alias only in development if the local frappe-ui exists
if (isDev) {
try {
// Check if the local frappe-ui directory exists
const fs = await import('node:fs')
const localFrappeUIPath = path.resolve(__dirname, '../frappe-ui')
if (fs.existsSync(localFrappeUIPath)) {
config.resolve.alias['frappe-ui'] = localFrappeUIPath
} else {
console.warn('Local frappe-ui directory not found, using npm package')
}
} catch (error) {
console.warn(
'Error checking for local frappe-ui, using npm package:',
error.message,
)
}
}
return config
})
async function importFrappeUIPlugin(isDev) {
if (isDev) {
try {
const module = await import('../frappe-ui/vite')
return module.default
} catch (error) {
console.warn(
'Local frappe-ui not found, falling back to npm package:',
error.message,
)
}
}
// Fall back to npm package if local import fails
const module = await import('frappe-ui/vite')
return module.default
}

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,10 @@
{
"private": true,
"type": "module",
"workspaces": ["frontend", "frappe-ui"],
"scripts": {
"postinstall": "cd frontend && yarn install",
"dev": "cd frontend && yarn dev",
"build": "cd frontend && yarn build",
"disable-workspaces": "sed -i '' 's/\"workspaces\"/\"aworkspaces\"/g' package.json",
"enable-workspaces": "sed -i '' 's/\"aworkspaces\"/\"workspaces\"/g' package.json && rm -rf node_modules ./frontend/node_modules/ frappe-ui/node_modules/ && yarn install",
"upgrade-frappeui": "cd frontend && yarn add frappe-ui@latest && cd ..",
"disable-workspaces-and-upgrade-frappeui": "yarn disable-workspaces && yarn upgrade-frappeui"
"upgrade-frappeui": "cd frontend && yarn add frappe-ui@latest && cd .."
}
}