Compare commits

...

265 Commits

Author SHA1 Message Date
frappe-pr-bot
ed79bf55eb chore: update POT file 2025-06-08 09:36:10 +00:00
Shariq Ansari
fb644f5fbe
Merge pull request #905 from NagariaHussain/fix/image-upload
refactor: DRY up validate image file
2025-06-08 12:49:03 +05:30
Hussain Nagaria
ab409dfd2c fix: yet another unused import due to merge conflict 2025-06-08 12:37:15 +05:30
Hussain Nagaria
42285dd911 fix: unused import due to merge conflict 2025-06-08 12:36:39 +05:30
Md Hussain Nagaria
db2a6c65b7
Merge branch 'develop' into fix/image-upload 2025-06-08 12:34:30 +05:30
Hussain Nagaria
c6ad10857a refactor: DRY up validate image file
* Also, allows more types of image files
2025-06-08 12:30:51 +05:30
Shariq Ansari
f128a55f97
Merge pull request #902 from shariquerik/onload-onsave 2025-06-06 21:15:42 +05:30
Shariq Ansari
2a817e5861 fix: only load assignee if docname is passed 2025-06-06 21:14:20 +05:30
Shariq Ansari
5e616f1a50 refactor: statusOptions code 2025-06-06 21:09:27 +05:30
Shariq Ansari
c6e9d71e1f fix: allow snake & camel case for on load, on save, convert to deal, on create lead 2025-06-06 21:01:20 +05:30
Shariq Ansari
f58d44bf9c fix: use document.doc in status dropdown 2025-06-06 20:49:58 +05:30
Shariq Ansari
f72ab39c93 fix: onLoad & onSave 2025-06-06 20:35:25 +05:30
Shariq Ansari
6c706e6162
Merge pull request #899 from shariquerik/minor-fix 2025-06-06 17:51:43 +05:30
Shariq Ansari
8f81d207b8 fix: activity is not loading 2025-06-06 17:50:48 +05:30
Shariq Ansari
b74c5f384d
Merge pull request #896 from shariquerik/custom-actions 2025-06-06 17:24:13 +05:30
Shariq Ansari
df412d51fe fix: add custom actions using class based script in mobile view 2025-06-06 17:19:27 +05:30
Shariq Ansari
8942bb7e48 feat: add custom statuses using class based script 2025-06-06 17:17:11 +05:30
Shariq Ansari
ca60679126 feat: add custom actions using class based script 2025-06-06 16:38:39 +05:30
Shariq Ansari
8db846ad5d fix: trigger onload method if controller is loaded 2025-06-06 16:37:55 +05:30
Shariq Ansari
bb6a90058b
Merge pull request #894 from shariquerik/refactor-assignees 2025-06-06 14:58:47 +05:30
Shariq Ansari
44df09fac2 fix: removed setuAssignees code 2025-06-06 14:06:02 +05:30
Shariq Ansari
e214ce8bfb refactor: render assignees from document.js
reload assignees if lead_owner/deal_owner is changed
2025-06-05 18:55:28 +05:30
Shariq Ansari
6d281922e4 fix: load assignees in document.js 2025-06-05 18:53:57 +05:30
Shariq Ansari
58f09331b0 fix: remove multiple assignees not working 2025-06-05 18:52:39 +05:30
Shariq Ansari
9780a6b63e
Merge pull request #891 from shariquerik/fixes-2 2025-06-05 16:14:38 +05:30
Shariq Ansari
71f764c224 refactor: set default values for new lead, deal and contact 2025-06-05 16:08:51 +05:30
Shariq Ansari
9362997246 refactor: organization modal code refactor 2025-06-05 16:08:13 +05:30
Shariq Ansari
28ea88f61e refactor: call log modal code refactor 2025-06-05 16:07:40 +05:30
Shariq Ansari
a25ff14dd4 fix: activity is not loading 2025-06-05 15:31:36 +05:30
Shariq Ansari
d86caee7af
Merge pull request #887 from shariquerik/new-doc-issue 2025-06-05 13:56:57 +05:30
Shariq Ansari
c4caabe722 fix: moved address modal to global modals and control it using modals.js 2025-06-04 19:15:12 +05:30
Shariq Ansari
8dcb77634b fix: moved quick entry modal related logic to modals.js & GlobalModals for all pages 2025-06-04 19:14:15 +05:30
Shariq Ansari
c4feed116d fix: handle new document for lead/deal/contact/organization 2025-06-04 19:00:57 +05:30
Shariq Ansari
e220767179 fix: global modals not working in mobile view 2025-06-04 18:44:51 +05:30
Shariq Ansari
571126c36d fix: moved modal related code to modal.js 2025-06-04 18:34:19 +05:30
Shariq Ansari
832323f25e fix: handle new document for call log 2025-06-04 12:50:19 +05:30
Shariq Ansari
3b73432d8c fix: handle controllers for new document 2025-06-04 12:49:14 +05:30
Shariq Ansari
3aa341370b fix: await scripts.list.promise 2025-06-04 12:48:20 +05:30
Ankush Menat
895da1a812
fix: remove invasive settings (#884) 2025-06-02 19:03:54 +05:30
Shariq Ansari
d34ee6fe48
Merge pull request #875 from Ocheretovich/patch-2 2025-06-02 12:37:56 +05:30
Shariq Ansari
7298fe378c
Merge pull request #878 from frappe/pot_develop_2025-06-01 2025-06-02 12:37:01 +05:30
Shariq Ansari
2da0b48c29
Merge pull request #874 from Ocheretovich/patch-1 2025-06-02 12:36:01 +05:30
frappe-pr-bot
165509f5a0 chore: update POT file 2025-06-01 09:36:30 +00:00
Ocheretovich
c9b9dbb092
Update README.md 2025-05-29 13:06:52 +03:00
Ocheretovich
0cc1d5da8f
Update README.md 2025-05-29 13:03:21 +03:00
Pratik Badhe
cf91f3f72a
Merge pull request #872 from pratikb64/export-filter-fix 2025-05-28 17:18:19 +05:30
Pratik
51b87d0ac6 fix: export with filter 2025-05-28 11:09:22 +00:00
Shariq Ansari
c83d7adddd
Merge pull request #868 from shariquerik/prettydate 2025-05-28 13:58:41 +05:30
Shariq Ansari
549665bc61 fix: use prettydate method instead of useTimeAgo 2025-05-28 13:50:01 +05:30
Shariq Ansari
43e1309bd8
Merge pull request #865 from shariquerik/form-script-fix 2025-05-26 17:56:12 +05:30
Shariq Ansari
91f7cf05fc fix: handle script load while setting up script 2025-05-26 17:50:24 +05:30
Shariq Ansari
875431a620 fix: moved setupHelperMethods from setupFormController to evaluateFormClass 2025-05-26 16:57:02 +05:30
Shariq Ansari
db0c0d98bc fix: pass getDoc function instead of document.doc to keep the reactivity 2025-05-26 16:46:24 +05:30
Shariq Ansari
5406f4a11b
Merge pull request #863 from shariquerik/convert-to-deal-script 2025-05-26 15:40:01 +05:30
Shariq Ansari
bfdd3273fe feat: intercept convert to deal via form script 2025-05-26 14:30:15 +05:30
Shariq Ansari
8798103e7e
Merge pull request #859 from frappe/pot_develop_2025-05-25 2025-05-26 12:08:46 +05:30
Shariq Ansari
203b5ab1ac
Merge branch 'develop' into pot_develop_2025-05-25 2025-05-26 12:04:31 +05:30
Shariq Ansari
ed1b26207b
Merge pull request #858 from shariquerik/create-call-log-script 2025-05-26 11:58:49 +05:30
frappe-pr-bot
e0166a08e2 chore: update POT file 2025-05-25 09:35:42 +00:00
Shariq Ansari
8af4e9b5e8 feat: intercept create lead from call log via form script 2025-05-23 21:49:12 +05:30
Shariq Ansari
900c1d3570
Merge pull request #856 from shariquerik/table-multiselect-fix 2025-05-23 20:39:42 +05:30
Shariq Ansari
b95a17a4e0 fix: set default value as empty array 2025-05-23 20:26:43 +05:30
Shariq Ansari
0f0b012a44
Merge pull request #849 from shariquerik/communication-date 2025-05-22 18:53:20 +05:30
Shariq Ansari
b291f82e4d fix: show communication date instead of creation 2025-05-22 18:13:07 +05:30
Pratik Badhe
86b7222916
Merge pull request #847 from pratikb64/filter-selected-filters 2025-05-22 16:41:57 +05:30
Pratik
7a12b80dd2 fix: hide selected filters from filter list 2025-05-22 10:50:39 +00:00
Shariq Ansari
f3b9103a51
Merge pull request #843 from shariquerik/about 2025-05-21 18:00:12 +05:30
Shariq Ansari
dc3ccdddd4 fix: added about link in standard_dropdown_items in hook.py 2025-05-21 17:56:54 +05:30
Shariq Ansari
807eb4a7d9 fix: removed doc & telegram link from user dropdown 2025-05-21 17:51:26 +05:30
Shariq Ansari
a24283eb5e feat: added action to open about modal in user dropdown 2025-05-21 17:50:53 +05:30
Shariq Ansari
fd7116b2e1 feat: show about details in about modal 2025-05-21 17:49:09 +05:30
Shariq Ansari
2e1289df28
Merge pull request #841 from shariquerik/update-toast 2025-05-20 14:37:38 +05:30
Shariq Ansari
6064ca5a4f fix: use toast.create api instead of createToast 2025-05-20 14:35:02 +05:30
Shariq Ansari
3db1b3c0f3 chore: update frappe-ui 2025-05-20 14:21:47 +05:30
Shariq Ansari
06ffa203ef
Merge pull request #838 from shariquerik/invite-member-fix-5 2025-05-20 14:09:38 +05:30
Shariq Ansari
dd1db8f782 fix: added update your password step in onboarding 2025-05-20 14:07:26 +05:30
Shariq Ansari
fe8e309399 feat: added password control 2025-05-20 14:07:15 +05:30
Shariq Ansari
e7a20374c7 fix: set default value as 0 in int field 2025-05-20 14:05:58 +05:30
Shariq Ansari
4cfa0f512b fix: do not show contacts in dropdown in invite member page 2025-05-20 14:05:47 +05:30
Shariq Ansari
64b4f6b759 fix: only set FCRM module if user is Sales User 2025-05-20 14:05:37 +05:30
Shariq Ansari
2d421e6052 fix: allow read permission for form script 2025-05-20 14:05:14 +05:30
Shariq Ansari
cd8dd683fa
Merge pull request #837 from frappe/revert-836-invite-member-fix-3 2025-05-20 13:54:44 +05:30
Shariq Ansari
a2bdc7ab93
Revert "fix: Invite Member Page" 2025-05-20 13:53:35 +05:30
Shariq Ansari
d4132c2411
Merge pull request #836 from shariquerik/invite-member-fix-3 2025-05-20 13:47:44 +05:30
Shariq Ansari
4c6e273268 fix: added update your password step in onboarding 2025-05-20 13:26:02 +05:30
Shariq Ansari
043f174e05 fix: updated components.d.ts 2025-05-20 13:24:42 +05:30
Shariq Ansari
26e9fac1ed feat: added password control 2025-05-20 13:23:26 +05:30
Shariq Ansari
88f33db249 fix: set default value as 0 in int field 2025-05-20 13:17:52 +05:30
Shariq Ansari
55a67bbc0c fix: do not show contacts in dropdown in invite member page 2025-05-20 13:17:20 +05:30
Shariq Ansari
08f042589d fix: only set FCRM module if user is Sales User 2025-05-20 13:16:58 +05:30
Shariq Ansari
52f540a014 fix: allow read permission for form script 2025-05-20 13:15:15 +05:30
Shariq Ansari
e85ef93480
Merge pull request #835 from frappe/revert-833-invite-member-fix-2 2025-05-20 13:09:32 +05:30
Shariq Ansari
a757f80263
Revert "fix: Invite Member Page" 2025-05-20 13:04:02 +05:30
Shariq Ansari
b9b8ff0e10
Merge pull request #833 from shariquerik/invite-member-fix-2 2025-05-20 12:47:27 +05:30
Shariq Ansari
e0aad074ec fix: added update your password step in onboarding 2025-05-19 21:58:03 +05:30
Shariq Ansari
ad88b4e046 feat: added password control 2025-05-19 21:02:10 +05:30
Shariq Ansari
5156814e7a fix: set default value as 0 in int field 2025-05-19 20:41:06 +05:30
Shariq Ansari
f988d16215 fix: use toast.create api instead of createToast 2025-05-19 20:40:11 +05:30
Shariq Ansari
f5a3fccad3 fix: do not show contacts in dropdown in invite member page 2025-05-19 19:05:37 +05:30
Shariq Ansari
e3f0079578 fix: only set FCRM module if user is Sales User 2025-05-19 18:02:30 +05:30
Shariq Ansari
b831ea3c47 fix: allow read permission for form script 2025-05-19 17:58:17 +05:30
Shariq Ansari
a88545b8b9
Merge pull request #831 from shariquerik/onboarding-fixes 2025-05-19 16:51:44 +05:30
Shariq Ansari
44523a0392 fix: store firstLead & firstDeal per user 2025-05-19 16:44:42 +05:30
Shariq Ansari
dbc207a9a6
Merge branch 'develop' into onboarding-fixes 2025-05-19 16:23:35 +05:30
Shariq Ansari
e68d861ee5
Merge pull request #828 from shariquerik/esm-toast 2025-05-19 16:21:16 +05:30
Shariq Ansari
7851bbadfa
Merge branch 'develop' into esm-toast 2025-05-19 16:13:55 +05:30
Shariq Ansari
9223d00af3
Merge pull request #826 from frappe/pot_develop_2025-05-18 2025-05-19 16:12:59 +05:30
Shariq Ansari
740c21532a fix: update Vue compiler options for custom lucide elements 2025-05-19 16:11:19 +05:30
Shariq Ansari
9fdd8bbc17 fix: add @tiptap/extension-paragraph dependency version 2.12.0 2025-05-19 15:56:43 +05:30
Shariq Ansari
0978fa58a2 fix: wrap layout and dialogs in FrappeUIProvider 2025-05-19 15:55:54 +05:30
Shariq Ansari
1395a12d32 build(deps): bump frappeui to 0.1.145 2025-05-19 15:55:37 +05:30
Shariq Ansari
9aab0e7417 fix: update package.json and config files to use ES module syntax 2025-05-19 15:55:07 +05:30
Shariq Ansari
ddc5810c71 fix: change heading to paragraph in invitation email template 2025-05-19 13:40:26 +05:30
Shariq Ansari
21c349e1d7 fix: added dependsOn value on dependent step to gray out 2025-05-19 13:39:06 +05:30
Shariq Ansari
a7784c2985 fix: get filtered steps based on condition 2025-05-19 13:06:38 +05:30
frappe-pr-bot
0cc69d90f0 chore: update POT file 2025-05-18 09:35:38 +00:00
Shariq Ansari
f125737d30
Merge pull request #821 from shariquerik/call-issue 2025-05-15 11:25:13 +05:30
Shariq Ansari
18aef2376a fix: cannot make call 2025-05-15 11:24:27 +05:30
Shariq Ansari
c8287ff107
Merge pull request #818 from shariquerik/contact-not-loading-1 2025-05-15 01:18:00 +05:30
Shariq Ansari
baf344a697 fix: remove updateField event from various components 2025-05-15 01:15:25 +05:30
Shariq Ansari
8c94049e3c fix: contact/organization page not loading 2025-05-15 01:01:48 +05:30
Shariq Ansari
646c76c3cb
Merge pull request #814 from shariquerik/product-details 2025-05-14 23:42:46 +05:30
Shariq Ansari
adbb9f5765 fix: pass doctype argument to get_product_details_script in create_product_details_script 2025-05-14 20:13:34 +05:30
Shariq Ansari
d3a6cc968f fix: add patch to create default scripts 2025-05-14 20:00:48 +05:30
Shariq Ansari
d6ff40cc6a chore: formatting fix 2025-05-14 19:58:39 +05:30
Shariq Ansari
fdd6c46b5f fix: create product detail script on install 2025-05-14 19:57:51 +05:30
Shariq Ansari
26c892c2a0 fix: added products table in crm lead 2025-05-14 19:57:09 +05:30
Shariq Ansari
3516e1ff44 fix: update field visibility logic and disable inputs based on read-only status 2025-05-14 19:08:29 +05:30
Shariq Ansari
0047077074 feat: enhance FormattedInput component with description slot and useAttrs for better attribute handling 2025-05-14 19:01:43 +05:30
Shariq Ansari
8459fac184 fix: set discount amount and net amount fields to read-only 2025-05-14 17:16:32 +05:30
Shariq Ansari
afe828f012 fix: update mandatory field indicator color and replace FormControl with FormattedInput for various field types 2025-05-14 16:55:19 +05:30
Shariq Ansari
60ed0a2043 fix: cache controller on document level not on doctype level 2025-05-14 15:21:47 +05:30
Shariq Ansari
2c9bc07dec fix: added default percent & int to 0 2025-05-14 15:21:07 +05:30
Shariq Ansari
91ba11b565 feat: added callback to update link field value after creating new 2025-05-14 14:04:25 +05:30
Shariq Ansari
8f79427720 fix: select text on focus 2025-05-14 13:34:21 +05:30
Shariq Ansari
32f3aaf38f fix: show formatted percent, currency & float only when not focused 2025-05-14 13:19:54 +05:30
Shariq Ansari
76aaf7f37d feat: added global create document modal for link field 2025-05-12 19:27:41 +05:30
Shariq Ansari
7d37c606cc fix: added columns for product code field 2025-05-12 17:51:36 +05:30
Shariq Ansari
6bce89f277 fix: right aligned number fields 2025-05-12 17:47:49 +05:30
Shariq Ansari
5420fcfe29 fix: render correct currency format 2025-05-12 17:45:05 +05:30
Shariq Ansari
8507c20481 fix: do not show qty and other fields 2025-05-12 17:20:28 +05:30
Shariq Ansari
914dd8bf93 fix: handle this.doc.getRow effectively 2025-05-12 16:37:33 +05:30
Shariq Ansari
960ebdc727 fix: handle field change for float, percent & currency 2025-05-12 16:34:48 +05:30
Shariq Ansari
74ef956638 fix: removed total quantity field 2025-05-12 16:32:09 +05:30
Shariq Ansari
a6323f42af fix: added logic to update amount, net amount, total and net total 2025-05-12 11:58:07 +05:30
Shariq Ansari
bc1c20c91f fix: added crm products table in crm deal and total field 2025-05-12 11:58:07 +05:30
Shariq Ansari
43297373ed fix: added crm product doctype 2025-05-12 11:58:07 +05:30
Shariq Ansari
5228755f7f fix: show formatted percent, currency & float in grid 2025-05-12 11:58:07 +05:30
Shariq Ansari
7ded0a0742 fix: show formatted percent, currency if read only 2025-05-12 11:53:03 +05:30
Shariq Ansari
d74ff9ab62
Merge pull request #811 from shariquerik/required-field-modal-fix 2025-05-12 11:30:32 +05:30
Shariq Ansari
6ef27106df
Merge branch 'develop' into required-field-modal-fix 2025-05-12 11:19:37 +05:30
Shariq Ansari
35a27101c1 fix: error if section is removed and saved 2025-05-12 11:18:47 +05:30
Shariq Ansari
6fbe75c8ad
Merge pull request #803 from frappe/pot_develop_2025-05-04 2025-05-09 20:30:58 +05:30
Shariq Ansari
89fd754efc
Merge pull request #806 from shariquerik/form-script-refactor 2025-05-09 20:24:49 +05:30
Shariq Ansari
576763fe5b fix: enhance error and warning messages with localization support 2025-05-09 18:00:46 +05:30
Shariq Ansari
c67ec08e1a fix: update toast messages for document update success and error handling 2025-05-09 17:53:28 +05:30
Shariq Ansari
6f49573f2f fix: add loading state check to prevent rendering issues in SidePanelLayout 2025-05-09 17:50:43 +05:30
Shariq Ansari
12c3290f19 fix: streamline trigger functions to use a unified handler for controller actions 2025-05-09 17:38:10 +05:30
Shariq Ansari
53c0706a3a feat: implement runSequentially utility for sequential function execution 2025-05-09 17:06:41 +05:30
Shariq Ansari
556386e446 fix: cache controllers and use Promise.all for concurrent execution 2025-05-09 15:22:40 +05:30
Shariq Ansari
07b2d9f792 fix: loop through controllers with multiple instances of multiple scripts and run trigger methods 2025-05-08 18:32:56 +05:30
Shariq Ansari
a2081da296 fix: provide array of instances in controllers if multiple script exist 2025-05-08 18:32:13 +05:30
Shariq Ansari
dde7db9489 fix: remove deprecated setupForm warning and error handling 2025-05-07 19:07:54 +05:30
Shariq Ansari
f947f55fc6 fix: do not show non value fields in dropdown 2025-05-07 19:05:10 +05:30
Shariq Ansari
7bbac6c703 fix: use dayjs for date field default value 2025-05-07 18:26:23 +05:30
Shariq Ansari
420ecb6147 fix: update all fields default value 2025-05-07 18:18:14 +05:30
Shariq Ansari
dcb2787498 feat: handle default value in grid 2025-05-07 18:12:22 +05:30
Shariq Ansari
336083a00f feat: added trigger function on row add & remove 2025-05-07 18:06:38 +05:30
Shariq Ansari
727d0a9acd fix: add doctype, idx, parent, parenttype & parentfield in new grid row 2025-05-07 16:59:22 +05:30
Shariq Ansari
29894ffcca fix: handle commented class declations 2025-05-07 16:38:01 +05:30
Shariq Ansari
e804fa39ba fix: exclude Float & Currency from read only formcontrol 2025-05-07 14:01:39 +05:30
Shariq Ansari
f866284240 fix: allow empty actions 2025-05-07 13:59:55 +05:30
Shariq Ansari
9e3124d29e fix: added triggerOnRefresh & getActions method 2025-05-07 12:15:49 +05:30
Shariq Ansari
d7e0eb09b3 fix: getRow should be available in parent & child instances 2025-05-07 12:14:47 +05:30
Shariq Ansari
5fcd447bc8 fix: added this.meta 2025-05-07 12:13:56 +05:30
Shariq Ansari
6f04b85663 fix: added this.doc.trigger & this.doc.getRow with row.trigger 2025-05-06 13:03:56 +05:30
Shariq Ansari
47262761fe fix: handle section.contacts also 2025-05-06 12:59:31 +05:30
frappe-pr-bot
b46e7a2185 chore: update POT file 2025-05-04 09:35:32 +00:00
Shariq Ansari
2d484c1ad2 fix: handle onchange of grid row field in modal 2025-05-03 15:35:10 +05:30
Shariq Ansari
275fa90a4d fix: added trigger method to call methods from same or different class instance 2025-05-02 18:25:51 +05:30
Shariq Ansari
f8956c70bf fix: handle onchange of grid row field 2025-05-02 16:56:57 +05:30
Shariq Ansari
39fa9c78f8 fix: parse multiple class in form script 2025-05-02 15:56:47 +05:30
Shariq Ansari
d96a29543e fix: added deprecation warning if using old formScript syntax 2025-05-02 08:03:42 +05:30
Shariq Ansari
d2d4abe91f fix: avoid none values 2025-05-02 07:28:40 +05:30
Shariq Ansari
5f567cf138 fix: added change emit in Table bulti select 2025-05-02 06:51:52 +05:30
Shariq Ansari
7bf7d94127 fix: added fieldChange method in almost all fieldtypes 2025-05-01 18:25:25 +05:30
Shariq Ansari
5b8d0d2aeb fix: check if script exist 2025-05-01 18:07:40 +05:30
Shariq Ansari
d37e585205 fix: trigger on change in Field & SidePanelLayout for Select field 2025-05-01 18:03:04 +05:30
Shariq Ansari
a30503ca5f fix: use document to load doc data in sidepanel layout 2025-05-01 18:01:53 +05:30
Shariq Ansari
e65899e384 fix: use document to load doc data in DataFields 2025-05-01 17:57:17 +05:30
Shariq Ansari
16a3f3d66c fix: created triggerOnChange method 2025-05-01 17:56:18 +05:30
Shariq Ansari
1e2f325c55 fix: setup form script in document.js 2025-05-01 17:55:54 +05:30
Shariq Ansari
ccd240f4e8 fix: created document composable to get any doctype record 2025-05-01 17:54:13 +05:30
Shariq Ansari
7b34c5eb66 fix: load script and setup class instances 2025-05-01 17:52:11 +05:30
Shariq Ansari
6da3761e76 fix: check if setupForm exist 2025-05-01 17:27:00 +05:30
Shariq Ansari
b03abdd2eb fix: get scripts api 2025-05-01 17:22:26 +05:30
Shariq Ansari
6ea4e985ef
Merge pull request #787 from frappe/pot_develop_2025-04-27 2025-04-28 12:28:29 +05:30
frappe-pr-bot
699d6cb08c chore: update POT file 2025-04-27 09:35:22 +00:00
Pratik Badhe
ac70deaf19
Merge pull request #781 from pratikb64/call-log-fix
fix: international call log issue
2025-04-23 16:12:59 +05:30
Pratik
4907db44eb fix: international call log issue 2025-04-23 15:54:54 +05:30
Pratik Badhe
81154d1f50
Merge pull request #776 from pratikb64/email-acc-localization
chore: add localization support for email account settings
2025-04-22 15:39:14 +05:30
Pratik
5eb46f6b6c chore: add localization support for email account settings 2025-04-22 15:33:28 +05:30
Shariq Ansari
001a6617f5
Merge pull request #771 from shariquerik/contact-not-loading 2025-04-22 13:11:42 +05:30
Shariq Ansari
c009373a43 fix: do not show error page while loading 2025-04-22 12:59:24 +05:30
Shariq Ansari
cef20e37c2 fix: contact page not loading 2025-04-22 12:57:20 +05:30
Shariq Ansari
20d16c6a32
Merge pull request #759 from frappe/pot_develop_2025-04-20 2025-04-21 14:36:29 +05:30
Shariq Ansari
2fc3daee70
Merge branch 'develop' into pot_develop_2025-04-20 2025-04-21 14:30:57 +05:30
Shariq Ansari
a7955ba9c5
Merge pull request #761 from shariquerik/data-tab-dirty-fix 2025-04-21 11:53:36 +05:30
Shariq Ansari
84e773eab9 fix: do not show error page while loading 2025-04-21 11:46:42 +05:30
Shariq Ansari
da4d3032be fix: mark data tab form dirty by watching field updates 2025-04-21 11:46:19 +05:30
frappe-pr-bot
d89e71ac2f chore: update POT file 2025-04-20 09:35:21 +00:00
Pratik Badhe
de806ee6d9
Merge pull request #753 from pratikb64/email-account-dark-mode
fix: dark mode email account css
2025-04-16 18:10:27 +05:30
Pratik
9c45877999 fix: dark mode email account css 2025-04-16 18:00:28 +05:30
Shariq Ansari
2059ecdb40
Merge pull request #726 from pratikb64/fix-export-logic 2025-04-14 11:19:59 +05:30
Shariq Ansari
52d66b5de4
Merge branch 'develop' into fix-export-logic 2025-04-14 11:15:25 +05:30
Shariq Ansari
fb9b026ad6 fix: restrict app in apps page if no access to FCRM module 2025-04-14 11:05:43 +05:30
Shariq Ansari
8f1b6f6b67
Merge pull request #742 from shariquerik/restrict-app-if-no-module-access-2 2025-04-14 10:36:12 +05:30
Shariq Ansari
0bd448a399 revert: restrict app in apps page if no access to FCRM module 2025-04-14 10:35:28 +05:30
Shariq Ansari
2b395a05ea
Merge pull request #734 from frappe/pot_develop_2025-04-13 2025-04-13 20:14:27 +05:30
Shariq Ansari
dce17de000
Merge pull request #735 from shariquerik/restrict-app-if-no-module-access-1 2025-04-13 20:07:47 +05:30
Shariq Ansari
3881179f72 fix: restrict app in apps page if no access to FCRM module 2025-04-13 19:59:23 +05:30
frappe-pr-bot
da0a502756 chore: update POT file 2025-04-13 09:36:51 +00:00
Pratik
cbf00e29ac refactor: make function names clearer 2025-04-11 18:09:15 +05:30
Pratik
a466766c5c refactor: remove unnecessary watchers 2025-04-08 18:22:44 +05:30
Shariq Ansari
a4781509c4
Merge branch 'develop' into fix-export-logic 2025-04-08 16:37:26 +05:30
Shariq Ansari
8a9361d822 revert: module validation 2025-04-08 16:01:23 +05:30
Shariq Ansari
e2522a492a
Merge pull request #728 from shariquerik/restrict-doc-access
fix: added ErrorPage if user does not have access to doc
2025-04-08 15:41:10 +05:30
Shariq Ansari
bab551c511
Merge branch 'develop' into restrict-doc-access 2025-04-08 15:39:37 +05:30
Shariq Ansari
c63bb16704 ci: added backport to main-hotfix ci 2025-04-08 15:36:32 +05:30
Shariq Ansari
fa56dc4791 fix: show error page if there is no access 2025-04-08 15:28:54 +05:30
Shariq Ansari
e92ee3b730 fix: check read access before loading data 2025-04-08 15:28:19 +05:30
Shariq Ansari
bb794f4887 fix: added ErrorPage component 2025-04-08 15:27:50 +05:30
Pratik
a227389e3e fix: export logic 2025-04-08 15:07:27 +05:30
Shariq Ansari
d9f0b067ca
Merge pull request #722 from shariquerik/added-mergify
ci: added mergify.yml for backport
2025-04-07 21:17:59 +05:30
Shariq Ansari
c0b708462a ci: added mergify.yml for backport 2025-04-07 18:00:59 +05:30
Shariq Ansari
adb0dfff47
Merge pull request #721 from shariquerik/restrict-app-if-no-module-access
fix: restrict app in apps page if no access to FCRM module
2025-04-07 17:37:02 +05:30
Shariq Ansari
6139cb5cb9 fix: restrict app in apps page if no access to FCRM module 2025-04-07 17:31:17 +05:30
Shariq Ansari
61d7924c54
Merge pull request #701 from frappe/pot_develop_2025-03-30
chore: update POT file
2025-04-07 16:51:16 +05:30
Shariq Ansari
899b09ac40
Merge branch 'develop' into pot_develop_2025-03-30 2025-04-07 16:51:07 +05:30
Shariq Ansari
debc9fc1cb
Merge pull request #716 from shariquerik/make-create-call
fix: Create & Make call
2025-04-07 16:49:38 +05:30
Shariq Ansari
5c76adedf3
Merge pull request #712 from shariquerik/dynamic-link
feat: Dynamic Link field support
2025-04-07 16:49:30 +05:30
Shariq Ansari
1ebb26e4c2
Merge pull request #708 from frappe/pot_develop_2025-04-06
chore: update POT file
2025-04-07 16:44:59 +05:30
Shariq Ansari
67378c1f52
Merge pull request #719 from pratikb64/default-assigned-to
fix: default "assigned to" in deals and leads list view
2025-04-07 16:44:32 +05:30
Pratik
469a22ef5f fix: default "assigned to" in deals and leads list view 2025-04-07 16:37:19 +05:30
Shariq Ansari
fdceb51fdc fix: added multi action button to make and create call 2025-04-07 15:34:46 +05:30
Shariq Ansari
97a132e05f fix: show call tab always 2025-04-07 15:32:34 +05:30
Shariq Ansari
26fabddcbe fix: handle feather icon in multi action button 2025-04-07 15:32:09 +05:30
Shariq Ansari
40370067b2 fix: dynamic variant 2025-04-07 14:13:55 +05:30
Shariq Ansari
f0bf6962e7 fix: do not show dropdown if only one option 2025-04-07 14:07:41 +05:30
Shariq Ansari
3b432a0209 fix: added multi action button 2025-04-07 13:58:58 +05:30
Shariq Ansari
c7a03922a0 feat: Dynamic Link field support 2025-04-07 13:16:52 +05:30
frappe-pr-bot
e70b4c091e chore: update POT file 2025-04-06 09:35:28 +00:00
Pratik Badhe
7e38d5e405
Merge pull request #707 from pratikb64/kanban-filter-fix
fix: kanban filter
2025-04-04 17:14:46 +05:30
Pratik
f810e82b45 fix: kanban filter 2025-04-04 17:07:54 +05:30
Pratik Badhe
dff9f93a6b
Merge pull request #704 from pratikb64/make-fields-mandatory
fix: add mandatory fields
2025-04-04 10:26:29 +05:30
Shariq Ansari
c4109ad6ac build(deps): bump frappeui to 0.1.123 2025-04-04 10:09:18 +05:30
Pratik
7a6efb900e fix: add mandatory fields 2025-04-01 17:26:46 +05:30
frappe-pr-bot
e080e47a35 chore: update POT file 2025-03-30 09:35:00 +00:00
Pratik Badhe
82599f91d8
Merge pull request #698 from pratikb64/email-settings-fix
fix: ui alignment
2025-03-28 15:34:36 +05:30
Pratik
8fa156f625 fix: ui alignment 2025-03-28 15:33:40 +05:30
Pratik Badhe
55112cefa9
Merge pull request #697 from pratikb64/email-setting-fix
fix: broken images
2025-03-27 17:38:28 +05:30
Pratik
152c7c8a91 fix: broken images 2025-03-27 17:37:39 +05:30
Pratik Badhe
aa1c0da80e
Merge pull request #696 from pratikb64/add-email-setting
feat: add email account
2025-03-27 15:33:20 +05:30
Pratik
87174f207d feat: add email account 2025-03-27 15:32:37 +05:30
Shariq Ansari
400f879d29 fix: only allow invite by email for Sales Manager & Sales User role 2025-03-26 14:44:40 +05:30
148 changed files with 5474 additions and 1916 deletions

45
.mergify.yml Normal file
View File

@ -0,0 +1,45 @@
pull_request_rules:
- name: Auto-close PRs on stable branch
conditions:
- and:
- and:
- author!=shariquerik
- author!=frappe-pr-bot
- author!=mergify[bot]
- or:
- base=main
actions:
close:
comment:
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
- name: backport to develop
conditions:
- label="backport develop"
actions:
backport:
branches:
- develop
assignees:
- "{{ author }}"
- name: backport to main-hotfix
conditions:
- label="backport main-hotfix"
actions:
backport:
branches:
- main-hotfix
assignees:
- "{{ author }}"
- name: backport to main
conditions:
- label="backport main"
actions:
backport:
branches:
- main
assignees:
- "{{ author }}"

View File

@ -8,7 +8,7 @@
**Simplify Sales, Amplify Relationships**
![GitHub release (latest by date)](https://img.shields.io/github/v/release/frappe/crm)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/frappe/crm)](https://github.com/frappe/crm/releases)
<div>
<picture>
@ -181,6 +181,7 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
- [Discuss Forum](https://discuss.frappe.io/c/frappe-crm)
- [Documentation](https://docs.frappe.io/crm)
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
- [X/Twitter](https://x.com/frappetech)
<br>
<br>

View File

@ -1,9 +1,10 @@
from bs4 import BeautifulSoup
import frappe
from frappe.translate import get_all_translations
from frappe.utils import validate_email_address, split_emails, cstr
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
from bs4 import BeautifulSoup
from frappe.core.api.file import get_max_file_size
from frappe.translate import get_all_translations
from frappe.utils import cstr, split_emails, validate_email_address
from frappe.utils.modules import get_modules_from_all_apps_for_user
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
@frappe.whitelist(allow_guest=True)
@ -63,6 +64,11 @@ def check_app_permission():
if frappe.session.user == "Administrator":
return True
allowed_modules = get_modules_from_all_apps_for_user()
allowed_modules = [x["module_name"] for x in allowed_modules]
if "FCRM" not in allowed_modules:
return False
roles = frappe.get_roles()
if any(
role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles
@ -94,8 +100,13 @@ def accept_invitation(key: str | None = None):
@frappe.whitelist()
def invite_by_email(emails: str, role: str):
frappe.only_for("Sales Manager")
if role not in ["Sales Manager", "Sales User"]:
frappe.throw("Cannot invite for this role")
if not emails:
return
email_string = validate_email_address(emails, throw=False)
email_list = split_emails(email_string)
if not email_list:
@ -112,6 +123,12 @@ def invite_by_email(emails: str, role: str):
for email in to_invite:
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
return {
"existing_members": existing_members,
"existing_invites": existing_invites,
"to_invite": to_invite,
}
@frappe.whitelist()
def get_file_uploader_defaults(doctype: str):

View File

@ -124,6 +124,7 @@ def get_deal_activities(name):
activity = {
"activity_type": "communication",
"communication_type": communication.communication_type,
"communication_date": communication.communication_date or communication.creation,
"creation": communication.creation,
"data": {
"subject": communication.subject,
@ -255,6 +256,7 @@ def get_lead_activities(name):
activity = {
"activity_type": "communication",
"communication_type": communication.communication_type,
"communication_date": communication.communication_date or communication.creation,
"creation": communication.creation,
"data": {
"subject": communication.subject,

View File

@ -23,22 +23,14 @@ def update_deals_email_mobile_no(doc):
@frappe.whitelist()
def get_contact(name):
Contact = frappe.qb.DocType("Contact")
contact = frappe.get_doc("Contact", name)
contact.check_permission("read")
query = frappe.qb.from_(Contact).select("*").where(Contact.name == name).limit(1)
contact = contact.as_dict()
contact = query.run(as_dict=True)
if not len(contact):
frappe.throw(_("Contact not found"), frappe.DoesNotExistError)
contact = contact.pop()
contact["doctype"] = "Contact"
contact["email_ids"] = frappe.get_all(
"Contact Email", filters={"parent": name}, fields=["name", "email_id", "is_primary"]
)
contact["phone_nos"] = frappe.get_all(
"Contact Phone", filters={"parent": name}, fields=["name", "phone", "is_primary_mobile_no"]
)
return contact

View File

@ -3,6 +3,7 @@ import json
import frappe
from frappe import _
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.form.assign_to import set_status
from frappe.model import no_value_fields
from frappe.model.document import get_controller
from frappe.utils import make_filter_tuple
@ -418,16 +419,23 @@ def get_data(
rows.append(field)
for kc in kanban_columns:
column_filters = {column_field: kc.get("name")}
# Start with base filters
column_filters = []
# Convert and add the main filters first
if filters:
base_filters = convert_filter_to_tuple(doctype, filters)
column_filters.extend(base_filters)
# Add the column-specific filter
if column_field and kc.get("name"):
column_filters.append([doctype, column_field, "=", kc.get("name")])
order = kc.get("order")
if (column_field in filters and filters.get(column_field) != kc.get("name")) or kc.get("delete"):
if kc.get("delete"):
column_data = []
else:
column_filters.update(filters.copy())
page_length = 20
if kc.get("page_length"):
page_length = kc.get("page_length")
page_length = kc.get("page_length", 20)
if order:
column_data = get_records_based_on_order(
@ -437,26 +445,20 @@ def get_data(
column_data = frappe.get_list(
doctype,
fields=rows,
filters=convert_filter_to_tuple(doctype, column_filters),
filters=column_filters,
order_by=order_by,
page_length=page_length,
)
new_filters = filters.copy()
new_filters.update({column_field: kc.get("name")})
all_count = frappe.get_list(
doctype,
filters=convert_filter_to_tuple(doctype, new_filters),
filters=column_filters,
fields="count(*) as total_count",
)[0].total_count
kc["all_count"] = all_count
kc["count"] = len(column_data)
for d in column_data:
getCounts(d, doctype)
if order:
column_data = sorted(
column_data,
@ -658,6 +660,24 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False, only_re
return fields_meta
@frappe.whitelist()
def remove_assignments(doctype, name, assignees, ignore_permissions=False):
assignees = json.loads(assignees)
if not assignees:
return
for assign_to in assignees:
set_status(
doctype,
name,
todo=None,
assign_to=assign_to,
status="Cancelled",
ignore_permissions=ignore_permissions,
)
@frappe.whitelist()
def get_assigned_users(doctype, name, default_assigned_to=None):
assigned_users = frappe.get_all(
"ToDo",

99
crm/api/settings.py Normal file
View File

@ -0,0 +1,99 @@
import frappe
@frappe.whitelist()
def create_email_account(data):
service = data.get("service")
service_config = email_service_config.get(service)
if not service_config:
return "Service not supported"
try:
email_doc = frappe.get_doc(
{
"doctype": "Email Account",
"email_id": data.get("email_id"),
"email_account_name": data.get("email_account_name"),
"service": service,
"enable_incoming": data.get("enable_incoming"),
"enable_outgoing": data.get("enable_outgoing"),
"default_incoming": data.get("default_incoming"),
"default_outgoing": data.get("default_outgoing"),
"email_sync_option": "ALL",
"initial_sync_count": 100,
"create_contact": 1,
"track_email_status": 1,
"use_tls": 1,
"use_imap": 1,
"smtp_port": 587,
**service_config,
}
)
if service == "Frappe Mail":
email_doc.api_key = data.get("api_key")
email_doc.api_secret = data.get("api_secret")
email_doc.frappe_mail_site = data.get("frappe_mail_site")
email_doc.append_to = "CRM Lead"
else:
email_doc.append("imap_folder", {"append_to": "CRM Lead", "folder_name": "INBOX"})
email_doc.password = data.get("password")
# validate whether the credentials are correct
email_doc.get_incoming_server()
# if correct credentials, save the email account
email_doc.save()
except Exception as e:
frappe.throw(str(e))
email_service_config = {
"Frappe Mail": {
"domain": None,
"password": None,
"awaiting_password": 0,
"ascii_encode_password": 0,
"login_id_is_different": 0,
"login_id": None,
"use_imap": 0,
"use_ssl": 0,
"validate_ssl_certificate": 0,
"use_starttls": 0,
"email_server": None,
"incoming_port": 0,
"always_use_account_email_id_as_sender": 1,
"use_tls": 0,
"use_ssl_for_outgoing": 0,
"smtp_server": None,
"smtp_port": None,
"no_smtp_authentication": 0,
},
"GMail": {
"email_server": "imap.gmail.com",
"use_ssl": 1,
"smtp_server": "smtp.gmail.com",
},
"Outlook": {
"email_server": "imap-mail.outlook.com",
"use_ssl": 1,
"smtp_server": "smtp-mail.outlook.com",
},
"Sendgrid": {
"smtp_server": "smtp.sendgrid.net",
"smtp_port": 587,
},
"SparkPost": {
"smtp_server": "smtp.sparkpostmail.com",
},
"Yahoo": {
"email_server": "imap.mail.yahoo.com",
"use_ssl": 1,
"smtp_server": "smtp.mail.yahoo.com",
"smtp_port": 587,
},
"Yandex": {
"email_server": "imap.yandex.com",
"use_ssl": 1,
"smtp_server": "smtp.yandex.com",
"smtp_port": 587,
},
}

View File

@ -41,13 +41,15 @@
"fieldname": "from",
"fieldtype": "Data",
"in_list_view": 1,
"label": "From"
"label": "From",
"reqd": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled"
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled",
"reqd": 1
},
{
"fieldname": "start_time",
@ -69,13 +71,15 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Type",
"options": "Incoming\nOutgoing"
"options": "Incoming\nOutgoing",
"reqd": 1
},
{
"fieldname": "to",
"fieldtype": "Data",
"in_list_view": 1,
"label": "To"
"label": "To",
"reqd": 1
},
{
"description": "Call duration in seconds",
@ -153,7 +157,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-22 17:57:59.289548",
"modified": "2025-04-01 16:01:54.479309",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Call Log",

View File

@ -190,11 +190,20 @@ def get_call_log(name):
@frappe.whitelist()
def create_lead_from_call_log(call_log):
def create_lead_from_call_log(call_log, lead_details=None):
lead = frappe.new_doc("CRM Lead")
lead.first_name = "Lead from call " + call_log.get("from")
lead.mobile_no = call_log.get("from")
lead.lead_owner = frappe.session.user
lead_details = frappe.parse_json(lead_details or "{}")
if not lead_details.get("lead_owner"):
lead_details["lead_owner"] = frappe.session.user
if not lead_details.get("mobile_no"):
lead_details["mobile_no"] = call_log.get("from") or ""
if not lead_details.get("first_name"):
lead_details["first_name"] = "Lead from call " + (
lead_details.get("mobile_no") or call_log.get("name")
)
lead.update(lead_details)
lead.save(ignore_permissions=True)
# link call log with lead

View File

@ -6,11 +6,13 @@ from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_deal(name):
deal = frappe.get_doc("CRM Deal", name).as_dict()
deal = frappe.get_doc("CRM Deal", name)
deal.check_permission("read")
deal = deal.as_dict()
deal["fields_meta"] = get_fields_meta("CRM Deal")
deal["_form_script"] = get_form_script("CRM Deal")
deal["_assign"] = get_assigned_users("CRM Deal", deal.name)
return deal

View File

@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Deal", {
refresh(frm) {
frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal"));
},
update_total: function (frm) {
let total = 0;
let total_qty = 0;
let net_total = 0;
frm.doc.products.forEach((d) => {
total += d.amount;
total_qty += d.qty;
net_total += d.net_amount;
});
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
frappe.model.set_value(
frm.doctype,
frm.docname,
"net_total",
net_total || total
);
}
});
frappe.ui.form.on("CRM Products", {
products_add: function (frm, cdt, cdn) {
frm.trigger("update_total");
},
products_remove: function (frm, cdt, cdn) {
frm.trigger("update_total");
},
product_code: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
},
rate: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.rate && d.qty) {
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
}
frm.trigger("update_total");
},
qty: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.rate && d.qty) {
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
}
frm.trigger("update_total");
},
discount_percentage: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.discount_percentage && d.amount) {
discount_amount = (d.discount_percentage / 100) * d.amount;
frappe.model.set_value(
cdt,
cdn,
"discount_amount",
discount_amount
);
frappe.model.set_value(
cdt,
cdn,
"net_amount",
d.amount - discount_amount
);
}
frm.trigger("update_total");
}
});

View File

@ -43,6 +43,12 @@
"mobile_no",
"phone",
"gender",
"products_tab",
"products",
"section_break_ccbj",
"total",
"column_break_udbq",
"net_total",
"sla_tab",
"sla",
"sla_creation",
@ -334,11 +340,46 @@
"fieldtype": "Link",
"label": "Currency",
"options": "Currency"
},
{
"fieldname": "products_tab",
"fieldtype": "Tab Break",
"label": "Products"
},
{
"fieldname": "products",
"fieldtype": "Table",
"label": "Products",
"options": "CRM Products"
},
{
"fieldname": "section_break_ccbj",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_udbq",
"fieldtype": "Column Break"
},
{
"fieldname": "total",
"fieldtype": "Currency",
"label": "Total",
"options": "currency",
"read_only": 1
},
{
"description": "Total after discount",
"fieldname": "net_total",
"fieldtype": "Currency",
"label": "Net Total",
"options": "currency",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-12-11 14:31:41.058895",
"modified": "2025-05-12 12:30:55.415282",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",
@ -370,10 +411,11 @@
"write": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "organization",
"track_changes": 1
}
}

View File

@ -27,7 +27,9 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
if not tabs and type != "Required Fields":
tabs = get_default_layout(doctype)
has_tabs = tabs[0].get("sections") if tabs and tabs[0] else False
has_tabs = False
if isinstance(tabs, list) and len(tabs) > 0 and isinstance(tabs[0], dict):
has_tabs = any("sections" in tab for tab in tabs)
if not has_tabs:
tabs = [{"name": "first_tab", "sections": tabs}]
@ -47,7 +49,10 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
for tab in tabs:
for section in tab.get("sections"):
if section.get("columns"):
section["columns"] = [column for column in section.get("columns") if column]
for column in section.get("columns") if section.get("columns") else []:
column["fields"] = [field for field in column.get("fields") if field]
for field in column.get("fields") if column.get("fields") else []:
field = next((f for f in fields if f.fieldname == field), None)
if field:

View File

@ -65,7 +65,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-16 19:40:19.340948",
"modified": "2025-05-19 17:57:24.610295",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Form Script",
@ -83,9 +83,19 @@
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@ -21,7 +21,7 @@ class CRMInvitation(Document):
if frappe.local.dev_server:
print(f"Invite link for {self.email}: {invite_link}")
title = f"Frappe CRM"
title = "Frappe CRM"
template = "crm_invitation"
frappe.sendmail(
@ -44,12 +44,24 @@ class CRMInvitation(Document):
user = self.create_user_if_not_exists()
user.append_roles(self.role)
if self.role == "Sales User":
self.update_module_in_user(user, "FCRM")
user.save(ignore_permissions=True)
self.status = "Accepted"
self.accepted_at = frappe.utils.now()
self.save(ignore_permissions=True)
def update_module_in_user(self, user, module):
block_modules = frappe.get_all(
"Module Def",
fields=["name as module"],
filters={"name": ["!=", module]},
)
if block_modules:
user.set("block_modules", block_modules)
def create_user_if_not_exists(self):
if not frappe.db.exists("User", self.email):
first_name = self.email.split("@")[0].title()

View File

@ -6,9 +6,11 @@ from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_lead(name):
lead = frappe.get_doc("CRM Lead", name).as_dict()
lead = frappe.get_doc("CRM Lead", name)
lead.check_permission("read")
lead = lead.as_dict()
lead["fields_meta"] = get_fields_meta("CRM Lead")
lead["_form_script"] = get_form_script("CRM Lead")
lead["_assign"] = get_assigned_users("CRM Lead", lead.name)
return lead

View File

@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Lead", {
refresh(frm) {
frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal"));
},
update_total: function (frm) {
let total = 0;
let total_qty = 0;
let net_total = 0;
frm.doc.products.forEach((d) => {
total += d.amount;
total_qty += d.qty;
net_total += d.net_amount;
});
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
frappe.model.set_value(
frm.doctype,
frm.docname,
"net_total",
net_total || total
);
}
});
frappe.ui.form.on("CRM Products", {
products_add: function (frm, cdt, cdn) {
frm.trigger("update_total");
},
products_remove: function (frm, cdt, cdn) {
frm.trigger("update_total");
},
product_code: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
},
rate: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.rate && d.qty) {
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
}
frm.trigger("update_total");
},
qty: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.rate && d.qty) {
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
}
frm.trigger("update_total");
},
discount_percentage: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.discount_percentage && d.amount) {
discount_amount = (d.discount_percentage / 100) * d.amount;
frappe.model.set_value(
cdt,
cdn,
"discount_amount",
discount_amount
);
frappe.model.set_value(
cdt,
cdn,
"net_amount",
d.amount - discount_amount
);
}
frm.trigger("update_total");
}
});

