Compare commits

...

437 Commits

Author SHA1 Message Date
frappe-pr-bot
223187c7ea chore: update POT file 2025-07-06 09:36:54 +00:00
Shariq Ansari
40c5c92230
Merge pull request #1013 from shariquerik/primary-mobile-no 2025-07-05 14:33:10 +05:30
Shariq Ansari
6760798f18 fix: useDocument in organization page 2025-07-05 14:17:26 +05:30
Shariq Ansari
42ea1ad16e fix: useDocument in contact page 2025-07-05 14:09:37 +05:30
Shariq Ansari
96200aebe6 fix: update primary mobile_no & email in deal if contact is updated 2025-07-05 13:21:21 +05:30
Shariq Ansari
bcfe4b6a49 fix: made mobile_no, email & phone readonly since it captures primary contacts data 2025-07-05 12:30:40 +05:30
Shariq Ansari
cafc4fb22f
Merge pull request #1009 from shariquerik/products-table-fix 2025-07-02 17:50:58 +05:30
Shariq Ansari
39eb5600d9 fix: grid field is not getting set 2025-07-02 17:45:46 +05:30
Shariq Ansari
0b97462dc9
Merge pull request #1007 from shariquerik/lost-reason-fix 2025-07-02 17:26:54 +05:30
Shariq Ansari
cab80edf60 fix: check reason.reason not reason 2025-07-02 17:15:59 +05:30
Shariq Ansari
6f3b58d1a5
Merge pull request #1005 from shariquerik/frappeui-update 2025-07-02 17:09:33 +05:30
Shariq Ansari
fc89c7b93c build(deps): bump frappeui to 0.1.166 2025-07-02 17:03:28 +05:30
Shariq Ansari
4a57c4eb84
Merge pull request #1003 from shariquerik/default-lost-reason 2025-07-02 16:59:23 +05:30
Shariq Ansari
96cbdea820 fix: add default lost reason on install 2025-07-02 16:57:26 +05:30
Shariq Ansari
a3a54aef94
Merge pull request #984 from shariquerik/lost-reasons 2025-07-02 16:03:30 +05:30
Shariq Ansari
c96e5ff6c5 fix: update default probability from deal status 2025-07-02 15:58:12 +05:30
Shariq Ansari
144470877d fix: allow creating lost reason from lost reason modal field 2025-07-02 15:42:25 +05:30
Shariq Ansari
391844512a fix: intercept data tab's before save and side panel's before field change to show lost reason modal 2025-07-02 15:28:21 +05:30
Shariq Ansari
d89c304b13 fix: show lost reason modal if status changed to lost 2025-07-02 14:11:11 +05:30
Shariq Ansari
881126c7f1 refactor: statusOptions code 2025-07-02 14:10:14 +05:30
Shariq Ansari
5bbec00803 fix: renamed other_lost_reason to lost_notes 2025-07-02 13:59:34 +05:30
Shariq Ansari
7730e46cfc fix: removed unused triggerOnChange 2025-07-02 13:58:51 +05:30
Shariq Ansari
97b2253e9d fix: made lost notes as text and non mandatory if lost reason is not Other 2025-07-02 11:10:53 +05:30
Shariq Ansari
1afb2a783b
Merge branch 'develop' into lost-reasons 2025-07-01 19:34:35 +05:30
Shariq Ansari
0fdbfa3ad4
Merge pull request #998 from shariquerik/prettydate-fix 2025-07-01 19:23:24 +05:30
Shariq Ansari
a7dc5e05b3 fix: show absolute day count not in decimels 2025-07-01 19:20:04 +05:30
Shariq Ansari
92d7280728 chore: resolved conflict 2025-07-01 16:53:40 +05:30
Shariq Ansari
5d01b88a1e feat: created lost reason doctype 2025-07-01 16:51:01 +05:30
Shariq Ansari
2b47e3f4c9
Merge pull request #994 from shariquerik/forecasting-fix-1 2025-07-01 16:49:48 +05:30
Shariq Ansari
485360f291 fix: show forcasted sales section in sidepanel if forecasting is enabled 2025-07-01 16:37:04 +05:30
Shariq Ansari
17fdbb05ce fix: add mandatory fields in convert to deal modal if not added 2025-07-01 16:05:08 +05:30
Shariq Ansari
adc22efcb1 fix: show error message on convert to deal modal 2025-07-01 16:04:40 +05:30
Shariq Ansari
4c70b1a06b fix: mandatory error 2025-07-01 15:06:02 +05:30
Shariq Ansari
4f58aa110a fix: made deal value mandatory if forecasting is enabled 2025-07-01 15:05:37 +05:30
Shariq Ansari
4d3fe722e8 fix: added default probability to Lost status 2025-07-01 13:16:40 +05:30
Shariq Ansari
6320e580ae refactor: moved convert to deal modal into separate component 2025-07-01 13:07:36 +05:30
Shariq Ansari
611f4cde70 fix: prettyDate is not accurate 2025-07-01 12:58:57 +05:30
Shariq Ansari
6d3268a61e fix: add default probabilities in deal status 2025-07-01 12:07:44 +05:30
Shariq Ansari
bf0a1ecebd
Merge pull request #991 from shariquerik/edit-call-log 2025-07-01 11:52:18 +05:30
Shariq Ansari
693c086930 fix: show edit call log button in call log details modal 2025-07-01 11:49:42 +05:30
Shariq Ansari
7c307a9134
Merge pull request #988 from shariquerik/call-log-on-before-create 2025-06-30 20:06:01 +05:30
Shariq Ansari
aae7e0e36c fix: pass reference doc to call log modal to get reference doc in on before create 2025-06-30 20:00:05 +05:30
Shariq Ansari
2014a3d6de
Merge pull request #985 from shariquerik/on-before-create 2025-06-30 19:41:35 +05:30
Shariq Ansari
2e5c1bc3b5 fix: added on before create hook in all modals 2025-06-30 19:16:24 +05:30
Shariq Ansari
ac13b7a3bd fix: added on before create hook in call log modal 2025-06-30 18:43:19 +05:30
Shariq Ansari
6b7bdf5afb feat: added on before create hook in document.js 2025-06-30 18:42:20 +05:30
Shariq Ansari
ff657ec34c
Merge pull request #980 from frappe/pot_develop_2025-06-29 2025-06-30 12:42:55 +05:30
Shariq Ansari
da4698d431
Merge branch 'develop' into pot_develop_2025-06-29 2025-06-30 12:42:12 +05:30
Shariq Ansari
20d47ae323
Merge pull request #978 from shariquerik/dynamic-app-alias 2025-06-30 11:59:29 +05:30
Shariq Ansari
f4f799f636 feat: create dynamic alias to use components from frontend vue apps 2025-06-30 11:49:31 +05:30
frappe-pr-bot
cc411f036d chore: update POT file 2025-06-29 09:36:46 +00:00
Shariq Ansari
8350c5ee36
Merge pull request #971 from shariquerik/email-template-settings 2025-06-26 17:54:04 +05:30
Shariq Ansari
65435cf2b5 fix: delete icon issue & more cleanup 2025-06-26 17:49:10 +05:30
Shariq Ansari
af4c64e633 build(deps): bump frappeui to 0.1.162 2025-06-26 17:11:37 +05:30
Shariq Ansari
41b913debe fix: cannot change role of user with Admin access 2025-06-26 17:07:26 +05:30
Shariq Ansari
a3b9368953 fix: give Sales Manager & Sales User role if System Manager access is given 2025-06-26 16:54:33 +05:30
Shariq Ansari
28ece820ed style: better spacing 2025-06-26 16:46:23 +05:30
Shariq Ansari
cca420b1a0 style: minor changes 2025-06-25 21:19:49 +05:30
Shariq Ansari
05803c79b4 fix: make email template row clickable 2025-06-25 17:02:41 +05:30
Shariq Ansari
5932ccafec fix: add new email template from email selector modal 2025-06-25 15:23:30 +05:30
Shariq Ansari
7cee017e20 fix: removed email template page and related components 2025-06-25 15:15:34 +05:30
Shariq Ansari
b15a8d9c8a fix: added email template icon 2025-06-25 15:03:32 +05:30
Shariq Ansari
7e6d5c3e54 fix: Duplicate email template 2025-06-24 19:46:27 +05:30
Shariq Ansari
dd3d297dab fix: Edit email template 2025-06-24 19:37:13 +05:30
Shariq Ansari
e4f728d809 fix: Create email template 2025-06-24 18:52:36 +05:30
Shariq Ansari
cd7bab9184 feat: Create/Edit & List page for email template & implemented delete from list 2025-06-24 18:52:00 +05:30
Shariq Ansari
ec6b1558b1 fix: only show search if users are more than 10 2025-06-24 15:36:54 +05:30
Shariq Ansari
1c3ee8b557 refactor: added email templates in settings modal 2025-06-24 15:35:48 +05:30
Shariq Ansari
1db7f69f89
Merge pull request #960 from shariquerik/users-fix-1 2025-06-24 12:12:20 +05:30
Shariq Ansari
3c1ce1fe27 fix: make header sticky 2025-06-24 12:11:39 +05:30
Shariq Ansari
2d05b6a282
Merge pull request #957 from shariquerik/users-fix 2025-06-24 11:45:46 +05:30
Shariq Ansari
b5ed9692df fix: user with System Manager role is admin 2025-06-24 11:44:13 +05:30
Shariq Ansari
9a326d791b
Merge pull request #953 from shariquerik/users 2025-06-23 21:00:32 +05:30
Shariq Ansari
7fbd240d97 fix: added search in users page 2025-06-23 20:55:35 +05:30
Shariq Ansari
58d4691354
Merge pull request #846 from pratikb64/delete-from-record-view 2025-06-23 13:50:19 +05:30
Pratik
594295b7c8 style: switch button position 2025-06-23 08:13:06 +00:00
Shariq Ansari
2c45673f54
Merge pull request #845 from shariquerik/agents 2025-06-23 13:22:56 +05:30
Shariq Ansari
7827afe606
Merge pull request #946 from kalungia/dev 2025-06-23 13:21:59 +05:30
Shariq Ansari
bc7498e02b
Merge branch 'develop' into dev 2025-06-23 13:20:01 +05:30
Shariq Ansari
e5dd85aefb
Merge pull request #948 from frappe/pot_develop_2025-06-22 2025-06-23 13:19:09 +05:30
Shariq Ansari
cf1fce3dc0 fix: renaming fix and removed CRM User code 2025-06-23 13:18:16 +05:30
Pratik
480cc07cd9 refactor: remove unnecessary functions & components 2025-06-23 07:35:15 +00:00
Shariq Ansari
84d4327e80 fix: use change password modal in place 2025-06-23 13:04:12 +05:30
Pratik
34102ef6ef refactor: change labels & function names 2025-06-23 05:15:40 +00:00
Pratik
ca985a0b76 Merge branch 'develop' of https://github.com/pratikb64/crm into delete-from-record-view 2025-06-23 04:26:50 +00:00
frappe-pr-bot
4b4a154261 chore: update POT file 2025-06-22 09:36:52 +00:00
Shariq Ansari
e957327877 fix: use crmUsers in all link field 2025-06-20 18:57:09 +05:30
Shariq Ansari
2fdea90ad4 fix: loading state in Users page 2025-06-20 18:55:42 +05:30
Shariq Ansari
ad1aee9c9e fix: add user is actually add role 2025-06-20 18:55:14 +05:30
Shariq Ansari
bd7451e86f fix: if role is set to sales user then remove modules and set FCRM 2025-06-20 17:56:14 +05:30
Shariq Ansari
0230360145 fix: use text-ink-gray-8 instead of 9 2025-06-20 17:47:07 +05:30
Shariq Ansari
eee1190f10 fix: dark mode fixes for email account setting 2025-06-20 17:42:10 +05:30
Shariq Ansari
96c8aae01e chore: fixed warning 2025-06-20 17:25:14 +05:30
Shariq Ansari
0ad65be961 fix: layout change 2025-06-20 17:20:12 +05:30
Shariq Ansari
0f8d484e28 fix: existingEmail is a list 2025-06-20 17:18:36 +05:30
Shariq Ansari
364c369199 fix: updated users page to update user directly and removed unnecessary code 2025-06-20 16:46:41 +05:30
Shariq Ansari
901bcb8460 fix: removed CRM User doctype and moved api's to user.py 2025-06-20 16:45:48 +05:30
Shariq Ansari
d06ac91052 fix: use multi select user input and show already exist error if user with email exist or invited 2025-06-20 16:43:48 +05:30
Shariq Ansari
f818a4c1d6 fix: created multi select user input 2025-06-20 16:42:45 +05:30
Shariq Ansari
85191e10c8 fix: use crmUsers in comment box 2025-06-20 16:41:08 +05:30
Shariq Ansari
001a3231e1 fix: get users and crm users 2025-06-20 15:50:47 +05:30
Shariq Ansari
d951dff5a9 fix: added tooltip with shortcut 2025-06-19 15:08:53 +05:30
Shariq Ansari
dc82f837aa fix: moved change password modal in global modals 2025-06-19 15:07:57 +05:30
Shariq Ansari
7f1db0b444 fix: change password validation messsage 2025-06-19 12:56:50 +05:30
Abraham Kalungi
a317950567 fix: prevent TypeError when concatenating first and last name in WhatsApp messages
The last name on CRM Leads can be empty. In cases where it is, an error occurs: can only concatenate str (not "NoneType") to str. This prevents retrieving messages until a last name is added.
2025-06-18 15:31:00 +02:00
Shariq Ansari
4c7269e357 fix: updated components.d.ts 2025-06-17 23:46:53 +05:30
Shariq Ansari
15fd763de8 fix: use validateIsImageFile from utils 2025-06-17 23:46:28 +05:30
Shariq Ansari
0c314674fc
Merge branch 'develop' into agents 2025-06-17 23:42:42 +05:30
Shariq Ansari
efd03141f0
chore: resolved conflict 2025-06-17 23:42:20 +05:30
Shariq Ansari
675bcb549d
Discard changes to frontend/src/components/Settings/ProfileImageEditor.vue 2025-06-17 23:40:58 +05:30
Shariq Ansari
56425254a9 fix: updated components.d.ts 2025-06-17 23:35:51 +05:30
Shariq Ansari
22856351fd fix: only show users to manager 2025-06-17 23:35:51 +05:30
Shariq Ansari
dd1229309f fix: updated components.d.ts 2025-06-17 23:35:51 +05:30
Shariq Ansari
fad7c5985c refactor: profile page 2025-06-17 23:34:45 +05:30
Shariq Ansari
3234102e55 fix: capture onboarding step event of setting up password 2025-06-17 23:34:45 +05:30
Shariq Ansari
fb2f105520 feat: update password modal 2025-06-17 23:34:45 +05:30
Shariq Ansari
03abe0b5cd fix: create crm user on accepting invite 2025-06-17 23:34:44 +05:30
Shariq Ansari
6873c6db4e feat: add existing users 2025-06-17 23:34:44 +05:30
Shariq Ansari
08bab927a2 fix: filter out existing emails 2025-06-17 23:34:44 +05:30
Shariq Ansari
d244567b30 fix: open invite user page from users add new agent dropdown option 2025-06-17 23:34:44 +05:30
Shariq Ansari
2b1b21d2e2 fix: show role options based on logged in user's role 2025-06-17 23:34:44 +05:30
Shariq Ansari
12213de478 fix: show role options based on logged in user's role 2025-06-17 23:34:44 +05:30
Shariq Ansari
2a2c832e0b fix: updated roles in Invite user page 2025-06-17 23:34:44 +05:30
Shariq Ansari
b534aae70b fix: renamed component names from Agent to User 2025-06-17 23:34:44 +05:30
Shariq Ansari
6d3e4406ae fix: renamed & added role with filter 2025-06-17 23:34:44 +05:30
Shariq Ansari
463d60b650 fix: renamed Agent to User in Settings 2025-06-17 23:34:44 +05:30
Shariq Ansari
e9812495e9 fix: update agent fields based on user 2025-06-17 23:34:44 +05:30
Shariq Ansari
0a836c78bb fix: activate/deactivate agent 2025-06-17 23:34:44 +05:30
Shariq Ansari
5c7f835e4c fix: renamed invite members to invite agent 2025-06-17 23:34:44 +05:30
Shariq Ansari
346849631e fix: show and allow changing role from agents settings page 2025-06-17 23:34:44 +05:30
Shariq Ansari
bf166bdaad fix: added get user role in users store 2025-06-17 23:34:44 +05:30
Shariq Ansari
123f183f68 fix: removed x button from settings modal 2025-06-17 23:34:44 +05:30
Shariq Ansari
bea1505c63 fix: added Agents page in settings 2025-06-17 23:34:44 +05:30
Shariq Ansari
fac5ed5579 feat: added crm agent doctype 2025-06-17 23:34:44 +05:30
Shariq Ansari
96fefbd8a3
Merge pull request #937 from shariquerik/forecasting-fix 2025-06-16 17:48:08 +05:30
Shariq Ansari
548018997e build(deps): bump frappeui to 0.1.156 2025-06-16 17:42:28 +05:30
Shariq Ansari
9f3477e1cd fix: updated button with icon 2025-06-16 17:42:01 +05:30
Shariq Ansari
baa03246e6 fix: fixed breaking button with icon and open email box 2025-06-16 17:06:06 +05:30
Shariq Ansari
a17b1cd0e2 fix: added mandatory field error toast 2025-06-16 16:32:15 +05:30
Shariq Ansari
49d82870c4 fix: hide calendar icon in side panel date fields 2025-06-16 13:41:11 +05:30
Shariq Ansari
6a72a4467a build(deps): bump frappeui to 0.1.154 2025-06-16 13:40:53 +05:30
Shariq Ansari
efbed6e0b6 fix: renamed Expected Closure Date to Close Date 2025-06-16 11:44:08 +05:30
Shariq Ansari
5270670b65
Merge pull request #933 from frappe/pot_develop_2025-06-15 2025-06-16 11:39:47 +05:30
frappe-pr-bot
b82f4ca02b chore: update POT file 2025-06-15 09:36:57 +00:00
Pratik Badhe
0f451c7e3a
Merge pull request #930 from pratikb64/git-error 2025-06-13 18:26:31 +05:30
Pratik
824dc8dcdd fix: git command error 2025-06-13 12:51:04 +00:00
Shariq Ansari
20405be86c
Merge pull request #920 from shariquerik/forecasting 2025-06-13 15:04:23 +05:30
Shariq Ansari
4edfa951dc fix: asterisk is not visible if label is big enough 2025-06-13 15:02:50 +05:30
Shariq Ansari
029c16d1d0 fix: circular import 2025-06-13 14:46:44 +05:30
Shariq Ansari
b3acff8cba fix: create standard forecasting script on install and added in patch 2025-06-13 14:42:09 +05:30
Shariq Ansari
a98b0e3a00 fix: set close date to now if status is Won 2025-06-13 14:20:50 +05:30
Shariq Ansari
b1cbcbd98d
Merge pull request #923 from shariquerik/lead-org-avatar-fix 2025-06-12 18:09:41 +05:30
Shariq Ansari
e079980598 fix: removed leads organization logo 2025-06-12 18:07:07 +05:30
Shariq Ansari
2e27c0459c fix: set close date reqd as 1 or 0 based on enabled_forecasting 2025-06-11 19:30:08 +05:30
Shariq Ansari
d87a237789 feat: added enable forecasting settings 2025-06-11 19:03:22 +05:30
Shariq Ansari
e9e0aa357b fix: trigger on change on status change 2025-06-11 14:20:11 +05:30
Shariq Ansari
9af300bba8 fix: added throwError global method 2025-06-11 14:19:16 +05:30
Shariq Ansari
7d79cbf5bd fix: update and reset value in triggerOnChange method 2025-06-11 14:18:25 +05:30
Shariq Ansari
fdca27bb81 fix: added probability field in deal status 2025-06-11 13:01:22 +05:30
Shariq Ansari
01f0213693 fix: added deal value field 2025-06-11 13:00:02 +05:30
Shariq Ansari
5d29a49120
Merge pull request #916 from mahsem/develop 2025-06-11 11:13:25 +05:30
mahsem
33e6b80d5a
fix: add context for Integrations 2025-06-09 22:11:22 +02:00
Shariq Ansari
100d931535
Merge pull request #913 from shariquerik/future-date-time-ago 2025-06-09 16:51:02 +05:30
Shariq Ansari
dba6dd1983 fix: future date is not captured in pretty date 2025-06-09 16:49:09 +05:30
Shariq Ansari
dc8898e1da
Merge pull request #907 from frappe/pot_develop_2025-06-08 2025-06-09 14:09:56 +05:30
frappe-pr-bot
ed79bf55eb chore: update POT file 2025-06-08 09:36:10 +00:00
Shariq Ansari
fb644f5fbe
Merge pull request #905 from NagariaHussain/fix/image-upload
refactor: DRY up validate image file
2025-06-08 12:49:03 +05:30
Hussain Nagaria
ab409dfd2c fix: yet another unused import due to merge conflict 2025-06-08 12:37:15 +05:30
Hussain Nagaria
42285dd911 fix: unused import due to merge conflict 2025-06-08 12:36:39 +05:30
Md Hussain Nagaria
db2a6c65b7
Merge branch 'develop' into fix/image-upload 2025-06-08 12:34:30 +05:30
Hussain Nagaria
c6ad10857a refactor: DRY up validate image file
* Also, allows more types of image files
2025-06-08 12:30:51 +05:30
Shariq Ansari
f128a55f97
Merge pull request #902 from shariquerik/onload-onsave 2025-06-06 21:15:42 +05:30
Shariq Ansari
2a817e5861 fix: only load assignee if docname is passed 2025-06-06 21:14:20 +05:30
Shariq Ansari
5e616f1a50 refactor: statusOptions code 2025-06-06 21:09:27 +05:30
Shariq Ansari
c6e9d71e1f fix: allow snake & camel case for on load, on save, convert to deal, on create lead 2025-06-06 21:01:20 +05:30
Shariq Ansari
f58d44bf9c fix: use document.doc in status dropdown 2025-06-06 20:49:58 +05:30
Shariq Ansari
f72ab39c93 fix: onLoad & onSave 2025-06-06 20:35:25 +05:30
Shariq Ansari
6c706e6162
Merge pull request #899 from shariquerik/minor-fix 2025-06-06 17:51:43 +05:30
Shariq Ansari
8f81d207b8 fix: activity is not loading 2025-06-06 17:50:48 +05:30
Shariq Ansari
b74c5f384d
Merge pull request #896 from shariquerik/custom-actions 2025-06-06 17:24:13 +05:30
Shariq Ansari
df412d51fe fix: add custom actions using class based script in mobile view 2025-06-06 17:19:27 +05:30
Shariq Ansari
8942bb7e48 feat: add custom statuses using class based script 2025-06-06 17:17:11 +05:30
Shariq Ansari
ca60679126 feat: add custom actions using class based script 2025-06-06 16:38:39 +05:30
Shariq Ansari
8db846ad5d fix: trigger onload method if controller is loaded 2025-06-06 16:37:55 +05:30
Shariq Ansari
bb6a90058b
Merge pull request #894 from shariquerik/refactor-assignees 2025-06-06 14:58:47 +05:30
Shariq Ansari
44df09fac2 fix: removed setuAssignees code 2025-06-06 14:06:02 +05:30
Shariq Ansari
e214ce8bfb refactor: render assignees from document.js
reload assignees if lead_owner/deal_owner is changed
2025-06-05 18:55:28 +05:30
Shariq Ansari
6d281922e4 fix: load assignees in document.js 2025-06-05 18:53:57 +05:30
Shariq Ansari
58f09331b0 fix: remove multiple assignees not working 2025-06-05 18:52:39 +05:30
Shariq Ansari
9780a6b63e
Merge pull request #891 from shariquerik/fixes-2 2025-06-05 16:14:38 +05:30
Shariq Ansari
71f764c224 refactor: set default values for new lead, deal and contact 2025-06-05 16:08:51 +05:30
Shariq Ansari
9362997246 refactor: organization modal code refactor 2025-06-05 16:08:13 +05:30
Shariq Ansari
28ea88f61e refactor: call log modal code refactor 2025-06-05 16:07:40 +05:30
Shariq Ansari
a25ff14dd4 fix: activity is not loading 2025-06-05 15:31:36 +05:30
Shariq Ansari
d86caee7af
Merge pull request #887 from shariquerik/new-doc-issue 2025-06-05 13:56:57 +05:30
Shariq Ansari
c4caabe722 fix: moved address modal to global modals and control it using modals.js 2025-06-04 19:15:12 +05:30
Shariq Ansari
8dcb77634b fix: moved quick entry modal related logic to modals.js & GlobalModals for all pages 2025-06-04 19:14:15 +05:30
Shariq Ansari
c4feed116d fix: handle new document for lead/deal/contact/organization 2025-06-04 19:00:57 +05:30
Shariq Ansari
e220767179 fix: global modals not working in mobile view 2025-06-04 18:44:51 +05:30
Shariq Ansari
571126c36d fix: moved modal related code to modal.js 2025-06-04 18:34:19 +05:30
Shariq Ansari
832323f25e fix: handle new document for call log 2025-06-04 12:50:19 +05:30
Shariq Ansari
3b73432d8c fix: handle controllers for new document 2025-06-04 12:49:14 +05:30
Shariq Ansari
3aa341370b fix: await scripts.list.promise 2025-06-04 12:48:20 +05:30
Ankush Menat
895da1a812
fix: remove invasive settings (#884) 2025-06-02 19:03:54 +05:30
Shariq Ansari
d34ee6fe48
Merge pull request #875 from Ocheretovich/patch-2 2025-06-02 12:37:56 +05:30
Shariq Ansari
7298fe378c
Merge pull request #878 from frappe/pot_develop_2025-06-01 2025-06-02 12:37:01 +05:30
Shariq Ansari
2da0b48c29
Merge pull request #874 from Ocheretovich/patch-1 2025-06-02 12:36:01 +05:30
frappe-pr-bot
165509f5a0 chore: update POT file 2025-06-01 09:36:30 +00:00
Ocheretovich
c9b9dbb092
Update README.md 2025-05-29 13:06:52 +03:00
Ocheretovich
0cc1d5da8f
Update README.md 2025-05-29 13:03:21 +03:00
Pratik
c70dced268 refactor: internationalization & code clean up 2025-05-29 06:02:35 +00:00
Pratik
df698387dc Merge remote-tracking branch 'origin/develop' into delete-from-record-view 2025-05-29 05:54:17 +00:00
Pratik
716dc056d6 refactor: move delete button 2025-05-29 05:52:36 +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
Pratik
7f5f43f0c2 style: fix dark mode styles 2025-05-27 05:22:30 +00:00
Pratik
af41469d58 feat: add list view & handle bulk delete, unlink 2025-05-26 15:24:24 +00:00
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
Pratik
4a836a58ee feat: handle bulk delete 2025-05-22 07:15:22 +00:00
Pratik
b47fc5b93b feat: handle linked docs while deleting 2025-05-21 14:20:48 +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
192 changed files with 10641 additions and 3627 deletions

45
.mergify.yml Normal file
View File

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

View File

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

View File

@ -1,9 +1,10 @@
from bs4 import BeautifulSoup
import frappe
from frappe.translate import get_all_translations
from frappe.utils import validate_email_address, split_emails, cstr
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
from bs4 import BeautifulSoup
from frappe.core.api.file import get_max_file_size
from frappe.translate import get_all_translations
from frappe.utils import cstr, split_emails, validate_email_address
from frappe.utils.modules import get_modules_from_all_apps_for_user
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
@frappe.whitelist(allow_guest=True)
@ -63,9 +64,14 @@ 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
role in ["System Manager", "Sales User", "Sales Manager"] for role in roles
):
return True
@ -93,9 +99,14 @@ def accept_invitation(key: str | None = None):
@frappe.whitelist()
def invite_by_email(emails: str, role: str):
frappe.only_for("Sales Manager")
frappe.only_for(["Sales Manager", "System Manager"])
if role not in ["System Manager", "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:
@ -103,7 +114,10 @@ def invite_by_email(emails: str, role: str):
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
existing_invites = frappe.db.get_all(
"CRM Invitation",
filters={"email": ["in", email_list], "role": ["in", ["Sales Manager", "Sales User"]]},
filters={
"email": ["in", email_list],
"role": ["in", ["System Manager", "Sales Manager", "Sales User"]],
},
pluck="email",
)
@ -112,6 +126,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

@ -14,32 +14,16 @@ def update_deals_email_mobile_no(doc):
)
for linked_deal in linked_deals:
deal = frappe.get_cached_doc("CRM Deal", linked_deal.parent)
deal = frappe.db.get_values("CRM Deal", linked_deal.parent, ["email", "mobile_no"], as_dict=True)[0]
if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no:
deal.email = doc.email_id
deal.mobile_no = doc.mobile_no
deal.save(ignore_permissions=True)
@frappe.whitelist()
def get_contact(name):
Contact = frappe.qb.DocType("Contact")
query = frappe.qb.from_(Contact).select("*").where(Contact.name == name).limit(1)
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
frappe.db.set_value(
"CRM Deal",
linked_deal.parent,
{
"email": doc.email_id,
"mobile_no": doc.mobile_no,
},
)
@frappe.whitelist()

