Compare commits

...

183 Commits

Author SHA1 Message Date
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
111 changed files with 3959 additions and 775 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

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

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

@ -418,16 +418,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 +444,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,

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

@ -6,7 +6,10 @@ 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")

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

@ -6,7 +6,10 @@ 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")

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

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

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

@ -1 +1 @@
Subproject commit 3423aa5b5c38d3a1b143ae8ab08cbde7360f9a7c
Subproject commit 29307e4fffaacdbb3d9c5d95c5270b2f245a5607

View File

@ -53,6 +53,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 +79,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 +114,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']
@ -149,6 +159,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']

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

@ -373,11 +373,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 +466,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 +484,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'
@ -785,5 +782,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,16 @@
:doc="doc.data?.name"
@after="redirect('notes')"
/>
<CallLogModal
v-model="showCallLogModal"
v-model:callLog="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 +83,22 @@ 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 = {
data: {
reference_doctype: doctype,
reference_docname: docname,
},
}
showCallLogModal.value = true
}
// common
const route = useRoute()
const router = useRouter()
@ -95,5 +117,6 @@ defineExpose({
deleteTask,
updateTaskStatus,
showNote,
createCallLog,
})
</script>

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: {
@ -76,33 +76,11 @@ const props = defineProps({
required: true,
},
})
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 +90,22 @@ const tabs = createResource({
})
function saveChanges() {
data.save.submit()
document.save.submit()
}
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

@ -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,48 @@
variant="outline"
v-model="row[field.fieldname]"
:options="field.options"
@change="(e) => fieldChange(e.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 +264,7 @@
variant="outline"
v-model="row[field.fieldname]"
:options="field.options"
@change="fieldChange($event.target.value, field, row)"
/>
</div>
</div>
@ -252,6 +325,7 @@
</template>
<script setup>
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 +333,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 +344,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 +362,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 +410,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 +470,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

@ -60,6 +60,8 @@ const props = defineProps({
},
})
const emit = defineEmits(['change'])
const { getFields } = getMeta(props.doctype)
const values = defineModel()
@ -109,14 +111,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 +129,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,95 @@
</Link>
<DateTimePicker
v-else-if="field.fieldtype === 'Datetime'"
v-model="data[field.fieldname]"
:value="data[field.fieldname]"
icon-left=""
: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
<FormattedInput
v-else-if="['Int'].includes(field.fieldtype)"
type="number"
:placeholder="getPlaceholder(field)"
v-model="data[field.fieldname]"
:value="data[field.fieldname]"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<FormControl
<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="data[field.fieldname]"
:disabled="Boolean(field.read_only)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
</div>
</template>
<script setup>
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 +232,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 +274,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 +324,16 @@ 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)
}
}
</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

@ -168,6 +168,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 +301,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')
}
@ -337,12 +341,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 +416,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 +437,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 +458,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 +487,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 +537,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

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