View File

@ -37,6 +37,12 @@
"annual_revenue",
"image",
"converted",
"products_tab",
"products",
"section_break_ggwh",
"total",
"column_break_uisv",
"net_total",
"sla_tab",
"sla",
"sla_creation",
@ -285,12 +291,47 @@
"fieldtype": "Table",
"label": "Status Change Log",
"options": "CRM Status Change Log"
},
{
"fieldname": "products_tab",
"fieldtype": "Tab Break",
"label": "Products"
},
{
"fieldname": "products",
"fieldtype": "Table",
"label": "Products",
"options": "CRM Products"
},
{
"fieldname": "section_break_ggwh",
"fieldtype": "Section Break"
},
{
"fieldname": "total",
"fieldtype": "Currency",
"label": "Total",
"options": "currency",
"read_only": 1
},
{
"fieldname": "column_break_uisv",
"fieldtype": "Column Break"
},
{
"description": "Total after discount",
"fieldname": "net_total",
"fieldtype": "Currency",
"label": "Net Total",
"options": "currency",
"read_only": 1
}
],
"grid_page_length": 50,
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-02 22:14:01.991054",
"modified": "2025-05-14 19:51:06.184569",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lead",
@ -331,6 +372,7 @@
"share": 1
}
],
"row_format": "Dynamic",
"sender_field": "email",
"sender_name_field": "first_name",
"show_title_field_in_link": 1,
@ -339,4 +381,4 @@
"states": [],
"title_field": "lead_name",
"track_changes": 1
}
}