View File

@ -3,6 +3,7 @@ import json
import frappe
from frappe import _
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.form.assign_to import set_status
from frappe.model import no_value_fields
from frappe.model.document import get_controller
from frappe.utils import make_filter_tuple
@ -10,6 +11,7 @@ from pypika import Criterion
from crm.api.views import get_views
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
from crm.utils import get_dynamic_linked_docs, get_linked_docs
@frappe.whitelist()
@ -418,16 +420,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 +446,20 @@ def get_data(
column_data = frappe.get_list(
doctype,
fields=rows,
filters=convert_filter_to_tuple(doctype, column_filters),
filters=column_filters,
order_by=order_by,
page_length=page_length,
)
new_filters = filters.copy()
new_filters.update({column_field: kc.get("name")})
all_count = frappe.get_list(
doctype,
filters=convert_filter_to_tuple(doctype, new_filters),
filters=column_filters,
fields="count(*) as total_count",
)[0].total_count
kc["all_count"] = all_count
kc["count"] = len(column_data)
for d in column_data:
getCounts(d, doctype)
if order:
column_data = sorted(
column_data,
@ -658,6 +661,25 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False, only_re
return fields_meta
@frappe.whitelist()
def remove_assignments(doctype, name, assignees, ignore_permissions=False):
assignees = json.loads(assignees)
if not assignees:
return
for assign_to in assignees:
set_status(
doctype,
name,
todo=None,
assign_to=assign_to,
status="Cancelled",
ignore_permissions=ignore_permissions,
)
@frappe.whitelist()
def get_assigned_users(doctype, name, default_assigned_to=None):
assigned_users = frappe.get_all(
"ToDo",
@ -725,3 +747,98 @@ def getCounts(d, doctype):
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
)
return d
@frappe.whitelist()
def get_linked_docs_of_document(doctype, docname):
doc = frappe.get_doc(doctype, docname)
linked_docs = get_linked_docs(doc)
dynamic_linked_docs = get_dynamic_linked_docs(doc)
linked_docs.extend(dynamic_linked_docs)
linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values())
docs_data = []
for doc in linked_docs:
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
title = data.get("title")
if data.doctype == "CRM Call Log":
title = f"Call from {data.get('from')} to {data.get('to')}"
if data.doctype == "CRM Deal":
title = data.get("organization")
docs_data.append(
{
"doc": data.doctype,
"title": title or data.get("name"),
"reference_docname": doc["reference_docname"],
"reference_doctype": doc["reference_doctype"],
}
)
return docs_data
def remove_doc_link(doctype, docname):
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"reference_doctype": None,
"reference_docname": None,
}
)
linked_doc_data.save(ignore_permissions=True)
def remove_contact_link(doctype, docname):
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"contact": None,
"contacts": [],
}
)
linked_doc_data.save(ignore_permissions=True)
@frappe.whitelist()
def remove_linked_doc_reference(items, remove_contact=None, delete=False):
if isinstance(items, str):
items = frappe.parse_json(items)
for item in items:
if remove_contact:
remove_contact_link(item["doctype"], item["docname"])
else:
remove_doc_link(item["doctype"], item["docname"])
if delete:
frappe.delete_doc(item["doctype"], item["docname"])
return "success"
@frappe.whitelist()
def delete_bulk_docs(doctype, items, delete_linked=False):
from frappe.desk.reportview import delete_bulk
items = frappe.parse_json(items)
for doc in items:
linked_docs = get_linked_docs_of_document(doctype, doc)
for linked_doc in linked_docs:
remove_linked_doc_reference(
[
{
"doctype": linked_doc["reference_doctype"],
"docname": linked_doc["reference_docname"],
}
],
remove_contact=doctype == "Contact",
delete=delete_linked,
)
if len(items) > 10:
frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
else:
delete_bulk(doctype, items)
return "success"