@ -1,55 +1,36 @@
<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 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">
{{ __(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 v-if="isManager() && !isMobileView" variant="ghost" class="w-7" @click="openQuickEntryModal">
<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"
doctype="CRM Call Log"
/>
<ErrorMessage class="mt-2" :message="error" />
<FieldLayout :tabs="tabs.data" :data="_callLog" doctype="CRM Call Log" />
<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"
v-for="action in dialogOptions.actions"
:key="action.label"
v-bind="action"
:label="__(action.label)"
:loading="loading"
/>
<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>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
doctype="CRM Call Log"
/>
<QuickEntryModal v-if="showQuickEntryModal" v-model="showQuickEntryModal" doctype="CRM Call Log" />
</template>
<script setup>
@ -67,7 +48,7 @@ const props = defineProps({
options: {
type: Object,
default: {
afterInsert: () => {},
afterInsert: () => { },
},
},
})
@ -175,6 +156,13 @@ const createCallLog = createResource({
},
onError(err) {
loading.value = false
if (err.exc_type == 'MandatoryError') {
const errorMessage = err.messages
.map(msg => msg.split('Log:')[1].trim())
.join(', ')
error.value = `These fields are required: ${errorMessage}`
return
}
error.value = err
},
})

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 { 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(['showQuickEntryModal', '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() {
emit('showQuickEntryModal', props.doctype)
nextTick(() => {
show.value = false
})
}
</script>

View File

@ -0,0 +1,34 @@
<template>
<CreateDocumentModal
v-if="showCreateDocumentModal"
v-model="showCreateDocumentModal"
:doctype="createDocumentDoctype"
:data="createDocumentData"
@showQuickEntryModal="(dt) => openQuickEntryModal(dt)"
@callback="(data) => createDocumentCallback(data)"
/>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
:doctype="quickEntryDoctype"
/>
</template>
<script setup>
import CreateDocumentModal from '@/components/Modals/CreateDocumentModal.vue'
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import {
showCreateDocumentModal,
createDocumentDoctype,
createDocumentData,
createDocumentCallback,
} from '@/composables/document'
import { ref } from 'vue'
const showQuickEntryModal = ref(false)
const quickEntryDoctype = ref('')
function openQuickEntryModal(dt) {
showQuickEntryModal.value = true
quickEntryDoctype.value = dt
}
</script>

View File

@ -46,6 +46,7 @@ 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 { capture } from '@/telemetry'
import { createResource } from 'frappe-ui'
@ -57,6 +58,7 @@ const props = defineProps({
defaults: Object,
})
const { user } = sessionStore()
const { getUser, isManager } = usersStore()
const { getLeadStatus, statusOptions } = statusesStore()
const { updateOnboardingStep } = useOnboarding('frappecrm')
@ -169,7 +171,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) {

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,43 +1,28 @@
<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') }}
</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 v-if="isManager() && !isMobileView" variant="ghost" class="w-7" @click="openQuickEntryModal">
<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"
doctype="CRM Organization"
/>
<FieldLayout v-if="tabs.data?.length" :tabs="tabs.data" :data="_organization" 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"
variant="solid"
:label="__('Create')"
:loading="loading"
@click="createOrganization"
/>
<Button class="w-full" variant="solid" :label="__('Create')" :loading="loading" @click="createOrganization" />
</div>
</div>
</template>
@ -59,7 +44,7 @@ const props = defineProps({
type: Object,
default: {
redirect: true,
afterInsert: () => {},
afterInsert: () => { },
},
},
})
@ -84,6 +69,7 @@ let _organization = ref({
})
let doc = ref({})
const error = ref(null)
async function createOrganization() {
const doc = await call('frappe.client.insert', {
@ -91,6 +77,12 @@ async function createOrganization() {
doctype: 'CRM Organization',
..._organization.value,
},
}, {
onError: (err) => {
if (err.error.exc_type == 'ValidationError') {
error.value = err.error?.messages?.[0]
}
}
})
loading.value = false
if (doc.name) {

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,163 @@
<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 } from 'frappe-ui'
import CircleAlert from '~icons/lucide/circle-alert'
import { createToast } from '@/utils'
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: () => {
createToast({
title: __('Email account created successfully'),
icon: 'check',
iconClasses: 'text-green-600',
})
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,224 @@
<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 } from 'frappe-ui'
import EmailProviderIcon from './EmailProviderIcon.vue'
import {
emailIcon,
services,
popularProviderFields,
customProviderFields,
validateInputs,
incomingOutgoingFields,
} from './emailConfig'
import { createToast } from '@/utils'
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) {
createToast({
title: __('No changes made'),
icon: 'info',
iconClasses: 'text-blue-600',
})
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')
createToast({
title: __('Email account updated successfully'),
icon: 'check',
iconClasses: 'text-green-600',
})
}
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>

View File

@ -6,8 +6,8 @@
>
<template #body>
<div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9">
<div class="flex flex-col p-2 w-52 shrink-0 bg-surface-gray-2">
<h1 class="px-2 pt-2 mb-3 text-lg font-semibold text-ink-gray-9">
{{ __('Settings') }}
</h1>
<div v-for="tab in tabs">
@ -34,7 +34,7 @@
</div>
</div>
<div
class="flex relative flex-1 flex-col overflow-y-auto bg-surface-modal"
class="relative flex flex-col flex-1 overflow-y-auto bg-surface-modal"
>
<Button
class="absolute right-5 top-5"
@ -53,12 +53,14 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import InviteIcon from '@/components/Icons/InviteIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import GeneralSettings from '@/components/Settings/GeneralSettings.vue'
import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
import EmailConfig from '@/components/Settings/EmailConfig.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { usersStore } from '@/stores/users'
import {
@ -101,6 +103,12 @@ const tabs = computed(() => {
component: markRaw(InviteMemberPage),
condition: () => isManager(),
},
{
label: __('Email Accounts'),
icon: Email2Icon,
component: markRaw(EmailConfig),
condition: () => isManager(),
},
],
},
{

View File

@ -0,0 +1,186 @@
import { validateEmail } from '../../utils'
import LogoGmail from '@/images/gmail.png'
import LogoOutlook from '@/images/outlook.png'
import LogoSendgrid from '@/images/sendgrid.png'
import LogoSparkpost from '@/images/sparkpost.webp'
import LogoYahoo from '@/images/yahoo.png'
import LogoYandex from '@/images/yandex.png'
import LogoFrappeMail from '@/images/frappe-mail.svg'
const fixedFields = [
{
label: __('Account Name'),
name: 'email_account_name',
type: 'text',
placeholder: __('Support / Sales'),
},
{
label: 'Email ID',
name: 'email_id',
type: 'email',
placeholder: 'johndoe@example.com',
},
]
export const incomingOutgoingFields = [
{
label: __('Enable Incoming'),
name: 'enable_incoming',
type: 'checkbox',
description: __(
'If enabled, records can be created from the incoming emails on this account.',
),
},
{
label: __('Enable Outgoing'),
name: 'enable_outgoing',
type: 'checkbox',
description: __(
'If enabled, outgoing emails can be sent from this account.',
),
},
{
label: __('Default Incoming'),
name: 'default_incoming',
type: 'checkbox',
description: __(
'If enabled, all replies to your company (eg: replies@yourcomany.com) will come to this account. Note: Only one account can be default incoming.',
),
},
{
label: __('Default Outgoing'),
name: 'default_outgoing',
type: 'checkbox',
description: __(
'If enabled, all outgoing emails will be sent from this account. Note: Only one account can be default outgoing.',
),
},
]
export const popularProviderFields = [
...fixedFields,
{
label: __('Password'),
name: 'password',
type: 'password',
placeholder: '********',
},
]
export const customProviderFields = [
...fixedFields,
{
label: 'Frappe Mail Site',
name: 'frappe_mail_site',
type: 'text',
placeholder: 'https://frappemail.com',
},
{
label: 'API Key',
name: 'api_key',
type: 'text',
placeholder: '********',
},
{
label: 'API Secret',
name: 'api_secret',
type: 'password',
placeholder: '********',
},
]
export const services = [
{
name: 'GMail',
icon: LogoGmail,
info: __(`Setting up GMail requires you to enable two factor authentication
and app specific passwords. Read more`),
link: 'https://support.google.com/accounts/answer/185833',
custom: false,
},
{
name: 'Outlook',
icon: LogoOutlook,
info: __(`Setting up Outlook requires you to enable two factor authentication
and app specific passwords. Read more`),
link: 'https://support.microsoft.com/en-us/account-billing/how-to-get-and-use-app-passwords-5896ed9b-4263-e681-128a-a6f2979a7944',
custom: false,
},
{
name: 'Sendgrid',
icon: LogoSendgrid,
info: __(`Setting up Sendgrid requires you to enable two factor authentication
and app specific passwords. Read more `),
link: 'https://sendgrid.com/docs/ui/account-and-settings/two-factor-authentication/',
custom: false,
},
{
name: 'SparkPost',
icon: LogoSparkpost,
info: __(`Setting up SparkPost requires you to enable two factor authentication
and app specific passwords. Read more `),
link: 'https://support.sparkpost.com/docs/my-account-and-profile/enabling-two-factor-authentication',
custom: false,
},
{
name: 'Yahoo',
icon: LogoYahoo,
info: __(`Setting up Yahoo requires you to enable two factor authentication
and app specific passwords. Read more `),
link: 'https://help.yahoo.com/kb/SLN15241.html',
custom: false,
},
{
name: 'Yandex',
icon: LogoYandex,
info: __(`Setting up Yandex requires you to enable two factor authentication
and app specific passwords. Read more `),
link: 'https://yandex.com/support/id/authorization/app-passwords.html',
custom: false,
},
{
name: 'Frappe Mail',
icon: LogoFrappeMail,
info: __(
`Setting up Frappe Mail requires you to have an API key and API Secret of your email account. Read more `,
),
link: 'https://github.com/frappe/mail',
custom: true,
},
]
export const emailIcon = {
GMail: LogoGmail,
Outlook: LogoOutlook,
Sendgrid: LogoSendgrid,
SparkPost: LogoSparkpost,
Yahoo: LogoYahoo,
Yandex: LogoYandex,
'Frappe Mail': LogoFrappeMail,
}
export function validateInputs(state, isCustom) {
if (!state.email_account_name) {
return __('Account name is required')
}
if (!state.email_id) {
return __('Email ID is required')
}
const validEmail = validateEmail(state.email_id)
if (!validEmail) {
return __('Invalid email ID')
}
if (!isCustom && !state.password) {
return __('Password is required')
}
if (isCustom) {
if (!state.api_key) {
return __('API Key is required')
}
if (!state.api_secret) {
return
}
}
return ''
}

View File

@ -1,5 +1,8 @@
<template>
<div class="sections flex flex-col overflow-y-auto">
<div
v-if="!document.get?.loading"
class="sections flex flex-col overflow-y-auto"
>
<template v-for="(section, i) in _sections" :key="section.name">
<div v-if="section.visible" class="section flex flex-col">
<div
@ -50,7 +53,7 @@
(field.mandatory_depends_on &&
field.mandatory_via_depends_on)
"
class="text-ink-red-3"
class="text-ink-red-2"
>*</span
>
</div>
@ -62,26 +65,33 @@
<div
v-if="
field.read_only &&
!['Check', 'Dropdown'].includes(field.fieldtype)
![
'Int',
'Float',
'Currency',
'Percent',
'Check',
'Dropdown',
].includes(field.fieldtype)
"
class="flex h-7 cursor-pointer items-center px-2 py-1 text-ink-gray-5"
>
<Tooltip :text="__(field.tooltip)">
<div>{{ data[field.fieldname] }}</div>
<div>{{ document.doc[field.fieldname] }}</div>
</Tooltip>
</div>
<div v-else-if="field.fieldtype === 'Dropdown'">
<NestedPopover>
<template #target="{ open }">
<Button
:label="data[field.fieldname]"
:label="document.doc[field.fieldname]"
class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-surface-gray-2 px-2 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3"
>
<div
v-if="data[field.fieldname]"
v-if="document.doc[field.fieldname]"
class="truncate"
>
{{ data[field.fieldname] }}
{{ document.doc[field.fieldname] }}
</div>
<div
v-else
@ -138,13 +148,9 @@
v-else-if="field.fieldtype == 'Check'"
class="form-control"
type="checkbox"
v-model="data[field.fieldname]"
v-model="document.doc[field.fieldname]"
@change.stop="
emit(
'update',
field.fieldname,
$event.target.checked,
)
fieldChange($event.target.checked, field)
"
:disabled="Boolean(field.read_only)"
/>
@ -159,43 +165,40 @@
"
class="form-control"
type="textarea"
:value="data[field.fieldname]"
:value="document.doc[field.fieldname]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="
emit('update', field.fieldname, $event.target.value)
"
@change.stop="fieldChange($event.target.value, field)"
/>
<FormControl
v-else-if="field.fieldtype === 'Select'"
class="form-control cursor-pointer [&_select]:cursor-pointer truncate"
type="select"
v-model="data[field.fieldname]"
v-model="document.doc[field.fieldname]"
:options="field.options"
:placeholder="field.placeholder"
@change.stop="
emit('update', field.fieldname, $event.target.value)
"
@change.stop="fieldChange($event.target.value, field)"
/>
<Link
v-else-if="field.fieldtype === 'User'"
class="form-control"
:value="
data[field.fieldname] &&
getUser(data[field.fieldname]).full_name
document.doc[field.fieldname] &&
getUser(document.doc[field.fieldname]).full_name
"
doctype="User"
:filters="field.filters"
@change="
(data) => emit('update', field.fieldname, data)
"
@change="(v) => fieldChange(v, field)"
:placeholder="'Select' + ' ' + field.label + '...'"
:hideMe="true"
>
<template v-if="data[field.fieldname]" #prefix>
<template
v-if="document.doc[field.fieldname]"
#prefix
>
<UserAvatar
class="mr-1.5"
:user="data[field.fieldname]"
:user="document.doc[field.fieldname]"
size="sm"
/>
</template>
@ -215,15 +218,19 @@
</template>
</Link>
<Link
v-else-if="field.fieldtype === 'Link'"
v-else-if="
['Link', 'Dynamic Link'].includes(field.fieldtype)
"
class="form-control select-text"
:value="data[field.fieldname]"
:doctype="field.options"
:value="document.doc[field.fieldname]"
:doctype="
field.fieldtype == 'Link'
? field.options
: document.doc[field.options]
"
:filters="field.filters"
:placeholder="field.placeholder"
@change="
(data) => emit('update', field.fieldname, data)
"
@change="(v) => fieldChange(v, field)"
:onCreate="field.create"
/>
<div
@ -232,15 +239,13 @@
>
<DateTimePicker
icon-left=""
:value="data[field.fieldname]"
:value="document.doc[field.fieldname]"
:formatter="
(date) => getFormat(date, '', true, true)
"
:placeholder="field.placeholder"
placement="left-start"
@change="
(data) => emit('update', field.fieldname, data)
"
@change="(v) => fieldChange(v, field)"
/>
</div>
<div
@ -249,81 +254,73 @@
>
<DatePicker
icon-left=""
:value="data[field.fieldname]"
:value="document.doc[field.fieldname]"
:formatter="(date) => getFormat(date, '', true)"
:placeholder="field.placeholder"
placement="left-start"
@change="
(data) => emit('update', field.fieldname, data)
"
@change="(v) => fieldChange(v, field)"
/>
</div>
<FormControl
<FormattedInput
v-else-if="field.fieldtype === 'Percent'"
class="form-control"
type="text"
:value="getFormattedPercent(field.fieldname, data)"
:value="
getFormattedPercent(field.fieldname, document.doc)
"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="
emit(
'update',
field.fieldname,
flt($event.target.value),
)
fieldChange(flt($event.target.value), field)
"
:disabled="Boolean(field.read_only)"
/>
<FormControl
<FormattedInput
v-else-if="field.fieldtype === 'Int'"
class="form-control"
type="number"
v-model="data[field.fieldname]"
type="text"
v-model="document.doc[field.fieldname]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="
emit('update', field.fieldname, $event.target.value)
"
@change.stop="fieldChange($event.target.value, field)"
:disabled="Boolean(field.read_only)"
/>
<FormControl
<FormattedInput
v-else-if="field.fieldtype === 'Float'"
class="form-control"
type="text"
:value="getFormattedFloat(field.fieldname, data)"
:value="
getFormattedFloat(field.fieldname, document.doc)
"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="
emit(
'update',
field.fieldname,
flt($event.target.value),
)
fieldChange(flt($event.target.value), field)
"
:disabled="Boolean(field.read_only)"
/>
<FormControl
<FormattedInput
v-else-if="field.fieldtype === 'Currency'"
class="form-control"
type="text"
:value="getFormattedCurrency(field.fieldname, data)"
:value="
getFormattedCurrency(field.fieldname, document.doc)
"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="
emit(
'update',
field.fieldname,
flt($event.target.value),
)
fieldChange(flt($event.target.value), field)
"
:disabled="Boolean(field.read_only)"
/>
<FormControl
v-else
class="form-control"
type="text"
:value="data[field.fieldname]"
:value="document.doc[field.fieldname]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="
emit('update', field.fieldname, $event.target.value)
"
@change.stop="fieldChange($event.target.value, field)"
/>
</div>
<div class="ml-1">
@ -331,19 +328,23 @@
v-if="
field.fieldtype === 'Link' &&
field.link &&
data[field.fieldname]
document.doc[field.fieldname]
"
class="h-4 w-4 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
@click.stop="field.link(data[field.fieldname])"
@click.stop="
field.link(document.doc[field.fieldname])
"
/>
<EditIcon
v-if="
field.fieldtype === 'Link' &&
field.edit &&
data[field.fieldname]
document.doc[field.fieldname]
"
class="size-3.5 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
@click.stop="field.edit(data[field.fieldname])"
@click.stop="
field.edit(document.doc[field.fieldname])
"
/>
</div>
</div>
@ -365,6 +366,7 @@
</template>
<script setup>
import FormattedInput from '@/components/Controls/FormattedInput.vue'
import Section from '@/components/Section.vue'
import NestedPopover from '@/components/NestedPopover.vue'
import DropdownItem from '@/components/DropdownItem.vue'
@ -380,6 +382,7 @@ import { isMobileView } from '@/composables/settings'
import { getFormat, evaluateDependsOnValue } from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
import { useDocument } from '@/data/document'
import { ref, computed } from 'vue'
const props = defineProps({
@ -389,6 +392,11 @@ const props = defineProps({
doctype: {
type: String,
default: 'CRM Lead',
required: true,
},
docname: {
type: String,
required: true,
},
preview: {
type: Boolean,
@ -401,13 +409,22 @@ const props = defineProps({
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(props.doctype)
const { isManager, getUser } = usersStore()
const emit = defineEmits(['update', 'reload'])
const emit = defineEmits(['reload'])
const data = defineModel()
const showSidePanelModal = ref(false)
let document = { doc: {} }
let triggerOnChange
if (props.docname) {
let d = useDocument(props.doctype, props.docname)
document = d.document
triggerOnChange = d.triggerOnChange
}
const _sections = computed(() => {
if (!props.sections?.length) return []
let editButtonAdded = false
@ -447,11 +464,11 @@ function parsedField(field) {
placeholder: field.placeholder || field.label,
display_via_depends_on: evaluateDependsOnValue(
field.depends_on,
data.value,
document.doc,
),
mandatory_via_depends_on: evaluateDependsOnValue(
field.mandatory_depends_on,
data.value,
document.doc,
),
}
@ -459,6 +476,16 @@ function parsedField(field) {
return _field
}
async function fieldChange(value, df) {
if (props.preview) return
document.doc[df.fieldname] = value
await triggerOnChange(df.fieldname)
document.save.submit()
}
function parsedSection(section, editButtonAdded) {
let isContactSection = section.name == 'contacts_section'
section.showEditButton = !(
@ -479,7 +506,7 @@ function isFieldVisible(field) {
if (props.preview) return true
return (
(field.fieldtype == 'Check' ||
(field.read_only && data.value[field.fieldname]) ||
(field.read_only && document.doc?.[field.fieldname]) ||
!field.read_only) &&
(!field.depends_on || field.display_via_depends_on) &&
!field.hidden

View File

@ -545,6 +545,11 @@ function reload() {
const showExportDialog = ref(false)
const export_type = ref('Excel')
const export_all = ref(false)
const selectedRows = ref([])
function updateSelections(selections) {
selectedRows.value = Array.from(selections)
}
async function exportRows() {
let fields = JSON.stringify(list.value.data.columns.map((f) => f.key))
@ -560,7 +565,15 @@ async function exportRows() {
page_length = list.value.data.total_count
}
window.location.href = `/api/method/frappe.desk.reportview.export_query?file_format_type=${export_type.value}&title=${props.doctype}&doctype=${props.doctype}&fields=${fields}&filters=${filters}&order_by=${order_by}&page_length=${page_length}&start=0&view=Report&with_comment_count=1`
let url = `/api/method/frappe.desk.reportview.export_query?file_format_type=${export_type.value}&title=${props.doctype}&doctype=${props.doctype}&fields=${fields}&filters=${filters}&order_by=${order_by}&page_length=${page_length}&start=0&view=Report&with_comment_count=1`
// Add selected items parameter if rows are selected
if (selectedRows.value?.length && !export_all.value) {
url += `&selected_items=${JSON.stringify(selectedRows.value)}`
}
window.location.href = url
showExportDialog.value = false
export_all.value = false
export_type.value = 'Excel'
@ -1336,6 +1349,7 @@ defineExpose({
viewActions,
viewsDropdownOptions,
currentView,
updateSelections,
})
// Watchers

View File

@ -0,0 +1,16 @@
import { ref } from 'vue'
export const showCreateDocumentModal = ref(false)
export const createDocumentDoctype = ref('')
export const createDocumentData = ref({})
export const createDocumentCallback = ref(null)
export function createDocument(doctype, obj, close, callback) {
if (doctype) {
close?.()
createDocumentDoctype.value = doctype
createDocumentData.value = obj || {}
createDocumentCallback.value = callback || null
showCreateDocumentModal.value = true
}
}

View File

@ -0,0 +1,142 @@
import { getScript } from '@/data/script'
import { createToast, runSequentially } from '@/utils'
import { createDocumentResource } from 'frappe-ui'
const documentsCache = {}
const controllersCache = {}
export function useDocument(doctype, docname) {
const { setupScript } = getScript(doctype)
documentsCache[doctype] = documentsCache[doctype] || {}
if (!documentsCache[doctype][docname]) {
documentsCache[doctype][docname] = createDocumentResource({
doctype: doctype,
name: docname,
onSuccess: () => setupFormScript(),
setValue: {
onSuccess: () => {
createToast({
title: __('Document updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
},
onError: (err) => {
createToast({
title: __('Error updating document'),
text: err.messages[0],
icon: 'x',
iconClasses: 'text-red-600',
})
},
},
})
}
function setupFormScript() {
if (controllersCache[doctype]?.[docname]) return
if (!controllersCache[doctype]) {
controllersCache[doctype] = {}
}
controllersCache[doctype][docname] = setupScript(
documentsCache[doctype][docname],
)
}
function getControllers(row = null) {
const _doctype = row?.doctype || doctype
return (controllersCache[doctype]?.[docname] || []).filter(
(c) => c.constructor.name === _doctype.replace(/\s+/g, ''),
)
}
async function triggerOnRefresh() {
const handler = async function () {
await this.refresh()
}
await trigger(handler)
}
async function triggerOnChange(fieldname, row) {
const handler = async function () {
if (row) {
this.currentRowIdx = row.idx
this.value = row[fieldname]
this.oldValue = getOldValue(fieldname, row)
} else {
this.value = documentsCache[doctype][docname].doc[fieldname]
this.oldValue = getOldValue(fieldname)
}
await this[fieldname]?.()
}
await trigger(handler, row)
}
async function triggerOnRowAdd(row) {
const handler = async function () {
this.currentRowIdx = row.idx
this.value = row
await this[row.parentfield + '_add']?.()
}
await trigger(handler, row)
}
async function triggerOnRowRemove(selectedRows, rows) {
const handler = async function () {
if (selectedRows.size === 1) {
const selectedRow = Array.from(selectedRows)[0]
this.currentRowIdx = rows.find((r) => r.name === selectedRow).idx
} else {
delete this.currentRowIdx
}
this.selectedRows = Array.from(selectedRows)
this.rows = rows
await this[rows[0].parentfield + '_remove']?.()
}
await trigger(handler, rows[0])
}
async function trigger(taskFn, row = null) {
const controllers = getControllers(row)
if (!controllers.length) return
const tasks = controllers.map(
(controller) => async () => await taskFn.call(controller),
)
await runSequentially(tasks)
}
function getOldValue(fieldname, row) {
if (!documentsCache[doctype][docname]) return ''
const document = documentsCache[doctype][docname]
const oldDoc = document.originalDoc
if (row?.name) {
return oldDoc?.[row.parentfield]?.find((r) => r.name === row.name)?.[
fieldname
]
}
return oldDoc?.[fieldname] || document.doc[fieldname]
}
return {
document: documentsCache[doctype][docname],
triggerOnChange,
triggerOnRowAdd,
triggerOnRowRemove,
triggerOnRefresh,
setupFormScript,
}
}

271
frontend/src/data/script.js Normal file
View File

@ -0,0 +1,271 @@
import { globalStore } from '@/stores/global'
import { getMeta } from '@/stores/meta'
import { createToast } from '@/utils'
import { call, createListResource } from 'frappe-ui'
import { reactive } from 'vue'
import router from '@/router'
const doctypeScripts = reactive({})
export function getScript(doctype, view = 'Form') {
const scripts = createListResource({
doctype: 'CRM Form Script',
cache: ['Form Scripts', doctype, view],
fields: ['name', 'dt', 'view', 'script'],
filters: { view, dt: doctype, enabled: 1 },
onSuccess: (_scripts) => {
for (let script of _scripts) {
if (!doctypeScripts[doctype]) {
doctypeScripts[doctype] = {}
}
doctypeScripts[doctype][script.name] = script || {}
}
},
})
if (!doctypeScripts[doctype] && !scripts.loading) {
scripts.fetch()
}
function setupScript(document, helpers = {}) {
let scripts = doctypeScripts[doctype]
if (!scripts) return null
const { $dialog, $socket, makeCall } = globalStore()
helpers.createDialog = $dialog
helpers.createToast = createToast
helpers.socket = $socket
helpers.router = router
helpers.call = call
helpers.crm = {
makePhoneCall: makeCall,
}
return setupMultipleFormControllers(scripts, document, helpers)
}
function setupMultipleFormControllers(scriptStrings, document, helpers) {
const controllers = []
let parentInstanceIdx = null
for (let scriptName in scriptStrings) {
let script = scriptStrings[scriptName]?.script
if (!script) continue
try {
const classNames = getClassNames(script)
if (!classNames) continue
classNames.forEach((className) => {
const FormClass = evaluateFormClass(script, className, helpers)
if (!FormClass) return
let parentInstance = null
let doctypeName = doctype.replace(/\s+/g, '')
let { doctypeMeta } = getMeta(doctype)
// if className is not doctype name, then it is a child doctype
let isChildDoctype = className !== doctypeName
if (isChildDoctype) {
if (!controllers.length) {
console.error(
__(
'⚠️ No class found for doctype: {0}, it is mandatory to have a class for the parent doctype. it can be empty, but it should be present.',
[doctype],
),
)
return
}
parentInstance = controllers[parentInstanceIdx]
} else {
parentInstanceIdx = controllers.length || 0
}
const instance = setupFormController(
FormClass,
doctypeMeta,
document,
parentInstance,
isChildDoctype,
)
controllers.push(instance)
})
} catch (err) {
console.error(__('Failed to load form controller: {0}', [err]))
}
}
return controllers
}
function setupFormController(
FormClass,
meta,
document,
parentInstance = null,
isChildDoctype = false,
) {
let instance = new FormClass()
instance._isChildDoctype = isChildDoctype
for (const key in document) {
if (document.hasOwnProperty(key)) {
instance[key] = document[key]
}
}
instance.getMeta = async (doctype) => {
if (!meta[doctype]) {
await getMeta(doctype)
return meta[doctype]
}
return meta[doctype]
}
setupHelperMethods(FormClass, document)
if (isChildDoctype) {
instance.doc = createDocProxy(document.doc, parentInstance, instance)
if (!parentInstance._childInstances) {
parentInstance._childInstances = []
}
parentInstance._childInstances.push(instance)
} else {
instance.doc = createDocProxy(document.doc, instance)
}
return instance
}
function setupHelperMethods(FormClass, document) {
if (typeof FormClass.prototype.getRow !== 'function') {
FormClass.prototype.getRow = function (parentField, idx) {
let data = document.doc
idx = idx || this.currentRowIdx
let dt = null
if (this instanceof Array) {
const { getFields } = getMeta(data.doctype)
let fields = getFields()
let field = fields.find((f) => f.fieldname === parentField)
dt = field?.options?.replace(/\s+/g, '')
if (!idx && dt) {
idx = this.find((r) => r.constructor.name === dt)?.currentRowIdx
}
}
if (!data[parentField]) {
console.warn(
__('⚠️ No data found for parent field: {0}', [parentField]),
)
return null
}
const row = data[parentField].find((r) => r.idx === idx)
if (!row) {
console.warn(
__('⚠️ No row found for idx: {0} in parent field: {1}', [
idx,
parentField,
]),
)
return null
}
row.parent = row.parent || data.name
if (this instanceof Array && dt) {
return createDocProxy(
row,
this.find((r) => r.constructor.name === dt),
)
}
return createDocProxy(row, this)
}
}
}
// utility function to setup a form controller
function getClassNames(script) {
const withoutComments = script
.replace(/\/\/.*$/gm, '') // Remove single-line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove multi-line comments
// Match class declarations
return (
[...withoutComments.matchAll(/class\s+([A-Za-z0-9_]+)/g)].map(
(match) => match[1],
) || []
)
}
function evaluateFormClass(script, className, helpers = {}) {
const helperKeys = Object.keys(helpers)
const helperValues = Object.values(helpers)
const wrappedScript = `
${script}
return ${className};
`
const FormClass = new Function(...helperKeys, wrappedScript)(
...helperValues,
)
return FormClass
}
function createDocProxy(data, instance, childInstance = null) {
return new Proxy(data, {
get(target, prop) {
if (prop === 'trigger') {
if ('trigger' in data) {
console.warn(
__(
'⚠️ Avoid using "trigger" as a field name — it conflicts with the built-in trigger() method.',
),
)
}
return (methodName, ...args) => {
const method = instance[methodName]
if (typeof method === 'function') {
return method.apply(instance, args)
} else {
console.warn(
__('⚠️ Method "{0}" not found in class.', [methodName]),
)
}
}
}
if (prop === 'getRow') {
return instance.getRow.bind(
childInstance || instance._childInstances || instance,
)
}
return target[prop]
},
set(target, prop, value) {
target[prop] = value
return true
},
})
}
return {
scripts,
setupScript,
setupFormController,
}
}

View File

@ -0,0 +1,4 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5714 44L31.4286 44C38.3716 44 44 38.3716 44 31.4286L44 12.5714C44 5.62842 38.3716 0 31.4286 0L12.5714 0C5.62842 0 0 5.62842 0 12.5714L0 31.4286C0 38.3716 5.62842 44 12.5714 44Z" fill="#0466DC"/>
<path d="M9.42859 12.5715V14.8972L12.5714 17.4587L18.5743 22.3458C19.5329 23.1315 20.7586 23.5715 22 23.5715C23.2414 23.5715 24.4672 23.1315 25.4257 22.3458L31.4286 17.443V28.2701H12.5714V21.5287L9.42859 18.9672V27.4844C9.42859 29.653 11.1886 31.413 13.3572 31.413H30.6429C32.8115 31.413 34.5715 29.653 34.5715 27.4844V12.5715H9.42859ZM23.4457 19.9101C22.6286 20.5701 21.3714 20.5701 20.57 19.9101L15.4157 15.7144H28.6L23.4457 19.9101Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -41,6 +41,9 @@
@applyFilter="(data) => viewControls.applyFilter(data)"
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
@likeDoc="(data) => viewControls.likeDoc(data)"
@selectionsChanged="
(selections) => viewControls.updateSelections(selections)
"
/>
<div
v-else-if="callLogs.data"

View File

@ -8,7 +8,7 @@
</Breadcrumbs>
</template>
</LayoutHeader>
<div ref="parentRef" class="flex h-full">
<div v-if="contact.data" ref="parentRef" class="flex h-full">
<Resizer
v-if="contact.data"
:parent="$refs.parentRef"
@ -121,10 +121,9 @@
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<SidePanelLayout
v-model="contact.data"
:sections="sections.data"
doctype="Contact"
@update="updateField"
:docname="contact.data.name"
@reload="sections.reload"
/>
</div>
@ -168,10 +167,16 @@
</template>
</Tabs>
</div>
<ErrorPage
v-else-if="errorTitle"
:errorTitle="errorTitle"
:errorMessage="errorMessage"
/>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
</template>
<script setup>
import ErrorPage from '@/components/ErrorPage.vue'
import Resizer from '@/components/Resizer.vue'
import Icon from '@/components/Icon.vue'
import SidePanelLayout from '@/components/SidePanelLayout.vue'
@ -202,6 +207,7 @@ import {
} from 'frappe-ui'
import { ref, computed, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { errorMessage as _errorMessage } from '../utils'
const { brand } = getSettings()
const { $dialog, makeCall } = globalStore()
@ -225,6 +231,9 @@ const showAddressModal = ref(false)
const _contact = ref({})
const _address = ref({})
const errorTitle = ref('')
const errorMessage = ref('')
const contact = createResource({
url: 'crm.api.contact.get_contact',
cache: ['contact', props.contactId],
@ -237,6 +246,18 @@ const contact = createResource({
mobile_no: data.mobile_no,
}
},
onSuccess: () => {
errorTitle.value = ''
errorMessage.value = ''
},
onError: (err) => {
if (err.messages?.[0]) {
errorTitle.value = __('Not permitted')
errorMessage.value = __(err.messages?.[0])
} else {
router.push({ name: 'Contacts' })
}
},
})
const breadcrumbs = computed(() => {

View File

@ -44,6 +44,9 @@
@applyFilter="(data) => viewControls.applyFilter(data)"
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
@likeDoc="(data) => viewControls.likeDoc(data)"
@selectionsChanged="
(selections) => viewControls.updateSelections(selections)
"
/>
<div
v-else-if="contacts.data"

View File

@ -89,7 +89,7 @@
@click="
deal.data.email
? openEmailBox()
: errorMessage(__('No email set'))
: _errorMessage(__('No email set'))
"
/>
</Button>
@ -103,7 +103,7 @@
@click="
deal.data.website
? openWebsite(deal.data.website)
: errorMessage(__('No website set'))
: _errorMessage(__('No website set'))
"
/>
</Button>
@ -129,11 +129,10 @@
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<SidePanelLayout
v-model="deal.data"
:sections="sections.data"
:addContact="addContact"
doctype="CRM Deal"
@update="updateField"
:docname="deal.data.name"
@reload="sections.reload"
>
<template #actions="{ section }">
@ -267,6 +266,11 @@
</div>
</Resizer>
</div>
<ErrorPage
v-else-if="errorTitle"
:errorTitle="errorTitle"
:errorMessage="errorMessage"
/>
<OrganizationModal
v-model="showOrganizationModal"
v-model:organization="_organization"
@ -297,6 +301,7 @@
/>
</template>
<script setup>
import ErrorPage from '@/components/ErrorPage.vue'
import Icon from '@/components/Icon.vue'
import Resizer from '@/components/Resizer.vue'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
@ -330,7 +335,7 @@ import {
createToast,
setupAssignees,
setupCustomizations,
errorMessage,
errorMessage as _errorMessage,
copyToClipboard,
} from '@/utils'
import { getView } from '@/utils/view'
@ -372,11 +377,17 @@ const props = defineProps({
},
})
const errorTitle = ref('')
const errorMessage = ref('')
const deal = createResource({
url: 'crm.fcrm.doctype.crm_deal.api.get_deal',
params: { name: props.dealId },
cache: ['deal', props.dealId],
onSuccess: (data) => {
errorTitle.value = ''
errorMessage.value = ''
if (data.organization) {
organization.update({
params: { doctype: 'CRM Organization', name: data.organization },
@ -401,6 +412,14 @@ const deal = createResource({
call,
})
},
onError: (err) => {
if (err.messages?.[0]) {
errorTitle.value = __('Not permitted')
errorMessage.value = __(err.messages?.[0])
} else {
router.push({ name: 'Deals' })
}
},
})
const organization = createResource({
@ -545,7 +564,6 @@ const tabs = computed(() => {
name: 'Calls',
label: __('Calls'),
icon: PhoneIcon,
condition: () => callEnabled.value,
},
{
name: 'Tasks',
@ -699,12 +717,12 @@ function triggerCall() {
let mobile_no = primaryContact.mobile_no || null
if (!primaryContact) {
errorMessage(__('No primary contact set'))
_errorMessage(__('No primary contact set'))
return
}
if (!mobile_no) {
errorMessage(__('No mobile number set'))
_errorMessage(__('No mobile number set'))
return
}

View File

@ -223,6 +223,9 @@
@applyFilter="(data) => viewControls.applyFilter(data)"
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
@likeDoc="(data) => viewControls.likeDoc(data)"
@selectionsChanged="
(selections) => viewControls.updateSelections(selections)
"
/>
<div v-else-if="deals.data" class="flex h-full items-center justify-center">
<div
@ -457,9 +460,6 @@ function parseRows(rows, columns = []) {
}
} else if (row == '_assign') {
let assignees = JSON.parse(deal._assign || '[]')
if (!assignees.length && deal.deal_owner) {
assignees = [deal.deal_owner]
}
_rows[row] = assignees.map((user) => ({
name: user,
image: getUser(user).user_image,

View File

@ -45,6 +45,9 @@
@applyFilter="(data) => viewControls.applyFilter(data)"
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
@likeDoc="(data) => viewControls.likeDoc(data)"
@selectionsChanged="
(selections) => viewControls.updateSelections(selections)
"
/>
<div
v-else-if="emailTemplates.data"

View File

@ -124,7 +124,7 @@
() =>
lead.data.mobile_no
? makeCall(lead.data.mobile_no)
: errorMessage(__('No phone number set'))
: _errorMessage(__('No phone number set'))
"
>
<PhoneIcon class="h-4 w-4" />
@ -139,7 +139,7 @@
@click="
lead.data.email
? openEmailBox()
: errorMessage(__('No email set'))
: _errorMessage(__('No email set'))
"
/>
</Button>
@ -153,7 +153,7 @@
@click="
lead.data.website
? openWebsite(lead.data.website)
: errorMessage(__('No website set'))
: _errorMessage(__('No website set'))
"
/>
</Button>
@ -182,15 +182,19 @@
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<SidePanelLayout
v-model="lead.data"
:sections="sections.data"
doctype="CRM Lead"
@update="updateField"
:docname="lead.data.name"
@reload="sections.reload"
/>
</div>
</Resizer>
</div>
<ErrorPage
v-else-if="errorTitle"
:errorTitle="errorTitle"
:errorMessage="errorMessage"
/>
<Dialog
v-model="showConvertToDealModal"
:options="{
@ -309,6 +313,7 @@
/>
</template>
<script setup>
import ErrorPage from '@/components/ErrorPage.vue'
import Icon from '@/components/Icon.vue'
import Resizer from '@/components/Resizer.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
@ -342,11 +347,12 @@ import {
createToast,
setupAssignees,
setupCustomizations,
errorMessage,
errorMessage as _errorMessage,
copyToClipboard,
} from '@/utils'
import { getView } from '@/utils/view'
import { getSettings } from '@/stores/settings'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/users'
import { globalStore } from '@/stores/global'
import { statusesStore } from '@/stores/statuses'
@ -375,6 +381,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useActiveTabManager } from '@/composables/useActiveTabManager'
const { brand } = getSettings()
const { user } = sessionStore()
const { isManager } = usersStore()
const { $dialog, $socket, makeCall } = globalStore()
const { statusOptions, getLeadStatus, getDealStatus } = statusesStore()
@ -392,11 +399,16 @@ const props = defineProps({
},
})
const errorTitle = ref('')
const errorMessage = ref('')
const lead = createResource({
url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
params: { name: props.leadId },
cache: ['lead', props.leadId],
onSuccess: (data) => {
errorTitle.value = ''
errorMessage.value = ''
setupAssignees(lead)
setupCustomizations(lead, {
doc: data,
@ -410,6 +422,14 @@ const lead = createResource({
call,
})
},
onError: (err) => {
if (err.messages?.[0]) {
errorTitle.value = __('Not permitted')
errorMessage.value = __(err.messages?.[0])
} else {
router.push({ name: 'Leads' })
}
},
})
onMounted(() => {
@ -532,7 +552,6 @@ const tabs = computed(() => {
name: 'Calls',
label: __('Calls'),
icon: PhoneIcon,
condition: () => callEnabled.value,
},
{
name: 'Tasks',
@ -658,7 +677,7 @@ async function convertToDeal() {
existingContact.value = ''
existingOrganization.value = ''
updateOnboardingStep('convert_lead_to_deal', true, false, () => {
localStorage.setItem('firstDeal', _deal)
localStorage.setItem('firstDeal' + user, _deal)
})
capture('convert_lead_to_deal')
router.push({ name: 'Deal', params: { dealId: _deal } })
@ -688,10 +707,10 @@ const dealTabs = createResource({
auto: true,
transform: (_tabs) => {
let hasFields = false
let parsedTabs = _tabs.forEach((tab) => {
tab.sections.forEach((section) => {
section.columns.forEach((column) => {
column.fields.forEach((field) => {
let parsedTabs = _tabs?.forEach((tab) => {
tab.sections?.forEach((section) => {
section.columns?.forEach((column) => {
column.fields?.forEach((field) => {
hasFields = true
if (field.fieldname == 'status') {
field.fieldtype = 'Select'

View File

@ -249,6 +249,9 @@
@applyFilter="(data) => viewControls.applyFilter(data)"
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
@likeDoc="(data) => viewControls.likeDoc(data)"
@selectionsChanged="
(selections) => viewControls.updateSelections(selections)
"
/>
<div v-else-if="leads.data" class="flex h-full items-center justify-center">
<div
@ -480,9 +483,6 @@ function parseRows(rows, columns = []) {
}
} else if (row == '_assign') {
let assignees = JSON.parse(lead._assign || '[]')
if (!assignees.length && lead.lead_owner) {
assignees = [lead.lead_owner]
}
_rows[row] = assignees.map((user) => ({
name: user,
image: getUser(user).user_image,

View File

@ -130,10 +130,9 @@
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<SidePanelLayout
v-model="contact.data"
:sections="sections.data"
doctype="Contact"
@update="updateField"
:docname="contact.data.name"
@reload="sections.reload"
/>
</div>

View File

@ -62,10 +62,9 @@
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<SidePanelLayout
v-model="deal.data"
:sections="sections.data"
doctype="CRM Deal"
@update="updateField"
:docname="deal.data.name"
@reload="sections.reload"
>
<template #actions="{ section }">

View File

@ -67,10 +67,9 @@
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<SidePanelLayout
v-model="lead.data"
:sections="sections.data"
doctype="CRM Lead"
@update="updateField"
:docname="lead.data.name"
@reload="sections.reload"
/>
</div>

View File

@ -112,10 +112,9 @@
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<SidePanelLayout
v-model="organization.doc"
:sections="sections.data"
doctype="CRM Organization"
@update="updateField"
:docname="organization.doc.name"
@reload="sections.reload"
/>
</div>

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