View File

View File

@ -0,0 +1,9 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("CRM Product", {
product_code: function (frm) {
if (!frm.doc.product_name)
frm.set_value("product_name", frm.doc.product_code);
}
});

View File

@ -0,0 +1,105 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:product_code",
"creation": "2025-04-28 11:45:09.309636",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"naming_series",
"product_code",
"product_name",
"column_break_bpdj",
"disabled",
"standard_rate",
"image",
"section_break_rtwm",
"description"
],
"fields": [
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "CRM-PROD-.YYYY.-"
},
{
"fieldname": "product_code",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Product Code",
"reqd": 1,
"unique": 1
},
{
"fieldname": "product_name",
"fieldtype": "Data",
"label": "Product Name"
},
{
"fieldname": "column_break_bpdj",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{
"fieldname": "image",
"fieldtype": "Attach Image",
"label": "Image"
},
{
"fieldname": "section_break_rtwm",
"fieldtype": "Section Break"
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"fieldname": "standard_rate",
"fieldtype": "Currency",
"label": "Standard Selling Rate"
}
],
"grid_page_length": 50,
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-04-28 12:47:25.087957",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Product",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "product_name,description",
"show_name_in_global_search": 1,
"show_preview_popup": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "product_name",
"track_changes": 1
}

View File

@ -0,0 +1,16 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class CRMProduct(Document):
def validate(self):
self.set_product_name()
def set_product_name(self):
if not self.product_name:
self.product_name = self.product_code
else:
self.product_name = self.product_name.strip()

View File