View File

@ -23,11 +23,35 @@ def get_users():
if frappe.session.user == user.name:
user.session_user = True
user.is_manager = "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator"
user.is_manager = "Sales Manager" in frappe.get_roles(user.name)
user.is_admin = user.name == "Administrator"
user.roles = frappe.get_roles(user.name)
user.role = ""
if "System Manager" in user.roles:
user.role = "System Manager"
elif "Sales Manager" in user.roles:
user.role = "Sales Manager"
elif "Sales User" in user.roles:
user.role = "Sales User"
elif "Guest" in user.roles:
user.role = "Guest"
if frappe.session.user == user.name:
user.session_user = True
user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
return users
crm_users = []
# crm users are users with role Sales User or Sales Manager
for user in users:
if "Sales User" in user.roles or "Sales Manager" in user.roles:
crm_users.append(user)
return users, crm_users
@frappe.whitelist()

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,
},
}

84
crm/api/user.py Normal file
View File

@ -0,0 +1,84 @@
import frappe
@frappe.whitelist()
def add_existing_users(users, role="Sales User"):
"""
Add existing users to the CRM by assigning them a role (Sales User or Sales Manager).
:param users: List of user names to be added
"""
frappe.only_for(["System Manager", "Sales Manager"])
users = frappe.parse_json(users)
for user in users:
add_user(user, role)
@frappe.whitelist()
def update_user_role(user, new_role):
"""
Update the role of the user to Sales Manager, Sales User, or System Manager.
:param user: The name of the user
:param new_role: The new role to assign (Sales Manager or Sales User)
"""
frappe.only_for(["System Manager", "Sales Manager"])
if new_role not in ["System Manager", "Sales Manager", "Sales User"]:
frappe.throw("Cannot assign this role")
user_doc = frappe.get_doc("User", user)
if new_role == "System Manager":
user_doc.append_roles("System Manager", "Sales Manager", "Sales User")
user_doc.set("block_modules", [])
if new_role == "Sales Manager":
user_doc.append_roles("Sales Manager", "Sales User")
user_doc.remove_roles("System Manager")
if new_role == "Sales User":
user_doc.append_roles("Sales User")
user_doc.remove_roles("Sales Manager", "System Manager")
update_module_in_user(user_doc, "FCRM")
user_doc.save(ignore_permissions=True)
@frappe.whitelist()
def add_user(user, role):
"""
Add a user means adding role (Sales User or/and Sales Manager) to the user.
:param user: The name of the user to be added
:param role: The role to be assigned (Sales User or Sales Manager)
"""
update_user_role(user, role)
@frappe.whitelist()
def remove_user(user):
"""
Remove a user means removing Sales User & Sales Manager roles from the user.
:param user: The name of the user to be removed
"""
frappe.only_for(["System Manager", "Sales Manager"])
user_doc = frappe.get_doc("User", user)
roles = [d.role for d in user_doc.roles]
if "Sales User" in roles:
user_doc.remove_roles("Sales User")
if "Sales Manager" in roles:
user_doc.remove_roles("Sales Manager")
user_doc.save(ignore_permissions=True)
frappe.msgprint(f"User {user} has been removed from CRM roles.")
def update_module_in_user(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)

