Compare commits

...

221 Commits

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
:fetchContacts="false"
/>
</div>
<FormControl
@ -127,10 +128,17 @@ const inviteByEmail = createResource({
role: role.value,
}
},
onSuccess() {
onSuccess(data) {
if (data?.existing_invites?.length) {
error.value = __('Agent with email {0} already exists', [
data.existing_invites.join(', '),
])
} else {
role.value = 'Sales User'
error.value = null
}
invitees.value = []
role.value = 'Sales User'
error.value = null
pendingInvitations.reload()
updateOnboardingStep('invite_your_team')
},

View File

@ -57,7 +57,7 @@
v-model="profile.email"
:disabled="true"
/>
<FormControl
<Password
class="w-full"
label="Set new password"
v-model="profile.new_password"
@ -77,13 +77,15 @@
</div>
</template>
<script setup>
import Password from '@/components/Controls/Password.vue'
import ProfileImageEditor from '@/components/Settings/ProfileImageEditor.vue'
import { usersStore } from '@/stores/users'
import { createToast } from '@/utils'
import { Dialog, Avatar, createResource, ErrorMessage } from 'frappe-ui'
import { Dialog, Avatar, createResource, ErrorMessage, toast } from 'frappe-ui'
import { useOnboarding } from 'frappe-ui/frappe'
import { ref, computed, onMounted } from 'vue'
const { getUser, users } = usersStore()
const { updateOnboardingStep } = useOnboarding('frappecrm')
const user = computed(() => getUser() || {})
@ -95,6 +97,13 @@ const error = ref('')
function updateUser() {
loading.value = true
let passwordUpdated = false
if (profile.value.new_password) {
passwordUpdated = true
}
const fieldname = {
first_name: profile.value.first_name,
last_name: profile.value.last_name,
@ -111,15 +120,14 @@ function updateUser() {
},
auto: true,
onSuccess: () => {
if (passwordUpdated) {
updateOnboardingStep('setup_your_password')
}
loading.value = false
error.value = ''
profile.value.new_password = ''
showEditProfilePhotoModal.value = false
createToast({
title: __('Profile updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
toast.success(__('Profile updated successfully'))
users.reload()
},
onError: (err) => {

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

@ -42,9 +42,10 @@ import {
createResource,
Spinner,
Badge,
toast,
ErrorMessage,
} from 'frappe-ui'
import { createToast, getRandom } from '@/utils'
import { getRandom } from '@/utils'
import { computed } from 'vue'
const props = defineProps({
@ -79,20 +80,10 @@ const data = createDocumentResource({
auto: true,
setValue: {
onSuccess: () => {
createToast({
title: __('Success'),
text: __(props.successMessage),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
toast.success(__(props.successMessage))
},
onError: (err) => {
createToast({
title: __('Error'),
text: err.message + ': ' + err.messages[0],
icon: 'x',
iconClasses: 'text-ink-red-4',
})
toast.error(err.message + ': ' + err.messages[0])
},
},
})

View File

@ -87,7 +87,8 @@ import {
} from 'frappe-ui'
import { defaultCallingMedium } from '@/composables/settings'
import { usersStore } from '@/stores/users'
import { createToast, getRandom } from '@/utils'
import { toast } from 'frappe-ui'
import { getRandom } from '@/utils'
import { ref, computed, watch } from 'vue'
const { isManager, isAgent } = usersStore()
@ -119,20 +120,10 @@ const twilio = createDocumentResource({
auto: true,
setValue: {
onSuccess: () => {
createToast({
title: __('Success'),
text: __('Twilio settings updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
toast.success(__('Twilio settings updated successfully'))
},
onError: (err) => {
createToast({
title: __('Error'),
text: err.message + ': ' + err.messages[0],
icon: 'x',
iconClasses: 'text-ink-red-4',
})
toast.error(err.message + ': ' + err.messages[0])
},
},
})
@ -144,20 +135,10 @@ const exotel = createDocumentResource({
auto: true,
setValue: {
onSuccess: () => {
createToast({
title: __('Success'),
text: __('Exotel settings updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
toast.success(__('Exotel settings updated successfully'))
},
onError: (err) => {
createToast({
title: __('Error'),
text: err.message + ': ' + err.messages[0],
icon: 'x',
iconClasses: 'text-ink-red-4',
})
toast.error(err.message + ': ' + err.messages[0])
},
},
})
@ -294,12 +275,7 @@ async function updateMedium() {
})
mediumChanged.value = false
error.value = ''
createToast({
title: __('Success'),
text: __('Default calling medium updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
toast.success(__('Default calling medium updated successfully'))
}
const error = ref('')

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,82 @@
>
<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
<Password
v-else-if="field.fieldtype === 'Password'"
class="form-control"
:value="document.doc[field.fieldname]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="fieldChange($event.target.value, field)"
:disabled="Boolean(field.read_only)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Int'"
class="form-control"
type="number"
v-model="data[field.fieldname]"
type="text"
:value="document.doc[field.fieldname] || '0'"
: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 +337,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 +375,8 @@
</template>
<script setup>
import Password from '@/components/Controls/Password.vue'
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 +392,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 +402,11 @@ const props = defineProps({
doctype: {
type: String,
default: 'CRM Lead',
required: true,
},
docname: {
type: String,
required: true,
},
preview: {
type: Boolean,
@ -401,13 +419,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 +474,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 +486,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 +516,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

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