@ -0,0 +1,29 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestCRMProduct(UnitTestCase):
"""
Unit tests for CRMProduct.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMProduct(IntegrationTestCase):
"""
Integration tests for CRMProduct.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -0,0 +1,136 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-04-28 12:50:49.812915",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"product_code",
"column_break_gvbc",
"product_name",
"section_break_fnvf",
"qty",
"column_break_ajac",
"rate",
"section_break_olqb",
"discount_percentage",
"column_break_uvra",
"discount_amount",
"section_break_cnpb",
"column_break_pozr",
"amount",
"column_break_ejqw",
"net_amount"
],
"fields": [
{
"fieldname": "column_break_gvbc",
"fieldtype": "Column Break"
},
{
"fieldname": "product_name",
"fieldtype": "Data",
"label": "Product Name",
"reqd": 1
},
{
"fieldname": "section_break_fnvf",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_olqb",
"fieldtype": "Section Break"
},
{
"bold": 1,
"fieldname": "discount_percentage",
"fieldtype": "Percent",
"label": "Discount %"
},
{
"fieldname": "discount_amount",
"fieldtype": "Currency",
"label": "Discount Amount",
"options": "currency",
"read_only": 1
},
{
"fieldname": "section_break_cnpb",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_pozr",
"fieldtype": "Column Break"
},
{
"bold": 1,
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
"options": "currency",
"reqd": 1
},
{
"bold": 1,
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"options": "currency",
"read_only": 1
},
{
"bold": 1,
"depends_on": "discount_percentage",
"description": "Amount after discount",
"fieldname": "net_amount",
"fieldtype": "Currency",
"label": "Net Amount",
"options": "currency",
"read_only": 1
},
{
"bold": 1,
"columns": 5,
"fieldname": "product_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Product",
"options": "CRM Product"
},
{
"bold": 1,
"default": "1",
"fieldname": "qty",
"fieldtype": "Float",
"label": "Quantity"
},
{
"fieldname": "column_break_ajac",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_uvra",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_ejqw",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-05-14 18:52:26.183306",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Products",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,110 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class CRMProducts(Document):
pass
def create_product_details_script(doctype):
if not frappe.db.exists("CRM Form Script", "Product Details Script for " + doctype):
script = get_product_details_script(doctype)
frappe.get_doc(
{
"doctype": "CRM Form Script",
"name": "Product Details Script for " + doctype,
"dt": doctype,
"view": "Form",
"script": script,
"enabled": 1,
"is_standard": 1,
}
).insert()
def get_product_details_script(doctype):
doctype_class = "class " + doctype.replace(" ", "")
return (
doctype_class
+ " {"
+ """
update_total() {
let total = 0
let total_qty = 0
let net_total = 0
let discount_applied = false
this.doc.products.forEach((d) => {
total += d.amount
net_total += d.net_amount
if (d.discount_percentage > 0) {
discount_applied = true
}
})
this.doc.total = total
this.doc.net_total = net_total || total
if (!net_total && discount_applied) {
this.doc.net_total = net_total
}
}
}
class CRMProducts {
products_add() {
let row = this.doc.getRow('products')
row.trigger('qty')
this.doc.trigger('update_total')
}
products_remove() {
this.doc.trigger('update_total')
}
async product_code(idx) {
let row = this.doc.getRow('products', idx)
let a = await call("frappe.client.get_value", {
doctype: "CRM Product",
filters: { name: row.product_code },
fieldname: ["product_name", "standard_rate"],
})
row.product_name = a.product_name
if (a.standard_rate && !row.rate) {
row.rate = a.standard_rate
row.trigger("rate")
}
}
qty(idx) {
let row = this.doc.getRow('products', idx)
row.amount = row.qty * row.rate
row.trigger('discount_percentage', idx)
}
rate() {
let row = this.doc.getRow('products')
row.amount = row.qty * row.rate
row.trigger('discount_percentage')
}
discount_percentage(idx) {
let row = this.doc.getRow('products', idx)
if (!row.discount_percentage) {
row.net_amount = row.amount
row.discount_amount = 0
}
if (row.discount_percentage && row.amount) {
row.discount_amount = (row.discount_percentage / 100) * row.amount
row.net_amount = row.amount - row.discount_amount
}
this.doc.trigger('update_total')
}
}"""
)

View File

@ -264,7 +264,7 @@ def create_customer_in_remote_site(customer, erpnext_crm_settings):
@frappe.whitelist()
def get_crm_form_script():
return """
async function setupForm({ doc, call, $dialog, updateField, createToast }) {
async function setupForm({ doc, call, $dialog, updateField, toast }) {
let actions = [];
let is_erpnext_integration_enabled = await call("frappe.client.get_single_value", {doctype: "ERPNext CRM Settings", field: "enabled"});
if (!["Lost", "Won"].includes(doc?.status) && is_erpnext_integration_enabled) {

View File

@ -19,7 +19,8 @@
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Title"
"label": "Title",
"reqd": 1
},
{
"fieldname": "content",
@ -49,7 +50,7 @@
"link_fieldname": "note"
}
],
"modified": "2024-01-19 21:56:30.123334",
"modified": "2025-04-01 15:30:14.742001",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Note",

View File

@ -24,9 +24,15 @@ class FCRMSettings(Document):
standard_old_items = [d.name1 for d in old_items if d.is_standard]
deleted_standard_items = set(standard_old_items) - set(standard_new_items)
if deleted_standard_items:
standard_dropdown_items = get_standard_dropdown_items()
if not deleted_standard_items.intersection(standard_dropdown_items):
return
frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
def get_standard_dropdown_items():
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
def after_migrate():
sync_table("dropdown_items", "standard_dropdown_items")

View File

@ -264,22 +264,6 @@ standard_dropdown_items = [
"route": "#",
"is_standard": 1,
},
{
"name1": "support_link",
"label": "Support",
"type": "Route",
"icon": "life-buoy",
"route": "https://t.me/frappecrm",
"is_standard": 1,
},
{
"name1": "docs_link",
"label": "Docs",
"type": "Route",
"icon": "book-open",
"route": "https://docs.frappe.io/crm",
"is_standard": 1,
},
{
"name1": "toggle_theme",
"label": "Toggle theme",
@ -303,6 +287,14 @@ standard_dropdown_items = [
"route": "#",
"is_standard": 1,
},
{
"name1": "about",
"label": "About",
"type": "Route",
"icon": "info",
"route": "#",
"is_standard": 1,
},
{
"name1": "separator",
"label": "",

View File

@ -4,6 +4,8 @@ import click
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
def before_install():
pass
@ -19,6 +21,7 @@ def after_install(force=False):
add_default_industries()
add_default_lead_sources()
add_standard_dropdown_items()
add_default_scripts()
frappe.db.commit()
@ -353,3 +356,8 @@ def add_standard_dropdown_items():
crm_settings.append("dropdown_items", item)
crm_settings.save()
def add_default_scripts():
for doctype in ["CRM Lead", "CRM Deal"]:
create_product_details_script(doctype)

View File

@ -110,12 +110,12 @@ def get_contact_by_phone_number(phone_number):
number = parse_phone_number(phone_number)
if number.get("is_valid"):
return get_contact(number.get("national_number"))
return get_contact(number.get("national_number"), number.get("country"))
else:
return get_contact(phone_number, exact_match=True)
return get_contact(phone_number, number.get("country"), exact_match=True)
def get_contact(phone_number, exact_match=False):
def get_contact(phone_number, country="IN", exact_match=False):
if not phone_number:
return {"mobile_no": phone_number}
@ -149,11 +149,11 @@ def get_contact(phone_number, exact_match=False):
deal = frappe.db.get_value(
"CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
)
if are_same_phone_number(contact.mobile_no, phone_number, validate=not exact_match):
if are_same_phone_number(contact.mobile_no, phone_number, country, validate=not exact_match):
contact["deal"] = deal
return contact
# Else, return the first contact
if are_same_phone_number(contacts[0].mobile_no, phone_number, validate=not exact_match):
if are_same_phone_number(contacts[0].mobile_no, phone_number, country, validate=not exact_match):
return contacts[0]
# Else, Check if the number is associated with a lead
@ -173,7 +173,7 @@ def get_contact(phone_number, exact_match=False):
if len(leads):
for lead in leads:
if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match):
if are_same_phone_number(lead.mobile_no, phone_number, country, validate=not exact_match):
lead["lead"] = lead.name
lead["full_name"] = lead.lead_name
return lead

File diff suppressed because it is too large Load Diff

View File

@ -11,4 +11,5 @@ crm.patches.v1_0.create_default_fields_layout #22/01/2025
crm.patches.v1_0.create_default_sidebar_fields_layout
crm.patches.v1_0.update_deal_quick_entry_layout
crm.patches.v1_0.update_layouts_to_new_format
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
crm.patches.v1_0.create_default_scripts

View File

@ -0,0 +1,5 @@
from crm.install import add_default_scripts
def execute():
add_default_scripts()

View File

@ -1,4 +1,4 @@
<h2>You have been invited to join Frappe CRM</h2>
<p>You have been invited to join Frappe CRM</p>
<p>
<a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a>
</p>

View File

@ -1,12 +1,13 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import click
import frappe
def before_uninstall():
delete_email_template_custom_fields()
def delete_email_template_custom_fields():
if frappe.get_meta("Email Template").has_field("enabled"):
click.secho("* Uninstalling Custom Fields from Email Template")
@ -19,4 +20,4 @@ def delete_email_template_custom_fields():
for fieldname in fieldnames:
frappe.db.delete("Custom Field", {"name": "Email Template-" + fieldname})
frappe.clear_cache(doctype="Email Template")
frappe.clear_cache(doctype="Email Template")

View File

@ -1,8 +1,10 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# GNU GPLv3 License. See license.txt
import os
import subprocess
import frappe
from frappe import safe_decode
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
from frappe.utils import cint, get_system_timezone
from frappe.utils.telemetry import capture
@ -43,9 +45,41 @@ def get_boot():
"user": frappe.db.get_value("User", frappe.session.user, "time_zone")
or get_system_timezone(),
},
"app_version": get_app_version(),
}
)
def get_default_route():
return "/crm"
def get_app_version():
app = "crm"
branch = run_git_command(f"cd ../apps/{app} && git rev-parse --abbrev-ref HEAD")
commit = run_git_command(f"git -C ../apps/{app} rev-parse --short=7 HEAD")
tag = run_git_command(f"git -C ../apps/{app} describe --tags --abbrev=0")
dirty = run_git_command(f"git -C ../apps/{app} diff --quiet || echo 'dirty'") == "dirty"
commit_date = run_git_command(f"git -C ../apps/{app} log -1 --format=%cd")
commit_message = run_git_command(f"git -C ../apps/{app} log -1 --pretty=%B")
return {
"branch": branch,
"commit": commit,
"commit_date": commit_date,
"commit_message": commit_message,
"tag": tag,
"dirty": dirty,
}
def run_git_command(command):
try:
with open(os.devnull, "wb") as null_stream:
result = subprocess.check_output(command, shell=True, stdin=null_stream, stderr=null_stream)
return safe_decode(result).strip()
except Exception:
frappe.log_error(
title="Git Command Error",
)
return ""

@ -1 +1 @@
Subproject commit 3423aa5b5c38d3a1b143ae8ab08cbde7360f9a7c
Subproject commit 8b615c0e899d75b99c7d36ec6df97b5d0386b2ca

View File

@ -8,6 +8,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AboutModal: typeof import('./src/components/Modals/AboutModal.vue')['default']
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
@ -53,6 +54,7 @@ declare module 'vue' {
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
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']
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
@ -78,16 +80,23 @@ declare module 'vue' {
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default']
EmailAccountCard: typeof import('./src/components/Settings/EmailAccountCard.vue')['default']
EmailAccountList: typeof import('./src/components/Settings/EmailAccountList.vue')['default']
EmailAdd: typeof import('./src/components/Settings/EmailAdd.vue')['default']
EmailArea: typeof import('./src/components/Activities/EmailArea.vue')['default']
EmailAtIcon: typeof import('./src/components/Icons/EmailAtIcon.vue')['default']
EmailConfig: typeof import('./src/components/Settings/EmailConfig.vue')['default']
EmailContent: typeof import('./src/components/Activities/EmailContent.vue')['default']
EmailEdit: typeof import('./src/components/Settings/EmailEdit.vue')['default']
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default']
ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default']
ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default']
@ -106,9 +115,11 @@ 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']
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/GeneralSettings.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']
GridFieldsEditorModal: typeof import('./src/components/Controls/GridFieldsEditorModal.vue')['default']
@ -140,6 +151,10 @@ declare module 'vue' {
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']
LucideSearch: typeof import('~icons/lucide/search')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
@ -149,6 +164,7 @@ declare module 'vue' {
MobileLayout: typeof import('./src/components/Layouts/MobileLayout.vue')['default']
MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default']
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
@ -162,6 +178,7 @@ declare module 'vue' {
OrganizationsIcon: typeof import('./src/components/Icons/OrganizationsIcon.vue')['default']
OrganizationsListView: typeof import('./src/components/ListViews/OrganizationsListView.vue')['default']
OutboundCallIcon: typeof import('./src/components/Icons/OutboundCallIcon.vue')['default']
Password: typeof import('./src/components/Controls/Password.vue')['default']
PauseIcon: typeof import('./src/components/Icons/PauseIcon.vue')['default']
PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default']
PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default']
@ -196,6 +213,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']
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']
TableMultiselectInput: typeof import('./src/components/Controls/TableMultiselectInput.vue')['default']
@ -206,6 +224,7 @@ declare module 'vue' {
TaskPriorityIcon: typeof import('./src/components/Icons/TaskPriorityIcon.vue')['default']
TasksListView: typeof import('./src/components/ListViews/TasksListView.vue')['default']
TaskStatusIcon: typeof import('./src/components/Icons/TaskStatusIcon.vue')['default']
TelegramIcon: typeof import('./src/components/Icons/TelegramIcon.vue')['default']
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']

View File

@ -2,6 +2,7 @@
"name": "crm-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/crm/frontend/ && yarn copy-html-entry",
@ -9,9 +10,10 @@
"serve": "vite preview"
},
"dependencies": {
"@tiptap/extension-paragraph": "^2.12.0",
"@twilio/voice-sdk": "^2.10.2",
"@vueuse/integrations": "^10.3.0",
"frappe-ui": "^0.1.121",
"frappe-ui": "^0.1.145",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

View File

@ -1,16 +1,17 @@
<template>
<Layout v-if="session().isLoggedIn">
<router-view />
</Layout>
<Dialogs />
<Toasts />
<FrappeUIProvider>
<Layout v-if="session().isLoggedIn">
<router-view />
</Layout>
<Dialogs />
</FrappeUIProvider>
</template>
<script setup>
import { Dialogs } from '@/utils/dialogs'
import { sessionStore as session } from '@/stores/session'
import { setTheme } from '@/stores/theme'
import { Toasts, setConfig } from 'frappe-ui'
import { FrappeUIProvider, setConfig } from 'frappe-ui'
import { computed, defineAsyncComponent, onMounted } from 'vue'
const MobileLayout = defineAsyncComponent(

View File

@ -250,14 +250,14 @@
</span>
<span v-if="activity.type">{{ __(activity.type) }}</span>
<span
v-if="activity.data.field_label"
v-if="activity.data?.field_label"
class="max-w-xs truncate font-medium text-ink-gray-8"
>
{{ __(activity.data.field_label) }}
</span>
<span v-if="activity.value">{{ __(activity.value) }}</span>
<span
v-if="activity.data.old_value"
v-if="activity.data?.old_value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
@ -273,7 +273,7 @@
</span>
<span v-if="activity.to">{{ __('to') }}</span>
<span
v-if="activity.data.value"
v-if="activity.data?.value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
@ -307,7 +307,7 @@
>
<div class="inline-flex flex-wrap gap-1 text-ink-gray-5">
<span
v-if="activity.data.field_label"
v-if="activity.data?.field_label"
class="max-w-xs truncate text-ink-gray-5"
>
{{ __(activity.data.field_label) }}
@ -320,7 +320,7 @@
{{ startCase(__(activity.type)) }}
</span>
<span
v-if="activity.data.old_value"
v-if="activity.data?.old_value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
@ -336,7 +336,7 @@
</span>
<span v-if="activity.to">{{ __('to') }}</span>
<span
v-if="activity.data.value"
v-if="activity.data?.value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
@ -365,7 +365,11 @@
</div>
</div>
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
<DataFields :doctype="doctype" :docname="doc.data.name" />
<DataFields
:doctype="doctype"
:docname="doc.data.name"
@afterSave="(data) => emit('afterSave', data)"
/>
</div>
<div
v-else
@ -373,11 +377,7 @@
>
<component :is="emptyTextIcon" class="h-10 w-10" />
<span>{{ __(emptyText) }}</span>
<Button
v-if="title == 'Calls'"
:label="__('Make a Call')"
@click="makeCall(doc.data.mobile_no)"
/>
<MultiActionButton v-if="title == 'Calls'" :options="callActions" />
<Button
v-else-if="title == 'Notes'"
:label="__('Create Note')"
@ -470,6 +470,7 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import MultiActionButton from '@/components/MultiActionButton.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import DotIcon from '@/components/Icons/DotIcon.vue'
@ -487,7 +488,7 @@ import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import { timeAgo, formatDate, startCase } from '@/utils'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { whatsappEnabled } from '@/composables/settings'
import { whatsappEnabled, callEnabled } from '@/composables/settings'
import { capture } from '@/telemetry'
import { Button, Tooltip, createResource } from 'frappe-ui'
import { useElementVisibility } from '@vueuse/core'
@ -517,6 +518,8 @@ const props = defineProps({
},
})
const emit = defineEmits(['afterSave'])
const route = useRoute()
const doc = defineModel()
@ -785,5 +788,23 @@ function scroll(hash) {
}, 500)
}
const callActions = computed(() => {
let actions = [
{
label: __('Create Call Log'),
onClick: () => modalRef.value.createCallLog(),
},
{
label: __('Make a Call'),
onClick: () => makeCall(doc.data.mobile_no),
condition: () => callEnabled.value,
},
]
return actions.filter((action) =>
action.condition ? action.condition() : true,
)
})
defineExpose({ emailBox, all_activities })
</script>

View File

@ -26,16 +26,11 @@
</template>
<span>{{ __('New Comment') }}</span>
</Button>
<Button
<MultiActionButton
v-else-if="title == 'Calls'"
variant="solid"
@click="makeCall(doc.data.mobile_no)"
>
<template #prefix>
<PhoneIcon class="h-4 w-4" />
</template>
<span>{{ __('Make a Call') }}</span>
</Button>
:options="callActions"
/>
<Button
v-else-if="title == 'Notes'"
variant="solid"
@ -97,6 +92,7 @@
</div>
</template>
<script setup>
import MultiActionButton from '@/components/MultiActionButton.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
@ -136,6 +132,11 @@ const defaultActions = computed(() => {
label: __('New Comment'),
onClick: () => (props.emailBox.showComment = true),
},
{
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
label: __('Create Call Log'),
onClick: () => props.modalRef.createCallLog(),
},
{
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
label: __('Make a Call'),
@ -172,4 +173,24 @@ const defaultActions = computed(() => {
function getTabIndex(name) {
return props.tabs.findIndex((tab) => tab.name === name)
}
const callActions = computed(() => {
let actions = [
{
label: __('Create Call Log'),
icon: 'plus',
onClick: () => props.modalRef.createCallLog(),
},
{
label: __('Make a Call'),
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
onClick: () => makeCall(props.doc.data.mobile_no),
condition: () => callEnabled.value,
},
]
return actions.filter((action) =>
action.condition ? action.condition() : true,
)
})
</script>

View File

@ -15,10 +15,17 @@
:doc="doc.data?.name"
@after="redirect('notes')"
/>
<CallLogModal
v-if="showCallLogModal"
v-model="showCallLogModal"
:data="callLog"
:options="{ afterInsert: () => activities.reload() }"
/>
</template>
<script setup>
import TaskModal from '@/components/Modals/TaskModal.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import CallLogModal from '@/components/Modals/CallLogModal.vue'
import { call } from 'frappe-ui'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@ -77,6 +84,20 @@ function showNote(n) {
showNoteModal.value = true
}
// Call Logs
const showCallLogModal = ref(false)
const callLog = ref({})
function createCallLog() {
let doctype = props.doctype
let docname = props.doc.data?.name
callLog.value = {
reference_doctype: doctype,
reference_docname: docname,
}
showCallLogModal.value = true
}
// common
const route = useRoute()
const router = useRouter()
@ -95,5 +116,6 @@ defineExpose({
deleteTask,
updateTaskStatus,
showNote,
createCallLog,
})
</script>

View File

@ -97,7 +97,11 @@
v-model:callLogModal="showCallLogModal"
v-model:callLog="callLog"
/>
<CallLogModal v-model="showCallLogModal" v-model:callLog="callLog" />
<CallLogModal
v-if="showCallLogModal"
v-model="showCallLogModal"
:data="callLog.data"
/>
</div>
</template>
<script setup>

View File

@ -5,7 +5,7 @@
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
{{ __('Data') }}
<Badge
v-if="data.isDirty"
v-if="document.isDirty"
class="ml-3"
:label="'Not Saved'"
theme="orange"
@ -20,15 +20,15 @@
</Button>
<Button
label="Save"
:disabled="!data.isDirty"
:disabled="!document.isDirty"
variant="solid"
:loading="data.save.loading"
:loading="document.save.loading"
@click="saveChanges"
/>
</div>
</div>
<div
v-if="data.get.loading"
v-if="document.get.loading"
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
>
<LoadingIndicator class="h-6 w-6" />
@ -38,7 +38,7 @@
<FieldLayout
v-if="tabs.data"
:tabs="tabs.data"
:data="data.doc"
:data="document.doc"
:doctype="doctype"
/>
</div>
@ -49,7 +49,7 @@
@reload="
() => {
tabs.reload()
data.reload()
document.reload()
}
"
/>
@ -59,12 +59,12 @@
import EditIcon from '@/components/Icons/EditIcon.vue'
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import { Badge, createResource, createDocumentResource } from 'frappe-ui'
import { Badge, createResource } from 'frappe-ui'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import { createToast } from '@/utils'
import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { isMobileView } from '@/composables/settings'
import { ref } from 'vue'
import { ref, watch } from 'vue'
const props = defineProps({
doctype: {
@ -77,32 +77,13 @@ const props = defineProps({
},
})
const emit = defineEmits(['afterSave'])
const { isManager } = usersStore()
const showDataFieldsModal = ref(false)
const data = createDocumentResource({
doctype: props.doctype,
name: props.docname,
setValue: {
onSuccess: () => {
data.reload()
createToast({
title: 'Data Updated',
icon: 'check',
iconClasses: 'text-ink-green-3',
})
},
onError: (err) => {
createToast({
title: 'Error',
text: err.messages[0],
icon: 'x',
iconClasses: 'text-red-600',
})
},
},
})
const { document } = useDocument(props.doctype, props.docname)
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
@ -112,6 +93,36 @@ const tabs = createResource({
})
function saveChanges() {
data.save.submit()
if (!document.isDirty) return
const updatedDoc = { ...document.doc }
const oldDoc = { ...document.originalDoc }
const changes = Object.keys(updatedDoc).reduce((acc, key) => {
if (JSON.stringify(updatedDoc[key]) !== JSON.stringify(oldDoc[key])) {
acc[key] = updatedDoc[key]
}
return acc
}, {})
document.save.submit(null, {
onSuccess: () => emit('afterSave', changes),
})
}
watch(
() => document.doc,
(newValue, oldValue) => {
if (!oldValue) return
if (newValue && oldValue) {
const isDirty =
JSON.stringify(newValue) !== JSON.stringify(document.originalDoc)
document.isDirty = isDirty
if (isDirty) {
document.save.loading = false
}
}
},
{ deep: true },
)
</script>

View File

@ -22,9 +22,9 @@
variant="subtle"
:theme="status.color"
/>
<Tooltip :text="formatDate(activity.creation)">
<Tooltip :text="formatDate(activity.communication_date)">
<div class="text-sm text-ink-gray-5">
{{ __(timeAgo(activity.creation)) }}
{{ __(timeAgo(activity.communication_date)) }}
</div>
</Tooltip>
<div class="flex gap-0.5">

View File

@ -0,0 +1,58 @@
<template>
<TextInput
ref="inputRef"
:value="displayValue"
@focus="handleFocus"
@blur="isFocused = false"
v-bind="$attrs"
/>
<slot name="description">
<p v-if="attrs.description" class="mt-1.5" :class="descriptionClasses">
{{ attrs.description }}
</p>
</slot>
</template>
<script setup>
import { TextInput } from 'frappe-ui'
import { ref, computed, nextTick, useAttrs } from 'vue'
const props = defineProps({
value: {
type: [String, Number],
default: '',
},
formattedValue: {
type: [String, Number],
default: '',
},
})
const attrs = useAttrs()
const isFocused = ref(false)
const inputRef = ref(null)
function handleFocus() {
isFocused.value = true
nextTick(() => {
if (inputRef.value) {
inputRef.value.el?.select()
}
})
}
const displayValue = computed(() => {
return isFocused.value ? props.value : props.formattedValue || props.value
})
const descriptionClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[attrs.size || 'sm'],
'text-ink-gray-5',
]
})
</script>

View File

@ -33,10 +33,23 @@
<div
v-for="field in fields"
class="border-r border-outline-gray-2 p-2 truncate"
:class="
['Int', 'Float', 'Currency', 'Percent'].includes(field.fieldtype)
? 'text-right'
: ''
"
:key="field.fieldname"
:title="field.label"
>
{{ __(field.label) }}
<span
v-if="
field.reqd ||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
"
class="text-ink-red-2"
>*</span
>
</div>
</div>
<div class="w-12">
@ -93,18 +106,37 @@
:key="field.fieldname"
>
<FormControl
v-if="field.read_only && field.fieldtype !== 'Check'"
v-if="
field.read_only &&
![
'Int',
'Float',
'Currency',
'Percent',
'Check',
].includes(field.fieldtype)
"
type="text"
:placeholder="field.placeholder"
v-model="row[field.fieldname]"
:disabled="true"
/>
<Link
v-else-if="field.fieldtype === 'Link'"
v-else-if="
['Link', 'Dynamic Link'].includes(field.fieldtype)
"
class="text-sm text-ink-gray-8"
v-model="row[field.fieldname]"
:doctype="field.options"
:value="row[field.fieldname]"
:doctype="
field.fieldtype == 'Link'
? field.options
: row[field.options]
"
:filters="field.filters"
@change="(v) => fieldChange(v, field, row)"
:onCreate="
(value, close) => field.create(v, field, row, close)
"
/>
<Link
v-else-if="field.fieldtype === 'User'"
@ -112,7 +144,7 @@
:value="getUser(row[field.fieldname]).full_name"
:doctype="field.options"
:filters="field.filters"
@change="(v) => (row[field.fieldname] = v)"
@change="(v) => fieldChange(v, field, row)"
:placeholder="field.placeholder"
:hideMe="true"
>
@ -142,23 +174,26 @@
class="cursor-pointer duration-300"
v-model="row[field.fieldname]"
:disabled="!gridSettings.editable_grid"
@change="(e) => fieldChange(e.target.checked, field, row)"
/>
</div>
<DatePicker
v-else-if="field.fieldtype === 'Date'"
v-model="row[field.fieldname]"
:value="row[field.fieldname]"
icon-left=""
variant="outline"
:formatter="(date) => getFormat(date, '', true)"
input-class="border-none text-sm text-ink-gray-8"
@change="(v) => fieldChange(v, field, row)"
/>
<DateTimePicker
v-else-if="field.fieldtype === 'Datetime'"
v-model="row[field.fieldname]"
:value="row[field.fieldname]"
icon-left=""
variant="outline"
:formatter="(date) => getFormat(date, '', true, true)"
input-class="border-none text-sm text-ink-gray-8"
@change="(v) => fieldChange(v, field, row)"
/>
<FormControl
v-else-if="
@ -169,13 +204,8 @@
rows="1"
type="textarea"
variant="outline"
v-model="row[field.fieldname]"
/>
<FormControl
v-else-if="['Int'].includes(field.fieldtype)"
type="number"
variant="outline"
v-model="row[field.fieldname]"
:value="row[field.fieldname]"
@change="fieldChange($event.target.value, field, row)"
/>
<FormControl
v-else-if="field.fieldtype === 'Select'"
@ -184,6 +214,55 @@
variant="outline"
v-model="row[field.fieldname]"
:options="field.options"
@change="(e) => fieldChange(e.target.value, field, row)"
/>
<Password
v-else-if="field.fieldtype === 'Password'"
variant="outline"
:value="row[field.fieldname]"
:disabled="Boolean(field.read_only)"
@change="fieldChange($event.target.value, field, row)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Int'"
class="[&_input]:text-right"
type="text"
variant="outline"
:value="row[field.fieldname] || '0'"
:disabled="Boolean(field.read_only)"
@change="fieldChange($event.target.value, field, row)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Percent'"
class="[&_input]:text-right"
type="text"
variant="outline"
:value="getFloatWithPrecision(field.fieldname, row)"
:formattedValue="(row[field.fieldname] || '0') + '%'"
:disabled="Boolean(field.read_only)"
@change="fieldChange(flt($event.target.value), field, row)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Float'"
class="[&_input]:text-right"
type="text"
variant="outline"
:value="getFloatWithPrecision(field.fieldname, row)"
:formattedValue="row[field.fieldname]"
:disabled="Boolean(field.read_only)"
@change="fieldChange(flt($event.target.value), field, row)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Currency'"
class="[&_input]:text-right"
type="text"
variant="outline"
:value="getCurrencyWithPrecision(field.fieldname, row)"
:formattedValue="
getFormattedCurrency(field.fieldname, row, parentDoc)
"
:disabled="Boolean(field.read_only)"
@change="fieldChange(flt($event.target.value), field, row)"
/>
<FormControl
v-else
@ -192,6 +271,7 @@
variant="outline"
v-model="row[field.fieldname]"
:options="field.options"
@change="fieldChange($event.target.value, field, row)"
/>
</div>
</div>
@ -252,6 +332,8 @@
</template>
<script setup>
import Password from '@/components/Controls/Password.vue'
import FormattedInput from '@/components/Controls/FormattedInput.vue'
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
import GridRowModal from '@/components/Controls/GridRowModal.vue'
@ -259,8 +341,10 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
import Link from '@/components/Controls/Link.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { getRandom, getFormat, isTouchScreenDevice } from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { usersStore } from '@/stores/users'
import { getMeta } from '@/stores/meta'
import { createDocument } from '@/composables/document'
import {
FeatherIcon,
FormControl,
@ -268,9 +352,10 @@ import {
DateTimePicker,
DatePicker,
Tooltip,
dayjs,
} from 'frappe-ui'
import Draggable from 'vuedraggable'
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, inject, provide } from 'vue'
const props = defineProps({
label: {
@ -285,15 +370,32 @@ const props = defineProps({
type: String,
required: true,
},
parentFieldname: {
type: String,
required: true,
},
})
const { getGridViewSettings, getFields, getGridSettings } = getMeta(
props.doctype,
)
const triggerOnChange = inject('triggerOnChange')
const triggerOnRowAdd = inject('triggerOnRowAdd')
const triggerOnRowRemove = inject('triggerOnRowRemove')
const {
getGridViewSettings,
getFields,
getFloatWithPrecision,
getCurrencyWithPrecision,
getFormattedCurrency,
getGridSettings,
} = getMeta(props.doctype)
getMeta(props.parentDoctype)
const { getUser } = usersStore()
const rows = defineModel()
const parentDoc = defineModel('parent')
provide('parentDoc', parentDoc)
const showRowList = ref(new Array(rows.value?.length || []).fill(false))
const selectedRows = reactive(new Set())
@ -316,7 +418,22 @@ const fields = computed(() => {
)
})
const allFields = computed(() => {
return getFields()?.map((f) => getFieldObj(f)) || []
})
function getFieldObj(field) {
if (field.fieldtype === 'Link' && field.options !== 'User') {
if (!field.create) {
field.create = (value, field, row, close) => {
const callback = (d) => {
if (d) fieldChange(d.name, field, row)
}
createDocument(field.options, value, close, callback)
}
}
}
return {
...field,
filters: field.link_filters && JSON.parse(field.link_filters),
@ -361,21 +478,71 @@ const toggleSelectRow = (row) => {
const addRow = () => {
const newRow = {}
fields.value?.forEach((field) => {
if (field.fieldtype === 'Check') newRow[field.fieldname] = false
else newRow[field.fieldname] = ''
allFields.value?.forEach((field) => {
if (field.fieldtype === 'Check') {
newRow[field.fieldname] = false
} else {
newRow[field.fieldname] = ''
}
if (field.default) {
newRow[field.fieldname] = getDefaultValue(field.default, field.fieldtype)
}
})
newRow.name = getRandom(10)
showRowList.value.push(false)
newRow['__islocal'] = true
newRow['idx'] = rows.value.length + 1
newRow['doctype'] = props.doctype
newRow['parentfield'] = props.parentFieldname
newRow['parenttype'] = props.parentDoctype
rows.value.push(newRow)
triggerOnRowAdd(newRow)
}
const deleteRows = () => {
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
triggerOnRowRemove(selectedRows, rows.value)
showRowList.value.pop()
selectedRows.clear()
}
function fieldChange(value, field, row) {
row[field.fieldname] = value
triggerOnChange(field.fieldname, row)
}
function getDefaultValue(defaultValue, fieldtype) {
if (['Float', 'Currency', 'Percent'].includes(fieldtype)) {
return flt(defaultValue)
} else if (fieldtype === 'Check') {
if (['1', 'true', 'True'].includes(defaultValue)) {
return true
} else if (['0', 'false', 'False'].includes(defaultValue)) {
return false
}
} else if (fieldtype === 'Int') {
return parseInt(defaultValue)
} else if (defaultValue === 'Today' && fieldtype === 'Date') {
return dayjs().format('YYYY-MM-DD')
} else if (
['Now', 'now'].includes(defaultValue) &&
fieldtype === 'Datetime'
) {
return dayjs().format('YYYY-MM-DD HH:mm:ss')
} else if (['Now', 'now'].includes(defaultValue) && fieldtype === 'Time') {
return dayjs().format('HH:mm:ss')
} else if (fieldtype === 'Date') {
return dayjs(defaultValue).format('YYYY-MM-DD')
} else if (fieldtype === 'Datetime') {
return dayjs(defaultValue).format('YYYY-MM-DD HH:mm:ss')
} else if (fieldtype === 'Time') {
return dayjs(defaultValue).format('HH:mm:ss')
}
return defaultValue
}
</script>
<style scoped>

View File

@ -139,9 +139,14 @@ const oldFields = computed(() => {
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
const dropdownFields = computed(() => {
return getFields()?.filter(
(field) => !fields.value.find((f) => f.fieldname === field.fieldname),
)
return getFields()?.filter((field) => {
return (
!fields.value.find((f) => f.fieldname === field.fieldname) &&
!['Tab Break', 'Section Break', 'Column Break', 'Table'].includes(
field.fieldtype,
)
)
})
})
function reset() {

View File

@ -23,7 +23,13 @@
</div>
</div>
<div>
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="data" />
<FieldLayout
v-if="tabs.data"
:tabs="tabs.data"
:data="data"
:doctype="doctype"
:isGridRow="true"
/>
</div>
</div>
</template>

View File

@ -159,6 +159,7 @@ const options = createResource({
})
function reload(val) {
if (!props.doctype) return
if (
options.data?.length &&
val === options.params?.txt &&

View File

@ -58,6 +58,21 @@
class="p-1.5 max-h-[12rem] overflow-y-auto"
static
>
<div
v-if="!options.length"
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon
v-if="fetchContacts"
name="search"
class="h-4"
/>
{{
fetchContacts
? __('No results found')
: __('Type an email address to add')
}}
</div>
<ComboboxOption
v-for="option in options"
:key="option.value"
@ -137,6 +152,10 @@ const props = defineProps({
type: Function,
default: (value) => `${value} is an Invalid value`,
},
fetchContacts: {
type: Boolean,
default: true,
},
})
const values = defineModel()
@ -191,17 +210,19 @@ const filterOptions = createResource({
})
const options = computed(() => {
let searchedContacts = filterOptions.data || []
if (!searchedContacts.length && query.value) {
let searchedContacts = props.fetchContacts ? filterOptions.data : []
if (!searchedContacts?.length && query.value) {
searchedContacts.push({
label: query.value,
value: query.value,
})
}
return searchedContacts
return searchedContacts || []
})
function reload(val) {
if (!props.fetchContacts) return
filterOptions.update({
params: { txt: val },
})

View File

@ -0,0 +1,32 @@
<template>
<FormControl
:type="show ? 'text' : 'password'"
:value="modelValue || value"
v-bind="$attrs"
>
<template #suffix>
<Button v-show="showEye" class="!h-4" @click="show = !show">
<FeatherIcon :name="show ? 'eye-off' : 'eye'" class="h-3" />
</Button>
</template>
</FormControl>
</template>
<script setup>
import { FormControl } from 'frappe-ui'
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
value: {
type: [String, Number],
default: '',
},
})
const show = ref(false)
const showEye = computed(() => {
let v = props.modelValue || props.value
return !v?.includes('*')
})
</script>

View File

@ -60,9 +60,14 @@ const props = defineProps({
},
})
const emit = defineEmits(['change'])
const { getFields } = getMeta(props.doctype)
const values = defineModel()
const values = defineModel({
type: Array,
default: () => [],
})
const valuesRef = ref([])
const error = ref(null)
@ -109,14 +114,16 @@ const addValue = (value) => {
if (value) {
values.value.push({ [linkField.value.fieldname]: value })
emit('change', values.value)
!error.value && (query.value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter(
let _value = values.value.filter(
(row) => row[linkField.value.fieldname] !== value,
)
emit('change', _value)
}
const removeLastValue = () => {
@ -125,12 +132,11 @@ const removeLastValue = () => {
let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el
if (document.activeElement === valueRef) {
values.value.pop()
emit('change', values.value)
nextTick(() => {
if (values.value.length) {
valueRef = valuesRef.value[valuesRef.value.length - 1].$el
valueRef?.focus()
} else {
setFocus()
}
})
} else {

View File

@ -37,8 +37,8 @@ import { isMobileView } from '@/composables/settings'
const props = defineProps({
actions: {
type: Object,
required: true,
type: [Object, Array, undefined],
default: () => [],
},
})
@ -85,7 +85,7 @@ const groupedActions = computed(() => {
})
}
_actions = _actions.concat(
props.actions.filter((action) => action.group && !action.buttonLabel)
props.actions.filter((action) => action.group && !action.buttonLabel),
)
return _actions
})

View File

@ -0,0 +1,24 @@
<template>
<div
class="grid h-full place-items-center px-4 py-20 text-center text-lg text-ink-gray-5"
>
<div class="flex flex-col justify-between items-center gap-3">
<FeatherIcon name="x-octagon" class="h-12 w-12 text-ink-red-3" />
<div class="text-2xl font-semibold">{{ errorTitle }}</div>
<div v-html="errorMessage" />
</div>
</div>
</template>
<script setup>
const props = defineProps({
errorTitle: {
type: String,
required: true,
},
errorMessage: {
type: String,
required: true,
},
})
</script>

View File

@ -7,22 +7,30 @@
field.reqd ||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
"
class="text-ink-red-3"
class="text-ink-red-2"
>*</span
>
</div>
<FormControl
v-if="field.read_only && field.fieldtype !== 'Check'"
v-if="
field.read_only &&
!['Int', 'Float', 'Currency', 'Percent', 'Check'].includes(
field.fieldtype,
)
"
type="text"
:placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]"
:disabled="true"
:description="field.description"
/>
<Grid
v-else-if="field.fieldtype === 'Table'"
v-model="data[field.fieldname]"
v-model:parent="data"
:doctype="field.options"
:parentDoctype="doctype"
:parentFieldname="field.fieldname"
/>
<FormControl
v-else-if="field.fieldtype === 'Select'"
@ -31,7 +39,9 @@
:class="field.prefix ? 'prefix' : ''"
:options="field.options"
v-model="data[field.fieldname]"
@change="(e) => fieldChange(e.target.value, field)"
:placeholder="getPlaceholder(field)"
:description="field.description"
>
<template v-if="field.prefix" #prefix>
<IndicatorIcon :class="field.prefix" />
@ -42,8 +52,9 @@
class="form-control"
type="checkbox"
v-model="data[field.fieldname]"
@change="(e) => (data[field.fieldname] = e.target.checked)"
@change="(e) => fieldChange(e.target.checked, field)"
:disabled="Boolean(field.read_only)"
:description="field.description"
/>
<label
class="text-sm text-ink-gray-5"
@ -59,13 +70,18 @@
<span class="text-ink-red-3" v-if="field.mandatory">*</span>
</label>
</div>
<div class="flex gap-1" v-else-if="field.fieldtype === 'Link'">
<div
class="flex gap-1"
v-else-if="['Link', 'Dynamic Link'].includes(field.fieldtype)"
>
<Link
class="form-control flex-1 truncate"
:value="data[field.fieldname]"
:doctype="field.options"
:doctype="
field.fieldtype == 'Link' ? field.options : data[field.options]
"
:filters="field.filters"
@change="(v) => (data[field.fieldname] = v)"
@change="(v) => fieldChange(v, field)"
:placeholder="getPlaceholder(field)"
:onCreate="field.create"
/>
@ -85,6 +101,7 @@
v-else-if="field.fieldtype === 'Table MultiSelect'"
v-model="data[field.fieldname]"
:doctype="field.options"
@change="(v) => fieldChange(v, field)"
/>
<Link
@ -93,7 +110,7 @@
:value="data[field.fieldname] && getUser(data[field.fieldname]).full_name"
:doctype="field.options"
:filters="field.filters"
@change="(v) => (data[field.fieldname] = v)"
@change="(v) => fieldChange(v, field)"
:placeholder="getPlaceholder(field)"
:hideMe="true"
>
@ -118,80 +135,101 @@
</Link>
<DateTimePicker
v-else-if="field.fieldtype === 'Datetime'"
v-model="data[field.fieldname]"
icon-left=""
:value="data[field.fieldname]"
:formatter="(date) => getFormat(date, '', true, true)"
:placeholder="getPlaceholder(field)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<DatePicker
v-else-if="field.fieldtype === 'Date'"
icon-left=""
v-model="data[field.fieldname]"
:value="data[field.fieldname]"
:formatter="(date) => getFormat(date, '', true)"
:placeholder="getPlaceholder(field)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<FormControl
v-else-if="
['Small Text', 'Text', 'Long Text', 'Code'].includes(field.fieldtype)
"
type="textarea"
:value="data[field.fieldname]"
:placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<FormControl
v-else-if="['Int'].includes(field.fieldtype)"
type="number"
<Password
v-else-if="field.fieldtype === 'Password'"
:value="data[field.fieldname]"
:placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<FormControl
<FormattedInput
v-else-if="field.fieldtype === 'Int'"
type="text"
:placeholder="getPlaceholder(field)"
:value="data[field.fieldname] || '0'"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Percent'"
type="text"
:value="getFormattedPercent(field.fieldname, data)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)"
@change="data[field.fieldname] = flt($event.target.value)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<FormControl
<FormattedInput
v-else-if="field.fieldtype === 'Float'"
type="text"
:value="getFormattedFloat(field.fieldname, data)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)"
@change="data[field.fieldname] = flt($event.target.value)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<FormControl
<FormattedInput
v-else-if="field.fieldtype === 'Currency'"
type="text"
:value="getFormattedCurrency(field.fieldname, data)"
:value="getFormattedCurrency(field.fieldname, data, parentDoc)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.read_only)"
@change="data[field.fieldname] = flt($event.target.value)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<FormControl
v-else
type="text"
:placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]"
:value="getDataValue(data[field.fieldname], field)"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
</div>
</template>
<script setup>
import Password from '@/components/Controls/Password.vue'
import FormattedInput from '@/components/Controls/FormattedInput.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import TableMultiselectInput from '@/components/Controls/TableMultiselectInput.vue'
import Link from '@/components/Controls/Link.vue'
import Grid from '@/components/Controls/Grid.vue'
import { createDocument } from '@/composables/document'
import { getFormat, evaluateDependsOnValue } from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
import { computed, inject } from 'vue'
import { computed, provide, inject } from 'vue'
const props = defineProps({
field: Object,
@ -200,11 +238,32 @@ const props = defineProps({
const data = inject('data')
const doctype = inject('doctype')
const preview = inject('preview')
const isGridRow = inject('isGridRow')
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(doctype)
const { getUser } = usersStore()
let triggerOnChange
let parentDoc
if (!isGridRow) {
const {
triggerOnChange: trigger,
triggerOnRowAdd,
triggerOnRowRemove,
} = useDocument(doctype, data.value.name)
triggerOnChange = trigger
provide('triggerOnChange', triggerOnChange)
provide('triggerOnRowAdd', triggerOnRowAdd)
provide('triggerOnRowRemove', triggerOnRowRemove)
} else {
triggerOnChange = inject('triggerOnChange')
parentDoc = inject('parentDoc')
}
const field = computed(() => {
let field = props.field
if (field.fieldtype == 'Select' && typeof field.options === 'string') {
@ -221,6 +280,17 @@ const field = computed(() => {
field.fieldtype = 'User'
}
if (field.fieldtype === 'Link' && field.options !== 'User') {
if (!field.create) {
field.create = (value, close) => {
const callback = (d) => {
if (d) fieldChange(d.name, field)
}
createDocument(field.options, value, close, callback)
}
}
}
let _field = {
...field,
filters: field.link_filters && JSON.parse(field.link_filters),
@ -260,6 +330,23 @@ const getPlaceholder = (field) => {
return __('Enter {0}', [__(field.label)])
}
}
function fieldChange(value, df) {
data.value[df.fieldname] = value
if (isGridRow) {
triggerOnChange(df.fieldname, data.value)
} else {
triggerOnChange(df.fieldname)
}
}
function getDataValue(value, field) {
if (field.fieldtype === 'Duration') {
return value || 0
}
return value
}
</script>
<style scoped>
:deep(.form-control.prefix select) {

View File

@ -34,6 +34,10 @@ const props = defineProps({
type: String,
default: 'CRM Lead',
},
isGridRow: {
type: Boolean,
default: false,
},
preview: {
type: Boolean,
default: false,
@ -55,6 +59,7 @@ provide(
provide('hasTabs', hasTabs)
provide('doctype', props.doctype)
provide('preview', props.preview)
provide('isGridRow', props.isGridRow)
</script>
<style scoped>
.section:not(:has(.field)) {

View File

@ -277,13 +277,13 @@ const fields = createResource({
]
let existingFields = []
for (let tab of props.tabs) {
for (let section of tab.sections) {
for (let column of section.columns) {
props.tabs?.forEach((tab) => {
tab.sections?.forEach((section) => {
section.columns?.forEach((column) => {
existingFields = existingFields.concat(column.fields)
}
}
}
})
})
})
return data.filter((field) => {
return (

View File

@ -104,7 +104,7 @@
import FilesUploaderArea from '@/components/FilesUploader/FilesUploaderArea.vue'
import FilesUploadHandler from './filesUploaderHandler'
import { isMobileView } from '@/composables/settings'
import { createToast } from '@/utils'
import { toast } from 'frappe-ui'
import { ref, computed } from 'vue'
const props = defineProps({
@ -165,12 +165,7 @@ function attachFiles() {
function uploadViaWebLink() {
let fileUrl = filesUploaderArea.value.webLink
if (!fileUrl) {
createToast({
title: __('Error'),
title: __('Please enter a valid URL'),
icon: 'x',
iconClasses: 'text-ink-red-4',
})
toast.error(__('Please enter a valid URL'))
return
}
fileUrl = decodeURI(fileUrl)

View File

@ -126,8 +126,13 @@
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
import { createToast, formatDate, convertSize } from '@/utils'
import { FormControl, CircularProgressBar, createResource } from 'frappe-ui'
import { formatDate, convertSize } from '@/utils'
import {
FormControl,
CircularProgressBar,
createResource,
toast,
} from 'frappe-ui'
import { ref, onMounted, watch, onUnmounted } from 'vue'
const props = defineProps({
@ -324,24 +329,18 @@ function checkRestrictions(file) {
if (!isCorrectType) {
console.warn('File skipped because of invalid file type', file)
createToast({
title: __('File "{0}" was skipped because of invalid file type', [
file.name,
]),
icon: 'alert-circle',
iconClasses: 'text-orange-600',
})
toast.warning(
__('File "{0}" was skipped because of invalid file type', [file.name]),
)
}
if (!validFileSize) {
console.warn('File skipped because of invalid file size', file.size, file)
createToast({
title: __('File "{0}" was skipped because size exceeds {1} MB', [
toast.warning(
__('File "{0}" was skipped because size exceeds {1} MB', [
file.name,
maxFileSize / (1024 * 1024),
]),
icon: 'alert-circle',
iconClasses: 'text-orange-600',
})
)
}
return isCorrectType && validFileSize
@ -363,11 +362,7 @@ function showMaxFilesNumberWarning(file, maxNumberOfFiles) {
)
}
createToast({
title: message,
icon: 'alert-circle',
iconClasses: 'text-orange-600',
})
toast.warning(message)
}
function removeFile(name) {

View File

@ -126,7 +126,7 @@
<div class="flex items-center justify-between gap-2">
<Autocomplete
value=""
:options="filterableFields.data"
:options="availableFilters"
@change="(e) => setfilter(e)"
:placeholder="__('First name')"
>
@ -217,6 +217,19 @@ const filters = computed(() => {
return convertFilters(filterableFields.data, allFilters)
})
const availableFilters = computed(() => {
if (!filterableFields.data) return []
const selectedFieldNames = new Set()
for (const filter of filters.value) {
selectedFieldNames.add(filter.fieldname)
}
return filterableFields.data.filter(
(field) => !selectedFieldNames.has(field.fieldname),
)
})
function removeCommonFilters(commonFilters, allFilters) {
for (const key in commonFilters) {
if (commonFilters.hasOwnProperty(key) && allFilters.hasOwnProperty(key)) {

View File

@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-square-asterisk-icon lucide-square-asterisk"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M12 8v8" />
<path d="m8.5 14 7-4" />
<path d="m8.5 10 7 4" />
</svg>
</template>

View File

@ -0,0 +1,21 @@
<template>
<svg
viewBox="0 0 64 64"
xmlns="http://www.w3.org/2000/svg"
stroke-width="3"
stroke="currentColor"
fill="none"
>
<g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<path
d="M26.67,38.57l-.82,11.54A2.88,2.88,0,0,0,28.14,49l5.5-5.26,11.42,8.35c2.08,1.17,3.55.56,4.12-1.92l7.49-35.12h0c.66-3.09-1.08-4.33-3.16-3.55l-44,16.85C6.47,29.55,6.54,31.23,9,32l11.26,3.5L45.59,20.71c1.23-.83,2.36-.37,1.44.44Z"
stroke-linecap="round"
></path>
</g>
</svg>
</template>

View File

@ -150,6 +150,7 @@ import Section from '@/components/Section.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import PinIcon from '@/components/Icons/PinIcon.vue'
import UserDropdown from '@/components/UserDropdown.vue'
import SquareAsterisk from '@/components/Icons/SquareAsterisk.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
@ -168,6 +169,8 @@ import {
unreadNotificationsCount,
notificationsStore,
} from '@/stores/notifications'
import { usersStore } from '@/stores/users'
import { sessionStore } from '@/stores/session'
import { showSettings, activeSettingsPage } from '@/composables/settings'
import { FeatherIcon, call } from 'frappe-ui'
import {
@ -299,16 +302,18 @@ function getIcon(routeName, icon) {
}
// onboarding
const { user } = sessionStore()
const { users, isManager } = usersStore()
const { isOnboardingStepsCompleted, setUp } = useOnboarding('frappecrm')
async function getFirstLead() {
let firstLead = localStorage.getItem('firstLead')
let firstLead = localStorage.getItem('firstLead' + user)
if (firstLead) return firstLead
return await call('crm.api.onboarding.get_first_lead')
}
async function getFirstDeal() {
let firstDeal = localStorage.getItem('firstDeal')
let firstDeal = localStorage.getItem('firstDeal' + user)
if (firstDeal) return firstDeal
return await call('crm.api.onboarding.get_first_deal')
}
@ -317,6 +322,17 @@ const showIntermediateModal = ref(false)
const currentStep = ref({})
const steps = reactive([
{
name: 'setup_your_password',
title: __('Setup your password'),
icon: markRaw(SquareAsterisk),
completed: false,
onClick: () => {
minimize.value = true
showSettings.value = true
activeSettingsPage.value = 'Profile'
},
},
{
name: 'create_first_lead',
title: __('Create your first lead'),
@ -337,12 +353,14 @@ const steps = reactive([
showSettings.value = true
activeSettingsPage.value = 'Invite Members'
},
condition: () => isManager(),
},
{
name: 'convert_lead_to_deal',
title: __('Convert lead to deal'),
icon: markRaw(ConvertIcon),
completed: false,
dependsOn: 'create_first_lead',
onClick: async () => {
minimize.value = true
@ -410,6 +428,7 @@ const steps = reactive([
title: __('Add your first comment'),
icon: markRaw(CommentIcon),
completed: false,
dependsOn: 'create_first_lead',
onClick: async () => {
minimize.value = true
let deal = await getFirstDeal()
@ -430,6 +449,7 @@ const steps = reactive([
title: __('Send email'),
icon: markRaw(EmailIcon),
completed: false,
dependsOn: 'create_first_lead',
onClick: async () => {
minimize.value = true
let deal = await getFirstDeal()
@ -450,6 +470,7 @@ const steps = reactive([
title: __('Change deal status'),
icon: markRaw(StepsIcon),
completed: false,
dependsOn: 'convert_lead_to_deal',
onClick: async () => {
minimize.value = true
@ -478,7 +499,18 @@ const steps = reactive([
},
])
onMounted(() => setUp(steps))
onMounted(async () => {
await users.promise
const filteredSteps = steps.filter((step) => {
if (step.condition) {
return step.condition()
}
return true
})
setUp(filteredSteps)
})
// help center
const articles = ref([
@ -517,9 +549,7 @@ const articles = ref([
{
title: __('Capturing leads'),
opened: false,
subArticles: [
{ name: 'web-form', title: __('Web form') },
],
subArticles: [{ name: 'web-form', title: __('Web form') }],
},
{
title: __('Views'),

View File

@ -7,9 +7,11 @@
<AppHeader />
<slot />
</div>
<GlobalModals />
</div>
</template>
<script setup>
import AppSidebar from '@/components/Layouts/AppSidebar.vue'
import AppHeader from '@/components/Layouts/AppHeader.vue'
import GlobalModals from '@/components/Modals/GlobalModals.vue'
</script>

View File

@ -5,9 +5,11 @@
<MobileAppHeader />
<slot />
</div>
<GlobalModals />
</div>
</template>
<script setup>
import MobileSidebar from '@/components/Mobile/MobileSidebar.vue'
import MobileAppHeader from '@/components/Mobile/MobileAppHeader.vue'
import GlobalModals from '@/components/Modals/GlobalModals.vue'
</script>

View File

@ -19,10 +19,10 @@
<script setup>
import EditValueModal from '@/components/Modals/EditValueModal.vue'
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
import { setupListCustomizations, createToast } from '@/utils'
import { setupListCustomizations } from '@/utils'
import { globalStore } from '@/stores/global'
import { capture } from '@/telemetry'
import { call } from 'frappe-ui'
import { call, toast } from 'frappe-ui'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
@ -75,11 +75,7 @@ function convertToDeal(selections, unselectAll) {
call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
lead: name,
}).then(() => {
createToast({
title: __('Converted successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
toast.success(__('Converted successfully'))
list.value.reload()
unselectAll()
close()
@ -110,11 +106,7 @@ function deleteValues(selections, unselectAll) {
items: JSON.stringify(Array.from(selections)),
doctype: props.doctype,
}).then(() => {
createToast({
title: __('Deleted successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
toast.success(__('Deleted successfully'))
unselectAll()
list.value.reload()
close()
@ -154,11 +146,7 @@ function clearAssignemnts(selections, unselectAll) {
names: JSON.stringify(Array.from(selections)),
ignore_permissions: true,
}).then(() => {
createToast({
title: __('Assignment cleared successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
toast.success(__('Assignment cleared successfully'))
reload(unselectAll)
close()
})
@ -215,7 +203,8 @@ function bulkActions(selections, unselectAll) {
selections,
unselectAll,
call,
createToast,
createToast: toast.create,
toast,
$dialog,
router,
}),
@ -235,7 +224,8 @@ onMounted(async () => {
let customization = await setupListCustomizations(list.value.data, {
list: list.value,
call,
createToast,
createToast: toast.create,
toast,
$dialog,
$socket,
router,

View File

@ -10,6 +10,7 @@
}"
row-key="name"
v-bind="$attrs"
@update:selections="(selections) => emit('selectionsChanged', selections)"
>
<ListHeader
class="sm:mx-5 mx-3"
@ -205,6 +206,7 @@ const emit = defineEmits([
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const pageLengthCount = defineModel()

View File

@ -14,6 +14,7 @@
resizeColumn: options.resizeColumn,
}"
row-key="name"
@update:selections="(selections) => emit('selectionsChanged', selections)"
>
<ListHeader
class="mx-3 sm:mx-5"
@ -201,6 +202,7 @@ const emit = defineEmits([
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const route = useRoute()

View File

@ -14,6 +14,7 @@
resizeColumn: options.resizeColumn,
}"
row-key="name"
@update:selections="(selections) => emit('selectionsChanged', selections)"
>
<ListHeader
class="sm:mx-5 mx-3"
@ -245,6 +246,7 @@ const emit = defineEmits([
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const route = useRoute()

View File

@ -9,6 +9,7 @@
resizeColumn: options.resizeColumn,
}"
row-key="name"
@update:selections="(selections) => emit('selectionsChanged', selections)"
>
<ListHeader
class="sm:mx-5 mx-3"
@ -191,6 +192,7 @@ const emit = defineEmits([
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const pageLengthCount = defineModel()

View File

@ -14,6 +14,7 @@
resizeColumn: options.resizeColumn,
}"
row-key="name"
@update:selections="(selections) => emit('selectionsChanged', selections)"
>
<ListHeader
class="sm:mx-5 mx-3"
@ -250,7 +251,6 @@ const props = defineProps({
}),
},
})
const emit = defineEmits([
'loadMore',
'updatePageCount',
@ -258,6 +258,7 @@ const emit = defineEmits([
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const route = useRoute()

View File

@ -13,6 +13,7 @@
resizeColumn: options.resizeColumn,
}"
row-key="name"
@update:selections="(selections) => emit('selectionsChanged', selections)"
>
<ListHeader
class="sm:mx-5 mx-3"
@ -186,6 +187,7 @@ const emit = defineEmits([
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const route = useRoute()

View File

@ -9,6 +9,7 @@
resizeColumn: options.resizeColumn,
}"
row-key="name"
@update:selections="(selections) => emit('selectionsChanged', selections)"
>
<ListHeader
class="mx-3 sm:mx-5"
@ -207,6 +208,7 @@ const emit = defineEmits([
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const pageLengthCount = defineModel()

View File

@ -0,0 +1,100 @@
<template>
<Dialog v-model="show" :options="{ size: 'sm' }">
<template #body>
<div class="p-4 pt-5">
<div class="flex justify-center">
<div class="flex flex-col items-center">
<CRMLogo class="mb-3 size-12" />
<h3 class="font-semibold text-xl text-ink-gray-9">Frappe CRM</h3>
<div class="flex items-center mt-1">
<div class="text-base text-ink-gray-6">
{{ appVersion.branch != 'main' ? appVersion.branch : '' }}
<template v-if="appVersion.branch != 'main'">
({{ appVersion.commit }})
</template>
<template v-else>{{ appVersion.tag }}</template>
</div>
<Tooltip
:text="`${appVersion.commit_message} - ${appVersion.commit_date}`"
placement="top"
>
<LucideInfo class="size-3.5 text-ink-gray-8 ml-1" />
</Tooltip>
</div>
</div>
</div>
<hr class="border-t my-3 mx-2" />
<div>
<a
v-for="link in links"
:key="link.label"
class="flex py-2 px-2 hover:bg-surface-gray-1 rounded cursor-pointer"
target="_blank"
:href="link.url"
>
<component
v-if="link.icon"
:is="link.icon"
class="size-4 mr-2 text-ink-gray-7"
/>
<span class="text-base text-ink-gray-8">
{{ link.label }}
</span>
</a>
</div>
<hr class="border-t my-3 mx-2" />
<p class="text-sm text-ink-gray-6 px-2 mt-2">
© Frappe Technologies Pvt. Ltd. and contributors
</p>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Tooltip } from 'frappe-ui'
import CRMLogo from '@/components/Icons/CRMLogo.vue'
import LucideGlobe from '~icons/lucide/globe'
import LucideGitHub from '~icons/lucide/github'
import LucideHeadset from '~icons/lucide/headset'
import LucideBug from '~icons/lucide/bug'
import LucideBookOpen from '~icons/lucide/book-open'
import TelegramIcon from '@/components/Icons/TelegramIcon.vue'
let show = defineModel()
let links = [
{
label: __('Website'),
url: 'https://frappe.io/crm',
icon: LucideGlobe,
},
{
label: __('GitHub Repository'),
url: 'https://github.com/frappe/crm',
icon: LucideGitHub,
},
{
label: __('Documentation'),
url: 'https://docs.frappe.io/crm',
icon: LucideBookOpen,
},
{
label: __('Telegram Channel'),
url: 'https://t.me/frappecrm',
icon: TelegramIcon,
},
{
label: __('Report an Issue'),
url: 'https://github.com/frappe/crm/issues',
icon: LucideBug,
},
{
label: __('Contact Support'),
url: 'https://support.frappe.io',
icon: LucideHeadset,
},
]
let appVersion = window.app_version
</script>

View File

@ -22,8 +22,12 @@
</Button>
</div>
</div>
<div v-if="tabs.data">
<FieldLayout :tabs="tabs.data" :data="_address" doctype="Address" />
<div v-if="tabs.data && _address.doc">
<FieldLayout
:tabs="tabs.data"
:data="_address.doc"
doctype="Address"
/>
<ErrorMessage class="mt-2" :message="error" />
</div>
</div>
@ -41,24 +45,24 @@
</div>
</template>
</Dialog>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
doctype="Address"
/>
</template>
<script setup>
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import { usersStore } from '@/stores/users'
import { isMobileView } from '@/composables/settings'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { useDocument } from '@/data/document'
import { capture } from '@/telemetry'
import { FeatherIcon, createResource, ErrorMessage } from 'frappe-ui'
import { ref, nextTick, watch, computed } from 'vue'
import { ref, nextTick, computed, onMounted } from 'vue'
const props = defineProps({
address: {
type: String,
default: '',
},
options: {
type: Object,
default: {
@ -70,30 +74,18 @@ const props = defineProps({
const { isManager } = usersStore()
const show = defineModel()
const address = defineModel('address')
const loading = ref(false)
const error = ref(null)
const title = ref(null)
const editMode = ref(false)
let _address = ref({
name: '',
address_title: '',
address_type: 'Billing',
address_line1: '',
address_line2: '',
city: '',
county: '',
state: '',
country: '',
pincode: '',
})
const { document: _address } = useDocument('Address', props.address || '')
const dialogOptions = computed(() => {
let title = !editMode.value
? __('New Address')
: __(_address.value.address_title)
: __(_address.doc?.address_title)
let size = 'xl'
let actions = [
{
@ -114,42 +106,28 @@ const tabs = createResource({
auto: true,
})
let doc = ref({})
function updateAddress() {
error.value = null
const old = { ...doc.value }
const newAddress = { ..._address.value }
const dirty = JSON.stringify(old) !== JSON.stringify(newAddress)
const values = newAddress
if (!dirty) {
show.value = false
return
}
loading.value = true
updateAddressValues.submit({
doctype: 'Address',
name: _address.value.name,
fieldname: values,
})
}
const updateAddressValues = createResource({
url: 'frappe.client.set_value',
onSuccess(doc) {
const callBacks = {
onSuccess: (doc) => {
loading.value = false
if (doc.name) {
handleAddressUpdate(doc)
}
handleAddressUpdate(doc)
},
onError(err) {
onError: (err) => {
loading.value = false
if (err.exc_type == 'MandatoryError') {
const errorMessage = err.messages
.map((msg) => msg.split(': ')[2].trim())
.join(', ')
error.value = __('These fields are required: {0}', [errorMessage])
return
}
error.value = err
},
})
}
async function updateAddress() {
loading.value = true
await _address.save.submit(null, callBacks)
}
const createAddress = createResource({
url: 'frappe.client.insert',
@ -157,7 +135,7 @@ const createAddress = createResource({
return {
doc: {
doctype: 'Address',
..._address.value,
..._address.doc,
},
}
},
@ -179,27 +157,17 @@ function handleAddressUpdate(doc) {
props.options.afterInsert && props.options.afterInsert(doc)
}
watch(
() => show.value,
(value) => {
if (!value) return
editMode.value = false
nextTick(() => {
// TODO: Issue with FormControl
// title.value.el.focus()
doc.value = address.value?.doc || address.value || {}
_address.value = { ...doc.value }
if (_address.value.name) {
editMode.value = true
}
})
},
)
onMounted(() => {
editMode.value = props.address ? true : false
const showQuickEntryModal = ref(false)
if (!props.address) {
_address.doc = { address_type: 'Billing' }
}
})
function openQuickEntryModal() {
showQuickEntryModal.value = true
quickEntryProps.value = { doctype: 'Address' }
nextTick(() => {
show.value = false
})

View File

@ -145,13 +145,11 @@ function updateAssignees() {
.map((assignee) => assignee.name)
if (removedAssignees.length) {
for (let a of removedAssignees) {
call('frappe.desk.form.assign_to.remove', {
doctype: props.doctype,
name: props.doc.name,
assign_to: a,
})
}
call('crm.api.doc.remove_assignments', {
doctype: props.doctype,
name: props.doc.name,
assignees: JSON.stringify(removedAssignees),
})
}
if (addedAssignees.length) {

View File

@ -172,8 +172,9 @@ import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
import { getCallLogDetail } from '@/utils/callLog'
import { usersStore } from '@/stores/users'
import { isMobileView } from '@/composables/settings'
import { useDocument } from '@/data/document'
import { FeatherIcon, Dropdown, Avatar, Tooltip, call } from 'frappe-ui'
import { ref, computed, h, nextTick } from 'vue'
import { ref, computed, h, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
const { isManager } = usersStore()
@ -289,9 +290,19 @@ const detailFields = computed(() => {
.filter((detail) => (detail.condition ? detail.condition() : true))
})
function createLead() {
const d = ref({})
const leadDetails = ref({})
async function createLead() {
await d.value.triggerOnCreateLead?.(
callLog.value?.data,
leadDetails.value,
() => (show.value = false),
)
call('crm.fcrm.doctype.crm_call_log.crm_call_log.create_lead_from_call_log', {
call_log: callLog.value?.data,
lead_details: leadDetails.value,
}).then((d) => {
if (d) {
router.push({ name: 'Lead', params: { leadId: d } })
@ -351,6 +362,14 @@ async function addTaskToCallLog(_task, insert_mode = false) {
})
}
}
watch(
() => callLog.value?.data?.name,
(value) => {
if (!value) return
d.value = useDocument('CRM Call Log', value)
},
)
</script>
<style scoped>

View File

@ -1,12 +1,13 @@
<template>
<Dialog v-model="show" :options="dialogOptions">
<template #body>
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-5 flex items-center justify-between">
<div>
<div class="px-4 pt-5 pb-6 bg-surface-modal sm:px-6">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-2">
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __(dialogOptions.title) || __('Untitled') }}
</h3>
<Badge v-if="callLog.isDirty" :label="'Not Saved'" theme="orange" />
</div>
<div class="flex items-center gap-1">
<Button
@ -15,23 +16,23 @@
class="w-7"
@click="openQuickEntryModal"
>
<EditIcon class="h-4 w-4" />
<EditIcon class="w-4 h-4" />
</Button>
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
<FeatherIcon name="x" class="w-4 h-4" />
</Button>
</div>
</div>
<div v-if="tabs.data">
<FieldLayout
:tabs="tabs.data"
:data="_callLog"
:data="callLog.doc"
doctype="CRM Call Log"
/>
<ErrorMessage class="mt-2" :message="error" />
<ErrorMessage class="mt-8" :message="error" />
</div>
</div>
<div class="px-4 pb-7 pt-4 sm:px-6">
<div class="px-4 pt-4 pb-7 sm:px-6">
<div class="space-y-2">
<Button
class="w-full"
@ -45,25 +46,25 @@
</div>
</template>
</Dialog>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
doctype="CRM Call Log"
/>
</template>
<script setup>
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import { usersStore } from '@/stores/users'
import { isMobileView } from '@/composables/settings'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { getRandom } from '@/utils'
import { capture } from '@/telemetry'
import { FeatherIcon, createResource, ErrorMessage } from 'frappe-ui'
import { ref, nextTick, watch, computed } from 'vue'
import { useDocument } from '@/data/document'
import { FeatherIcon, createResource, ErrorMessage, Badge } from 'frappe-ui'
import { ref, nextTick, computed, onMounted } from 'vue'
const props = defineProps({
data: {
type: Object,
default: () => ({}),
},
options: {
type: Object,
default: {
@ -75,26 +76,15 @@ const props = defineProps({
const { isManager } = usersStore()
const show = defineModel()
const callLog = defineModel('callLog')
const loading = ref(false)
const error = ref(null)
const title = ref(null)
const editMode = ref(false)
let _callLog = ref({
name: '',
type: '',
from: '',
to: '',
medium: '',
duration: '',
caller: '',
receiver: '',
status: '',
recording_url: '',
telephony_medium: 'Manual',
})
const { document: callLog } = useDocument(
'CRM Call Log',
props.data?.name || '',
)
const dialogOptions = computed(() => {
let title = !editMode.value ? __('New Call Log') : __('Edit Call Log')
@ -118,41 +108,28 @@ const tabs = createResource({
auto: true,
})
let doc = ref({})
function updateCallLog() {
error.value = null
const old = { ...doc.value }
const newCallLog = { ..._callLog.value }
const dirty = JSON.stringify(old) !== JSON.stringify(newCallLog)
if (!dirty) {
show.value = false
return
}
loading.value = true
updateCallLogValues.submit({
doctype: 'CRM Call Log',
name: _callLog.value.name,
fieldname: newCallLog,
})
}
const updateCallLogValues = createResource({
url: 'frappe.client.set_value',
onSuccess(doc) {
const callBacks = {
onSuccess: (doc) => {
loading.value = false
if (doc.name) {
handleCallLogUpdate(doc)
}
handleCallLogUpdate(doc)
},
onError(err) {
onError: (err) => {
loading.value = false
if (err.exc_type == 'MandatoryError') {
const errorMessage = err.messages
.map((msg) => msg.split(': ')[2].trim())
.join(', ')
error.value = __('These fields are required: {0}', [errorMessage])
return
}
error.value = err
},
})
}
async function updateCallLog() {
loading.value = true
await callLog.save.submit(null, callBacks)
}
const createCallLog = createResource({
url: 'frappe.client.insert',
@ -162,7 +139,7 @@ const createCallLog = createResource({
doctype: 'CRM Call Log',
id: getRandom(6),
telephony_medium: 'Manual',
..._callLog.value,
...callLog.doc,
},
}
},
@ -174,8 +151,7 @@ const createCallLog = createResource({
}
},
onError(err) {
loading.value = false
error.value = err
callBacks.onError(err)
},
})
@ -184,29 +160,17 @@ function handleCallLogUpdate(doc) {
props.options.afterInsert && props.options.afterInsert(doc)
}
watch(
() => show.value,
(value) => {
if (!value) return
editMode.value = false
nextTick(() => {
// TODO: Issue with FormControl
// title.value.el.focus()
doc.value = callLog.value?.data || {}
_callLog.value = { ...doc.value }
if (_callLog.value.name) {
editMode.value = true
}
})
},
)
onMounted(() => {
editMode.value = props.data?.name ? true : false
const showQuickEntryModal = ref(false)
if (!props.data?.name) {
callLog.doc = { ...props.data }
}
})
function openQuickEntryModal() {
showQuickEntryModal.value = true
nextTick(() => {
show.value = false
})
quickEntryProps.value = { doctype: 'CRM Call Log' }
nextTick(() => (show.value = false))
}
</script>

View File

@ -25,7 +25,7 @@
<FieldLayout
v-if="tabs.data?.length"
:tabs="tabs.data"
:data="_contact"
:data="_contact.doc"
doctype="Contact"
/>
</div>
@ -49,9 +49,16 @@ import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import { usersStore } from '@/stores/users'
import { isMobileView } from '@/composables/settings'
import {
showQuickEntryModal,
quickEntryProps,
showAddressModal,
addressProps,
} from '@/composables/modals'
import { useDocument } from '@/data/document'
import { capture } from '@/telemetry'
import { call, createResource } from 'frappe-ui'
import { ref, nextTick, watch } from 'vue'
import { ref, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
@ -68,8 +75,6 @@ const props = defineProps({
},
})
const emit = defineEmits(['openAddressModal'])
const { isManager } = usersStore()
const router = useRouter()
@ -77,23 +82,23 @@ const show = defineModel()
const loading = ref(false)
let _contact = ref({})
const { document: _contact } = useDocument('Contact')
async function createContact() {
if (_contact.value.email_id) {
_contact.value.email_ids = [{ email_id: _contact.value.email_id }]
delete _contact.value.email_id
if (_contact.doc.email_id) {
_contact.doc.email_ids = [{ email_id: _contact.doc.email_id }]
delete _contact.doc.email_id
}
if (_contact.value.mobile_no) {
_contact.value.phone_nos = [{ phone: _contact.value.mobile_no }]
delete _contact.value.mobile_no
if (_contact.doc.mobile_no) {
_contact.doc.phone_nos = [{ phone: _contact.doc.mobile_no }]
delete _contact.doc.mobile_no
}
const doc = await call('frappe.client.insert', {
doc: {
doctype: 'Contact',
..._contact.value,
..._contact.doc,
},
})
if (doc.name) {
@ -130,17 +135,13 @@ const tabs = createResource({
field.read_only = false
} else if (field.fieldname == 'address') {
field.create = (value, close) => {
_contact.value.address = value
emit('openAddressModal')
show.value = false
_contact.doc.address = value
openAddressModal()
close()
}
field.edit = (address) => {
emit('openAddressModal', address)
show.value = false
}
field.edit = (address) => openAddressModal(address)
} else if (field.fieldtype === 'Table') {
_contact.value[field.fieldname] = []
_contact.doc[field.fieldname] = []
}
})
})
@ -149,20 +150,23 @@ const tabs = createResource({
},
})
watch(
() => show.value,
(value) => {
if (!value) return
nextTick(() => {
_contact.value = { ...props.contact.data }
})
},
)
const showQuickEntryModal = defineModel('showQuickEntryModal')
onMounted(() => {
_contact.doc = {}
Object.assign(_contact.doc, props.contact.data || props.contact)
})
function openQuickEntryModal() {
showQuickEntryModal.value = true
quickEntryProps.value = { doctype: 'Contact' }
nextTick(() => (show.value = false))
}
function openAddressModal(_address) {
showAddressModal.value = true
addressProps.value = {
doctype: 'Address',
address: _address,
}
nextTick(() => (show.value = false))
}
</script>

View File

@ -0,0 +1,147 @@
<template>
<Dialog v-model="show" :options="dialogOptions">
<template #body>
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-5 flex items-center justify-between">
<div>
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __(dialogOptions.title) || __('Untitled') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button
v-if="isManager() && !isMobileView"
variant="ghost"
class="w-7"
@click="openQuickEntryModal"
>
<EditIcon class="h-4 w-4" />
</Button>
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>
</div>
<div v-if="tabs.data">
<FieldLayout :tabs="tabs.data" :data="_data" :doctype="doctype" />
<ErrorMessage class="mt-2" :message="error" />
</div>
</div>
<div class="px-4 pb-7 pt-4 sm:px-6">
<div class="space-y-2">
<Button
class="w-full"
v-for="action in dialogOptions.actions"
:key="action.label"
v-bind="action"
:label="__(action.label)"
:loading="loading"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import { usersStore } from '@/stores/users'
import { isMobileView } from '@/composables/settings'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { FeatherIcon, createResource, ErrorMessage, call } from 'frappe-ui'
import { ref, nextTick, watch, computed } from 'vue'
const props = defineProps({
doctype: {
type: String,
required: true,
},
data: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['callback'])
const { isManager } = usersStore()
const show = defineModel()
const loading = ref(false)
const error = ref(null)
let _data = ref({})
const dialogOptions = computed(() => {
let doctype = props.doctype
if (doctype.startsWith('CRM ') || doctype.startsWith('FCRM ')) {
doctype = doctype.replace(/^(CRM |FCRM )/, '')
}
let title = __('New {0}', [doctype])
let size = 'xl'
let actions = [
{
label: __('Create'),
variant: 'solid',
onClick: () => create(),
},
]
return { title, size, actions }
})
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['QuickEntry', props.doctype],
params: { doctype: props.doctype, type: 'Quick Entry' },
auto: true,
})
async function create() {
loading.value = true
error.value = null
let doc = await call(
'frappe.client.insert',
{
doc: {
doctype: props.doctype,
..._data.value,
},
},
{
onError: (err) => {
loading.value = false
if (err.error) {
error.value = err.error.messages?.[0]
}
},
},
)
loading.value = false
show.value = false
emit('callback', doc)
}
watch(
() => show.value,
(value) => {
if (!value) return
nextTick(() => {
_data.value = { ...props.data }
})
},
)
function openQuickEntryModal() {
showQuickEntryModal.value = true
quickEntryProps.value = { doctype: props.doctype }
nextTick(() => (show.value = false))
}
</script>

View File

@ -50,7 +50,7 @@
ref="fieldLayoutRef"
v-if="tabs.data?.length"
:tabs="tabs.data"
:data="deal"
:data="deal.doc"
doctype="CRM Deal"
/>
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
@ -76,9 +76,11 @@ import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { isMobileView } from '@/composables/settings'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { useDocument } from '@/data/document'
import { capture } from '@/telemetry'
import { Switch, createResource } from 'frappe-ui'
import { computed, ref, reactive, onMounted, nextTick, watch } from 'vue'
import { computed, ref, onMounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
@ -92,24 +94,7 @@ const show = defineModel()
const router = useRouter()
const error = ref(null)
const deal = reactive({
organization: '',
organization_name: '',
website: '',
no_of_employees: '',
territory: '',
annual_revenue: '',
industry: '',
contact: '',
salutation: '',
first_name: '',
last_name: '',
email: '',
mobile_no: '',
gender: '',
status: '',
deal_owner: '',
})
const { document: deal } = useDocument('CRM Deal')
const hasOrganizationSections = ref(true)
const hasContactSections = ref(true)
@ -165,11 +150,11 @@ const tabs = createResource({
if (field.fieldname == 'status') {
field.fieldtype = 'Select'
field.options = dealStatuses.value
field.prefix = getDealStatus(deal.status).color
field.prefix = getDealStatus(deal.doc.status).color
}
if (field.fieldtype === 'Table') {
deal[field.fieldname] = []
deal.doc[field.fieldname] = []
}
})
})
@ -180,46 +165,49 @@ const tabs = createResource({
const dealStatuses = computed(() => {
let statuses = statusOptions('deal')
if (!deal.status) {
deal.status = statuses[0].value
if (!deal.doc.status) {
deal.doc.status = statuses[0].value
}
return statuses
})
function createDeal() {
if (deal.website && !deal.website.startsWith('http')) {
deal.website = 'https://' + deal.website
if (deal.doc.website && !deal.doc.website.startsWith('http')) {
deal.doc.website = 'https://' + deal.doc.website
}
if (chooseExistingContact.value) {
deal['first_name'] = null
deal['last_name'] = null
deal['email'] = null
deal['mobile_no'] = null
} else deal['contact'] = null
deal.doc['first_name'] = null
deal.doc['last_name'] = null
deal.doc['email'] = null
deal.doc['mobile_no'] = null
} else deal.doc['contact'] = null
createResource({
url: 'crm.fcrm.doctype.crm_deal.crm_deal.create_deal',
params: { args: deal },
params: { args: deal.doc },
auto: true,
validate() {
error.value = null
if (deal.annual_revenue) {
if (typeof deal.annual_revenue === 'string') {
deal.annual_revenue = deal.annual_revenue.replace(/,/g, '')
} else if (isNaN(deal.annual_revenue)) {
if (deal.doc.annual_revenue) {
if (typeof deal.doc.annual_revenue === 'string') {
deal.doc.annual_revenue = deal.doc.annual_revenue.replace(/,/g, '')
} else if (isNaN(deal.doc.annual_revenue)) {
error.value = __('Annual Revenue should be a number')
return error.value
}
}
if (deal.mobile_no && isNaN(deal.mobile_no.replace(/[-+() ]/g, ''))) {
if (
deal.doc.mobile_no &&
isNaN(deal.doc.mobile_no.replace(/[-+() ]/g, ''))
) {
error.value = __('Mobile No should be a number')
return error.value
}
if (deal.email && !deal.email.includes('@')) {
if (deal.doc.email && !deal.doc.email.includes('@')) {
error.value = __('Invalid Email')
return error.value
}
if (!deal.status) {
if (!deal.doc.status) {
error.value = __('Status is required')
return error.value
}
@ -242,22 +230,21 @@ function createDeal() {
})
}
const showQuickEntryModal = defineModel('quickEntry')
function openQuickEntryModal() {
showQuickEntryModal.value = true
nextTick(() => {
show.value = false
})
quickEntryProps.value = { doctype: 'CRM Deal' }
nextTick(() => (show.value = false))
}
onMounted(() => {
Object.assign(deal, props.defaults)
if (!deal.deal_owner) {
deal.deal_owner = getUser().name
deal.doc = { no_of_employees: '1-10' }
Object.assign(deal.doc, props.defaults)
if (!deal.doc.deal_owner) {
deal.doc.deal_owner = getUser().name
}
if (!deal.status && dealStatuses.value[0].value) {
deal.status = dealStatuses.value[0].value
if (!deal.doc.status && dealStatuses.value[0].value) {
deal.doc.status = dealStatuses.value[0].value
}
})
</script>

View File

@ -0,0 +1,39 @@
<template>
<CreateDocumentModal
v-if="showCreateDocumentModal"
v-model="showCreateDocumentModal"
:doctype="createDocumentDoctype"
:data="createDocumentData"
@callback="(data) => createDocumentCallback(data)"
/>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
v-bind="quickEntryProps"
/>
<AddressModal
v-if="showAddressModal"
v-model="showAddressModal"
v-bind="addressProps"
/>
<AboutModal v-model="showAboutModal" />
</template>
<script setup>
import CreateDocumentModal from '@/components/Modals/CreateDocumentModal.vue'
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'
import AboutModal from '@/components/Modals/AboutModal.vue'
import {
showCreateDocumentModal,
createDocumentDoctype,
createDocumentData,
createDocumentCallback,
} from '@/composables/document'
import {
showQuickEntryModal,
quickEntryProps,
showAddressModal,
addressProps,
showAboutModal
} from '@/composables/modals'
</script>

View File

@ -23,7 +23,7 @@
</div>
</div>
<div>
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="lead" />
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="lead.doc" />
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
</div>
</div>
@ -46,17 +46,21 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { sessionStore } from '@/stores/session'
import { isMobileView } from '@/composables/settings'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { capture } from '@/telemetry'
import { createResource } from 'frappe-ui'
import { useOnboarding } from 'frappe-ui/frappe'
import { computed, onMounted, ref, reactive, nextTick } from 'vue'
import { useDocument } from '@/data/document'
import { computed, onMounted, ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
defaults: Object,
})
const { user } = sessionStore()
const { getUser, isManager } = usersStore()
const { getLeadStatus, statusOptions } = statusesStore()
const { updateOnboardingStep } = useOnboarding('frappecrm')
@ -66,6 +70,16 @@ const router = useRouter()
const error = ref(null)
const isLeadCreating = ref(false)
const { document: lead } = useDocument('CRM Lead')
const leadStatuses = computed(() => {
let statuses = statusOptions('lead')
if (!lead.doc.status) {
lead.doc.status = statuses?.[0]?.value
}
return statuses
})
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['QuickEntry', 'CRM Lead'],
@ -79,11 +93,11 @@ const tabs = createResource({
if (field.fieldname == 'status') {
field.fieldtype = 'Select'
field.options = leadStatuses.value
field.prefix = getLeadStatus(lead.status).color
field.prefix = getLeadStatus(lead.doc.status).color
}
if (field.fieldtype === 'Table') {
lead[field.fieldname] = []
lead.doc[field.fieldname] = []
}
})
})
@ -92,23 +106,6 @@ const tabs = createResource({
},
})
const lead = reactive({
salutation: '',
first_name: '',
last_name: '',
email: '',
mobile_no: '',
gender: '',
organization: '',
website: '',
no_of_employees: '',
territory: '',
annual_revenue: '',
industry: '',
status: '',
lead_owner: '',
})
const createLead = createResource({
url: 'frappe.client.insert',
makeParams(values) {
@ -121,43 +118,38 @@ const createLead = createResource({
},
})
const leadStatuses = computed(() => {
let statuses = statusOptions('lead')
if (!lead.status) {
lead.status = statuses?.[0]?.value
}
return statuses
})
function createNewLead() {
if (lead.website && !lead.website.startsWith('http')) {
lead.website = 'https://' + lead.website
if (lead.doc.website && !lead.doc.website.startsWith('http')) {
lead.doc.website = 'https://' + lead.doc.website
}
createLead.submit(lead, {
createLead.submit(lead.doc, {
validate() {
error.value = null
if (!lead.first_name) {
if (!lead.doc.first_name) {
error.value = __('First Name is mandatory')
return error.value
}
if (lead.annual_revenue) {
if (typeof lead.annual_revenue === 'string') {
lead.annual_revenue = lead.annual_revenue.replace(/,/g, '')
} else if (isNaN(lead.annual_revenue)) {
if (lead.doc.annual_revenue) {
if (typeof lead.doc.annual_revenue === 'string') {
lead.doc.annual_revenue = lead.doc.annual_revenue.replace(/,/g, '')
} else if (isNaN(lead.doc.annual_revenue)) {
error.value = __('Annual Revenue should be a number')
return error.value
}
}
if (lead.mobile_no && isNaN(lead.mobile_no.replace(/[-+() ]/g, ''))) {
if (
lead.doc.mobile_no &&
isNaN(lead.doc.mobile_no.replace(/[-+() ]/g, ''))
) {
error.value = __('Mobile No should be a number')
return error.value
}
if (lead.email && !lead.email.includes('@')) {
if (lead.doc.email && !lead.doc.email.includes('@')) {
error.value = __('Invalid Email')
return error.value
}
if (!lead.status) {
if (!lead.doc.status) {
error.value = __('Status is required')
return error.value
}
@ -169,7 +161,7 @@ function createNewLead() {
show.value = false
router.push({ name: 'Lead', params: { leadId: data.name } })
updateOnboardingStep('create_first_lead', true, false, () => {
localStorage.setItem('firstLead', data.name)
localStorage.setItem('firstLead' + user, data.name)
})
},
onError(err) {
@ -183,22 +175,21 @@ function createNewLead() {
})
}
const showQuickEntryModal = defineModel('quickEntry')
function openQuickEntryModal() {
showQuickEntryModal.value = true
nextTick(() => {
show.value = false
})
quickEntryProps.value = { doctype: 'CRM Lead' }
nextTick(() => (show.value = false))
}
onMounted(() => {
Object.assign(lead, props.defaults)
if (!lead.lead_owner) {
lead.lead_owner = getUser().name
lead.doc = { no_of_employees: '1-10' }
Object.assign(lead.doc, props.defaults)
if (!lead.doc?.lead_owner) {
lead.doc.lead_owner = getUser().name
}
if (!lead.status && leadStatuses.value[0]?.value) {
lead.status = leadStatuses.value[0].value
if (!lead.doc?.status && leadStatuses.value[0]?.value) {
lead.doc.status = leadStatuses.value[0].value
}
})
</script>

View File

@ -1,34 +1,25 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
actions: [
{
label: editMode ? __('Update') : __('Create'),
variant: 'solid',
onClick: () => updateNote(),
},
],
}"
>
<Dialog v-model="show" :options="{
size: 'xl',
actions: [
{
label: editMode ? __('Update') : __('Create'),
variant: 'solid',
onClick: () => updateNote(),
},
],
}">
<template #body-title>
<div class="flex items-center gap-3">
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ editMode ? __('Edit Note') : __('Create Note') }}
</h3>
<Button
v-if="_note?.reference_docname"
size="sm"
:label="
_note.reference_doctype == 'CRM Deal'
? __('Open Deal')
: __('Open Lead')
"
@click="redirect()"
>
<Button v-if="_note?.reference_docname" size="sm" :label="_note.reference_doctype == 'CRM Deal'
? __('Open Deal')
: __('Open Lead')
" @click="redirect()">
<template #suffix>
<ArrowUpRightIcon class="h-4 w-4" />
<ArrowUpRightIcon class="w-4 h-4" />
</template>
</Button>
</div>
@ -36,27 +27,17 @@
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<FormControl
ref="title"
:label="__('Title')"
v-model="_note.title"
:placeholder="__('Call with John Doe')"
/>
<FormControl ref="title" :label="__('Title')" v-model="_note.title" :placeholder="__('Call with John Doe')"
required />
</div>
<div>
<div class="mb-1.5 text-xs text-ink-gray-5">{{ __('Content') }}</div>
<TextEditor
variant="outline"
ref="content"
<TextEditor variant="outline" ref="content"
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm 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"
:bubbleMenu="true"
:content="_note.content"
@change="(val) => (_note.content = val)"
:placeholder="
__('Took a call with John Doe and discussed the new project.')
"
/>
:bubbleMenu="true" :content="_note.content" @change="(val) => (_note.content = val)" :placeholder="__('Took a call with John Doe and discussed the new project.')
" />
</div>
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
</div>
</template>
</Dialog>
@ -94,17 +75,12 @@ const router = useRouter()
const { updateOnboardingStep } = useOnboarding('frappecrm')
const error = ref(null)
const title = ref(null)
const editMode = ref(false)
let _note = ref({})
async function updateNote() {
if (
props.note.title === _note.value.title &&
props.note.content === _note.value.content
)
return
if (_note.value.name) {
let d = await call('frappe.client.set_value', {
doctype: 'FCRM Note',
@ -124,6 +100,12 @@ async function updateNote() {
reference_doctype: props.doctype,
reference_docname: props.doc || '',
},
}, {
onError: (err) => {
if (err.error.exc_type == 'MandatoryError') {
error.value = "Title is mandatory"
}
}
})
if (d.name) {
updateOnboardingStep('create_first_note')

View File

@ -1,8 +1,8 @@
<template>
<Dialog v-model="show" :options="{ size: 'xl' }">
<template #body>
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-5 flex items-center justify-between">
<div class="px-4 pt-5 pb-6 bg-surface-modal sm:px-6">
<div class="flex items-center justify-between mb-5">
<div>
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('New Organization') }}
@ -15,21 +15,22 @@
class="w-7"
@click="openQuickEntryModal"
>
<EditIcon class="h-4 w-4" />
<EditIcon class="w-4 h-4" />
</Button>
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
<FeatherIcon name="x" class="w-4 h-4" />
</Button>
</div>
</div>
<FieldLayout
v-if="tabs.data?.length"
:tabs="tabs.data"
:data="_organization"
:data="organization.doc"
doctype="CRM Organization"
/>
<ErrorMessage class="mt-8" v-if="error" :message="__(error)" />
</div>
<div class="px-4 pb-7 pt-4 sm:px-6">
<div class="px-4 pt-4 pb-7 sm:px-6">
<div class="space-y-2">
<Button
class="w-full"
@ -49,12 +50,23 @@ import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import { usersStore } from '@/stores/users'
import { isMobileView } from '@/composables/settings'
import {
showQuickEntryModal,
quickEntryProps,
showAddressModal,
addressProps,
} from '@/composables/modals'
import { useDocument } from '@/data/document'
import { capture } from '@/telemetry'
import { call, FeatherIcon, createResource } from 'frappe-ui'
import { ref, nextTick, watch } from 'vue'
import { ref, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
data: {
type: Object,
default: () => ({}),
},
options: {
type: Object,
default: {
@ -64,34 +76,33 @@ const props = defineProps({
},
})
const emit = defineEmits(['openAddressModal'])
const { isManager } = usersStore()
const router = useRouter()
const show = defineModel()
const organization = defineModel('organization')
const loading = ref(false)
const title = ref(null)
const error = ref(null)
let _organization = ref({
organization_name: '',
website: '',
annual_revenue: '',
no_of_employees: '1-10',
industry: '',
})
let doc = ref({})
const { document: organization } = useDocument('CRM Organization')
async function createOrganization() {
const doc = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Organization',
..._organization.value,
const doc = await call(
'frappe.client.insert',
{
doc: {
doctype: 'CRM Organization',
...organization.doc,
},
},
})
{
onError: (err) => {
if (err.error.exc_type == 'ValidationError') {
error.value = err.error?.messages?.[0]
}
},
},
)
loading.value = false
if (doc.name) {
capture('organization_created')
@ -105,8 +116,6 @@ function handleOrganizationUpdate(doc) {
name: 'Organization',
params: { organizationId: doc.name },
})
} else {
organization.value?.reload?.()
}
show.value = false
props.options.afterInsert && props.options.afterInsert(doc)
@ -124,17 +133,13 @@ const tabs = createResource({
column.fields.forEach((field) => {
if (field.fieldname == 'address') {
field.create = (value, close) => {
_organization.value.address = value
emit('openAddressModal')
show.value = false
organization.doc.address = value
openAddressModal()
close()
}
field.edit = (address) => {
emit('openAddressModal', address)
show.value = false
}
field.edit = (address) => openAddressModal(address)
} else if (field.fieldtype === 'Table') {
_organization.value[field.fieldname] = []
organization.doc[field.fieldname] = []
}
})
})
@ -143,23 +148,23 @@ const tabs = createResource({
},
})
watch(
() => show.value,
(value) => {
if (!value) return
nextTick(() => {
// TODO: Issue with FormControl
// title.value.el.focus()
doc.value = organization.value?.doc || organization.value || {}
_organization.value = { ...doc.value }
})
},
)
const showQuickEntryModal = defineModel('showQuickEntryModal')
onMounted(() => {
organization.doc = { no_of_employees: '1-10' }
Object.assign(organization.doc, props.data)
})
function openQuickEntryModal() {
showQuickEntryModal.value = true
quickEntryProps.value = { doctype: 'CRM Organization' }
nextTick(() => (show.value = false))
}
function openAddressModal(_address) {
showAddressModal.value = true
addressProps.value = {
doctype: 'Address',
address: _address,
}
nextTick(() => (show.value = false))
}
</script>

View File

@ -38,9 +38,9 @@
/>
<div v-if="preview" class="flex flex-1 flex-col border rounded">
<SidePanelLayout
v-model="data"
:sections="tabs.data[0].sections"
:doctype="_doctype"
docname=""
:preview="true"
v-slot="{ section }"
>

View File

@ -1,34 +1,25 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
actions: [
{
label: editMode ? __('Update') : __('Create'),
variant: 'solid',
onClick: () => updateTask(),
},
],
}"
>
<Dialog v-model="show" :options="{
size: 'xl',
actions: [
{
label: editMode ? __('Update') : __('Create'),
variant: 'solid',
onClick: () => updateTask(),
},
],
}">
<template #body-title>
<div class="flex items-center gap-3">
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ editMode ? __('Edit Task') : __('Create Task') }}
</h3>
<Button
v-if="task?.reference_docname"
size="sm"
:label="
task.reference_doctype == 'CRM Deal'
? __('Open Deal')
: __('Open Lead')
"
@click="redirect()"
>
<Button v-if="task?.reference_docname" size="sm" :label="task.reference_doctype == 'CRM Deal'
? __('Open Deal')
: __('Open Lead')
" @click="redirect()">
<template #suffix>
<ArrowUpRightIcon class="h-4 w-4" />
<ArrowUpRightIcon class="w-4 h-4" />
</template>
</Button>
</div>
@ -36,74 +27,53 @@
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<FormControl
ref="title"
:label="__('Title')"
v-model="_task.title"
:placeholder="__('Call with John Doe')"
/>
<FormControl ref="title" :label="__('Title')" v-model="_task.title" :placeholder="__('Call with John Doe')"
required />
</div>
<div>
<div class="mb-1.5 text-xs text-ink-gray-5">
{{ __('Description') }}
</div>
<TextEditor
variant="outline"
ref="description"
<TextEditor variant="outline" ref="description"
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm 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"
:bubbleMenu="true"
:content="_task.description"
@change="(val) => (_task.description = val)"
:placeholder="
__('Took a call with John Doe and discussed the new project.')
"
/>
:bubbleMenu="true" :content="_task.description" @change="(val) => (_task.description = val)" :placeholder="__('Took a call with John Doe and discussed the new project.')
" />
</div>
<div class="flex flex-wrap items-center gap-2">
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
<Button :label="_task.status" class="w-full justify-between">
<Button :label="_task.status" class="justify-between w-full">
<template #prefix>
<TaskStatusIcon :status="_task.status" />
</template>
</Button>
</Dropdown>
<Link
class="form-control"
:value="getUser(_task.assigned_to).full_name"
doctype="User"
@change="(option) => (_task.assigned_to = option)"
:placeholder="__('John Doe')"
:hideMe="true"
>
<template #prefix>
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer text-ink-gray-9">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
<Link class="form-control" :value="getUser(_task.assigned_to).full_name" doctype="User"
@change="(option) => (_task.assigned_to = option)" :placeholder="__('John Doe')" :hideMe="true">
<template #prefix>
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer text-ink-gray-9">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
<DateTimePicker
class="datepicker w-36"
v-model="_task.due_date"
:placeholder="__('01/04/2024 11:30 PM')"
:formatter="(date) => getFormat(date, '', true, true)"
input-class="border-none"
/>
<DateTimePicker class="datepicker w-36" v-model="_task.due_date" :placeholder="__('01/04/2024 11:30 PM')"
:formatter="(date) => getFormat(date, '', true, true)" input-class="border-none" />
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
<Button :label="_task.priority" class="w-full justify-between">
<Button :label="_task.priority" class="justify-between w-full">
<template #prefix>
<TaskPriorityIcon :priority="_task.priority" />
</template>
</Button>
</Dropdown>
</div>
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
</div>
</template>
</Dialog>
@ -147,6 +117,7 @@ const router = useRouter()
const { getUser } = usersStore()
const { updateOnboardingStep } = useOnboarding('frappecrm')
const error = ref(null)
const title = ref(null)
const editMode = ref(false)
const _task = ref({
@ -200,6 +171,12 @@ async function updateTask() {
reference_docname: props.doc || null,
..._task.value,
},
}, {
onError: (err) => {
if (err.error.exc_type == 'MandatoryError') {
error.value = "Title is mandatory"
}
}
})
if (d.name) {
updateOnboardingStep('create_first_task')

View File

@ -0,0 +1,71 @@
<template>
<div class="flex items-center">
<Button
:variant="$attrs.variant"
class="border-0"
:label="activeButton.label"
:size="$attrs.size"
:class="[
$attrs.class,
showDropdown ? 'rounded-br-none rounded-tr-none' : '',
]"
@click="() => activeButton.onClick()"
>
<template #prefix>
<FeatherIcon
v-if="activeButton.icon && typeof activeButton.icon === 'string'"
:name="activeButton.icon"
class="h-4 w-4"
/>
<component
v-else-if="activeButton.icon"
:is="activeButton.icon"
class="h-4 w-4"
/>
</template>
</Button>
<Dropdown
v-show="showDropdown"
:options="parsedOptions"
size="sm"
class="flex-1 [&>div>div>div]:w-full"
placement="right"
>
<template v-slot="{ togglePopover }">
<Button
:variant="$attrs.variant"
@click="togglePopover"
icon="chevron-down"
class="!w-6 justify-start rounded-bl-none rounded-tl-none border-0 pr-0 text-xs"
/>
</template>
</Dropdown>
</div>
</template>
<script setup>
import { Dropdown } from 'frappe-ui'
import { computed, ref } from 'vue'
const props = defineProps({
options: {
type: Array,
default: () => [],
},
})
const showDropdown = ref(props.options?.length > 1)
const activeButton = ref(props.options?.[0] || {})
const parsedOptions = computed(() => {
return (
props.options?.map((option) => {
return {
label: option.label,
onClick: () => {
activeButton.value = option
},
}
}) || []
)
})
</script>

View File

@ -0,0 +1,50 @@
<template>
<div
class="flex items-center justify-between p-1 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer"
>
<!-- avatar and name -->
<div class="flex items-center justify-between gap-2">
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
<div>
<p class="text-sm font-semibold text-ink-gray-9">
{{ emailAccount.email_account_name }}
</p>
<div class="text-sm text-gray-500">{{ emailAccount.email_id }}</div>
</div>
</div>
<div>
<Badge variant="subtle" :label="badgeTitle" :theme="gray" />
</div>
<!-- email id -->
</div>
</template>
<script setup>
import { emailIcon } from './emailConfig'
import EmailProviderIcon from './EmailProviderIcon.vue'
import { computed } from 'vue'
const props = defineProps({
emailAccount: {
type: Object,
required: true,
},
})
const badgeTitle = computed(() => {
if (
props.emailAccount.default_incoming &&
props.emailAccount.default_outgoing
) {
return __('Default Sending and Inbox')
} else if (props.emailAccount.default_incoming) {
return __('Default Inbox')
} else if (props.emailAccount.default_outgoing) {
return __('Default Sending')
} else {
return __('Inbox')
}
})
</script>
<style scoped></style>

View File

@ -0,0 +1,64 @@
<template>
<div>
<!-- header -->
<div class="flex items-center justify-between text-ink-gray-9">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Email Accounts') }}
</h2>
<Button
:label="__('Add Account')"
theme="gray"
variant="solid"
@click="emit('update:step', 'email-add')"
class="mr-8"
>
<template #prefix>
<LucidePlus class="w-4 h-4" />
</template>
</Button>
</div>
<!-- list accounts -->
<div
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
class="mt-4"
>
<div v-for="emailAccount in emailAccounts.data" :key="emailAccount.name">
<EmailAccountCard
:emailAccount="emailAccount"
@click="emit('update:step', 'email-edit', emailAccount)"
/>
</div>
</div>
<!-- fallback if no email accounts -->
<div v-else class="flex items-center justify-center h-64 text-gray-500">
{{ __('Please add an email account to continue.') }}
</div>
</div>
</template>
<script setup>
import { createListResource } from 'frappe-ui'
import EmailAccountCard from './EmailAccountCard.vue'
const emit = defineEmits(['update:step'])
const emailAccounts = createListResource({
doctype: 'Email Account',
cache: true,
fields: ['*'],
filters: {
email_id: ['Not Like', '%example%'],
},
pageLength: 10,
auto: true,
onSuccess: (accounts) => {
// convert 0 to false to handle boolean fields
accounts.forEach((account) => {
account.enable_incoming = Boolean(account.enable_incoming)
account.enable_outgoing = Boolean(account.enable_outgoing)
account.default_incoming = Boolean(account.default_incoming)
account.default_outgoing = Boolean(account.default_outgoing)
})
},
})
</script>

View File

@ -0,0 +1,158 @@
<template>
<div class="flex flex-col h-full gap-4">
<!-- title and desc -->
<div role="heading" aria-level="1" class="flex flex-col gap-1">
<h2 class="text-xl font-semibold text-ink-gray-9">
{{ __('Setup Email') }}
</h2>
<p class="text-sm text-gray-600">
{{ __('Choose the email service provider you want to configure.') }}
</p>
</div>
<!-- email service provider selection -->
<div class="flex flex-wrap items-center">
<div
v-for="s in services"
:key="s.name"
class="flex flex-col items-center gap-1 mt-4 w-[70px]"
@click="handleSelect(s)"
>
<EmailProviderIcon
:service-name="s.name"
:logo="s.icon"
:selected="selectedService?.name === s?.name"
/>
</div>
</div>
<div v-if="selectedService" class="flex flex-col gap-4">
<!-- email service provider info -->
<div
class="flex items-center gap-2 p-2 rounded-md ring-1 ring-gray-400 dark:ring-gray-700 text-gray-700 dark:text-gray-500"
>
<CircleAlert class="w-5 h-6 w-min-5 w-max-5 min-h-5 max-w-5" />
<div class="text-xs text-wrap">
{{ selectedService.info }}
<a :href="selectedService.link" target="_blank" class="underline"
>here</a
>.
</div>
</div>
<!-- service provider fields -->
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4">
<div
v-for="field in fields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div
v-for="field in incomingOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
/>
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
</div>
</div>
<ErrorMessage v-if="error" class="ml-1" :message="error" />
</div>
</div>
<!-- action button -->
<div v-if="selectedService" class="flex justify-between mt-auto">
<Button
label="Back"
theme="gray"
variant="outline"
:disabled="addEmailRes.loading"
@click="emit('update:step', 'email-list')"
/>
<Button
label="Create"
variant="solid"
:loading="addEmailRes.loading"
@click="createEmailAccount"
/>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { createResource, toast } from 'frappe-ui'
import CircleAlert from '~icons/lucide/circle-alert'
import {
customProviderFields,
popularProviderFields,
services,
validateInputs,
incomingOutgoingFields,
} from './emailConfig'
import EmailProviderIcon from './EmailProviderIcon.vue'
const emit = defineEmits()
const state = reactive({
service: '',
email_account_name: '',
email_id: '',
password: '',
api_key: '',
api_secret: '',
frappe_mail_site: '',
enable_incoming: false,
enable_outgoing: false,
default_incoming: false,
default_outgoing: false,
})
const selectedService = ref(null)
const fields = computed(() =>
selectedService.value.custom ? customProviderFields : popularProviderFields,
)
function handleSelect(service) {
selectedService.value = service
state.service = service.name
}
const addEmailRes = createResource({
url: 'crm.api.settings.create_email_account',
makeParams: (val) => {
return {
...val,
}
},
onSuccess: () => {
toast.success(__('Email account created successfully'))
emit('update:step', 'email-list')
},
onError: () => {
error.value = __('Failed to create email account, Invalid credentials')
},
})
const error = ref()
function createEmailAccount() {
error.value = validateInputs(state, selectedService.value.custom)
if (error.value) return
addEmailRes.submit({ data: state })
}
</script>
<style scoped></style>

View File

@ -0,0 +1,27 @@
<template>
<div class="flex-1 p-8">
<div v-if="step === 'email-add'" class="h-full">
<EmailAdd @update:step="updateStep" />
</div>
<div v-else-if="step === 'email-list'" class="h-full">
<EmailAccountList @update:step="updateStep" />
</div>
<div v-else-if="step === 'email-edit'" class="h-full">
<EmailEdit :account-data="accountData" @update:step="updateStep" />
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import EmailAdd from "./EmailAdd.vue";
import EmailAccountList from "./EmailAccountList.vue";
import EmailEdit from "./EmailEdit.vue";
const step = ref("email-list");
const accountData = ref(null);
function updateStep(newStep, data) {
step.value = newStep;
accountData.value = data;
}
</script>

View File

@ -0,0 +1,215 @@
<template>
<div class="flex flex-col h-full gap-4">
<!-- title and desc -->
<div role="heading" aria-level="1" class="flex justify-between gap-1">
<h2 class="text-xl font-semibold text-ink-gray-9">
{{ __('Edit Email') }}
</h2>
</div>
<div class="w-fit">
<EmailProviderIcon
:logo="emailIcon[accountData.service]"
:service-name="accountData.service"
/>
</div>
<!-- banner for setting up email account -->
<div
class="flex items-center gap-2 p-2 rounded-md ring-1 ring-gray-400 dark:ring-gray-700"
>
<CircleAlert
class="size-6 text-gray-500 w-min-5 w-max-5 min-h-5 max-w-5"
/>
<div class="text-xs text-gray-700 dark:text-gray-500 text-wrap">
{{ info.description }}
<a :href="info.link" target="_blank" class="underline">{{
__('here')
}}</a>
.
</div>
</div>
<!-- fields -->
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4">
<div
v-for="field in fields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div
v-for="field in incomingOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
/>
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
</div>
</div>
<ErrorMessage v-if="error" class="ml-1" :message="error" />
</div>
<!-- action buttons -->
<div class="flex justify-between mt-auto">
<Button
:label="__('Back')"
theme="gray"
variant="outline"
:disabled="loading"
@click="emit('update:step', 'email-list')"
/>
<Button
:label="__('Update Account')"
variant="solid"
@click="updateAccount"
:loading="loading"
/>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { call, toast } from 'frappe-ui'
import EmailProviderIcon from './EmailProviderIcon.vue'
import {
emailIcon,
services,
popularProviderFields,
customProviderFields,
validateInputs,
incomingOutgoingFields,
} from './emailConfig'
import CircleAlert from '~icons/lucide/circle-alert'
const props = defineProps({
accountData: null,
})
const emit = defineEmits()
const state = reactive({
email_account_name: props.accountData.email_account_name || '',
service: props.accountData.service || '',
email_id: props.accountData.email_id || '',
api_key: props.accountData?.api_key || null,
api_secret: props.accountData?.api_secret || null,
password: props.accountData?.password || null,
frappe_mail_site: props.accountData?.frappe_mail_site || '',
enable_incoming: props.accountData.enable_incoming || false,
enable_outgoing: props.accountData.enable_outgoing || false,
default_outgoing: props.accountData.default_outgoing || false,
default_incoming: props.accountData.default_incoming || false,
})
const info = {
description: __('To know more about setting up email accounts, click'),
link: 'https://docs.erpnext.com/docs/user/manual/en/email-account',
}
const isCustomService = computed(() => {
return services.find((s) => s.name === props.accountData.service).custom
})
const fields = computed(() => {
if (isCustomService.value) {
return customProviderFields
}
return popularProviderFields
})
const error = ref()
const loading = ref(false)
async function updateAccount() {
error.value = validateInputs(state, isCustomService.value)
if (error.value) return
const old = { ...props.accountData }
const updatedEmailAccount = { ...state }
const nameChanged =
old.email_account_name !== updatedEmailAccount.email_account_name
delete old.email_account_name
delete updatedEmailAccount.email_account_name
const otherFieldsChanged = isDirty.value
const values = updatedEmailAccount
if (!nameChanged && !otherFieldsChanged) {
toast.info(__('No changes made'))
return
}
if (nameChanged) {
try {
loading.value = true
await callRenameDoc()
succesHandler()
} catch (err) {
errorHandler()
}
}
if (otherFieldsChanged) {
try {
loading.value = true
await callSetValue(values)
succesHandler()
} catch (err) {
errorHandler()
}
}
}
const isDirty = computed(() => {
return (
state.email_id !== props.accountData.email_id ||
state.api_key !== props.accountData.api_key ||
state.api_secret !== props.accountData.api_secret ||
state.password !== props.accountData.password ||
state.enable_incoming !== props.accountData.enable_incoming ||
state.enable_outgoing !== props.accountData.enable_outgoing ||
state.default_outgoing !== props.accountData.default_outgoing ||
state.default_incoming !== props.accountData.default_incoming ||
state.frappe_mail_site !== props.accountData.frappe_mail_site
)
})
async function callRenameDoc() {
const d = await call('frappe.client.rename_doc', {
doctype: 'Email Account',
old_name: props.accountData.email_account_name,
new_name: state.email_account_name,
})
return d
}
async function callSetValue(values) {
const d = await call('frappe.client.set_value', {
doctype: 'Email Account',
name: state.email_account_name,
fieldname: values,
})
return d.name
}
function succesHandler() {
emit('update:step', 'email-list')
toast.success(__('Email account updated successfully'))
}
function errorHandler() {
loading.value = false
error.value = __('Failed to update email account, Invalid credentials')
}
</script>

View File

@ -0,0 +1,33 @@
<template>
<div
class="flex items-center justify-center w-8 h-8 bg-gray-100 cursor-pointer rounded-xl hover:bg-gray-200"
:class="{ 'ring-2 ring-gray-500 dark:ring-gray-100': selected }"
>
<img :src="logo" class="w-4 h-4" />
</div>
<p
v-if="serviceName"
class="text-xs text-center text-gray-700 dark:text-gray-500 mt-2"
>
{{ serviceName }}
</p>
</template>
<script setup>
defineProps({
logo: {
type: String,
required: true,
},
serviceName: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
})
</script>
<style scoped></style>

View File

@ -101,6 +101,7 @@
v-model="settings.doc.dropdown_items"
doctype="CRM Dropdown Item"
parentDoctype="FCRM Settings"
parentFieldname="dropdown_items"
/>
</div>
</div>

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