View File

@ -335,5 +335,5 @@ def get_from_name(message):
else:
from_name = doc.get("lead_name")
else:
from_name = doc.get("first_name") + " " + doc.get("last_name")
from_name = " ".join(filter(None, [doc.get("first_name"), doc.get("last_name")]))
return from_name

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

@ -1,16 +1,18 @@
import frappe
from crm.api.doc import get_assigned_users, get_fields_meta
from crm.api.doc import get_fields_meta
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_deal(name):
deal = frappe.get_doc("CRM Deal", name).as_dict()
deal = frappe.get_doc("CRM Deal", name)
deal.check_permission("read")
deal = deal.as_dict()
deal["fields_meta"] = get_fields_meta("CRM Deal")
deal["_form_script"] = get_form_script("CRM Deal")
deal["_assign"] = get_assigned_users("CRM Deal", deal.name)
return deal
@ -30,24 +32,12 @@ def get_deal_contacts(name):
is_primary = contact.is_primary
contact = frappe.get_doc("Contact", contact.contact).as_dict()
def get_primary_email(contact):
for email in contact.email_ids:
if email.is_primary:
return email.email_id
return contact.email_ids[0].email_id if contact.email_ids else ""
def get_primary_mobile_no(contact):
for phone in contact.phone_nos:
if phone.is_primary:
return phone.phone
return contact.phone_nos[0].phone if contact.phone_nos else ""
_contact = {
"name": contact.name,
"image": contact.image,
"full_name": contact.full_name,
"email": get_primary_email(contact),
"mobile_no": get_primary_mobile_no(contact),
"email": contact.email_id,
"mobile_no": contact.mobile_no,
"is_primary": is_primary,
}
deal_contacts.append(_contact)

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

@ -11,11 +11,16 @@
"naming_series",
"organization",
"next_step",
"probability",
"column_break_ijan",
"status",
"close_date",
"deal_owner",
"lost_reason",
"lost_notes",
"section_break_jgpm",
"probability",
"deal_value",
"column_break_kpxa",
"close_date",
"contacts_tab",
"contacts",
"contact",
@ -43,6 +48,12 @@
"mobile_no",
"phone",
"gender",
"products_tab",
"products",
"section_break_ccbj",
"total",
"column_break_udbq",
"net_total",
"sla_tab",
"sla",
"sla_creation",
@ -119,14 +130,16 @@
{
"fieldname": "email",
"fieldtype": "Data",
"label": "Email",
"options": "Email"
"label": "Primary Email",
"options": "Email",
"read_only": 1
},
{
"fieldname": "mobile_no",
"fieldtype": "Data",
"label": "Mobile No",
"options": "Phone"
"label": "Primary Mobile No",
"options": "Phone",
"read_only": 1
},
{
"default": "Qualification",
@ -239,8 +252,9 @@
{
"fieldname": "phone",
"fieldtype": "Data",
"label": "Phone",
"options": "Phone"
"label": "Primary Phone",
"options": "Phone",
"read_only": 1
},
{
"fieldname": "log_tab",
@ -334,11 +348,73 @@
"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
},
{
"fieldname": "section_break_jgpm",
"fieldtype": "Section Break"
},
{
"fieldname": "deal_value",
"fieldtype": "Currency",
"label": "Deal Value",
"options": "currency"
},
{
"fieldname": "column_break_kpxa",
"fieldtype": "Column Break"
},
{
"fieldname": "lost_reason",
"fieldtype": "Link",
"label": "Lost Reason",
"mandatory_depends_on": "eval: doc.status == \"Lost\"",
"options": "CRM Lost Reason"
},
{
"fieldname": "lost_notes",
"fieldtype": "Text",
"label": "Lost Notes",
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-12-11 14:31:41.058895",
"modified": "2025-07-05 12:25:05.927806",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",
@ -370,10 +446,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

@ -24,6 +24,8 @@ class CRMDeal(Document):
self.assign_agent(self.deal_owner)
if self.has_value_changed("status"):
add_status_change_log(self)
self.validate_forcasting_fields()
self.validate_lost_reason()
def after_insert(self):
if self.deal_owner:
@ -133,6 +135,39 @@ class CRMDeal(Document):
if sla:
sla.apply(self)
def update_close_date(self):
"""
Update the close date based on the "Won" status.
"""
if self.status == "Won" and not self.close_date:
self.close_date = frappe.utils.nowdate()
def update_default_probability(self):
"""
Update the default probability based on the status.
"""
if not self.probability or self.probability == 0:
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
def validate_forcasting_fields(self):
self.update_close_date()
self.update_default_probability()
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
if not self.deal_value or self.deal_value == 0:
frappe.throw(_("Deal Value is required."), frappe.MandatoryError)
if not self.close_date:
frappe.throw(_("Close Date is required."), frappe.MandatoryError)
def validate_lost_reason(self):
"""
Validate the lost reason if the status is set to "Lost".
"""
if self.status == "Lost":
if not self.lost_reason:
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
elif self.lost_reason == "Other" and not self.lost_notes:
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
@staticmethod
def default_list_data():
columns = [

View File

@ -8,7 +8,8 @@
"field_order": [
"deal_status",
"color",
"position"
"position",
"probability"
],
"fields": [
{
@ -32,11 +33,18 @@
"fieldtype": "Int",
"in_list_view": 1,
"label": "Position"
},
{
"fieldname": "probability",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Probability"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-01-19 21:56:44.552134",
"modified": "2025-07-01 12:06:42.937440",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal Status",
@ -68,7 +76,8 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

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}]
@ -45,9 +47,19 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldname in allowed_fields]
required_fields = []
if type == "Required Fields":
required_fields = [
field for field in frappe.get_meta(doctype, False).fields if field.reqd and not field.default
]
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:
@ -55,6 +67,32 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
handle_perm_level_restrictions(field, doctype, parent_doctype)
column["fields"][column.get("fields").index(field["fieldname"])] = field
# remove field from required_fields if it is already present
if (
type == "Required Fields"
and field.reqd
and any(f.get("fieldname") == field.get("fieldname") for f in required_fields)
):
required_fields = [
f for f in required_fields if f.get("fieldname") != field.get("fieldname")
]
if type == "Required Fields" and required_fields and tabs:
tabs[-1].get("sections").append(
{
"label": "Required Fields",
"name": "required_fields_section_" + str(random_string(4)),
"opened": True,
"hideLabel": True,
"columns": [
{
"name": "required_fields_column_" + str(random_string(4)),
"fields": [field.as_dict() for field in required_fields],
}
],
}
)
return tabs or []
@ -78,6 +116,8 @@ def get_sidepanel_sections(doctype):
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
add_forecasting_section(layout, doctype)
for section in layout:
section["name"] = section.get("name") or section.get("label")
for column in section.get("columns") if section.get("columns") else []:
@ -95,6 +135,38 @@ def get_sidepanel_sections(doctype):
return layout
def add_forecasting_section(layout, doctype):
if (
doctype == "CRM Deal"
and frappe.db.get_single_value("FCRM Settings", "enable_forecasting")
and not any(section.get("name") == "forecasted_sales_section" for section in layout)
):
contacts_section_index = next(
(
i
for i, section in enumerate(layout)
if section.get("name") == "contacts_section" or section.get("label") == "Contacts"
),
None,
)
if contacts_section_index is not None:
layout.insert(
contacts_section_index + 1,
{
"name": "forecasted_sales_section",
"label": "Forecasted Sales",
"opened": True,
"columns": [
{
"name": "column_" + str(random_string(4)),
"fields": ["close_date", "probability", "deal_value"],
}
],
},
)
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
if field.permlevel == 0:
return

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

@ -27,7 +27,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Role",
"options": "\nSales User\nSales Manager",
"options": "\nSales User\nSales Manager\nSystem Manager",
"reqd": 1
},
{
@ -66,7 +66,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-03 14:59:29.450018",
"modified": "2025-06-17 17:20:18.935395",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Invitation",
@ -106,7 +106,8 @@
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"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(
@ -35,7 +35,7 @@ class CRMInvitation(Document):
@frappe.whitelist()
def accept_invitation(self):
frappe.only_for("System Manager")
frappe.only_for(["System Manager", "Sales Manager"])
self.accept()
def accept(self):
@ -44,12 +44,28 @@ class CRMInvitation(Document):
user = self.create_user_if_not_exists()
user.append_roles(self.role)
if self.role == "System Manager":
user.append_roles("Sales Manager", "Sales User")
elif self.role == "Sales Manager":
user.append_roles("Sales User")
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

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

View File

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

View File

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

View File

@ -27,9 +27,10 @@
"label": "Details"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-02 22:13:30.498404",
"modified": "2025-06-30 16:53:51.721752",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lead Source",
@ -44,7 +45,7 @@
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"role": "System Manager",
"share": 1,
"write": 1
},
@ -60,6 +61,15 @@
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1
},
{
"email": 1,
"export": 1,
@ -71,7 +81,8 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("CRM Lost Reason", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,79 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:lost_reason",
"creation": "2025-06-30 16:51:31.082360",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"lost_reason",
"description"
],
"fields": [
{
"fieldname": "lost_reason",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Lost Reason",
"reqd": 1,
"unique": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-30 16:59:15.094049",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lost Reason",
"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
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# 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 CRMLostReason(Document):
pass

View File

@ -0,0 +1,30 @@
# 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 UnitTestCRMLostReason(UnitTestCase):
"""
Unit tests for CRMLostReason.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMLostReason(IntegrationTestCase):
"""
Integration tests for CRMLostReason.
Use this class for testing interactions between multiple components.
"""
pass

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

@ -7,6 +7,7 @@
"field_order": [
"defaults_tab",
"restore_defaults",
"enable_forecasting",
"branding_tab",
"brand_name",
"brand_logo",
@ -28,7 +29,7 @@
{
"fieldname": "defaults_tab",
"fieldtype": "Tab Break",
"label": "Defaults"
"label": "Settings"
},
{
"fieldname": "branding_tab",
@ -56,12 +57,19 @@
"fieldname": "favicon",
"fieldtype": "Attach",
"label": "Favicon"
},
{
"default": "0",
"description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights",
"fieldname": "enable_forecasting",
"fieldtype": "Check",
"label": "Enable Forecasting"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-02-20 12:38:38.088477",
"modified": "2025-07-01 13:20:48.757603",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Settings",
@ -95,7 +103,8 @@
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
from frappe.model.document import Document
from crm.install import after_install
@ -15,6 +16,7 @@ class FCRMSettings(Document):
def validate(self):
self.do_not_allow_to_delete_if_standard()
self.setup_forecasting()
def do_not_allow_to_delete_if_standard(self):
if not self.has_value_changed("dropdown_items"):
@ -24,8 +26,43 @@ 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 setup_forecasting(self):
if self.has_value_changed("enable_forecasting"):
if not self.enable_forecasting:
delete_property_setter(
"CRM Deal",
"reqd",
"close_date",
)
delete_property_setter(
"CRM Deal",
"reqd",
"deal_value",
)
else:
make_property_setter(
"CRM Deal",
"close_date",
"reqd",
1 if self.enable_forecasting else 0,
"Check",
)
make_property_setter(
"CRM Deal",
"deal_value",
"reqd",
1 if self.enable_forecasting else 0,
"Check",
)
def get_standard_dropdown_items():
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
def after_migrate():
@ -51,3 +88,36 @@ def sync_table(key, hook):
crm_settings.set(key, items)
crm_settings.save()
def create_forecasting_script():
if not frappe.db.exists("CRM Form Script", "Forecasting Script"):
script = get_forecasting_script()
frappe.get_doc(
{
"doctype": "CRM Form Script",
"name": "Forecasting Script",
"dt": "CRM Deal",
"view": "Form",
"script": script,
"enabled": 1,
"is_standard": 1,
}
).insert()
def get_forecasting_script():
return """class CRMDeal {
async status() {
await this.doc.trigger('updateProbability')
}
async updateProbability() {
let status = await call("frappe.client.get_value", {
doctype: "CRM Deal Status",
fieldname: "probability",
filters: { name: this.doc.status },
})
this.doc.probability = status.probability
}
}"""

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
@ -18,7 +20,9 @@ def after_install(force=False):
add_email_template_custom_fields()
add_default_industries()
add_default_lead_sources()
add_default_lost_reasons()
add_standard_dropdown_items()
add_default_scripts()
frappe.db.commit()
@ -65,30 +69,37 @@ def add_default_deal_statuses():
statuses = {
"Qualification": {
"color": "gray",
"probability": 10,
"position": 1,
},
"Demo/Making": {
"color": "orange",
"probability": 25,
"position": 2,
},
"Proposal/Quotation": {
"color": "blue",
"probability": 50,
"position": 3,
},
"Negotiation": {
"color": "yellow",
"probability": 70,
"position": 4,
},
"Ready to Close": {
"color": "purple",
"probability": 90,
"position": 5,
},
"Won": {
"color": "green",
"probability": 100,
"position": 6,
},
"Lost": {
"color": "red",
"probability": 0,
"position": 7,
},
}
@ -100,6 +111,7 @@ def add_default_deal_statuses():
doc = frappe.new_doc("CRM Deal Status")
doc.deal_status = status
doc.color = statuses[status]["color"]
doc.probability = statuses[status]["probability"]
doc.position = statuses[status]["position"]
doc.insert()
@ -340,6 +352,44 @@ def add_default_lead_sources():
doc.insert()
def add_default_lost_reasons():
lost_reasons = [
{
"reason": "Pricing",
"description": "The prospect found the pricing to be too high or not competitive.",
},
{"reason": "Competition", "description": "The prospect chose a competitor's product or service."},
{
"reason": "Budget Constraints",
"description": "The prospect did not have the budget to proceed with the purchase.",
},
{
"reason": "Missing Features",
"description": "The prospect felt that the product or service was missing key features they needed.",
},
{
"reason": "Long Sales Cycle",
"description": "The sales process took too long, leading to loss of interest.",
},
{
"reason": "No Decision-Maker",
"description": "The prospect was not the decision-maker and could not proceed.",
},
{"reason": "Unresponsive Prospect", "description": "The prospect did not respond to follow-ups."},
{"reason": "Poor Fit", "description": "The prospect was not a good fit for the product or service."},
{"reason": "Other", "description": ""},
]
for reason in lost_reasons:
if frappe.db.exists("CRM Lost Reason", reason["reason"]):
continue
doc = frappe.new_doc("CRM Lost Reason")
doc.lost_reason = reason["reason"]
doc.description = reason["description"]
doc.insert()
def add_standard_dropdown_items():
crm_settings = frappe.get_single("FCRM Settings")
@ -353,3 +403,11 @@ def add_standard_dropdown_items():
crm_settings.append("dropdown_items", item)
crm_settings.save()
def add_default_scripts():
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import create_forecasting_script
for doctype in ["CRM Lead", "CRM Deal"]:
create_product_details_script(doctype)
create_forecasting_script()

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,6 @@ 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 # 13-06-2025
crm.patches.v1_0.update_deal_status_probabilities

View File

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

View File

@ -0,0 +1,24 @@
import frappe
def execute():
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "probability", "deal_status"])
for status in deal_statuses:
if status.probability is None or status.probability == 0:
if status.deal_status == "Qualification":
probability = 10
elif status.deal_status == "Demo/Making":
probability = 25
elif status.deal_status == "Proposal/Quotation":
probability = 50
elif status.deal_status == "Negotiation":
probability = 70
elif status.deal_status == "Ready to Close":
probability = 90
elif status.deal_status == "Won":
probability = 100
else:
probability = 0
frappe.db.set_value("CRM Deal Status", status.name, "probability", probability)

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,7 +1,10 @@
from frappe import frappe
import phonenumbers
from frappe.utils import floor
from phonenumbers import NumberParseException
from phonenumbers import PhoneNumberFormat as PNF
from frappe.model.docstatus import DocStatus
from frappe.model.dynamic_links import get_dynamic_link_map
def parse_phone_number(phone_number, default_country="IN"):
@ -93,3 +96,129 @@ def seconds_to_duration(seconds):
return f"{seconds}s"
else:
return "0s"
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked
def get_linked_docs(doc, method="Delete"):
from frappe.model.rename_doc import get_link_fields
link_fields = get_link_fields(doc.doctype)
ignored_doctypes = set()
if method == "Cancel" and (doc_ignore_flags := doc.get("ignore_linked_doctypes")):
ignored_doctypes.update(doc_ignore_flags)
if method == "Delete":
ignored_doctypes.update(frappe.get_hooks("ignore_links_on_delete"))
docs = []
for lf in link_fields:
link_dt, link_field, issingle = lf["parent"], lf["fieldname"], lf["issingle"]
if link_dt in ignored_doctypes or (link_field == "amended_from" and method == "Cancel"):
continue
try:
meta = frappe.get_meta(link_dt)
except frappe.DoesNotExistError:
frappe.clear_last_message()
# This mostly happens when app do not remove their customizations, we shouldn't
# prevent link checks from failing in those cases
continue
if issingle:
if frappe.db.get_single_value(link_dt, link_field) == doc.name:
docs.append({"doc": doc.name, "link_dt": link_dt, "link_field": link_field})
continue
fields = ["name", "docstatus"]
if meta.istable:
fields.extend(["parent", "parenttype"])
for item in frappe.db.get_values(link_dt, {link_field: doc.name}, fields, as_dict=True):
# available only in child table cases
item_parent = getattr(item, "parent", None)
linked_parent_doctype = item.parenttype if item_parent else link_dt
if linked_parent_doctype in ignored_doctypes:
continue
if method != "Delete" and (method != "Cancel" or not DocStatus(item.docstatus).is_submitted()):
# don't raise exception if not
# linked to a non-cancelled doc when deleting or to a submitted doc when cancelling
continue
elif link_dt == doc.doctype and (item_parent or item.name) == doc.name:
# don't raise exception if not
# linked to same item or doc having same name as the item
continue
else:
reference_docname = item_parent or item.name
docs.append(
{
"doc": doc.name,
"reference_doctype": linked_parent_doctype,
"reference_docname": reference_docname,
}
)
return docs
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked
def get_dynamic_linked_docs(doc, method="Delete"):
docs = []
for df in get_dynamic_link_map().get(doc.doctype, []):
ignore_linked_doctypes = doc.get("ignore_linked_doctypes") or []
if df.parent in frappe.get_hooks("ignore_links_on_delete") or (
df.parent in ignore_linked_doctypes and method == "Cancel"
):
# don't check for communication and todo!
continue
meta = frappe.get_meta(df.parent)
if meta.issingle:
# dynamic link in single doc
refdoc = frappe.db.get_singles_dict(df.parent)
if (
refdoc.get(df.options) == doc.doctype
and refdoc.get(df.fieldname) == doc.name
and (
# linked to an non-cancelled doc when deleting
(method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled())
# linked to a submitted doc when cancelling
or (method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted())
)
):
docs.append({"doc": doc.name, "reference_doctype": df.parent, "reference_docname": df.parent})
else:
# dynamic link in table
df["table"] = ", `parent`, `parenttype`, `idx`" if meta.istable else ""
for refdoc in frappe.db.sql(
"""select `name`, `docstatus` {table} from `tab{parent}` where
`{options}`=%s and `{fieldname}`=%s""".format(**df),
(doc.doctype, doc.name),
as_dict=True,
):
# linked to an non-cancelled doc when deleting
# or linked to a submitted doc when cancelling
if (method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled()) or (
method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted()
):
reference_doctype = refdoc.parenttype if meta.istable else df.parent
reference_docname = refdoc.parent if meta.istable else refdoc.name
if reference_doctype in frappe.get_hooks("ignore_links_on_delete") or (
reference_doctype in ignore_linked_doctypes and method == "Cancel"
):
# don't check for communication and todo!
continue
at_position = f"at Row: {refdoc.idx}" if meta.istable else ""
docs.append(
{
"doc": doc.name,
"reference_doctype": reference_doctype,
"reference_docname": reference_docname,
"at_position": at_position,
}
)
return docs

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
@ -49,3 +51,15 @@ def get_boot():
def get_default_route():
return "/crm"
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 424288f77af4779dd3bb71dc3d278fc627f95179

View File

@ -8,9 +8,11 @@ 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']
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
AllModals: typeof import('./src/components/Activities/AllModals.vue')['default']
@ -29,6 +31,7 @@ declare module 'vue' {
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
@ -37,6 +40,7 @@ declare module 'vue' {
CallUI: typeof import('./src/components/Telephony/CallUI.vue')['default']
CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default']
CertificateIcon: typeof import('./src/components/Icons/CertificateIcon.vue')['default']
ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default']
CheckCircleIcon: typeof import('./src/components/Icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
@ -52,7 +56,9 @@ declare module 'vue' {
ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default']
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
ConvertToDealModal: typeof import('./src/components/Modals/ConvertToDealModal.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']
@ -62,6 +68,7 @@ declare module 'vue' {
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default']
DeleteLinkedDocModal: typeof import('./src/components/DeleteLinkedDocModal.vue')['default']
DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default']
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default']
@ -75,19 +82,30 @@ declare module 'vue' {
DropdownItem: typeof import('./src/components/DropdownItem.vue')['default']
DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default']
DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default']
EditEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/EditEmailTemplate.vue')['default']
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']
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.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 +124,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']
@ -125,21 +145,29 @@ declare module 'vue' {
InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
InviteMemberPage: typeof import('./src/components/Settings/InviteMemberPage.vue')['default']
InviteUserPage: typeof import('./src/components/Settings/InviteUserPage.vue')['default']
KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default']
KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default']
KanbanView: typeof import('./src/components/Kanban/KanbanView.vue')['default']
KeyboardShortcut: typeof import('./src/components/KeyboardShortcut.vue')['default']
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default']
LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default']
LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default']
LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default']
LinkedDocsListView: typeof import('./src/components/ListViews/LinkedDocsListView.vue')['default']
LinkIcon: typeof import('./src/components/Icons/LinkIcon.vue')['default']
ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default']
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']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']
LucideSearch: typeof import('~icons/lucide/search')['default']
LucideX: typeof import('~icons/lucide/x')['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,10 +177,13 @@ 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']
MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default']
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
NestedPopover: typeof import('./src/components/NestedPopover.vue')['default']
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.vue')['default']
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default']
@ -162,6 +193,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 +228,7 @@ declare module 'vue' {
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
SortBy: typeof import('./src/components/SortBy.vue')['default']
SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default']
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,12 +239,14 @@ 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']
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
Users: typeof import('./src/components/Settings/Users.vue')['default']
ViewBreadcrumbs: typeof import('./src/components/ViewBreadcrumbs.vue')['default']
ViewControls: typeof import('./src/components/ViewControls.vue')['default']
ViewModal: typeof import('./src/components/Modals/ViewModal.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.166",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

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

View File

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

View File

@ -250,14 +250,14 @@
</span>
<span v-if="activity.type">{{ __(activity.type) }}</span>
<span
v-if="activity.data.field_label"
v-if="activity.data?.field_label"
class="max-w-xs truncate font-medium text-ink-gray-8"
>
{{ __(activity.data.field_label) }}
</span>
<span v-if="activity.value">{{ __(activity.value) }}</span>
<span
v-if="activity.data.old_value"
v-if="activity.data?.old_value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
@ -273,7 +273,7 @@
</span>
<span v-if="activity.to">{{ __('to') }}</span>
<span
v-if="activity.data.value"
v-if="activity.data?.value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
@ -307,7 +307,7 @@
>
<div class="inline-flex flex-wrap gap-1 text-ink-gray-5">
<span
v-if="activity.data.field_label"
v-if="activity.data?.field_label"
class="max-w-xs truncate text-ink-gray-5"
>
{{ __(activity.data.field_label) }}
@ -320,7 +320,7 @@
{{ startCase(__(activity.type)) }}
</span>
<span
v-if="activity.data.old_value"
v-if="activity.data?.old_value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
@ -336,7 +336,7 @@
</span>
<span v-if="activity.to">{{ __('to') }}</span>
<span
v-if="activity.data.value"
v-if="activity.data?.value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
@ -365,7 +365,12 @@
</div>
</div>
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
<DataFields :doctype="doctype" :docname="doc.data.name" />
<DataFields
:doctype="doctype"
:docname="doc.data.name"
@beforeSave="(data) => emit('beforeSave', data)"
@afterSave="(data) => emit('afterSave', data)"
/>
</div>
<div
v-else
@ -373,11 +378,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 +471,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 +489,7 @@ import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import { timeAgo, formatDate, startCase } from '@/utils'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { whatsappEnabled } from '@/composables/settings'
import { whatsappEnabled, callEnabled } from '@/composables/settings'
import { capture } from '@/telemetry'
import { Button, Tooltip, createResource } from 'frappe-ui'
import { useElementVisibility } from '@vueuse/core'
@ -517,6 +519,8 @@ const props = defineProps({
},
})
const emit = defineEmits(['beforeSave', 'afterSave'])
const route = useRoute()
const doc = defineModel()
@ -785,5 +789,23 @@ function scroll(hash) {
}, 500)
}
defineExpose({ emailBox, all_activities })
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, changeTabTo })
</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,18 @@
:doc="doc.data?.name"
@after="redirect('notes')"
/>
<CallLogModal
v-if="showCallLogModal"
v-model="showCallLogModal"
:data="callLog"
:referenceDoc="referenceDoc"
: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 +85,22 @@ function showNote(n) {
showNoteModal.value = true
}
// Call Logs
const showCallLogModal = ref(false)
const callLog = ref({})
const referenceDoc = ref({})
function createCallLog() {
let doctype = props.doctype
let docname = props.doc.data?.name
referenceDoc.value = { ...props.doc.data }
callLog.value = {
reference_doctype: doctype,
reference_docname: docname,
}
showCallLogModal.value = true
}
// common
const route = useRoute()
const router = useRouter()
@ -95,5 +119,6 @@ defineExpose({
deleteTask,
updateTaskStatus,
showNote,
createCallLog,
})
</script>

View File

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

View File

@ -5,7 +5,7 @@
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
{{ __('Data') }}
<Badge
v-if="data.isDirty"
v-if="document.isDirty"
class="ml-3"
:label="'Not Saved'"
theme="orange"
@ -16,20 +16,22 @@
v-if="isManager() && !isMobileView"
@click="showDataFieldsModal = true"
>
<EditIcon class="h-4 w-4" />
<template #icon>
<EditIcon />
</template>
</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"
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
v-if="document.get.loading"
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-6"
>
<LoadingIndicator class="h-6 w-6" />
<span>{{ __('Loading...') }}</span>
@ -38,7 +40,7 @@
<FieldLayout
v-if="tabs.data"
:tabs="tabs.data"
:data="data.doc"
:data="document.doc"
:doctype="doctype"
/>
</div>
@ -49,7 +51,7 @@
@reload="
() => {
tabs.reload()
data.reload()
document.reload()
}
"
/>
@ -59,12 +61,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, getCurrentInstance } from 'vue'
const props = defineProps({
doctype: {
@ -77,32 +79,16 @@ const props = defineProps({
},
})
const emit = defineEmits(['beforeSave', 'afterSave'])
const { isManager } = usersStore()
const instance = getCurrentInstance()
const attrs = instance?.vnode?.props ?? {}
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 +98,42 @@ const tabs = createResource({
})
function saveChanges() {
data.save.submit()
if (!document.isDirty) return
const updatedDoc = { ...document.doc }
const oldDoc = { ...document.originalDoc }
const changes = Object.keys(updatedDoc).reduce((acc, key) => {
if (JSON.stringify(updatedDoc[key]) !== JSON.stringify(oldDoc[key])) {
acc[key] = updatedDoc[key]
}
return acc
}, {})
const hasListener = attrs['onBeforeSave'] !== undefined
if (hasListener) {
emit('beforeSave', changes)
} else {
document.save.submit(null, {
onSuccess: () => emit('afterSave', changes),
})
}
}
watch(
() => document.doc,
(newValue, oldValue) => {
if (!oldValue) return
if (newValue && oldValue) {
const isDirty =
JSON.stringify(newValue) !== JSON.stringify(document.originalDoc)
document.isDirty = isDirty
if (isDirty) {
document.save.loading = false
}
}
},
{ deep: true },
)
</script>

View File

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

View File

@ -0,0 +1,154 @@
<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-6 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{ __('Delete') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" icon="x" @click="show = false" />
</div>
</div>
<div>
<div class="text-ink-gray-5">
{{
__('Are you sure you want to delete {0} items?', [
props.items?.length,
])
}}
</div>
</div>
</div>
<div class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
:label="__('Delete {0} items', [props.items.length])"
icon-left="trash-2"
variant="solid"
theme="red"
@click="confirmDelete()"
/>
<Button
:label="__('Unlink and delete {0} items', [props.items.length])"
icon-left="unlock"
variant="solid"
@click="confirmUnlink()"
/>
</div>
</div>
</template>
<template #body v-if="confirmDeleteInfo.show">
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{ __('Delete') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" icon="x" @click="show = false" />
</div>
</div>
<div>
<div class="text-ink-gray-5">
{{
confirmDeleteInfo.delete
? __(
'This will delete selected items and items linked to it, are you sure?',
)
: __(
'This will delete selected items and unlink linked items to it, are you sure?',
)
}}
</div>
</div>
</div>
<div class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
:label="
confirmDeleteInfo.delete ? __('Delete') : __('Unlink and delete')
"
:icon-left="confirmDeleteInfo.delete ? 'trash-2' : 'unlock'"
variant="solid"
theme="red"
@click="deleteDocs()"
/>
<Button
:label="__('Cancel')"
variant="subtle"
@click="confirmDeleteInfo.show = false"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { call } from 'frappe-ui'
import { ref } from 'vue'
const show = defineModel()
const props = defineProps({
doctype: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
reload: {
type: Function,
required: true,
},
})
const confirmDeleteInfo = ref({
show: false,
title: '',
message: '',
delete: false,
})
const confirmDelete = () => {
confirmDeleteInfo.value = {
show: true,
title: __('Delete'),
message: __('Are you sure you want to delete {0} linked doc(s)?', [
props.items.length,
]),
delete: true,
}
}
const confirmUnlink = () => {
confirmDeleteInfo.value = {
show: true,
title: __('Unlink'),
message: __('Are you sure you want to unlink {0} linked doc(s)?', [
props.items.length,
]),
delete: false,
}
}
const deleteDocs = () => {
call('crm.api.doc.delete_bulk_docs', {
items: props.items,
doctype: props.doctype,
delete_linked: confirmDeleteInfo.value.delete,
}).then(() => {
confirmDeleteInfo.value = {
show: false,
title: '',
}
show.value = false
props.reload()
})
}
</script>

View File

@ -30,20 +30,24 @@
<DragIcon class="h-3.5" />
<div>{{ __(element.label) }}</div>
</div>
<div class="flex cursor-pointer items-center gap-1">
<div class="flex cursor-pointer items-center gap-0.5">
<Button
variant="ghost"
class="!h-5 w-5 !p-1"
@click="editColumn(element)"
>
<EditIcon class="h-3.5" />
<template #icon>
<EditIcon class="h-3.5" />
</template>
</Button>
<Button
variant="ghost"
class="!h-5 w-5 !p-1"
@click="removeColumn(element)"
>
<FeatherIcon name="x" class="h-3.5" />
<template #icon>
<FeatherIcon name="x" class="h-3.5" />
</template>
</Button>
</div>
</div>
@ -215,7 +219,9 @@ const fields = computed(() => {
})
function addColumn(c) {
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type) ? 'right' : 'left'
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type)
? 'right'
: 'left'
let _column = {
label: c.label,
type: c.fieldtype,

View File

@ -149,7 +149,7 @@ function removeAttachment(attachment) {
const users = computed(() => {
return (
usersList.data
usersList.data?.crmUsers
?.filter((user) => user.enabled)
.map((user) => ({
label: user.full_name.trimEnd(),

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">
@ -45,7 +58,9 @@
variant="outline"
@click="showGridFieldsEditorModal = true"
>
<FeatherIcon name="settings" class="h-4 w-4 text-ink-gray-7" />
<template #icon>
<FeatherIcon name="settings" class="size-4 text-ink-gray-7" />
</template>
</Button>
</div>
</div>
@ -93,18 +108,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 +146,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 +176,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 +206,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 +216,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 +273,7 @@
variant="outline"
v-model="row[field.fieldname]"
:options="field.options"
@change="fieldChange($event.target.value, field, row)"
/>
</div>
</div>
@ -201,7 +283,9 @@
variant="outline"
@click="showRowList[index] = true"
>
<EditIcon class="h-4 w-4 text-ink-gray-7" />
<template #icon>
<EditIcon class="text-ink-gray-7" />
</template>
</Button>
</div>
<GridRowModal
@ -252,6 +336,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 +345,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 +356,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 +374,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 { users, 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 +422,30 @@ 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)
}
}
}
if (field.fieldtype === 'Link' && field.options === 'User') {
field.fieldtype = 'User'
field.link_filters = JSON.stringify({
...(field.link_filters ? JSON.parse(field.link_filters) : {}),
name: ['in', users.data.crmUsers?.map((user) => user.name)],
})
}
return {
...field,
filters: field.link_filters && JSON.parse(field.link_filters),
@ -361,21 +490,70 @@ 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) {
triggerOnChange(field.fieldname, value, 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

@ -15,15 +15,25 @@
class="w-7"
@click="openGridRowFieldsModal"
>
<EditIcon class="h-4 w-4" />
<template #icon>
<EditIcon />
</template>
</Button>
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
<template #icon>
<FeatherIcon name="x" class="size-4" />
</template>
</Button>
</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 invite')
}}
</div>
<ComboboxOption
v-for="option in options"
:key="option.value"
@ -137,6 +152,14 @@ const props = defineProps({
type: Function,
default: (value) => `${value} is an Invalid value`,
},
fetchContacts: {
type: Boolean,
default: true,
},
existingEmails: {
type: Array,
default: () => [],
},
})
const values = defineModel()
@ -186,22 +209,32 @@ const filterOptions = createResource({
value: email,
}
})
// Filter out existing emails
if (props.existingEmails?.length) {
allData = allData.filter((option) => {
return !props.existingEmails.includes(option.value)
})
}
return allData
},
})
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,278 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:class="{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
:class="[
variant == 'ghost'
? 'bg-surface-white hover:bg-surface-white'
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
inputClass,
]"
:placeholder="placeholder"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<ComboboxOptions
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="fetchUsers"
name="search"
class="h-4"
/>
{{
fetchUsers
? __('No results found')
: __('Type an email address to invite')
}}
</div>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-3': active },
]"
>
<UserAvatar
class="mr-2"
:user="option.value"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ option.label }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
>
{{ info }}
</div>
</div>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Popover from '@/components/frappe-ui/Popover.vue'
import { usersStore } from '@/stores/users'
import { ref, computed, nextTick } from 'vue'
const props = defineProps({
validate: {
type: Function,
default: null,
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: '',
},
inputClass: {
type: String,
default: '',
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
fetchUsers: {
type: Boolean,
default: true,
},
existingEmails: {
type: Array,
default: () => [],
},
})
const values = defineModel()
const { users } = usersStore()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const info = ref(null)
const query = ref('')
const showOptions = ref(false)
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
val?.value && addValue(val.value)
},
})
const options = computed(() => {
let userEmails = props.fetchUsers ? users?.data?.allUsers : []
if (props.fetchUsers) {
userEmails = userEmails.map((user) => ({
label: user.full_name || user.name || user.email,
value: user.email,
}))
if (props.existingEmails?.length) {
userEmails = userEmails.filter((option) => {
return !props.existingEmails.includes(option.value)
})
}
if (query.value) {
userEmails = userEmails.filter(
(option) =>
option.label.toLowerCase().includes(query.value.toLowerCase()) ||
option.value.toLowerCase().includes(query.value.toLowerCase()),
)
}
} else if (!userEmails?.length && query.value) {
userEmails.push({
label: query.value,
value: query.value,
})
}
return userEmails || []
})
const addValue = (value) => {
error.value = null
info.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
query.value = value
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
} else {
info.value = __('email already exists')
}
}
})
!error.value && (value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
</script>

View File

@ -0,0 +1,62 @@
<template>
<FormControl
:type="show ? 'text' : 'password'"
:value="modelValue || value"
v-bind="$attrs"
@keydown.meta.i.prevent="show = !show"
@keydown.ctrl.i.prevent="show = !show"
>
<template #prefix v-if="$slots.prefix">
<slot name="prefix" />
</template>
<template #suffix>
<Tooltip>
<template #body>
<div
class="rounded bg-surface-gray-7 py-1.5 px-2 text-xs text-ink-white shadow-xl"
>
<span class="flex items-center gap-1">
{{ show ? __('Hide Password') : __('Show Password') }}
<KeyboardShortcut
bg
ctrl
class="!bg-surface-gray-5 !text-ink-gray-2 px-1"
>
<span class="font-mono leading-none tracking-widest">+I</span>
</KeyboardShortcut>
</span>
</div>
</template>
<div>
<FeatherIcon
v-show="showEye"
:name="show ? 'eye-off' : 'eye'"
class="h-3 cursor-pointer mr-1"
@click="show = !show"
/>
</div>
</Tooltip>
</template>
</FormControl>
</template>
<script setup>
import KeyboardShortcut from '@/components/KeyboardShortcut.vue'
import { FormControl, Tooltip } 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,265 @@
<template>
<Dialog v-model="show" :options="{ size: 'xl' }">
<template #body v-if="!confirmDeleteInfo.show">
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{
linkedDocs?.length == 0
? __('Delete')
: __('Delete or unlink linked documents')
}}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" icon="x" @click="show = false" />
</div>
</div>
<div>
<div v-if="linkedDocs?.length > 0">
<span class="text-ink-gray-5 text-base">
{{
__(
'Delete or unlink these linked documents before deleting this document',
)
}}
</span>
<LinkedDocsListView
class="mt-4"
:rows="linkedDocs"
:columns="[
{
label: 'Document',
key: 'title',
},
{
label: 'Master',
key: 'reference_doctype',
width: '30%',
},
]"
@selectionsChanged="
(selections) => viewControls.updateSelections(selections)
"
:linkedDocsResource="linkedDocsResource"
:unlinkLinkedDoc="unlinkLinkedDoc"
/>
</div>
<div v-if="linkedDocs?.length == 0" class="text-ink-gray-5 text-base">
{{
__('Are you sure you want to delete {0} - {1}?', [
props.doctype,
props.docname,
])
}}
</div>
</div>
</div>
<div class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
v-if="linkedDocs?.length > 0"
:label="
viewControls?.selections?.length == 0
? __('Delete all')
: __('Delete {0} item(s)', [viewControls?.selections?.length])
"
theme="red"
variant="solid"
icon-left="trash-2"
@click="confirmDelete()"
/>
<Button
v-if="linkedDocs?.length > 0"
:label="
viewControls?.selections?.length == 0
? __('Unlink all')
: __('Unlink {0} item(s)', [viewControls?.selections?.length])
"
variant="subtle"
theme="gray"
icon-left="unlock"
@click="confirmUnlink()"
/>
<Button
v-if="linkedDocs?.length == 0"
variant="solid"
icon-left="trash-2"
:label="__('Delete')"
:loading="isDealCreating"
@click="deleteDoc()"
theme="red"
/>
</div>
</div>
</template>
<template #body v-if="confirmDeleteInfo.show">
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{ confirmDeleteInfo.title }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" icon="x" @click="show = false" />
</div>
</div>
<div class="text-ink-gray-5 text-base">
{{ confirmDeleteInfo.message }}
</div>
<div class="flex justify-end gap-2 mt-6">
<Button variant="ghost" @click="cancel()">
{{ __('Cancel') }}
</Button>
<Button
variant="solid"
:label="confirmDeleteInfo.title"
@click="removeDocLinks()"
theme="red"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { createResource, call } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, ref } from 'vue'
const show = defineModel()
const router = useRouter()
const props = defineProps({
name: {
type: String,
required: true,
},
doctype: {
type: String,
required: true,
},
docname: {
type: String,
required: true,
},
reload: {
type: Function,
},
})
const viewControls = ref({
selections: [],
updateSelections: (selections) => {
viewControls.value.selections = Array.from(selections || [])
},
})
const confirmDeleteInfo = ref({
show: false,
title: '',
})
const linkedDocsResource = createResource({
url: 'crm.api.doc.get_linked_docs_of_document',
params: {
doctype: props.doctype,
docname: props.docname,
},
auto: true,
validate(params) {
if (!params?.doctype || !params?.docname) {
return false
}
},
})
const linkedDocs = computed(() => {
return (
linkedDocsResource.data?.map((doc) => ({
id: doc.reference_docname,
...doc,
})) || []
)
})
const cancel = () => {
confirmDeleteInfo.value.show = false
viewControls.value.updateSelections([])
}
const unlinkLinkedDoc = (doc) => {
let selectedDocs = []
if (viewControls.value.selections.length > 0) {
Array.from(viewControls.value.selections).forEach((selection) => {
const docData = linkedDocs.value.find((d) => d.id == selection)
selectedDocs.push({
doctype: docData.reference_doctype,
docname: docData.reference_docname,
})
})
} else {
selectedDocs = linkedDocs.value.map((doc) => ({
doctype: doc.reference_doctype,
docname: doc.reference_docname,
}))
}
call('crm.api.doc.remove_linked_doc_reference', {
items: selectedDocs,
remove_contact: props.doctype == 'Contact',
delete: doc.delete,
}).then(() => {
linkedDocsResource.reload()
confirmDeleteInfo.value = {
show: false,
title: '',
}
})
}
const confirmDelete = () => {
const items =
viewControls.value.selections.length == 0
? 'all'
: viewControls.value.selections.length
confirmDeleteInfo.value = {
show: true,
title: __('Delete linked item'),
message: __('Are you sure you want to delete {0} linked item(s)?', [items]),
delete: true,
}
}
const confirmUnlink = () => {
const items =
viewControls.value.selections.length == 0
? 'all'
: viewControls.value.selections.length
confirmDeleteInfo.value = {
show: true,
title: __('Unlink linked item'),
message: __('Are you sure you want to unlink {0} linked item(s)?', [items]),
delete: false,
}
}
const removeDocLinks = () => {
unlinkLinkedDoc({
reference_doctype: props.doctype,
reference_docname: props.docname,
delete: confirmDeleteInfo.value.delete,
})
viewControls.value.updateSelections([])
}
const deleteDoc = async () => {
await call('frappe.client.delete', {
doctype: props.doctype,
name: props.docname,
})
router.push({ name: props.name })
props?.reload?.()
}
</script>

View File

@ -31,7 +31,9 @@
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
@click="option.onClick"
>
<SuccessIcon />
<template #icon>
<SuccessIcon />
</template>
</Button>
</div>
</Tooltip>
@ -43,7 +45,9 @@
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
@click="toggleEditMode"
>
<EditIcon />
<template #icon>
<EditIcon />
</template>
</Button>
</div>
</Tooltip>

View File

@ -150,7 +150,7 @@
@click="showEmailTemplateSelectorModal = true"
>
<template #icon>
<Email2Icon class="h-4" />
<EmailTemplateIcon class="h-4" />
</template>
</Button>
</div>
@ -176,7 +176,7 @@
<script setup>
import IconPicker from '@/components/IconPicker.vue'
import SmileIcon from '@/components/Icons/SmileIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import AttachmentItem from '@/components/AttachmentItem.vue'
import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'

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,10 +238,31 @@ 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()
const { users, 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
@ -219,6 +278,21 @@ const field = computed(() => {
if (field.fieldtype === 'Link' && field.options === 'User') {
field.fieldtype = 'User'
field.link_filters = JSON.stringify({
...(field.link_filters ? JSON.parse(field.link_filters) : {}),
name: ['in', users.data.crmUsers?.map((user) => user.name)],
})
}
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 = {
@ -260,6 +334,21 @@ const getPlaceholder = (field) => {
return __('Enter {0}', [__(field.label)])
}
}
function fieldChange(value, df) {
if (isGridRow) {
triggerOnChange(df.fieldname, value, data.value)
} else {
triggerOnChange(df.fieldname, value)
}
}
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,30 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1191_1930)">
<path
d="M1.45001 12.1V12.1C1.45001 11.7869 1.60364 11.4936 1.86111 11.3154L6.87595 7.84359C7.52855 7.39178 8.38658 7.36866 9.06257 7.78465L13.5984 10.5759C14.1276 10.9016 14.45 11.4786 14.45 12.1V12.1"
stroke="currentColor"
/>
<path
d="M14.45 7.60001L11.95 9.60001M4.45001 9.60001L1.45001 7.60001"
stroke="currentColor"
/>
<path
d="M4 9V3C4 2.44772 4.44772 2 5 2H11C11.5523 2 12 2.44772 12 3V9"
stroke="currentColor"
/>
<path
d="M4 4.49999L2.1786 6C1.71727 6.37992 1.45002 6.94623 1.45002 7.54385L1.45002 12.1C1.45002 13.2046 2.34545 14.1 3.45002 14.1L12.45 14.1C13.5546 14.1 14.45 13.2046 14.45 12.1V7.51988C14.45 6.93603 14.1949 6.38133 13.7516 6.00137L12 4.5"
stroke="currentColor"
/>
<path d="M6 6H10" stroke="currentColor" stroke-linecap="round" />
<path d="M6 4H9" stroke="currentColor" stroke-linecap="round" />
</g>
</svg>
</template>

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

@ -0,0 +1,33 @@
<template>
<div
class="inline-flex items-center gap-0.5 text-sm"
:class="{
'bg-surface-gray-2 rounded-sm text-ink-gray-5 py-0.5 px-1': bg,
'text-ink-gray-4': !bg,
}"
>
<span v-if="ctrl || meta">
<LucideCommand v-if="isMac" class="w-3 h-3" />
<span v-else>Ctrl</span>
</span>
<span v-if="shift"><LucideShift class="w-3 h-3" /></span>
<span v-if="alt"><LucideAlt class="w-3 h-3" /></span>
<slot></slot>
</div>
</template>
<script setup>
import LucideCommand from '~icons/lucide/command'
import LucideShift from '~icons/lucide/arrow-big-up'
import LucideAlt from '~icons/lucide/option'
const isMac = navigator.userAgent.includes('Mac')
defineProps({
meta: Boolean,
ctrl: Boolean,
shift: Boolean,
alt: Boolean,
shortcut: String,
bg: Boolean,
})
</script>

View File

@ -147,9 +147,9 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import StepsIcon from '@/components/Icons/StepsIcon.vue'
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,7 +168,10 @@ import {
unreadNotificationsCount,
notificationsStore,
} from '@/stores/notifications'
import { usersStore } from '@/stores/users'
import { sessionStore } from '@/stores/session'
import { showSettings, activeSettingsPage } from '@/composables/settings'
import { showChangePasswordModal } from '@/composables/modals'
import { FeatherIcon, call } from 'frappe-ui'
import {
SignupBanner,
@ -229,11 +232,6 @@ const links = [
icon: PhoneIcon,
to: 'Call Logs',
},
{
label: 'Email Templates',
icon: Email2Icon,
to: 'Email Templates',
},
]
const allViews = computed(() => {
@ -299,16 +297,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 +317,16 @@ 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
showChangePasswordModal.value = true
},
},
{
name: 'create_first_lead',
title: __('Create your first lead'),
@ -335,14 +345,16 @@ const steps = reactive([
onClick: () => {
minimize.value = true
showSettings.value = true
activeSettingsPage.value = 'Invite Members'
activeSettingsPage.value = 'Invite User'
},
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 +422,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 +443,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 +464,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 +493,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([
@ -497,7 +523,7 @@ const articles = ref([
{ name: 'profile', title: __('Profile') },
{ name: 'custom-branding', title: __('Custom branding') },
{ name: 'home-actions', title: __('Home actions') },
{ name: 'invite-members', title: __('Invite members') },
{ name: 'invite-users', title: __('Invite users') },
],
},
{
@ -517,9 +543,7 @@ const articles = ref([
{
title: __('Capturing leads'),
opened: false,
subArticles: [
{ name: 'web-form', title: __('Web form') },
],
subArticles: [{ name: 'web-form', title: __('Web form') }],
},
{
title: __('Views'),

View File

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

View File

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

View File

@ -14,15 +14,29 @@
:doctype="doctype"
@reload="reload"
/>
<DeleteLinkedDocModal
v-if="showDeleteDocModal.showLinkedDocsModal"
v-model="showDeleteDocModal.showLinkedDocsModal"
:doctype="props.doctype"
:docname="showDeleteDocModal.docname"
:reload="reload"
/>
<BulkDeleteLinkedDocModal
v-if="showDeleteDocModal.showDeleteModal"
v-model="showDeleteDocModal.showDeleteModal"
:doctype="props.doctype"
:items="showDeleteDocModal.items"
:reload="reload"
/>
</template>
<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'
@ -50,7 +64,11 @@ const { $dialog, $socket } = globalStore()
const showEditModal = ref(false)
const selectedValues = ref([])
const unselectAllAction = ref(() => {})
const showDeleteDocModal = ref({
showLinkedDocsModal: false,
showDeleteModal: false,
docname: null,
})
function editValues(selections, unselectAll) {
selectedValues.value = selections
showEditModal.value = true
@ -75,11 +93,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()
@ -92,37 +106,18 @@ function convertToDeal(selections, unselectAll) {
}
function deleteValues(selections, unselectAll) {
$dialog({
title: __('Delete'),
message: __('Are you sure you want to delete {0} item(s)?', [
selections.size,
]),
variant: 'solid',
theme: 'red',
actions: [
{
label: __('Delete'),
variant: 'solid',
theme: 'red',
onClick: (close) => {
capture('bulk_delete')
call('frappe.desk.reportview.delete_items', {
items: JSON.stringify(Array.from(selections)),
doctype: props.doctype,
}).then(() => {
createToast({
title: __('Deleted successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
unselectAll()
list.value.reload()
close()
})
},
},
],
})
const selectedDocs = Array.from(selections)
if (selectedDocs.length == 1) {
showDeleteDocModal.value = {
showLinkedDocsModal: true,
docname: selectedDocs[0],
}
} else {
showDeleteDocModal.value = {
showDeleteModal: true,
items: selectedDocs,
}
}
}
const showAssignmentModal = ref(false)
@ -154,11 +149,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 +206,8 @@ function bulkActions(selections, unselectAll) {
selections,
unselectAll,
call,
createToast,
createToast: toast.create,
toast,
$dialog,
router,
}),
@ -235,7 +227,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

@ -1,224 +0,0 @@
<template>
<ListView
:columns="columns"
:rows="rows"
:options="{
onRowClick: (row) => emit('showEmailTemplate', row.name),
selectable: options.selectable,
showTooltip: options.showTooltip,
resizeColumn: options.resizeColumn,
}"
row-key="name"
>
<ListHeader
class="sm:mx-5 mx-3"
@columnWidthUpdated="emit('columnWidthUpdated')"
>
<ListHeaderItem
v-for="column in columns"
:key="column.key"
:item="column"
@columnWidthUpdated="emit('columnWidthUpdated', column)"
>
<Button
v-if="column.key == '_liked_by'"
variant="ghosted"
class="!h-4"
:class="isLikeFilterApplied ? 'fill-red-500' : 'fill-white'"
@click="() => emit('applyLikeFilter')"
>
<HeartIcon class="h-4 w-4" />
</Button>
</ListHeaderItem>
</ListHeader>
<ListRows
class="mx-3 sm:mx-5"
:rows="rows"
v-slot="{ idx, column, item }"
doctype="Email Template"
>
<ListRowItem :item="item" :align="column.align">
<!-- <template #prefix>
</template> -->
<template #default="{ label }">
<div
v-if="['modified', 'creation'].includes(column.key)"
class="truncate text-base"
@click="
(event) =>
emit('applyFilter', {
event,
idx,
column,
item,
firstColumn: columns[0],
})
"
>
<Tooltip :text="item.label">
<div>{{ item.timeAgo }}</div>
</Tooltip>
</div>
<div v-else-if="column.key === 'status'" class="truncate text-base">
<Badge
:variant="'subtle'"
:theme="item.color"
size="md"
:label="item.label"
@click="
(event) =>
emit('applyFilter', {
event,
idx,
column,
item,
firstColumn: columns[0],
})
"
/>
</div>
<div v-else-if="column.type === 'Check'">
<FormControl
type="checkbox"
:modelValue="item"
:disabled="true"
class="text-ink-gray-9"
/>
</div>
<div v-else-if="column.key === '_liked_by'">
<Button
v-if="column.key == '_liked_by'"
variant="ghosted"
:class="isLiked(item) ? 'fill-red-500' : 'fill-white'"
@click.stop.prevent="
() => emit('likeDoc', { name: row.name, liked: isLiked(item) })
"
>
<HeartIcon class="h-4 w-4" />
</Button>
</div>
<div
v-else
class="truncate text-base"
@click="
(event) =>
emit('applyFilter', {
event,
idx,
column,
item,
firstColumn: columns[0],
})
"
>
{{ label }}
</div>
</template>
</ListRowItem>
</ListRows>
<ListSelectBanner>
<template #actions="{ selections, unselectAll }">
<Dropdown
:options="listBulkActionsRef.bulkActions(selections, unselectAll)"
>
<Button icon="more-horizontal" variant="ghost" />
</Dropdown>
</template>
</ListSelectBanner>
</ListView>
<ListFooter
class="border-t sm:px-5 px-3 py-2"
v-model="pageLengthCount"
:options="{
rowCount: options.rowCount,
totalCount: options.totalCount,
}"
@loadMore="emit('loadMore')"
/>
<ListBulkActions
ref="listBulkActionsRef"
v-model="list"
doctype="Email Template"
:options="{
hideAssign: true,
}"
/>
</template>
<script setup>
import HeartIcon from '@/components/Icons/HeartIcon.vue'
import ListBulkActions from '@/components/ListBulkActions.vue'
import ListRows from '@/components/ListViews/ListRows.vue'
import {
ListView,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRowItem,
ListFooter,
Dropdown,
Tooltip,
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import { ref, computed, watch } from 'vue'
const props = defineProps({
rows: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
options: {
type: Object,
default: () => ({
selectable: true,
showTooltip: true,
resizeColumn: false,
totalCount: 0,
rowCount: 0,
}),
},
})
const emit = defineEmits([
'loadMore',
'updatePageCount',
'showEmailTemplate',
'columnWidthUpdated',
'applyFilter',
'applyLikeFilter',
'likeDoc',
])
const pageLengthCount = defineModel()
const list = defineModel('list')
const isLikeFilterApplied = computed(() => {
return list.value.params?.filters?._liked_by ? true : false
})
const { user } = sessionStore()
function isLiked(item) {
if (item) {
let likedByMe = JSON.parse(item)
return likedByMe.includes(user)
}
}
watch(pageLengthCount, (val, old_value) => {
if (val === old_value) return
emit('updatePageCount', val)
})
const listBulkActionsRef = ref(null)
defineExpose({
customListActions: computed(
() => listBulkActionsRef.value?.customListActions,
),
})
</script>

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"
@ -71,15 +72,6 @@
size="sm"
/>
</div>
<div v-else-if="column.key === 'organization'">
<Avatar
v-if="item"
class="flex items-center"
:image="item"
:label="item"
size="sm"
/>
</div>
<div v-else-if="column.key === 'lead_owner'">
<Avatar
v-if="item.full_name"
@ -250,7 +242,6 @@ const props = defineProps({
}),
},
})
const emit = defineEmits([
'loadMore',
'updatePageCount',
@ -258,6 +249,7 @@ const emit = defineEmits([
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const route = useRoute()

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