Compare commits

...

1370 Commits

Author SHA1 Message Date
RitvikSardana
11f74d65bd Merge pull request #3226 from frappe/main-hotfix
chore(release): hotfix to main
2026-04-22 00:14:31 +05:30
RitvikSardana
4a0c34477a Merge branch 'main' into main-hotfix 2026-04-21 23:52:29 +05:30
RitvikSardana
9821e46012 chore: version bump 2026-04-21 23:50:42 +05:30
RitvikSardana
90e49cab7f Merge pull request #3225 from frappe/mergify/bp/main-hotfix/pr-3223
fix: restrict Helpdesk app visibility by role (backport #3223)
2026-04-21 23:22:21 +05:30
Ritvik Sardana
eaf3923a9d chore: code cleanup
(cherry picked from commit 51824e90ff)
2026-04-21 17:10:27 +00:00
Faris Ansari
fcba625540 fix: restrict helpdesk app visibility by role
(cherry picked from commit d8181027d3)
2026-04-21 17:10:27 +00:00
RitvikSardana
b15016fbd2 Merge pull request #3198 from frappe/mergify/bp/main-hotfix/pr-3065
fix: patch authorization and privilege escalation (backport #3065)
2026-04-14 19:23:47 +05:30
RitvikSardana
b94e46aa1f Merge pull request #3217 from dankgarlic1/fix-mergify-hotfix
chore: auto-format test file using black
2026-04-14 19:18:48 +05:30
Harshit Raizada
9e336dafdc chore: auto-format test file using black 2026-04-14 16:26:55 +05:30
RitvikSardana
0ae782acf0 Merge pull request #3216 from dankgarlic1/fix-mergify-hotfix
fix: test isolation and override in_test bypass for security checks
2026-04-14 15:59:45 +05:30
Harshit Raizada
b2c841ef3d fix: test isolation and override in_test bypass for security checks 2026-04-14 02:25:43 +05:30
RitvikSardana
0fd76cc430 chore: code cleanup 2026-04-13 23:14:55 +05:30
Sydney Gomes
5ce49fb7fb Merge pull request #3200 from frappe/mergify/bp/main-hotfix/pr-3175
fix: truncate text for translations in settings menu (backport #3175)
2026-04-10 18:26:44 +05:30
Sydney
7cc400a3ca fix: remove merge conflicts 2026-04-10 18:17:58 +05:30
RitvikSardana
993245ffa6 chore: resolve conflict 2026-04-08 00:38:19 +05:30
Sydney
f5bb8dd3b4 fix: add translation for tooltip labels
(cherry picked from commit 4b458035f0)

# Conflicts:
#	desk/src/components/Settings/SettingsModal.vue
2026-04-06 10:40:12 +00:00
Sydney
c849132ca1 fix: truncation bugs for mobile and sidebar
(cherry picked from commit c3079c9061)

# Conflicts:
#	desk/src/components/Settings/SettingsModal.vue
2026-04-06 10:40:12 +00:00
Sydney
1a75162626 fix: truncate text for translations in settings menu
(cherry picked from commit 2d583532fd)

# Conflicts:
#	desk/src/components/Settings/SettingsModal.vue
2026-04-06 10:40:12 +00:00
Harshit Raizada
975e7bf04d fix: remove manual override of frappe in_test flag
(cherry picked from commit 2f0689bf5f)
2026-04-06 10:20:06 +00:00
Ritvik Sardana
b77819ee7e chore: remove unwanted code
(cherry picked from commit 22647bbba2)
2026-04-06 10:20:05 +00:00
Harshit Raizada
2594183b05 fix: address reviewer comments
(cherry picked from commit 95bfd47f92)
2026-04-06 10:20:05 +00:00
Harshit Raizada
85ac217596 fix: isort ordering for frappe imports
(cherry picked from commit 03fb98df2f)

# Conflicts:
#	helpdesk/helpdesk/doctype/hd_ticket/test_hd_ticket.py
2026-04-06 10:20:05 +00:00
Harshit Raizada
119a56a94a chore(format): fix linting and formatting for CI
(cherry picked from commit 90a3319825)

# Conflicts:
#	helpdesk/helpdesk/doctype/hd_ticket/test_hd_ticket.py
2026-04-06 10:20:04 +00:00
Harshit Raizada
9fe63f4a25 chore(underscore): removed extra underscore
(cherry picked from commit f339ea98fb)
2026-04-06 10:20:03 +00:00
Harshit Raizada
3978a653e9 chore(leftover): forgot to stage
(cherry picked from commit 738909db35)
2026-04-06 10:20:00 +00:00
Harshit Raizada
ed5f599d9f test(security): add tests for agent role escalation and parameter injection
(cherry picked from commit 3f23513ca6)
2026-04-06 10:19:59 +00:00
Harshit Raizada
ec957e9588 test(hd_ticket): add security tests for unauthorized actions and info disclosure
(cherry picked from commit 345c34a6c6)

# Conflicts:
#	helpdesk/helpdesk/doctype/hd_ticket/test_hd_ticket.py
2026-04-06 10:19:58 +00:00
Harshit Raizada
cd1cde66ea fix(security): patch authorization and privilege escalation vulnerabilities
(cherry picked from commit e79107eec0)
2026-04-06 10:19:58 +00:00
Sydney Gomes
873712c930 Merge pull request #3189 from frappe/mergify/bp/main-hotfix/pr-3178
fix: add debounce to filter component (backport #3178)
2026-04-06 14:27:54 +05:30
Sydney Gomes
f452486493 Merge pull request #3197 from aerodeval/fix-main-code-cleanup
fix: remove git conflicts
2026-04-06 14:25:13 +05:30
Sydney
210fbce8d0 fix: remove git conflicts 2026-04-06 14:13:43 +05:30
Sydney Gomes
d313336b81 Merge pull request #3190 from frappe/mergify/bp/main-hotfix/pr-3176 2026-04-06 13:59:56 +05:30
RitvikSardana
b144e9ac02 Merge pull request #3196 from frappe/mergify/bp/main-hotfix/pr-3187
chore: update POT file (backport #3187)
2026-04-06 13:54:48 +05:30
frappe-pr-bot
526f542259 chore: update POT file
(cherry picked from commit 11bfebe13f)
2026-04-06 08:23:59 +00:00
RitvikSardana
c0d85ff097 Merge pull request #3195 from frappe/mergify/bp/main-hotfix/pr-3171
chore: sync translations from crowdin (backport #3171)
2026-04-06 13:53:26 +05:30
MochaMind
75b67f41ee chore: Bosnian translations
(cherry picked from commit bcb483fe57)
2026-04-06 08:21:14 +00:00
MochaMind
4cf4ba9ff3 chore: Croatian translations
(cherry picked from commit 4669332bc9)
2026-04-06 08:21:13 +00:00
MochaMind
81d2df5be0 chore: Esperanto translations
(cherry picked from commit 4b11cca96b)
2026-04-06 08:21:13 +00:00
MochaMind
6840c29ff8 chore: Russian translations
(cherry picked from commit cf22f8331c)
2026-04-06 08:21:13 +00:00
MochaMind
eb364d4c73 chore: Serbian (Latin) translations
(cherry picked from commit 9c2fcc283e)
2026-04-06 08:21:13 +00:00
MochaMind
5fb59d2078 chore: Norwegian Bokmal translations
(cherry picked from commit cf7d0fbb3e)
2026-04-06 08:21:12 +00:00
MochaMind
c292addab4 chore: Bosnian translations
(cherry picked from commit 6c24921eb6)
2026-04-06 08:21:12 +00:00
MochaMind
a8178aa584 chore: Burmese translations
(cherry picked from commit 491fabdf84)
2026-04-06 08:21:12 +00:00
MochaMind
56f9cab005 chore: Croatian translations
(cherry picked from commit e735f1fc4c)
2026-04-06 08:21:11 +00:00
MochaMind
4b7b6cb7da chore: Thai translations
(cherry picked from commit 615e5166d0)
2026-04-06 08:21:11 +00:00
MochaMind
7dcc094a53 chore: Persian translations
(cherry picked from commit 93bba4b570)
2026-04-06 08:21:11 +00:00
MochaMind
f3fbc16cbc chore: Indonesian translations
(cherry picked from commit a8f836dbcf)
2026-04-06 08:21:10 +00:00
MochaMind
da78a5bac3 chore: Portuguese, Brazilian translations
(cherry picked from commit 839654b343)
2026-04-06 08:21:10 +00:00
MochaMind
3b11e381f6 chore: Vietnamese translations
(cherry picked from commit 06c5b1a48e)
2026-04-06 08:21:10 +00:00
MochaMind
d683c5d9f1 chore: Chinese Simplified translations
(cherry picked from commit 2dfe252029)
2026-04-06 08:21:09 +00:00
MochaMind
b7d52b97b4 chore: Turkish translations
(cherry picked from commit 412ada7775)
2026-04-06 08:21:09 +00:00
MochaMind
b355a514dd chore: Swedish translations
(cherry picked from commit 89996fe94c)
2026-04-06 08:21:09 +00:00
MochaMind
2d46cc1f1e chore: Serbian (Cyrillic) translations
(cherry picked from commit 26a2dca117)
2026-04-06 08:21:09 +00:00
MochaMind
27988dbb61 chore: Slovenian translations
(cherry picked from commit 399bea9c20)
2026-04-06 08:21:08 +00:00
MochaMind
5a260045fd chore: Portuguese translations
(cherry picked from commit 5b64bbec28)
2026-04-06 08:21:08 +00:00
MochaMind
55fc572069 chore: Polish translations
(cherry picked from commit 4cff3ee8e4)
2026-04-06 08:21:08 +00:00
MochaMind
3f33765f55 chore: Dutch translations
(cherry picked from commit d883a09b97)
2026-04-06 08:21:08 +00:00
MochaMind
44e6b09eef chore: Italian translations
(cherry picked from commit d0d9a6ac68)
2026-04-06 08:21:07 +00:00
MochaMind
75c96fd91f chore: Hungarian translations
(cherry picked from commit 573cf539f3)
2026-04-06 08:21:07 +00:00
MochaMind
dc5d0e6128 chore: German translations
(cherry picked from commit 550d5a7886)
2026-04-06 08:21:07 +00:00
MochaMind
53b0e1ebe2 chore: Danish translations
(cherry picked from commit 82dd647877)
2026-04-06 08:21:07 +00:00
MochaMind
dfd111cf82 chore: Czech translations
(cherry picked from commit fef8589dca)
2026-04-06 08:21:06 +00:00
MochaMind
2c4550f243 chore: Arabic translations
(cherry picked from commit 86722702df)
2026-04-06 08:21:06 +00:00
MochaMind
0808eb6817 chore: Spanish translations
(cherry picked from commit c4d69e16a6)
2026-04-06 08:21:06 +00:00
MochaMind
8ab3466232 chore: French translations
(cherry picked from commit fb1fa5d9fa)
2026-04-06 08:21:05 +00:00
RitvikSardana
42f3605899 Merge pull request #3193 from frappe/mergify/bp/main-hotfix/pr-3191
fix: new submodule setup (backport #3191)
2026-04-06 13:39:10 +05:30
RitvikSardana
c72fc2f285 Merge pull request #3194 from frappe/mergify/bp/main-hotfix/pr-3192
fix: iframe click does not close popover (backport #3192)
2026-04-06 13:17:00 +05:30
RitvikSardana
e0fbf2af46 chore: resolve conflict 2026-04-06 13:16:00 +05:30
Ritvik Sardana
df1001b597 fix: iframe click does not close popover
(cherry picked from commit 1716acc427)
2026-04-06 07:43:36 +00:00
Ritvik Sardana
27e694cf44 fix: new submodule setup
(cherry picked from commit b2ae6d8fc0)

# Conflicts:
#	package.json
#	yarn.lock
2026-04-06 07:43:18 +00:00
Sydney
383e5d9ec2 fix: improve sidepanel scrollbar and fix quick filters
(cherry picked from commit 7c3088de2c)

# Conflicts:
#	desk/src/components/view-controls/QuickFilters.vue
#	desk/src/index.css
2026-04-06 07:16:05 +00:00
Sydney
209d27c59e fix: add debounce to filters
(cherry picked from commit a4ede50fa4)
2026-04-06 07:10:41 +00:00
Sydney Gomes
15639edce5 Merge pull request #3173 from frappe/mergify/bp/main-hotfix/pr-3170
fix: display only date & datetime fields in date format in Ticket SIdebar (backport #3170)
2026-04-03 00:19:47 +05:30
Sydney
e872829e9b fix: code cleanup
(cherry picked from commit 02cb007537)
2026-04-02 18:41:02 +00:00
Sydney
b0c6ca0ec5 fix: prevent non-date fields from being parsed as valid dates
(cherry picked from commit 8cc10bcd16)
2026-04-02 18:41:02 +00:00
Sydney
a53cd0fdf8 fix: display only date & datetime fields in date format
(cherry picked from commit e72627aa79)
2026-04-02 18:41:02 +00:00
RitvikSardana
eef7c4d427 Merge pull request #3169 from frappe/mergify/bp/main/pr-3168
chore: bump fui (backport #3168)
2026-04-01 19:13:54 +05:30
Ritvik Sardana
2a4286d418 chore: bump fui
(cherry picked from commit f554b6c94b)
2026-04-01 13:40:36 +00:00
RitvikSardana
cf26bbd54b Merge pull request #3168 from RitvikSardana/fui-bump-bp
chore: bump fui
2026-04-01 19:09:26 +05:30
Ritvik Sardana
f554b6c94b chore: bump fui 2026-04-01 19:03:02 +05:30
RitvikSardana
83dd0a99c0 Merge pull request #3167 from frappe/revert-3166-mergify/bp/main-hotfix/pr-3165
Revert "chore: bump fui (backport #3165)"
2026-04-01 18:56:45 +05:30
RitvikSardana
e0d9ef815e Revert "chore: bump fui (backport #3165)" 2026-04-01 18:56:30 +05:30
RitvikSardana
be229438b2 Merge pull request #3166 from frappe/mergify/bp/main-hotfix/pr-3165
chore: bump fui (backport #3165)
2026-04-01 18:56:05 +05:30
Ritvik Sardana
4aa6f7a8bd chore: bump fui
(cherry picked from commit e61c82041f)

# Conflicts:
#	yarn.lock
2026-04-01 13:24:44 +00:00
RitvikSardana
c3b7de4d3f Merge pull request #3164 from frappe/main-hotfix
chore(release): hotfix to main
2026-04-01 18:14:26 +05:30
RitvikSardana
ba49e22437 Merge pull request #3161 from frappe/mergify/bp/main-hotfix/pr-3160
chore: version bump (backport #3160)
2026-04-01 18:02:29 +05:30
RitvikSardana
aabe2a5f2e Merge pull request #3163 from frappe/mergify/bp/main-hotfix/pr-3150
chore: update POT file (backport #3150)
2026-04-01 18:00:40 +05:30
RitvikSardana
08eadb73d6 chore: resolve conflict 2026-04-01 18:00:25 +05:30
RitvikSardana
325197766f Merge pull request #3162 from frappe/mergify/bp/main-hotfix/pr-3147
chore: sync translations from crowdin (backport #3147)
2026-04-01 17:50:23 +05:30
frappe-pr-bot
5d157ceac1 chore: update POT file
(cherry picked from commit 8beed8cb04)

# Conflicts:
#	helpdesk/locale/main.pot
2026-04-01 12:20:01 +00:00
MochaMind
7364f6f5aa chore: Russian translations
(cherry picked from commit 3196c9c69c)
2026-04-01 12:19:44 +00:00
MochaMind
e1bbaa2b2a chore: Serbian (Latin) translations
(cherry picked from commit ecbc05aed6)
2026-04-01 12:19:43 +00:00
MochaMind
d89d8b2bbd chore: Serbian (Cyrillic) translations
(cherry picked from commit 4fd3c7314a)
2026-04-01 12:19:43 +00:00
MochaMind
871364b63c chore: Russian translations
(cherry picked from commit 16abb01689)
2026-04-01 12:19:43 +00:00
MochaMind
af6b97fc13 chore: Esperanto translations
(cherry picked from commit 2e23acc137)
2026-04-01 12:19:43 +00:00
MochaMind
18c999fdf0 chore: Russian translations
(cherry picked from commit 18a93a48c8)
2026-04-01 12:19:42 +00:00
MochaMind
8b2298ee38 chore: Serbian (Latin) translations
(cherry picked from commit 6bba0c2a56)
2026-04-01 12:19:42 +00:00
MochaMind
117b62f3c2 chore: Norwegian Bokmal translations
(cherry picked from commit 1d108baeff)
2026-04-01 12:19:42 +00:00
MochaMind
e9f469bea8 chore: Bosnian translations
(cherry picked from commit 06912cd7c1)
2026-04-01 12:19:41 +00:00
MochaMind
eb20b78903 chore: Burmese translations
(cherry picked from commit b73cd1ac70)
2026-04-01 12:19:41 +00:00
MochaMind
d5a3f5f01e chore: Croatian translations
(cherry picked from commit 85aee81b4f)
2026-04-01 12:19:41 +00:00
MochaMind
496c89713b chore: Thai translations
(cherry picked from commit b7836e7468)
2026-04-01 12:19:41 +00:00
MochaMind
01f2df8629 chore: Persian translations
(cherry picked from commit 43cb0814b1)
2026-04-01 12:19:40 +00:00
MochaMind
c356f6142e chore: Indonesian translations
(cherry picked from commit 048b62d255)
2026-04-01 12:19:40 +00:00
MochaMind
324c3ef65d chore: Portuguese, Brazilian translations
(cherry picked from commit 703db19eea)
2026-04-01 12:19:40 +00:00
MochaMind
3dca4df2cc chore: Vietnamese translations
(cherry picked from commit 7a570724ee)
2026-04-01 12:19:40 +00:00
MochaMind
31af40a680 chore: Chinese Simplified translations
(cherry picked from commit 158cfe2fff)
2026-04-01 12:19:39 +00:00
MochaMind
16a32cedf6 chore: Turkish translations
(cherry picked from commit 7b712e1e87)
2026-04-01 12:19:39 +00:00
MochaMind
7fe52fa1b9 chore: Swedish translations
(cherry picked from commit 3f08f67d1e)
2026-04-01 12:19:38 +00:00
MochaMind
4f345f2232 chore: Serbian (Cyrillic) translations
(cherry picked from commit 0693d0c34a)
2026-04-01 12:19:38 +00:00
MochaMind
9eb3301656 chore: Slovenian translations
(cherry picked from commit da3d1099ec)
2026-04-01 12:19:38 +00:00
MochaMind
ae399c85e9 chore: Portuguese translations
(cherry picked from commit 8b0ddf74f2)
2026-04-01 12:19:37 +00:00
MochaMind
9a978f9303 chore: Polish translations
(cherry picked from commit 98214151af)
2026-04-01 12:19:37 +00:00
MochaMind
474f3a305f chore: Dutch translations
(cherry picked from commit 21b2a3f7c4)
2026-04-01 12:19:37 +00:00
MochaMind
cec199bed6 chore: Italian translations
(cherry picked from commit 4dbc5c1722)
2026-04-01 12:19:37 +00:00
MochaMind
54ac7719c0 chore: Hungarian translations
(cherry picked from commit 1ace98c4d8)
2026-04-01 12:19:36 +00:00
MochaMind
e8c3a5c270 chore: German translations
(cherry picked from commit d800ae4571)
2026-04-01 12:19:36 +00:00
MochaMind
297ef5cec2 chore: Danish translations
(cherry picked from commit a5a04535ec)
2026-04-01 12:19:36 +00:00
MochaMind
aa414c4496 chore: Czech translations
(cherry picked from commit b6bf8b6f3d)
2026-04-01 12:19:35 +00:00
MochaMind
f38d683014 chore: Arabic translations
(cherry picked from commit d78984ca0d)
2026-04-01 12:19:35 +00:00
MochaMind
c2ce4f3484 chore: Spanish translations
(cherry picked from commit e3c7e5935f)
2026-04-01 12:19:35 +00:00
MochaMind
ece507a310 chore: French translations
(cherry picked from commit 3af0fabc87)
2026-04-01 12:19:35 +00:00
Sydney Gomes
fa0498cad8 Merge pull request #3157 from aerodeval/feat-custom-domain-support
feat(settings): select Email Domain for custom email accounts (backport #2850)
2026-04-01 17:11:46 +05:30
Sydney
2239bf7e96 fix: add missing imports and configs 2026-04-01 16:52:36 +05:30
Sydney
b46c843d5d fix: add back setting imap folder to inbox 2026-04-01 16:35:05 +05:30
RitvikSardana
0d03c1acce chore: version bump
(cherry picked from commit e7f927a895)
2026-04-01 10:57:04 +00:00
RitvikSardana
eb48bf1d71 Merge pull request #3159 from frappe/mergify/bp/main-hotfix/pr-3154
fix: scrollable table (backport #3154)
2026-04-01 16:21:11 +05:30
Ritvik Sardana
e20a890fc4 chore: code cleanup
(cherry picked from commit 788f1577b0)
2026-04-01 10:50:34 +00:00
Ritvik Sardana
234b68151f fix: scrollable table
(cherry picked from commit d905078245)
2026-04-01 10:50:34 +00:00
Sydney Gomes
15bb2c9898 Merge pull request #3158 from frappe/mergify/bp/main-hotfix/pr-3156
fix: utilize tiptap select all command for tiptap based elements in dom (backport #3156)
2026-04-01 15:53:17 +05:30
Sydney
550be35a09 fix: run tip-tap inbuilt select all after focus guard check
(cherry picked from commit 160b4c9f83)
2026-04-01 09:29:37 +00:00
Sydney
31099aa202 fix: utilize tiptap select all command for tiptap based elements in dom
(cherry picked from commit 3990ce5f6f)
2026-04-01 09:29:37 +00:00
Sydney
e33d16422b fix: solve linter issues 2026-04-01 13:54:37 +05:30
Sydney
ff8f442840 feat: backport custom domain feature support 2026-04-01 01:09:45 +05:30
Sydney Gomes
8fcd963e68 Merge pull request #3152 from aerodeval/chore/bump-tiptap
fix: bump tiptap and frappe ui and update yarn files (backport #3131)
2026-03-30 11:57:42 +05:30
Sydney
1760b34e53 fix: update yarn.lock in desk 2026-03-30 11:28:02 +05:30
Sydney
d36599963f fix: bump tiptap and frappe ui and update yarn files 2026-03-30 10:54:25 +05:30
Sydney Gomes
9962c9cfd4 Merge pull request #3149 from frappe/mergify/bp/main-hotfix/pr-3148
refactor: improve welcome email content (backport #3148)
2026-03-27 22:26:59 +05:30
Sydney Gomes
bf3692afa0 refactor: improve welcome email content for onboarding(#3148)
* chore: improve onboarding experience

* fix: fix imports for image and gif

* fix: fix layout issues for the mailer

* fix: update layout of welcome mail and add user name in mail

* fix: update gifs

* chore: use videos instead of gifs for faster load

* fix: incorrect first name fetching in mail

* fix: handle administrator name scenario

* fix: update demo videos

* fix: update videos for demo

* fix: improve copy

(cherry picked from commit 0a4de51eb9)
2026-03-27 16:49:03 +00:00
RitvikSardana
cb1e6ca24c Merge pull request #3145 from frappe/mergify/bp/main-hotfix/pr-3140
chore: sync translations from crowdin (backport #3140)
2026-03-25 16:10:48 +05:30
MochaMind
04d3693d31 chore: Vietnamese translations
(cherry picked from commit 9bac2c2c62)
2026-03-25 10:40:33 +00:00
MochaMind
084098debc chore: Spanish translations
(cherry picked from commit 5b7c2f33df)
2026-03-25 10:40:33 +00:00
MochaMind
32566c5d80 chore: Swedish translations
(cherry picked from commit 40c9741396)
2026-03-25 10:40:33 +00:00
RitvikSardana
a404a850d5 Merge pull request #3144 from frappe/mergify/bp/main-hotfix/pr-3139
chore: update POT file (backport #3139)
2026-03-25 16:10:07 +05:30
frappe-pr-bot
7f1ad2be57 chore: update POT file
(cherry picked from commit b115e0875e)

# Conflicts:
#	helpdesk/locale/main.pot
2026-03-25 10:39:48 +00:00
Jayanta
5d3bb6361a Merge pull request #3143 from frappe/mergify/bp/main-hotfix/pr-3137
fix: set Link field page limit to 999 (backport #3137)
2026-03-25 10:07:52 +05:30
Jayanta
2d5a622bf4 fix: set Link field page limit to 999 (#3137)
* fix: set pageLength to 999 for Link field type

(cherry picked from commit a853d8eb60)
2026-03-25 04:30:47 +00:00
RitvikSardana
77d9632845 Merge pull request #3133 from frappe/mergify/bp/main-hotfix/pr-3126
chore: update POT file (backport #3126)
2026-03-18 13:04:39 +05:30
frappe-pr-bot
a1e48699ec chore: update POT file
(cherry picked from commit 20b5ee931f)
2026-03-18 07:04:20 +00:00
RitvikSardana
c659aec522 Merge pull request #3132 from frappe/mergify/bp/main-hotfix/pr-3118
chore: sync translations from crowdin (backport #3118)
2026-03-18 12:33:52 +05:30
MochaMind
1812ea2a85 chore: Spanish translations
(cherry picked from commit 02afcd4c3c)
2026-03-18 05:47:12 +00:00
MochaMind
957a2b7eab chore: Portuguese, Brazilian translations
(cherry picked from commit 865bd99df0)
2026-03-18 05:47:11 +00:00
MochaMind
cb2fa44412 chore: Swedish translations
(cherry picked from commit 4870d001d5)
2026-03-18 05:47:11 +00:00
MochaMind
4e56b4e87f chore: Swedish translations
(cherry picked from commit 1cc3671930)
2026-03-18 05:47:11 +00:00
RitvikSardana
0d82fa0a50 Merge pull request #3114 from frappe/main-hotfix
chore(release): hotfix to main
2026-03-10 01:09:11 +05:30
RitvikSardana
4ad1991cb0 Merge pull request #3116 from frappe/mergify/bp/main-hotfix/pr-3115
chore: bump frappe-ui (backport #3115)
2026-03-10 01:03:46 +05:30
RitvikSardana
10028686b4 chore: resolve conflict 2026-03-10 01:02:25 +05:30
Ritvik Sardana
6c7ca130b4 chore: bump frappe-ui
(cherry picked from commit 0d4ca83f5f)

# Conflicts:
#	yarn.lock
2026-03-09 19:30:34 +00:00
RitvikSardana
e9543ad4e4 Merge pull request #3113 from frappe/mergify/bp/main-hotfix/pr-3110
chore: update POT file (backport #3110)
2026-03-10 00:41:00 +05:30
RitvikSardana
0c593846f6 Merge pull request #3112 from frappe/mergify/bp/main-hotfix/pr-3107
chore: sync translations from crowdin (backport #3107)
2026-03-10 00:40:55 +05:30
RitvikSardana
9782f5b893 Merge pull request #3111 from frappe/mergify/bp/main-hotfix/pr-3106
fix: excel pasting formating in text editor (backport #3106)
2026-03-10 00:40:32 +05:30
RitvikSardana
ae630f4061 chore: formatting 2026-03-10 00:35:18 +05:30
RitvikSardana
b1699ec728 chore: resolve conflict 2026-03-10 00:33:37 +05:30
frappe-pr-bot
7364697bc3 chore: update POT file
(cherry picked from commit 6be18800ac)
2026-03-09 19:03:08 +00:00
MochaMind
da99dc51b7 chore: Serbian (Latin) translations
(cherry picked from commit 86ca1cc939)
2026-03-09 19:02:53 +00:00
MochaMind
269d7e1c88 chore: Serbian (Cyrillic) translations
(cherry picked from commit cf67e5982e)
2026-03-09 19:02:53 +00:00
MochaMind
d0a2ae16f6 chore: Esperanto translations
(cherry picked from commit f7ae670d3e)
2026-03-09 19:02:52 +00:00
MochaMind
b2651d26c8 chore: Serbian (Latin) translations
(cherry picked from commit 06c7cb2e0d)
2026-03-09 19:02:52 +00:00
MochaMind
f1f3b9bd2b chore: Norwegian Bokmal translations
(cherry picked from commit ce5fd9807d)
2026-03-09 19:02:52 +00:00
MochaMind
ae0784fda2 chore: Bosnian translations
(cherry picked from commit beee851fc8)
2026-03-09 19:02:51 +00:00
MochaMind
e939bd7141 chore: Burmese translations
(cherry picked from commit 97cc8df3a1)
2026-03-09 19:02:51 +00:00
MochaMind
1173616af1 chore: Croatian translations
(cherry picked from commit 47e6aa93cf)
2026-03-09 19:02:51 +00:00
MochaMind
873e55c945 chore: Thai translations
(cherry picked from commit 0a39dfc819)
2026-03-09 19:02:51 +00:00
MochaMind
86f3833618 chore: Persian translations
(cherry picked from commit f10a0ead76)
2026-03-09 19:02:50 +00:00
MochaMind
a110493400 chore: Indonesian translations
(cherry picked from commit 2f82d784f6)
2026-03-09 19:02:50 +00:00
MochaMind
2017ebd81e chore: Portuguese, Brazilian translations
(cherry picked from commit 445633e1f3)
2026-03-09 19:02:50 +00:00
MochaMind
c2d8beef06 chore: Vietnamese translations
(cherry picked from commit a08b365b04)
2026-03-09 19:02:49 +00:00
MochaMind
538e6e8222 chore: Chinese Simplified translations
(cherry picked from commit 16c09008c0)
2026-03-09 19:02:49 +00:00
MochaMind
c526cb8f1c chore: Turkish translations
(cherry picked from commit ee02d83e83)
2026-03-09 19:02:49 +00:00
MochaMind
8f50495f22 chore: Swedish translations
(cherry picked from commit 68c56fb564)
2026-03-09 19:02:48 +00:00
MochaMind
5f448d37ea chore: Serbian (Cyrillic) translations
(cherry picked from commit 99e167563b)
2026-03-09 19:02:48 +00:00
MochaMind
1d5970cc5c chore: Slovenian translations
(cherry picked from commit 85b5973a34)
2026-03-09 19:02:47 +00:00
MochaMind
1b9e379361 chore: Portuguese translations
(cherry picked from commit b0fbd842c2)
2026-03-09 19:02:47 +00:00
MochaMind
a0fd1cee25 chore: Polish translations
(cherry picked from commit 19b847569f)
2026-03-09 19:02:47 +00:00
MochaMind
52ae3528bb chore: Dutch translations
(cherry picked from commit 3a0b8d0b0c)
2026-03-09 19:02:47 +00:00
MochaMind
afbd3a7398 chore: Italian translations
(cherry picked from commit 76ceb80a72)
2026-03-09 19:02:46 +00:00
MochaMind
297d7685f5 chore: Hungarian translations
(cherry picked from commit 0aa2d91512)
2026-03-09 19:02:46 +00:00
MochaMind
b63a23fd94 chore: German translations
(cherry picked from commit b931fea9f5)
2026-03-09 19:02:46 +00:00
MochaMind
0be89a8655 chore: Danish translations
(cherry picked from commit 7e5fdb03dc)
2026-03-09 19:02:46 +00:00
MochaMind
d7a3d9994b chore: Czech translations
(cherry picked from commit f689beb8e8)
2026-03-09 19:02:44 +00:00
MochaMind
efda04cfdc chore: Arabic translations
(cherry picked from commit 5b092db083)
2026-03-09 19:02:44 +00:00
MochaMind
cad3b7a5db chore: Spanish translations
(cherry picked from commit 5f2648c93e)
2026-03-09 19:02:44 +00:00
MochaMind
9f84945651 chore: French translations
(cherry picked from commit fbffed7ffb)
2026-03-09 19:02:43 +00:00
MochaMind
065b299e5a chore: Russian translations
(cherry picked from commit 2aae85be24)
2026-03-09 19:02:43 +00:00
Ritvik Sardana
f3abb26d0d fix: move cursor to after the table
(cherry picked from commit daac5f8ccc)
2026-03-09 19:02:15 +00:00
Sydney
d66224942c fix: add additional support for tabular data
(cherry picked from commit 08f3cd3cad)
2026-03-09 19:02:14 +00:00
Ritvik Sardana
6626514c28 chore: version bump
(cherry picked from commit 401dcb85c9)

# Conflicts:
#	helpdesk/__init__.py
2026-03-09 19:02:14 +00:00
Ritvik Sardana
19d92395d2 fix: enhance excel pasted data
(cherry picked from commit 16429b781e)
2026-03-09 19:02:14 +00:00
RitvikSardana
c98b317beb Merge pull request #3104 from frappe/mergify/bp/main-hotfix/pr-2648
fix: frappe ui z index issues (backport #2648)
2026-03-10 00:31:38 +05:30
Sydney Gomes
14a3a870dd Merge pull request #3109 from frappe/mergify/bp/main-hotfix/pr-3108
fix: allow shift+enter by removing .stop from keydown handler (backport #3108)
2026-03-08 15:28:53 +05:30
Sydney
6f7951518f fix: allow shift+enter by removing .stop from keydown handler
(cherry picked from commit 4b3b431749)
2026-03-08 09:52:53 +00:00
Pratik Badhe
40a04c40ee fix: handle on hover apps dropdown
(cherry picked from commit c21247015b)
2026-03-06 06:25:35 +00:00
Pratik Badhe
625ae93e1c fix: apps list hover state
(cherry picked from commit 95301a2a9f)
2026-03-06 06:25:35 +00:00
Pratik Badhe
95dd2c004c refactor: change apps list UI
(cherry picked from commit 0175b4e5fc)
2026-03-06 06:25:35 +00:00
Pratik Badhe
2a0ad52055 fix: frappe ui z index issues
(cherry picked from commit de95204c72)
2026-03-06 06:25:35 +00:00
RitvikSardana
8f8066101d Merge pull request #3103 from frappe/main-hotfix
chore(release): hotfix to main
2026-03-06 10:47:14 +05:30
RitvikSardana
077a1c0ef5 chore: version bump 2026-03-06 10:33:52 +05:30
RitvikSardana
c421db13d8 Merge pull request #3102 from frappe/mergify/bp/main-hotfix/pr-3086
chore: sync translations from crowdin (backport #3086)
2026-03-06 10:28:29 +05:30
RitvikSardana
2a325f5c2f Merge pull request #3101 from frappe/mergify/bp/main-hotfix/pr-3085
chore: update POT file (backport #3085)
2026-03-06 10:27:50 +05:30
RitvikSardana
0727894cb2 chore: resolve conflict 2026-03-06 10:27:12 +05:30
MochaMind
3a59a4a2d1 chore: Russian translations
(cherry picked from commit 65a93f7a68)
2026-03-06 04:54:32 +00:00
MochaMind
9f1df7a79a chore: Swedish translations
(cherry picked from commit a1d16363b9)
2026-03-06 04:54:31 +00:00
MochaMind
d0cc9adc93 chore: Spanish translations
(cherry picked from commit 298f899ae9)
2026-03-06 04:54:31 +00:00
MochaMind
d0f6befa7c chore: French translations
(cherry picked from commit 678213f31c)
2026-03-06 04:54:31 +00:00
MochaMind
db08a86f8a chore: Russian translations
(cherry picked from commit 662b17f748)
2026-03-06 04:54:31 +00:00
MochaMind
fb22c610c9 chore: Russian translations
(cherry picked from commit 1ad6d8d4f9)
2026-03-06 04:54:30 +00:00
frappe-pr-bot
ab42160ea1 chore: update POT file
(cherry picked from commit 158e6f5bdf)

# Conflicts:
#	helpdesk/locale/main.pot
2026-03-06 04:54:23 +00:00
Sydney Gomes
7ad7824d22 Merge pull request #3100 from aerodeval/fix/handle-email-formatting
fix: handle email formatting ( backport #2975)
2026-03-06 02:19:47 +05:30
Sydney
cb14a5b086 fix: handle email formatting 2026-03-06 02:10:11 +05:30
RitvikSardana
e40716e2dc Merge pull request #3099 from frappe/mergify/bp/main-hotfix/pr-3098
fix: dont delete src tag (backport #3098)
2026-03-06 01:49:53 +05:30
Ritvik Sardana
6b57346a2d chore: code cleanup
(cherry picked from commit 4979045fb6)
2026-03-05 20:12:15 +00:00
Ritvik Sardana
c5650b48aa fix: dont delete src tag
(cherry picked from commit b0e4b3be64)
2026-03-05 20:12:14 +00:00
Sydney Gomes
169bc57da2 Merge pull request #3091 from frappe/mergify/bp/main-hotfix/pr-3090
fix: fix saved replies scope filtering (backport #3090)
2026-03-02 14:04:43 +05:30
Sydney
3957380164 fix: fix saved replies scope filtering
(cherry picked from commit 91ed2d6c54)
2026-03-02 08:29:11 +00:00
RitvikSardana
2ab7d8f5b2 Merge pull request #3083 from frappe/mergify/bp/main-hotfix/pr-3082
chore: sync translations from crowdin (backport #3082)
2026-02-28 12:39:35 +05:30
MochaMind
f73c3a5eb8 chore: Russian translations
(cherry picked from commit e3ffb460d0)
2026-02-28 06:41:05 +00:00
MochaMind
134938f0e1 chore: Spanish translations
(cherry picked from commit 52ee89f7ba)
2026-02-28 06:41:04 +00:00
RitvikSardana
1a8bf43578 Merge pull request #3081 from frappe/main-hotfix
chore(release): hotfix to main
2026-02-27 18:28:43 +05:30
RitvikSardana
3c6a22c6d6 Merge pull request #3078 from frappe/mergify/bp/main-hotfix/pr-3077
fix: handle excel pasting in email editor (backport #3077)
2026-02-27 16:48:50 +05:30
RitvikSardana
95dc627b09 Merge pull request #3080 from frappe/mergify/bp/main-hotfix/pr-3079
fix: remove depends_on in raised_by field (backport #3079)
2026-02-27 16:45:53 +05:30
Ritvik Sardana
dc70c83fc3 chore: version bump
(cherry picked from commit e7ba190fbe)
2026-02-27 11:14:57 +00:00
Ritvik Sardana
132d86d8d7 fix: remove unwanted depends on in raised_by field
(cherry picked from commit 94637de7ff)
2026-02-27 11:14:57 +00:00
Sydney
f334b90837 fix: fix improper key in extension
(cherry picked from commit b8c160f22b)
2026-02-27 11:00:22 +00:00
Sydney
1275da1ccb fix: handle excel pasting in email editor
(cherry picked from commit 5f682fe8ef)
2026-02-27 11:00:22 +00:00
Sydney Gomes
0bd0be0ad1 Merge pull request #3076 from frappe/mergify/bp/main-hotfix/pr-3006
fix: comment box mentions bug on edit (backport #3006)
2026-02-27 12:17:20 +05:30
Sydney
bca54632d1 feat: add keyup event support for saving comments
(cherry picked from commit 7fb269ff04)
2026-02-27 06:22:32 +00:00
Sydney
5101822c0b fix: comments discard button bug fix and mentions fix
(cherry picked from commit 0f1abb75a1)
2026-02-27 06:22:31 +00:00
Sydney
e364335845 fix: fix flaky mentions rendering when agent list not populated
(cherry picked from commit 5f03f0d144)
2026-02-27 06:22:31 +00:00
Sydney
a096eaee95 chore: remove unecessary storeToRef
(cherry picked from commit b9b82b48cd)
2026-02-27 06:22:31 +00:00
Sydney
56550aa821 chore: remove redundant code
(cherry picked from commit 13e5a14867)
2026-02-27 06:22:31 +00:00
Sydney
4c948a6be6 chore: code cleanup
(cherry picked from commit bfa2dcc2a3)
2026-02-27 06:22:31 +00:00
Sydney
a9ad34ad7e chore: remove debugging print statements
(cherry picked from commit f8311a98c7)
2026-02-27 06:22:30 +00:00
Sydney
a9f123090e chore: added test case to check if multiple notifications are not sent on adding mention when edited
(cherry picked from commit bdda40443c)
2026-02-27 06:22:30 +00:00
Sydney
a81f4c9f9e fix: comment box edit mentions fix
(cherry picked from commit fd94e0bc12)
2026-02-27 06:22:30 +00:00
RitvikSardana
12a47792b8 Merge pull request #3075 from frappe/mergify/bp/main-hotfix/pr-3063
chore: sync translations from crowdin (backport #3063)
2026-02-27 11:10:33 +05:30
MochaMind
488bf6fced chore: Serbian (Latin) translations
(cherry picked from commit 0112488cdd)
2026-02-27 05:25:54 +00:00
MochaMind
be763b020d chore: Swedish translations
(cherry picked from commit 0006e98d44)
2026-02-27 05:25:54 +00:00
MochaMind
efd539bad9 chore: Serbian (Cyrillic) translations
(cherry picked from commit 0f056060fc)
2026-02-27 05:25:53 +00:00
MochaMind
992cfca6ee chore: Spanish translations
(cherry picked from commit ee8b612ca7)
2026-02-27 05:25:53 +00:00
MochaMind
a83b7c9ae0 chore: Esperanto translations
(cherry picked from commit 890dedad69)
2026-02-27 05:25:53 +00:00
MochaMind
fa968132a6 chore: Serbian (Latin) translations
(cherry picked from commit 62518c0843)
2026-02-27 05:25:52 +00:00
MochaMind
20f799ec57 chore: Norwegian Bokmal translations
(cherry picked from commit 44750ec641)
2026-02-27 05:25:52 +00:00
MochaMind
cd006a95ad chore: Bosnian translations
(cherry picked from commit 343809f489)
2026-02-27 05:25:52 +00:00
MochaMind
b270b02563 chore: Burmese translations
(cherry picked from commit 9acf110b42)
2026-02-27 05:25:52 +00:00
MochaMind
a3ad1a937a chore: Croatian translations
(cherry picked from commit 5eec882f14)
2026-02-27 05:25:51 +00:00
MochaMind
a126a20d0c chore: Thai translations
(cherry picked from commit fb97216bca)
2026-02-27 05:25:51 +00:00
MochaMind
9cf61eeca9 chore: Persian translations
(cherry picked from commit 01437abc7e)
2026-02-27 05:25:51 +00:00
MochaMind
cf0024ec6f chore: Indonesian translations
(cherry picked from commit 8f7df1627a)
2026-02-27 05:25:51 +00:00
MochaMind
fe7ddfa9bd chore: Portuguese, Brazilian translations
(cherry picked from commit dfd08b6c85)
2026-02-27 05:25:50 +00:00
MochaMind
672ad53541 chore: Vietnamese translations
(cherry picked from commit 5a63a92b5c)
2026-02-27 05:25:50 +00:00
MochaMind
93924bc707 chore: Chinese Simplified translations
(cherry picked from commit 944b9f7b19)
2026-02-27 05:25:50 +00:00
MochaMind
f30f1b7919 chore: Turkish translations
(cherry picked from commit a9bbcfad00)
2026-02-27 05:25:49 +00:00
MochaMind
4f7a8a2c31 chore: Swedish translations
(cherry picked from commit e609efad07)
2026-02-27 05:25:49 +00:00
MochaMind
341af00ca2 chore: Serbian (Cyrillic) translations
(cherry picked from commit f441217c31)
2026-02-27 05:25:49 +00:00
MochaMind
f5547b0db8 chore: Slovenian translations
(cherry picked from commit bcd6c0a3a0)
2026-02-27 05:25:49 +00:00
MochaMind
ae4f7754b4 chore: Russian translations
(cherry picked from commit 4f664db460)
2026-02-27 05:25:48 +00:00
MochaMind
a14ce55394 chore: Portuguese translations
(cherry picked from commit fd2dc00e21)
2026-02-27 05:25:48 +00:00
MochaMind
794e2ffe36 chore: Polish translations
(cherry picked from commit 18f3d81410)
2026-02-27 05:25:48 +00:00
MochaMind
13d805981e chore: Dutch translations
(cherry picked from commit ec934009ad)
2026-02-27 05:25:47 +00:00
MochaMind
4f770a919f chore: Italian translations
(cherry picked from commit bf64aa02ee)
2026-02-27 05:25:47 +00:00
MochaMind
7ed70afb4a chore: Hungarian translations
(cherry picked from commit 0ae8afb535)
2026-02-27 05:25:47 +00:00
MochaMind
75553f5ab6 chore: German translations
(cherry picked from commit a33bc988b7)
2026-02-27 05:25:47 +00:00
MochaMind
d8cfb43e0b chore: Danish translations
(cherry picked from commit e2ccef3334)
2026-02-27 05:25:46 +00:00
MochaMind
6d86b6a3f6 chore: Czech translations
(cherry picked from commit 95226cd273)
2026-02-27 05:25:46 +00:00
MochaMind
8078f22e9f chore: Arabic translations
(cherry picked from commit 99d6710e0b)
2026-02-27 05:25:46 +00:00
MochaMind
b1daf1ebf9 chore: Spanish translations
(cherry picked from commit c189521c0a)
2026-02-27 05:25:46 +00:00
Sydney Gomes
6b7fd287cb Merge pull request #3073 from aerodeval/fix/remove-unused-stop-session
chore: remove stop session code
2026-02-26 19:17:13 +05:30
Sydney
648a062738 chore: remove stop session code 2026-02-26 19:07:29 +05:30
Sydney Gomes
5dcfc6d289 Merge pull request #3070 from frappe/mergify/bp/main-hotfix/pr-3009 2026-02-26 18:47:46 +05:30
Sydney Gomes
8492c5addb chore: fix merge conflicts 2026-02-26 18:47:22 +05:30
RitvikSardana
8fe0656521 Merge pull request #3072 from frappe/mergify/bp/main-hotfix/pr-3071
fix(auth): allow export api endpoint (backport #3071)
2026-02-26 17:39:07 +05:30
Ritvik Sardana
a6816dfd42 fix(auth): allow export api endpoint
(cherry picked from commit 2a062809e3)
2026-02-26 12:08:49 +00:00
Sydney
94564058b2 chore: code cleanup
(cherry picked from commit a2394c2b59)

# Conflicts:
#	desk/src/components/ticket/TicketCustomerSidebar.vue
2026-02-26 10:25:49 +00:00
Sydney
c6e1bb1870 chore: truncate text for autocomplete
(cherry picked from commit e8cb8fc799)
2026-02-26 10:25:49 +00:00
Sydney
1e7efe91bc fix: autocomplete import fixed
(cherry picked from commit 5a0edfe4d7)
2026-02-26 10:25:49 +00:00
Sydney
9893b14520 chore: code cleanup
(cherry picked from commit 173cd745eb)
2026-02-26 10:25:48 +00:00
Sydney
4f9b149acf fix: hard reload after timezone is changed
(cherry picked from commit a4bf8967c4)
2026-02-26 10:25:48 +00:00
Sydney
ff63f9a1a5 fix: force hard reload after language change and delay it so toast is visible
(cherry picked from commit 85824b6908)
2026-02-26 10:25:48 +00:00
Sydney
12d2dc4467 feat: add autocomplete switching in profile settings
(cherry picked from commit 70c621225e)
2026-02-26 10:25:48 +00:00
Sydney
9b1409a97b chore: Revert chore: Delete desk/src/types/doctypes.ts
This reverts commit 90e2a7bd3f.

(cherry picked from commit f7fbcce42e)

# Conflicts:
#	desk/src/types/doctypes.ts
2026-02-26 10:25:47 +00:00
Sydney Gomes
4fb4df29d7 chore: Delete desk/src/types/doctypes.ts
(cherry picked from commit 90e2a7bd3f)

# Conflicts:
#	desk/src/types/doctypes.ts
2026-02-26 10:25:47 +00:00
Sydney
fd2eb4ca3b chore: code cleanup
(cherry picked from commit 0928581960)
2026-02-26 10:25:47 +00:00
Sydney
3303e209d0 chore: cleanup redundant code
(cherry picked from commit bb8ef76156)
2026-02-26 10:25:47 +00:00
Sydney
78e87c0a52 fix: fix localization all over helpdesk and fix config fetching and setting
(cherry picked from commit d2ea5904b5)

# Conflicts:
#	desk/src/App.vue
2026-02-26 10:25:46 +00:00
Sydney
ff775012b1 fix: use prettyDate function to handle edge cases
(cherry picked from commit e6f13d4cf3)
2026-02-26 10:25:46 +00:00
Sydney
e4983dc963 chore: add dayjs local for support in different timezones
(cherry picked from commit d46429165f)

# Conflicts:
#	desk/src/components/ticket-agent/TicketSLA.vue
2026-02-26 10:25:46 +00:00
RitvikSardana
0bd5a5c35c Merge pull request #3069 from frappe/mergify/bp/main-hotfix/pr-3068
fix(field): handle case when no options are set for select field (backport #3068)
2026-02-26 15:17:35 +05:30
RitvikSardana
cb17035fc3 chore: code cleanup 2026-02-26 15:17:19 +05:30
Ritvik Sardana
a79468f749 fix(field): handle case when no options are set for select field
(cherry picked from commit 2f5099fbba)
2026-02-26 07:55:47 +00:00
RitvikSardana
a332917fc9 Merge pull request #3061 from frappe/main-hotfix
chore(release): hotfix to main
2026-02-25 16:41:16 +05:30
RitvikSardana
d0050ef489 chore: code cleanup 2026-02-25 16:36:28 +05:30
RitvikSardana
beb5b2ef9d chore: code cleanup 2026-02-25 16:31:26 +05:30
RitvikSardana
6c2149ba78 Merge branch 'main' into main-hotfix 2026-02-25 16:27:24 +05:30
RitvikSardana
10f1eae002 Merge pull request #3060 from frappe/mergify/bp/main-hotfix/pr-3057
feat: telemetry with pulse (backport #3057)
2026-02-25 16:25:57 +05:30
RitvikSardana
f20e8f393e chore: resolve merge conflict 2026-02-25 16:17:30 +05:30
Ritvik Sardana
e64c71447a chore: bump version
(cherry picked from commit b6ade86b5b)
2026-02-25 10:38:40 +00:00
Ritvik Sardana
f83dbd3db2 fix: better placeholder if no user is found
(cherry picked from commit fbc2c66d08)
2026-02-25 10:38:40 +00:00
Ritvik Sardana
4e06436571 fix: dont capture events for pre-filled data
(cherry picked from commit 334345f73f)
2026-02-25 10:38:40 +00:00
Ritvik Sardana
2edce1df53 fix: dont index hd_ticket twice
(cherry picked from commit a6a88791f2)
2026-02-25 10:38:39 +00:00
Ritvik Sardana
0eb09c6ea1 chore: add site data for analytics
(cherry picked from commit 7c80701fe2)
2026-02-25 10:38:39 +00:00
Ritvik Sardana
3fa5aa86f7 fix: add event for hd_team
(cherry picked from commit 17fa75759a)
2026-02-25 10:38:39 +00:00
Ritvik Sardana
c7c65344ee feat: telemetry with pulse
(cherry picked from commit 7f07d6bfca)

# Conflicts:
#	desk/src/components/Settings/EmailAdd.vue
#	desk/src/components/layouts/Sidebar.vue
2026-02-25 10:38:39 +00:00
RitvikSardana
0b909703b8 Merge pull request #3059 from frappe/mergify/bp/main-hotfix/pr-3050
fix: email send button bug and null value appearing in saved replies (backport #3050)
2026-02-25 12:00:29 +05:30
Sydney
df74338e08 fix: broken replies section and reply content bug
(cherry picked from commit f6a7c1868f)
2026-02-25 05:39:49 +00:00
Sydney
74167d55ad fix: email send button bug where null value allowed send button to be active
(cherry picked from commit ee3f456009)
2026-02-25 05:39:48 +00:00
RitvikSardana
2626824fc5 Merge pull request #3058 from frappe/mergify/bp/main-hotfix/pr-3056
fix(filters): model-value for select component (backport #3056)
2026-02-25 11:09:15 +05:30
Ritvik Sardana
fa0dd4b18e chore: code cleanup
(cherry picked from commit d917e42dcb)
2026-02-25 05:34:54 +00:00
Ritvik Sardana
f2d07d5aef fix(filters): model-value for select component
(cherry picked from commit d3aeefb327)
2026-02-25 05:34:54 +00:00
RitvikSardana
3d487f13c6 Merge pull request #3055 from frappe/mergify/bp/main-hotfix/pr-3047
chore: update POT file (backport #3047)
2026-02-24 11:34:27 +05:30
RitvikSardana
c6b8d5dad6 fix: merge conflict 2026-02-24 10:25:13 +05:30
RitvikSardana
de7f001f1b Merge pull request #3054 from frappe/mergify/bp/main-hotfix/pr-3046
chore: sync translations from crowdin (backport #3046)
2026-02-24 10:23:25 +05:30
frappe-pr-bot
93e9d1c558 chore: update POT file
(cherry picked from commit fcea5fd3ae)

# Conflicts:
#	helpdesk/locale/main.pot
2026-02-24 04:52:38 +00:00
MochaMind
9e77e1ec16 chore: Swedish translations
(cherry picked from commit fcd775ba0b)
2026-02-24 04:52:31 +00:00
MochaMind
5b0b0bdf2c chore: Thai translations
(cherry picked from commit b64e56e40c)
2026-02-24 04:52:31 +00:00
MochaMind
eb9429a245 chore: Vietnamese translations
(cherry picked from commit 1e33702c79)
2026-02-24 04:52:31 +00:00
MochaMind
a6f847870b chore: Swedish translations
(cherry picked from commit 15f86f2026)
2026-02-24 04:52:30 +00:00
MochaMind
f2005f1907 chore: Serbian (Cyrillic) translations
(cherry picked from commit 3cd5fcbbe5)
2026-02-24 04:52:30 +00:00
MochaMind
222492a78b chore: Dutch translations
(cherry picked from commit 6c588ead84)
2026-02-24 04:52:30 +00:00
MochaMind
a6caf2de47 chore: Spanish translations
(cherry picked from commit 7a8716b465)
2026-02-24 04:52:29 +00:00
RitvikSardana
a7450d4c9e Merge pull request #3053 from RitvikSardana/import-dashboard
fix: Imports in dashboard
2026-02-24 10:18:15 +05:30
Ritvik Sardana
a2360a78ea fix: add DateRangePicker import in Dashboard 2026-02-24 10:17:15 +05:30
RitvikSardana
0e0e6f42a6 Merge pull request #3052 from frappe/mergify/bp/main-hotfix/pr-3051
fix: add disabled field in HD Ticket Type (backport #3051)
2026-02-24 09:47:37 +05:30
RitvikSardana
26dd7a4d78 fix: merge conflict 2026-02-24 09:34:16 +05:30
Ritvik Sardana
820d199f68 fix: add disabled field in HD Ticket Type
(cherry picked from commit c00ab76168)

# Conflicts:
#	helpdesk/helpdesk/doctype/hd_ticket/hd_ticket.json
2026-02-24 04:01:23 +00:00
RitvikSardana
165b74a035 Merge pull request #3049 from frappe/mergify/bp/main-hotfix/pr-3048
fix: check for months and years while formatting date (backport #3048)
2026-02-23 12:01:57 +05:30
Ritvik Sardana
c59cdfb6fc fix: check for months and years while formatting date
(cherry picked from commit 69504c0ec4)
2026-02-23 06:31:35 +00:00
RitvikSardana
32977ac0a6 chore: Remove permission check in get_call_logs function
Removed permission check for viewing call logs.
2026-02-23 10:18:25 +05:30
RitvikSardana
7e33c6a238 chore: bump version to 1.20.2 2026-02-23 10:16:00 +05:30
RitvikSardana
546b09b7bc chore: Bump version to 1.20.1 2026-02-23 10:15:07 +05:30
RitvikSardana
b4d68d40dd chore: Remove permission check in get_call_logs function
Removed permission check for viewing call logs.
2026-02-23 10:14:17 +05:30
RitvikSardana
f5f26c3fa3 Merge pull request #3043 from frappe/main-hotfix
chore(release): hotfix to main
2026-02-19 18:40:59 +05:30
RitvikSardana
bc54aae6e3 Merge pull request #3042 from frappe/mergify/bp/main-hotfix/pr-3041
chore: bump version (backport #3041)
2026-02-19 18:35:17 +05:30
Ritvik Sardana
1234197c46 chore: bump version
(cherry picked from commit abc5058b0b)
2026-02-19 13:04:43 +00:00
RitvikSardana
95b1382247 Merge pull request #3037 from frappe/mergify/bp/main-hotfix/pr-3004
chore: sync translations from crowdin (backport #3004)
2026-02-19 18:13:08 +05:30
RitvikSardana
7345580368 Merge pull request #3022 from frappe/mergify/bp/main-hotfix/pr-3000
fix: socket and saved reply (backport #3000)
2026-02-19 18:12:22 +05:30
RitvikSardana
6437f3f95d Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-3000 2026-02-19 18:05:57 +05:30
RitvikSardana
1472c5954e Merge pull request #3040 from aerodeval/feat/outside-working-hours-banner
feat: add outside working hours banner feature (backport #2902)
2026-02-19 18:04:04 +05:30
RitvikSardana
d1bece41a2 fix: resolve merge conflict 2026-02-19 16:59:15 +05:30
RitvikSardana
73cd8695c5 Merge pull request #3025 from frappe/mergify/bp/main-hotfix/pr-3024
fix: type check for ticket doctype with int | str (backport #3024)
2026-02-19 16:56:41 +05:30
Sydney
76e0771a68 feat: add outside working hours banner 2026-02-19 16:54:16 +05:30
RitvikSardana
5e305eaf17 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-3024 2026-02-19 14:30:53 +05:30
RitvikSardana
eef1020137 Merge pull request #3038 from frappe/mergify/bp/main-hotfix/pr-3016
fix: add type hints for all API endpoints (backport #3016)
2026-02-19 14:25:06 +05:30
RitvikSardana
93037d56e6 fix: resolve merge conflicts 2026-02-19 14:18:29 +05:30
Ritvik Sardana
5c2918ee2c fix: add hook for type checking
(cherry picked from commit bfd4cb6a9b)

# Conflicts:
#	helpdesk/hooks.py
2026-02-19 08:47:12 +00:00
Ritvik Sardana
1224c7b01e fix: add type hints for all API endpoints
(cherry picked from commit 02b1b0473c)
2026-02-19 08:47:11 +00:00
MochaMind
f7d77f17e7 chore: Serbian (Latin) translations
(cherry picked from commit 1b4fe28b01)
2026-02-19 08:43:29 +00:00
MochaMind
dc1ce1afb1 chore: Persian translations
(cherry picked from commit fa2c98cb0d)
2026-02-19 08:43:28 +00:00
MochaMind
524096c000 chore: Spanish translations
(cherry picked from commit ed91deca37)
2026-02-19 08:43:28 +00:00
MochaMind
4a2d5bd4dd chore: Esperanto translations
(cherry picked from commit 5468734b33)
2026-02-19 08:43:28 +00:00
MochaMind
955c10b996 chore: Serbian (Latin) translations
(cherry picked from commit 6ea82815fd)
2026-02-19 08:43:28 +00:00
MochaMind
009bb8904c chore: Norwegian Bokmal translations
(cherry picked from commit 0872507484)
2026-02-19 08:43:27 +00:00
MochaMind
0e35cc797a chore: Bosnian translations
(cherry picked from commit c23c23f542)
2026-02-19 08:43:27 +00:00
MochaMind
17de104af5 chore: Burmese translations
(cherry picked from commit 4144fb67a2)
2026-02-19 08:43:27 +00:00
MochaMind
124288fa0c chore: Croatian translations
(cherry picked from commit 65921275f6)
2026-02-19 08:43:26 +00:00
MochaMind
22cf2ccc84 chore: Thai translations
(cherry picked from commit a94a3b9244)
2026-02-19 08:43:26 +00:00
MochaMind
efe621ae15 chore: Persian translations
(cherry picked from commit 951bd54aae)
2026-02-19 08:43:26 +00:00
MochaMind
13e01b287f chore: Indonesian translations
(cherry picked from commit bef6f0ce92)
2026-02-19 08:43:26 +00:00
MochaMind
904dccaa1b chore: Portuguese, Brazilian translations
(cherry picked from commit 884e7eab6c)
2026-02-19 08:43:25 +00:00
MochaMind
42172333f5 chore: Vietnamese translations
(cherry picked from commit ec49fa7546)
2026-02-19 08:43:25 +00:00
MochaMind
f11240e804 chore: Chinese Simplified translations
(cherry picked from commit fb75199f52)
2026-02-19 08:43:25 +00:00
MochaMind
bfc46366c9 chore: Turkish translations
(cherry picked from commit 123abdce36)
2026-02-19 08:43:25 +00:00
MochaMind
ffad2013bf chore: Swedish translations
(cherry picked from commit 6c27f5b9d5)
2026-02-19 08:43:24 +00:00
MochaMind
184d024af5 chore: Serbian (Cyrillic) translations
(cherry picked from commit e609c19d96)
2026-02-19 08:43:24 +00:00
MochaMind
793ac1d584 chore: Slovenian translations
(cherry picked from commit ec63f41e73)
2026-02-19 08:43:24 +00:00
MochaMind
7c1ecfd6a7 chore: Russian translations
(cherry picked from commit 1da2f63f16)
2026-02-19 08:43:23 +00:00
MochaMind
baa87bbd77 chore: Portuguese translations
(cherry picked from commit 2084349da9)
2026-02-19 08:43:23 +00:00
MochaMind
6758e94a4f chore: Polish translations
(cherry picked from commit b7a30c80be)
2026-02-19 08:43:23 +00:00
MochaMind
c3b46e2d3f chore: Dutch translations
(cherry picked from commit 9e4505a18e)
2026-02-19 08:43:23 +00:00
MochaMind
098c7a11fb chore: Italian translations
(cherry picked from commit 138825d6b4)
2026-02-19 08:43:22 +00:00
MochaMind
c449bd7e29 chore: Hungarian translations
(cherry picked from commit 1ddb560979)
2026-02-19 08:43:22 +00:00
MochaMind
af386e7075 chore: German translations
(cherry picked from commit ae5990e1dc)
2026-02-19 08:43:22 +00:00
MochaMind
51860ac408 chore: Danish translations
(cherry picked from commit 6c540b917f)
2026-02-19 08:43:22 +00:00
MochaMind
421bd3088f chore: Czech translations
(cherry picked from commit f4266ebaa0)
2026-02-19 08:43:21 +00:00
MochaMind
674e08091a chore: Arabic translations
(cherry picked from commit c211f817ae)
2026-02-19 08:43:21 +00:00
MochaMind
5549159ea5 chore: Spanish translations
(cherry picked from commit d8db4a0e6b)
2026-02-19 08:43:21 +00:00
MochaMind
ef3736c12d chore: French translations
(cherry picked from commit 015a800d90)
2026-02-19 08:43:21 +00:00
MochaMind
6e4291ad6a chore: Persian translations
(cherry picked from commit 9c87f0d089)
2026-02-19 08:43:20 +00:00
MochaMind
abbc9c0ccc chore: Serbian (Latin) translations
(cherry picked from commit 40d72b6af2)
2026-02-19 08:43:20 +00:00
MochaMind
70f65febee chore: Serbian (Cyrillic) translations
(cherry picked from commit 228fa9ff8d)
2026-02-19 08:43:20 +00:00
MochaMind
bad8cc43ae chore: Russian translations
(cherry picked from commit fb802a0ea7)
2026-02-19 08:43:20 +00:00
MochaMind
fc25efa84a chore: Spanish translations
(cherry picked from commit 0541976cea)
2026-02-19 08:43:19 +00:00
MochaMind
23cd1448be chore: Serbian (Latin) translations
(cherry picked from commit 8b182a2201)
2026-02-19 08:43:19 +00:00
MochaMind
35076b0c61 chore: Burmese translations
(cherry picked from commit 03cc38c06f)
2026-02-19 08:43:19 +00:00
MochaMind
8c060aa8c5 chore: Bosnian translations
(cherry picked from commit 164f8e3c91)
2026-02-19 08:43:18 +00:00
MochaMind
543755505a chore: Croatian translations
(cherry picked from commit e1024b017a)
2026-02-19 08:43:18 +00:00
MochaMind
00c8c50c80 chore: Swedish translations
(cherry picked from commit 4b2c340d84)
2026-02-19 08:43:18 +00:00
MochaMind
7c20a1a933 chore: Italian translations
(cherry picked from commit 27b8b014ea)
2026-02-19 08:43:18 +00:00
RitvikSardana
69717779b6 Merge pull request #3036 from frappe/mergify/bp/main-hotfix/pr-2999
chore: sync translations from crowdin (backport #2999)
2026-02-19 14:06:04 +05:30
Jannat Patel
5d02353472 chore: Swedish translations
(cherry picked from commit efb274ecbd)
2026-02-19 08:31:53 +00:00
RitvikSardana
0654ff724c Merge pull request #3035 from frappe/mergify/bp/main-hotfix/pr-2990
chore: sync translations from crowdin (backport #2990)
2026-02-19 14:01:03 +05:30
Jannat Patel
8ae9661106 chore: Esperanto translations
(cherry picked from commit 8bb50ac347)
2026-02-19 08:29:57 +00:00
Jannat Patel
78a72da39a chore: Serbian (Latin) translations
(cherry picked from commit b858fdcf54)
2026-02-19 08:29:57 +00:00
Jannat Patel
dc7049e757 chore: Norwegian Bokmal translations
(cherry picked from commit 20615dc619)
2026-02-19 08:29:57 +00:00
Jannat Patel
16f2b857f7 chore: Bosnian translations
(cherry picked from commit cbd313d18d)
2026-02-19 08:29:56 +00:00
Jannat Patel
338b48bb9b chore: Burmese translations
(cherry picked from commit 7f8135f1ba)
2026-02-19 08:29:56 +00:00
Jannat Patel
58caa8c556 chore: Croatian translations
(cherry picked from commit 76915db34a)
2026-02-19 08:29:56 +00:00
Jannat Patel
144ccda231 chore: Thai translations
(cherry picked from commit 29efce94ed)
2026-02-19 08:29:55 +00:00
Jannat Patel
7659f06491 chore: Persian translations
(cherry picked from commit e9fafea0ac)
2026-02-19 08:29:55 +00:00
Jannat Patel
b6f1ecc30a chore: Indonesian translations
(cherry picked from commit fd7751756f)
2026-02-19 08:29:55 +00:00
Jannat Patel
b94a330c93 chore: Portuguese, Brazilian translations
(cherry picked from commit d04cea4724)
2026-02-19 08:29:55 +00:00
Jannat Patel
5d3b3140a3 chore: Vietnamese translations
(cherry picked from commit 46f5c903e4)
2026-02-19 08:29:54 +00:00
Jannat Patel
b906a33cf8 chore: Chinese Simplified translations
(cherry picked from commit f94bafd44f)
2026-02-19 08:29:54 +00:00
Jannat Patel
fc7c240535 chore: Turkish translations
(cherry picked from commit afe1a18779)
2026-02-19 08:29:54 +00:00
Jannat Patel
1399795a5c chore: Swedish translations
(cherry picked from commit 1fc57f4325)
2026-02-19 08:29:54 +00:00
Jannat Patel
a5da3503dd chore: Serbian (Cyrillic) translations
(cherry picked from commit fdc3c610aa)
2026-02-19 08:29:53 +00:00
Jannat Patel
7e175c157e chore: Slovenian translations
(cherry picked from commit 2eef45125e)
2026-02-19 08:29:53 +00:00
Jannat Patel
68f9523151 chore: Russian translations
(cherry picked from commit 7c62431e4c)
2026-02-19 08:29:53 +00:00
Jannat Patel
6e721bf17a chore: Portuguese translations
(cherry picked from commit d26d2c90f5)
2026-02-19 08:29:52 +00:00
Jannat Patel
a9d28b530b chore: Polish translations
(cherry picked from commit e835e58695)
2026-02-19 08:29:52 +00:00
Jannat Patel
6026b1e2a3 chore: Dutch translations
(cherry picked from commit e3c3967465)
2026-02-19 08:29:52 +00:00
Jannat Patel
be75ad2617 chore: Italian translations
(cherry picked from commit 680053d1a3)
2026-02-19 08:29:52 +00:00
Jannat Patel
2f4c20b8f0 chore: Hungarian translations
(cherry picked from commit 80052ed221)
2026-02-19 08:29:51 +00:00
Jannat Patel
00b9cbe8ca chore: German translations
(cherry picked from commit c37250eb06)
2026-02-19 08:29:51 +00:00
Jannat Patel
f542c86d1a chore: Danish translations
(cherry picked from commit 042d496497)
2026-02-19 08:29:51 +00:00
Jannat Patel
4b54c60e87 chore: Czech translations
(cherry picked from commit 2c1a9775e1)
2026-02-19 08:29:50 +00:00
Jannat Patel
00f175b16b chore: Arabic translations
(cherry picked from commit 87b3a32814)
2026-02-19 08:29:50 +00:00
Jannat Patel
f62a66411b chore: Spanish translations
(cherry picked from commit 61d4738a84)
2026-02-19 08:29:50 +00:00
Jannat Patel
fb139c06fd chore: French translations
(cherry picked from commit b266ee5072)
2026-02-19 08:29:50 +00:00
Jannat Patel
51cd8bf11c chore: Italian translations
(cherry picked from commit 9cebf53ea4)
2026-02-19 08:29:49 +00:00
RitvikSardana
79b2bf4a25 Merge pull request #3034 from frappe/mergify/bp/main-hotfix/pr-2970
fix: add type hints (backport #2970)
2026-02-19 13:58:32 +05:30
Ritvik Sardana
b2781617ff fix: add type hints
(cherry picked from commit f9240bc248)
2026-02-19 08:27:43 +00:00
RitvikSardana
4b1c447bc2 Merge pull request #3033 from frappe/mergify/bp/main-hotfix/pr-2966
fix: perm issues (backport #2966)
2026-02-19 13:56:49 +05:30
RitvikSardana
8592645e0e fix: resolve merge conflict 2026-02-19 13:51:13 +05:30
Pratik
a6a515cce3 fix: use standard api for fetching agent
(cherry picked from commit 064110d940)

# Conflicts:
#	desk/vite.config.js
2026-02-19 08:18:28 +00:00
Pratik
d365afc249 fix: security issues
(cherry picked from commit 348884a912)

# Conflicts:
#	desk/src/types.ts
#	desk/vite.config.js
2026-02-19 08:18:28 +00:00
RitvikSardana
0d65b35dff Merge pull request #3032 from frappe/mergify/bp/main-hotfix/pr-2960
fix: remove unwanted files (backport #2960)
2026-02-19 13:47:05 +05:30
RitvikSardana
9de29f4122 fix: resolve merge conflict 2026-02-19 13:42:21 +05:30
Pratik
f4b35e718b fix: use standard api for fetching agent
(cherry picked from commit 69c8626f80)
2026-02-19 08:05:01 +00:00
Pratik
1dcf550f52 fix: security issues
(cherry picked from commit b3b6ac9a04)

# Conflicts:
#	desk/src/types.ts
2026-02-19 08:05:01 +00:00
Ritvik Sardana
f86732e1a8 fix: version bump
(cherry picked from commit b219aefb20)

# Conflicts:
#	helpdesk/__init__.py
2026-02-19 08:05:00 +00:00
Ritvik Sardana
87e84e2574 fix: version bump
(cherry picked from commit d150c290fb)

# Conflicts:
#	helpdesk/__init__.py
2026-02-19 08:05:00 +00:00
Ritvik Sardana
8279162135 fix(test): update test cases
(cherry picked from commit d2dbddd5a4)
2026-02-19 08:05:00 +00:00
Ritvik Sardana
8de2124435 fix: add perm checks for API endpoints
(cherry picked from commit e4ee29f95e)
2026-02-19 08:04:59 +00:00
Ritvik Sardana
a566adbde1 fix: select perms instead of read perm for correct doctypes
(cherry picked from commit 0da0b73dc8)
2026-02-19 08:04:59 +00:00
Ritvik Sardana
2ffaa84659 fix(security): remove roles and unwanted files
(cherry picked from commit b45a658b01)
2026-02-19 08:04:59 +00:00
RitvikSardana
fc4bf5460c Merge pull request #3030 from frappe/mergify/bp/main-hotfix/pr-2932
fix(patch): ticket id not found (backport #2932)
2026-02-19 13:33:49 +05:30
Ritvik Sardana
a24c08853b fix(patch): ticket id not found
(cherry picked from commit 73604e7f11)
2026-02-19 07:57:17 +00:00
RitvikSardana
998c7c0232 Merge pull request #3029 from frappe/mergify/bp/main-hotfix/pr-2900
fix: add fields for last customer and agent response (backport #2900)
2026-02-19 13:26:20 +05:30
RitvikSardana
ffd8243d7b fix: resolve merge conflict 2026-02-19 13:21:14 +05:30
Ritvik Sardana
8d24279974 chore: code cleanup
(cherry picked from commit af46f0b818)

# Conflicts:
#	desk/src/components/notifications/Notifications.vue
2026-02-19 07:47:17 +00:00
Ritvik Sardana
0f570d8e71 chore: code cleanup
(cherry picked from commit 8f2baba838)
2026-02-19 07:47:17 +00:00
Ritvik Sardana
51c5cdbfe4 fix: better patch
(cherry picked from commit 41578afca1)
2026-02-19 07:47:17 +00:00
Ritvik Sardana
3200592b6f fix: better patch
(cherry picked from commit b3fa7241d7)
2026-02-19 07:47:16 +00:00
Ritvik Sardana
622bd684a6 fix: add fields for last customer and agent response
(cherry picked from commit 9eb3902f63)
2026-02-19 07:47:16 +00:00
RitvikSardana
628e1ef95a Merge pull request #3028 from frappe/mergify/bp/main-hotfix/pr-2930
fix: notification styles and order by (backport #2930)
2026-02-19 13:10:59 +05:30
Ritvik Sardana
916092ef18 fix: notification styles and order by
(cherry picked from commit c96b00db75)
2026-02-19 07:36:16 +00:00
RitvikSardana
49fae54f1b Merge pull request #2908 from frappe/mergify/bp/main-hotfix/pr-2907
fix: duplicate mention notification (backport #2907)
2026-02-19 13:05:20 +05:30
RitvikSardana
ed0903a59f Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2907 2026-02-19 12:51:56 +05:30
RitvikSardana
0adb24c28d Merge pull request #3027 from frappe/mergify/bp/main-hotfix/pr-2812
feat: add comment reactions (thumbs up) feature (backport #2812)
2026-02-19 12:50:41 +05:30
RitvikSardana
fb4106f057 fix: merge conflicts 2026-02-19 12:45:14 +05:30
Ritvik Sardana
816b216ee4 fix(styles): reaction tooltip
(cherry picked from commit 2c3e8dbcb2)
2026-02-19 07:06:50 +00:00
samarth93
ec34dde51d test: enable comment reactions setting in test setup
(cherry picked from commit 96faca2b87)
2026-02-19 07:06:50 +00:00
Ritvik Sardana
e0b1b426a3 chore: arrange child table properly
(cherry picked from commit 813841f2b2)
2026-02-19 07:06:49 +00:00
Ritvik Sardana
89af35bc94 fix: add socket support and make color changes
(cherry picked from commit c8ab762d89)
2026-02-19 07:06:49 +00:00
samarth93
253201838e feat: add enable_comment_reactions setting to toggle feature
(cherry picked from commit 0b05a7c021)

# Conflicts:
#	desk/src/stores/config.ts
#	helpdesk/api/config.py
2026-02-19 07:06:49 +00:00
samarth93
074229ab7d fix: use format() instead of f-string for Python 3.14 compatibility
(cherry picked from commit 7a18b330ca)
2026-02-19 07:06:48 +00:00
samarth93
67fa875028 style: remove trailing comma for prettier
(cherry picked from commit 8d1101c7eb)
2026-02-19 07:06:48 +00:00
samarth93
702e51c478 test: update assertions for new notification message format
(cherry picked from commit de070ed4ef)
2026-02-19 07:06:48 +00:00
samarth93
300427a5e5 style: apply prettier formatting
(cherry picked from commit 1ca388de7b)

# Conflicts:
#	desk/src/components/notifications/Notifications.vue
2026-02-19 07:06:47 +00:00
samarth93
477560e098 fix: improve reaction notification display and routing
(cherry picked from commit f69de39a09)

# Conflicts:
#	desk/src/components/notifications/Notifications.vue
2026-02-19 07:06:47 +00:00
samarth93
31c5191fee fix: wrap user-facing strings in translate function
(cherry picked from commit f313fd00b1)
2026-02-19 07:06:47 +00:00
samarth93
66de85079c fix: prettier formatting
(cherry picked from commit 270ece00f9)
2026-02-19 07:06:46 +00:00
samarth93
abbff7d4e0 fix: pre-commit formatting
(cherry picked from commit 73673ea050)
2026-02-19 07:06:46 +00:00
samarth93
3fa46f9806 feat: grouped notifications and UI refinements for comment reactions
(cherry picked from commit 7535c416a1)
2026-02-19 07:06:46 +00:00
samarth93
620404292f chore: remove temporary pr description file
(cherry picked from commit 18c2aa1f69)
2026-02-19 07:06:45 +00:00
samarth93
f658c54933 fix: use dynamic ticket name in tests
The test was failing because it tried to use hardcoded
'TEST-TICKET-001' name but HD Ticket uses autoname.

Changed to:
- Create ticket dynamically and store in self.test_ticket
- Reference self.test_ticket.name for comments
- Clean up ticket in tearDown

(cherry picked from commit 3a3c3941df)
2026-02-19 07:06:45 +00:00
samarth93
8f46d5acc3 fix: remove unused username variable
Fixes linter error from unused variable

(cherry picked from commit 6733de3f00)
2026-02-19 07:06:45 +00:00
samarth93
0357db16b2 feat: implement preset emojis with validation and comprehensive tests
Addressing feedback:
1. One reaction per user (not multiple)
2. Preset emojis only: ['👍', '👎', '❤️', '🎉', '👀', '✅']
3. API validation to block non-preset emojis
4. Updated notification message: 'reacted with X on your comment'
5. Comprehensive test cases covering all requirements

Backend changes:
- Add PRESET_EMOJIS constant
- Add emoji validation in toggle_reaction API
- Change to one reaction per user (replaces on different emoji)
- Update notification message format
- Add get_preset_emojis() API

Frontend changes:
- Update emojiList to match preset emojis

Test cases added:
- test_one_reaction_per_user: Verifies only one reaction allowed
- test_reaction_count_accuracy: Tests count with 10 hearts, 3 thumbs up
- test_notification_created_on_reaction: Validates HD Notification creation
- test_only_preset_emojis_allowed: Ensures only preset emojis accepted
- test_reaction_toggle_behavior: Tests toggle on/off behavior
- test_get_reactions_returns_correct_data: Validates API response structure

(cherry picked from commit 5671b4baa8)
2026-02-19 07:06:45 +00:00
samarth93
0142096774 fix: rename to HD Comment Reaction and allow multiple reactions
Addressing feedback from @RitvikSardana:
1. Renamed child table from 'HD Reaction' to 'HD Comment Reaction'
2. Allow users to add multiple different reactions (not just one)
3. Updated PR description with screenshot

Changes:
- Rename HD Reaction → HD Comment Reaction (DocType name)
- Update toggle_reaction logic to allow multiple reactions per user
- Users can now react with different emojis (👍 + ❤️ + 🎉 etc)
- Clicking same emoji toggles it off
- Updated all references in code

(cherry picked from commit 26a20ac1d7)
2026-02-19 07:06:44 +00:00
samarth93
8c8480d558 refactor: store reactions as child table with emoji picker
Based on feedback from @RitvikSardana:
- Replace standalone HD Comment Reaction with HD Reaction child table
- Store reactions inside HD Ticket Comment (like Gameplan)
- Add emoji picker with multiple emoji options (👍👎❤️😄🎉😕👀🚀)
- Users can only have one reaction per comment (clicking different emoji replaces)

Changes:
- New HD Reaction DocType (child table with emoji + user)
- Add reactions Table field to HD Ticket Comment
- Add toggle_reaction() and get_reactions() APIs
- Update CommentBox.vue with emoji picker popover
- Remove old HD Comment Reaction standalone DocType

(cherry picked from commit 55ce67903b)
2026-02-19 07:06:44 +00:00
samarth93
61ff676bf4 feat: add comment reactions (thumbs up) feature
Allows users to react to comments with a thumbs up emoji instead of
creating new comments. This reduces ticket clutter when acknowledging
a comment.

Changes:
- Add new HD Comment Reaction DocType to store reactions
- Add toggle_reaction and get_comment_reactions API endpoints
- Add reaction UI to CommentBox.vue with hover tooltip showing users
- Send notification to comment author when reaction is added

Backend:
- helpdesk/helpdesk/doctype/hd_comment_reaction/ - new DocType
- API: toggle_reaction(comment, reaction) - add/remove reaction
- API: get_comment_reactions(comment) - list reactions with users

Frontend:
- CommentBox.vue - thumbs up button with count and user tooltip
- Highlights button when current user has reacted
- Real-time update via publish_event

(cherry picked from commit d8c1f8ca19)
2026-02-19 07:06:44 +00:00
Ritvik Sardana
39f89f16ff fix: type check for ticket doctype with int | str
(cherry picked from commit edc3d29d74)

# Conflicts:
#	helpdesk/api/doc.py
#	helpdesk/api/saved_replies.py
#	helpdesk/helpdesk/doctype/hd_ticket/api.py
2026-02-17 15:54:26 +00:00
RitvikSardana
c4f32eda16 Merge pull request #3023 from frappe/mergify/bp/main-hotfix/pr-2623
fix: ignore automated system notifications in ticket communication from automatically reopens the ticket. (backport #2623)
2026-02-17 20:08:14 +05:30
MostafaKadry
f3a701bfab fix: ignore automated system notifications in ticket communication handling
(cherry picked from commit ee86eb6a0e)
2026-02-17 14:37:04 +00:00
RitvikSardana
cec8db5626 Merge pull request #3021 from frappe/mergify/bp/main-hotfix/pr-3020
fix: filter personal replies from list (backport #3020)
2026-02-17 20:06:54 +05:30
Ritvik Sardana
1cadd07155 fix: revert CP changes
(cherry picked from commit 60584fcd4d)
2026-02-17 14:36:51 +00:00
Ritvik Sardana
894a588928 fix: order_by in Ticket Summary report
(cherry picked from commit a096e1b19f)
2026-02-17 14:36:51 +00:00
Ritvik Sardana
280ef71fc1 fix: dont send notification if agent is same as session.user
(cherry picked from commit d6d4c4dfe9)
2026-02-17 14:36:50 +00:00
Ritvik Sardana
51b0bad8c8 fix: redisearch syntax error
(cherry picked from commit adb3ee3b0d)
2026-02-17 14:36:50 +00:00
Ritvik Sardana
9ec01333d3 fix: redisearch syntax error
(cherry picked from commit 93bc04605e)
2026-02-17 14:36:50 +00:00
Ritvik Sardana
b0f275487a fix: CP styles
(cherry picked from commit 29ad7da211)
2026-02-17 14:36:49 +00:00
Ritvik Sardana
0124948ed8 fix: append saved reply to email content instead of replacing it
(cherry picked from commit ea60812f81)
2026-02-17 14:36:49 +00:00
Ritvik Sardana
c1aa15145f fix: socket and saved reply
(cherry picked from commit 69e047f69e)

# Conflicts:
#	desk/src/pages/ticket/TicketCustomer.vue
2026-02-17 14:36:49 +00:00
Pratik
49af2e1fe9 fix: saved reply admin test
(cherry picked from commit 576432d380)
2026-02-17 12:34:42 +00:00
Pratik
981515387f fix: filter personal replies from list
(cherry picked from commit c13aabdbdc)
2026-02-17 12:34:42 +00:00
RitvikSardana
1f5ffe5084 Merge pull request #3019 from frappe/mergify/bp/main-hotfix/pr-3018
chore: project URLs (backport #3018)
2026-02-17 17:31:43 +05:30
Ankush Menat
6d5f8535b5 chore: project URLs (#3018)
(cherry picked from commit d6be9938ad)
2026-02-17 10:20:53 +00:00
RitvikSardana
2cc2987d94 Merge pull request #2998 from frappe/mergify/bp/main-hotfix/pr-2811
fix: add DatePicker support for custom Date/Datetime fields in UniInput (backport #2811)
2026-02-09 15:28:16 +05:30
RitvikSardana
d95d3ee878 Merge pull request #2997 from frappe/mergify/bp/main-hotfix/pr-2996
fix: add login endpoints in auth (backport #2996)
2026-02-09 15:27:57 +05:30
RitvikSardana
5b7dc536df Merge pull request #2995 from frappe/mergify/bp/main-hotfix/pr-2991
chore: update POT file (backport #2991)
2026-02-09 15:27:47 +05:30
Ritvik Sardana
8b5cde9bbd fix: add support in ticket creation form
(cherry picked from commit 8e64cf9cca)
2026-02-09 09:51:38 +00:00
Ritvik Sardana
ad52394ce6 fix: add date/datetime support in agent and customer ticket view
(cherry picked from commit df7deec952)
2026-02-09 09:51:38 +00:00
samarth93
f9c10d3d8b fix: prettier formatting
(cherry picked from commit 5e3173ac5a)
2026-02-09 09:51:38 +00:00
samarth93
4e305315c2 fix: add DatePicker support for custom Date/Datetime fields in UniInput
Fixes #2669

Custom Date and Datetime fields on the new ticket form were rendering
as plain text inputs instead of proper DatePicker/DateTimePicker
components.

Changes:
- Import DatePicker and DateTimePicker from frappe-ui
- Add handling for 'Date' fieldtype to render DatePicker component
- Add handling for 'Datetime' fieldtype to render DateTimePicker component
- Add date formatting in transValue for proper display

(cherry picked from commit d42af58d88)
2026-02-09 09:51:37 +00:00
Ritvik Sardana
c56fc87826 fix: add login endpoints in auth
(cherry picked from commit 8478892383)
2026-02-09 06:40:57 +00:00
frappe-pr-bot
7b2d73dea2 chore: update POT file
(cherry picked from commit c47a44096c)
2026-02-09 06:01:59 +00:00
RitvikSardana
f8ea7765a0 Merge pull request #2989 from frappe/main-hotfix
chore(release): hotfix to main
2026-02-08 01:05:11 +05:30
RitvikSardana
a931adcb3d Merge pull request #2985 from frappe/mergify/bp/main-hotfix/pr-2973
fix: added support for mail with display names and latin characters (backport #2973)
2026-02-07 19:46:49 +05:30
RitvikSardana
101f5214f3 Merge pull request #2984 from frappe/mergify/bp/main-hotfix/pr-2977
chore: sync translations from crowdin (backport #2977)
2026-02-07 19:46:41 +05:30
RitvikSardana
2f32852840 chore: bump version 2026-02-07 19:38:39 +05:30
RitvikSardana
1efeb5f7f9 fix: remove canned response from sidebar 2026-02-07 19:36:52 +05:30
Sydney
42fce2b60e fix: add deboubnce and use zod validation for email
(cherry picked from commit 3b5b2ce9c6)
2026-02-06 09:08:13 +00:00
Sydney
ba8f819eb4 fix: fixed mail backspace bug and added support for multiinput for normalization
(cherry picked from commit d00431f51c)
2026-02-06 09:08:12 +00:00
Sydney
14a2dff554 fix: add regex support for latin characters and input support for MultiInput for mail ids
(cherry picked from commit 257c12e79f)
2026-02-06 09:08:12 +00:00
Sydney
5fcdd736f1 fix: add support for display names in mail
(cherry picked from commit 233b935393)
2026-02-06 09:08:12 +00:00
Jannat Patel
f37a70fab3 chore: Persian translations
(cherry picked from commit e6c267338c)
2026-02-06 09:03:42 +00:00
Jannat Patel
a385e9e05b chore: Portuguese, Brazilian translations
(cherry picked from commit 85a0644593)
2026-02-06 09:03:42 +00:00
Jannat Patel
1724565f7f chore: Swedish translations
(cherry picked from commit a91f4867c4)
2026-02-06 09:03:42 +00:00
Jannat Patel
e18bbd2eeb chore: Dutch translations
(cherry picked from commit 420ef47b66)
2026-02-06 09:03:41 +00:00
Jannat Patel
1ecac6270a chore: Italian translations
(cherry picked from commit cc665c838f)
2026-02-06 09:03:41 +00:00
Jannat Patel
60eb1e39fb chore: Spanish translations
(cherry picked from commit 22901fd402)
2026-02-06 09:03:41 +00:00
RitvikSardana
7ec09103da Merge pull request #2983 from frappe/mergify/bp/main-hotfix/pr-2972
fix: hide captions in text editor (backport #2972)
2026-02-06 14:32:41 +05:30
RitvikSardana
0c8704b08b Merge pull request #2982 from frappe/mergify/bp/main-hotfix/pr-2981
fix: sprint fixes-1 (backport #2981)
2026-02-06 14:32:32 +05:30
RitvikSardana
b05cd37cd3 Merge pull request #2968 from frappe/mergify/bp/main-hotfix/pr-2939
chore: sync translations from crowdin (backport #2939)
2026-02-06 14:25:54 +05:30
RitvikSardana
abc20dd161 Merge pull request #2976 from frappe/mergify/bp/main-hotfix/pr-2956
chore(setup): Add build info in pyproject (backport #2956)
2026-02-06 14:25:01 +05:30
RitvikSardana
39f0545c8f chore: fix merge conflicts 2026-02-06 14:17:59 +05:30
Sydney
4565a76e7c chore: disable image captions all over
(cherry picked from commit 64f94848ea)
2026-02-06 08:44:31 +00:00
Sydney
5dea5e3497 fix: disable input for image captions in textinput
(cherry picked from commit 23571113f6)
2026-02-06 08:44:31 +00:00
Ritvik Sardana
933fe94eac chore: add type hints to api
(cherry picked from commit 4c73593c8a)
2026-02-06 08:44:19 +00:00
Ritvik Sardana
cd6050f5f1 fix: persist count in LV
(cherry picked from commit 4c594535a6)
2026-02-06 08:44:19 +00:00
Ritvik Sardana
81322323c4 fix: add seen functionality in list view
(cherry picked from commit 5a4f152d52)
2026-02-06 08:44:18 +00:00
Ritvik Sardana
54414613ee fix(search): prepare doc if field exists
(cherry picked from commit a7135bc17b)
2026-02-06 08:44:18 +00:00
Ritvik Sardana
3713cd6b2c fix: persist rows count in list view
(cherry picked from commit e92963357f)
2026-02-06 08:44:18 +00:00
Aradhya-Tripathi
3d44b09851 chore(build): Setup out dir and index from project root
(cherry picked from commit a76ff09d6e)

# Conflicts:
#	pyproject.toml
2026-02-05 05:59:27 +00:00
Aradhya-Tripathi
6de40f928b chore(setup): Add build info in pyproject
(cherry picked from commit ce4c738898)

# Conflicts:
#	pyproject.toml
2026-02-05 05:59:26 +00:00
RitvikSardana
b90f20ee21 chore: Arabic translations
(cherry picked from commit 29ca9701c2)
2026-02-02 16:14:56 +00:00
RitvikSardana
f112dc08df chore: Dutch translations
(cherry picked from commit 09552d7e90)
2026-02-02 16:14:55 +00:00
RitvikSardana
637f3879db Merge pull request #2965 from frappe/main-hotfix
chore(release): hotfix to main
2026-02-02 14:07:51 +05:30
RitvikSardana
e156154855 Merge pull request #2962 from frappe/mergify/bp/main-hotfix/pr-2961
fix: add signup auth and redirect route perm (backport #2961)
2026-02-02 13:56:11 +05:30
RitvikSardana
18a2e02525 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2961 2026-02-02 13:49:58 +05:30
RitvikSardana
bd56224691 Merge pull request #2944 from frappe/mergify/bp/main-hotfix/pr-2943
fix: add OAuth2 paths (backport #2943)
2026-02-02 13:49:33 +05:30
RitvikSardana
ed3e27c8c5 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2943 2026-02-02 13:43:13 +05:30
RitvikSardana
fa3bcf27ae Merge pull request #2964 from frappe/mergify/bp/main-hotfix/pr-2941
fix: custom login auth path (backport #2941)
2026-02-02 13:41:36 +05:30
Ritvik Sardana
bead00ad68 fix: custom login auth path
(cherry picked from commit 2dc26b378d)
2026-02-02 08:09:46 +00:00
RitvikSardana
83f4d35ad0 Merge pull request #2963 from frappe/mergify/bp/main-hotfix/pr-2933
fix(security): limit auth hooks (backport #2933)
2026-02-02 13:38:57 +05:30
Ritvik Sardana
4434c20716 fix(security): limit auth hooks
(cherry picked from commit a7596090c4)
2026-02-02 08:08:17 +00:00
Ritvik Sardana
d6d7c1f5dd fix: version bump
(cherry picked from commit a3116603c7)
2026-02-02 08:05:23 +00:00
Ritvik Sardana
94ce44ad02 fix: version bump
(cherry picked from commit 1ce95fcab5)
2026-02-02 08:05:23 +00:00
Ritvik Sardana
2aa9533d26 fix: add signup auth and redirect route perm
(cherry picked from commit b625a95e22)

# Conflicts:
#	helpdesk/auth.py
2026-02-02 08:05:22 +00:00
Shariq Ansari
c9b2fc1291 Merge pull request #2953 from frappe/mergify/bp/main/pr-2951
fix: posthog optional chaining added to fix delete button bug (backport #2951)
2026-01-28 21:01:21 +05:30
Shariq Ansari
862bb94cbc Merge pull request #2952 from frappe/mergify/bp/main-hotfix/pr-2951
fix: posthog optional chaining added to fix delete button bug (backport #2951)
2026-01-28 20:58:33 +05:30
Sydney
ac504d84ff fix: posthog optional chaining added to fix close button bug
(cherry picked from commit 82d3751f80)
2026-01-28 15:27:01 +00:00
Sydney
6e4545a058 fix: posthog optional chaining added to fix close button bug
(cherry picked from commit 82d3751f80)
2026-01-28 15:24:24 +00:00
Ritvik Sardana
060338d3c4 fix: add OAuth2 paths
(cherry picked from commit a94013e8d6)

# Conflicts:
#	helpdesk/auth.py
2026-01-26 07:46:10 +00:00
RitvikSardana
41abffdbbd Merge pull request #2937 from frappe/main-hotfix
chore(release): hotfix to main
2026-01-23 16:57:44 +05:30
RitvikSardana
95825dc9f6 chore: version bump 2026-01-23 16:57:20 +05:30
RitvikSardana
3974b153a8 Merge pull request #2935 from frappe/mergify/bp/main-hotfix/pr-2934
fix: fail install (backport #2934)
2026-01-23 16:56:06 +05:30
RitvikSardana
45273dabe8 Merge pull request #2936 from RitvikSardana/email-box-width
fix: email box height
2026-01-23 16:54:46 +05:30
Ritvik Sardana
e33e9203ed fix: email box height 2026-01-23 16:54:02 +05:30
Ritvik Sardana
c07d5dc043 chore: code cleanup
(cherry picked from commit 402dd042ae)
2026-01-23 11:20:07 +00:00
Ritvik Sardana
ba67d869bc fix: add agent_name field while installing
(cherry picked from commit 012b6b91ea)
2026-01-23 11:20:07 +00:00
RitvikSardana
28e909a9e0 Merge pull request #2931 from frappe/main-hotfix
chore(release): hotfix to main
2026-01-23 13:00:11 +05:30
RitvikSardana
1ebe1503c4 Merge pull request #2920 from frappe/mergify/bp/main-hotfix/pr-2880
chore: sync translations from crowdin (backport #2880)
2026-01-23 12:48:53 +05:30
RitvikSardana
4859978e0a Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2880 2026-01-23 12:48:44 +05:30
RitvikSardana
dc376d7e09 chore: version bump 2026-01-23 12:45:08 +05:30
RitvikSardana
eb605fd7b6 Merge pull request #2928 from frappe/mergify/bp/main-hotfix/pr-2922
chore: sync translations from crowdin (backport #2922)
2026-01-23 12:16:12 +05:30
RitvikSardana
22ce271aa1 chore: Bosnian translations
(cherry picked from commit e4372f72cf)
2026-01-22 18:11:20 +00:00
RitvikSardana
cc64bc1723 chore: Swedish translations
(cherry picked from commit aa70598d7a)
2026-01-22 18:11:20 +00:00
RitvikSardana
3d5692cdc6 chore: Dutch translations
(cherry picked from commit 2c4ec37fb1)
2026-01-22 18:11:20 +00:00
RitvikSardana
a989dc1af4 chore: Norwegian Bokmal translations
(cherry picked from commit 774b9073a1)
2026-01-22 18:11:20 +00:00
RitvikSardana
d61b38c674 chore: Bosnian translations
(cherry picked from commit 41fd854f2d)
2026-01-22 18:11:19 +00:00
RitvikSardana
5d51ccd525 chore: Croatian translations
(cherry picked from commit 78282416d1)
2026-01-22 18:11:19 +00:00
RitvikSardana
0ad7fdaa8d chore: Portuguese, Brazilian translations
(cherry picked from commit 3da40a1fed)
2026-01-22 18:11:19 +00:00
RitvikSardana
3aa0ce46ef chore: Vietnamese translations
(cherry picked from commit c968fdf0f3)
2026-01-22 18:11:19 +00:00
RitvikSardana
5032dc611a chore: Chinese Simplified translations
(cherry picked from commit 52c01e93e9)
2026-01-22 18:11:18 +00:00
RitvikSardana
244838fcef chore: Turkish translations
(cherry picked from commit 4b7a065bae)
2026-01-22 18:11:18 +00:00
RitvikSardana
7eecb4c173 chore: Swedish translations
(cherry picked from commit 7932b40420)
2026-01-22 18:11:18 +00:00
RitvikSardana
488956db07 chore: Slovenian translations
(cherry picked from commit 66b50e3799)
2026-01-22 18:11:18 +00:00
RitvikSardana
9367660af3 chore: Portuguese translations
(cherry picked from commit 71780d5e04)
2026-01-22 18:11:17 +00:00
RitvikSardana
a878780786 chore: Polish translations
(cherry picked from commit 45bf04d592)
2026-01-22 18:11:17 +00:00
RitvikSardana
bac49e41a3 chore: Italian translations
(cherry picked from commit 6f161fd717)
2026-01-22 18:11:17 +00:00
RitvikSardana
84ac4c2f48 chore: German translations
(cherry picked from commit 0ca91888d2)
2026-01-22 18:11:17 +00:00
RitvikSardana
cce5a30496 chore: Arabic translations
(cherry picked from commit c18eb9f361)
2026-01-22 18:11:16 +00:00
RitvikSardana
d44ffe4b24 chore: Spanish translations
(cherry picked from commit f9b2b423af)
2026-01-22 18:11:16 +00:00
RitvikSardana
8b34a4a9e2 chore: French translations
(cherry picked from commit 57d00bb726)

# Conflicts:
#	helpdesk/locale/fr.po
2026-01-22 18:11:16 +00:00
RitvikSardana
8b8c92f5bb chore: Indonesian translations
(cherry picked from commit e60cedd170)
2026-01-22 18:11:15 +00:00
RitvikSardana
e124cb2dcf chore: Russian translations
(cherry picked from commit 1dbec24253)
2026-01-22 18:11:15 +00:00
RitvikSardana
95355c67c4 chore: Persian translations
(cherry picked from commit dc1bb0596a)
2026-01-22 18:11:15 +00:00
RitvikSardana
e945f118a7 chore: Dutch translations
(cherry picked from commit cc9a1638f6)
2026-01-22 18:11:15 +00:00
RitvikSardana
7f8d3be345 chore: Hungarian translations
(cherry picked from commit 9447c8ccd4)
2026-01-22 18:11:14 +00:00
RitvikSardana
93cef0c509 Merge pull request #2924 from frappe/mergify/bp/main-hotfix/pr-2923
fix(patch): rename canned response (backport #2923)
2026-01-22 23:29:27 +05:30
Pratik
c23e8b001e fix: rename canned response patch
(cherry picked from commit 1888f24f46)
2026-01-22 10:14:39 +00:00
RitvikSardana
ca6ae4cdf4 chore: French translations
(cherry picked from commit 79943278e9)
2026-01-20 11:37:59 +00:00
RitvikSardana
54261e3c3e Merge pull request #2919 from frappe/mergify/bp/main-hotfix/pr-2914
fix(HD Agent): make fields mandatory (backport #2914)
2026-01-20 10:42:04 +05:30
Ritvik Sardana
76edb52a1d chore: code cleanup
(cherry picked from commit 8b765ba34d)
2026-01-20 04:51:39 +00:00
Ritvik Sardana
e9aa895159 test: add agent_name field in test utils
(cherry picked from commit 6944796c8e)
2026-01-20 04:51:39 +00:00
Ritvik Sardana
42b1a7862c test: add agent_name field in test utils
(cherry picked from commit 04d289bb95)
2026-01-20 04:51:39 +00:00
Ritvik Sardana
67c59d26de fix(HD Agent): make fields mandatory
(cherry picked from commit c34dfc18ac)
2026-01-20 04:51:39 +00:00
RitvikSardana
07fa11fa87 Merge pull request #2917 from frappe/mergify/bp/main-hotfix/pr-2913
fix: update yarn lock in frontend folder (backport #2913)
2026-01-20 00:41:22 +05:30
RitvikSardana
8b1fb35730 chore: resolve conflicts 2026-01-20 00:32:39 +05:30
Ritvik Sardana
346275d1b1 fix: update yarn lock in frontend folder
(cherry picked from commit b784ea807f)

# Conflicts:
#	desk/src/components/notifications/Notifications.vue
2026-01-19 18:59:28 +00:00
RitvikSardana
5a271df560 Merge pull request #2916 from frappe/mergify/bp/main-hotfix/pr-2912
fix: update lock file (backport #2912)
2026-01-20 00:28:13 +05:30
Ritvik Sardana
beefbdce11 fix: update lock file
(cherry picked from commit 6d6d3498cf)
2026-01-19 18:57:24 +00:00
RitvikSardana
1770545fc0 Merge pull request #2915 from frappe/mergify/bp/main-hotfix/pr-2910
fix(reactivity): mentions dropdown (backport #2910)
2026-01-20 00:24:00 +05:30
Ritvik Sardana
9336363968 fix(reactivity): mentions dropdown
(cherry picked from commit 53fe99dc8a)
2026-01-19 18:53:24 +00:00
RitvikSardana
8241dc83e7 Merge pull request #2909 from frappe/revert-2906-mergify/bp/main-hotfix/pr-2905
Revert "fix: email scroll & notifications (backport #2905)"
2026-01-19 15:18:51 +05:30
RitvikSardana
ab9f3bc818 Revert "fix: email scroll & notifications (backport #2905)" 2026-01-19 14:03:47 +05:30
Ritvik Sardana
10cd71be8e fix: notify only when comment is updated
(cherry picked from commit 07ea05aa37)

# Conflicts:
#	helpdesk/helpdesk/doctype/hd_ticket_comment/hd_ticket_comment.py
2026-01-19 08:32:15 +00:00
Ritvik Sardana
a3959a6da9 fix: duplicate mention notification
(cherry picked from commit 67a753e5fb)

# Conflicts:
#	helpdesk/helpdesk/doctype/hd_ticket_comment/hd_ticket_comment.py
2026-01-19 08:32:15 +00:00
RitvikSardana
8d3b676c03 Merge pull request #2906 from frappe/mergify/bp/main-hotfix/pr-2905
fix: email scroll & notifications (backport #2905)
2026-01-19 13:06:40 +05:30
Ritvik Sardana
96911c65d3 fix: render notifications properly
(cherry picked from commit c696a595dd)

# Conflicts:
#	desk/src/components/notifications/Notifications.vue
2026-01-19 05:33:06 +00:00
Ritvik Sardana
b43107add2 fix(EmailBox): load content properly
(cherry picked from commit f4339b51e1)
2026-01-19 05:33:06 +00:00
RitvikSardana
0f62199ae7 Merge pull request #2899 from frappe/main-hotfix
chore(release): hotfix to main
2026-01-16 12:25:09 +05:30
RitvikSardana
aad1a0705d Merge branch 'main' into main-hotfix 2026-01-16 12:12:53 +05:30
RitvikSardana
db8ac693e2 Merge pull request #2889 from frappe/mergify/bp/main-hotfix/pr-2854
fix: saved reply renaming (backport #2854)
2026-01-16 12:11:36 +05:30
RitvikSardana
0408360716 Merge pull request #2898 from frappe/mergify/bp/main-hotfix/pr-2897
test(saved-reply): revert user to admin (backport #2897)
2026-01-16 12:11:19 +05:30
Ritvik Sardana
7ab0cabc1e test(saved-reply): revert user to admin
(cherry picked from commit 6c9d66e06b)
2026-01-16 06:41:02 +00:00
RitvikSardana
25f5c06b70 chore: version bump 2026-01-16 11:54:32 +05:30
RitvikSardana
5dd7bd9513 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2854 2026-01-16 11:52:41 +05:30
RitvikSardana
bc9d51960c chore: handle versioning properly 2026-01-16 10:47:00 +05:30
RitvikSardana
29a3d410b5 Merge pull request #2896 from RitvikSardana/fix-tests
fix: test cases for holidays
2026-01-16 10:22:48 +05:30
RitvikSardana
b4f4078427 Merge pull request #2888 from pratikb64/backport-2708
refactor: move canned responses into settings modal (backport #2708)
2026-01-16 10:22:33 +05:30
Ritvik Sardana
890f27cbb9 fix: test cases for holidays 2026-01-16 10:17:26 +05:30
RitvikSardana
bbd95dafa1 Merge pull request #2892 from frappe/mergify/bp/main-hotfix/pr-2891
fix(dashboard): order by count (backport #2891)
2026-01-15 15:37:21 +05:30
Ritvik Sardana
286c196ba7 fix(dashboard): order by count
(cherry picked from commit 44877bd0e8)
2026-01-15 10:06:55 +00:00
Pratik
eac1280adc fix: saved reply renaming
(cherry picked from commit 819c28a805)

# Conflicts:
#	desk/src/components/Settings/SavedReplies/SavedReplyView.vue
#	helpdesk/helpdesk/doctype/hd_saved_reply/hd_saved_reply.json
2026-01-15 05:43:17 +00:00
Pratik
be7e4f417c fix: CI fix 2026-01-15 04:18:32 +00:00
Pratik
c4d9a25c03 fix: ci lint error 2026-01-14 17:27:01 +00:00
Ritvik Sardana
2e749fc76a fix: add list count 2026-01-14 17:26:42 +00:00
Pratik
e65507e1b9 chore: add translation 2026-01-14 17:24:50 +00:00
Pratik
5042fc718d chore: add translation 2026-01-14 17:24:50 +00:00
Pratik
696802e65b fix: avatar label & loading state in reply selector modal 2026-01-14 17:24:50 +00:00
Ritvik Sardana
857fd3df32 fix(naming): use title field for naming in Saved Reply 2026-01-14 17:24:50 +00:00
Pratik
d7f4fa7711 fix: focus on reply selector modal open 2026-01-14 17:24:48 +00:00
Pratik
81b3757a17 fix: tiptap extension suggestion search 2026-01-14 17:24:12 +00:00
Pratik
0fd4178ef3 test: add test for editing personal reply 2026-01-14 17:23:35 +00:00
Pratik
73377712d3 test: add tests 2026-01-14 17:23:32 +00:00
Pratik
07737b1609 fix: loading indicator fix 2026-01-14 17:22:55 +00:00
Pratik
3effc56d84 fix: minor conditional logic fix 2026-01-14 17:22:55 +00:00
Pratik
1ca7e5c41b refactor: rename canned response to saved reply 2026-01-14 17:22:52 +00:00
Pratik
7414b40d0f fix: bug in saved reply select modal 2026-01-14 17:21:21 +00:00
Pratik
975fea9f93 fix: code cleanup & refactor patches 2026-01-14 17:21:20 +00:00
Pratik
3ac62b1583 refactor: add & use HD Saved Reply doctype 2026-01-14 17:19:55 +00:00
Pratik
5695f86e16 refactor: change file name 2026-01-14 17:19:55 +00:00
Pratik
62b51d8383 fix: add icons in scope select 2026-01-14 17:19:55 +00:00
Pratik
674fdc1194 fix: minor enhancements 2026-01-14 17:19:55 +00:00
Pratik
4252657d17 refactor: rename canned response to saved reply 2026-01-14 17:19:53 +00:00
Pratik
9dccf0c352 fix: improve toast error message in saved replies preview dialog 2026-01-14 17:19:11 +00:00
Pratik
1d2e4bdaa2 fix: improve patch logic 2026-01-14 17:19:11 +00:00
Pratik
9b2c988162 fix: owner field not getting set in patch 2026-01-14 17:19:11 +00:00
Pratik
31ef62dd96 fix: patch order 2026-01-14 17:19:08 +00:00
Pratik
7fa910dee6 fix: add patch for email template fields 2026-01-14 17:18:11 +00:00
Pratik
756618a23b fix: code cleanup & enhancements 2026-01-14 17:18:09 +00:00
Pratik
7fe8d62a6c fix: code cleanup 2026-01-14 17:17:39 +00:00
Pratik
c9d6574acf refactor: move tiptap extension 2026-01-14 17:17:39 +00:00
Pratik
8550ddb52c fix: modal permission & small code cleanup 2026-01-14 17:17:37 +00:00
Pratik
1f54124b45 feat: add ticket based restriction logic 2026-01-14 17:15:01 +00:00
Pratik Badhe
562e34e38b chore: code cleanup 2026-01-14 17:13:08 +00:00
Pratik Badhe
1fd8a17305 feat: add load more button 2026-01-14 17:12:34 +00:00
Pratik Badhe
ee9b70260b fix: handle case when user does not have team assigned 2026-01-14 17:12:34 +00:00
Pratik
6b91cc456e feat: add scope & improve UI 2026-01-14 17:12:32 +00:00
Pratik Badhe
aa214a752a fix: remove subject & add toolbar for response editor 2026-01-14 17:11:55 +00:00
Pratik Badhe
377582a48a chore: add i18n 2026-01-14 17:11:55 +00:00
Pratik Badhe
6dd1779049 refactor: move canned responses into settings modal 2026-01-14 17:11:47 +00:00
Ritvik Sardana
eb69aef769 fix: support frappe v16 2026-01-14 18:27:04 +05:30
RitvikSardana
b414902d7e Merge pull request #2885 from frappe/mergify/bp/main-hotfix/pr-2884
fix: sanitize search query (backport #2884)
2026-01-14 17:09:05 +05:30
RitvikSardana
4eb2d9fe49 Merge pull request #2887 from frappe/mergify/bp/main-hotfix/pr-2886
fix: better support for v16 (backport #2886)
2026-01-14 17:08:55 +05:30
RitvikSardana
27fcf2f0da chore: code cleanup 2026-01-14 17:07:33 +05:30
RitvikSardana
591092e3ed chore: code cleanup 2026-01-14 17:05:09 +05:30
Ritvik Sardana
2a9c9f457e fix: better support for v16
(cherry picked from commit 3b85a3b269)

# Conflicts:
#	helpdesk/test_utils.py
2026-01-14 11:28:31 +00:00
Ritvik Sardana
fae2f361a9 fix: sanitize search query
(cherry picked from commit 5182126876)
2026-01-14 11:14:18 +00:00
RitvikSardana
b02e9a3849 Merge pull request #2883 from frappe/mergify/bp/main-hotfix/pr-2878
test: add tests for ticket split and merge (backport #2878)
2026-01-14 16:41:52 +05:30
RitvikSardana
8ac3f805a5 Merge pull request #2882 from frappe/mergify/bp/main-hotfix/pr-2881
fix: minor UI fixes (backport #2881)
2026-01-14 16:41:39 +05:30
Ritvik Sardana
8ab6848f48 chore: formatting 2026-01-14 16:30:41 +05:30
RitvikSardana
21da9cb643 chore: merge conflict 2026-01-14 16:28:06 +05:30
RitvikSardana
9606e343c4 chore: formatting 2026-01-14 16:25:09 +05:30
RitvikSardana
a7f62a23fb fix: merge conflict 2026-01-14 13:55:56 +05:30
RitvikSardana
e762652860 Merge pull request #2876 from frappe/mergify/bp/main-hotfix/pr-2859
chore: sync translations from crowdin (backport #2859)
2026-01-14 13:52:31 +05:30
Ritvik Sardana
5ccfd82895 fix(tests): add email domain
(cherry picked from commit 1515970c39)
2026-01-14 07:32:00 +00:00
Ritvik Sardana
95d5ad1784 test: add tests for ticket split and merge
(cherry picked from commit b652cb9c65)

# Conflicts:
#	helpdesk/test_utils.py
2026-01-14 07:32:00 +00:00
Ritvik Sardana
09feebce83 chore: code cleanup
(cherry picked from commit 1ce75aa717)
2026-01-14 07:31:59 +00:00
Ritvik Sardana
8a394aeec5 fix: loading state in editor
(cherry picked from commit 6bd424ff83)

# Conflicts:
#	desk/src/components/EmailEditor.vue
2026-01-14 07:31:59 +00:00
Ritvik Sardana
a7af289133 fix: minor style changes
(cherry picked from commit 9602681745)
2026-01-14 07:31:58 +00:00
Ritvik Sardana
eb341e9f58 fix: order_by in ticket_summary report
(cherry picked from commit b5ff021b86)
2026-01-14 07:31:58 +00:00
RitvikSardana
defe28fdc0 Merge pull request #2879 from RitvikSardana/dayjs-import
fix: add remaining imports
2026-01-14 12:15:29 +05:30
Ritvik Sardana
fafd0d0bcd fix: add remaining imports 2026-01-13 19:20:34 +05:30
RitvikSardana
36b359326e chore: Indonesian translations
(cherry picked from commit f2f195e855)
2026-01-13 10:43:06 +00:00
RitvikSardana
543a3fae67 chore: Russian translations
(cherry picked from commit 3fe45bcf7e)
2026-01-13 10:43:05 +00:00
RitvikSardana
e3afef5dcd chore: Hungarian translations
(cherry picked from commit b8a4803824)
2026-01-13 10:43:05 +00:00
RitvikSardana
cc5b4e35fe chore: Dutch translations
(cherry picked from commit 5d8a7ba05c)
2026-01-13 10:43:05 +00:00
RitvikSardana
8727f3075b chore: Hungarian translations
(cherry picked from commit 4004daf191)
2026-01-13 10:43:05 +00:00
RitvikSardana
8304de0e69 Merge pull request #2875 from frappe/mergify/bp/main-hotfix/pr-2874
chore: update readme (backport #2874)
2026-01-13 10:44:36 +05:30
Ritvik Sardana
b780ad4253 chore: update readme
(cherry picked from commit 03fe225df7)
2026-01-13 05:07:55 +00:00
RitvikSardana
d2aa02d0d0 Merge pull request #2868 from frappe/mergify/bp/main-hotfix/pr-2867
fix: rendering @mention in comments (backport #2867)
2026-01-13 09:52:20 +05:30
Vivek Agrawal
34e07f907b fix: rendering @mention in comments
(cherry picked from commit d8228382b0)
2026-01-09 12:12:19 +00:00
RitvikSardana
a3b8bf26c2 Merge pull request #2864 from frappe/mergify/bp/main-hotfix/pr-2863
fix: dont run onClick twice (backport #2863)
2026-01-08 00:06:22 +05:30
RitvikSardana
b008091137 fix: revert translation changes
(cherry picked from commit ec82ee91a9)
2026-01-06 18:54:36 +00:00
Ritvik Sardana
e3587c09e7 fix: dont run onClick twice
(cherry picked from commit 4e1e261a51)
2026-01-06 18:54:36 +00:00
RitvikSardana
09a174f190 chore: Bump version to 1.18.1 2026-01-02 13:05:12 +05:30
RitvikSardana
e09116a523 chore: Update frappe dependency version range 2026-01-02 12:46:13 +05:30
RitvikSardana
f77349faea Merge pull request #2855 from frappe/main-hotfix
chore(release): hotfix to main
2026-01-01 23:44:26 +05:30
RitvikSardana
29d503090f chore: replace IntegrationTestCase with FrappeTestCase 2026-01-01 23:38:36 +05:30
RitvikSardana
b850d733f7 chore: update Frappe branch in install script 2026-01-01 21:49:33 +05:30
RitvikSardana
185442d153 chore: Bump version from 1.17.4 to 1.18.0 2026-01-01 21:26:41 +05:30
RitvikSardana
c56de5904b Merge pull request #2849 from frappe/mergify/bp/main-hotfix/pr-2836
feat(i18n): Add translation wrappers to Vue components (backport #2836)
2026-01-01 21:24:30 +05:30
RitvikSardana
88afe2f413 fix: add translation strings for sidebar label
(cherry picked from commit 4532c42f64)
2025-12-30 21:53:33 +00:00
Jérémy Christillin
6a8447a882 style: apply prettier formatting
(cherry picked from commit 58dfc6d95d)
2025-12-30 21:53:33 +00:00
Jérémy Christillin
253afdc16c feat(i18n): Add French translations for new i18n strings
Add 40+ French translations for settings, SLA, and workflow strings.

(cherry picked from commit 838ffcdf74)
2025-12-30 21:53:32 +00:00
Jérémy Christillin
ce17e05a70 feat(i18n): Add translation wrappers to Vue components
Add __() translation function wrappers to hardcoded strings in Vue/TypeScript
components to enable internationalization support.

Files modified:
- ListViewBuilder, SearchMultiSelect, FilterPopover components
- Settings: NewTeamModal, ExportModal
- Ticket views: TicketAgent, MobileTicketAgent, TicketCustomer, TicketAgentSidebar
- Knowledge Base: Article, NewArticle, KnowledgeBase views
- Contact: ContactDialog, SetContactPhoneModal
- Notifications: MobileNotifications
- Activity: ActivityHeader, CommunicationArea
- Customer: NewCustomerDialog

This enables proper translation of UI strings through PO files.

(cherry picked from commit f6db2f72d7)
2025-12-30 21:53:32 +00:00
RitvikSardana
1b52048b8f Merge pull request #2848 from frappe/mergify/bp/main-hotfix/pr-2839
fix(workspace): helpdesk reports routing (backport #2839)
2025-12-31 03:05:53 +05:30
RitvikSardana
010938804a Merge pull request #2847 from frappe/mergify/bp/main-hotfix/pr-2842
fix: last_synced_at to last_received_at in email sync (backport #2842)
2025-12-31 03:05:39 +05:30
KerollesFathy
1cda760798 fix(workspace): helpdesk reports routing
(cherry picked from commit c395c8b67a)
2025-12-30 21:34:28 +00:00
KerollesFathy
4e5ca82963 revert: helpdesk reports routing"
This reverts commit 7f785be3b8.

(cherry picked from commit ae97eafff5)
2025-12-30 21:34:27 +00:00
KerollesFathy
2854c573dd fix(workspace): helpdesk reports routing
(cherry picked from commit 7f785be3b8)
2025-12-30 21:34:27 +00:00
Elnegren
ddd380d2b4 fix: last_synced_at to last_received_at in email sync
(cherry picked from commit 7432d3e9d3)
2025-12-30 21:33:34 +00:00
RitvikSardana
6f381d745e Merge pull request #2845 from frappe/mergify/bp/main-hotfix/pr-2837
chore: update POT file (backport #2837)
2025-12-30 12:59:56 +05:30
RitvikSardana
b18b81c22e Merge pull request #2844 from frappe/mergify/bp/main-hotfix/pr-2833
chore: sync translations from crowdin (backport #2833)
2025-12-30 12:59:46 +05:30
frappe-pr-bot
3bdda78513 chore: update POT file
(cherry picked from commit 63d4442a54)
2025-12-30 07:25:09 +00:00
RitvikSardana
f007a1be29 chore: Persian translations
(cherry picked from commit 1218e9219a)
2025-12-30 07:15:49 +00:00
RitvikSardana
ee387164d4 chore: Spanish translations
(cherry picked from commit 930e8ec184)
2025-12-30 07:15:49 +00:00
RitvikSardana
65ce2c1333 chore: Persian translations
(cherry picked from commit 847e485fb4)
2025-12-30 07:15:49 +00:00
RitvikSardana
65467272a8 chore: Hungarian translations
(cherry picked from commit 2adbe1cdba)
2025-12-30 07:15:49 +00:00
RitvikSardana
6410ced280 chore: Hungarian translations
(cherry picked from commit 5eddc89075)
2025-12-30 07:15:48 +00:00
RitvikSardana
cc8bde3091 chore: Spanish translations
(cherry picked from commit d0c6e5167a)
2025-12-30 07:15:48 +00:00
RitvikSardana
e4316de66e Merge pull request #2801 from frappe/mergify/bp/main-hotfix/pr-2794
fix: bugs related to frappe-ui update (backport #2794)
2025-12-18 17:25:50 +05:30
Pratik Badhe
7930000998 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2794 2025-12-18 11:32:23 +05:30
RitvikSardana
f3725e53ac Merge pull request #2832 from pratikb64/backport-2664
feat: add profile, general settings page & search bars for lists (backport #2664)
2025-12-17 13:41:06 +05:30
Ritvik Sardana
0ceda51542 chore(list-view): dont show border-b for last element 2025-12-15 11:18:12 +00:00
Pratik
669812d6f9 fix: hover state 2025-12-15 11:18:12 +00:00
Pratik
c19b54df7f fix: agents list filter hover state 2025-12-15 11:18:12 +00:00
Pratik
378214e22c chore: code cleanup 2025-12-15 11:17:37 +00:00
Pratik
5bd3ff389f chore: fix description 2025-12-15 11:17:37 +00:00
Pratik
451772506d fix: dropdown bug & labels 2025-12-15 11:17:37 +00:00
Pratik
8c4b10f567 fix: spacing in agents list view dropdowns 2025-12-15 11:17:37 +00:00
Pratik
1e16241217 chore: add Dropdown component imports 2025-12-15 11:17:37 +00:00
Pratik
399c3f89cb fix: button label & use symbol for inject/provide 2025-12-15 11:17:34 +00:00
Pratik
24089711d2 fix: assignee search bug 2025-12-15 11:16:42 +00:00
Pratik
d079147540 feat: add language setting 2025-12-15 11:15:56 +00:00
Pratik
03a7b5199c fix: ui cleanup & move SettingsLayoutBase component 2025-12-15 11:10:35 +00:00
Pratik Badhe
d8c95bb39a fix: remove i18n for week days 2025-12-15 11:10:35 +00:00
Pratik Badhe
c9300824f0 fix: ui alignment 2025-12-15 11:10:35 +00:00
Pratik Badhe
8c1777f512 fix: order list view's by modified desc 2025-12-15 11:10:35 +00:00
Pratik Badhe
9ad9bea8ac feat: add teams agents selector 2025-12-15 11:10:35 +00:00
Pratik Badhe
b6e480501e ci: remove frappe db commit 2025-12-15 11:10:35 +00:00
Pratik Badhe
cc18255dc9 fix: CI lint error 2025-12-15 11:10:35 +00:00
Pratik Badhe
4f482faf58 refactor: add common settings layout base component 2025-12-15 11:10:32 +00:00
Pratik Badhe
3478361dcb chore: add i18n and code cleanup 2025-12-15 10:58:27 +00:00
Pratik Badhe
cbcd1d96ff fix: improve telephony dirty state logic 2025-12-15 10:55:45 +00:00
Pratik Badhe
f79cd726be fix: favicon icon 2025-12-15 10:55:45 +00:00
Pratik Badhe
950e23983c refactor: remove some setting options & add i18n 2025-12-15 10:55:45 +00:00
Pratik Badhe
e2bbd830ed fix: semgrep lint fix 2025-12-15 10:55:45 +00:00
Pratik Badhe
457d6e1f9c fix: semgrep lint fix 2025-12-15 10:55:45 +00:00
Pratik Badhe
6c54da1d1e refactor: improve assignment rule invite agent handling 2025-12-15 10:55:37 +00:00
Pratik Badhe
5f73ea65c7 fix: internationalization & state re-rendering 2025-12-15 10:52:18 +00:00
Pratik Badhe
a7e5f65422 fix: agent flow test 2025-12-15 10:52:18 +00:00
Pratik Badhe
f05afc8465 fix: improve team edit 2025-12-15 10:52:18 +00:00
Pratik Badhe
a57e2b5faf fix: add empty state for team edit page 2025-12-15 10:52:18 +00:00
Pratik Badhe
3baff61473 fix: semgrep CI error 2025-12-15 10:52:18 +00:00
Pratik Badhe
6ff72d7c33 fix: horizontal line conditional rendering in general settings 2025-12-15 10:52:18 +00:00
Pratik Badhe
afd6b97dd5 fix: import paths 2025-12-15 10:52:18 +00:00
Pratik Badhe
748fef89af fix: layouts & alignments 2025-12-15 10:51:41 +00:00
Pratik Badhe
daeb72b474 fix: improve logo upload functionality 2025-12-15 10:51:22 +00:00
Pratik Badhe
ce8fce52d9 fix: add unsaved changes badge in profile 2025-12-15 10:51:22 +00:00
Pratik Badhe
b2126a4606 fix: handle roles for settings modal tabs 2025-12-15 10:51:22 +00:00
Pratik Badhe
f1663e1edc fix: profile image upload loading state 2025-12-15 10:51:22 +00:00
Pratik Badhe
b7c894cea3 fix: hide search bar when items are less than 10 2025-12-15 10:51:17 +00:00
Pratik Badhe
82b14dd378 fix: assignment rule new button label 2025-12-15 10:47:12 +00:00
Pratik Badhe
97c55258d3 fix: logo upload disable state 2025-12-15 10:47:12 +00:00
Pratik Badhe
c3d411a854 fix: empty state icon alignment & brand logo, favicon edge case 2025-12-15 10:47:12 +00:00
Pratik Badhe
b78c0a63d6 fix: agents dropdown placement 2025-12-15 10:47:12 +00:00
Pratik Badhe
434a5fee9f fix: padding & spacing 2025-12-15 10:47:12 +00:00
Pratik Badhe
d53bb65e3e fix: agent name while creating new agent 2025-12-15 10:47:12 +00:00
Pratik Badhe
d1f399f7d7 fix: add email account empty state & fix agent before_save hook 2025-12-15 10:47:12 +00:00
Pratik Badhe
49a66176a2 fix: handle case where agent_name is not available 2025-12-15 10:47:12 +00:00
Pratik Badhe
804b04c17b feat: add profile, general settings page & search bars for lists 2025-12-15 10:47:06 +00:00
RitvikSardana
6d3da9def5 Merge pull request #2831 from frappe/mergify/bp/main-hotfix/pr-2823
chore: update POT file (backport #2823)
2025-12-15 13:17:26 +05:30
RitvikSardana
760d6f49ad Merge pull request #2830 from frappe/mergify/bp/main-hotfix/pr-2816
chore: sync translations from crowdin (backport #2816)
2025-12-15 13:17:19 +05:30
RitvikSardana
58ffcf76f6 Merge pull request #2829 from frappe/mergify/bp/main-hotfix/pr-2798
fix: preserve query parameters when switching tabs (#2790) (backport #2798)
2025-12-15 13:17:09 +05:30
frappe-pr-bot
4e66acaab8 chore: update POT file
(cherry picked from commit c5b6ab3569)
2025-12-15 07:22:30 +00:00
RitvikSardana
655229ff9c chore: Russian translations
(cherry picked from commit 6cd0163e2d)
2025-12-15 07:21:38 +00:00
RitvikSardana
18bdf0791a chore: Hungarian translations
(cherry picked from commit 3189db9884)
2025-12-15 07:21:38 +00:00
RitvikSardana
0806083ed8 chore: Hungarian translations
(cherry picked from commit f2bacb6200)
2025-12-15 07:21:37 +00:00
RitvikSardana
9057611f8e chore: Hungarian translations
(cherry picked from commit 032e1b0bbe)
2025-12-15 07:21:37 +00:00
RitvikSardana
49be94bb61 chore: Serbian (Latin) translations
(cherry picked from commit a1fd77f1f3)
2025-12-15 07:21:37 +00:00
RitvikSardana
066eb8b72d chore: Serbian (Cyrillic) translations
(cherry picked from commit 39e38c27ec)
2025-12-15 07:21:37 +00:00
RitvikSardana
aff7049d2d chore: Esperanto translations
(cherry picked from commit 09e6d637dd)
2025-12-15 07:21:36 +00:00
RitvikSardana
2ddc720723 chore: Slovenian translations
(cherry picked from commit fee0b713c9)
2025-12-15 07:21:36 +00:00
RitvikSardana
b790f63bb3 chore: French translations
(cherry picked from commit d492740475)
2025-12-15 07:21:36 +00:00
RitvikSardana
653b9044b8 chore: Serbian (Latin) translations
(cherry picked from commit 211781be1c)
2025-12-15 07:21:36 +00:00
RitvikSardana
9e825f5452 chore: Norwegian Bokmal translations
(cherry picked from commit 2fc900c02d)
2025-12-15 07:21:35 +00:00
RitvikSardana
a6bd06db43 chore: Bosnian translations
(cherry picked from commit 0744eb5d03)
2025-12-15 07:21:35 +00:00
RitvikSardana
21ad46f59d chore: Burmese translations
(cherry picked from commit e3661e9836)
2025-12-15 07:21:35 +00:00
RitvikSardana
29aa40de27 chore: Croatian translations
(cherry picked from commit 13aab390b0)
2025-12-15 07:21:35 +00:00
RitvikSardana
3a77ffb593 chore: Thai translations
(cherry picked from commit c64dcea9b8)
2025-12-15 07:21:34 +00:00
RitvikSardana
d7491a72ed chore: Tamil translations
(cherry picked from commit ecb076eec8)
2025-12-15 07:21:34 +00:00
RitvikSardana
cd9a429270 chore: Persian translations
(cherry picked from commit b51a22c2b6)
2025-12-15 07:21:34 +00:00
RitvikSardana
bac9e5aaa2 chore: Indonesian translations
(cherry picked from commit 50fb1c20fc)
2025-12-15 07:21:34 +00:00
RitvikSardana
126c9c1f14 chore: Portuguese, Brazilian translations
(cherry picked from commit abd6d46963)
2025-12-15 07:21:33 +00:00
RitvikSardana
ddca7c4146 chore: Vietnamese translations
(cherry picked from commit da338ef7b0)
2025-12-15 07:21:33 +00:00
RitvikSardana
6ae0023cd8 chore: Chinese Simplified translations
(cherry picked from commit b5cee665b9)
2025-12-15 07:21:33 +00:00
RitvikSardana
c2e3551e4f chore: Turkish translations
(cherry picked from commit e3a7ea1516)
2025-12-15 07:21:33 +00:00
RitvikSardana
f308079f59 chore: Swedish translations
(cherry picked from commit 68ad2efdf9)
2025-12-15 07:21:32 +00:00
RitvikSardana
470fe5949d chore: Serbian (Cyrillic) translations
(cherry picked from commit 8acff3fc51)
2025-12-15 07:21:32 +00:00
RitvikSardana
d53412258b chore: Russian translations
(cherry picked from commit 420c33787e)
2025-12-15 07:21:32 +00:00
RitvikSardana
2f0aaa7188 chore: Portuguese translations
(cherry picked from commit 93f3504009)
2025-12-15 07:21:32 +00:00
RitvikSardana
728ec2fb33 chore: Polish translations
(cherry picked from commit 7b9ff2497f)
2025-12-15 07:21:31 +00:00
RitvikSardana
20579acf55 chore: Dutch translations
(cherry picked from commit f1ad5b8e03)
2025-12-15 07:21:31 +00:00
RitvikSardana
ba7720ff2c chore: Italian translations
(cherry picked from commit 393ad9073b)
2025-12-15 07:21:31 +00:00
RitvikSardana
c2b0e9fa59 chore: Hungarian translations
(cherry picked from commit ecc17e12e7)
2025-12-15 07:21:31 +00:00
RitvikSardana
10d3c9651a chore: German translations
(cherry picked from commit 4357bd544f)
2025-12-15 07:21:30 +00:00
RitvikSardana
2221f8c33d chore: Danish translations
(cherry picked from commit 8c74a69625)
2025-12-15 07:21:30 +00:00
RitvikSardana
47416cdf3c chore: Czech translations
(cherry picked from commit 61b971c0d1)
2025-12-15 07:21:30 +00:00
RitvikSardana
dafd1d7066 chore: Arabic translations
(cherry picked from commit b2776805f6)
2025-12-15 07:21:30 +00:00
RitvikSardana
b6893210b7 chore: Spanish translations
(cherry picked from commit 97e424c56b)
2025-12-15 07:21:29 +00:00
samarth
937d8a0909 fix: preserve query parameters when switching tabs (#2790)
Fixes #2790 - Active View is removed from the query params when the page is reloaded

When navigating to a ticket from an Active View and switching between tabs,
the query parameters (including view parameter) were being removed from the URL.

Changes:
- Updated router.replace() calls in useActiveTabManager.ts to preserve
  route.query alongside route.path
- Ensures view context is maintained during tab navigation
- Applies to changeTabTo(), setActiveTab(), and hash watch handler

This fix ensures that when users navigate to a ticket from an Active View
and switch between tabs, the view parameter remains in the URL for proper
navigation context.

(cherry picked from commit 3f01c575fb)
2025-12-15 07:18:26 +00:00
RitvikSardana
5991711a60 Merge pull request #2828 from frappe/mergify/bp/main-hotfix/pr-2827
fix: use get_list to get count (backport #2827)
2025-12-15 12:47:49 +05:30
Ritvik Sardana
0afd392c53 fix: add fallback value
(cherry picked from commit 8d847962f3)
2025-12-15 06:34:21 +00:00
Ritvik Sardana
71d517fbda chore: code cleanup
(cherry picked from commit 698659f7c4)
2025-12-15 06:34:21 +00:00
Ritvik Sardana
981bb6f4a5 fix: use get_list to get count
(cherry picked from commit 28947034ba)
2025-12-15 06:34:20 +00:00
RitvikSardana
cada6fc48f Merge pull request #2815 from frappe/mergify/bp/main-hotfix/pr-2809
chore: update POT file (backport #2809)
2025-12-09 15:55:08 +05:30
RitvikSardana
0b115a925b Merge pull request #2814 from frappe/mergify/bp/main-hotfix/pr-2805
chore: sync translations from crowdin (backport #2805)
2025-12-09 15:54:50 +05:30
frappe-pr-bot
b056b87c6d chore: update POT file
(cherry picked from commit 0d5d0e2b1d)
2025-12-09 10:21:39 +00:00
RitvikSardana
f8650059d7 chore: Russian translations
(cherry picked from commit a9e35f9b9b)
2025-12-09 10:20:33 +00:00
RitvikSardana
942fb17cec chore: Hungarian translations
(cherry picked from commit 6b44eac9d1)
2025-12-09 10:20:33 +00:00
RitvikSardana
f87cfa33b7 chore: Russian translations
(cherry picked from commit 20fb1b7e42)
2025-12-09 10:20:33 +00:00
RitvikSardana
f4e081850a chore: Hungarian translations
(cherry picked from commit bdce05270a)
2025-12-09 10:20:33 +00:00
RitvikSardana
8983e0b4bd Merge pull request #2802 from frappe/mergify/bp/main-hotfix/pr-2797
chore: sync translations from crowdin (backport #2797)
2025-12-09 15:50:02 +05:30
RitvikSardana
24d64897dc Merge pull request #2804 from frappe/mergify/bp/main-hotfix/pr-2803
fix: add support for button variables in custom buttons (backport #2803)
2025-12-05 22:51:20 +05:30
Ritvik Sardana
6289d66c59 fix: add support for button variables in custom buttons
(cherry picked from commit 7f4ad4387a)
2025-12-05 17:18:06 +00:00
RitvikSardana
1281b54d7d chore: Slovenian translations
(cherry picked from commit 6f13958974)
2025-12-05 15:50:36 +00:00
RitvikSardana
c6aa12a2aa chore: Bosnian translations
(cherry picked from commit c9b969f3fd)
2025-12-05 15:50:36 +00:00
RitvikSardana
9a72503b39 chore: Croatian translations
(cherry picked from commit fa44401f6e)
2025-12-05 15:50:36 +00:00
RitvikSardana
f787395865 chore: Russian translations
(cherry picked from commit b927731bcf)
2025-12-05 15:50:36 +00:00
RitvikSardana
45dae2eb4d chore: Esperanto translations
(cherry picked from commit 39d49bba6d)
2025-12-05 15:50:35 +00:00
RitvikSardana
4899a9e9d5 chore: Slovenian translations
(cherry picked from commit 5908ac8cd1)
2025-12-05 15:50:35 +00:00
RitvikSardana
aebf2caecc chore: French translations
(cherry picked from commit 3d61f226ff)
2025-12-05 15:50:35 +00:00
RitvikSardana
e8b3fe6052 chore: Serbian (Latin) translations
(cherry picked from commit 526828ca87)
2025-12-05 15:50:35 +00:00
RitvikSardana
6c2858102a chore: Norwegian Bokmal translations
(cherry picked from commit 918aa7582f)
2025-12-05 15:50:34 +00:00
RitvikSardana
b17ea62f95 chore: Bosnian translations
(cherry picked from commit ab12cc4017)
2025-12-05 15:50:34 +00:00
RitvikSardana
2ca7d118fc chore: Burmese translations
(cherry picked from commit bb2db66f81)
2025-12-05 15:50:34 +00:00
RitvikSardana
fae8492cf3 chore: Croatian translations
(cherry picked from commit 1398427e65)
2025-12-05 15:50:34 +00:00
RitvikSardana
56461a3428 chore: Thai translations
(cherry picked from commit d329acb0d7)
2025-12-05 15:50:33 +00:00
RitvikSardana
df9c505961 chore: Tamil translations
(cherry picked from commit f39db539e7)
2025-12-05 15:50:33 +00:00
RitvikSardana
6482524705 chore: Persian translations
(cherry picked from commit 6b694352b1)
2025-12-05 15:50:33 +00:00
RitvikSardana
f4c2c12b9d chore: Indonesian translations
(cherry picked from commit 6c356d3a73)
2025-12-05 15:50:33 +00:00
RitvikSardana
299e16f1e2 chore: Portuguese, Brazilian translations
(cherry picked from commit 7c6311eeb1)
2025-12-05 15:50:32 +00:00
RitvikSardana
b099a6658b chore: Vietnamese translations
(cherry picked from commit 9a6e74f23c)
2025-12-05 15:50:32 +00:00
RitvikSardana
7f1d06588e chore: Chinese Simplified translations
(cherry picked from commit d6a96a4c6b)
2025-12-05 15:50:32 +00:00
RitvikSardana
b970d9ed82 chore: Turkish translations
(cherry picked from commit 90e1b75793)
2025-12-05 15:50:32 +00:00
RitvikSardana
712c1b6af7 chore: Swedish translations
(cherry picked from commit 9d6488ac7b)
2025-12-05 15:50:31 +00:00
RitvikSardana
5eb8bb8c05 chore: Serbian (Cyrillic) translations
(cherry picked from commit 38d326294f)
2025-12-05 15:50:31 +00:00
RitvikSardana
47dcb01852 chore: Russian translations
(cherry picked from commit 2c8d353105)
2025-12-05 15:50:31 +00:00
RitvikSardana
7c16051cd3 chore: Portuguese translations
(cherry picked from commit cb80db8bb6)
2025-12-05 15:50:31 +00:00
RitvikSardana
651a3d67e9 chore: Polish translations
(cherry picked from commit fb02488cc2)
2025-12-05 15:50:30 +00:00
RitvikSardana
678df4256e chore: Dutch translations
(cherry picked from commit bdb401cea8)
2025-12-05 15:50:30 +00:00
RitvikSardana
57b077f648 chore: Italian translations
(cherry picked from commit cec8e1c963)
2025-12-05 15:50:30 +00:00
RitvikSardana
0258be7a05 chore: Hungarian translations
(cherry picked from commit 4184f067f7)
2025-12-05 15:50:30 +00:00
RitvikSardana
1f417d0ddc chore: German translations
(cherry picked from commit 07764582bc)
2025-12-05 15:50:29 +00:00
RitvikSardana
bb3983b35d chore: Danish translations
(cherry picked from commit cb5ffcc299)
2025-12-05 15:50:29 +00:00
RitvikSardana
bdad810b4b chore: Czech translations
(cherry picked from commit 1c4b5eb4f5)
2025-12-05 15:50:29 +00:00
RitvikSardana
81c8d24495 chore: Arabic translations
(cherry picked from commit 6126dbef15)
2025-12-05 15:50:29 +00:00
RitvikSardana
09eef74ccb chore: Spanish translations
(cherry picked from commit 9a43e789a3)
2025-12-05 15:50:28 +00:00
Pratik
c80a6c1d12 fix: ticket view tabs alignment
(cherry picked from commit 5171c3bf22)
2025-12-05 15:48:27 +00:00
Pratik
d11258e90b fix: revert select element in gemeral settings
(cherry picked from commit cc42501517)
2025-12-05 15:48:27 +00:00
Pratik
b9457b7448 fix: popover width
(cherry picked from commit 2c2039faa6)

# Conflicts:
#	desk/src/components/Settings/Assignment Rules/AssignmentRuleView.vue
2025-12-05 15:48:27 +00:00
Pratik
5b2b96c5b6 fix: select options width & assigmnent rule bugs
(cherry picked from commit 4b43b6d01f)

# Conflicts:
#	desk/src/components/Settings/Assignment Rules/AssignmentRuleView.vue
2025-12-05 15:48:26 +00:00
Pratik
a94fe5ed1b fix: bugs introduced by frappe-ui update
(cherry picked from commit 8e6848dc23)
2025-12-05 15:48:26 +00:00
Pratik
461edc93aa fix: bugs related to frappe-ui update
(cherry picked from commit e2a2d29b84)

# Conflicts:
#	desk/src/components/SelectDropdown.vue
#	desk/src/components/Settings/General/components/TicketSettings.vue
#	desk/src/components/Settings/Sla/SlaPriorityListItem.vue
#	desk/src/components/Settings/Sla/SlaWorkDaysListItem.vue
2025-12-05 15:48:26 +00:00
RitvikSardana
1a427ef047 Merge pull request #2800 from frappe/mergify/bp/main-hotfix/pr-2795
fix(security): dashboard APIs & realtime events (backport #2795)
2025-12-05 18:27:04 +05:30
RitvikSardana
0ed1d38b37 chore: update dashboard file 2025-12-05 17:00:40 +05:30
RitvikSardana
a653ada9b5 chore: update dashboard file 2025-12-05 16:48:58 +05:30
RitvikSardana
54da393df7 chore: update dashboard file 2025-12-05 16:24:16 +05:30
Ritvik Sardana
7c78e5ed58 fix: dont bombard socket events
(cherry picked from commit b2f7e029be)
2025-12-05 10:36:37 +00:00
Ritvik Sardana
3539979ecc refactor: convert dashboard api to class and add QB support
(cherry picked from commit 1e1cd602d6)

# Conflicts:
#	helpdesk/api/dashboard.py
2025-12-05 10:36:37 +00:00
RitvikSardana
e8f53b9063 Merge pull request #2792 from frappe/mergify/bp/main-hotfix/pr-2788
chore: update POT file (backport #2788)
2025-12-03 14:29:46 +05:30
RitvikSardana
08156fda53 Merge pull request #2793 from frappe/mergify/bp/main-hotfix/pr-2786
chore: sync translations from crowdin (backport #2786)
2025-12-03 14:29:38 +05:30
RitvikSardana
91c9aca479 chore: Persian translations
(cherry picked from commit cf5be1ea67)
2025-12-03 08:58:35 +00:00
RitvikSardana
452aff7ba8 chore: Hungarian translations
(cherry picked from commit 2ed447646d)
2025-12-03 08:58:35 +00:00
RitvikSardana
012fa5e56f chore: Spanish translations
(cherry picked from commit 8a87142618)
2025-12-03 08:58:35 +00:00
RitvikSardana
08e638d598 chore: Persian translations
(cherry picked from commit 12b9fe46ef)
2025-12-03 08:58:35 +00:00
RitvikSardana
6cff520fab chore: Slovenian translations
(cherry picked from commit bca072eb23)
2025-12-03 08:58:34 +00:00
RitvikSardana
8e136a2873 chore: Slovenian translations
(cherry picked from commit 3b2b3a194f)
2025-12-03 08:58:34 +00:00
RitvikSardana
944008b176 chore: Serbian (Latin) translations
(cherry picked from commit 3960d96377)
2025-12-03 08:58:34 +00:00
RitvikSardana
26fc1648ed chore: Norwegian Bokmal translations
(cherry picked from commit b04b4b1aa3)
2025-12-03 08:58:34 +00:00
RitvikSardana
75783677ea chore: Bosnian translations
(cherry picked from commit 11de025c42)
2025-12-03 08:58:33 +00:00
RitvikSardana
55cc6f2640 chore: Croatian translations
(cherry picked from commit 401ee26e3e)
2025-12-03 08:58:33 +00:00
RitvikSardana
2db40ba4f7 chore: Thai translations
(cherry picked from commit 35ae4af61a)
2025-12-03 08:58:33 +00:00
RitvikSardana
a43718427e chore: Tamil translations
(cherry picked from commit 6dee7bf2e2)
2025-12-03 08:58:33 +00:00
RitvikSardana
b5169d0d15 chore: Persian translations
(cherry picked from commit d615d7ae3e)
2025-12-03 08:58:32 +00:00
RitvikSardana
dcdda3b770 chore: Indonesian translations
(cherry picked from commit 9cb6df0bee)
2025-12-03 08:58:32 +00:00
RitvikSardana
227ff783f6 chore: Portuguese, Brazilian translations
(cherry picked from commit 13aff48429)
2025-12-03 08:58:32 +00:00
RitvikSardana
33ffc0abe5 chore: Vietnamese translations
(cherry picked from commit c413ef2e48)
2025-12-03 08:58:32 +00:00
RitvikSardana
561e4ab441 chore: Chinese Simplified translations
(cherry picked from commit b367cdc828)
2025-12-03 08:58:31 +00:00
RitvikSardana
4b67c5f2f5 chore: Turkish translations
(cherry picked from commit 5c7efb6372)
2025-12-03 08:58:31 +00:00
RitvikSardana
29d159dd11 chore: Swedish translations
(cherry picked from commit e3aacdbcdc)
2025-12-03 08:58:31 +00:00
RitvikSardana
7adeed96fe chore: Serbian (Cyrillic) translations
(cherry picked from commit 91dd2f4a54)
2025-12-03 08:58:31 +00:00
RitvikSardana
93362f939b chore: Russian translations
(cherry picked from commit cf629ebd21)
2025-12-03 08:58:31 +00:00
RitvikSardana
a16b701032 chore: Portuguese translations
(cherry picked from commit ce62f05c25)
2025-12-03 08:58:30 +00:00
RitvikSardana
e3a09004c6 chore: Polish translations
(cherry picked from commit 5150c85284)
2025-12-03 08:58:30 +00:00
RitvikSardana
68faaebb37 chore: Dutch translations
(cherry picked from commit bff28f2642)
2025-12-03 08:58:30 +00:00
RitvikSardana
4d72882d40 chore: Italian translations
(cherry picked from commit 70da538f78)
2025-12-03 08:58:30 +00:00
RitvikSardana
f05e66b039 chore: Hungarian translations
(cherry picked from commit a2827630b1)
2025-12-03 08:58:29 +00:00
RitvikSardana
28f44bfe61 chore: German translations
(cherry picked from commit d527448d5b)
2025-12-03 08:58:29 +00:00
RitvikSardana
1e3fc96adc chore: Danish translations
(cherry picked from commit 3cf62a9a2e)
2025-12-03 08:58:29 +00:00
RitvikSardana
4e8ecc5447 chore: Czech translations
(cherry picked from commit e86ee3f5e8)
2025-12-03 08:58:29 +00:00
RitvikSardana
14aefb9b23 chore: Arabic translations
(cherry picked from commit 6789a25986)
2025-12-03 08:58:28 +00:00
frappe-pr-bot
4cb3c57865 chore: update POT file
(cherry picked from commit a27001e934)
2025-12-03 08:57:28 +00:00
RitvikSardana
105da6be52 Merge pull request #2791 from frappe/mergify/bp/main-hotfix/pr-2789
chore: V16 support (backport #2789)
2025-12-03 14:26:25 +05:30
RitvikSardana
994c14e70d Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2789 2025-12-03 14:10:46 +05:30
RitvikSardana
284531ab56 Merge pull request #2774 from frappe/mergify/bp/main-hotfix/pr-2719
refactor: reduce bundle size (backport #2719)
2025-12-03 14:10:08 +05:30
RitvikSardana
484d00e06d chore: solvemerge conflicts 2025-12-03 14:05:19 +05:30
Ritvik Sardana
350275ece1 chore: code cleanup
(cherry picked from commit b800ee03c2)
2025-12-03 08:27:22 +00:00
Ritvik Sardana
c00da37443 fix: add util to check version
(cherry picked from commit d016312f86)
2025-12-03 08:27:21 +00:00
Ritvik Sardana
d2acd5f7e9 fix: better way to check version
(cherry picked from commit 7b9dbe748e)
2025-12-03 08:27:21 +00:00
Ritvik Sardana
98491bb277 chore: revert py project changes
(cherry picked from commit 246462901e)

# Conflicts:
#	pyproject.toml
2025-12-03 08:27:21 +00:00
Ritvik Sardana
8e3189d9a0 fix: add version change for testing in v15
(cherry picked from commit 2309dd9c9d)

# Conflicts:
#	pyproject.toml
2025-12-03 08:27:21 +00:00
Ritvik Sardana
be58389888 fix: add conditional version support
(cherry picked from commit a36e377771)
2025-12-03 08:27:21 +00:00
Ritvik Sardana
3f1299d6ed chore: add v16 support
(cherry picked from commit 825ec55d80)

# Conflicts:
#	desk/src/pages/dashboard/Dashboard.vue
#	desk/tsconfig.json
#	helpdesk/api/dashboard.py
2025-12-03 08:27:20 +00:00
RitvikSardana
d0bc2f5250 chore: code cleanup 2025-12-03 12:11:30 +05:30
RitvikSardana
f8d9828f85 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2719 2025-12-03 11:53:22 +05:30
RitvikSardana
99682ad5fd Merge pull request #2785 from frappe/main-hotfix
chore(release): hotfix to main
2025-11-28 14:59:18 +05:30
RitvikSardana
8cea4bcc45 Merge pull request #2784 from frappe/mergify/bp/main-hotfix/pr-2783
chore: bump version (backport #2783)
2025-11-28 11:32:37 +05:30
Ritvik Sardana
d9f6fdf492 chore: bump version
(cherry picked from commit 8fb56bad25)
2025-11-28 06:01:59 +00:00
RitvikSardana
bb77ed8673 Merge pull request #2782 from frappe/mergify/bp/main-hotfix/pr-2779
chore: sync translations from crowdin (backport #2779)
2025-11-28 11:31:29 +05:30
RitvikSardana
508d6dc5c3 chore: Slovenian translations
(cherry picked from commit c1781bd8b4)
2025-11-28 05:59:50 +00:00
RitvikSardana
9fd72ba073 chore: Burmese translations
(cherry picked from commit 5c4a7ac43c)
2025-11-28 05:59:50 +00:00
RitvikSardana
c24477f90f chore: Esperanto translations
(cherry picked from commit 4ea804b0d0)
2025-11-28 05:59:49 +00:00
RitvikSardana
3555ef8f69 chore: Slovenian translations
(cherry picked from commit a546403f1c)
2025-11-28 05:59:49 +00:00
RitvikSardana
526b030305 chore: French translations
(cherry picked from commit 5947c38d30)
2025-11-28 05:59:49 +00:00
RitvikSardana
caef4a6670 chore: Spanish translations
(cherry picked from commit 863b543e28)
2025-11-28 05:59:49 +00:00
RitvikSardana
e48b138baa Merge pull request #2778 from frappe/mergify/bp/main-hotfix/pr-2761
fix: mandatory feedback bug (backport #2761)
2025-11-27 00:35:41 +05:30
RitvikSardana
ecffab176c Merge pull request #2777 from frappe/mergify/bp/main-hotfix/pr-2765
fix: auto closing bug in reply & comment section (backport #2765)
2025-11-27 00:33:37 +05:30
RitvikSardana
2d9ce87d9c Merge pull request #2776 from frappe/mergify/bp/main-hotfix/pr-2768
fix: Validate that published HD articles contain content (backport #2768)
2025-11-27 00:31:09 +05:30
RitvikSardana
f2e4482c2b Merge pull request #2775 from frappe/mergify/bp/main-hotfix/pr-2758
chore: update POT file (backport #2758)
2025-11-27 00:30:34 +05:30
RitvikSardana
cab852c4d0 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2758 2025-11-27 00:30:27 +05:30
Pratik
8f631a9e33 fix: remove feedback field in validation
(cherry picked from commit 015bb01599)
2025-11-26 19:00:23 +00:00
Pratik
6fef06be3a fix: mandatory feedback bug
(cherry picked from commit 86b4440149)
2025-11-26 19:00:23 +00:00
RitvikSardana
b5a0dca8c5 Merge pull request #2773 from frappe/mergify/bp/main-hotfix/pr-2751
chore: sync translations from crowdin (backport #2751)
2025-11-27 00:30:02 +05:30
RitvikSardana
5521e46696 Merge pull request #2772 from frappe/mergify/bp/main-hotfix/pr-2766
chore: update POT file (backport #2766)
2025-11-27 00:29:53 +05:30
Pratik
b006403eff fix: revert changes & add ignore args
(cherry picked from commit 77eaa45ee7)
2025-11-26 18:58:23 +00:00
Pratik
794303795d fix: auto closing bug in reply & comment section
(cherry picked from commit e197993dbf)
2025-11-26 18:58:23 +00:00
KerollesFathy
00d7cad822 fix: validate published articles have content
(cherry picked from commit 356c88bc1f)
2025-11-26 18:44:36 +00:00
frappe-pr-bot
4a9260ad37 chore: update POT file
(cherry picked from commit 75647b121d)
2025-11-26 18:41:32 +00:00
Ritvik Sardana
62c56faec0 fix: update frappe-ui version
(cherry picked from commit f386c33a09)
2025-11-26 18:40:31 +00:00
Ritvik Sardana
2bce281c7d fix: change PWA version
(cherry picked from commit b457646087)
2025-11-26 18:40:31 +00:00
Ritvik Sardana
98f0460c24 fix: resolve for node 18
(cherry picked from commit d14fc7dd5f)
2025-11-26 18:40:31 +00:00
Ritvik Sardana
8a1cf06df3 chore: update frappe-ui version
(cherry picked from commit 88acdd0a4f)
2025-11-26 18:40:31 +00:00
Ritvik Sardana
da4d1810b7 chore: code cleanup
(cherry picked from commit 09ef6a1d18)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
8d23efeb0c fix: update frappe-ui version
(cherry picked from commit 98c8eb7445)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
03329051a9 fix: frappe-ui changes in sidebar
(cherry picked from commit 15fdea2cd1)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
4e11103806 fix: change frappe-ui commit hash
(cherry picked from commit 97a3be2fc7)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
636607df99 fix: change frappe-ui commit hash
(cherry picked from commit 538674f68b)
2025-11-26 18:40:30 +00:00
Ritvik Sardana
8260debc30 fix: incorporate new changes
(cherry picked from commit c9a6a28af7)

# Conflicts:
#	desk/src/components/ticket-agent/TicketSLA.vue
2025-11-26 18:40:29 +00:00
Ritvik Sardana
0104750c59 chore: update git ignore file
(cherry picked from commit 0e84f92d32)
2025-11-26 18:40:29 +00:00
Ritvik Sardana
cbffb8304b fix: frappe-ui tree shaking compatible
(cherry picked from commit 6f02b27ff6)
2025-11-26 18:40:29 +00:00
Ritvik Sardana
2233c7ed37 chore: bump frappe-ui version
(cherry picked from commit 580ac33487)
2025-11-26 18:40:28 +00:00
Ritvik Sardana
2d2f0cbe68 chore: update yarn.lock
(cherry picked from commit 020bd77358)
2025-11-26 18:40:28 +00:00
Ritvik Sardana
14251eaa61 fix: remove visualizer
(cherry picked from commit 511fa074b4)
2025-11-26 18:40:28 +00:00
Ritvik Sardana
edf3f88136 refactor: reduce bundle size
(cherry picked from commit 01175b0fc8)

# Conflicts:
#	desk/src/components/ticket-agent/TicketActivityPanel.vue
2025-11-26 18:40:28 +00:00
RitvikSardana
0db3a26557 chore: Slovenian translations
(cherry picked from commit 660fd178b9)
2025-11-26 18:39:47 +00:00
RitvikSardana
c72f8c5923 chore: Portuguese, Brazilian translations
(cherry picked from commit 6c29b677cb)
2025-11-26 18:39:47 +00:00
RitvikSardana
0a2ba097df chore: Slovenian translations
(cherry picked from commit 530340a01f)
2025-11-26 18:39:46 +00:00
RitvikSardana
4fe3a05f55 chore: French translations
(cherry picked from commit 6f104d56f4)
2025-11-26 18:39:46 +00:00
RitvikSardana
685fb6d770 chore: Slovenian translations
(cherry picked from commit 9e66c34d99)
2025-11-26 18:39:46 +00:00
RitvikSardana
42347b4114 chore: Slovenian translations
(cherry picked from commit 144f1b9f6a)
2025-11-26 18:39:44 +00:00
RitvikSardana
b68653fc32 chore: Slovenian translations
(cherry picked from commit e93b3d3912)
2025-11-26 18:39:43 +00:00
RitvikSardana
74eb4093c3 chore: Slovenian translations
(cherry picked from commit b922219bb6)
2025-11-26 18:39:43 +00:00
RitvikSardana
be40fbfb53 chore: Indonesian translations
(cherry picked from commit 73adb17b98)
2025-11-26 18:39:43 +00:00
RitvikSardana
190f9801a8 chore: Portuguese, Brazilian translations
(cherry picked from commit 2157d27cbe)
2025-11-26 18:39:43 +00:00
RitvikSardana
9ac3d812ac chore: Spanish translations
(cherry picked from commit 4b0c61fa16)
2025-11-26 18:39:41 +00:00
RitvikSardana
9e87c2526a chore: Persian translations
(cherry picked from commit f4670c5fb2)
2025-11-26 18:39:40 +00:00
RitvikSardana
7f0e7fe4b2 chore: French translations
(cherry picked from commit ce7ccf1730)
2025-11-26 18:39:40 +00:00
RitvikSardana
df185cef71 chore: Serbian (Latin) translations
(cherry picked from commit 4d7601a527)
2025-11-26 18:39:40 +00:00
RitvikSardana
28574f6203 chore: Norwegian Bokmal translations
(cherry picked from commit 6be1784277)
2025-11-26 18:39:38 +00:00
RitvikSardana
f34dcb5974 chore: Bosnian translations
(cherry picked from commit c177472a42)
2025-11-26 18:39:38 +00:00
RitvikSardana
e3ab99bb36 chore: Croatian translations
(cherry picked from commit 7f80b00860)
2025-11-26 18:39:37 +00:00
RitvikSardana
e4a66d68d4 chore: Persian translations
(cherry picked from commit fd74e6311b)
2025-11-26 18:39:36 +00:00
RitvikSardana
090ee1b021 chore: Indonesian translations
(cherry picked from commit 64b8991408)
2025-11-26 18:39:35 +00:00
RitvikSardana
2501bafc46 chore: Chinese Simplified translations
(cherry picked from commit ea37dcfc3a)
2025-11-26 18:39:35 +00:00
RitvikSardana
37161a79e8 chore: Turkish translations
(cherry picked from commit 6be74cad34)
2025-11-26 18:39:35 +00:00
RitvikSardana
fb0edce5b0 chore: Swedish translations
(cherry picked from commit 03556b02b5)
2025-11-26 18:39:34 +00:00
RitvikSardana
fe13c7e383 chore: Serbian (Cyrillic) translations
(cherry picked from commit a7f42d9d14)
2025-11-26 18:39:34 +00:00
RitvikSardana
910f753c61 chore: Polish translations
(cherry picked from commit a83e75a4d3)
2025-11-26 18:39:32 +00:00
RitvikSardana
dcd9361a45 chore: German translations
(cherry picked from commit afb5f2cb34)
2025-11-26 18:39:32 +00:00
RitvikSardana
58638f67e8 chore: Arabic translations
(cherry picked from commit ff30d7d318)
2025-11-26 18:39:31 +00:00
RitvikSardana
423f01c27f chore: Spanish translations
(cherry picked from commit 952fe5daa0)
2025-11-26 18:39:31 +00:00
RitvikSardana
8350870406 chore: German translations
(cherry picked from commit caaf65d63b)
2025-11-26 18:39:31 +00:00
RitvikSardana
737ad7e807 chore: Persian translations
(cherry picked from commit c813319260)
2025-11-26 18:39:30 +00:00
frappe-pr-bot
d03120778a chore: update POT file
(cherry picked from commit 6fcae817d4)
2025-11-26 18:39:24 +00:00
RitvikSardana
0dd85bae82 Merge pull request #2749 from frappe/mergify/bp/main-hotfix/pr-2746
chore: sync translations from crowdin (backport #2746)
2025-11-12 02:30:47 +05:30
RitvikSardana
332a03230a chore: Bosnian translations
(cherry picked from commit c60636e65d)
2025-11-11 20:59:53 +00:00
RitvikSardana
f3122c7001 chore: Croatian translations
(cherry picked from commit 64876b97b3)
2025-11-11 20:59:53 +00:00
RitvikSardana
c46e97c990 chore: Swedish translations
(cherry picked from commit 6544352f17)
2025-11-11 20:59:53 +00:00
RitvikSardana
2a843ea14e chore: French translations
(cherry picked from commit 5d3155301e)
2025-11-11 20:59:53 +00:00
RitvikSardana
c82485520e chore: Serbian (Latin) translations
(cherry picked from commit 8eae98f9fb)
2025-11-11 20:59:52 +00:00
RitvikSardana
b44d833656 chore: Norwegian Bokmal translations
(cherry picked from commit 64730f9c10)
2025-11-11 20:59:52 +00:00
RitvikSardana
58e4314856 chore: Bosnian translations
(cherry picked from commit 4697af4d7b)
2025-11-11 20:59:52 +00:00
RitvikSardana
d9bf8b3787 chore: Croatian translations
(cherry picked from commit 76f45d5ac0)
2025-11-11 20:59:52 +00:00
RitvikSardana
fd49c2d34d chore: Persian translations
(cherry picked from commit 347205c866)
2025-11-11 20:59:51 +00:00
RitvikSardana
9f08a80a25 chore: Indonesian translations
(cherry picked from commit 13ea1ffc8c)
2025-11-11 20:59:51 +00:00
RitvikSardana
522b84e0ec chore: Portuguese, Brazilian translations
(cherry picked from commit 928a9391d0)
2025-11-11 20:59:51 +00:00
RitvikSardana
f8970a6693 chore: Chinese Simplified translations
(cherry picked from commit 240651564a)
2025-11-11 20:59:51 +00:00
RitvikSardana
91b7e98776 chore: Turkish translations
(cherry picked from commit c2a68b6a6b)
2025-11-11 20:59:51 +00:00
RitvikSardana
2eb6e479e6 chore: Swedish translations
(cherry picked from commit 5df19be765)
2025-11-11 20:59:50 +00:00
RitvikSardana
7c5973d56d chore: Serbian (Cyrillic) translations
(cherry picked from commit df85bb2111)
2025-11-11 20:59:50 +00:00
RitvikSardana
dda0438d82 chore: Portuguese translations
(cherry picked from commit df1adc6c6a)
2025-11-11 20:59:50 +00:00
RitvikSardana
1bbbcd0c8e chore: Polish translations
(cherry picked from commit 601b674f8d)
2025-11-11 20:59:50 +00:00
RitvikSardana
77f77073b2 chore: Dutch translations
(cherry picked from commit 79100fe489)
2025-11-11 20:59:49 +00:00
RitvikSardana
506fbca15d chore: Italian translations
(cherry picked from commit b0a8f48dae)
2025-11-11 20:59:49 +00:00
RitvikSardana
d7f5769ead chore: German translations
(cherry picked from commit 2a713fa2a4)
2025-11-11 20:59:49 +00:00
RitvikSardana
160dd3d1e8 chore: Danish translations
(cherry picked from commit abb2c8b8df)
2025-11-11 20:59:49 +00:00
RitvikSardana
de41ce2135 chore: Arabic translations
(cherry picked from commit b016726247)
2025-11-11 20:59:48 +00:00
RitvikSardana
e0f97a9bdb chore: Spanish translations
(cherry picked from commit b019883b91)
2025-11-11 20:59:48 +00:00
RitvikSardana
5f1eaae00b Merge pull request #2748 from frappe/mergify/bp/main-hotfix/pr-2747
fix: parse API Options correctly while creating tickets (backport #2747)
2025-11-12 02:28:43 +05:30
Ritvik Sardana
721866b782 fix: parse API Options correctly while creating tickets
(cherry picked from commit bf144300ba)
2025-11-11 07:03:21 +00:00
RitvikSardana
bfd01b0549 Merge pull request #2745 from frappe/main-hotfix
chore(release): hotfix to main
2025-11-10 15:59:34 +05:30
RitvikSardana
a0ca9daa97 Merge pull request #2742 from frappe/mergify/bp/main-hotfix/pr-2741
fix: validation error when replying on a ticket (backport #2741)
2025-11-10 15:50:58 +05:30
RitvikSardana
129dbb3cd2 Merge pull request #2744 from frappe/mergify/bp/main-hotfix/pr-2743
fix: filter out null/undefined values in apiOptions (backport #2743)
2025-11-10 15:50:42 +05:30
Ritvik Sardana
d06df8aafb chore: code cleanup
(cherry picked from commit 03583e44d5)
2025-11-10 10:19:03 +00:00
Ritvik Sardana
bf46380aae fix: filter out null/undefined values in apiOptions
(cherry picked from commit 4bb8b7695c)
2025-11-10 10:19:03 +00:00
Ritvik Sardana
73b608d133 fix: validation error when replying on a ticket
(cherry picked from commit 643155e4ec)
2025-11-10 10:02:00 +00:00
RitvikSardana
06ce05bcb0 Merge pull request #2739 from frappe/mergify/bp/main-hotfix/pr-2715
refactor: update apiOptions transform to map object labels and values (backport #2715)
2025-11-10 12:29:03 +05:30
KerollesFathy
62d35686b3 refactor: update apiOptions transform to handle object labels and values
(cherry picked from commit f06862057f)
2025-11-09 16:04:57 +00:00
RitvikSardana
7182665584 Merge pull request #2738 from frappe/mergify/bp/main-hotfix/pr-2737
chore: sync translations from crowdin (backport #2737)
2025-11-09 21:34:21 +05:30
RitvikSardana
a42f700154 chore: French translations
(cherry picked from commit 3d22970159)
2025-11-09 15:20:58 +00:00
RitvikSardana
dc41f10fb7 chore: Esperanto translations
(cherry picked from commit 4d39b43f15)
2025-11-09 15:20:58 +00:00
RitvikSardana
6a8cd912bd chore: Serbian (Latin) translations
(cherry picked from commit 5489209a0d)
2025-11-09 15:20:57 +00:00
RitvikSardana
dc2505c1ff chore: Norwegian Bokmal translations
(cherry picked from commit 4845183691)
2025-11-09 15:20:57 +00:00
RitvikSardana
d6a8c17860 chore: Bosnian translations
(cherry picked from commit e17d9f0123)
2025-11-09 15:20:57 +00:00
RitvikSardana
2fc428c25b chore: Burmese translations
(cherry picked from commit 30f4654d0f)
2025-11-09 15:20:57 +00:00
RitvikSardana
32831f9e6c chore: Croatian translations
(cherry picked from commit 6b6dacea76)
2025-11-09 15:20:56 +00:00
RitvikSardana
f74dc38917 chore: Thai translations
(cherry picked from commit 0594cba992)
2025-11-09 15:20:56 +00:00
RitvikSardana
cf09a7e922 chore: Tamil translations
(cherry picked from commit 1cfcbd964b)
2025-11-09 15:20:56 +00:00
RitvikSardana
7ed9d4e13b chore: Persian translations
(cherry picked from commit cffd32dda9)
2025-11-09 15:20:56 +00:00
RitvikSardana
e18e37752e chore: Indonesian translations
(cherry picked from commit 7e8071f33c)
2025-11-09 15:20:56 +00:00
RitvikSardana
6e6ea85c31 chore: Portuguese, Brazilian translations
(cherry picked from commit 926e21d258)
2025-11-09 15:20:55 +00:00
RitvikSardana
e85451d2d6 chore: Vietnamese translations
(cherry picked from commit 8af08fe0fa)
2025-11-09 15:20:55 +00:00
RitvikSardana
a4cc6f59fc chore: Chinese Simplified translations
(cherry picked from commit 0826769b62)
2025-11-09 15:20:55 +00:00
RitvikSardana
d9f3fc8a5c chore: Turkish translations
(cherry picked from commit f35457bdb0)
2025-11-09 15:20:55 +00:00
RitvikSardana
a18a544f2f chore: Swedish translations
(cherry picked from commit 6bc303f434)
2025-11-09 15:20:54 +00:00
RitvikSardana
a973a10297 chore: Serbian (Cyrillic) translations
(cherry picked from commit 12050328a2)
2025-11-09 15:20:54 +00:00
RitvikSardana
5bfd9ea9f8 chore: Russian translations
(cherry picked from commit 5ec5c53024)
2025-11-09 15:20:54 +00:00
RitvikSardana
f5b4e536b4 chore: Portuguese translations
(cherry picked from commit 0388bcaf45)
2025-11-09 15:20:54 +00:00
RitvikSardana
693b88df22 chore: Polish translations
(cherry picked from commit 50faed905e)
2025-11-09 15:20:53 +00:00
RitvikSardana
aff88c6078 chore: Dutch translations
(cherry picked from commit b97d1e8edc)
2025-11-09 15:20:53 +00:00
RitvikSardana
6754511b2a chore: Italian translations
(cherry picked from commit 699ce06906)
2025-11-09 15:20:53 +00:00
RitvikSardana
68272b43e0 chore: Hungarian translations
(cherry picked from commit 28bf477422)
2025-11-09 15:20:53 +00:00
RitvikSardana
a88f68564a chore: German translations
(cherry picked from commit 10c206c37f)
2025-11-09 15:20:53 +00:00
RitvikSardana
0d52ce1a3b chore: Danish translations
(cherry picked from commit 506a5619cb)
2025-11-09 15:20:52 +00:00
RitvikSardana
ca5a9f46dd chore: Czech translations
(cherry picked from commit 56e4a8fa27)
2025-11-09 15:20:52 +00:00
RitvikSardana
b0346cb7d8 chore: Arabic translations
(cherry picked from commit 387e6f65ea)
2025-11-09 15:20:52 +00:00
RitvikSardana
f154f26e39 chore: Spanish translations
(cherry picked from commit 6e94d7668e)
2025-11-09 15:20:52 +00:00
RitvikSardana
220d037088 Merge pull request #2736 from frappe/mergify/bp/main-hotfix/pr-2733
chore: sync translations from crowdin (backport #2733)
2025-11-09 20:49:32 +05:30
RitvikSardana
2ca10d4c75 Merge pull request #2735 from frappe/mergify/bp/main-hotfix/pr-2734
chore: update POT file (backport #2734)
2025-11-09 20:49:22 +05:30
RitvikSardana
7bd6442fb6 chore: Esperanto translations
(cherry picked from commit fbf5bed567)
2025-11-09 12:25:32 +00:00
RitvikSardana
57fa5106cf chore: Serbian (Latin) translations
(cherry picked from commit b7dcf6324c)
2025-11-09 12:25:32 +00:00
RitvikSardana
72c8dbccd7 chore: Norwegian Bokmal translations
(cherry picked from commit fe25d084d9)
2025-11-09 12:25:31 +00:00
RitvikSardana
b5f52079ed chore: Bosnian translations
(cherry picked from commit cb329e9747)
2025-11-09 12:25:31 +00:00
RitvikSardana
7649a9189a chore: Burmese translations
(cherry picked from commit f5720fba05)
2025-11-09 12:25:31 +00:00
RitvikSardana
247a866f3a chore: Croatian translations
(cherry picked from commit 0745ddf8cd)
2025-11-09 12:25:31 +00:00
RitvikSardana
58f34cae64 chore: Thai translations
(cherry picked from commit 4a6ce11692)
2025-11-09 12:25:31 +00:00
RitvikSardana
65573e904c chore: Tamil translations
(cherry picked from commit bed330a7db)
2025-11-09 12:25:30 +00:00
RitvikSardana
2f5c21c45d chore: Persian translations
(cherry picked from commit b939caaf68)
2025-11-09 12:25:30 +00:00
RitvikSardana
fdb2493ae4 chore: Indonesian translations
(cherry picked from commit dae531dd26)
2025-11-09 12:25:30 +00:00
RitvikSardana
f4b4dea414 chore: Portuguese, Brazilian translations
(cherry picked from commit 84af9ba07f)
2025-11-09 12:25:30 +00:00
RitvikSardana
334f9b11ca chore: Vietnamese translations
(cherry picked from commit d32244a877)
2025-11-09 12:25:29 +00:00
RitvikSardana
b794cff536 chore: Chinese Simplified translations
(cherry picked from commit f2b5213e6e)
2025-11-09 12:25:29 +00:00
RitvikSardana
08d830e0bd chore: Turkish translations
(cherry picked from commit 487ea74b51)
2025-11-09 12:25:29 +00:00
RitvikSardana
7d0c147e08 chore: Swedish translations
(cherry picked from commit 124a1a6911)
2025-11-09 12:25:29 +00:00
RitvikSardana
e8e672efcb chore: Serbian (Cyrillic) translations
(cherry picked from commit 2e85998e72)
2025-11-09 12:25:28 +00:00
RitvikSardana
631b53773c chore: Russian translations
(cherry picked from commit 83cefc54a1)
2025-11-09 12:25:28 +00:00
RitvikSardana
abc0ac26d1 chore: Portuguese translations
(cherry picked from commit fb42f359a1)
2025-11-09 12:25:28 +00:00
RitvikSardana
407c259aa7 chore: Polish translations
(cherry picked from commit 0334db5ef9)
2025-11-09 12:25:28 +00:00
RitvikSardana
736e1c404c chore: Dutch translations
(cherry picked from commit 305ef5e560)
2025-11-09 12:25:27 +00:00
RitvikSardana
05c2ccd29e chore: Italian translations
(cherry picked from commit a4f3d1d5b4)
2025-11-09 12:25:27 +00:00
RitvikSardana
32aa3fa9e0 chore: Hungarian translations
(cherry picked from commit 16f72e287a)
2025-11-09 12:25:27 +00:00
RitvikSardana
f2cf5ce054 chore: German translations
(cherry picked from commit de04086da7)
2025-11-09 12:25:27 +00:00
frappe-pr-bot
6d1758c7b7 chore: update POT file
(cherry picked from commit ce4f422335)
2025-11-09 12:25:26 +00:00
RitvikSardana
02f4336b8b chore: Danish translations
(cherry picked from commit 557bd6a498)
2025-11-09 12:25:26 +00:00
RitvikSardana
9279cb567b chore: Czech translations
(cherry picked from commit 4b0704060b)
2025-11-09 12:25:26 +00:00
RitvikSardana
70358a6e3a chore: Arabic translations
(cherry picked from commit 30debfc42b)
2025-11-09 12:25:26 +00:00
RitvikSardana
2498a20a2e chore: Spanish translations
(cherry picked from commit 23568e9b97)
2025-11-09 12:25:26 +00:00
RitvikSardana
0ea469f796 Merge pull request #2732 from frappe/main-hotfix
chore(release): hotfix to main
2025-11-07 17:33:54 +05:30
RitvikSardana
c961f3db39 Merge pull request #2731 from frappe/mergify/bp/main-hotfix/pr-2730
chore: bump version (backport #2730)
2025-11-07 17:04:58 +05:30
Ritvik Sardana
4b6cd0d6df chore: bump version
(cherry picked from commit 260d15de65)
2025-11-07 11:31:23 +00:00
RitvikSardana
42c4d0c0fc Merge pull request #2729 from frappe/mergify/bp/main-hotfix/pr-2705
chore: sync translations from crowdin (backport #2705)
2025-11-07 16:54:29 +05:30
RitvikSardana
bac4d39f51 Merge pull request #2728 from frappe/mergify/bp/main-hotfix/pr-2721
chore: update POT file (backport #2721)
2025-11-07 16:54:16 +05:30
RitvikSardana
12f99c0451 Merge pull request #2727 from frappe/mergify/bp/main-hotfix/pr-2726
fix: remove unnecessary validation while giving feedback for email tickets (backport #2726)
2025-11-07 16:53:52 +05:30
RitvikSardana
3daf50e765 chore: Chinese Simplified translations
(cherry picked from commit 01dd4fb42c)
2025-11-07 11:23:15 +00:00
RitvikSardana
b5576df9a7 chore: Serbian (Latin) translations
(cherry picked from commit 75799fa4a2)
2025-11-07 11:23:15 +00:00
RitvikSardana
a14f3c9e7d chore: Serbian (Cyrillic) translations
(cherry picked from commit 7fdcc42626)
2025-11-07 11:23:15 +00:00
RitvikSardana
b68a9f8be1 chore: Esperanto translations
(cherry picked from commit 2a15bfb7e2)
2025-11-07 11:23:14 +00:00
RitvikSardana
6cd27f5444 chore: Burmese translations
(cherry picked from commit 0722e819c3)
2025-11-07 11:23:14 +00:00
RitvikSardana
18d6eeff1a chore: Tamil translations
(cherry picked from commit 5f49a5afd1)
2025-11-07 11:23:14 +00:00
RitvikSardana
a238dcb20c chore: Serbian (Latin) translations
(cherry picked from commit a3254586e5)
2025-11-07 11:23:14 +00:00
RitvikSardana
66b0632584 chore: Norwegian Bokmal translations
(cherry picked from commit 04c4a57141)
2025-11-07 11:23:13 +00:00
RitvikSardana
5d8928e3b6 chore: Bosnian translations
(cherry picked from commit d7fd5cda70)
2025-11-07 11:23:13 +00:00
RitvikSardana
46ddee1111 chore: Croatian translations
(cherry picked from commit 9850e2da85)
2025-11-07 11:23:13 +00:00
RitvikSardana
21fbb8fe47 chore: Thai translations
(cherry picked from commit a0b7fab8da)
2025-11-07 11:23:13 +00:00
RitvikSardana
1da7dce716 chore: Persian translations
(cherry picked from commit 96fbb18677)
2025-11-07 11:23:12 +00:00
RitvikSardana
b7a38821f3 chore: Indonesian translations
(cherry picked from commit 9b10275f36)
2025-11-07 11:23:12 +00:00
RitvikSardana
fddc8b9b59 chore: Portuguese, Brazilian translations
(cherry picked from commit cb1000f32d)
2025-11-07 11:23:12 +00:00
RitvikSardana
c88310a2d3 chore: Vietnamese translations
(cherry picked from commit c0c9bf2cae)
2025-11-07 11:23:12 +00:00
RitvikSardana
cfcc08fe67 chore: Chinese Simplified translations
(cherry picked from commit 1b64db4792)
2025-11-07 11:23:11 +00:00
RitvikSardana
33f6aa0316 chore: Turkish translations
(cherry picked from commit 7631e7f9a4)
2025-11-07 11:23:11 +00:00
RitvikSardana
eab198c78b chore: Swedish translations
(cherry picked from commit 39f26f0050)
2025-11-07 11:23:11 +00:00
RitvikSardana
485cb2d678 chore: Serbian (Cyrillic) translations
(cherry picked from commit 2c67d72e4f)
2025-11-07 11:23:11 +00:00
RitvikSardana
3f76cf27d0 chore: Russian translations
(cherry picked from commit 006ead4074)
2025-11-07 11:23:10 +00:00
RitvikSardana
9d56115ac8 chore: Portuguese translations
(cherry picked from commit ee57aa5fb0)
2025-11-07 11:23:10 +00:00
RitvikSardana
4b3fdcab54 chore: Polish translations
(cherry picked from commit aec5d229eb)
2025-11-07 11:23:10 +00:00
RitvikSardana
0132e6129b chore: Dutch translations
(cherry picked from commit 3ecc905ae8)
2025-11-07 11:23:10 +00:00
RitvikSardana
712c456527 chore: Italian translations
(cherry picked from commit e1bb325c03)
2025-11-07 11:23:09 +00:00
RitvikSardana
a2fe7a53cc chore: Hungarian translations
(cherry picked from commit 037ae628a1)
2025-11-07 11:23:09 +00:00
RitvikSardana
beeec49ccf chore: German translations
(cherry picked from commit 8af498aa8b)
2025-11-07 11:23:09 +00:00
RitvikSardana
eda0ce2cfd chore: Danish translations
(cherry picked from commit 3adddec261)
2025-11-07 11:23:09 +00:00
RitvikSardana
34f9e1a2e4 chore: Czech translations
(cherry picked from commit 4225f50f0c)
2025-11-07 11:23:08 +00:00
RitvikSardana
2ee895491f chore: Arabic translations
(cherry picked from commit ef8b183c88)
2025-11-07 11:23:08 +00:00
RitvikSardana
e8948ce2f1 chore: Spanish translations
(cherry picked from commit 682a25a677)
2025-11-07 11:23:08 +00:00
RitvikSardana
2d9561796f chore: French translations
(cherry picked from commit 21c3a00c78)
2025-11-07 11:23:08 +00:00
RitvikSardana
543bf922a6 Merge pull request #2707 from frappe/mergify/bp/main-hotfix/pr-2706
fix(perm): agent should see the assigned tickets (backport #2706)
2025-11-07 16:52:30 +05:30
frappe-pr-bot
a270cf565c chore: update POT file
(cherry picked from commit 7c29161b3b)
2025-11-07 11:22:18 +00:00
RitvikSardana
e72c64b1a9 Merge pull request #2717 from frappe/mergify/bp/main-hotfix/pr-2716
fix: remove index delete from hooks (backport #2716)
2025-11-07 16:52:06 +05:30
Ritvik Sardana
87cf42ddea fix: remove unnecessary validation while giving feedback for email tickets
(cherry picked from commit f054d9e209)
2025-11-07 11:22:05 +00:00
Ritvik Sardana
2f1c2de00c fix: remove index delete from hooks
(cherry picked from commit 84fce9936e)
2025-10-31 07:32:29 +00:00
RitvikSardana
6857d2be4e Merge pull request #2713 from frappe/main-hotfix
chore(release): hotfix to main
2025-10-30 10:45:05 +05:30
RitvikSardana
b91a82eecb Bump version to 1.17.1 2025-10-30 10:43:22 +05:30
RitvikSardana
e139fa12df Merge pull request #2711 from frappe/mergify/bp/main-hotfix/pr-2710 2025-10-30 10:40:39 +05:30
Pratik Badhe
8a6568dcce fix: undefined ref
(cherry picked from commit 7a1545ef1b)
2025-10-30 04:59:03 +00:00
Ritvik Sardana
3d5f93dc4e fix(search): add ref ticket only if doc is HD Ticket
(cherry picked from commit 38d0a0b76e)
2025-10-29 11:35:20 +00:00
Ritvik Sardana
15ab7e2adc fix: type coercion while setting value
(cherry picked from commit 40f0032380)
2025-10-29 11:35:20 +00:00
Ritvik Sardana
26cf3ffd90 fix: show ticket to agent is they are assigned to it
(cherry picked from commit f564284d96)
2025-10-29 11:35:20 +00:00
RitvikSardana
3fe5020aa4 Merge pull request #2704 from frappe/main-hotfix
chore(release): hotfix to main
2025-10-29 13:30:31 +05:30
RitvikSardana
27dc26b81a Merge pull request #2703 from frappe/mergify/bp/main-hotfix/pr-2699
chore: bump version (backport #2699)
2025-10-29 12:59:54 +05:30
RitvikSardana
5efea5e456 chore: code cleanup 2025-10-29 12:58:40 +05:30
Ritvik Sardana
af5f937c6d chore: update yarn.lock
(cherry picked from commit c583974b74)
2025-10-29 07:24:21 +00:00
Ritvik Sardana
2f2055d760 chore: bump version
(cherry picked from commit 295c1fded4)
2025-10-29 07:24:21 +00:00
RitvikSardana
9331955bcb Merge pull request #2702 from RitvikSardana/url-hash-hotfix
fix: tab url hash & make a call button (backport #2576)
2025-10-29 12:53:19 +05:30
Pratik Badhe
7114e80e9f refactor: change loading state handling 2025-10-29 12:44:29 +05:30
Pratik Badhe
463026c2ac fix: edge case where tab wont change if call is disabled 2025-10-29 12:44:29 +05:30
Pratik Badhe
7f46ba1191 fix: reset tab state on navigation 2025-10-29 12:44:29 +05:30
Pratik Badhe
60686f151e fix: use correct variable 2025-10-29 12:44:26 +05:30
Pratik Badhe
ca4c6a7128 fix: url bug for custom view 2025-10-29 12:43:53 +05:30
Pratik
5f73663797 fix: tab url hash & make a call button 2025-10-29 12:43:39 +05:30
RitvikSardana
f7e1100d75 Merge pull request #2701 from frappe/mergify/bp/main-hotfix/pr-2689
fix: handle edge case for cc & to field (backport #2689)
2025-10-29 12:28:54 +05:30
Pratik Badhe
13cda63eb6 fix: handle edge case for cc & to field
(cherry picked from commit ffbb1c5e8f)
2025-10-29 06:42:20 +00:00
RitvikSardana
ea9f190683 Merge pull request #2691 from frappe/mergify/bp/main-hotfix/pr-2690
fix: minor fixes (backport #2690)
2025-10-29 10:45:51 +05:30
RitvikSardana
8511d032eb Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2690 2025-10-28 19:34:49 +05:30
RitvikSardana
02b32388dc Merge pull request #2698 from frappe/mergify/bp/main-hotfix/pr-2636
fix: assign history message (backport #2636)
2025-10-28 19:30:28 +05:30
Ritvik Sardana
e2171096b5 fix: reload activities
(cherry picked from commit 046aa8e085)
2025-10-28 13:59:38 +00:00
Ritvik Sardana
3ce3d85ef1 fix: assign history message
(cherry picked from commit 46f0faca0b)
2025-10-28 13:59:38 +00:00
RitvikSardana
35ef3c51dc Merge pull request #2697 from RitvikSardana/shortcuts-hotfix
feat: Shortcuts (backport #2676)
2025-10-28 19:18:35 +05:30
Ritvik Sardana
6e162b95fc fix: delete search index when a ticket is deleted 2025-10-28 19:03:01 +05:30
Ritvik Sardana
b475380508 fix: add shortcuts only in Agent Portal 2025-10-28 19:03:01 +05:30
Ritvik Sardana
20391a7d93 chore: code cleanup 2025-10-28 19:02:59 +05:30
Ritvik Sardana
fe3973c376 chore: code cleanup 2025-10-28 19:02:27 +05:30
Ritvik Sardana
650ca7035e fix: add shortcut for showing help panel 2025-10-28 19:02:27 +05:30
Ritvik Sardana
9e15c94d5e fix: handle special cases in shortcuts 2025-10-28 19:02:27 +05:30
Ritvik Sardana
5a3462f9d5 fix: persist redirect URL 2025-10-28 19:02:27 +05:30
Ritvik Sardana
4bf4ac05a4 chore: UI cleanup 2025-10-28 19:02:27 +05:30
Ritvik Sardana
7f7a934382 chore: code cleanup 2025-10-28 19:02:27 +05:30
Ritvik Sardana
0cafac8f38 refactor: shortcut composable 2025-10-28 19:02:27 +05:30
Ritvik Sardana
38cbb7c717 fix: UI for shortcuts 2025-10-28 19:02:27 +05:30
Ritvik Sardana
90d0303b3b fix: add proper handling to remove key bindings 2025-10-28 19:02:27 +05:30
Ritvik Sardana
62168f7d4f refactor: shortcut handling 2025-10-28 19:02:27 +05:30
Ritvik Sardana
6892132128 fix: handle delete_doc in sqlite search 2025-10-28 19:02:27 +05:30
Ritvik Sardana
cb715e8a2a fix: replace old shortcuts with new API 2025-10-28 19:02:25 +05:30
Ritvik Sardana
64fe77005e fix: add shortcuts for ticket navigation 2025-10-28 19:01:53 +05:30
Ritvik Sardana
33089a6a0c fix: add shortcuts for reply&comment 2025-10-28 19:01:53 +05:30
Ritvik Sardana
79f04ff750 fix: add shortcuts for core fields 2025-10-28 19:01:53 +05:30
Ritvik Sardana
59e57b8d56 fix: better way to disable shortcuts 2025-10-28 19:01:53 +05:30
Ritvik Sardana
46b74ae70d feat: key bindings 2025-10-28 19:01:21 +05:30
RitvikSardana
a6cbabbaba Merge pull request #2696 from frappe/mergify/bp/main-hotfix/pr-2678
chore: update POT file (backport #2678)
2025-10-28 18:46:39 +05:30
RitvikSardana
ece2092932 Merge pull request #2695 from frappe/mergify/bp/main-hotfix/pr-2679
chore: sync translations from crowdin (backport #2679)
2025-10-28 18:46:35 +05:30
RitvikSardana
a0bd028496 Merge branch 'main-hotfix' into mergify/bp/main-hotfix/pr-2678 2025-10-28 18:37:38 +05:30
RitvikSardana
f214c73b02 Merge pull request #2694 from frappe/mergify/bp/main-hotfix/pr-2687
chore: update POT file (backport #2687)
2025-10-28 18:36:50 +05:30
RitvikSardana
99d43706cc Merge pull request #2693 from frappe/mergify/bp/main-hotfix/pr-2657
fix: handle json parse error & json validation on doctype save (backport #2657)
2025-10-28 18:36:42 +05:30
RitvikSardana
0845564356 Merge pull request #2683 from efeone/main-hotfix
fix: update required_apps to include frappe/telephony
2025-10-28 18:35:20 +05:30
frappe-pr-bot
955dc75078 chore: update POT file
(cherry picked from commit 012cfd179b)
2025-10-28 13:02:14 +00:00
RitvikSardana
3b48f660b2 Merge pull request #2692 from frappe/mergify/bp/main-hotfix/pr-2688
fix: prevent usermenu button overflow in sidebar (backport #2688)
2025-10-28 18:31:42 +05:30
RitvikSardana
8e5986b6b9 chore: Polish translations
(cherry picked from commit 5300f24b10)
2025-10-28 13:01:14 +00:00
RitvikSardana
040c4569d5 chore: Polish translations
(cherry picked from commit 03bf47d082)
2025-10-28 13:01:14 +00:00
RitvikSardana
8b052f2764 chore: Persian translations
(cherry picked from commit e2ed80369c)
2025-10-28 13:01:14 +00:00
frappe-pr-bot
eafd4ee634 chore: update POT file
(cherry picked from commit da4008d6ab)
2025-10-28 13:00:10 +00:00
Ritvik Sardana
e53d52c2b7 chore: format error message in assignment rule
(cherry picked from commit 2b62a32f55)
2025-10-28 12:58:06 +00:00
Pratik Badhe
d3fbfb5169 fix: handle long email and name in assignee search
(cherry picked from commit aa0c887504)
2025-10-28 12:58:06 +00:00
Pratik Badhe
34f64f2d33 fix: bug in assignment rule overwrite handling
(cherry picked from commit 4fd9a38860)
2025-10-28 12:58:06 +00:00
Pratik Badhe
0d39e36a46 fix: assignment rule conditions
(cherry picked from commit 22d06eea8a)
2025-10-28 12:58:06 +00:00
Pratik Badhe
bc70ca90d5 fix: show toast if conditions are invalid
(cherry picked from commit 5cce62efef)
2025-10-28 12:58:05 +00:00
Pratik Badhe
b66717731c fix: handle json parse error & json validation on doctype save
(cherry picked from commit 746fb39d1c)
2025-10-28 12:58:05 +00:00
siduck
76ee72c0ea fix: prevent usermenu button overflow in sidebar
Related to https://github.com/frappe/frappe-ui/issues/298

(cherry picked from commit 856a161164)
2025-10-28 12:49:02 +00:00
Ritvik Sardana
e31833a486 fix(sqlite_search): convert to int only when doctype is HD Ticket
(cherry picked from commit 652188cc64)
2025-10-28 10:53:12 +00:00
Ritvik Sardana
0ec14e4ed6 fix: add handling bad_emails in process_mail
(cherry picked from commit 943da1be93)
2025-10-28 10:53:12 +00:00
Ritvik Sardana
bd57310ee6 fix: decoding the receiving email properly with try except
(cherry picked from commit d7225a82c0)
2025-10-28 10:53:11 +00:00
Ritvik Sardana
2c3b1d3a22 fix: dont add build_index function in hooks.py of helpdesk
(cherry picked from commit a25c9c0a8a)
2025-10-28 10:53:11 +00:00
Ritvik Sardana
c6b23de4f1 chore: cleanup
(cherry picked from commit e64fe01ed8)
2025-10-28 10:53:11 +00:00
Ritvik Sardana
998449b71c fix: add support for numberFields in side panel
(cherry picked from commit 9e1d493ef5)
2025-10-28 10:53:11 +00:00
Ritvik Sardana
5c79767406 fix: remove unwanted imports
(cherry picked from commit 8b1bd75348)

# Conflicts:
#	desk/src/components/ticket-agent/AssignTo.vue
#	desk/src/components/ticket-agent/TicketDetailsTab.vue
#	desk/src/components/ticket-agent/TicketNavigation.vue
#	desk/src/pages/SearchAgent.vue
2025-10-28 10:53:11 +00:00
MochaMind
b29bc60aa9 Merge pull request #2685 from frappe/main-hotfix
chore: Merge hotfix into main
2025-10-24 02:53:48 -07:00
Sherin KR
81af8b5e98 fix: update required_apps to include frappe/telephony 2025-10-24 10:00:23 +05:30
RitvikSardana
7b1c9152fa Merge pull request #2674 from frappe/mergify/bp/main-hotfix/pr-2667
chore: update POT file (backport #2667)
2025-10-15 15:28:11 +05:30
RitvikSardana
f432260f65 Merge pull request #2673 from frappe/mergify/bp/main-hotfix/pr-2652
chore: sync translations from crowdin (backport #2652)
2025-10-15 15:28:05 +05:30
frappe-pr-bot
a1a37e2102 chore: update POT file
(cherry picked from commit f6eec48671)
2025-10-15 09:51:18 +00:00
RitvikSardana
b85c38fc5d chore: Serbian (Latin) translations
(cherry picked from commit f9158f4bcf)
2025-10-15 09:50:22 +00:00
RitvikSardana
7623835cd0 chore: Serbian (Cyrillic) translations
(cherry picked from commit 246044776f)
2025-10-15 09:50:22 +00:00
RitvikSardana
c1eeaf3748 chore: Polish translations
(cherry picked from commit 25f33001ae)
2025-10-15 09:50:21 +00:00
RitvikSardana
f94c0fa609 chore: Polish translations
(cherry picked from commit de096bf1bc)
2025-10-15 09:50:21 +00:00
RitvikSardana
a8807155d7 chore: Burmese translations
(cherry picked from commit aac3b105d9)
2025-10-15 09:50:21 +00:00
RitvikSardana
d740c208e7 chore: Tamil translations
(cherry picked from commit 98e4852f43)
2025-10-15 09:50:21 +00:00
RitvikSardana
e951f5df1a chore: Esperanto translations
(cherry picked from commit f7ede8edb0)
2025-10-15 09:50:20 +00:00
RitvikSardana
8012c5c3a7 chore: Serbian (Latin) translations
(cherry picked from commit 76dc90c563)
2025-10-15 09:50:20 +00:00
RitvikSardana
ee13da2bb3 chore: Norwegian Bokmal translations
(cherry picked from commit 24ab3d735b)
2025-10-15 09:50:20 +00:00
RitvikSardana
70b08d2f0a chore: Bosnian translations
(cherry picked from commit 75348d5266)
2025-10-15 09:50:19 +00:00
RitvikSardana
8b57956505 chore: Croatian translations
(cherry picked from commit 037742eacb)
2025-10-15 09:50:19 +00:00
RitvikSardana
1aacf5b75f chore: Thai translations
(cherry picked from commit 31ac3b4bbb)
2025-10-15 09:50:19 +00:00
RitvikSardana
240b1b0a64 chore: Persian translations
(cherry picked from commit 3afcd2f43c)
2025-10-15 09:50:19 +00:00
RitvikSardana
9fef721e60 chore: Indonesian translations
(cherry picked from commit db71ab4744)
2025-10-15 09:50:18 +00:00
RitvikSardana
cabfd3499d chore: Portuguese, Brazilian translations
(cherry picked from commit b6fb4a1024)
2025-10-15 09:50:18 +00:00
RitvikSardana
be8a78a795 chore: Vietnamese translations
(cherry picked from commit 11da05c81e)
2025-10-15 09:50:18 +00:00
RitvikSardana
2e6f00cc1f chore: Chinese Simplified translations
(cherry picked from commit 95dcd882db)
2025-10-15 09:50:18 +00:00
RitvikSardana
b872bbab30 chore: Turkish translations
(cherry picked from commit 6b010d34d4)
2025-10-15 09:50:17 +00:00
RitvikSardana
ccfa7c4c72 chore: Swedish translations
(cherry picked from commit e4f0f7ace0)
2025-10-15 09:50:17 +00:00
RitvikSardana
df544c114e chore: Serbian (Cyrillic) translations
(cherry picked from commit 42b89e65cf)
2025-10-15 09:50:17 +00:00
RitvikSardana
2f21abd777 chore: Russian translations
(cherry picked from commit 131f03a0f6)
2025-10-15 09:50:17 +00:00
RitvikSardana
1581970179 chore: Portuguese translations
(cherry picked from commit d667d15ee9)
2025-10-15 09:50:16 +00:00
RitvikSardana
48164ca420 chore: Polish translations
(cherry picked from commit f3cc7920bd)
2025-10-15 09:50:16 +00:00
RitvikSardana
989ff6b2ab chore: Dutch translations
(cherry picked from commit 790bbcd772)
2025-10-15 09:50:16 +00:00
RitvikSardana
39391ab607 chore: Italian translations
(cherry picked from commit 11a608ead6)
2025-10-15 09:50:15 +00:00
RitvikSardana
ca48068aa4 chore: Hungarian translations
(cherry picked from commit 785e087e95)
2025-10-15 09:50:15 +00:00
RitvikSardana
4496bbdbed chore: German translations
(cherry picked from commit 0c1ad5e944)
2025-10-15 09:50:15 +00:00
RitvikSardana
4bbfe8e792 chore: Danish translations
(cherry picked from commit 29f6a71881)
2025-10-15 09:50:15 +00:00
RitvikSardana
13a29cae8a chore: Czech translations
(cherry picked from commit 04a05dd50b)
2025-10-15 09:50:14 +00:00
RitvikSardana
81314c422a chore: Arabic translations
(cherry picked from commit 2e2ae7e7db)
2025-10-15 09:50:14 +00:00
RitvikSardana
0b5f733c55 chore: Spanish translations
(cherry picked from commit 23cfb9afd8)
2025-10-15 09:50:14 +00:00
RitvikSardana
0ab6c17ef2 chore: French translations
(cherry picked from commit ee3d1e2dea)
2025-10-15 09:50:14 +00:00
RitvikSardana
f09e519b47 chore: Burmese translations
(cherry picked from commit 23260de0d7)
2025-10-15 09:50:13 +00:00
RitvikSardana
5276814156 chore: Portuguese, Brazilian translations
(cherry picked from commit 5959048cae)
2025-10-15 09:50:13 +00:00
RitvikSardana
c5b34bda25 Merge pull request #2663 from frappe/mergify/bp/main-hotfix/pr-2656
fix: reply all 'To' email bug (backport #2656)
2025-10-15 15:19:50 +05:30
RitvikSardana
ab34ac8fff Merge pull request #2662 from frappe/mergify/bp/main-hotfix/pr-2649
chore: update POT file (backport #2649)
2025-10-15 15:19:43 +05:30
Pratik Badhe
56b85aada5 fix: reply all 'To' email bug
(cherry picked from commit 4636ffab67)
2025-10-10 07:15:51 +00:00
RitvikSardana
07e79e2572 Merge pull request #2661 from frappe/mergify/bp/main-hotfix/pr-2647
fix: docker bash file (backport #2647)
2025-10-10 12:41:58 +05:30
frappe-pr-bot
bb9927d252 chore: update POT file
(cherry picked from commit dee4cdf688)
2025-10-10 07:11:51 +00:00
Pratik Badhe
e08e1c94fd fix: docker bash file
(cherry picked from commit 043780c85d)
2025-10-10 07:01:19 +00:00
RitvikSardana
b2934885b1 Merge pull request #2660 from frappe/mergify/bp/main-hotfix/pr-2659
fix: bump vueuse to fix builds (backport #2659)
2025-10-10 12:14:44 +05:30
18alantom
b2fac92338 fix: bump vueuse to fix builds
(cherry picked from commit 4099373b62)
2025-10-10 06:08:32 +00:00
RitvikSardana
52bfc91781 Merge pull request #2645 from frappe/mergify/bp/main-hotfix/pr-2641
chore: sync translations from crowdin (backport #2641)
2025-10-03 12:28:03 +05:30
RitvikSardana
5507ee420d Merge pull request #2644 from frappe/mergify/bp/main-hotfix/pr-2639
fix: disable command palette when text editor is enabled (backport #2639)
2025-10-03 12:27:44 +05:30
RitvikSardana
8d7201c477 chore: Tamil translations
(cherry picked from commit 9afc83e633)
2025-10-03 06:04:57 +00:00
RitvikSardana
0b3174be90 chore: Esperanto translations
(cherry picked from commit 9f017522f0)
2025-10-03 06:04:57 +00:00
RitvikSardana
b3eeba27b0 chore: Serbian (Latin) translations
(cherry picked from commit 4bddc7f0ef)
2025-10-03 06:04:56 +00:00
RitvikSardana
35b5a2e82d chore: Norwegian Bokmal translations
(cherry picked from commit b470a17913)
2025-10-03 06:04:56 +00:00
RitvikSardana
4a56f45da9 chore: Bosnian translations
(cherry picked from commit 00a2efbfbb)
2025-10-03 06:04:56 +00:00
RitvikSardana
2443223a91 chore: Croatian translations
(cherry picked from commit 54b1854bdf)
2025-10-03 06:04:55 +00:00
RitvikSardana
9cc28b6789 chore: Thai translations
(cherry picked from commit dbe03e6fd0)
2025-10-03 06:04:55 +00:00
RitvikSardana
36c3dd6509 chore: Persian translations
(cherry picked from commit 309c084222)
2025-10-03 06:04:55 +00:00
RitvikSardana
abf2248d3e chore: Indonesian translations
(cherry picked from commit 51f2a5e428)
2025-10-03 06:04:55 +00:00
RitvikSardana
ac9e854b34 chore: Portuguese, Brazilian translations
(cherry picked from commit 5343a006ef)
2025-10-03 06:04:54 +00:00
RitvikSardana
6a943f0000 chore: Vietnamese translations
(cherry picked from commit 2caf0e94b8)
2025-10-03 06:04:54 +00:00
RitvikSardana
1cf28aab92 chore: Chinese Simplified translations
(cherry picked from commit 8c9eab3d71)
2025-10-03 06:04:54 +00:00
RitvikSardana
7f9ab5eb96 chore: Turkish translations
(cherry picked from commit 8790567a50)
2025-10-03 06:04:53 +00:00
RitvikSardana
d40f71ff5c chore: Swedish translations
(cherry picked from commit 7a75306222)
2025-10-03 06:04:52 +00:00
RitvikSardana
7936c1243d chore: Serbian (Cyrillic) translations
(cherry picked from commit a1e4bb2555)
2025-10-03 06:04:52 +00:00
RitvikSardana
dba45334c0 chore: Russian translations
(cherry picked from commit 0a0fc03f70)
2025-10-03 06:04:52 +00:00
RitvikSardana
51e35a5838 chore: Portuguese translations
(cherry picked from commit 24b30fb9cb)
2025-10-03 06:04:52 +00:00
RitvikSardana
a9d3a39c56 chore: Polish translations
(cherry picked from commit 6c40f9b2a5)
2025-10-03 06:04:51 +00:00
RitvikSardana
f490014415 chore: Dutch translations
(cherry picked from commit 6e59e71c7c)
2025-10-03 06:04:51 +00:00
RitvikSardana
5733c5fff1 chore: Italian translations
(cherry picked from commit 95e10e43a8)
2025-10-03 06:04:51 +00:00
RitvikSardana
8e395cdeab chore: Hungarian translations
(cherry picked from commit 88396d22a9)
2025-10-03 06:04:50 +00:00
RitvikSardana
3f97ae78b3 chore: German translations
(cherry picked from commit 207b8efcc5)
2025-10-03 06:04:50 +00:00
RitvikSardana
eef3ea7a6f chore: Danish translations
(cherry picked from commit 309cd9e417)
2025-10-03 06:04:50 +00:00
RitvikSardana
325d6ec506 chore: Czech translations
(cherry picked from commit 69fd0d17ac)
2025-10-03 06:04:50 +00:00
RitvikSardana
69ae56d923 chore: Arabic translations
(cherry picked from commit 02cf1d1375)
2025-10-03 06:04:49 +00:00
RitvikSardana
ef830ebbb6 chore: Spanish translations
(cherry picked from commit faade64fb5)
2025-10-03 06:04:49 +00:00
RitvikSardana
6591d5aec5 chore: French translations
(cherry picked from commit 9deb301619)
2025-10-03 06:04:49 +00:00
Ritvik Sardana
5027b876a3 fix: disable command palette when text editor is enabled
(cherry picked from commit 105e8daff6)
2025-10-03 06:04:37 +00:00
RitvikSardana
f34d744f36 Merge pull request #2640 from frappe/mergify/bp/main-hotfix/pr-2625
chore: update POT file (backport #2625)
2025-10-03 11:32:46 +05:30
frappe-pr-bot
e61525de50 chore: update POT file
(cherry picked from commit 5bbfeaff35)
2025-10-01 15:47:14 +00:00
RitvikSardana
1fcc8f7a28 Merge pull request #2638 from frappe/mergify/bp/main-hotfix/pr-2624
chore: sync translations from crowdin (backport #2624)
2025-10-01 21:16:59 +05:30
RitvikSardana
a0fe934e36 chore: Tamil translations
(cherry picked from commit 5d9bdedb22)
2025-10-01 15:37:01 +00:00
RitvikSardana
0bd667223b chore: Persian translations
(cherry picked from commit add112ff9a)
2025-10-01 15:37:01 +00:00
RitvikSardana
62f1d6a3f8 chore: Portuguese, Brazilian translations
(cherry picked from commit 9bc28bec9e)
2025-10-01 15:36:59 +00:00
RitvikSardana
a69b1259ca chore: Vietnamese translations
(cherry picked from commit 5b768c0e4f)
2025-10-01 15:36:58 +00:00
RitvikSardana
36a9252ce3 chore: Chinese Simplified translations
(cherry picked from commit 9d7bcfcbec)
2025-10-01 15:36:58 +00:00
RitvikSardana
48f8c79898 chore: Polish translations
(cherry picked from commit f658054549)
2025-10-01 15:36:57 +00:00
RitvikSardana
ce7b2ec996 chore: Hungarian translations
(cherry picked from commit 1919abfcea)
2025-10-01 15:36:57 +00:00
RitvikSardana
8b548d699e chore: Arabic translations
(cherry picked from commit d058962498)
2025-10-01 15:36:57 +00:00
RitvikSardana
c8971ed41f chore: Spanish translations
(cherry picked from commit 0434986a00)
2025-10-01 15:36:55 +00:00
RitvikSardana
44d28502f4 chore: French translations
(cherry picked from commit 6c6941d53c)
2025-10-01 15:36:54 +00:00
RitvikSardana
8bd6b02233 chore: Norwegian Bokmal translations
(cherry picked from commit 32447b2e77)
2025-10-01 15:36:54 +00:00
RitvikSardana
04ebf1c056 chore: Hungarian translations
(cherry picked from commit 557e0c1d61)
2025-10-01 15:36:54 +00:00
RitvikSardana
fe508b6114 chore: Norwegian Bokmal translations
(cherry picked from commit 6ea1fb316a)
2025-10-01 15:36:53 +00:00
RitvikSardana
b5793d0f2c Merge pull request #2637 from frappe/mergify/bp/main-hotfix/pr-2594
feat: Full Text Search using SQLite (backport #2594)
2025-10-01 21:05:11 +05:30
RitvikSardana
019ca9c58f chore: bump version 2025-10-01 16:30:18 +05:30
Ritvik Sardana
923241e450 chore: version bump
(cherry picked from commit 74a434acac)

# Conflicts:
#	helpdesk/__init__.py
2025-10-01 07:07:10 +00:00
Ritvik Sardana
32527cbf59 fix: search icon and placeholder
(cherry picked from commit ecabb8d55d)
2025-10-01 07:07:10 +00:00
Ritvik Sardana
aca4a01aed fix: persist filters and remove dump code from hooks and search_sqlite
(cherry picked from commit e9e5a58154)
2025-10-01 07:07:09 +00:00
Ritvik Sardana
b78d2e0b1b fix: dont index same doc thrice
(cherry picked from commit 741200e217)
2025-10-01 07:07:09 +00:00
Ritvik Sardana
d794ecc8b9 fix: hide dialog after deleting a comment
(cherry picked from commit 5029510766)
2025-10-01 07:07:08 +00:00
Ritvik Sardana
9ecdd96ee4 fix: add shortcut to focus on search bar
(cherry picked from commit 16b358b599)
2025-10-01 07:07:08 +00:00
Ritvik Sardana
09446a7ec7 fix: show white icon for online and offline
(cherry picked from commit 0248fcfc48)
2025-10-01 07:07:08 +00:00
Ritvik Sardana
9ea3ea71d9 chore: update yarn.lock
(cherry picked from commit 232b30ebdd)
2025-10-01 07:07:08 +00:00
Ritvik Sardana
c094193b59 fix: linters
(cherry picked from commit 7ade7b602b)
2025-10-01 07:07:07 +00:00
Ritvik Sardana
ca0d59f7e6 fix: add scrollbar in custom fields of customer portal
(cherry picked from commit da7bf3d861)
2025-10-01 07:07:07 +00:00
Ritvik Sardana
a0cf892ea8 fix: dont show count
(cherry picked from commit f8280ac203)
2025-10-01 07:07:07 +00:00
Ritvik Sardana
7aed2a563f fix: convert reference_ticket value to int
(cherry picked from commit aad177fc73)
2025-10-01 07:07:07 +00:00
Ritvik Sardana
7640087fb5 fix: search results with permission query filters
(cherry picked from commit ed06002cfc)
2025-10-01 07:07:06 +00:00
Ritvik Sardana
26e547aa36 fix: ticket not found error
(cherry picked from commit 063c8aeea4)
2025-10-01 07:07:06 +00:00
Ritvik Sardana
88b9b9a28b fix: convert command pallete to composition API from options API
(cherry picked from commit efa9ca438e)
2025-10-01 07:07:06 +00:00
Ritvik Sardana
c0b661d9a8 fix: ticket agent scroll
(cherry picked from commit 02080dd4c0)
2025-10-01 07:07:05 +00:00
Faris Ansari
fa2c323ee0 feat: Full Text Search using SQLite
- dedicated search page
- route to ticket and scroll to relevant comment or email
- wip

(cherry picked from commit 0c2967f503)
2025-10-01 07:07:05 +00:00
RitvikSardana
bebcde4144 Merge pull request #2622 from frappe/mergify/bp/main-hotfix/pr-2621
fix: respect read_only fields (backport #2621)
2025-09-29 23:17:01 +05:30
Ritvik Sardana
a071cd7a67 fix: respect read_only fields
(cherry picked from commit 3317f1446b)
2025-09-26 10:43:01 +00:00
RitvikSardana
4ec1299d74 Merge pull request #2620 from frappe/mergify/bp/main-hotfix/pr-2617
fix: add disable field to Feedback Option (backport #2617)
2025-09-26 16:11:28 +05:30
RitvikSardana
fc4a9ac96b Merge pull request #2619 from frappe/mergify/bp/main-hotfix/pr-2618
fix: fetch notifications after the user is fetched (backport #2618)
2025-09-26 16:11:18 +05:30
Ritvik Sardana
abcab05fbe fix: validate for old options only
(cherry picked from commit 0bf79c7be8)
2025-09-26 10:29:35 +00:00
Ritvik Sardana
aed4198add fix: fetch notifications after the user is fetched
(cherry picked from commit 916f039ed3)
2025-09-26 10:29:34 +00:00
Ritvik Sardana
315ae1dd34 fix: add disable field to Feedback Option
(cherry picked from commit 8520a225d1)
2025-09-26 10:29:34 +00:00
RitvikSardana
868304ffc8 Merge pull request #2616 from frappe/main-hotfix
chore: merge hotfix to main
2025-09-26 13:46:27 +05:30
RitvikSardana
0c5c96c3fe Merge pull request #2613 from frappe/mergify/bp/main-hotfix/pr-2610
fix: search in Canned responses  (backport #2610)
2025-09-25 21:06:29 +05:30
RitvikSardana
1a408be504 Merge pull request #2612 from frappe/mergify/bp/main-hotfix/pr-2595
chore: sync translations from crowdin (backport #2595)
2025-09-25 20:37:52 +05:30
Ritvik Sardana
fff8e5346b fix: add debounce in search
(cherry picked from commit f0060c24c2)
2025-09-25 15:07:32 +00:00
MhmdSinanKT
deee7efbce feat: enhance search with or filter
(cherry picked from commit 2b6e54a941)
2025-09-25 15:07:32 +00:00
MhmdSinanKT
cd5f05c059 feat: search for canned responses in hd view
(cherry picked from commit 85e8d9d2e9)
2025-09-25 15:07:32 +00:00
RitvikSardana
3ecbd4a555 Merge pull request #2611 from frappe/mergify/bp/main-hotfix/pr-2604
chore: update POT file (backport #2604)
2025-09-25 20:37:12 +05:30
RitvikSardana
6b76acbaa2 chore: Norwegian Bokmal translations
(cherry picked from commit d67ad04e89)
2025-09-25 15:02:43 +00:00
RitvikSardana
a22f73b93d chore: Norwegian Bokmal translations
(cherry picked from commit 5ea83dccdd)
2025-09-25 15:02:42 +00:00
RitvikSardana
c05b9a3aac chore: Persian translations
(cherry picked from commit db9426279e)
2025-09-25 15:02:42 +00:00
RitvikSardana
a33e34d2fa chore: Norwegian Bokmal translations
(cherry picked from commit f64e305f91)
2025-09-25 15:02:42 +00:00
RitvikSardana
2e8b172dcc chore: Norwegian Bokmal translations
(cherry picked from commit a55a148a78)
2025-09-25 15:02:41 +00:00
RitvikSardana
773ae4c476 chore: Norwegian Bokmal translations
(cherry picked from commit b93128d390)
2025-09-25 15:02:41 +00:00
RitvikSardana
229bf8a2a7 chore: Serbian (Latin) translations
(cherry picked from commit d4936a833f)
2025-09-25 15:02:41 +00:00
RitvikSardana
7566c761ef chore: Serbian (Cyrillic) translations
(cherry picked from commit 781fa723e4)
2025-09-25 15:02:40 +00:00
RitvikSardana
1f4b565bb7 chore: Esperanto translations
(cherry picked from commit d07a8765ed)
2025-09-25 15:02:40 +00:00
RitvikSardana
fe61cb822d chore: Serbian (Latin) translations
(cherry picked from commit f513485016)
2025-09-25 15:02:40 +00:00
RitvikSardana
df1ba3bb7f chore: Norwegian Bokmal translations
(cherry picked from commit e866951197)
2025-09-25 15:02:39 +00:00
RitvikSardana
7e70bef84e chore: Bosnian translations
(cherry picked from commit 7bc623051c)
2025-09-25 15:02:39 +00:00
RitvikSardana
536201602e chore: Croatian translations
(cherry picked from commit 998b277216)
2025-09-25 15:02:39 +00:00
RitvikSardana
61c1c58c41 chore: Thai translations
(cherry picked from commit dbcf6fe866)
2025-09-25 15:02:38 +00:00
RitvikSardana
a2e1fe50b1 chore: Persian translations
(cherry picked from commit 0bd75cec41)
2025-09-25 15:02:38 +00:00
RitvikSardana
7b67a03b90 chore: Indonesian translations
(cherry picked from commit 4e37c4c1d5)
2025-09-25 15:02:38 +00:00
RitvikSardana
168d296d73 chore: Portuguese, Brazilian translations
(cherry picked from commit 6f6b04e6a9)
2025-09-25 15:02:38 +00:00
RitvikSardana
c1c183f2fd chore: Vietnamese translations
(cherry picked from commit 1410bd82cf)
2025-09-25 15:02:37 +00:00
RitvikSardana
1a03488849 chore: Chinese Simplified translations
(cherry picked from commit 6eecc8d8aa)
2025-09-25 15:02:37 +00:00
RitvikSardana
ee48296c16 chore: Turkish translations
(cherry picked from commit 58b27e0261)
2025-09-25 15:02:37 +00:00
RitvikSardana
9b0620b5cd chore: Swedish translations
(cherry picked from commit 4e6a00823f)
2025-09-25 15:02:36 +00:00
RitvikSardana
81674811d8 chore: Serbian (Cyrillic) translations
(cherry picked from commit e599fd2e5a)
2025-09-25 15:02:36 +00:00
RitvikSardana
a985f72651 chore: Russian translations
(cherry picked from commit cee68f1b0d)
2025-09-25 15:02:36 +00:00
RitvikSardana
2264700c53 chore: Portuguese translations
(cherry picked from commit 6358533179)
2025-09-25 15:02:35 +00:00
RitvikSardana
e2fabb1a15 chore: Polish translations
(cherry picked from commit 117aa24a0a)
2025-09-25 15:02:35 +00:00
RitvikSardana
71a0628dab chore: Dutch translations
(cherry picked from commit ff91bd0c15)
2025-09-25 15:02:35 +00:00
RitvikSardana
aa25a2c521 chore: Italian translations
(cherry picked from commit 647fa443fc)
2025-09-25 15:02:34 +00:00
RitvikSardana
db95c35cbb chore: Hungarian translations
(cherry picked from commit 5be3593a13)
2025-09-25 15:02:34 +00:00
RitvikSardana
5cbefdb710 chore: German translations
(cherry picked from commit 93dd13deaf)
2025-09-25 15:02:34 +00:00
RitvikSardana
7535a2d6a6 chore: Danish translations
(cherry picked from commit d86fdba523)
2025-09-25 15:02:34 +00:00
RitvikSardana
9fce60e6fe chore: Czech translations
(cherry picked from commit a53450f740)
2025-09-25 15:02:33 +00:00
RitvikSardana
51c81d8111 chore: Arabic translations
(cherry picked from commit f7929ca621)
2025-09-25 15:02:33 +00:00
RitvikSardana
214d04d69f chore: Spanish translations
(cherry picked from commit dab44e62d5)
2025-09-25 15:02:33 +00:00
frappe-pr-bot
6163562235 chore: update POT file
(cherry picked from commit b125c63d7b)
2025-09-25 15:02:32 +00:00
RitvikSardana
5699356dba chore: French translations
(cherry picked from commit dd81a4cdad)
2025-09-25 15:02:32 +00:00
RitvikSardana
5d1bcef3c2 Merge pull request #2609 from frappe/mergify/bp/main-hotfix/pr-2608
fix: email reply all bug (backport #2608)
2025-09-25 20:31:30 +05:30
Pratik Badhe
1d9c75119b fix: email reply all bug
(cherry picked from commit 14f1acea19)
2025-09-25 14:35:37 +00:00
RitvikSardana
6bc673302f Merge pull request #2597 from frappe/mergify/bp/main-hotfix/pr-2596
fix(desk): don't call mark seen api on new ticket (backport #2596)
2025-09-21 15:31:39 +05:30
RitvikSardana
e8a384dacd chore: Bump version to 1.15.1 2025-09-20 17:47:07 +05:30
RitvikSardana
5f16f0f49e Merge pull request #2603 from frappe/mergify/bp/main-hotfix/pr-2602
fix: add telephony in easy install script (backport #2602)
2025-09-20 17:38:50 +05:30
Ritvik Sardana
705820ffad fix: add telephony in easy install script
(cherry picked from commit 096381bb5c)
2025-09-20 12:07:09 +00:00
Ritvik Sardana
283fc81091 fix(desk): dont call mark seen api on new ticket
(cherry picked from commit 96efa8624f)
2025-09-18 09:52:19 +00:00
RitvikSardana
83b7a94756 Merge pull request #2589 from frappe/main-hotfix
chore(release): hotfix to main
2025-09-17 00:24:06 +05:30
RitvikSardana
bf6372b570 Merge pull request #2588 from frappe/mergify/bp/main-hotfix/pr-2587
fix: update portal url param (backport #2587)
2025-09-17 00:22:31 +05:30
Ritvik Sardana
7a34ac4423 chore: bump version
(cherry picked from commit ee4f111f05)
2025-09-16 18:52:06 +00:00
Ritvik Sardana
a83dc0417d fix: update portal url param
(cherry picked from commit f671d05ba2)
2025-09-16 18:52:06 +00:00
RitvikSardana
efb7c6e21e chore: update frappe version 2025-09-16 17:15:37 +05:30
332 changed files with 127037 additions and 17581 deletions

View File

@@ -9,7 +9,7 @@ sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
pip install frappe-bench
git clone "https://github.com/frappe/frappe" --branch "develop" --depth 1
git clone "https://github.com/frappe/frappe" --branch "version-15" --depth 1
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
mkdir ~/frappe-bench/sites/test_site

View File

@@ -38,7 +38,7 @@ jobs:
- name: Set Branch
run: |
export APPS_JSON='[{"url": "https://github.com/frappe/helpdesk","branch": "main"}]'
export APPS_JSON='[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]'
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
@@ -62,4 +62,4 @@ jobs:
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
build-args: |
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ helpdesk/public/frontend
helpdesk/public/node_modules
helpdesk/www/helpdesk/index.html
desk/components.d.ts
desk/stats.html

329
AGENTS.md Normal file
View File

@@ -0,0 +1,329 @@
# Copilot Instructions for Frappe Helpdesk
## Project Overview
Frappe Helpdesk is an open-source ticket management tool built on the [Frappe Framework](https://github.com/frappe/frappe) (Python backend) and [Frappe UI](https://github.com/frappe/frappe-ui) (Vue.js frontend). It provides agent/customer portals, SLAs, assignment rules, a knowledge base, and saved replies.
## Tech Stack & Architecture
- **Backend**: Frappe Framework with Python
- **Frontend**: Vue 3 + TypeScript SPA with Vite dev server
- **Database**: MariaDB with structured doctypes
- **Real-time**: WebSockets via Socket.IO for live updates
- **UI Library**: frappe-ui for consistent design components
- **Styling**: Tailwind CSS with semantic design tokens
## Architecture
- **Backend (Python):** Located in `helpdesk/`.
- API endpoints: `helpdesk/api/`
- Business logic: `helpdesk/utils.py`, `helpdesk/search.py`, etc.
- Extensibility: `helpdesk/extends/`, `helpdesk/overrides/`
- **Frontend (Vue.js):** Located in `desk/`.
- Main entry: `desk/src/main.js`, `desk/src/App.vue`
- Components: `desk/src/components/`
- State: `desk/src/stores/`
- Routing: `desk/src/router/`
- Styles: Tailwind via `desk/index.css`, config in `desk/tailwind.config.js`
# Frontend: Vue 3 Development Standards
## Frontend Project Context
- `desk/src/` contains the main Vue 3 frontend source code
- Vue 3.x with Composition API as default approach
- TypeScript for type safety and better development experience
- Single File Components (`.vue`) with `<script setup lang="ts">` syntax preferred
- Vite as the build tool for fast development and optimized builds
- Pinia for state management (unlike gameplan which avoids state libraries)
- frappe-ui for UI components and data fetching utilities
## Development Standards
### Architecture & Component Design
- Favor the Composition API (`setup` functions and composables) over the Options API
- Organize components and composables by feature or domain for scalability
- Separate UI-focused components (presentational) from logic-focused components (containers)
- Use PascalCase for component names and file names
- Keep components small and focused on one concern
- Use `<script setup lang="ts">` syntax for brevity and performance
- Favor slots and scoped slots for flexible composition
### TypeScript Integration
- TypeScript is enabled but not in strict mode (as per actual tsconfig.json)
- Use `<script setup lang="ts">` with `defineProps` and `defineEmits`
- Leverage `PropType<T>` for typed props and default values
- Use interfaces or type aliases for complex prop and state shapes
- Define types for event handlers, refs, and `useRoute`/`useRouter` hooks
### Styling Guidelines
- Always prefer Tailwind CSS for styling
- Use utility classes for layout and spacing
- Use semantic class names wherever possible:
- Background colors: `bg-surface-white`, `bg-surface-gray-1` through `bg-surface-gray-9`, `bg-surface-black`
- Text colors: `text-ink-white`, `text-ink-gray-1` through `text-ink-gray-9`, `text-ink-black`
- Fill colors: `fill-ink-*`
- Placeholder colors: `placeholder-ink-*`
- Border colors: `border-outline-white`, `border-outline-gray-1` through `border-outline-gray-5`, `border-outline-black`
- Font sizes: `text-xs`, `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`
- Multiline text: `text-p-xs`, `text-p-sm`, `text-p-base`, `text-p-lg`, `text-p-xl`, `text-p-2xl`
- Always use gray shades for everything, never use color shades even for primary states
- Implement mobile-first, responsive design with CSS Grid and Flexbox
- Ensure styles are accessible (contrast, focus states)
### Data Fetching with frappe-ui
- Use `createResource`, `createListResource`, and `createDocumentResource` from frappe-ui for data fetching
- Handle loading, error, and success states explicitly
- These are the older but stable composables used throughout the project
When working with a list of documents:
```ts
const tickets = createListResource({
doctype: "HD Ticket",
filters: { status: "Open" },
fields: ["name", "subject", "modified"],
limit: 20,
auto: true,
});
// Access data
tickets.data; // Array of ticket objects
tickets.loading; // Boolean loading state
tickets.error; // Error object if any
// Operations
tickets.reload(); // Refresh the list
tickets.update({ name: "<id>", subject: "Updated Subject" }); // Update item
tickets.insert({ subject: "New Ticket" }); // Add new item
tickets.delete("<id>"); // Delete item
```
When working with a single document:
```ts
const ticket = createDocumentResource({
doctype: "HD Ticket",
name: "<ticket-id>",
auto: true,
});
// Access data
ticket.doc; // Document object
ticket.loading; // Boolean loading state
ticket.error; // Error object if any
// Operations
ticket.get(); // Fetch/refresh document
ticket.setValue("status", "Resolved"); // Update field
ticket.save(); // Save changes
ticket.delete(); // Delete document
```
For API calls:
```ts
const apiCall = createResource({
url: "helpdesk.api.ticket.assign_ticket_to_agent",
onSuccess(data) {
// Handle success
},
onError(error) {
// Handle error
},
});
// Make the call
apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
```
### Icons
- **Primary icon system**: Lucide icons via unplugin-icons for consistency and modern design
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage, prefer Lucide for new components)
- Use Lucide icons for all new components:
```vue
<template>
<LucidePlus class="size-4" />
</template>
```
- For script usage, import Lucide icons like this:
```ts
import LucidePlus from "~icons/lucide/plus";
import LucideTicket from "~icons/lucide/ticket";
```
- Only use FeatherIcon for maintaining existing components:
```vue
<template>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
```
### State Management
- Use Pinia stores for complex shared state
- For simple local state, use `ref` and `reactive` within `setup`
- Use `computed` for derived state
- Keep state normalized for complex structures
### Performance & Accessibility
- Apply `v-once` and `v-memo` for static or infrequently changing elements
- Avoid unnecessary watchers; prefer `computed` where possible
- Use semantic HTML elements and ARIA attributes
- Manage focus for modals and dynamic content
- Ensure keyboard navigation for interactive components
# Backend: Frappe Framework Development Standards
## Backend Project Context
- Frappe Framework provides full-stack capabilities with ORM, permissions, and background jobs
- MariaDB database with structured DocTypes for business entities
- Redis for background workers and caching
- Socket.IO for real-time updates
## Critical Development Workflows
**Local Development Setup**:
- Backend runs via `bench start` from frappe-bench directory
- Python web server: Assume server is already started on port 8000
- Frontend dev server: `cd desk && yarn dev` (typically runs on port 8080)
- Site-specific commands: `bench --site helpdesk.test <command>`
**Data Query Patterns**:
- Always prefer `frappe.qb.get_query()` over `frappe.db.get_all()` for new code
- Use `frappe.qb.get_query(..., ignore_permissions=False)` when permission checks are needed
- Example query patterns from helpdesk context
**Debugging Workflow**:
- Create debug files like `./helpdesk/debug.py` with `def execute():` function
- Run with `bench --site helpdesk.test execute helpdesk.debug.execute`
- Use `print()` statements for console output during debugging
## DocType Architecture Patterns
- Follow Frappe's DocType conventions for business entities
- Implement custom permission logic in `has_permission` hooks
- Use proper field types and validation in DocType definitions
- Leverage DocType methods for business logic encapsulation
## Conventions & Patterns
- **API Design:** Python modules in `helpdesk/api/` follow RESTful patterns. Extend via `helpdesk/extends/`.
- **Frontend:** Vue 3 + Vite with TypeScript. Use Pinia for state management. Components auto-imported via `desk/components.d.ts`.
- **Testing:** Follow Frappe's conventions for backend tests.
- **Localization:** Add translations in `helpdesk/locale/`.
- **Custom Logic:** Use `helpdesk/overrides/` for customizations.
## Utilities and Composables
- @vueuse/core is available for common Vue utilities
- Prefer vueuse composables over custom implementations where applicable
- Common composables: `useLocalStorage`, `useDebounce`, `useElementSize`
- Always use frappe-ui's data fetching composables (`createResource`, `createListResource`, `createDocumentResource`), never `useFetch`
## Error Handling & Forms
- Use global error handlers for uncaught errors
- Wrap risky logic in `try/catch` with user-friendly messages
- Build forms with controlled `v-model` bindings
- Validate on blur or input with debouncing for performance
- Ensure accessible labeling and error announcements
## Integration Points
- **Frappe Framework:** All backend logic integrates with Frappe ORM, permissions, and hooks (`helpdesk/hooks.py`).
- **Frappe UI:** Frontend uses Frappe UI for consistent design components and data fetching.
- **Real-time Updates:** WebSockets via Socket.IO for live ticket updates.
- **Crowdin:** Translation sync via `crowdin.yml`.
## Examples
### Adding a New API Endpoint
1. Create a Python file in `helpdesk/api/`
2. Add `@frappe.whitelist()` decorator to functions
3. Register in `helpdesk/hooks.py` if needed
4. Use `frappe.qb.get_query()` for database queries
### Adding a Frontend Component
1. Create Vue component in `desk/src/components/` or `desk/src/pages/`
2. Use `<script setup lang="ts">` syntax
3. Follow Tailwind semantic class conventions
4. Use frappe-ui composables for data fetching
5. Add route in `desk/src/router/` if it's a page component
### Styling Example
```vue
<template>
<div class="bg-surface-white border border-outline-gray-2 rounded-lg p-4">
<h2 class="text-lg text-ink-gray-9 font-semibold mb-2">Ticket Details</h2>
<p class="text-p-sm text-ink-gray-7">{{ ticket.doc.subject }}</p>
<LucideTicket class="size-4 text-ink-gray-6" />
</div>
</template>
<script setup lang="ts">
import { createDocumentResource } from "frappe-ui";
import LucideTicket from "~icons/lucide/ticket";
const ticket = createDocumentResource({
doctype: "HD Ticket",
name: props.ticketId,
auto: true,
});
</script>
```
## Key Files & Directories
### Backend
- `helpdesk/api/` — Backend API endpoints with @frappe.whitelist() decorators
- `helpdesk/utils.py` — Shared backend utilities and helper functions
- `helpdesk/hooks.py` — Frappe hooks
- `helpdesk/overrides/` — Custom logic and extensions
### Frontend
- `desk/src/` — Main Vue 3 frontend source code
- `desk/src/components/` — Reusable Vue components
- `desk/src/pages/` — Page-level components and routes
- `desk/src/stores/` — Pinia stores for state management
- `desk/tailwind.config.js` — Tailwind CSS configuration with semantic tokens
- `desk/components.d.ts` — Auto-generated component type definitions
### Development
- `docker/` — Docker setup for containerized development
## Code Quality & Standards
- Follow Vue's official style guide and best practices
- Use ESLint with Vue 3 recommended rules and Prettier for consistency
- Write meaningful commit messages and maintain clean git history
- Document complex logic with JSDoc/TSDoc comments
- Only add comments that explain why something is done, not what is done
- Keep dependencies updated and audit for security vulnerabilities
## References
- [README.md](../README.md) — Full setup and feature list
- [Frappe Docs](https://frappeframework.com/docs/) — Framework documentation
- [Frappe Helpdesk Docs](https://docs.frappe.io/helpdesk) — Project-specific docs
- [Vue 3 Style Guide](https://vuejs.org/style-guide/) — Vue.js best practices
- [Tailwind CSS](https://tailwindcss.com/docs) — Utility-first CSS framework
---
**For updates, merge new conventions here. If anything is unclear or missing, ask for clarification.**

View File

@@ -42,7 +42,7 @@ Managing issues from our customers was a big challenge for us. We were using the
- **Knowledge Base**: Learn how to create and manage help articles to empower users and reduce tickets.
- **Canned Responses**: Pre-written replies for common queries to ensure quick and consistent communication.
- **Saved Replies**: Pre-written replies for common queries to ensure quick and consistent communication.
<details open>
<summary >View Screenshots</summary>
@@ -165,6 +165,7 @@ To setup the repository locally follow the steps mentioned below:
1. Start the server by running `bench start`
1. In a separate terminal window, create a new site by running `bench new-site helpdesk.test`
1. Map your site to localhost with the command `bench --site helpdesk.test add-to-hosts`
1. Get the Telephony app. Run `bench get-app https://github.com/frappe/telephony`
1. Get the Helpdesk app. Run `bench get-app https://github.com/frappe/helpdesk`
1. Run `bench --site helpdesk.test install-app helpdesk`.
1. Run `bench build --app helpdesk`
@@ -187,6 +188,7 @@ To setup the repository locally follow the steps mentioned below:
| Helpdesk Branch | Compatible Frappe Framework Version |
|-----------------|-------------------------------------|
| main | version-15 |
| main | version-16 |
| develop | develop branch |

View File

@@ -6,5 +6,8 @@
// biome-ignore lint: disable
export {}
declare global {
const LucideAlignJustify: typeof import('~icons/lucide/align-justify')['default']
const LucideFileSearch: typeof import('~icons/lucide/file-search')['default']
const LucideKeyboard: typeof import('~icons/lucide/keyboard')['default']
const LucideSearch: typeof import("~icons/lucide/search")["default"]
}

View File

@@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Helpdesk</title>
<meta name="mobile-web-app-capable" content="yes" />
@@ -206,12 +205,7 @@
<div id="popovers"></div>
<script type="module" src="/src/main.js"></script>
<script>
window.favicon = "{{ favicon }}";
window.site_name = "{{ site_name }}";
document.querySelectorAll("link[rel='icon']").forEach((link) => {
link.href = window.favicon;
});
</script>
</body>
</html>

View File

@@ -1,37 +0,0 @@
import * as LucideIcons from "lucide-static";
const icons = {};
for (const icon in LucideIcons) {
let iconSvg = LucideIcons[icon];
if (icon == "default") {
continue;
}
// set stroke-width to 1.5
if (iconSvg && iconSvg.includes("stroke-width")) {
iconSvg = iconSvg.replace(/stroke-width="2"/g, 'stroke-width="1.5"');
}
icons[icon] = iconSvg;
const dashKeys = camelToDash(icon);
for (const dashKey of dashKeys) {
if (dashKey !== icon) {
icons[dashKey] = iconSvg;
}
}
}
export default icons;
function camelToDash(key) {
// barChart2 -> bar-chart-2
const withNumber = key.replace(/[A-Z0-9]/g, (m) => "-" + m.toLowerCase());
// barChart2 -> bar-chart2
const withoutNumber = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
if (withNumber !== withoutNumber) {
// both are required because unplugin icon resolver doesn't put a dash before numbers
return [withNumber, withoutNumber];
}
return [withNumber];
}

View File

@@ -10,50 +10,37 @@
},
"dependencies": {
"@headlessui/vue": "^1.7.22",
"@iconify-json/lucide": "^1.1.99",
"@iconify-json/ph": "^1.1.5",
"@iconify/tools": "^2.2.6",
"@iconify/vue": "^4.1.1",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.9",
"@tiptap/core": "^2.2.4",
"@tiptap/core": "^3.11.0",
"@twilio/voice-sdk": "^2.15.0",
"@vee-validate/zod": "^4.8.2",
"@vitejs/plugin-vue": "^4.2.3",
"@vueuse/core": "^10.0.2",
"@vueuse/integrations": "^12.0.0",
"@vueuse/core": "^13.8.0",
"@vueuse/integrations": "^13.8.0",
"dayjs": "^1.11.7",
"echarts": "^5.4.1",
"frappe-ui": "0.1.192",
"frappe-ui": "0.1.271",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"lucide-static": "^0.276.0",
"mime": "^3.0.0",
"pinia": "^2.0.33",
"pluralize": "^8.0.0",
"sanitize-html": "^2.10.0",
"socket.io-client": "^4.7.2",
"unplugin-icons": "^0.16.1",
"unplugin-vue-components": "^0.25.2",
"vee-validate": "^4.8.2",
"vue": "^3.5.14",
"vue-echarts": "^6.5.4",
"vue-router": "^4.2.2",
"vuedraggable": "^4.1.0",
"zod": "^3.21.4"
},
"resolutions": {
"cheerio": "1.0.0-rc.12",
"prosemirror-model": "1.25.1"
},
"devDependencies": {
"@vitejs/plugin-vue-jsx": "^3.0.1",
"prettier": "2.8.4",
"typescript": "^5.0.2",
"vite": "^4.4.9",
"vite-plugin-pwa": "^0.20.5",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.5",
"tailwindcss": "^3.4.15"
"prettier": "2.8.4",
"tailwindcss": "^3.4.15",
"typescript": "^5.0.2",
"vite": "^4.4.9",
"vite-plugin-pwa": "0.15.0"
},
"resolutions": {
"cheerio": "1.0.0-rc.12",
"prosemirror-model": "1.25.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

BIN
desk/public/timers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,36 +2,43 @@
<FrappeUIProvider>
<PortalRoot />
</FrappeUIProvider>
<KeymapDialog />
<Dialogs />
</template>
<script setup lang="ts">
import { Dialogs } from "@/components/dialogs";
import KeymapDialog from "@/pages/KeymapDialog.vue";
import { useConfigStore } from "@/stores/config";
import { stopSession } from "@/telemetry";
import { FrappeUIProvider, toast } from "frappe-ui";
import { FrappeUIProvider, toast, setConfig } from "frappe-ui";
import { computed, defineAsyncComponent, h, onMounted, onUnmounted } from "vue";
import Wifi from "~icons/lucide/wifi";
import WifiOff from "~icons/lucide/wifi-off";
import { useAuthStore } from "./stores/auth";
useConfigStore();
import { useFavicon } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { __ } from "./translation";
import { isCustomerPortal, getBrowserTimezone } from "./utils";
const configStore = useConfigStore();
const { favicon } = storeToRefs(configStore);
useFavicon(favicon);
onMounted(() => {
window.addEventListener("online", () => {
toast.create({
message: "You are now online",
icon: h(Wifi),
message: __("You are now online"),
icon: h(Wifi, { class: "text-white" }),
});
});
window.addEventListener("offline", () => {
toast.create({
message: "You are now offline",
icon: h(WifiOff),
message: __("You are now offline"),
icon: h(WifiOff, { class: "text-white" }),
});
});
!isCustomerPortal.value && setConfig("localTimezone", window.timezone?.user);
setConfig("systemTimezone", window.timezone?.system || null);
});
const AgentPortalRoot = defineAsyncComponent(
@@ -49,8 +56,4 @@ const PortalRoot = computed(() => {
return CustomerPortalRoot;
}
});
onUnmounted(() => {
stopSession();
});
</script>

View File

@@ -1,5 +1,11 @@
<template>
<Popover placement="right-start" class="flex w-full">
<Popover
placement="right-start"
class="flex w-full"
trigger="hover"
:hoverDelay="0.1"
:leaveDelay="0.1"
>
<template #target="{ togglePopover }">
<button
:class="[
@@ -16,14 +22,14 @@
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
class="flex flex-col justify-between mx-3 p-1.5 rounded-lg border border-gray-100 bg-white shadow-xl"
>
<div v-for="app in apps.data" key="name">
<a
:href="app.route"
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
class="flex gap-2 rounded items-center hover:bg-gray-100 p-1.5"
>
<img class="size-8" :src="app.logo" />
<img class="size-6" :src="app.logo" />
<div class="text-sm" @click="app.onClick">
{{ app.title }}
</div>
@@ -35,8 +41,8 @@
</template>
<script setup>
import { Popover, createResource } from "frappe-ui";
import AppsIcon from "./icons/AppsIcon.vue";
import ChevronRight from "~icons/lucide/chevron-right";
import AppsIcon from "./icons/AppsIcon.vue";
const apps = createResource({
url: "frappe.apps.get_apps",
@@ -48,7 +54,7 @@ const apps = createResource({
name: "frappe",
logo: "/assets/helpdesk/desk/desk.png",
title: "Desk",
route: "/app",
route: "/desk/helpdesk",
},
];
data.map((app) => {

View File

@@ -86,7 +86,7 @@ import { useConfigStore } from "@/stores/config";
import { useUserStore } from "@/stores/user";
import { call, createResource } from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, onMounted, ref, watch } from "vue";
import { computed, onMounted, ref } from "vue";
const props = defineProps({
doctype: {

View File

@@ -13,7 +13,7 @@
}
"
>
<div class="flex items-center">
<div class="flex items-center truncate">
<slot name="prefix" />
<span
v-if="selectedValue"

View File

@@ -1,106 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
title: 'Canned Responses',
size: '4xl',
}"
>
<template #body-content>
<TextInput
ref="searchInput"
v-model="search"
type="text"
:placeholder="'Site Down'"
>
<template #prefix>
<FeatherIcon name="search" class="h-4 w-4 text-gray-500" />
</template>
</TextInput>
<div
v-if="filteredTemplates.length"
class="mt-2 grid max-h-[560px] grid-cols-1 md:grid-cols-3 gap-2 overflow-y-auto"
>
<div
v-for="template in filteredTemplates"
:key="template.name"
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100"
@click="emit('apply', template)"
>
<div class="border-b pb-2 text-base font-semibold">
{{ template.title }}
</div>
<TextEditor
v-if="template.message"
:content="template.message"
:editable="false"
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
class="flex-1 overflow-hidden"
/>
</div>
</div>
<div v-else class="mt-2">
<div class="flex h-56 flex-col items-center justify-center">
<div class="text-lg text-gray-500">
{{ "No templates found" }}
</div>
</div>
</div>
<div class="flex justify-end mt-4">
<Button
label="New Canned Response"
@click="
() => {
$router.push('/canned-responses#new');
templates.data = null;
}
"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { TextEditor, createListResource } from "frappe-ui";
import { ref, computed, nextTick, watch, onMounted } from "vue";
const props = defineProps({
doctype: {
type: String,
default: "",
},
});
const show = defineModel();
const searchInput = ref("");
const emit = defineEmits(["apply"]);
const search = ref("");
const templates = createListResource({
type: "list",
doctype: "HD Canned Response",
cache: ["cannedResponses", props.doctype],
fields: ["title", "message", "modified"],
orderBy: "modified desc",
pageLength: 99999,
});
onMounted(() => {
if (templates.data == null) {
templates.fetch();
}
});
const filteredTemplates = computed(() => {
return (
templates.data?.filter((template) => {
return template.title.toLowerCase().includes(search.value.toLowerCase());
}) ?? []
);
});
watch(show, (value) => value && nextTick(() => searchInput.value?.el?.focus()));
</script>

View File

@@ -49,7 +49,10 @@
</div>
</div>
</div>
<div class="rounded bg-gray-50 px-4 py-3">
<div
:id="`comment-${name}`"
class="rounded bg-gray-50 transition-colors px-4 py-3"
>
<TextEditor
ref="editorRef"
:editor-class="[
@@ -59,16 +62,34 @@
:content="_content"
:editable="editable"
:bubble-menu="textEditorMenuButtons"
:mentions="userMentions"
@change="(event:string) => {_content = event}"
@keydown.ctrl.enter.capture.stop="handleSaveComment"
@keydown.meta.enter.capture.stop="handleSaveComment"
>
<template #bottom v-if="editable">
<div class="flex flex-row-reverse gap-2">
<Button label="Save" @click="handleSaveComment" variant="solid" />
<div>
<Button
:label="
isMobileView
? 'Save'
: isMac
? 'Save (⌘ + ⏎)'
: 'Save (Ctrl + ⏎)'
"
@click="handleSaveComment"
variant="solid"
/>
</div>
<Button label="Discard" @click="handleDiscard" />
</div>
</template>
</TextEditor>
<div class="flex flex-wrap gap-2" v-if="!editable">
<div
class="flex flex-wrap gap-2"
v-if="!editable && Boolean(attachments.length)"
>
<AttachmentItem
v-for="a in attachments"
:key="a.file_url"
@@ -76,6 +97,64 @@
:url="a.file_url"
/>
</div>
<div
class="flex items-center gap-2 mt-2"
v-if="!editable && enableCommentReactions"
>
<Popover>
<template #target="{ togglePopover }">
<button
class="flex h-full items-center justify-center rounded-full bg-surface-gray-2 px-2 py-1 text-ink-gray-6 transition hover:bg-surface-gray-3"
@click="togglePopover()"
>
<ReactionIcon class="w-4 h-4" />
</button>
</template>
<template #body>
<div
class="bg-surface-white rounded-lg shadow-lg p-2 border border-outline-gray-2"
>
<div class="grid grid-cols-6 gap-2">
<button
v-for="emoji in emojiList"
:key="emoji"
class="size-6 flex items-center justify-center rounded hover:bg-surface-gray-2 text-lg transition-colors"
@click="handleReaction(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
</template>
</Popover>
<template v-for="reaction in reactionsList" :key="reaction.emoji">
<Tooltip>
<template #body>
<div
class="bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl rounded"
>
<span v-for="(user, idx) in reaction.users" :key="user.user"
>{{ user.full_name
}}<span v-if="idx < reaction.users.length - 1">, </span></span
>
</div>
</template>
<button
class="flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors"
:class="
reaction.current_user_reacted
? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
: 'bg-surface-gray-3 text-ink-gray-6 hover:bg-surface-gray-4'
"
@click="handleReaction(reaction.emoji)"
>
<span>{{ reaction.emoji }}</span>
<span class="font-medium">{{ reaction.count }}</span>
</button>
</Tooltip>
</template>
</div>
</div>
</div>
<Dialog
@@ -97,10 +176,15 @@
<script setup lang="ts">
import { AttachmentItem } from "@/components";
import ReactionIcon from "@/components/icons/ReactionIcon.vue";
import { useAgentStore } from "@/stores/agent";
import { useAuthStore } from "@/stores/auth";
import { useConfigStore } from "@/stores/config";
import { updateRes as updateComment } from "@/stores/knowledgeBase";
import { useUserStore } from "@/stores/user";
import { CommentActivity } from "@/types";
import { useDevice } from "@/composables";
import { useScreenSize } from "@/composables/screen";
import {
dateFormat,
dateTooltipFormat,
@@ -113,11 +197,14 @@ import {
Avatar,
Dialog,
Dropdown,
Popover,
TextEditor,
Tooltip,
createResource,
toast,
} from "frappe-ui";
import { PropType, computed, onMounted, ref } from "vue";
const authStore = useAuthStore();
const props = defineProps({
activity: {
@@ -126,31 +213,72 @@ const props = defineProps({
},
});
const { getUser } = useUserStore();
const { enableCommentReactions } = useConfigStore();
const { name, creation, content, commenter, commentedBy, attachments } =
props.activity;
const { isMac } = useDevice();
const { isMobileView } = useScreenSize();
const isTicketMergedComment = computed(() => {
const regex = /has been merged with ticket #\d+/;
return regex.test(content);
});
const agentStore = useAgentStore();
const userMentions = computed(() => agentStore.dropdown ?? []);
const emit = defineEmits(["update"]);
const showDialog = ref(false);
const editable = ref(false);
const _content = ref(content);
// HTML refs
const emojiList = ["👍", "👎", "❤️", "🎉", "👀", "✅"];
const reactions = ref<
Array<{
emoji: string;
count: number;
users: Array<{ user: string; full_name: string }>;
current_user_reacted: boolean;
}>
>([]);
const reactionsList = computed(() => reactions.value || []);
const fetchReactions = createResource({
url: "helpdesk.helpdesk.doctype.hd_ticket_comment.hd_ticket_comment.get_reactions",
makeParams: () => ({ comment: name }),
auto: true,
onSuccess(data) {
reactions.value = data || [];
},
});
const toggleReaction = createResource({
url: "helpdesk.helpdesk.doctype.hd_ticket_comment.hd_ticket_comment.toggle_reaction",
makeParams: (emoji: string) => ({ comment: name, emoji }),
onSuccess() {
fetchReactions.reload();
},
});
function handleReaction(emoji: string) {
toggleReaction.submit(emoji);
}
const commentBoxRef = ref(null);
const editorRef = ref(null);
const lastSavedContent = ref(content);
const commentBoxState = ref(content);
function handleEditMode() {
editable.value = true;
commentBoxState.value = _content.value;
editorRef.value.editor.chain().focus("start");
}
function handleDiscard() {
_content.value = content;
_content.value = commentBoxState.value;
editable.value = false;
}
@@ -162,12 +290,13 @@ const deleteComment = createResource({
}),
onSuccess() {
emit("update");
showDialog.value = false;
toast.success("Comment deleted");
},
});
function handleSaveComment() {
if (content === _content.value) {
if (lastSavedContent.value === _content.value) {
editable.value = false;
return;
}
@@ -186,6 +315,7 @@ function handleSaveComment() {
{
onSuccess: () => {
editable.value = false;
lastSavedContent.value = _content.value;
emit("update");
toast.success("Comment updated");
},

View File

@@ -12,9 +12,9 @@
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
:placeholder="placeholder"
:editable="editable"
:mentions="agents"
:mentions="dropdown"
@change="editable ? (newComment = $event) : null"
:extensions="[PreserveVideoControls]"
:extensions="[ComponentUtils, HandleExcelPaste]"
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
>
<template #bottom>
@@ -39,10 +39,6 @@
<!-- Fixed Menu -->
<div class="flex justify-between overflow-hidden border-t py-2.5">
<div class="flex items-center overflow-x-auto w-[60%]">
<TextEditorFixedMenu
class="-ml-1"
:buttons="textEditorMenuButtons"
/>
<FileUploader
:upload-args="{
doctype: doctype,
@@ -51,7 +47,8 @@
}"
@success="(f) => attachments.push(f)"
>
<template #default="{ openFileSelector }">
<template #default="{ openFileSelector, uploading }">
{{ void (loading = uploading) }}
<Button
theme="gray"
variant="ghost"
@@ -66,6 +63,10 @@
</Button>
</template>
</FileUploader>
<TextEditorFixedMenu
class="-ml-0.5"
:buttons="textEditorMenuButtons"
/>
</div>
<div class="flex items-center justify-end space-x-2 w-[40%]">
<Button
@@ -81,7 +82,7 @@
<Button
variant="solid"
:label="label"
:disabled="commentEmpty"
:disabled="isDisabled"
:loading="loading"
@click="
() => {
@@ -112,7 +113,7 @@ import { AttachmentIcon } from "@/components/icons/";
import { useTyping } from "@/composables/realtime";
import { useAgentStore } from "@/stores/agent";
import { useAuthStore } from "@/stores/auth";
import { PreserveVideoControls } from "@/tiptap-extensions";
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions";
import {
getFontFamily,
isContentEmpty,
@@ -121,22 +122,12 @@ import {
uploadFunction,
} from "@/utils";
import { useStorage } from "@vueuse/core";
import { storeToRefs } from "pinia";
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { agents: agentsList } = useAgentStore();
const { agents: agentsList, dropdown } = storeToRefs(useAgentStore());
const { isManager } = useAuthStore();
onMounted(() => {
if (
agentsList.loading ||
agentsList.data?.length ||
agentsList.list.promise
) {
return;
}
agentsList.fetch();
});
const props = defineProps({
ticketId: {
type: String,
@@ -168,8 +159,8 @@ const newComment = useStorage("commentBoxContent" + props.ticketId, null);
const { onUserType, cleanup } = useTyping(props.ticketId);
const attachments = ref([]);
const commentEmpty = computed(() => {
return isContentEmpty(newComment.value);
const isDisabled = computed(() => {
return isContentEmpty(newComment.value) || loading.value;
});
const loading = ref(false);
@@ -188,15 +179,6 @@ const label = computed(() => {
return loading.value ? "Sending..." : props.label;
});
const agents = computed(() => {
return (
agentsList.data?.map((agent) => ({
label: agent.agent_name.trimEnd(),
value: agent.name,
})) || []
);
});
function removeAttachment(attachment) {
attachments.value = attachments.value.filter((a) => a !== attachment);
removeAttachmentFromServer(attachment.name);
@@ -237,6 +219,17 @@ async function submitComment() {
const editorRef = ref(null);
const editor = computed(() => editorRef.value?.editor);
onMounted(() => {
if (
agentsList.value.loading ||
agentsList.value.data?.length ||
agentsList.value.list.promise
) {
return;
}
agentsList.value.fetch();
});
defineExpose({
submitComment,
editor,

View File

@@ -29,6 +29,7 @@
</div>
</div>
<div
ref="emailBoxRef"
v-show="showEmailBox"
class="flex gap-1.5 flex-1"
@keydown.ctrl.enter.capture.stop="submitEmail"
@@ -59,6 +60,7 @@
/>
</div>
<div
ref="commentBoxRef"
v-show="showCommentBox"
@keydown.ctrl.enter.capture.stop="submitComment"
@keydown.meta.enter.capture.stop="submitComment"
@@ -97,8 +99,10 @@ import { CommentTextEditor, EmailEditor, TypingIndicator } from "@/components";
import { CommentIcon, EmailIcon } from "@/components/icons/";
import { useDevice } from "@/composables";
import { useScreenSize } from "@/composables/screen";
import { useShortcut } from "@/composables/shortcuts";
import { showCommentBox, showEmailBox } from "@/pages/ticket/modalStates";
import { ref, watch } from "vue";
import { onClickOutside } from "@vueuse/core";
const emit = defineEmits(["update"]);
const content = defineModel("content");
@@ -108,6 +112,8 @@ let doc = defineModel();
// let doc = inject(TicketSymbol)?.value.doc
const emailEditorRef = ref(null);
const commentTextEditorRef = ref(null);
const emailBoxRef = ref(null);
const commentBoxRef = ref(null);
function toggleEmailBox() {
if (showCommentBox.value) {
@@ -135,13 +141,21 @@ function submitComment() {
}
}
function splitIfString(str: string | string[]) {
if (typeof str === "string") {
return str.split(",");
}
return str;
}
function replyToEmail(data: object) {
showEmailBox.value = true;
emailEditorRef.value.addToReply(
data.content,
data.to?.split(","),
data.cc?.split(","),
data.bcc?.split(",")
splitIfString(data.to),
splitIfString(data.cc),
splitIfString(data.bcc)
);
}
@@ -186,12 +200,43 @@ watch(
}
);
useShortcut("r", () => {
toggleEmailBox();
});
useShortcut("c", () => {
toggleCommentBox();
});
defineExpose({
replyToEmail,
toggleEmailBox,
toggleCommentBox,
editor: emailEditorRef,
});
onClickOutside(
emailBoxRef,
() => {
if (showEmailBox.value) {
showEmailBox.value = false;
}
},
{
ignore: [".tippy-box", ".tippy-content"],
}
);
onClickOutside(
commentBoxRef,
() => {
if (showCommentBox.value) {
showCommentBox.value = false;
}
},
{
ignore: [".tippy-box", ".tippy-content"],
}
);
</script>
<style>

View File

@@ -5,6 +5,7 @@
:key="action.label"
:label="action.label"
@click="action.onClick()"
v-bind="action"
>
<template v-if="action.icon" #prefix>
<FeatherIcon :name="action.icon" class="h-4 w-4" />

View File

@@ -1,7 +1,10 @@
<template>
<Tooltip text="View documentation">
<a :href="url" target="_blank">
<lucide-info class="h-4 w-4 text-ink-gray-6" :class="color" />
<lucide-circle-question-mark
class="h-4 w-4 text-ink-gray-6"
:class="color"
/>
</a>
</Tooltip>
</template>

View File

@@ -1,5 +1,6 @@
<template>
<div
:id="`communication-${name}`"
v-bind="$attrs"
class="grow cursor-pointer border-transparent bg-white rounded-md shadow text-base leading-6 transition-all duration-300 ease-in-out"
>
@@ -10,7 +11,7 @@
<!-- email design for mobile -->
<div v-if="isMobileView" class="flex items-center gap-2 text-sm">
<div class="leading-tight">
<p>{{ sender.full_name || "No name found" }}</p>
<p>{{ sender.full_name || "Guest" }}</p>
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
<p class="text-xs md:text-sm text-gray-600">
{{ timeAgo(creation) }}
@@ -23,7 +24,7 @@
</div>
<!-- email design for desktop -->
<div v-else class="flex items-center gap-1">
<span>{{ sender.full_name || "No name found" }}</span>
<span>{{ sender.full_name || "Guest" }}</span>
<span class="sm:flex hidden text-sm text-gray-600" v-if="sender.name">{{
"<" + sender.name + ">"
}}</span>
@@ -45,30 +46,10 @@
{{ timeAgo(creation) }}
</p>
</Tooltip>
<Button
variant="ghost"
class="text-gray-700"
@click="
emit('reply', {
content: content,
to: sender?.name ?? to,
})
"
>
<Button variant="ghost" class="text-gray-700" @click="reply">
<ReplyIcon class="h-4 w-4" />
</Button>
<Button
variant="ghost"
class="text-gray-700"
@click="
emit('reply', {
content: content,
to: to ?? sender.name,
cc: cc ? cc : [],
bcc: bcc ? bcc : [],
})
"
>
<Button variant="ghost" class="text-gray-700" @click="replyAll">
<ReplyAllIcon class="h-4 w-4" />
</Button>
<Dropdown
@@ -132,6 +113,9 @@ import { computed, ref } from "vue";
import LucideSplit from "~icons/lucide/split";
import { ReplyAllIcon, ReplyIcon } from "./icons";
import TicketSplitModal from "./ticket/TicketSplitModal.vue";
import { useAuthStore } from "@/stores/auth";
import { storeToRefs } from "pinia";
const props = defineProps({
activity: {
type: Object,
@@ -158,6 +142,8 @@ const {
const emit = defineEmits(["reply"]);
const auth = storeToRefs(useAuthStore());
const { isMobileView } = useScreenSize();
const showSplitModal = ref(false);
@@ -177,6 +163,66 @@ const status = computed(() => {
return { label: _status, color: indicator_color };
});
const reply = () => {
const user = auth.user.value;
emit("reply", {
content: content,
to: user === sender.name ? to : sender.name,
});
};
const replyAll = () => {
const user = auth.user.value;
const normalizeAndFilter = (field) => {
let arr = [];
let current = "";
let inQuotes = false;
if (typeof field === "string") {
for (let char of field) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if (char === "," && !inQuotes) {
arr.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current) arr.push(current.trim());
} else {
arr = field || [];
}
return arr.filter((item) => item !== user && item !== sender.name);
};
const filteredTo = normalizeAndFilter(to);
const filteredCc = normalizeAndFilter(cc);
const filteredBcc = normalizeAndFilter(bcc);
let _to, _cc, _bcc;
if (user === sender.name) {
// User is the sender, reply to all original recipients
_to = filteredTo.join(", ");
_cc = filteredCc;
_bcc = filteredBcc;
} else {
// User is a recipient, reply to sender with all other recipients in cc
_to = sender.name;
_cc = [...filteredTo, ...filteredCc];
_bcc = filteredBcc;
}
emit("reply", {
content: content,
to: _to,
cc: _cc.filter(Boolean),
bcc: _bcc.filter(Boolean),
});
};
// TODO: Implement reply functionality using this way instead of emit drillup
// function reply(email, reply_all = false) {
// emailBox.toggleEmailBox();

View File

@@ -6,9 +6,9 @@
/>
</template>
<script setup>
import { ref, watch } from "vue";
<script setup lang="ts">
import { getFontFamily } from "@/utils";
import { computed, ref, watch } from "vue";
const props = defineProps({
content: {
@@ -17,12 +17,26 @@ const props = defineProps({
},
});
const files = import.meta.globEager("/src/index.css", { query: "?inline" });
const css = files["/src/index.css"].default;
const iframeRef = ref(null);
const iframeRef = ref<HTMLIFrameElement | null>(null);
const _content = ref(props.content);
// Get CSS path - in dev Vite serves it directly, in prod we need the built path
const cssHref = computed(() => {
if (import.meta.env.DEV) {
return "/src/index.css";
}
// In production, find the built CSS file from the document
const links = document.querySelectorAll('link[rel="stylesheet"]');
for (const link of links) {
const href = link.getAttribute("href");
if (href?.includes("/assets/helpdesk/desk/") && href.endsWith(".css")) {
return href;
}
}
// Fallback to a reasonable path
return "/assets/helpdesk/desk/index.css";
});
const parser = new DOMParser();
const doc = parser.parseFromString(_content.value, "text/html");
@@ -38,8 +52,12 @@ if (gmailReplyToContent.length) {
_content.value = parseReplyToContent(doc, "p.reply-to-content");
}
function parseReplyToContent(doc, selector, forGmail = false) {
function handleAllInstances(doc) {
function parseReplyToContent(
doc: Document,
selector: string,
forGmail = false
) {
function handleAllInstances(doc: Document) {
const replyToContentElements = doc.querySelectorAll(selector);
if (replyToContentElements.length === 0) return;
const replyToContentElement = replyToContentElements[0];
@@ -48,13 +66,15 @@ function parseReplyToContent(doc, selector, forGmail = false) {
}
handleAllInstances(doc);
return doc.body.innerHTML;
}
function replaceReplyToContent(replyToContentElement, forGmail) {
function replaceReplyToContent(
replyToContentElement: Element,
forGmail: boolean
) {
if (!replyToContentElement) return;
let randomId = Math.random().toString(36).substring(2, 7);
const randomId = Math.random().toString(36).substring(2, 7);
const wrapper = doc.createElement("div");
wrapper.classList.add("replied-content");
@@ -75,166 +95,128 @@ function replaceReplyToContent(replyToContentElement, forGmail) {
if (prevSibling && prevSibling.tagName === "BR") {
prevSibling.remove();
}
let cloned = replyToContentElement.cloneNode(true);
const cloned = replyToContentElement.cloneNode(true) as Element;
cloned.classList.remove("gmail_quote");
wrapper.appendChild(cloned);
} else {
const allSiblings = Array.from(
replyToContentElement.parentElement.children
replyToContentElement.parentElement?.children || []
);
const replyToContentIndex = allSiblings.indexOf(replyToContentElement);
const followingSiblings = allSiblings.slice(replyToContentIndex + 1);
if (followingSiblings.length === 0) return;
let clonedFollowingSiblings = followingSiblings.map((sibling) =>
const clonedFollowingSiblings = followingSiblings.map((sibling) =>
sibling.cloneNode(true)
);
const div = doc.createElement("div");
div.append(...clonedFollowingSiblings);
wrapper.append(div);
// Remove all siblings after the reply-to-content element
for (let i = replyToContentIndex + 1; i < allSiblings.length; i++) {
replyToContentElement.parentElement.removeChild(allSiblings[i]);
replyToContentElement.parentElement?.removeChild(allSiblings[i]);
}
}
replyToContentElement.parentElement.replaceChild(
replyToContentElement.parentElement?.replaceChild(
wrapper,
replyToContentElement
);
}
const htmlContent = `
const htmlContent = computed(
() => `
<!DOCTYPE html>
<html>
<head>
<style>
${css}
.replied-content .collapse {
margin: 10px 0 10px 0;
visibility: visible;
cursor: pointer;
display: flex;
font-size: larger;
font-weight: 700;
height: 12px;
line-height: 0.1;
background: #e8eaed;
width: 23px;
justify-content: center;
border-radius: 5px;
}
.replied-content .collapse:hover {
background: #dadce0;
}
.replied-content .collapse + input {
display: none;
}
.replied-content .collapse + input + div {
display: none;
}
.replied-content .collapse + input:checked + div {
display: block;
}
.email-content {
word-break: break-word;
}
.email-content
:is(:where(table):not(:where([class~='not-prose'], [class~='not-prose']
*))) {
table-layout: auto;
}
.email-content
:where(table):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
width: unset;
table-layout: auto;
text-align: unset;
margin-top: unset;
margin-bottom: unset;
font-size: unset;
line-height: unset;
}
/* tr */
.email-content
:where(tbody tr):not(:where([class~='not-prose'], [class~='not-prose']
*)) {
border-bottom-width: 0;
border-bottom-color: transparent;
}
/* td */
.email-content
:is(:where(td):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
position: unset;
border-width: 0;
border-color: transparent;
padding: 0;
}
.email-content
:where(tbody td):not(:where([class~='not-prose'], [class~='not-prose']
*)) {
vertical-align: revert;
}
/* image */
.email-content
:is(:where(img):not(:where([class~='not-prose'], [class~='not-prose']
*))) {
border-width: 0;
}
.email-content
:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
margin: 0;
}
/* before & after */
.email-content
:where(blockquote
p:first-of-type):not(:where([class~='not-prose'], [class~='not-prose']
*))::before {
content: none;
}
.email-content
:where(blockquote
p:last-of-type):not(:where([class~='not-prose'], [class~='not-prose']
*))::after {
content: none;
}
</style>
<link rel="stylesheet" href="${cssHref.value}" />
<base target="_blank" />
<style>
:root {
--bg-surface-gray-3: #ededed;
--bg-surface-gray-4: #e2e2e2;
}
[data-theme='dark'] {
--bg-surface-gray-3: #343434;
--bg-surface-gray-4: #424242;
}
.replied-content .collapse {
margin: 10px 0 10px 0;
visibility: visible;
cursor: pointer;
display: flex;
font-size: larger;
font-weight: 700;
height: 12px;
line-height: 0.1;
background: #e8eaed;
width: 23px;
justify-content: center;
border-radius: 5px;
}
.replied-content .collapse:hover {
background: #dadce0;
}
.replied-content .collapse + input {
display: none;
}
.replied-content .collapse + input + div {
display: none;
}
.replied-content .collapse + input:checked + div {
display: block;
}
.email-content {
word-break: break-word;
}
.email-content :is(:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
border-width: 0;
}
.email-content :where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
margin: 0;
}
.email-content :where(blockquote p:first-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::before {
content: none;
}
.email-content :where(blockquote p:last-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::after {
content: none;
}
</style>
</head>
<body>
<div ref="emailContentRef" class="email-content prose-f">${_content.value}</div>
<div class="email-content prose-f">${_content.value}</div>
</body>
<base target="_blank" />
</html>
`;
`
);
watch(iframeRef, (iframe) => {
if (iframe) {
iframe.onload = () => {
const emailContent =
iframe.contentWindow.document.querySelector(".email-content");
let parent = emailContent.closest("html");
emailContent.classList.add(getFontFamily(_content.value));
iframe.contentWindow?.document.querySelector(".email-content");
if (!emailContent) return;
const parent = emailContent.closest("html");
if (!parent) return;
let theme = document.documentElement.getAttribute("data-theme");
parent.setAttribute("data-theme", theme);
const font = getFontFamily(_content.value);
if (font) emailContent.classList.add(font);
iframe.style.height = parent.offsetHeight + 1 + "px";
let replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
// Clicks inside the iframe don't bubble to the parent document, popovers/dropdowns that close on outside-click never fire.
iframe.contentDocument?.addEventListener("pointerdown", () => {
document.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true })
);
});
const replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
if (replyCollapsers.length) {
replyCollapsers.forEach((replyCollapser) => {
replyCollapser.addEventListener("change", () => {

View File

@@ -2,18 +2,18 @@
<TextEditor
ref="editorRef"
:editor-class="[
'prose-sm max-w-full mx-6 md:mx-10 max-h-[50vh] py-3',
'min-h-[7rem]',
'prose-sm max-w-full mx-6 md:mx-10 py-3',
getFontFamily(newEmail),
editable && '!max-h-[35vh] overflow-y-auto',
'[&_p.reply-to-content]:hidden',
]"
:content="newEmail"
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
:placeholder="placeholder"
:editable="editable"
@change="editable ? (newEmail = $event) : null"
:extensions="[PreserveVideoControls]"
:extensions="[ComponentUtils, HandleExcelPaste]"
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
@keydown.capture="handleKeydown"
>
<template #top>
<div class="mx-6 md:mx-10 flex items-center gap-2 border-y py-2.5">
@@ -21,7 +21,7 @@
<MultiSelectInput
v-model="toEmailsClone"
class="flex-1"
:validate="validateEmail"
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
/>
<Button
@@ -45,7 +45,7 @@
ref="ccInput"
v-model="ccEmailsClone"
class="flex-1"
:validate="validateEmail"
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
/>
</div>
@@ -59,17 +59,24 @@
ref="bccInput"
v-model="bccEmailsClone"
class="flex-1"
:validate="validateEmail"
:validate="validateEmailWithZod"
:error-message="(value) => `${value} is an invalid email address`"
/>
</div>
</template>
<!-- <template v-slot:editor="{ _editor }">
<EditorContent
:class="[editable && 'max-h-[35vh] overflow-y-auto']"
:editor="_editor"
/>
</template> -->
<template #editor>
<div class="overflow-y-auto min-h-[7rem] max-h-[30vh]">
<EditorContent :editor="editor" />
<div
v-if="quotedContent"
ref="quotedContentRef"
contenteditable="true"
class="prose !max-w-full mx-6 md:mx-10 my-2 border-l-4 border-gray-300 pl-4 text-sm focus:outline-none"
@input="onQuotedInput"
/>
</div>
</template>
<template #bottom>
<!-- Attachments -->
<div class="flex flex-wrap gap-2 px-10">
@@ -106,8 +113,13 @@
}
"
>
<template #default="{ openFileSelector }">
<Button variant="ghost" @click="openFileSelector()">
<template #default="{ openFileSelector, uploading }">
{{ void (isUploading = uploading) }}
<Button
variant="ghost"
@click="openFileSelector()"
:loading="uploading"
>
<template #icon>
<AttachmentIcon
class="h-4"
@@ -119,13 +131,10 @@
</FileUploader>
<Button
variant="ghost"
@click="showCannedResponseSelectorModal = true"
@click="showSavedRepliesSelectorModal = true"
>
<template #icon>
<EmailIcon
class="h-4"
style="color: #000000; stroke-width: 1.2"
/>
<SavedReplyIcon class="h-4" />
</template>
</Button>
</div>
@@ -137,7 +146,7 @@
<Button label="Discard" @click="handleDiscard" />
<Button
variant="solid"
:disabled="emailEmpty"
:disabled="isDisabled"
:loading="sendMail.loading"
:label="label"
@click="
@@ -150,32 +159,34 @@
</div>
</template>
</TextEditor>
<CannedResponseSelectorModal
v-model="showCannedResponseSelectorModal"
<SavedRepliesSelectorModal
v-if="showSavedRepliesSelectorModal"
v-model="showSavedRepliesSelectorModal"
:doctype="doctype"
@apply="applyCannedResponse"
@apply="applySavedReplies"
:ticketId="ticketId"
/>
</template>
<script setup lang="ts">
import {
AttachmentItem,
CannedResponseSelectorModal,
MultiSelectInput,
SavedRepliesSelectorModal,
} from "@/components";
import { AttachmentIcon, EmailIcon } from "@/components/icons";
import { EditorContent } from "@tiptap/vue-3";
import { AttachmentIcon } from "@/components/icons";
import { useTyping } from "@/composables/realtime";
import { useAuthStore } from "@/stores/auth";
import { PreserveVideoControls } from "@/tiptap-extensions";
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions";
import {
getFontFamily,
isContentEmpty,
removeAttachmentFromServer,
textEditorMenuButtons,
uploadFunction,
validateEmail,
validateEmailWithZod,
} from "@/utils";
// import { EditorContent } from "@tiptap/vue-3";
import { useStorage } from "@vueuse/core";
import {
FileUploader,
@@ -185,10 +196,19 @@ import {
toast,
} from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
import SavedReplyIcon from "./icons/SavedReplyIcon.vue";
const editorRef = ref(null);
const showCannedResponseSelectorModal = ref(false);
const showSavedRepliesSelectorModal = ref(false);
const quotedContentRef = ref<HTMLElement | null>(null);
const props = defineProps({
ticketId: {
@@ -231,7 +251,16 @@ const label = computed(() => {
const emit = defineEmits(["submit", "discard"]);
const newEmail = useStorage("emailBoxContent" + props.ticketId, null);
const newEmail = useStorage<null | string>(
"emailBoxContent" + props.ticketId,
null
);
const quotedContent = useStorage<null | string>(
"quotedEmailBoxContent" + props.ticketId,
null
);
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
@@ -239,8 +268,13 @@ const { isManager } = useAuthStore();
const { onUserType, cleanup } = useTyping(props.ticketId);
const attachments = ref([]);
const emailEmpty = computed(() => {
return isContentEmpty(newEmail.value);
const isUploading = ref(false);
const isDisabled = computed(() => {
return (
(isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) ||
sendMail.loading ||
isUploading.value
);
});
// Watch for changes in email content to trigger typing events
@@ -264,9 +298,11 @@ const bcc = computed(() => (bccEmailsClone.value?.length ? true : false));
const ccInput = ref(null);
const bccInput = ref(null);
function applyCannedResponse(template) {
newEmail.value = template.message;
showCannedResponseSelectorModal.value = false;
function applySavedReplies(template: string) {
isContentEmpty(newEmail.value)
? (newEmail.value = template)
: (newEmail.value = newEmail.value + "\n" + template);
showSavedRepliesSelectorModal.value = false;
}
const sendMail = createResource({
@@ -280,7 +316,11 @@ const sendMail = createResource({
to: toEmailsClone.value.join(","),
cc: ccEmailsClone.value?.join(","),
bcc: bccEmailsClone.value?.join(","),
message: newEmail.value,
message:
newEmail.value +
(quotedContentRef.value
? `<p class="reply-to-content"><p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
: ""),
},
}),
onSuccess: () => {
@@ -295,7 +335,7 @@ const sendMail = createResource({
});
function submitMail() {
if (isContentEmpty(newEmail.value)) {
if (isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) {
return false;
}
if (!toEmailsClone.value.length) {
@@ -308,6 +348,21 @@ function submitMail() {
sendMail.submit();
}
watch(quotedContent, (newVal, oldVal) => {
if (!oldVal && newVal) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = newVal;
}
});
}
});
function onQuotedInput() {
const el = quotedContentRef.value;
if (!el) return;
quotedContent.value = el.innerHTML || null;
}
function toggleCC() {
showCC.value = !showCC.value;
@@ -339,35 +394,112 @@ function addToReply(
toEmailsClone.value = toEmails;
ccEmailsClone.value = ccEmails;
bccEmailsClone.value = bccEmails;
editorRef.value.editor
.chain()
.clearContent()
.insertContent(body)
.focus("all")
.setBlockquote()
.insertContentAt(0, { type: "paragraph" })
.focus("start")
.run();
if (body !== quotedContent.value) {
//trigger change for watch when replied to body data is different from current quoted content
quotedContent.value = null;
nextTick(() => {
quotedContent.value = body;
});
}
editorRef.value.editor.chain().clearContent().focus("start").run();
nextTick(() => {
newEmail.value = editorRef.value.editor.getHTML();
});
}
function resetState() {
newEmail.value = null;
attachments.value = [];
quotedContent.value = null;
}
function handleDiscard() {
attachments.value = [];
newEmail.value = null;
quotedContent.value = null;
ccEmailsClone.value = [];
bccEmailsClone.value = [];
ccEmailsClone.value = [];
showCC.value = false;
showBCC.value = false;
emit("discard");
}
//on load set quoted content from storage
onMounted(() => {
if (quotedContent.value) {
nextTick(() => {
if (quotedContentRef.value) {
quotedContentRef.value.innerHTML = quotedContent.value;
}
});
}
});
function handleSelectAll(e: KeyboardEvent) {
const active = document.activeElement;
const editorContext = editorRef.value?.editor;
const editorDom = editorContext?.view?.dom as HTMLElement | undefined;
const quotedEl = quotedContentRef.value;
const sel = window.getSelection();
if (!sel || !editorDom) return;
if (!editorDom.contains(active) && !(quotedEl && quotedEl.contains(active))) {
return;
}
e.preventDefault();
editorContext?.commands.selectAll();
sel.removeAllRanges();
const range = document.createRange();
if (quotedEl) {
range.setStartBefore(editorDom);
range.setEndAfter(quotedEl);
} else {
range.selectNodeContents(editorDom);
}
sel.addRange(range);
}
function handleDelete(e: KeyboardEvent) {
const sel = window.getSelection();
const quotedEl = quotedContentRef.value;
const editorDom = editorRef.value?.editor?.view?.dom as
| HTMLElement
| undefined;
if (!sel || sel.isCollapsed || !quotedEl || !editorDom) return;
const isSelectingEntireEditor = sel.containsNode(editorDom, true);
const isSelectingEntireQuote = sel.containsNode(quotedEl, true);
if (isSelectingEntireEditor && isSelectingEntireQuote) {
e.preventDefault();
editorRef.value?.editor?.commands?.clearContent();
newEmail.value = null;
quotedContent.value = null;
sel.removeAllRanges();
}
}
function handleKeydown(e: KeyboardEvent) {
const key = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && key === "a") {
handleSelectAll(e);
return;
}
if (key === "backspace" || key === "delete") {
handleDelete(e);
return;
}
}
const editor = computed(() => {
return editorRef.value.editor;
});

View File

@@ -22,7 +22,7 @@
</template>
</Button>
</div>
<div class="text-gray-600 w-4/6" v-else>
<div class="text-gray-600 w-4/6 text-p-base" v-else>
<span class="font-medium text-gray-800">
{{ user }}
</span>

View File

@@ -7,7 +7,7 @@
<QuickFilters v-if="!isMobileView" class="flex-1" />
<div class="flex items-start gap-2 justify-end h-full" v-if="!isMobileView">
<Button
label="Save Changes"
:label="__('Save Changes')"
v-if="isViewUpdated && canSaveView"
@click="handleViewUpdate"
/>
@@ -136,11 +136,12 @@ import { capture } from "@/telemetry";
import { View, ViewType } from "@/types";
import { formatTimeShort, getIcon } from "@/utils";
import { useStorage } from "@vueuse/core";
import { useTicketStatusStore } from "@/stores/ticketStatus";
import { __ } from "@/translation";
import {
call,
createResource,
dayjsLocal,
Dropdown,
FeatherIcon,
ListFooter,
@@ -163,6 +164,7 @@ import {
watch,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import EmptyState from "./EmptyState.vue";
import ListRows from "./ListRows.vue";
@@ -221,15 +223,17 @@ const defaultOptions = reactive({
},
selectBannerActions: [
{
label: "Delete",
label: __("Delete"),
icon: "trash-2",
onClick: (selections: Set<string>) => {
$dialog({
title: "Delete",
message: `Are you sure you want to delete ${selections.size} item(s)?`,
title: __("Delete"),
message: __("Are you sure you want to delete {0} item(s)?", [
selections.size,
]),
actions: [
{
label: "Confirm",
label: __("Confirm"),
variant: "solid",
onClick({ close }) {
handleBulkDelete(close, selections);
@@ -249,7 +253,7 @@ function handleBulkDelete(hide: Function, selections: Set<string>) {
items: JSON.stringify(Array.from(selections)),
doctype: props.options.doctype,
}).then(() => {
toast.success("Item(s) deleted successfully");
toast.success(__("Item(s) deleted successfully"));
hide();
reset();
});
@@ -271,16 +275,21 @@ const { isMobileView } = useScreenSize();
const defaultEmptyState = {
icon: "",
title: "No Data Found",
title: __("No Data Found"),
};
const pageLengthCount = useStorage(
`list_page_length_count+${props.options.doctype}`,
options.value.default_page_length
);
const defaultParams = reactive({
doctype: options.value.doctype,
filters: {},
default_filters: options.value.defaultFilters,
order_by: "modified desc",
page_length: options.value.default_page_length,
page_length_count: options.value.default_page_length,
page_length: pageLengthCount.value,
page_length_count: pageLengthCount.value,
view: options.value.view,
columns: [],
rows: [],
@@ -567,7 +576,8 @@ function reload(reset: boolean = false) {
defaultParams.filters = options.value.defaultFilters || {};
defaultParams.order_by = "modified desc";
defaultParams.page_length = options.value.default_page_length;
defaultParams.page_length_count = options.value.default_page_length;
pageLengthCount.value = options.value.default_page_length;
defaultParams.page_length_count = pageLengthCount.value;
defaultParams.columns = [];
defaultParams.rows = [];
defaultParams.is_default = true;
@@ -576,7 +586,8 @@ function reload(reset: boolean = false) {
}
function handlePageLength(count: number, loadMore: boolean = false) {
defaultParams.page_length_count = count;
pageLengthCount.value = count;
defaultParams.page_length_count = pageLengthCount.value;
if (loadMore) {
defaultParams.page_length += count;
} else {
@@ -657,8 +668,8 @@ watch(
defaultParams.view.name = val;
handleViewChanges();
if (!val) {
headerView.value.label = "List";
headerView.value.icon = "lucide:align-justify";
headerView.value.label = __("List");
headerView.value.icon = LucideAlignJustify;
}
}
);
@@ -691,7 +702,7 @@ onMounted(async () => {
if (route.query.view) {
const currentView = findCurrentView();
if (!currentView) return;
headerView.value.label = currentView.label || "List";
headerView.value.label = currentView.label || __("List");
headerView.value.icon = getIcon(currentView.icon);
}
return;

View File

@@ -8,7 +8,10 @@
:label="value"
theme="gray"
variant="subtle"
class="rounded-full"
: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>
@@ -60,12 +63,12 @@
>
<UserAvatar
class="mr-2"
:name="option.value"
:name="getUsernameLabel(option.value)"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-gray-800">
<div class="text-base font-medium">
{{ option.label }}
{{ getUsernameLabel(option.label) }}
</div>
<div class="text-sm text-gray-600">
{{ option.value }}
@@ -136,7 +139,7 @@ watchDebounced(
text.value = val;
reload(val);
},
{ debounce: 300, immediate: true }
{ debounce: 50, immediate: true }
);
const filterOptions = createResource({
@@ -174,10 +177,35 @@ function reload(val) {
filterOptions.reload();
}
const normalizeAndFilter = (field) => {
let arr = [];
let current = "";
let inQuotes = false;
for (let char of field) {
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if (char === "," && !inQuotes) {
if (current.trim()) {
arr.push(current.trim());
}
current = "";
} else {
current += char;
}
}
if (current.trim()) {
arr.push(current.trim());
}
return arr;
};
const addValue = (value) => {
error.value = null;
if (value) {
const splitValues = value.split(",");
const splitValues = normalizeAndFilter(value);
splitValues.forEach((value) => {
value = value.trim();
if (value) {
@@ -201,20 +229,28 @@ const addValue = (value) => {
!error.value && (value = "");
}
};
const getUsernameLabel = (value: string) => {
if (!value) return "";
// Take everything before '<'
const beforeAngle = value.split("<")[0];
// Remove quotes and trim
return beforeAngle.replace(/"/g, "").trim();
};
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value);
};
const removeLastValue = () => {
function removeLastValue() {
if (query.value) return;
let emailRef = emails.value[emails.value.length - 1]?.$el;
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
if (document.activeElement === emailRef) {
values.value.pop();
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el;
emailRef = emails.value[emails.value.length - 1].rootRef;
emailRef?.focus();
} else {
setFocus();
@@ -223,10 +259,10 @@ const removeLastValue = () => {
} else {
emailRef?.focus();
}
};
}
function setFocus() {
search.value.$el.focus();
search.value?.focus?.();
}
defineExpose({ setFocus });

View File

@@ -0,0 +1,294 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
@vue:unmounted="resetFilter"
>
<template #body>
<div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }">
<div class="flex items-center justify-between w-full p-4 pb-2">
<div class="text-2xl font-semibold">{{ __("Saved Replies") }}</div>
<Button
variant="solid"
icon-left="plus"
:label="__('New')"
@click="onNewSavedReplyClick"
/>
</div>
<div class="p-4">
<div class="flex items-center gap-2">
<div class="relative w-full">
<Input
ref="searchInput"
:model-value="search"
@input="search = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
<Button
v-if="search"
icon="x"
variant="ghost"
@click="search = ''"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
<Dropdown :options="filters" placement="right">
<Button :label="activeFilter" icon-left="filter" class="p-4">
<template #suffix>
<p
class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-white pt-px text-xs font-medium text-ink-gray-8 shadow-sm"
v-if="savedReplyListResource?.data?.length"
>
{{ savedReplyListResource?.data?.length }}
</p>
</template>
</Button>
</Dropdown>
</div>
</div>
<div class="px-4 h-full overflow-y-auto">
<div
v-if="savedReplyListResource?.list?.loading"
class="flex items-center justify-center mt-24"
>
<LoadingIndicator class="size-4" />
</div>
<div
v-if="
!savedReplyListResource?.list?.loading &&
savedReplyListResource?.data?.length
"
class="grid grid-cols-1 md:grid-cols-3 gap-2 pb-36"
>
<div
v-for="template in savedReplyListResource?.data"
:key="template.name"
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100 relative"
@click="onTemplateSelect(template)"
>
<div class="text-base font-semibold truncate border-b pb-2">
{{ template.title }}
</div>
<TextEditor
v-if="template.message"
:content="template.message"
:editable="false"
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
class="flex-1 overflow-hidden pointer-events-none"
/>
<div
v-if="
selectedTemplate.name === template.name &&
selectedTemplate.isLoading
"
class="flex items-center justify-center absolute top-0 left-0 w-full h-full bg-black/20 rounded-lg"
>
<LoadingIndicator class="size-4" />
</div>
</div>
</div>
<div
v-if="
!savedReplyListResource?.list?.loading &&
!savedReplyListResource?.data?.length
"
class="mt-2"
>
<div class="flex h-56 flex-col items-center justify-center">
<div class="text-p-sm text-gray-500">
{{ __("No Saved Replies found") }}
</div>
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { useConfigStore } from "@/stores/config";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
import { SavedReply } from "@/types";
import { useStorage } from "@vueuse/core";
import {
Button,
createListResource,
createResource,
Dialog,
Dropdown,
Input,
LoadingIndicator,
TextEditor,
} from "frappe-ui";
import { storeToRefs } from "pinia";
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { showEmailBox } from "../pages/ticket/modalStates";
import {
setActiveSettingsTab,
showSettingsModal,
} from "./Settings/settingsModal";
const props = defineProps({
doctype: {
type: String,
default: "",
},
ticketId: {
type: String,
default: "",
},
});
const show = defineModel();
const activeFilter = useStorage("saved-replies-filter", "Personal");
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
storeToRefs(useConfigStore());
const filters = computed(() => {
const options = [
{
label: __("All"),
value: "All",
onClick: () => (activeFilter.value = "All"),
},
{
label: __("Personal"),
value: "Personal",
onClick: () => (activeFilter.value = "Personal"),
},
{
label: __("My Team"),
value: "Team",
onClick: () => (activeFilter.value = "My Team"),
},
{
label: __("Global"),
value: "Global",
onClick: () => (activeFilter.value = "Global"),
},
];
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
options.pop();
}
return options;
});
// Set default filter to Personal if Global is disabled
if (
teamRestrictionApplied.value &&
disableGlobalScopeForSavedReplies.value &&
activeFilter.value == "Global"
) {
activeFilter.value = "Personal";
}
const emit = defineEmits(["apply"]);
const search = ref("");
const searchInput = ref<InstanceType<typeof Input>>();
const selectedTemplate = ref({
name: "",
isLoading: false,
});
const scope = computed(() => {
return filters.value.find((f) => f.value === activeFilter.value)?.value;
});
const savedReplyListResource = createListResource({
doctype: "HD Saved Reply",
fields: ["name", "title", "owner", "scope", "message"],
filters: {
scope: scope.value == "All" ? undefined : ["=", scope.value],
},
cache: ["SavedReplyListForModal"],
auto: true,
orderBy: "modified desc",
start: 0,
pageLength: 999,
});
onUnmounted(() => {
showEmailBox.value = true;
});
const onTemplateSelect = (template: SavedReply) => {
if (selectedTemplate.value.isLoading) return;
selectedTemplate.value = {
name: template.name,
isLoading: true,
};
const renderResponse = createResource({
url: "helpdesk.api.saved_replies.get_rendered_saved_reply",
params: {
saved_reply_id: template.name,
ticket_id: props.ticketId,
},
onSuccess: (data: string) => {
selectedTemplate.value = {
name: "",
isLoading: false,
};
emit("apply", data);
capture("saved_reply_applied");
},
});
renderResponse.submit().catch(() => {
selectedTemplate.value = {
name: "",
isLoading: false,
};
});
};
const onNewSavedReplyClick = () => {
show.value = false;
showSettingsModal.value = true;
setActiveSettingsTab("Saved Replies");
};
const resetFilter = () => {
savedReplyListResource.filters = {
...savedReplyListResource.filters,
title: undefined,
};
};
watch(search, (newValue) => {
savedReplyListResource.filters = {
...savedReplyListResource.filters,
title: ["like", `%${newValue}%`],
};
savedReplyListResource.list.reload();
});
watch(activeFilter, () => {
savedReplyListResource.filters = {
...savedReplyListResource?.filters,
scope: scope.value == "All" ? undefined : ["=", scope.value],
};
savedReplyListResource.list.reload();
});
watch(
show,
(newValue) => {
if (newValue) {
nextTick(() => {
const inputEl = searchInput.value?.$el?.querySelector("input");
inputEl?.focus();
});
}
},
{ immediate: true }
);
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="!isEmpty(articles.data) && query.length > 2"
v-if="Boolean(articles.data?.length) && query.length > 2"
class="rounded border p-4 text-base"
>
<div class="mb-2 font-medium pl-2" v-if="!hideViewAll">
@@ -52,7 +52,7 @@
"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
>
<Icon icon="heroicons-outline:search" class="h-8 w-8 text-gray-400" />
<LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center">
<p class="font-base">No answers found</p>
<span class="font-base text-p-sm text-gray-600 text-center"
@@ -64,7 +64,7 @@
v-else-if="articles.loading"
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
>
<Icon icon="heroicons-outline:search" class="h-8 w-8 text-gray-400" />
<LucideSearch class="size-8 text-ink-gray-3" />
<div class="flex items-center flex-col justify-center">
<p class="font-base">Searching...</p>
<span class="font-base text-p-sm text-gray-600 text-center"
@@ -75,11 +75,9 @@
</template>
<script setup lang="ts">
import { watch } from "vue";
import { createResource } from "frappe-ui";
import { isEmpty } from "lodash";
import { Icon } from "@iconify/vue";
import { capture } from "@/telemetry";
import { createResource } from "frappe-ui";
import { watch } from "vue";
interface P {
query: string;
hideViewAll?: boolean;

View File

@@ -0,0 +1,409 @@
<template>
<div class="relative inline-block text-left">
<div class="flex">
<Button
variant="outline"
:class="{ 'ring-2 ring-outline-gray-2': isOpen }"
@click="toggleDropdown"
>
<div
class="flex items-center min-h-[20px]"
:class="{ 'gap-2': hasSelectedImages && selectedCount > 0 }"
>
<template v-if="selectedCount > 0">
<!-- Stacked Avatars -->
<div class="flex -space-x-2 isolate">
<div
v-for="(option, index) in selectedOptions.slice(0, 3)"
:key="`avatar-${option.value}`"
class="relative flex"
:style="{ zIndex: 10 - index }"
>
<Avatar
v-if="option.image"
:image="option.image"
:label="option.label"
class="border-2 border-white flex-shrink-0"
size="sm"
/>
</div>
</div>
<!-- Count Text -->
<span class="text-ink-gray-7">
{{
selectedCount === 1
? selectedOptions[0]?.label
: `${selectedCount} ${selectionText}`
}}
</span>
</template>
<span v-else class="text-ink-gray-6">{{ placeholder }}</span>
</div>
<template #suffix>
<LucideChevronDown
class="ml-2 h-4 w-4 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
/>
</template>
</Button>
</div>
<div
v-if="isOpen"
class="absolute z-50 mt-2 w-64 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none left-0 origin-top-left"
>
<!-- Header -->
<div class="py-1.5 px-1.5">
<div
class="flex h-7 items-center text-sm font-medium text-ink-gray-6 justify-between"
>
<input
ref="inputRef"
v-model="filterText"
:placeholder="label"
class="px-2 flex-1 bg-transparent border-none outline-none text-sm focus:border-none focus:ring-0 text-ink-gray-6 placeholder-ink-gray-4"
@click.stop
@keydown="handleInputKeydown"
/>
<Button
variant="ghost"
size="sm"
v-if="selectedCount > 0"
@click="clearAll"
>
<template #icon>
<LucideX class="size-4" />
</template>
</Button>
</div>
</div>
<!-- Options List -->
<div class="px-1.5 pb-1.5 max-h-64 overflow-y-auto">
<!-- Individual Options (ungrouped) -->
<div class="pt-1.5" v-if="filteredIndividualOptions.length > 0">
<button
v-for="option in filteredIndividualOptions"
:key="`individual-${option.value}`"
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
@click="toggleOption(option)"
>
<Checkbox
:modelValue="props.modelValue.includes(option.value)"
class="mr-2 flex-shrink-0"
/>
<component v-if="option.icon" :is="renderIcon(option.icon)" />
<Avatar
v-else-if="hasAnyVisualElements"
:image="option.image"
:label="option.label"
class="mr-2 flex-shrink-0"
size="sm"
/>
<span class="text-ink-gray-7 flex-1 text-left truncate">
{{ option.label }}
</span>
<span
v-if="(option.count ?? 0) > 0"
class="text-xs text-ink-gray-5 ml-auto"
>
{{ option.count }}
</span>
</button>
</div>
<!-- Grouped Options -->
<div v-for="group in filteredGroups" :key="`group-${group.group}`">
<!-- Group Header -->
<div
class="flex h-7 pt-1.5 sticky top-0 bg-surface-white items-center px-2 text-sm font-medium text-ink-gray-6"
>
{{ group.group }}
</div>
<!-- Group Options -->
<button
v-for="option in group.items"
:key="`grouped-${option.value}`"
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
@click="toggleOption(option)"
>
<Checkbox
:modelValue="props.modelValue.includes(option.value)"
class="mr-2 flex-shrink-0"
/>
<component v-if="option.icon" :is="renderIcon(option.icon)" />
<Avatar
v-else-if="option.image"
:image="option.image"
:label="option.label"
class="mr-2 flex-shrink-0"
size="sm"
/>
<!-- Spacer for alignment when other options have visual elements -->
<div
v-else-if="hasAnyVisualElements"
class="mr-2 w-6 h-6 flex-shrink-0"
></div>
<span class="text-ink-gray-7 flex-1 text-left truncate">
{{ option.label }}
</span>
<span
v-if="(option.count ?? 0) > 0"
class="text-xs text-ink-gray-5 ml-auto"
>
{{ option.count }}
</span>
</button>
</div>
<!-- No options message -->
<div
v-if="filteredOptions.length === 0"
class="px-2 py-4 text-center text-sm text-ink-gray-5"
>
No options found
</div>
</div>
</div>
<!-- Overlay to close dropdown -->
<div v-if="isOpen" class="fixed inset-0 z-40" @click="closeDropdown"></div>
</div>
</template>
<script setup lang="ts">
import { Avatar, Button, Checkbox } from "frappe-ui";
import {
computed,
h,
nextTick,
ref,
useTemplateRef,
watch,
type Component,
} from "vue";
import LucideChevronDown from "~icons/lucide/chevron-down";
import LucideX from "~icons/lucide/x";
// Type Definitions
interface MultiSelectOption {
value: string;
label: string;
count?: number;
image?: string;
icon?: string | Component;
}
interface MultiSelectGroup {
group: string;
items: MultiSelectOption[];
}
// Support both flat options and grouped options in the same prop
type MultiSelectData = MultiSelectOption[] | MultiSelectGroup[];
interface Props {
options: MultiSelectData;
modelValue: string[];
placeholder?: string;
label?: string;
selectionText?: string;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: "Select options...",
label: "Options",
selectionText: "items",
options: () => [],
});
const emit = defineEmits<{
"update:modelValue": [value: string[]];
}>();
// Reactive State
const isOpen = ref(false);
const filterText = ref("");
const highlightedIndex = ref(0);
const inputRef = useTemplateRef<HTMLInputElement>("inputRef");
// Core Computed Properties
const selectedCount = computed(() => props.modelValue.length);
// Detect if the options are grouped or flat
const isGrouped = computed(() => {
return (
props.options.length > 0 &&
"group" in props.options[0] &&
"items" in props.options[0]
);
});
// Extract individual options and groups from the unified options prop
const individualOptions = computed(() => {
if (isGrouped.value) return [];
return props.options as MultiSelectOption[];
});
const groups = computed(() => {
if (!isGrouped.value) return [];
return props.options as MultiSelectGroup[];
});
// Flatten all options for easier processing
const allOptions = computed(() => {
if (isGrouped.value) {
return (props.options as MultiSelectGroup[]).flatMap(
(group) => group.items
);
}
return props.options as MultiSelectOption[];
});
const selectedOptions = computed(() =>
allOptions.value.filter((option) => props.modelValue.includes(option.value))
);
// UI Helper Computed Properties
const hasSelectedImages = computed(() =>
selectedOptions.value.some((option) => option.image)
);
const hasAnyVisualElements = computed(() => {
return allOptions.value.some((option) => option.image || option.icon);
});
// Filtering Logic
const filteredGroups = computed(() => {
if (!isGrouped.value) return [];
const searchText = filterText.value.toLowerCase();
return groups.value
.map((group) => ({
...group,
items: group.items.filter((option) => {
// Show all items if group name matches the search
const groupMatches = group.group.toLowerCase().includes(searchText);
// Show individual items if their label matches the search
const itemMatches = option.label.toLowerCase().includes(searchText);
return groupMatches || itemMatches;
}),
}))
.filter((group) => group.items.length > 0);
});
const filteredIndividualOptions = computed(() =>
individualOptions.value.filter((option) =>
option.label.toLowerCase().includes(filterText.value.toLowerCase())
)
);
// All filtered options in a flat array for keyboard navigation
const filteredOptions = computed(() => {
const individual = filteredIndividualOptions.value;
const grouped = filteredGroups.value.flatMap((group) => group.items);
return [...individual, ...grouped];
});
const highlightedOption = computed(
() => filteredOptions.value[highlightedIndex.value] || null
);
// Main Interaction Methods
function toggleDropdown() {
isOpen.value = !isOpen.value;
if (isOpen.value) {
highlightedIndex.value = 0; // Reset to first item when opening
nextTick(() => {
inputRef.value?.focus();
});
}
}
function closeDropdown() {
isOpen.value = false;
}
function toggleOption(option: MultiSelectOption) {
const newValue = [...props.modelValue];
const index = newValue.indexOf(option.value);
if (index > -1) {
newValue.splice(index, 1);
} else {
newValue.push(option.value);
}
emit("update:modelValue", newValue);
}
function clearAll() {
filterText.value = "";
emit("update:modelValue", []);
}
// Keyboard Navigation
function handleInputKeydown(event: KeyboardEvent) {
if (!isOpen.value) return;
if (event.key === "Escape") {
event.preventDefault();
closeDropdown();
} else if (event.key === "Enter" && highlightedOption.value) {
event.preventDefault();
event.stopPropagation();
toggleOption(highlightedOption.value);
} else if (event.key === "ArrowDown") {
event.preventDefault();
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
filteredOptions.value.length - 1
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
}
}
// Utility Functions
function renderIcon(icon: string | any) {
if (!icon) return null;
const iconContent =
typeof icon === "string"
? h("span", { class: "text-base" }, icon)
: h(icon, { class: "w-4 h-4" });
return h(
"span",
{
class:
"flex-shrink-0 w-4 h-4 inline-flex items-center justify-center mr-2",
},
[iconContent]
);
}
function isHighlighted(option: MultiSelectOption) {
return highlightedOption.value?.value === option.value;
}
// Watchers
// Reset highlighted index when filter changes
watch(filterText, () => {
highlightedIndex.value = 0;
});
</script>

View File

@@ -11,16 +11,18 @@
class="w-full focus:outline-none outline-none border-inherit shadow-none"
v-bind="$attrs"
v-model="query"
@update:model-value="(e:string)=>{
if (e.length >= 3) {
open();
} else {
close();
@update:model-value="
(e: string) => {
if (e.length >= 3) {
open();
} else {
close();
}
}
}"
"
>
<template #prefix>
<Icon icon="lucide:search" class="h-4 w-4 text-gray-500" />
<LucideSearch class="size-4 text-ink-gray-4" />
</template>
</FormControl>
</div>
@@ -40,9 +42,8 @@
<script setup lang="ts">
import { FormControl, Popover } from "frappe-ui";
import SearchArticles from "./SearchArticles.vue";
import { ModelRef } from "vue";
import { Icon } from "@iconify/vue";
import SearchArticles from "./SearchArticles.vue";
interface P {
popoverClass: Array<string>;

View File

@@ -45,7 +45,7 @@
</div>
</template>
<script setup>
import { ref } from "vue";
import { ref, watch } from "vue";
const props = defineProps({
label: {
@@ -81,6 +81,13 @@ const props = defineProps({
const hide = ref(props.hideLabel);
const opened = ref(props.opened);
watch(
() => props.opened,
(val) => {
opened.value = val;
}
);
function toggle() {
opened.value = !opened.value;
}

View File

@@ -0,0 +1,81 @@
<template>
<Popover :placement="props.placement">
<template #target="{ togglePopover }">
<Button
class="flex items-center justify-between min-w-36"
@click="togglePopover()"
:class="targetClass"
icon-right="chevron-down"
>
<div class="w-full truncate">
{{
options?.find((option) => option.value == model)?.label || "Select"
}}
</div>
</Button>
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute w-[--reka-popper-anchor-width] bg-white shadow-2xl rounded"
:class="bodyClass"
>
<div class="max-h-52 overflow-y-auto">
<div
v-for="option in options"
:key="option.value"
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-base flex items-center justify-between rounded"
@click="
() => {
onChange(option.value);
togglePopover();
}
"
>
<div class="w-full truncate">
{{ option.label }}
</div>
<FeatherIcon
v-if="model == option.value"
name="check"
class="size-4 ml-2"
/>
</div>
</div>
<hr class="my-1" />
<Button
variant="ghost"
:label="__('Reset')"
icon-left="refresh-ccw"
class="w-full focus-visible:ring-0"
@click="onReset(togglePopover)"
/>
</div>
</template>
</Popover>
</template>
<script setup lang="ts">
import { Button, FeatherIcon, Popover } from "frappe-ui";
const model = defineModel();
interface Props {
options: Array<{ value: string; label: string }>;
targetClass?: string;
bodyClass?: string;
placement?: string;
}
const props = withDefaults(defineProps<Props>(), {
placement: "bottom-start",
});
const onReset = (togglePopover: () => void) => {
model.value = null;
togglePopover();
};
const onChange = (value: string) => {
model.value = value;
};
</script>

View File

@@ -1,158 +1,210 @@
<template>
<div v-bind:class="$attrs.class" class="px-10 py-8">
<!-- Header -->
<SettingsLayoutHeader
:title="__('Agents')"
:description="__('Add, manage agents and assign roles to them.')"
>
<template #actions>
<Button
@click="() => (showNewAgentsDialog = !showNewAgentsDialog)"
label="New"
variant="solid"
>
<template #prefix>
<LucidePlus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</template>
<template #bottom-section>
<div class="flex items-center gap-2 justify-between">
<FormControl
v-model="search"
:placeholder="'Search'"
type="text"
:debounce="300"
class="w-60"
>
<template #prefix>
<LucideSearch class="h-4 w-4 text-gray-500" />
</template>
</FormControl>
<Dropdown :options="dropdownOptions" placement="right">
<template #default="{ open }">
<Button
:label="activeFilter"
class="flex items-center justify-between w-[90px]"
>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4"
/>
</template>
</Button>
</template>
<template #item="{ item, active }">
<button
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded px-2 text-base"
:class="{ 'bg-surface-gray-3': active }"
@click="item.onClick"
>
<div class="flex items-center justify-between flex-1">
<span class="whitespace-nowrap">
{{ item.label }}
</span>
<FeatherIcon
v-if="activeFilter === item.label"
name="check"
class="size-4 text-ink-gray-7"
/>
</div>
</button>
</template>
</Dropdown>
</div>
</template>
</SettingsLayoutHeader>
<!-- loading state -->
<div v-if="agents.loading" class="flex mt-28 justify-between w-full h-full">
<SettingsLayoutBase
:title="__('Agents')"
:description="__('Add, manage agents and assign roles to them.')"
>
<template #header-actions>
<Button
:loading="agents.loading"
variant="ghost"
class="w-full"
size="2xl"
/>
</div>
<!-- Empty State -->
<div
v-if="!agents.loading && !agents.data?.length"
class="flex mt-28 justify-between w-full h-full"
>
<p class="text-sm text-gray-500 w-full flex justify-center">
No agents found
</p>
</div>
<!-- Agent List -->
<div
class="overflow-y-auto w-full hide-scrollbar mt-4"
v-if="!agents.loading && Boolean(agents.data?.length)"
>
<div v-for="(agent, idx) in agents.data" :key="agent.agent_name">
<AgentCard
:agent="agent"
:show-status="true"
:class="idx !== agents.data.length - 1 && 'border-b '"
>
<template #right>
<Dropdown
v-if="isManager"
class="w-1/5 flex justify-end items-center"
:options="getRoles(agent.name)"
:label="getUserRole(agent.name)"
:button="{
label: getUserRole(agent.name),
iconRight: 'chevron-down',
}"
placement="right"
/>
<Dropdown
:options="getOptions(agent)"
placement="right"
:key="agent"
class="ml-2"
@click="() => setActiveSettingsTab('Invite Agents')"
label="New"
variant="solid"
>
<template #prefix>
<LucidePlus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</template>
<template #header-bottom>
<div class="flex items-center gap-2 justify-between">
<div class="relative grow">
<Input
:model-value="search"
@input="search = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
<Button
v-if="search"
icon="x"
variant="ghost"
@click="search = ''"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
<Dropdown :options="dropdownOptions" placement="right">
<template #default="{ open }">
<Button
:label="activeFilter"
class="flex items-center justify-between w-fit p-4"
>
<Button>
<template #icon>
<IconMoreHorizontal class="h-4 w-4" />
</template>
</Button>
</Dropdown>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4"
/>
</template>
</Button>
</template>
</AgentCard>
<template #item="{ item }">
<button
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded p-2 text-base hover:bg-surface-gray-3"
@click="item.onClick"
>
<div class="flex items-center justify-between flex-1">
<span class="whitespace-nowrap">
{{ item.label }}
</span>
<FeatherIcon
v-if="activeFilter === item.label"
name="check"
class="size-4 text-ink-gray-7"
/>
</div>
</button>
</template>
</Dropdown>
</div>
<!-- Load More Button -->
<div class="flex justify-center">
<Button
v-if="!agents.loading && agents.hasNextPage"
class="mt-3.5 p-2"
@click="() => agents.next()"
:loading="agents.loading"
label="Load More"
icon-left="refresh-cw"
/>
</template>
<template #content>
<div class="grow">
<!-- loading state -->
<div
v-if="agents.loading"
class="flex mt-28 justify-between w-full h-full"
>
<Button
:loading="agents.loading"
variant="ghost"
class="w-full"
size="2xl"
/>
</div>
<!-- Empty State -->
<div
v-if="!agents.loading && !agents.data?.length"
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex items-center justify-center"
>
<AgentIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No agent found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{
activeFilter.length
? __("Change your search terms or filters")
: __("Add one to get started.")
}}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="setActiveSettingsTab('Invite Agents')"
/>
</div>
<!-- Agent List -->
<div
class="w-full"
v-if="!agents.loading && Boolean(agents.data?.length)"
>
<div
class="grid grid-cols-8 items-center gap-3 text-sm text-gray-600"
>
<div class="col-span-6 text-p-sm">{{ __("Agent Name") }}</div>
</div>
<hr class="mt-2" />
<div v-for="(agent, index) in agents.data" :key="agent.agent_name">
<div class="flex items-center justify-between h-14 group rounded">
<div class="flex items-center space-x-3 grow">
<Avatar
:image="agent.user_image"
:label="agent.agent_name"
size="xl"
/>
<div>
<div class="flex items-center gap-2">
<p class="text-base">
{{ agent.agent_name }}
</p>
<Badge
:label="__('Inactive')"
:theme="'gray'"
:class="!agent.is_active ? 'opacity-100' : 'opacity-0'"
variant="subtle"
/>
</div>
<div class="text-base text-ink-gray-6 mt-1">
{{ agent.name }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<Dropdown
v-if="isManager"
class="flex justify-end items-center"
:options="getRoles(agent.name)"
:label="getUserRole(agent.name)"
:button="{
label: getUserRole(agent.name),
iconRight: 'chevron-down',
iconLeft:
getUserRole(agent.name) === 'Agent'
? 'user'
: getUserRole(agent.name) === 'Manager'
? 'briefcase'
: null,
}"
placement="right"
/>
<Dropdown
:options="getOptions(agent)"
:key="agent"
class="ml-2"
placement="right"
>
<Button icon="more-horizontal" variant="ghost" />
</Dropdown>
</div>
</div>
<hr v-if="index !== agents.data.length - 1" />
</div>
<!-- Load More Button -->
<div class="flex justify-center">
<Button
v-if="!agents.loading && agents.hasNextPage"
class="mt-3.5 p-2"
@click="() => agents.next()"
:loading="agents.loading"
:label="__('Load More')"
icon-left="refresh-cw"
/>
</div>
</div>
</div>
</div>
</div>
<AddNewAgentsDialog
:title="__('Add Agents')"
@close="showNewAgentsDialog = false"
:modelValue="showNewAgentsDialog"
:show="showNewAgentsDialog"
@update:modelValue="showNewAgentsDialog = $event"
/>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import { useAuthStore } from "@/stores/auth";
import { useUserStore } from "@/stores/user";
import { call, FormControl, toast } from "frappe-ui";
import { h } from "vue";
import { Avatar, Button, call, Dropdown, FeatherIcon, toast } from "frappe-ui";
import { h, onUnmounted } from "vue";
import LucideCheck from "~icons/lucide/check";
import IconMoreHorizontal from "~icons/lucide/more-horizontal";
import AgentCard from "./AgentCard.vue";
import { activeFilter, showNewAgentsDialog, useAgents } from "./agents";
import { activeFilter, useAgents } from "./agents";
import AgentIcon from "../icons/AgentIcon.vue";
import { setActiveSettingsTab } from "./settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const { getUserRole, updateUserRoleCache } = useUserStore();
const { isManager } = useAuthStore();
@@ -171,6 +223,7 @@ function getRoles(agent: string) {
role: "Agent",
active: props.active,
selected: agentRole === "Agent",
icon: "user",
onClick: () => {
updateRole(agent, "Agent");
},
@@ -185,6 +238,7 @@ function getRoles(agent: string) {
role: "Manager",
active: props.active,
selected: agentRole === "Manager",
icon: "briefcase",
onClick: () => {
updateRole(agent, "Manager");
},
@@ -194,18 +248,29 @@ function getRoles(agent: string) {
return roles;
}
function RoleOption({ active, role, onClick, selected }) {
function RoleOption({ active, role, onClick, selected, icon = null }) {
return h(
"button",
{
class: [
active ? "bg-surface-gray-2" : "text-ink-gray-7",
"group flex w-full justify-between items-center rounded-md px-2 py-2 text-base",
"group flex w-full text-ink-gray-8 justify-between items-center rounded-md px-2 py-2 text-sm hover:bg-surface-gray-3",
],
onClick: !selected ? onClick : null,
},
[
h("span", { class: "whitespace-nowrap" }, role),
h("div", { class: "flex gap-2" }, [
icon
? h(FeatherIcon, {
name: icon,
class: ["h-4 w-4 shrink-0"],
"aria-hidden": true,
})
: null,
h("span", { class: "whitespace-nowrap" }, role),
]),
selected
? h(LucideCheck, {
class: ["h-4 w-4 shrink-0 text-ink-gray-7"],
@@ -277,6 +342,8 @@ const dropdownOptions = [
},
},
];
</script>
<style scoped></style>
onUnmounted(() => {
agents.filters = {};
});
</script>

View File

@@ -27,7 +27,7 @@
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-40"
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-44"
@click="togglePopover()"
>
<div>
@@ -42,12 +42,12 @@
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-7 mt-1 w-48 bg-white shadow-xl rounded"
class="p-1 text-ink-gray-7 mt-1 bg-white shadow-xl rounded w-[--reka-popper-anchor-width]"
>
<div
v-for="option in ticketRoutingOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-gray-50 text-sm flex items-center justify-between rounded"
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-sm flex items-center justify-between rounded"
@click="
() => {
assignmentRuleData.rule = option.value;
@@ -115,7 +115,7 @@ import {
assignmentRuleData,
assignmentRulesErrors,
validateAssignmentRule,
} from "../../../stores/assignmentRules";
} from "@/stores/assignmentRules";
import AssigneeSearch from "./AssigneeSearch.vue";
import { computed } from "vue";
import { useUserStore } from "@/stores/user";

View File

@@ -36,7 +36,7 @@
<ComboboxOption
v-show="users.length > 0"
v-for="user in users"
:key="user.username"
:key="user.name"
:value="user"
as="template"
v-slot="{ active }"
@@ -55,14 +55,16 @@
<Avatar
:shape="'circle'"
:image="user.user_image"
:label="user.full_name"
:label="user.agent_name"
size="lg"
/>
<div class="flex flex-col gap-1">
<div class="font-semibold text-ink-gray-7">
{{ user.full_name }}
<div class="flex flex-col gap-1 truncate">
<div class="font-semibold text-ink-gray-7 truncate">
{{ user.agent_name }}
</div>
<div class="text-ink-gray-6 truncate">
{{ user.user }}
</div>
<div class="text-ink-gray-6">{{ user.email }}</div>
</div>
</div>
</li>
@@ -80,31 +82,22 @@
class="w-full"
icon-left="plus"
:label="__('Invite agent')"
@click="
() => {
showNewAgentsDialog = true;
togglePopover();
}
"
@click="inviteAgents"
/>
</div>
</div>
</template>
</Popover>
</Combobox>
<AddNewAgentsDialog
:title="__('Add Agents')"
@close="showNewAgentsDialog = false"
:modelValue="showNewAgentsDialog"
:show="showNewAgentsDialog"
@update:modelValue="showNewAgentsDialog = $event"
@onAgentsInvited="addInvitedAgents"
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
:message="showConfirmDialog.message"
:onConfirm="showConfirmDialog.onConfirm"
/>
</template>
<script setup lang="ts">
import AddNewAgentsDialog from "@/components/desk/global/AddNewAgentsDialog.vue";
import { assignmentRuleData } from "@/stores/assignmentRules";
import {
Combobox,
@@ -112,87 +105,76 @@ import {
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { watchDebounced } from "@vueuse/core";
import { Avatar, createListResource, Popover } from "frappe-ui";
import { computed, ref } from "vue";
import { Avatar, Popover } from "frappe-ui";
import { computed, inject, Ref, ref } from "vue";
import { setActiveSettingsTab } from "../settingsModal";
import { useAgentStore } from "@/stores/agent";
import { onMounted } from "vue";
import { __ } from "@/translation";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const emit = defineEmits(["addAssignee"]);
const query = ref("");
const showNewAgentsDialog = ref(false);
watchDebounced(
() => query.value,
(val) => {
const filters = {
full_name: undefined,
email: undefined,
};
const isEmail = val.includes("@") || val.includes(".");
if (isEmail) {
filters.email = ["like", `%${val}%`];
} else {
filters.full_name = ["like", `%${val}%`];
}
usersList.update({
filters,
});
usersList.reload();
},
{ debounce: 500 }
const { agents } = useAgentStore();
const isAssignmentRuleFormDirty = inject<Ref<boolean>>(
"isAssignmentRuleFormDirty"
);
const usersList = createListResource({
doctype: "User",
fields: ["name", "email", "full_name", "user_image"],
start: 0,
pageLength: 5,
auto: true,
transform: (data) => {
return data
.map((user) => {
if (user.full_name == "Administrator") {
return {
...user,
email: "Administrator",
user: "Administrator",
};
}
return {
...user,
user: user.email,
};
})
.filter((user) => user.full_name != "Guest");
},
const showConfirmDialog = ref({
show: false,
title: "",
message: "",
onConfirm: () => {},
});
const users = computed(() => {
return (
usersList.data?.filter(
let filteredAgents =
agents.data?.filter(
(user) =>
!assignmentRuleData.value.users.some((u) => u.user === user.email)
) || []
);
!assignmentRuleData.value.users.some((u) => u.user === user.user)
) || [];
if (query.value) {
const val = query.value.toLowerCase();
const isEmail = val.includes("@") || val.includes(".");
filteredAgents = filteredAgents.filter((user) => {
if (isEmail) {
return user.user?.toLowerCase().includes(val);
} else {
return user.agent_name?.toLowerCase().includes(val);
}
});
}
return filteredAgents;
});
const addInvitedAgents = (users) => {
users.forEach((user) => {
addAssignee({ user });
const addAssignee = (user) => {
assignmentRuleData.value.users.push({
user: user.user,
});
emit("addAssignee", user);
};
const addAssignee = (user) => {
const userExists = assignmentRuleData.value.users.some(
(u) => u.user === user.user
);
if (!userExists) {
assignmentRuleData.value.users.push({
full_name: user.full_name,
email: user.email,
user_image: user.user_image,
user: user.email,
});
emit("addAssignee", user);
const inviteAgents = () => {
if (isAssignmentRuleFormDirty.value) {
showConfirmDialog.value = {
show: true,
title: __("Unsaved changes"),
message: __(
"Are you sure you want to leave? Unsaved changes will be lost."
),
onConfirm: () => {
setActiveSettingsTab("Invite Agents");
},
};
} else {
setActiveSettingsTab("Invite Agents");
}
};
onMounted(() => {
if (agents.loading || agents.data?.length || agents.list.promise) {
return;
}
agents.fetch();
});
</script>

View File

@@ -1,26 +1,34 @@
<template>
<div
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
>
<div
@click="assignmentRulesActiveScreen = { screen: 'view', data: data }"
class="w-full py-3 pl-2 col-span-7"
class="w-full pl-2 col-span-7 h-14 flex flex-col justify-center"
>
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
<div
v-if="data.description && data.description.length > 0"
class="text-sm w-full text-ink-gray-5 mt-1 whitespace-nowrap overflow-ellipsis overflow-hidden"
class="text-sm w-full text-ink-gray-5 mt-1 truncate"
>
{{ data.description }}
</div>
</div>
<div class="col-span-2">
<Select
class="w-max bg-transparent -ml-2 border-0 text-ink-gray-6 focus-visible:!ring-0 bg-none"
:options="priorityOptions"
<div class="col-span-3">
<select
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md p-0 pl-2 pr-5 bg-transparent -ml-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
v-model="data.priority"
@update:modelValue="onPriorityChange"
/>
@change="onPriorityChange"
>
<option
v-for="option in priorityOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<div class="flex justify-between items-center w-full pr-2 col-span-2">
<div>
@@ -50,7 +58,7 @@
<FormControl
:label="__('New Assignment Rule Name')"
type="text"
v-model="duplicateDialog.name"
v-model="duplicateDialog.newName"
/>
</div>
</template>
@@ -69,19 +77,22 @@
<script setup lang="ts">
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
import { __ } from "@/translation";
import { AssignmentRuleListResourceSymbol } from "@/types";
import { AssignmentRule } from "@/types/doctypes";
import { ConfirmDelete } from "@/utils";
import {
Button,
createResource,
Dialog,
Dropdown,
FormControl,
Select,
Switch,
toast,
} from "frappe-ui";
import { inject, ref } from "vue";
const assignmentRulesList = inject<any>("assignmentRulesList");
const assignmentRulesListData = inject(AssignmentRuleListResourceSymbol);
const props = defineProps({
data: {
@@ -100,6 +111,7 @@ const priorityOptions = [
const duplicateDialog = ref({
show: false,
newName: "",
name: "",
});
@@ -113,9 +125,9 @@ const deleteAssignmentRule = () => {
name: props.data.name,
},
onSuccess: () => {
assignmentRulesList.reload();
assignmentRulesListData?.reload();
isConfirmingDelete.value = false;
toast.success("Assignment rule deleted");
toast.success(__("Assignment rule deleted"));
},
auto: true,
});
@@ -123,11 +135,12 @@ const deleteAssignmentRule = () => {
const dropdownOptions = [
{
label: "Duplicate",
label: __("Duplicate"),
onClick: () => {
duplicateDialog.value = {
show: true,
name: props.data.name + " (Copy)",
newName: props.data.name + " (Copy)",
name: props.data.name,
};
},
icon: "copy",
@@ -140,22 +153,37 @@ const dropdownOptions = [
const duplicate = () => {
createResource({
url: "helpdesk.api.assignment_rule.duplicate_assignment_rule",
url: "frappe.client.get",
params: {
docname: props.data.name,
new_name: duplicateDialog.value.name,
doctype: "Assignment Rule",
name: duplicateDialog.value.name,
},
onSuccess: (data) => {
assignmentRulesList.reload();
toast.success("Assignment rule duplicated");
duplicateDialog.value.show = false;
duplicateDialog.value.name = "";
assignmentRulesActiveScreen.value = {
screen: "view",
data: {
name: data.name,
onSuccess: (data: AssignmentRule) => {
createResource({
url: "frappe.client.insert",
params: {
doc: {
...data,
name: duplicateDialog.value.newName,
},
},
};
auto: true,
onSuccess(newAssignmentRuleData: AssignmentRule) {
assignmentRulesListData?.reload();
toast.success(__("Assignment rule duplicated"));
duplicateDialog.value = {
show: false,
newName: "",
name: "",
};
setTimeout(() => {
assignmentRulesActiveScreen.value = {
screen: "view",
data: newAssignmentRuleData,
};
}, 250);
},
});
},
auto: true,
});
@@ -167,13 +195,17 @@ const onPriorityChange = () => {
const onToggle = () => {
if (!props.data.users_exists && props.data.disabled) {
toast.error("Cannot enable rule without adding users in it");
toast.error(__("Cannot enable rule without adding users in it"));
return;
}
setAssignmentRuleValue("disabled", !props.data.disabled, "status");
};
const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
const setAssignmentRuleValue = (
key: string,
value: any,
fieldName?: string
) => {
createResource({
url: "frappe.client.set_value",
params: {
@@ -183,8 +215,8 @@ const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
value: value,
},
onSuccess: () => {
assignmentRulesList.reload();
toast.success(`Assignment rule ${fieldName || key} updated`);
assignmentRulesListData?.reload();
toast.success(__("Assignment rule {0} updated", fieldName || key));
},
auto: true,
});

View File

@@ -1,34 +1,27 @@
<template>
<div
v-if="getAssignmentRuleData.loading"
class="flex items-center h-full justify-center"
>
<LoadingIndicator class="w-4" />
</div>
<div
v-if="!getAssignmentRuleData.loading"
class="sticky top-0 z-10 bg-white pb-6 px-10 py-8"
>
<div class="flex items-center justify-between w-full">
<SettingsLayoutBase>
<template #title>
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="
assignmentRuleData.assignmentRuleName || 'New Assignment Rule'
assignmentRuleData.assignmentRuleName || __('New Assignment Rule')
"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-lg hover:opacity-70 !pr-0 !max-w-96 !justify-start"
/>
<Badge
:variant="'subtle'"
:theme="'orange'"
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div>
</template>
<template #header-actions>
<div class="flex items-center gap-4">
<div
class="flex items-center justify-between gap-2"
@@ -43,259 +36,292 @@
theme="gray"
variant="solid"
@click="saveAssignmentRule()"
:loading="isLoading || getAssignmentRuleData.loading"
:loading="
isLoading ||
getAssignmentRuleData.loading ||
createAssignmentRuleResource.loading
"
/>
</div>
</div>
</div>
<div v-if="!getAssignmentRuleData.loading" class="overflow-y-auto px-10 pb-8">
<div class="grid grid-cols-2 gap-5">
<div>
<FormControl
:type="'text'"
size="sm"
variant="subtle"
placeholder="Name"
label="Name"
v-model="assignmentRuleData.assignmentRuleName"
required
maxlength="50"
@change="validateAssignmentRule('assignmentRuleName')"
/>
<ErrorMessage
:message="assignmentRulesErrors.assignmentRuleName"
class="mt-2"
/>
</template>
<template #content>
<div
v-if="getAssignmentRuleData.loading"
class="flex items-center h-full justify-center"
>
<LoadingIndicator class="w-4" />
</div>
<div class="flex flex-col gap-1.5">
<FormLabel label="Priority" />
<Popover>
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
@click="togglePopover()"
>
<div>
<div v-if="!getAssignmentRuleData.loading">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<FormControl
:type="'text'"
size="sm"
variant="subtle"
:placeholder="__('Name')"
:label="__('Name')"
v-model="assignmentRuleData.assignmentRuleName"
required
maxlength="50"
@change="validateAssignmentRule('assignmentRuleName')"
/>
<ErrorMessage
:message="assignmentRulesErrors.assignmentRuleName"
class="mt-2"
/>
</div>
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Priority')" />
<Popover>
<template #target="{ togglePopover }">
<div
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
@click="togglePopover()"
>
<div>
{{
priorityOptions.find(
(option) => option.value == assignmentRuleData.priority
)?.label
}}
</div>
<FeatherIcon name="chevron-down" class="size-4" />
</div>
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute bg-white shadow-2xl rounded w-[--reka-popper-anchor-width]"
>
<div
v-for="option in priorityOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
@click="
assignmentRuleData.priority = option.value;
togglePopover();
"
>
{{ option.label }}
<FeatherIcon
v-if="assignmentRuleData.priority == option.value"
name="check"
class="size-4"
/>
</div>
</div>
</template>
</Popover>
</div>
<div>
<FormControl
:type="'textarea'"
size="sm"
variant="subtle"
:placeholder="__('Description')"
:label="__('Description')"
required
maxlength="140"
@change="validateAssignmentRule('description')"
v-model="assignmentRuleData.description"
/>
<ErrorMessage
:message="assignmentRulesErrors.description"
class="mt-2"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Assignment condition")
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
{{
priorityOptions.find(
(option) => option.value == assignmentRuleData.priority
)?.label
__(
"Choose which tickets are affected by this assignment rule."
)
}}
</div>
<FeatherIcon name="chevron-down" class="size-4" />
</div>
</template>
<template #body="{ togglePopover }">
<div
class="p-1 text-ink-gray-6 top-1 absolute w-full bg-white shadow-2xl rounded"
>
<a
class="font-medium underline"
href="https://docs.frappe.io/helpdesk/assignment-rule"
target="_blank"
>{{ __("Learn about conditions") }}</a
>
</span>
<div
v-for="option in priorityOptions"
:key="option.value"
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
@click="
assignmentRuleData.priority = option.value;
togglePopover();
v-if="isAssignConditionOld && assignmentRulesActiveScreen.data"
>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
>
<span>{{ __("Old Condition") }}</span>
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.assignCondition }}</code>
</div>
</template>
</Popover>
</div>
</div>
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="
!useNewUIForAssignCondition &&
assignmentRuleData.assignCondition
"
>
<span class="text-p-sm">
Conditions for this rule were created from
<a :href="deskUrl" target="_blank" class="underline">desk</a>
which are not compatible with this UI, you will need to recreate
the conditions here if you want to manage and add new conditions
from this UI.
</span>
<Button
label="I understand, add conditions"
variant="subtle"
theme="gray"
@click="useNewUIForAssignCondition = true"
/>
</div>
<AssignmentRulesSection
:conditions="assignmentRuleData.assignConditionJson"
name="assignCondition"
:errors="assignmentRulesErrors.assignConditionError"
v-else
/>
<ErrorMessage
:message="assignmentRulesErrors.assignCondition"
class="mt-2"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Unassignment condition")
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
{{
__(
"Choose which tickets are affected by this un-assignment rule."
)
}}
<a
class="font-medium underline"
href="https://docs.frappe.io/helpdesk/assignment-rule"
target="_blank"
>{{ __("Learn about conditions") }}</a
>
</span>
<div
v-if="
isUnassignConditionOld &&
assignmentRulesActiveScreen.data &&
assignmentRuleData.unassignCondition
"
>
{{ option.label }}
<FeatherIcon
v-if="assignmentRuleData.priority == option.value"
name="check"
class="size-4"
/>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
>
<span> {{ __("Old Condition") }} </span>
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.unassignCondition }}</code>
</div>
</template>
</Popover>
</div>
</div>
</template>
</Popover>
</div>
<div>
<FormControl
:type="'textarea'"
size="sm"
variant="subtle"
placeholder="Description"
label="Description"
required
maxlength="250"
@change="validateAssignmentRule('description')"
v-model="assignmentRuleData.description"
/>
<ErrorMessage
:message="assignmentRulesErrors.description"
class="mt-2"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Assignment condition")
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
{{
__("Choose which tickets are affected by this assignment rule.")
}}
<a
class="font-medium underline"
href="https://docs.frappe.io/helpdesk/assignment-rule"
target="_blank"
>{{ __("Learn about conditions") }}</a
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="
!useNewUIForUnassignCondition &&
assignmentRuleData.unassignCondition
"
>
</span>
<div v-if="isOldSla && assignmentRulesActiveScreen.data">
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
>
<span>{{ __("Old Condition") }}</span>
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.assignCondition }}</code>
</div>
</template>
</Popover>
<span class="text-p-sm">
Conditions for this rule were created from
<a :href="deskUrl" target="_blank" class="underline">desk</a>
which are not compatible with this UI, you will need to recreate
the conditions here if you want to manage and add new conditions
from this UI.
</span>
<Button
label="I understand, add conditions"
variant="subtle"
theme="gray"
@click="useNewUIForUnassignCondition = true"
/>
</div>
<AssignmentRulesSection
:conditions="assignmentRuleData.unassignConditionJson"
name="unassignCondition"
:errors="assignmentRulesErrors.unassignConditionError"
v-else
/>
</div>
</div>
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="!useNewUI && assignmentRuleData.assignCondition"
>
<span class="text-p-sm">
Conditions for this rule were created from
<a :href="deskUrl" target="_blank" class="underline">desk</a> which
are not compatible with this UI, you will need to recreate the
conditions here if you want to manage and add new conditions from
this UI.
</span>
<Button
label="I understand, add conditions"
variant="subtle"
theme="gray"
@click="useNewUI = true"
/>
</div>
<AssignmentRulesSection
:conditions="assignmentRuleData.assignConditionJson"
name="assignCondition"
:errors="assignmentRulesErrors.assignConditionError"
v-else
/>
<ErrorMessage
:message="assignmentRulesErrors.assignCondition"
class="mt-2"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Unassignment condition")
}}</span>
<div class="flex items-center justify-between gap-6">
<span class="text-p-sm text-ink-gray-6">
{{
__(
"Choose which tickets are affected by this un-assignment rule."
)
}}
<a
class="font-medium underline"
href="https://docs.frappe.io/helpdesk/assignment-rule"
target="_blank"
>{{ __("Learn about conditions") }}</a
>
</span>
<div
v-if="
isOldSla &&
assignmentRulesActiveScreen.data &&
assignmentRuleData.unassignCondition
"
>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
>
<span> {{ __("Old Condition") }} </span>
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ assignmentRuleData.unassignCondition }}</code>
</div>
</template>
</Popover>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Assignment Schedule")
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{
__(
"Choose the days of the week when this rule should be active."
)
}}
</span>
</div>
<div class="mt-6">
<AssignmentSchedule />
</div>
</div>
<hr class="my-8" />
<AssigneeRules />
</div>
<div class="mt-5">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="!useNewUI && assignmentRuleData.unassignCondition"
>
<span class="text-p-sm">
Conditions for this rule were created from
<a :href="deskUrl" target="_blank" class="underline">desk</a> which
are not compatible with this UI, you will need to recreate the
conditions here if you want to manage and add new conditions from
this UI.
</span>
<Button
label="I understand, add conditions"
variant="subtle"
theme="gray"
@click="useNewUI = true"
/>
</div>
<AssignmentRulesSection
:conditions="assignmentRuleData.unassignConditionJson"
name="unassignCondition"
:errors="assignmentRulesErrors.unassignConditionError"
v-else
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Assignment Schedule")
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{
__("Choose the days of the week when this rule should be active.")
}}
</span>
</div>
<div class="mt-6">
<AssignmentSchedule />
</div>
</div>
<hr class="my-8" />
<AssigneeRules />
</div>
</template>
</SettingsLayoutBase>
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
:message="showConfirmDialog.message"
:onConfirm="showConfirmDialog.onConfirm"
:onCancel="() => (showConfirmDialog.show = false)"
:onCancel="
() => {
if (showConfirmDialog.onCancel) {
showConfirmDialog.onCancel();
} else {
showConfirmDialog.show = false;
}
}
"
/>
</template>
@@ -314,7 +340,7 @@ import {
Switch,
toast,
} from "frappe-ui";
import { onMounted, onUnmounted, ref, watch } from "vue";
import { onMounted, onUnmounted, provide, ref, watch } from "vue";
import {
assignmentRuleData,
assignmentRulesActiveScreen,
@@ -322,25 +348,37 @@ import {
resetAssignmentRuleData,
resetAssignmentRuleErrors,
validateAssignmentRule,
} from "../../../stores/assignmentRules";
} from "@/stores/assignmentRules";
import AssigneeRules from "./AssigneeRules.vue";
import AssignmentRulesSection from "./AssignmentRulesSection.vue";
import AssignmentSchedule from "./AssignmentSchedule.vue";
import { convertToConditions } from "@/utils";
import { disableSettingModalOutsideClick } from "../settingsModal";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const isDirty = ref(false);
const initialData = ref(null);
const isLoading = ref(false);
provide("isAssignmentRuleFormDirty", isDirty);
const showConfirmDialog = ref({
const showConfirmDialog = ref<{
show: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel?: () => void;
}>({
show: false,
title: "",
message: "",
onConfirm: () => {},
});
const useNewUI = ref(true);
const isOldSla = ref(false);
const useNewUIForAssignCondition = ref(true);
const useNewUIForUnassignCondition = ref(true);
const isAssignConditionOld = ref(false);
const isUnassignConditionOld = ref(false);
const deskUrl = `${window.location.origin}/app/assignment-rule/${assignmentRulesActiveScreen.value.data?.name}`;
const getAssignmentRuleData = createResource({
@@ -351,12 +389,30 @@ const getAssignmentRuleData = createResource({
},
auto: Boolean(assignmentRulesActiveScreen.value.data),
onSuccess(data) {
let assignConditionJson;
let unassignConditionJson;
try {
assignConditionJson = JSON.parse(data.assign_condition_json || "[]");
} catch (error) {
toast.error(
"Assignment conditions are invalid or corrupt, recreate the conditions."
);
assignConditionJson = [];
}
try {
unassignConditionJson = JSON.parse(data.unassign_condition_json || "[]");
} catch (error) {
toast.error(
"Unassignment conditions are invalid or corrupt, recreate the conditions."
);
unassignConditionJson = [];
}
assignmentRuleData.value = {
loading: false,
assignCondition: data.assign_condition,
unassignCondition: data.unassign_condition,
assignConditionJson: JSON.parse(data.assign_condition_json || "[]"),
unassignConditionJson: JSON.parse(data.unassign_condition_json || "[]"),
assignConditionJson: assignConditionJson,
unassignConditionJson: unassignConditionJson,
rule: data.rule,
priority: data.priority,
users: data.users,
@@ -369,43 +425,52 @@ const getAssignmentRuleData = createResource({
initialData.value = JSON.stringify(assignmentRuleData.value);
const conditionsAvailable =
const assignConditionsAvailable =
assignmentRuleData.value.assignCondition?.length > 0;
const conditionsJsonAvailable =
const assignConditionsJsonAvailable =
assignmentRuleData.value.assignConditionJson?.length > 0;
if (conditionsAvailable && !conditionsJsonAvailable) {
useNewUI.value = false;
isOldSla.value = true;
if (assignConditionsAvailable && !assignConditionsJsonAvailable) {
useNewUIForAssignCondition.value = false;
isAssignConditionOld.value = true;
} else {
useNewUI.value = true;
isOldSla.value = false;
useNewUIForAssignCondition.value = true;
isAssignConditionOld.value = false;
}
const unassignConditionsAvailable =
assignmentRuleData.value.unassignCondition?.length > 0;
const unassignConditionsJsonAvailable =
assignmentRuleData.value.unassignConditionJson?.length > 0;
if (unassignConditionsAvailable && !unassignConditionsJsonAvailable) {
useNewUIForUnassignCondition.value = false;
isUnassignConditionOld.value = true;
} else {
useNewUIForUnassignCondition.value = true;
isUnassignConditionOld.value = false;
}
},
});
if (!assignmentRulesActiveScreen.value.data) {
disableSettingModalOutsideClick.value = true;
initialData.value = JSON.stringify(assignmentRuleData.value);
}
const goBack = () => {
const confirmDialogInfo = {
show: true,
title: "Unsaved changes",
message: "Are you sure you want to go back? Unsaved changes will be lost.",
title: __("Unsaved changes"),
message: __(
"Are you sure you want to go back? Unsaved changes will be lost."
),
onConfirm: goBack,
};
if (isDirty.value && !showConfirmDialog.value.show) {
showConfirmDialog.value = confirmDialogInfo;
return;
}
if (
!assignmentRulesActiveScreen.value.data &&
!showConfirmDialog.value.show
) {
showConfirmDialog.value = confirmDialogInfo;
return;
}
// Workaround fix for settings modal not closing after going back
setTimeout(() => {
assignmentRulesActiveScreen.value = {
@@ -417,27 +482,26 @@ const goBack = () => {
};
const saveAssignmentRule = () => {
const validationErrors = validateAssignmentRule(undefined, !useNewUI.value);
const validationErrors = validateAssignmentRule(
undefined,
!useNewUIForAssignCondition.value,
!useNewUIForUnassignCondition.value
);
if (Object.values(validationErrors).some((error) => error)) {
toast.error(
"Invalid fields, check if all are filled in and values are correct."
__("Invalid fields, check if all are filled in and values are correct.")
);
return;
}
if (assignmentRulesActiveScreen.value.data) {
if (isOldSla.value && useNewUI.value) {
showConfirmDialog.value = {
show: true,
title: "Confirm overwrite",
message:
"Your old condition will be overwritten. Are you sure you want to save?",
onConfirm: () => {
updateAssignmentRule();
showConfirmDialog.value.show = false;
},
};
if (isAssignConditionOld.value && useNewUIForAssignCondition.value) {
showOverwriteConfirm();
return;
}
if (isUnassignConditionOld.value && useNewUIForUnassignCondition.value) {
showOverwriteConfirm();
return;
}
updateAssignmentRule();
@@ -446,60 +510,65 @@ const saveAssignmentRule = () => {
}
};
const showOverwriteConfirm = () => {
showConfirmDialog.value = {
show: true,
title: "Confirm overwrite",
message:
"Your old condition will be overwritten. Are you sure you want to save?",
onConfirm: () => {
updateAssignmentRule();
showConfirmDialog.value.show = false;
},
};
};
const createAssignmentRule = () => {
isLoading.value = true;
createResource({
url: "frappe.client.insert",
params: {
doc: {
doctype: "Assignment Rule",
document_type: "HD Ticket",
rule: assignmentRuleData.value.rule,
priority: assignmentRuleData.value.priority,
users: assignmentRuleData.value.users,
disabled: assignmentRuleData.value.disabled,
description: assignmentRuleData.value.description,
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
day: day,
})),
name: assignmentRuleData.value.assignmentRuleName,
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
assign_condition: convertToConditions({
conditions: assignmentRuleData.value.assignConditionJson,
}),
unassign_condition: convertToConditions({
conditions: assignmentRuleData.value.unassignConditionJson,
}),
assign_condition_json: JSON.stringify(
assignmentRuleData.value.assignConditionJson
),
unassign_condition_json: JSON.stringify(
assignmentRuleData.value.unassignConditionJson
),
},
},
auto: true,
onSuccess(data) {
getAssignmentRuleData
.submit({
doctype: "Assignment Rule",
name: data.name,
})
.then(() => {
isLoading.value = false;
toast.success("Assignment rule created");
});
assignmentRulesActiveScreen.value = {
screen: "view",
data: data,
};
},
onError: () => {
isLoading.value = false;
createAssignmentRuleResource.submit({
doc: {
doctype: "Assignment Rule",
document_type: "HD Ticket",
rule: assignmentRuleData.value.rule,
priority: assignmentRuleData.value.priority,
users: assignmentRuleData.value.users,
disabled: assignmentRuleData.value.disabled,
description: assignmentRuleData.value.description,
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
day: day,
})),
name: assignmentRuleData.value.assignmentRuleName,
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
assign_condition: convertToConditions({
conditions: assignmentRuleData.value.assignConditionJson,
}),
unassign_condition: convertToConditions({
conditions: assignmentRuleData.value.unassignConditionJson,
}),
assign_condition_json: JSON.stringify(
assignmentRuleData.value.assignConditionJson
),
unassign_condition_json: JSON.stringify(
assignmentRuleData.value.unassignConditionJson
),
},
});
};
const createAssignmentRuleResource = createResource({
url: "frappe.client.insert",
onSuccess(data) {
toast.success(__("Assignment rule created"));
assignmentRulesActiveScreen.value = {
screen: "view",
data: data,
};
getAssignmentRuleData.submit({
doctype: "Assignment Rule",
name: data.name,
});
},
});
const priorityOptions = [
{ label: "Low", value: "0" },
{ label: "Low-Medium", value: "1" },
@@ -524,26 +593,27 @@ const updateAssignmentRule = async () => {
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
day: day,
})),
assign_condition: useNewUI.value
assign_condition: useNewUIForAssignCondition.value
? convertToConditions({
conditions: assignmentRuleData.value.assignConditionJson,
})
: assignmentRuleData.value.assignCondition,
unassign_condition: useNewUI.value
unassign_condition: useNewUIForUnassignCondition.value
? convertToConditions({
conditions: assignmentRuleData.value.unassignConditionJson,
})
: assignmentRuleData.value.unassignCondition,
assign_condition_json: useNewUI.value
assign_condition_json: useNewUIForAssignCondition.value
? JSON.stringify(assignmentRuleData.value.assignConditionJson)
: null,
unassign_condition_json: useNewUI.value
unassign_condition_json: useNewUIForUnassignCondition.value
? JSON.stringify(assignmentRuleData.value.unassignConditionJson)
: null,
},
}).catch((er) => {
const error =
er?.messages?.[0] || "Some error occurred while updating assignment rule";
er?.messages?.[0] ||
__("Some error occurred while updating assignment rule");
toast.error(error);
isLoading.value = false;
});
@@ -559,7 +629,7 @@ const updateAssignmentRule = async () => {
}).catch(async (er) => {
const error =
er?.messages?.[0] ||
"Some error occurred while renaming assignment rule";
__("Some error occurred while renaming assignment rule");
toast.error(error);
// Reset assignment rule to previous state
await getAssignmentRuleData.reload();
@@ -574,7 +644,7 @@ const updateAssignmentRule = async () => {
}
isLoading.value = false;
toast.success("Assignment rule updated");
toast.success(__("Assignment rule updated"));
};
watch(

View File

@@ -1,12 +1,15 @@
<template>
<div class="pb-8">
<AssignmentRulesList v-if="assignmentRulesActiveScreen.screen === 'list'" />
<AssignmentRuleView v-if="assignmentRulesActiveScreen.screen === 'view'" />
</div>
<AssignmentRulesList v-if="assignmentRulesActiveScreen.screen === 'list'" />
<AssignmentRuleView v-if="assignmentRulesActiveScreen.screen === 'view'" />
</template>
<script setup lang="ts">
import { assignmentRulesActiveScreen } from "../../../stores/assignmentRules";
import { provide, ref } from "vue";
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
import AssignmentRulesList from "./AssignmentRulesList.vue";
import AssignmentRuleView from "./AssignmentRuleView.vue";
const assignmentRuleSearchQuery = ref("");
provide("assignmentRuleSearchQuery", assignmentRuleSearchQuery);
</script>

View File

@@ -1,48 +1,80 @@
<template>
<div class="px-10 py-8 sticky top-0">
<div class="flex items-start justify-between">
<div class="flex flex-col gap-1">
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("Assignment rules") }}
</h1>
<p class="text-p-sm text-ink-gray-6 max-w-md">
{{
__(
"Assignment Rules automatically route tickets to the right team members based on predefined conditions."
)
}}
</p>
</div>
<SettingsLayoutBase>
<template #title>
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("Assignment Rules") }}
</h1>
</template>
<template #description>
<p class="text-p-sm max-w-md text-ink-gray-6">
{{
__(
"Assignment Rules automatically route tickets to the right team members based on predefined conditions."
)
}}
</p>
</template>
<template #header-actions>
<Button
:label="__('Create new')"
:label="__('New')"
theme="gray"
variant="solid"
@click="goToNew()"
icon-left="plus"
/>
</div>
</div>
<div class="overflow-y-auto px-10 pb-8">
<AssignmentRulesListView />
</div>
</template>
<template
v-if="
assignmentRulesListData.data?.length > 9 ||
assignmentRuleSearchQuery.length
"
#header-bottom
>
<div class="relative">
<Input
:model-value="assignmentRuleSearchQuery"
@input="assignmentRuleSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
<Button
v-if="assignmentRuleSearchQuery"
icon="x"
variant="ghost"
@click="assignmentRuleSearchQuery = ''"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
</template>
<template #content>
<AssignmentRulesListView />
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import { Button, createResource } from "frappe-ui";
import { provide } from "vue";
import { inject, provide, Ref } from "vue";
import {
assignmentRulesActiveScreen,
resetAssignmentRuleData,
} from "../../../stores/assignmentRules";
} from "@/stores/assignmentRules";
import AssignmentRulesListView from "./AssignmentRulesListView.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { AssignmentRuleListResourceSymbol } from "@/types";
const assignmentRulesListData = createResource({
url: "helpdesk.api.assignment_rule.get_assignment_rules_list",
cache: ["assignmentRules", "get_assignment_rules_list"],
auto: true,
});
const assignmentRuleSearchQuery = inject<Ref>("assignmentRuleSearchQuery");
provide("assignmentRulesList", assignmentRulesListData);
provide(AssignmentRuleListResourceSymbol, assignmentRulesListData);
const goToNew = () => {
resetAssignmentRuleData();

View File

@@ -1,32 +1,48 @@
<template>
<div
v-if="assignmentRulesList.loading && !assignmentRulesList.data"
v-if="assignmentRulesListData.loading && !assignmentRulesListData.data"
class="flex items-center justify-center mt-12"
>
<LoadingIndicator class="w-4" />
</div>
<div v-else>
<div v-else class="-ml-2 grow">
<div
v-if="assignmentRulesList.data?.length === 0"
class="flex items-center justify-center rounded-md border border-gray-200 p-4"
v-if="!assignmentRulesListData.loading && !assignmentRulesList?.length"
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div class="text-sm text-ink-gray-7">
{{ __("No items in the list") }}
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<Settings class="size-6 text-ink-gray-6 rotate-90" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No assignment rule found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="goToNew()"
/>
</div>
<div v-else>
<div class="grid grid-cols-11 items-center gap-4 text-sm text-gray-600">
<div class="grid grid-cols-12 items-center gap-4 text-sm text-gray-600">
<div class="col-span-7 ml-2">{{ __("Assignment rule") }}</div>
<div class="col-span-2">{{ __("Priority") }}</div>
<div class="col-span-3">{{ __("Priority") }}</div>
<div class="col-span-2">{{ __("Enabled") }}</div>
</div>
<hr class="mt-2 mx-2" />
<div
v-for="assignmentRule in assignmentRulesList.data"
v-for="(assignmentRule, index) in assignmentRulesList"
:key="assignmentRule.name"
>
<AssignmentRuleListItem :data="assignmentRule" />
<hr class="mx-2" />
<hr v-if="index !== assignmentRulesList.length - 1" class="mx-2" />
</div>
</div>
</div>
@@ -34,8 +50,43 @@
<script setup lang="ts">
import { LoadingIndicator } from "frappe-ui";
import { inject } from "vue";
import { inject, Ref, ref, watch } from "vue";
import AssignmentRuleListItem from "./AssignmentRuleListItem.vue";
import {
assignmentRulesActiveScreen,
resetAssignmentRuleData,
} from "@/stores/assignmentRules";
import Settings from "~icons/lucide/settings-2";
import { AssignmentRuleListResourceSymbol } from "@/types";
const assignmentRulesList = inject<any>("assignmentRulesList");
const assignmentRulesListData = inject(AssignmentRuleListResourceSymbol);
const assignmentRulesList = ref(assignmentRulesListData.data || []);
const assignmentRuleSearchQuery = inject<Ref>("assignmentRuleSearchQuery");
const goToNew = () => {
resetAssignmentRuleData();
assignmentRulesActiveScreen.value = {
screen: "view",
data: null,
};
};
watch(
() => [assignmentRuleSearchQuery.value, assignmentRulesListData.data],
([query, data]) => {
if (!query) {
assignmentRulesList.value = data || [];
return;
}
assignmentRulesList.value =
data?.filter((item) => {
return (
item.name.toLowerCase().includes(query.toLowerCase()) ||
item.description.toLowerCase().includes(query.toLowerCase())
);
}) || [];
}
);
</script>

View File

@@ -42,7 +42,8 @@ import CFConditions from "@/components/conditions-filter/CFConditions.vue";
import { validateConditions } from "@/utils";
import { watchDebounced } from "@vueuse/core";
import { Button, Dropdown, ErrorMessage, FeatherIcon } from "frappe-ui";
import { validateAssignmentRule } from "../../../stores/assignmentRules";
import { validateAssignmentRule } from "@/stores/assignmentRules";
import { __ } from "@/translation";
const props = defineProps({
conditions: Array<any>,
@@ -62,13 +63,13 @@ const getConjunction = () => {
const dropdownOptions = [
{
label: "Add condition",
label: __("Add condition"),
onClick: () => {
addCondition();
},
},
{
label: "Add condition group",
label: __("Add condition group"),
onClick: () => {
const conjunction = getConjunction();
props.conditions.push(conjunction, [[]]);

View File

@@ -29,16 +29,17 @@ import { onMounted, ref } from "vue";
import {
assignmentRuleData,
assignmentRulesErrors,
} from "../../../stores/assignmentRules";
} from "@/stores/assignmentRules";
import AssignmentScheduleItem from "./AssignmentScheduleItem.vue";
import { __ } from "@/translation";
const columns = [
{
label: "Days",
label: __("Days"),
key: "day",
},
{
label: "Active",
label: __("Active"),
key: "active",
},
];

View File

@@ -1,140 +0,0 @@
<template>
<div class="flex flex-col gap-4 px-10 py-8">
<SettingsLayoutHeader
title="Customise your Helpdesk"
description="Customise Helpdesk with your own branding."
/>
<!-- Brand Logo & Favicon -->
<div v-for="config in brandingConfig" class="flex flex-col gap-2">
<p class="text-sm text-gray-600">{{ config.title }}</p>
<div class="flex gap-4 items-center">
<Avatar
v-if="config.image && !config.loading"
size="3xl"
:image="config.image"
/>
<FileUploader
v-if="!config.image"
:fileTypes="['image/*']"
@success="
(file) => {
update(file.file_url, config.doctype, config.fieldname);
}
"
>
<template #default="{ openFileSelector }">
<Button
@click="openFileSelector()"
iconLeft="upload"
label="Upload Image"
:loading="config.loading"
/>
</template>
</FileUploader>
<div v-else>
<Button
label="Remove"
@click="update('', config.doctype, config.fieldname)"
iconLeft="trash"
:loading="config.loading"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useConfigStore } from "@/stores/config";
import { Avatar, createResource, FileUploader, toast } from "frappe-ui";
import { computed, reactive } from "vue";
import SettingsLayoutHeader from "./SettingsLayoutHeader.vue";
const config = useConfigStore();
const websiteSettings = createResource({
url: "frappe.client.get_value",
cache: true,
params: {
doctype: "Website Settings",
fieldname: "favicon",
},
onSuccess(data) {
state.brandFavicon = data.favicon;
},
auto: true,
});
const state = reactive({
brandLogo: config.brandLogo,
brandFavicon: websiteSettings.data?.favicon || "",
});
const loadingState = reactive({
logoLoading: false,
faviconLoading: false,
});
const brandingConfig = computed(() => [
{
title: "Brand Logo",
image: state.brandLogo,
doctype: "HD Settings",
fieldname: "brand_logo",
loading: loadingState.logoLoading,
},
{
title: "Brand Favicon",
image: state.brandFavicon,
doctype: "Website Settings",
fieldname: "favicon",
loading: loadingState.faviconLoading,
},
]);
const settingsResource = createResource({
url: "frappe.client.set_value",
debounce: 1000,
onSuccess(data) {
if (data.doctype === "HD Settings") {
handleLogoChange(data.brand_logo);
} else {
handleFaviconChange(data.favicon);
}
},
onError() {
toast.error("Failed to update, please try again");
loadingState.logoLoading = false;
loadingState.faviconLoading = false;
},
});
function update(file: string, doctype: string, fieldname: string) {
settingsResource.submit({
doctype: doctype,
name: doctype,
fieldname: fieldname,
value: file,
});
doctype === "HD Settings"
? (loadingState.logoLoading = true)
: (loadingState.faviconLoading = true);
}
function handleLogoChange(url: string) {
state.brandLogo = url;
loadingState.logoLoading = false;
toast.success("Brand logo updated");
}
function handleFaviconChange(url: string) {
state.brandFavicon = url;
loadingState.faviconLoading = false;
toast.success("Favicon updated, please refresh the page to see the changes");
}
</script>
<style scoped></style>

View File

@@ -1,15 +1,17 @@
<template>
<div
class="flex justify-between items-center border-gray-200 p-2 pl-0 cursor-pointer"
class="flex justify-between items-center border-gray-200 p-2 cursor-pointer hover:bg-gray-50 rounded h-14"
>
<!-- avatar and name -->
<div class="flex justify-between items-center gap-2">
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
<div>
<p class="text-gray-700 font-semibold">
<p class="text-base text-ink-gray-7 font-medium">
{{ emailAccount.email_account_name }}
</p>
<div class="text-sm text-gray-500">{{ emailAccount.email_id }}</div>
<div class="text-sm w-full text-ink-gray-5 mt-1">
{{ emailAccount.email_id }}
</div>
</div>
</div>
<div>
@@ -28,6 +30,7 @@ import { EmailAccount } from "@/types";
import { emailIcon } from "./emailConfig";
import EmailProviderIcon from "./EmailProviderIcon.vue";
import { computed } from "vue";
import { __ } from "@/translation";
interface P {
emailAccount: EmailAccount;
@@ -44,16 +47,16 @@ const badgeTitleColor = computed(() => {
props.emailAccount.enable_incoming && props.emailAccount.enable_outgoing
? "blue"
: "gray";
return ["Default Sending and Inbox", color];
return [__("Default Sending and Inbox"), color];
} else if (props.emailAccount.default_incoming) {
const color = props.emailAccount.enable_incoming ? "blue" : "gray";
return ["Default Inbox", color];
return [__("Default Inbox"), color];
} else if (props.emailAccount.default_outgoing) {
const color = props.emailAccount.enable_outgoing ? "blue" : "gray";
return ["Default Sending", color];
return [__("Default Sending"), color];
} else {
const color = props.emailAccount.enable_incoming ? "blue" : "gray";
return ["Inbox", color];
return [__("Inbox"), color];
}
});
</script>

View File

@@ -1,44 +1,76 @@
<template>
<div>
<!-- header -->
<SettingsLayoutHeader
title="Email Accounts"
description="Manage your email accounts and configure incoming and outgoing settings."
>
<template #actions>
<SettingsLayoutBase
:title="__('Email Accounts')"
:description="
__(
'Manage your email accounts and configure incoming and outgoing settings.'
)
"
>
<template #header-actions>
<Button
:label="__('New')"
theme="gray"
variant="solid"
@click="emit('update:step', 'email-add')"
icon-left="plus"
/>
</template>
<template #content>
<!-- list accounts -->
<div
class="-ml-2 grow"
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
>
<div class="flex text-sm text-gray-600">
<div class="ml-2">{{ __("Email Account name") }}</div>
</div>
<hr class="mx-2 mt-2" />
<div
v-for="emailAccount in emailAccounts.data"
:key="emailAccount.name"
>
<EmailAccountCard
:emailAccount="emailAccount"
@click="emit('update:step', 'email-edit', emailAccount)"
/>
<hr class="mx-2" />
</div>
</div>
<!-- fallback if no email accounts -->
<div
v-else
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<EmailIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No email account found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
<Button
label="Add Account"
theme="gray"
variant="solid"
@click="emit('update:step', 'email-add')"
:label="__('New')"
variant="outline"
icon-left="plus"
/>
</template>
</SettingsLayoutHeader>
<!-- list accounts -->
<div
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
class="mt-4 divide-y"
>
<div v-for="emailAccount in emailAccounts.data" :key="emailAccount.name">
<EmailAccountCard
:emailAccount="emailAccount"
@click="emit('update:step', 'email-edit', emailAccount)"
@click="emit('update:step', 'email-add')"
/>
</div>
</div>
<!-- fallback if no email accounts -->
<div v-else class="flex items-center justify-center h-64 text-gray-500">
Please add an email account to continue.
</div>
</div>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import { EmailAccount } from "@/types";
import { createListResource } from "frappe-ui";
import EmailAccountCard from "./EmailAccountCard.vue";
import SettingsLayoutHeader from "./SettingsLayoutHeader.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const emit = defineEmits(["update:step"]);

View File

@@ -1,122 +1,229 @@
<template>
<div class="flex h-full flex-col gap-4">
<!-- title and desc -->
<div role="heading" aria-level="1" class="flex flex-col gap-1">
<h5 class="text-lg font-semibold pt-[5px]">Setup Email</h5>
<p class="text-sm text-gray-600">
Choose the email service provider you want to configure.
</p>
</div>
<!-- email service provider selection -->
<div class="flex flex-wrap items-center gap-4">
<div
v-for="s in services"
:key="s.name"
class="min-w-3 flex flex-col items-center gap-1"
@click="handleSelect(s)"
>
<EmailProviderIcon
:service-name="s.name"
:logo="s.icon"
:selected="selectedService?.name === s?.name"
/>
</div>
</div>
<div v-if="selectedService" class="flex flex-col gap-4">
<!-- email service provider info -->
<div class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200">
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-blue-500"
/>
<div class="text-wrap text-xs text-gray-700">
{{ selectedService.info }}
<a
:href="selectedService.link"
target="_blank"
class="text-blue-500 underline"
>here</a
>
.
</div>
</div>
<!-- service provider fields -->
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4">
<div
v-for="field in fields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
<SettingsLayoutBase
:title="__('Setup Email')"
:description="
__('Choose the email service provider you want to configure.')
"
>
<template #content>
<div class="flex h-full flex-col gap-4">
<div class="flex flex-col gap-4 overflow-y-auto p-0.5">
<!-- email service provider selection -->
<div class="flex flex-wrap items-center gap-4">
<div
v-for="s in services"
:key="s.name"
class="min-w-3 flex flex-col items-center gap-1"
@click="handleSelect(s)"
>
<EmailProviderIcon
:service-name="s.name"
:logo="s.icon"
:selected="selectedService?.name === s?.name"
/>
</div>
</div>
<div v-if="selectedService" class="flex flex-col gap-4">
<!-- email service provider info -->
<div>
<div
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200"
>
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-blue-500"
/>
<div class="text-wrap text-xs text-gray-700">
{{ selectedService.info }}
<a
:href="selectedService.link"
target="_blank"
class="text-blue-500 underline"
>here</a
>
.
</div>
</div>
</div>
<!-- service provider fields -->
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4">
<div
v-for="field in fields"
:key="field.name"
class="flex flex-col gap-1"
>
<div
v-if="field.name === 'domain'"
class="flex flex-col gap-2"
>
<Link
v-model="customState.domain"
:label="field.label"
:placeholder="field.placeholder"
doctype="Email Domain"
:onCreate="handleCreateDomainClick"
/>
<div v-if="customState.domain" class="flex gap-2">
<Button
size="sm"
variant="subtle"
theme="gray"
:label="__('Edit Domain')"
@click="handleEditDomainClick"
/>
</div>
</div>
<FormControl
v-else
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
<div
v-if="isCustomSelected"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="flex flex-col gap-4">
<div
v-for="field in customIncomingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="customState[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
<div class="flex flex-col gap-4">
<div
v-for="field in customOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="customState[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div
v-for="field in incomingOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
/>
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
</div>
</div>
<ErrorMessage v-if="error" class="ml-1" :message="error" />
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div
v-for="field in incomingOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
/>
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
</div>
<!-- action button -->
<div class="mt-auto flex justify-between -mb-8">
<Button
:label="__('Back')"
theme="gray"
variant="outline"
:disabled="addEmailRes.loading"
@click="emit('update:step', 'email-list')"
/>
<Button
:label="__('Create')"
variant="solid"
:loading="addEmailRes.loading"
@click="createEmailAccount"
/>
</div>
<ErrorMessage v-if="error" class="ml-1" :message="error" />
</div>
</div>
<!-- action button -->
<div v-if="selectedService" class="mt-auto flex justify-between">
<Button
label="Back"
theme="gray"
variant="outline"
:disabled="addEmailRes.loading"
@click="emit('update:step', 'email-list')"
/>
<Button
label="Create"
variant="solid"
:loading="addEmailRes.loading"
@click="createEmailAccount"
/>
</div>
</div>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import { Link } from "@/components";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { capture } from "@/telemetry";
import { EmailAccount, EmailService, EmailStep } from "@/types";
import { createResource, toast } from "frappe-ui";
import { call, createResource, toast } from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, Reactive, reactive, Ref, ref } from "vue";
import { computed, reactive, Ref, ref, watch } from "vue";
import CircleAlert from "~icons/lucide/circle-alert";
import {
customProviderFields,
customIncomingFields,
customOutgoingFields,
customProviderTopFields,
frappeMailFields,
incomingOutgoingFields,
popularProviderFields,
services,
validateInputs,
} from "./emailConfig";
import EmailProviderIcon from "./EmailProviderIcon.vue";
import { __ } from "@/translation";
interface E {
(event: "update:step", value: EmailStep): void;
}
interface EmailAccountBaseState {
email_account_name: string;
email_id: string;
service: string;
enable_incoming: boolean;
enable_outgoing: boolean;
default_incoming: boolean;
default_outgoing: boolean;
}
interface EmailAccountProviderAuthState extends EmailAccountBaseState {
password?: string;
api_key?: string;
api_secret?: string;
frappe_mail_site?: string;
}
type CustomEmailAccountState = {
domain?: string;
email_server?: string;
incoming_port?: string | number;
smtp_server?: string;
smtp_port?: string | number;
use_ssl?: boolean | number;
use_starttls?: boolean | number;
use_tls?: boolean | number;
use_ssl_for_outgoing?: boolean | number;
validate_ssl_certificate?: boolean | number;
validate_ssl_certificate_for_outgoing?: boolean | number;
attachment_limit?: string | number;
append_emails_to_sent_folder?: boolean | number;
sent_folder_name?: string;
};
const emit = defineEmits<E>();
const { updateOnboardingStep } = useOnboarding("helpdesk");
const state: Reactive<EmailAccount> = reactive({
const state = reactive<EmailAccountProviderAuthState>({
service: "",
email_account_name: "",
email_id: "",
@@ -130,39 +237,175 @@ const state: Reactive<EmailAccount> = reactive({
default_outgoing: false,
});
const selectedService: Ref<EmailService> = ref(null);
const fields = computed(() =>
selectedService.value.custom ? customProviderFields : popularProviderFields
const getDefaultCustomState = (): CustomEmailAccountState => ({
domain: "",
email_server: "",
smtp_server: "",
incoming_port: "",
smtp_port: "",
use_ssl: false,
use_starttls: false,
use_tls: false,
use_ssl_for_outgoing: false,
validate_ssl_certificate: true,
validate_ssl_certificate_for_outgoing: true,
attachment_limit: "",
append_emails_to_sent_folder: false,
sent_folder_name: "",
});
const customState = reactive<CustomEmailAccountState>(getDefaultCustomState());
function resetCustomState() {
Object.assign(customState, getDefaultCustomState());
}
const selectedService: Ref<EmailService | null> = ref(null);
const fields = computed(() => {
if (!selectedService.value) return [];
if (selectedService.value.name === "Frappe Mail") {
return frappeMailFields;
}
if (selectedService.value.custom) {
return customProviderTopFields;
}
return popularProviderFields;
});
const isCustomSelected = computed(
() => selectedService.value?.name === "Custom"
);
watch(
() => customState.domain,
async (val) => {
if (state.service !== "Custom") return;
if (!isCustomSelected.value || !val) return;
try {
const doc = await call("frappe.client.get", {
doctype: "Email Domain",
name: val,
});
syncAccountFieldsFromDomain(doc);
} catch (err) {
console.warn("Failed to load Email Domain", err);
}
}
);
function handleSelect(service: EmailService) {
selectedService.value = service;
state.service = service.name;
if (service.name !== "Custom") {
resetCustomState();
}
}
const addEmailRes = createResource({
url: "helpdesk.api.settings.email.create_email_account",
makeParams: (val: EmailAccount) => {
return {
...val,
};
},
makeParams: (val: EmailAccount) => ({ ...val }),
onSuccess: () => {
toast.success("Email account created");
toast.success(__("Email account created"));
emit("update:step", "email-list");
updateOnboardingStep("setup_email_account");
capture("email_account_created", { data: { service: state.service } });
},
onError: () => {
error.value = "Failed to create email account, Invalid credentials";
error.value = __("Failed to create email account, Invalid credentials");
},
});
const error = ref<string | undefined>();
function createEmailAccount() {
error.value = validateInputs(state, selectedService.value.custom);
const validationState = { ...state, ...customState };
error.value = validateInputs(validationState, state.service);
if (error.value) return;
addEmailRes.submit({ data: state });
addEmailRes.submit({ data: buildCreatePayload() });
}
function buildCreatePayload() {
const commonPayload = {
email_account_name: state.email_account_name,
email_id: state.email_id,
service: state.service,
enable_incoming: state.enable_incoming,
enable_outgoing: state.enable_outgoing,
default_incoming: state.default_incoming,
default_outgoing: state.default_outgoing,
};
if (state.service === "Frappe Mail") {
return {
...commonPayload,
frappe_mail_site: state.frappe_mail_site,
api_key: state.api_key,
api_secret: state.api_secret,
};
}
if (state.service === "Custom") {
return {
...commonPayload,
password: state.password,
...customState,
};
}
return {
...commonPayload,
password: state.password,
};
}
function syncAccountFieldsFromDomain(payload: Record<string, any>) {
customState.email_server = payload.email_server ?? customState.email_server;
customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
customState.incoming_port =
payload.incoming_port ?? customState.incoming_port;
customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
customState.use_starttls = Boolean(
payload.use_starttls ?? customState.use_starttls
);
customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
customState.use_ssl_for_outgoing = Boolean(
payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
);
customState.validate_ssl_certificate = Boolean(
payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
);
customState.validate_ssl_certificate_for_outgoing = Boolean(
payload.validate_ssl_certificate_for_outgoing ??
customState.validate_ssl_certificate_for_outgoing
);
customState.append_emails_to_sent_folder = Boolean(
payload.append_emails_to_sent_folder ??
customState.append_emails_to_sent_folder
);
customState.sent_folder_name =
payload.sent_folder_name || customState.sent_folder_name;
customState.attachment_limit =
payload.attachment_limit || customState.attachment_limit;
}
function handleCreateDomainClick(value: any, close?: () => void) {
close?.();
window.open(
"/desk/email-domain/new-email-domain",
"_blank",
"noopener,noreferrer"
);
}
function handleEditDomainClick() {
if (!customState.domain) return;
window.open(
`/desk/email-domain/${encodeURIComponent(customState.domain)}`,
"_blank",
"noopener,noreferrer"
);
}
</script>

View File

@@ -1,14 +1,12 @@
<template>
<div class="flex-1 px-10 py-8">
<div v-if="step === 'email-add'" class="h-full">
<EmailAdd @update:step="updateStep" />
</div>
<div v-else-if="step === 'email-list'" class="h-full">
<EmailAccountList @update:step="updateStep" />
</div>
<div v-else-if="step === 'email-edit'" class="h-full">
<EmailEdit :account-data="accountData" @update:step="updateStep" />
</div>
<div v-if="step === 'email-add'" class="h-full">
<EmailAdd @update:step="updateStep" />
</div>
<div v-else-if="step === 'email-list'" class="h-full">
<EmailAccountList @update:step="updateStep" />
</div>
<div v-else-if="step === 'email-edit'" class="h-full">
<EmailEdit :account-data="accountData" @update:step="updateStep" />
</div>
</template>

View File

@@ -1,110 +1,222 @@
<template>
<div class="flex h-full flex-col gap-4">
<!-- title and desc -->
<div
role="heading"
aria-level="1"
class="flex gap-1 justify-between pt-[5px]"
>
<h5 class="text-lg font-semibold">Edit Email</h5>
</div>
<div class="w-fit">
<EmailProviderIcon
:logo="emailIcon[accountData.service]"
:service-name="accountData.service"
/>
</div>
<!-- banner for setting up email account -->
<div class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200">
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-2"
/>
<div class="text-wrap text-xs text-gray-700">
{{ info.description }}
<a :href="info.link" target="_blank" class="text-ink-blue-2 underline"
>here</a
>
.
</div>
</div>
<!-- fields -->
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4">
<div
v-for="field in fields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
<SettingsLayoutBase
:title="__('Edit Email')"
:description="__('Edit your email account')"
>
<template #content>
<div class="flex h-full flex-col gap-4">
<div class="overflow-y-auto flex flex-col gap-4 p-0.5">
<div class="w-fit">
<EmailProviderIcon
:logo="emailIcon[state.service || accountData.service]"
:service-name="state.service || accountData.service"
/>
</div>
<!-- banner for setting up email account -->
<div
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200"
>
<CircleAlert
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-2"
/>
<div class="text-wrap text-xs text-gray-700 flex flex-col gap-1">
<span>
{{ info.description }}
<a
:href="info.link"
target="_blank"
class="text-ink-blue-2 underline"
>here</a
>.
</span>
<span v-if="deskEditUrl" class="flex items-center gap-1">
<a
:href="deskEditUrl"
target="_blank"
class="text-ink-blue-2 underline"
>
{{ __("Open in Desk") }}
</a>
</span>
</div>
</div>
<!-- fields -->
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4">
<div
v-for="field in fields"
:key="field.name"
class="flex flex-col gap-1"
>
<div v-if="field.name === 'domain'" class="flex flex-col gap-2">
<Link
v-model="customState.domain"
:label="field.label"
:placeholder="field.placeholder"
doctype="Email Domain"
:onCreate="handleCreateDomainClick"
/>
<div v-if="customState.domain" class="flex gap-2">
<Button
size="sm"
variant="subtle"
theme="gray"
:label="__('Edit Domain')"
@click="handleEditDomainClick"
/>
</div>
</div>
<FormControl
v-else
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
<div
v-if="isCustomProvider"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="flex flex-col gap-4">
<div
v-for="field in customIncomingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="customState[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
<div class="flex flex-col gap-4">
<div
v-for="field in customOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="customState[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
:placeholder="field.placeholder"
/>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="field in incomingOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
/>
<p class="text-p-sm text-gray-500">{{ field.description }}</p>
</div>
</div>
<ErrorMessage v-if="error" class="ml-1" :message="error" />
</div>
</div>
<!-- action buttons -->
<div class="mt-auto flex justify-between -mb-8">
<Button
:label="__('Back')"
theme="gray"
variant="outline"
:disabled="loading"
@click="emit('update:step', 'email-list')"
/>
<div class="flex gap-2">
<Button
:label="__('Update Account')"
variant="solid"
@click="updateAccount"
:loading="loading"
/>
<Button
v-if="accountData.enable_incoming"
:label="__('Pull Emails')"
variant="subtle"
@click="pullEmails"
:loading="loadingPull"
:disabled="loading"
/>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div
v-for="field in incomingOutgoingFields"
:key="field.name"
class="flex flex-col gap-1"
>
<FormControl
v-model="state[field.name]"
:label="field.label"
:name="field.name"
:type="field.type"
/>
<p class="text-p-sm text-gray-500">{{ field.description }}</p>
</div>
</div>
<ErrorMessage v-if="error" class="ml-1" :message="error" />
</div>
<!-- action buttons -->
<div class="mt-auto flex justify-between">
<Button
label="Back"
theme="gray"
variant="outline"
:disabled="loading"
@click="emit('update:step', 'email-list')"
/>
<div class="flex gap-2">
<Button
label="Update Account"
variant="solid"
@click="updateAccount"
:loading="loading"
/>
<Button
v-if="accountData.enable_incoming"
label="Pull Emails"
variant="subtle"
@click="pullEmails"
:loading="loadingPull"
:disabled="loading"
/>
</div>
</div>
</div>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import { EmailAccount, EmailStep } from "@/types";
import { useStorage } from "@vueuse/core";
import { call, toast } from "frappe-ui";
import { computed, h, reactive, ref } from "vue";
import { computed, h, reactive, ref, watch } from "vue";
import CircleAlert from "~icons/lucide/circle-alert";
import EmailProviderIcon from "./EmailProviderIcon.vue";
import { Link } from "@/components";
import {
customProviderFields,
customIncomingFields,
customOutgoingFields,
customProviderTopFields,
emailIcon,
frappeMailFields,
incomingOutgoingFields,
popularProviderFields,
services,
validateInputs,
} from "./emailConfig";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
interface EmailAccountBaseState {
email_account_name: string;
service: string;
email_id: string;
enable_incoming: boolean;
enable_outgoing: boolean;
default_outgoing: boolean;
default_incoming: boolean;
}
interface EmailAccountProviderAuthState extends EmailAccountBaseState {
api_key?: string | null;
api_secret?: string | null;
password?: string | null;
frappe_mail_site?: string;
}
type CustomEmailAccountState = {
[key: string]: string | number | boolean | undefined;
domain?: string;
email_server?: string;
smtp_server?: string;
incoming_port?: string | number;
smtp_port?: string | number;
use_ssl?: boolean | number;
use_starttls?: boolean | number;
use_tls?: boolean | number;
use_ssl_for_outgoing?: boolean | number;
validate_ssl_certificate?: boolean | number;
validate_ssl_certificate_for_outgoing?: boolean | number;
attachment_limit?: string | number;
append_emails_to_sent_folder?: boolean | number;
sent_folder_name?: string;
};
interface P {
accountData: EmailAccount;
@@ -120,7 +232,7 @@ const props = withDefaults(defineProps<P>(), {
const emit = defineEmits<E>();
const state = reactive({
const state = reactive<EmailAccountProviderAuthState>({
email_account_name: props.accountData.email_account_name || "",
service: props.accountData.service || "",
email_id: props.accountData.email_id || "",
@@ -134,18 +246,73 @@ const state = reactive({
default_incoming: props.accountData.default_incoming || false,
});
const getInitialCustomState = (): CustomEmailAccountState => ({
domain: props.accountData?.domain || "",
email_server: props.accountData?.email_server || "",
smtp_server: props.accountData?.smtp_server || "",
incoming_port: props.accountData?.incoming_port || "",
smtp_port: props.accountData?.smtp_port || "",
use_ssl: props.accountData?.use_ssl || false,
use_starttls: props.accountData?.use_starttls || false,
use_tls: props.accountData?.use_tls || false,
use_ssl_for_outgoing: props.accountData?.use_ssl_for_outgoing || false,
validate_ssl_certificate: props.accountData?.validate_ssl_certificate ?? true,
validate_ssl_certificate_for_outgoing:
props.accountData?.validate_ssl_certificate_for_outgoing ?? true,
attachment_limit: (props.accountData as any)?.attachment_limit || "",
append_emails_to_sent_folder:
(props.accountData as any)?.append_emails_to_sent_folder || false,
sent_folder_name: (props.accountData as any)?.sent_folder_name || "",
});
const customState = reactive<CustomEmailAccountState>(getInitialCustomState());
const info = {
description: "To know more about setting up email accounts, click",
link: "https://docs.erpnext.com/docs/user/manual/en/email-account",
description: __("To know more about setting up email accounts, click"),
link: "https://docs.frappe.io/erpnext/user/manual/en/email-domain",
};
const isCustomService = computed(() => {
return services.find((s) => s.name === props.accountData.service).custom;
const deskEditUrl = computed(() => {
const name = state.email_account_name || props.accountData.email_account_name;
if (!name) return "";
return `/desk/email-account/${encodeURIComponent(name)}`;
});
const currentServiceName = computed(
() => state.service || props.accountData.service || ""
);
const serviceDef = computed(() => {
const currentService = currentServiceName.value;
return services.find((s) => s.name === currentService);
});
const isCustomProvider = computed(() => {
const currentService = currentServiceName.value;
if (currentService === "Custom") {
return true;
}
if (currentService) {
return false;
}
// Many existing custom accounts store service as blank; detect by server fields.
return Boolean(
customState.email_server ||
customState.smtp_server ||
props.accountData.email_server ||
props.accountData.smtp_server
);
});
const fields = computed(() => {
if (isCustomService.value) {
return customProviderFields;
if (isCustomProvider.value) {
return customProviderTopFields;
}
if (!serviceDef.value) {
return popularProviderFields;
}
if (serviceDef.value.name === "Frappe Mail") {
return frappeMailFields;
}
return popularProviderFields;
});
@@ -157,7 +324,11 @@ const loadingPull = useStorage(
false
);
async function updateAccount() {
error.value = validateInputs(state, isCustomService.value);
const validationState = { ...state, ...customState };
const validationService = isCustomProvider.value
? "Custom"
: currentServiceName.value;
error.value = validateInputs(validationState, validationService, true);
if (error.value) return;
const old = { ...props.accountData };
const updatedEmailAccount = { ...state };
@@ -168,11 +339,10 @@ async function updateAccount() {
delete updatedEmailAccount.email_account_name;
const otherFieldsChanged = isDirty.value;
const values = updatedEmailAccount;
const values = buildUpdatePayload();
if (!nameChanged && !otherFieldsChanged) {
toast.create({
message: "No changes made",
message: __("No changes made"),
icon: h(CircleAlert, { class: "text-ink-blue-2" }),
});
return;
@@ -198,11 +368,44 @@ async function updateAccount() {
}
}
function buildUpdatePayload() {
const commonPayload = {
email_id: state.email_id,
service: state.service,
enable_incoming: state.enable_incoming,
enable_outgoing: state.enable_outgoing,
default_incoming: state.default_incoming,
default_outgoing: state.default_outgoing,
};
if (currentServiceName.value === "Frappe Mail") {
return {
...commonPayload,
frappe_mail_site: state.frappe_mail_site,
api_key: state.api_key,
api_secret: state.api_secret,
};
}
if (isCustomProvider.value) {
return {
...commonPayload,
password: state.password,
...customState,
};
}
return {
...commonPayload,
password: state.password,
};
}
function pullEmails() {
loadingPull.value = true;
toast.create({
message: "Pulling emails, this may take a few minutes.",
message: __("Pulling emails, this may take a few minutes."),
icon: h(CircleAlert, { class: "text-blue-500" }),
});
@@ -212,17 +415,28 @@ function pullEmails() {
.then(() => {
localStorage.removeItem(`loading-emails-${state.email_account_name}`);
loadingPull.value = null;
toast.success("Emails pulled successfully");
toast.success(__("Emails pulled successfully"));
})
.catch(() => {
localStorage.removeItem(`loading-emails-${state.email_account_name}`);
loadingPull.value = null;
error.value = "Failed to pull emails";
error.value = __("Failed to pull emails");
});
}
const isDirty = computed(() => {
const customDirty = isCustomProvider.value
? customState.domain !== props.accountData.domain ||
customState.email_server !== props.accountData.email_server ||
customState.smtp_server !== props.accountData.smtp_server ||
customState.incoming_port !== props.accountData.incoming_port ||
customState.smtp_port !== props.accountData.smtp_port ||
customState.use_ssl !== props.accountData.use_ssl ||
customState.use_starttls !== props.accountData.use_starttls
: false;
return (
customDirty ||
state.service !== props.accountData.service ||
state.email_id !== props.accountData.email_id ||
state.api_key !== props.accountData.api_key ||
state.api_secret !== props.accountData.api_secret ||
@@ -236,15 +450,14 @@ const isDirty = computed(() => {
});
async function callRenameDoc() {
const d = await call("frappe.client.rename_doc", {
return call("frappe.client.rename_doc", {
doctype: "Email Account",
old_name: props.accountData.email_account_name,
new_name: state.email_account_name,
});
return d;
}
async function callSetValue(values) {
async function callSetValue(values: Record<string, any>) {
const d = await call("frappe.client.set_value", {
doctype: "Email Account",
name: state.email_account_name,
@@ -255,11 +468,91 @@ async function callSetValue(values) {
function succesHandler() {
emit("update:step", "email-list");
toast.success("Email account updated successfully");
toast.success(__("Email account updated successfully"));
}
function errorHandler() {
loading.value = false;
error.value = "Failed to update email account, Invalid credentials";
error.value = __("Failed to update email account, Invalid credentials");
}
watch(
() => props.accountData,
(val) => {
if (!val) return;
if (val.email_id && state.email_id !== val.email_id) {
state.email_id = val.email_id;
}
if (!customState.domain && val.domain) {
customState.domain = val.domain;
}
},
{ deep: true, immediate: true }
);
watch(
() => customState.domain,
async (val) => {
if (!isCustomProvider.value) return;
if (!val) return;
try {
const doc = await call("frappe.client.get", {
doctype: "Email Domain",
name: val,
});
syncAccountFieldsFromDomain(doc);
} catch (err) {
console.warn("Failed to load Email Domain", err);
}
}
);
function syncAccountFieldsFromDomain(payload: Record<string, any>) {
customState.email_server = payload.email_server ?? customState.email_server;
customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
customState.incoming_port =
payload.incoming_port ?? customState.incoming_port;
customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
customState.use_starttls = Boolean(
payload.use_starttls ?? customState.use_starttls
);
customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
customState.use_ssl_for_outgoing = Boolean(
payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
);
customState.validate_ssl_certificate = Boolean(
payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
);
customState.validate_ssl_certificate_for_outgoing = Boolean(
payload.validate_ssl_certificate_for_outgoing ??
customState.validate_ssl_certificate_for_outgoing
);
customState.append_emails_to_sent_folder = Boolean(
payload.append_emails_to_sent_folder ??
customState.append_emails_to_sent_folder
);
customState.sent_folder_name =
payload.sent_folder_name || customState.sent_folder_name;
customState.attachment_limit =
payload.attachment_limit || customState.attachment_limit;
}
function handleCreateDomainClick(value: any, close?: () => void) {
close?.();
window.open(
"/desk/email-domain/new-email-domain",
"_blank",
"noopener,noreferrer"
);
}
function handleEditDomainClick() {
if (!customState.domain) return;
window.open(
`/desk/email-domain/${encodeURIComponent(customState.domain)}`,
"_blank",
"noopener,noreferrer"
);
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="px-10 h-full overflow-y-auto flex flex-col isolate">
<div class="h-full overflow-y-auto flex flex-col isolate">
<NotificationList v-if="curNotification === null" :onSelect="onSelect" />
<component
v-else

View File

@@ -1,112 +1,107 @@
<template>
<div class="flex-grow flex flex-col isolate">
<!-- Header -->
<div class="sticky top-0 z-10 bg-white py-8">
<SettingsLayoutHeader :description="props.description">
<template #title>
<div class="flex items-center">
<div
class="pl-5 pr-2 relative text-ink-gray-7 hover:opacity-70 min-h-8 flex items-center"
>
<button
type="button"
@click="internalOnBack"
class="absolute top-0 -left-[0.375rem] w-full h-full"
>
<span class="sr-only">{{
__("back to email event list")
}}</span>
<LucideChevronLeft class="w-4.5 h-4.5" />
</button>
<h1 class="font-semibold text-xl">
{{ props.title }}
</h1>
</div>
<Badge
v-if="unsavedChanges"
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
/>
</div>
</template>
<template #actions>
<div
:inert="notificationDataResource.loading"
class="flex items-center gap-x-4 pt-[0.125rem]"
:class="{ invisible: notificationDataResource.loading }"
<SettingsLayoutBase>
<template #title>
<div class="flex items-center">
<div
class="pl-5 pr-2 relative text-ink-gray-7 hover:opacity-70 min-h-8 flex items-center"
>
<button
type="button"
@click="internalOnBack"
class="absolute top-0 -left-[0.375rem] w-full h-full"
>
<Switch
<span class="sr-only">{{ __("back to email event list") }}</span>
<LucideChevronLeft class="w-4.5 h-4.5" />
</button>
<h1 class="font-semibold text-xl">
{{ props.title }}
</h1>
</div>
<Badge
v-if="unsavedChanges"
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
/>
</div>
</template>
<template #header-actions>
<div
:inert="notificationDataResource.loading"
class="flex items-center gap-x-4 pt-[0.125rem]"
:class="{ invisible: notificationDataResource.loading }"
>
<Switch
size="sm"
:label="__('Enabled')"
v-model="enabled"
@update:model-value="setUnsavedChanges"
:style="{ background: 'transparent', padding: '0px' }"
class="flex-row-reverse gap-x-2 pl-0"
/>
<Button
type="button"
:label="__('Save')"
theme="gray"
variant="solid"
:disabled="!unsavedChanges"
:loading="props.submitting"
@click="props.onSubmit"
/>
</div>
</template>
<template #content>
<div
class="flex flex-col gap-8 flex-grow pb-8"
:class="{
'items-center justify-center': notificationDataResource.loading,
}"
>
<template v-if="!notificationDataResource.loading">
<slot name="formFields"></slot>
<div class="flex flex-col gap-2">
<FormControl
type="textarea"
size="sm"
:label="__('Enabled')"
v-model="enabled"
@update:model-value="setUnsavedChanges"
:style="{ background: 'transparent', padding: '0px' }"
class="flex-row-reverse gap-x-2 pl-0"
/>
<Button
type="button"
:label="__('Save')"
theme="gray"
variant="solid"
:disabled="!unsavedChanges"
:loading="props.submitting"
@click="props.onSubmit"
:label="__('Email Content')"
:required="true"
:rows="10"
v-model="content"
:oninput="setUnsavedChanges"
/>
<div class="flex gap-x-1 items-start justify-between">
<p class="text-sm text-gray-700 leading-5">
{{
__(
"Find out all of the variables that can be used in the content"
)
}}
<a
:href="props.documentationLink"
target="_blank"
class="underline font-semibold"
>{{ __("here") }}</a
>
</p>
<Button
:disabled="content === defaultContent"
type="button"
size="sm"
variant="subtle"
@click="onResetContent"
class="w-fit"
>
{{ __("Reset Content") }}
</Button>
</div>
</div>
</template>
</SettingsLayoutHeader>
</div>
<!-- Body -->
<div
class="flex flex-col gap-8 flex-grow pb-8"
:class="{
'items-center justify-center': notificationDataResource.loading,
}"
>
<template v-if="!notificationDataResource.loading">
<slot name="formFields"></slot>
<div class="flex flex-col gap-2">
<FormControl
type="textarea"
size="sm"
:label="__('Email Content')"
:required="true"
:rows="10"
v-model="content"
:oninput="setUnsavedChanges"
/>
<div class="flex gap-x-1 items-start justify-between">
<p class="text-sm text-gray-700 leading-5">
{{
__(
"Find out all of the variables that can be used in the content"
)
}}
<a
:href="props.documentationLink"
target="_blank"
class="underline font-semibold"
>{{ __("here") }}</a
>
</p>
<Button
:disabled="content === defaultContent"
type="button"
size="sm"
variant="subtle"
@click="onResetContent"
class="w-fit"
>
{{ __("Reset Content") }}
</Button>
</div>
</div>
</template>
<LoadingIndicator v-else class="w-4" />
</div>
</div>
<LoadingIndicator v-else class="w-4" />
</div>
</template>
</SettingsLayoutBase>
<ConfirmDialog
v-model="showUnsavedConfirm"
:title="__('Unsaved changes')"
@@ -141,9 +136,9 @@
import { ref, watch } from "vue";
import type { NotificationName } from "./types";
import { createResource, Switch, LoadingIndicator } from "frappe-ui";
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const props = defineProps<{
title: string;

View File

@@ -1,59 +1,58 @@
<template>
<!-- Header -->
<div class="pt-8 pb-2 bg-white sticky top-0 z-10">
<SettingsLayoutHeader
:title="__('Email Notifications')"
:description="
__(
'Customize your email notification preferences to stay informed about important updates and activities.'
)
"
/>
</div>
<!-- Body -->
<ul class="mt-4 pb-8 isolate">
<li
v-for="notification in notifications"
:key="notification.name"
class="flex items-center justify-between p-3 rounded relative"
>
<div class="flex flex-col gap-1">
<h2
class="text-base font-medium text-ink-gray-7 relative z-10 pointer-events-none"
<SettingsLayoutBase
:title="__('Email Notifications')"
:description="
__(
'Customize your email notification preferences to stay informed about important updates and activities.'
)
"
>
<template #content>
<ul class="isolate -ml-3">
<li
v-for="notification in notifications"
:key="notification.name"
class="flex items-center justify-between p-3 rounded relative"
>
{{ notification.label }}
</h2>
<p
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none"
>
{{ notification.description }}
</p>
</div>
<FeatherIcon
name="chevron-right"
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none"
/>
<button
type="button"
class="w-full h-full absolute top-0 left-0 hover:bg-gray-50 rounded-[inherit]"
@click="
() => {
props.onSelect(notification);
}
"
>
<span class="sr-only">{{
__("customize {0}", notification.name)
}}</span>
</button>
</li>
</ul>
<div class="flex flex-col gap-1">
<h2
class="text-base font-medium text-ink-gray-7 relative z-10 pointer-events-none"
>
{{ notification.label }}
</h2>
<p
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none"
>
{{ notification.description }}
</p>
</div>
<FeatherIcon
name="chevron-right"
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none"
/>
<button
type="button"
class="w-full h-full absolute top-0 left-0 hover:bg-gray-50 rounded-[inherit]"
@click="
() => {
props.onSelect(notification);
}
"
>
<span class="sr-only">{{
__("customize {0}", notification.name)
}}</span>
</button>
</li>
</ul>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
import type { AtLeastOneNotifcation, Notification } from "./types";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const props = defineProps<{
onSelect: (notification: Notification) => void;
@@ -62,7 +61,7 @@ const props = defineProps<{
const notifications: AtLeastOneNotifcation = [
{
name: "share_feedback",
label: __("Share Feedback"),
label: __("Share feedback"),
description: __(
"Sent to the user who has raised the ticket after the ticket is closed or resolved"
),
@@ -74,14 +73,14 @@ const notifications: AtLeastOneNotifcation = [
},
{
name: "reply_to_agents",
label: __("Reply From Contact"),
label: __("Reply from contact"),
description: __(
"Sent to all of the assigned agents after a reply from one of the contacts"
),
},
{
name: "reply_via_agent",
label: __("Reply From Agent"),
label: __("Reply from agent"),
description: __(
"Sent to all of the recipients associated with an agent's reply"
),

View File

@@ -1,14 +1,18 @@
export type NotificationName = "share_feedback" | "acknowledgement" | "reply_to_agents" | "reply_via_agent";
export type NotificationName =
| "share_feedback"
| "acknowledgement"
| "reply_to_agents"
| "reply_via_agent";
export type Notification = {
name: NotificationName;
label: string;
description: string;
name: NotificationName;
label: string;
description: string;
};
export type AtLeastOneNotifcation = [Notification, ...Notification[]];
export type BaseSettings = {
enabled: boolean;
content: string;
enabled: boolean;
content: string;
};

View File

@@ -1,9 +1,10 @@
<template>
<div
class="flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl bg-gray-100 hover:bg-gray-200"
class="flex size-8 cursor-pointer items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200"
:class="{ 'ring-2 ring-blue-500': selected }"
>
<img :src="logo" class="h-6 w-6" />
<img v-if="logoValue" :src="logoValue" class="size-4.5" />
<LucideMail v-else class="size-4.5 text-gray-700" />
</div>
<p v-if="serviceName" class="text-center text-p-xs text-gray-700">
{{ serviceName }}
@@ -11,17 +12,21 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import LucideMail from "~icons/lucide/mail";
interface P {
serviceName?: string;
logo?: string;
selected?: boolean;
}
withDefaults(defineProps<P>(), {
const props = withDefaults(defineProps<P>(), {
serviceName: "",
selected: false,
logo: "",
});
const logoValue = computed(() => props.logo);
</script>
<style scoped></style>

View File

@@ -1,74 +1,80 @@
<template>
<div class="flex flex-col py-8 gap-8 overflow-y-hidden">
<SettingsLayoutHeader>
<template #title>
<div class="flex items-center gap-2 pl-10">
<Button
variant="ghost"
icon-left="chevron-left"
:label="dependencyLabel"
size="md"
@click="handleBackNavigation"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 pl-0 -ml-[5px] pr-0"
/>
<Badge v-if="isDirty" theme="orange"> Unsaved </Badge>
<SettingsLayoutBase>
<template #title>
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="dependencyLabel"
size="md"
@click="handleBackNavigation"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge v-if="isDirty" theme="orange"> {{ __("Unsaved") }} </Badge>
</div>
</template>
<template #header-actions>
<div class="flex gap-4">
<!-- Switch -->
<div class="flex gap-2 items-center">
<Switch v-model="state.enabled" class="!w-fit" />
<span class="text-p-base text-ink-gray-6">
{{ __("Enabled") }}
</span>
</div>
</template>
<template #actions>
<div class="flex gap-4 pr-10">
<!-- Switch -->
<div class="flex gap-2 items-center">
<Switch v-model="state.enabled" class="!w-fit" />
<span class="text-p-base text-ink-gray-6">Enabled</span>
</div>
<!-- Actions -->
<div class="flex gap-1">
<Button
label="Save"
variant="solid"
size="sm"
:disabled="
!state.selectedParentField ||
!state.selectedChildField ||
Object.keys(state.childSelections).length === 0
"
:loading="createUpdateFieldDependency.loading"
@click="handleSubmit"
<!-- Actions -->
<div class="flex gap-1">
<Button
:label="__('Save')"
variant="solid"
size="sm"
:disabled="
!state.selectedParentField ||
!state.selectedChildField ||
Object.keys(state.childSelections).length === 0
"
:loading="createUpdateFieldDependency.loading"
@click="handleSubmit"
/>
</div>
</div>
</template>
<template #content>
<div>
<!-- Body -->
<div class="w-full flex-1 flex flex-col gap-8">
<!-- Field Selection -->
<FieldDependencyFieldsSelection
v-model="state"
:is-new="isNew"
:parent-fields="parentFields"
/>
<div class="flex flex-col gap-8">
<!-- Value Selection -->
<FieldDependencyValueSelection
v-model="state"
:is-new="isNew"
:parent-fields="parentFields"
/>
<!-- Criteria selection -->
<FieldDependencyCriteria
:parent-field-values="state.parentFieldValues"
v-model="fieldCriteriaState"
v-model:selections="state"
/>
</div>
</div>
</template>
</SettingsLayoutHeader>
<!-- Body -->
<div class="w-full flex-1 flex flex-col gap-8 overflow-y-hidden px-10">
<!-- Field Selection -->
<FieldDependencyFieldsSelection
v-model="state"
:is-new="isNew"
:parent-fields="parentFields"
/>
<div class="flex flex-col gap-8 overflow-y-scroll">
<!-- Value Selection -->
<FieldDependencyValueSelection
v-model="state"
:is-new="isNew"
:parent-fields="parentFields"
/>
<!-- Criteria selection -->
<FieldDependencyCriteria
:parent-field-values="state.parentFieldValues"
v-model="fieldCriteriaState"
v-model:selections="state"
/>
</div>
</div>
</div>
</template>
</SettingsLayoutBase>
<ConfirmDialog
v-model="showConfirmDialog"
title="Unsaved changes"
message="Are you sure you want to go back? Unsaved changes will be lost."
:title="__('Unsaved changes')"
:message="
__('Are you sure you want to go back? Unsaved changes will be lost.')
"
:onConfirm="() => $emit('update:step', 'fd-list')"
:onCancel="() => (showConfirmDialog = false)"
/>
@@ -79,13 +85,14 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { getMeta } from "@/stores/meta";
import { getFieldDependencyLabel } from "@/utils";
import { createResource, Switch, toast } from "frappe-ui";
import { computed, reactive, ref, watch } from "vue";
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
import { computed, onUnmounted, reactive, ref, watch } from "vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import { getFieldOptions, hiddenChildFields } from "./fieldDependency";
import FieldDependencyCriteria from "./FieldDependencyCriteria.vue";
import FieldDependencyFieldsSelection from "./FieldDependencyFieldsSelection.vue";
import FieldDependencyValueSelection from "./FieldDependencyValueSelection.vue";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
const props = defineProps({
fieldDependencyName: {
@@ -98,7 +105,7 @@ const emit = defineEmits(["update:step"]);
const isNew = computed(() => !props.fieldDependencyName);
const dependencyLabel = computed(() => {
if (isNew.value) return "New Field Dependency";
if (isNew.value) return __("New Field Dependency");
return getFieldDependencyLabel(props.fieldDependencyName);
});
@@ -297,8 +304,8 @@ function handleSubmit() {
if (!isDirty.value) return;
createUpdateFieldDependency.submit();
let successMessage = isNew.value
? "Field Dependency created successfully"
: "Field Dependency updated successfully";
? __("Field Dependency created successfully")
: __("Field Dependency updated successfully");
toast.success(successMessage);
}
@@ -329,4 +336,8 @@ watch(
state.childSearch = ""; // Reset child search when parent selection changes
}
);
onUnmounted(() => {
disableSettingModalOutsideClick.value = false;
});
</script>

View File

@@ -28,5 +28,3 @@ function updateStep(
fieldDependencyName.value = fieldDependency;
}
</script>
<style scoped></style>

View File

@@ -1,136 +1,166 @@
<template>
<div class="px-10 py-8">
<SettingsLayoutHeader>
<template #title>
<div class="flex items-center gap-2">
<h1 class="text-lg font-semibold text-ink-gray-8">
Field Dependencies
</h1>
<DocumentationButton
url="https://docs.frappe.io/helpdesk/field-dependency"
color="!text-ink-gray-6"
<SettingsLayoutBase>
<template #title>
<div class="flex items-center gap-2">
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("Field Dependencies") }}
</h1>
<DocumentationButton
url="https://docs.frappe.io/helpdesk/field-dependency"
color="!text-ink-gray-6"
/>
</div>
</template>
<template #description>
<p class="text-p-sm max-w-md text-ink-gray-6">
{{
__(
"Create dependencies between fields to dynamically control options based on user selections."
)
}}
</p>
</template>
<template #header-actions>
<Button
:label="__('New')"
theme="gray"
variant="solid"
@click="$emit('update:step', 'fd')"
icon-left="plus"
/>
</template>
<template #content>
<div class="grow">
<!-- Loading State -->
<div
v-if="fieldDependenciesList.loading"
class="flex items-center justify-center py-4"
>
<LoadingIndicator :scale="5" />
</div>
<!-- Empty State -->
<div
v-if="
!fieldDependenciesList.loading &&
!fieldDependenciesList.data?.length
"
class="flex flex-col items-center justify-center gap-4 p-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<FieldDependencyIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No field dependency found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started") }}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="$emit('update:step', 'fd')"
/>
</div>
</template>
<template #description>
<p class="text-p-sm max-w-md text-ink-gray-6">
Create dependencies between fields to dynamically control options
based on user selections.
</p>
</template>
<template #actions>
<Button
label="New"
theme="gray"
variant="solid"
@click="$emit('update:step', 'fd')"
icon-left="plus"
/>
</template>
</SettingsLayoutHeader>
</div>
<div class="px-10 pb-8">
<!-- Loading State -->
<div
v-if="fieldDependenciesList.loading"
class="flex items-center justify-center py-4"
>
<LoadingIndicator :scale="5" />
</div>
<!-- Empty State -->
<div
v-if="
!fieldDependenciesList.loading &&
fieldDependenciesList.data?.length === 0
"
class="flex mt-28 justify-between w-full h-full"
>
<p class="text-sm text-gray-500 w-full flex justify-center">
No field dependencies found.
</p>
</div>
<div
class="w-full"
v-if="
!fieldDependenciesList.loading && fieldDependenciesList.data?.length > 0
"
>
<!-- table heading -->
<div class="flex w-full p-2">
<p class="w-7/12 text-p-sm text-ink-gray-5">Name</p>
<p class="w-3/12 text-p-sm text-ink-gray-5">Created By</p>
<p class="w-1/12 text-p-sm text-ink-gray-5">Enabled</p>
<p class="w-1/12 text-p-sm text-ink-gray-5"></p>
</div>
<div class="h-px border-t mx-1 border-outline-gray-modals" />
<!-- Table content -->
<ul>
<!-- Each row -->
<li
class="w-full flex flex-col items-center cursor-pointer hover:bg-surface-menu-bar rounded"
v-for="(row, idx) in fieldDependenciesList.data"
@click.stop="$emit('update:step', 'fd', row.name)"
:key="row.name"
<div
class="w-full -ml-2"
v-if="
!fieldDependenciesList.loading &&
fieldDependenciesList.data?.length > 0
"
>
<div class="w-full flex items-center p-2">
<!-- Parent to Child -->
<div class="flex gap-2 w-7/12 text-base text-ink-gray-7 pr-3">
<span>{{ getFieldDependencyLabel(row.name) }}</span>
<div>
<div
class="grid grid-cols-11 items-center gap-4 text-sm text-gray-600"
>
<div class="col-span-7 ml-2">{{ __("Name") }}</div>
<div class="col-span-2">{{ __("Created By") }}</div>
<div class="col-span-2">{{ __("Enabled") }}</div>
</div>
<!-- Owner -->
<p class="w-3/12 flex items-center gap-2 pr-3">
<Avatar size="sm" :image="row.owner" :label="row.owner" />
<span class="text-base text-ink-gray-7 truncate">{{
row.owner
}}</span>
</p>
<!-- Enabled -->
<p class="w-1/12 flex items-center">
<Switch
:model-value="row.enabled"
@update:modelValue="(e) => handleSwitchToggle(row.name, e)"
@click.stop
/>
</p>
<!-- More Options -->
<p class="w-1/12 flex items-center justify-end">
<Dropdown placement="right" :options="getOptions(row.name)">
<Button
variant="ghost"
@click.stop="
() => {
isConfirmingDelete = false;
}
"
<hr class="mt-2 mx-2" />
<div
v-for="(row, index) in fieldDependenciesList.data"
:key="row.name"
>
<div
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded h-12.5"
>
<div
@click.stop="$emit('update:step', 'fd', row.name)"
class="w-full py-3 pl-2 col-span-7 text-base text-ink-gray-7"
>
<template #icon>
<LucideMoreHorizontal class="h-4 w-4" />
</template>
</Button>
</Dropdown>
</p>
<span>{{ getFieldDependencyLabel(row.name) }}</span>
</div>
<div class="col-span-2 flex items-center gap-1">
<Avatar size="sm" :image="row.owner" :label="row.owner" />
<span class="text-base text-ink-gray-7 truncate">{{
row.owner
}}</span>
</div>
<div
class="flex justify-between items-center w-full pr-2 col-span-2"
>
<div>
<Switch
:model-value="row.enabled"
@update:modelValue="
(e) => handleSwitchToggle(row.name, e)
"
@click.stop
/>
</div>
<div>
<Dropdown placement="right" :options="getOptions(row.name)">
<Button
variant="ghost"
@click.stop="
() => {
isConfirmingDelete = false;
}
"
>
<template #icon>
<LucideMoreHorizontal class="h-4 w-4" />
</template>
</Button>
</Dropdown>
</div>
</div>
</div>
<hr
v-if="index !== fieldDependenciesList.data.length - 1"
class="mx-2"
/>
</div>
</div>
<!-- Separator -->
<div
class="h-px border-b mx-1 border-outline-gray-modals w-[99%]"
v-if="idx < fieldDependenciesList.data?.length - 1"
/>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import { Avatar, LoadingIndicator, Switch, toast } from "frappe-ui";
import {
Avatar,
Button,
Dropdown,
LoadingIndicator,
Switch,
toast,
} from "frappe-ui";
import { getFieldDependencyLabel, ConfirmDelete } from "@/utils";
import { onMounted, ref } from "vue";
import { fieldDependenciesList } from "./fieldDependency";
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
import DocumentationButton from "@/components/DocumentationButton.vue";
import FieldDependencyIcon from "@/components/icons/FieldDependencyIcon.vue";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
onMounted(() => {
fieldDependenciesList.reload();
@@ -144,7 +174,7 @@ function getOptions(rowName: string) {
onConfirmDelete: () => {
fieldDependenciesList.delete.submit(rowName, {
onSuccess: () => {
toast.success("Field dependency deleted successfully");
toast.success(__("Field dependency deleted successfully"));
fieldDependenciesList.reload();
},
});
@@ -160,7 +190,7 @@ function handleSwitchToggle(rowName: string, value: boolean) {
},
{
onSuccess: () => {
toast.success("Field dependency updated successfully.");
toast.success(__("Field dependency updated successfully."));
},
}
);

View File

@@ -5,9 +5,10 @@ export const fieldDependenciesList = createListResource({
doctype: "HD Form Script",
filters: { is_standard: 1, name: ["like", "%Field Dependency%"] },
fields: ["name", "enabled", "owner"],
auto: true,
cache: ["FD", "List"],
orderBy: "modified desc",
start: 0,
pageLength: 999,
});
export const hiddenChildFields = computed(() => {

View File

@@ -0,0 +1,255 @@
<template>
<SettingsLayoutBase :description="__('Manage general settings of your app.')">
<template #title>
<div class="flex items-center gap-2">
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("General") }}
</h1>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty || isWebsiteSettingsChanged"
/>
</div>
</template>
<template #header-actions>
<Button
:label="__('Save')"
variant="solid"
@click="saveSettings"
:loading="
saveSettingsResource.loading || saveWebsiteSettingsResource.loading
"
:disabled="!isDirty && !isWebsiteSettingsChanged"
/>
</template>
<template #content>
<div
v-if="settingsDataResource.loading && !settingsDataResource.data"
class="flex items-center justify-center mt-12"
>
<LoadingIndicator class="w-4" />
</div>
<div v-else>
<Branding />
<hr class="my-8" />
<TicketSettings />
<hr class="my-8" />
<WorkflowKnowledgebaseSettings />
<hr class="my-8" />
<div>
<div class="text-base font-semibold text-gray-900">
{{ __("User signup") }}
</div>
<div class="flex items-center justify-between mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Disable signup")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__(
"New users will have to be manually registered by system managers."
)
}}</span>
</div>
<Switch v-model="disableSignup" />
</div>
</div>
</div>
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import {
Badge,
Button,
createResource,
LoadingIndicator,
Switch,
toast,
} from "frappe-ui";
import Branding from "./components/Branding.vue";
import TicketSettings from "./components/TicketSettings.vue";
import WorkflowKnowledgebaseSettings from "./components/WorkflowKnowledgebaseSettings.vue";
import { computed, provide, ref, watch } from "vue";
import { __ } from "@/translation";
import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { HDSettings, HDSettingsSymbol } from "@/types";
const isDirty = ref(false);
const initialData = ref<null | string>(null);
const settingsData = ref({
brandName: "",
brandLogo: "",
favicon: "",
autoCloseAfterDays: "",
autoCloseStatus: "",
autoCloseTickets: "",
assignWithinTeam: false,
doNotRestrictTicketsWithoutAnAgentGroup: false,
restrictTicketsByAgentGroup: false,
updateStatusTo: "",
autoUpdateStatus: false,
isFeedbackMandatory: false,
enableCommentReactions: false,
allowAnyoneToCreateTickets: false,
defaultTicketType: "",
preferKnowledgeBase: false,
skipEmailWorkflow: false,
disableSavedRepliesGlobalScope: false,
enableOutsideHoursBanner: false,
outsideWorkingHoursBannerMessage: "",
});
const disableSignup = ref(false);
provide(HDSettingsSymbol, settingsData);
const settingsDataResource = createResource({
url: "frappe.client.get",
params: {
doctype: "HD Settings",
name: "HD Settings",
},
auto: true,
onSuccess(data: HDSettings) {
settingsData.value = transformData(data);
initialData.value = JSON.stringify(settingsData.value);
},
});
const isWebsiteSettingsChanged = computed(() => {
return (
disableSignup.value !==
Boolean(websiteSettingsResource.data?.disable_signup)
);
});
const saveSettingsResource = createResource({
url: "frappe.client.set_value",
makeParams() {
return {
doctype: "HD Settings",
name: "HD Settings",
fieldname: {
brand_name: settingsData.value.brandName,
auto_close_after_days: Number(settingsData.value.autoCloseAfterDays),
auto_close_status: settingsData.value.autoCloseStatus,
auto_close_tickets: settingsData.value.autoCloseTickets,
assign_within_team: settingsData.value.assignWithinTeam,
do_not_restrict_tickets_without_an_agent_group:
settingsData.value.doNotRestrictTicketsWithoutAnAgentGroup,
restrict_tickets_by_agent_group:
settingsData.value.restrictTicketsByAgentGroup,
update_status_to: settingsData.value.updateStatusTo,
auto_update_status: settingsData.value.autoUpdateStatus,
is_feedback_mandatory: settingsData.value.isFeedbackMandatory,
enable_comment_reactions: settingsData.value.enableCommentReactions,
allow_anyone_to_create_tickets:
settingsData.value.allowAnyoneToCreateTickets,
default_ticket_type: settingsData.value.defaultTicketType,
prefer_knowledge_base: settingsData.value.preferKnowledgeBase,
skip_email_workflow: settingsData.value.skipEmailWorkflow,
disable_saved_replies_global_scope:
settingsData.value.disableSavedRepliesGlobalScope,
enable_outside_hours_banner:
settingsData.value.enableOutsideHoursBanner,
outside_working_hours_message:
settingsData.value.outsideWorkingHoursBannerMessage,
},
};
},
onSuccess(data: HDSettings) {
settingsData.value = transformData(data);
initialData.value = JSON.stringify(settingsData.value);
},
});
const transformData = (data: any) => {
return {
brandName: data.brand_name,
brandLogo: data.brand_logo,
favicon: data.favicon,
autoCloseAfterDays: data.auto_close_after_days,
autoCloseStatus: data.auto_close_status,
autoCloseTickets: data.auto_close_tickets,
assignWithinTeam: Boolean(data.assign_within_team),
doNotRestrictTicketsWithoutAnAgentGroup: Boolean(
data.do_not_restrict_tickets_without_an_agent_group
),
restrictTicketsByAgentGroup: Boolean(data.restrict_tickets_by_agent_group),
updateStatusTo: data.update_status_to,
autoUpdateStatus: data.auto_update_status,
isFeedbackMandatory: Boolean(data.is_feedback_mandatory),
enableCommentReactions: Boolean(data.enable_comment_reactions),
allowAnyoneToCreateTickets: Boolean(data.allow_anyone_to_create_tickets),
defaultTicketType: data.default_ticket_type,
preferKnowledgeBase: Boolean(data.prefer_knowledge_base),
skipEmailWorkflow: Boolean(data.skip_email_workflow),
disableSavedRepliesGlobalScope: Boolean(
data.disable_saved_replies_global_scope
),
enableOutsideHoursBanner: Boolean(data.enable_outside_hours_banner),
outsideWorkingHoursBannerMessage: data.outside_working_hours_message || "",
};
};
const websiteSettingsResource = createResource({
url: "frappe.client.get",
params: {
doctype: "Website Settings",
name: "Website Settings",
fields: ["disable_signup"],
},
auto: true,
onSuccess(data: any) {
disableSignup.value = Boolean(data.disable_signup);
},
});
const saveWebsiteSettingsResource = createResource({
url: "frappe.client.set_value",
makeParams() {
return {
doctype: "Website Settings",
name: "Website Settings",
fieldname: {
disable_signup: disableSignup.value,
},
};
},
onSuccess() {
websiteSettingsResource.reload();
},
});
const saveSettings = async () => {
const promises = [];
if (isDirty.value) {
promises.push(saveSettingsResource.submit());
}
if (isWebsiteSettingsChanged.value) {
promises.push(saveWebsiteSettingsResource.submit());
}
await Promise.allSettled(promises).then(() => {
toast.success(__("Settings updated"));
});
};
watch(
settingsData,
(data) => {
if (!initialData.value) return;
isDirty.value = JSON.stringify(data) !== initialData.value;
if (isDirty.value) {
disableSettingModalOutsideClick.value = true;
} else {
disableSettingModalOutsideClick.value = false;
}
},
{ deep: true }
);
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div v-if="settingsData">
<div class="text-base font-semibold text-gray-900">
{{ __("Branding") }}
</div>
<FormControl
type="text"
class="w-full md:w-1/2 mt-6"
v-model="settingsData.brandName"
:label="__('Brand name')"
:placeholder="__('Enter brand name')"
maxlength="30"
/>
<LogoUpload
:title="__('Logo')"
:description="
__(
'Appears in the left sidebar. Recommended size is minimum 32x32 px in PNG or SVG'
)
"
:image="brandLogo"
@onUpload="update($event, 'HD Settings', 'brand_logo')"
@onRemove="onRemove('HD Settings', 'brand_logo')"
:isLoading="isLoading"
/>
<LogoUpload
:title="__('Favicon')"
:description="
__(
'Appears next to the title in your browser tab. Recommended size is minimum 32x32 px in PNG or ICO'
)
"
:image="favicon"
@onUpload="update($event, 'HD Settings', 'favicon')"
@onRemove="onRemove('HD Settings', 'favicon')"
:isLoading="isLoading"
/>
</div>
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
:message="showConfirmDialog.message"
:onConfirm="showConfirmDialog.onConfirm"
:onCancel="() => (showConfirmDialog.show = false)"
/>
</template>
<script setup lang="ts">
import { inject, ref, watch } from "vue";
import LogoUpload from "./LogoUpload.vue";
import { createResource, toast } from "frappe-ui";
import { __ } from "@/translation";
import { useConfigStore } from "@/stores/config";
import { HDSettingsSymbol } from "@/types";
const configStore = useConfigStore();
const settingsData = inject(HDSettingsSymbol);
const isLoading = ref(false);
const brandLogo = ref(settingsData.value?.brandLogo);
const favicon = ref(settingsData.value?.favicon);
const showConfirmDialog = ref({
show: false,
title: "",
message: "",
onConfirm: () => {},
});
const settingsResource = createResource({
url: "frappe.client.set_value",
onSuccess(data) {
brandLogo.value = data.brand_logo;
favicon.value = data.favicon;
isLoading.value = false;
configStore.configResource.reload();
toast.success(__("Updated successfully"));
},
onError() {
isLoading.value = false;
},
});
function update(file: string, doctype: string, fieldname: string) {
isLoading.value = true;
settingsResource.submit({
doctype: doctype,
name: doctype,
fieldname: fieldname,
value: file,
});
}
const onRemove = (doctype: string, fieldname: string) => {
showConfirmDialog.value = {
show: true,
title: __("Remove Logo"),
message: __("Are you sure you want to remove the logo?"),
onConfirm: () => {
update("", doctype, fieldname);
showConfirmDialog.value.show = false;
},
};
};
watch(settingsData, (newData) => {
brandLogo.value = newData.brandLogo;
favicon.value = newData.favicon;
});
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div
class="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4 w-full"
>
<div class="flex flex-col sm:flex-row items-center gap-3.5">
<div
class="flex items-center justify-center min-w-16 min-h-16 rounded-lg overflow-hidden border border-gray-100"
>
<Avatar
v-if="props.image"
size="3xl"
:image="props.image"
:label="props.title"
/>
<FeatherIcon v-else name="image" class="size-6 text-ink-gray-4" />
</div>
<div class="flex flex-col gap-1 max-w-sm items-start">
<span class="text-base font-medium text-ink-gray-8">{{ title }}</span>
<span class="text-p-sm text-ink-gray-6">{{ description }}</span>
</div>
</div>
<div>
<FileUploader
:fileTypes="['image/*']"
@success="
(file) => {
emit('onUpload', file.file_url);
}
"
>
<template v-slot="{ progress, uploading, openFileSelector }">
<div class="flex items-end space-x-2">
<Button
@click="openFileSelector"
:iconLeft="ImageUpIcon"
:label="
uploading
? __('Uploading {0}%', progress)
: props.image
? __('Change')
: __('Upload')
"
:loading="isLoading"
/>
<Button
v-if="props.image"
iconLeft="trash-2"
:label="__('Remove')"
@click="emit('onRemove')"
:loading="isLoading"
/>
</div>
</template>
</FileUploader>
</div>
</div>
</template>
<script setup lang="ts">
import { Avatar, Button, FeatherIcon, FileUploader } from "frappe-ui";
import ImageUpIcon from "~icons/lucide/image-up";
const emit = defineEmits(["onUpload", "onRemove"]);
const props = defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
image: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
});
</script>

View File

@@ -0,0 +1,349 @@
<template>
<div>
<div class="text-base font-semibold text-gray-900">
{{ __("Ticket settings") }}
</div>
<div class="mt-6 flex flex-col gap-6">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Make feedback mandatory")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__(
"The feedback dialog will be shown, when a user tries to close a ticket from the customer portal."
)
}}</span>
</div>
<Switch v-model="settingsData.isFeedbackMandatory" />
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Enable comment reactions")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Allow users to react to comments with emojis")
}}</span>
</div>
<Switch v-model="settingsData.enableCommentReactions" />
</div>
<div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Restrict tickets by team")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__(
"Restrict tickets to be viewed and managed by team members only."
)
}}</span>
</div>
<Switch v-model="settingsData.restrictTicketsByAgentGroup" />
</div>
<div
class="grid grid-cols-2 gap-4 mt-3"
v-if="settingsData.restrictTicketsByAgentGroup"
>
<Checkbox
size="sm"
v-model="settingsData.doNotRestrictTicketsWithoutAnAgentGroup"
:label="__('Do not restrict tickets without a team')"
/>
<Checkbox
size="sm"
v-model="settingsData.assignWithinTeam"
:label="__('Restrict agent assignment to selected team')"
/>
</div>
</div>
<div
class="flex items-center justify-between"
v-if="settingsData.restrictTicketsByAgentGroup"
>
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Disable global saved replies")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__(
"Agents will no longer be able to view and create saved replies with global scope."
)
}}</span>
</div>
<Switch v-model="settingsData.disableSavedRepliesGlobalScope" />
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Auto update status")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__(
"The ticket status will automatically change whenever the agent respond to a ticket."
)
}}</span>
</div>
<SelectDropdown
:options="autoUpdateTicketStatusList"
:model-value="settingsData.updateStatusTo"
@update:model-value="
(value) => {
if (value) {
settingsData.updateStatusTo = value;
settingsData.autoUpdateStatus = true;
} else {
settingsData.updateStatusTo = null;
settingsData.autoUpdateStatus = false;
}
}
"
target-class="max-w-40"
placement="bottom-start"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Allow anyone to create tickets")
}}</span>
<span class="text-p-sm text-ink-gray-6"
>{{
__(
"Anyone will be able to create tickets without any permission. e.g. from webform."
)
}}
</span>
</div>
<Switch
:model-value="settingsData.allowAnyoneToCreateTickets"
@update:model-value="
(value) => (settingsData.allowAnyoneToCreateTickets = value)
"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Default ticket type")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Select what type all tickets get by default.")
}}</span>
</div>
<SelectDropdown
:options="ticketTypeList.data"
v-model="settingsData.defaultTicketType"
target-class="max-w-40"
placement="bottom-start"
/>
</div>
<div>
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Automatically close stale tickets")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__(
"Auto-close tickets that remain in a status for the specified number of days."
)
}}</span>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Ticket status')" />
<SelectDropdown
:options="autoCloseTicketStatusList"
:model-value="settingsData.autoCloseStatus"
@update:model-value="
(value) => {
if (value) {
settingsData.autoCloseStatus = value;
settingsData.autoCloseTickets = true;
} else {
settingsData.autoCloseStatus = null;
settingsData.autoCloseTickets = false;
}
}
"
target-class="w-full"
placement="bottom-start"
/>
</div>
<div class="flex flex-col gap-1.5">
<FormControl
:label="__('Auto-close after (Days)')"
placeholder="e.g. 30"
v-model="settingsData.autoCloseAfterDays"
type="number"
:debounce="300"
:disabled="!settingsData.autoCloseStatus"
/>
<ErrorMessage
:message="
settingsData.autoCloseStatus &&
settingsData.autoCloseAfterDays < 1
? __('The number of days must be 1 or more')
: ''
"
/>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Outside working hours notice")
}}</span>
<span class="text-p-sm text-ink-gray-6"
>{{
__(
"Display a customizable banner message when customers raise tickets outside your working hours."
)
}}
</span>
</div>
<Switch
:model-value="settingsData.enableOutsideHoursBanner"
@update:model-value="handleShowBannerToggle"
/>
</div>
<Textarea
v-if="settingsData.enableOutsideHoursBanner"
variant="subtle"
size="sm"
placeholder="Enter Notification Message"
:required="true"
v-model="settingsData.outsideWorkingHoursBannerMessage"
/>
</div>
<div
v-if="settingsData.enableOutsideHoursBanner"
class="flex gap-x-1 items-start justify-between"
>
<p class="text-sm text-gray-700 leading-5">
{{
__(
"Find out all of the variables that can be used in the content"
)
}}
<a
href="https://docs.frappe.io/helpdesk/helpdesk/customization/outside-working-hours-banner"
target="_blank"
class="underline font-semibold"
>{{ __("here") }}</a
>
</p>
<Button
type="button"
size="sm"
variant="subtle"
class="w-fit"
:disabled="!hasBannerMessageChanged"
@click="resetBannerContent"
:tooltip="
hasBannerMessageChanged &&
__('This will reset the content to the default message.')
"
>
{{ __("Reset Content") }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SelectDropdown from "@/components/SelectDropdown.vue";
import { useTicketStatusStore } from "@/stores/ticketStatus";
import { HDSettingsSymbol } from "@/types";
import { HDTicketStatus } from "@/types/doctypes";
import {
Checkbox,
createListResource,
createResource,
ErrorMessage,
FormControl,
FormLabel,
Switch,
Textarea,
} from "frappe-ui";
import { computed, inject } from "vue";
const settingsData = inject(HDSettingsSymbol);
const { statuses } = useTicketStatusStore();
const bannerMsg = createResource({
url: "helpdesk.helpdesk.doctype.hd_settings.helpers.get_banner_msg",
auto: true,
});
const hasBannerMessageChanged = computed(() => {
return (
settingsData.value.outsideWorkingHoursBannerMessage !==
bannerMsg.data?.default
);
});
function resetBannerContent() {
settingsData.value.outsideWorkingHoursBannerMessage = bannerMsg.data?.default;
}
function handleShowBannerToggle(value: boolean) {
if (!bannerMsg.data.current) {
if (value) {
settingsData.value.outsideWorkingHoursBannerMessage =
bannerMsg.data?.default;
} else {
settingsData.value.outsideWorkingHoursBannerMessage = "";
}
}
settingsData.value.enableOutsideHoursBanner = value;
}
const ticketTypeList = createListResource({
doctype: "HD Ticket Type",
name: "HD Ticket Type",
auto: true,
transform: (data) => {
return data.map((item) => {
return {
label: item.name,
value: item.name,
};
});
},
});
const autoUpdateTicketStatusList = computed(() => {
return (
statuses.data?.map((s: HDTicketStatus) => {
return {
label: s.label_agent,
value: s.label_agent,
};
}) || []
);
});
const autoCloseTicketStatusList = computed(() => {
return (
statuses.data
?.filter(
(s: HDTicketStatus) =>
s.category === "Resolved" || s.category === "Paused"
)
?.map((s: HDTicketStatus) => {
return {
label: s.label_agent,
value: s.label_agent,
};
}) || []
);
});
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div>
<div class="text-base font-semibold text-gray-900">
{{ __("Workflow & knowledge base settings") }}
</div>
<div class="mt-6 flex flex-col gap-6">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Prefer knowledge base")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Guide users to articles before tickets.")
}}</span>
</div>
<Switch
:model-value="settingsData.preferKnowledgeBase"
@update:model-value="
(value) => (settingsData.preferKnowledgeBase = value)
"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">{{
__("Skip email workflow")
}}</span>
<span class="text-p-sm text-ink-gray-6">{{
__(
"Disables all email notifications for the ticket - including creation, updates, and alerts."
)
}}</span>
</div>
<Switch
:model-value="settingsData.skipEmailWorkflow"
@update:model-value="
(value) => (settingsData.skipEmailWorkflow = value)
"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { HDSettingsSymbol } from "@/types";
import { Switch } from "frappe-ui";
import { inject } from "vue";
const settingsData = inject(HDSettingsSymbol);
</script>

View File

@@ -153,12 +153,11 @@
</template>
<script setup lang="ts">
import { getFormattedDate, htmlToText } from "@/utils";
import { Popover } from "frappe-ui";
import { useDatePicker } from "frappe-ui/src/components/DatePicker/useDatePicker";
import { ref, watch } from "vue";
import { holidayData } from "@/stores/holidayList";
import { getFormattedDate, htmlToText } from "@/utils";
import dayjs from "dayjs";
import { Popover, useDatePicker } from "frappe-ui";
import { ref, watch } from "vue";
import AddHolidayModal from "./Modals/AddHolidayModal.vue";
const dialog = ref({

View File

@@ -1,8 +1,6 @@
<template>
<div class="pb-8">
<Holidays v-if="holidayListActiveScreen.screen == 'list'" />
<HolidayView v-else-if="holidayListActiveScreen.screen == 'view'" />
</div>
<Holidays v-if="holidayListActiveScreen.screen == 'list'" />
<HolidayView v-else-if="holidayListActiveScreen.screen == 'view'" />
</template>
<script setup lang="ts">
@@ -10,16 +8,25 @@ import { holidayListActiveScreen } from "@/stores/holidayList";
import Holidays from "./Holidays.vue";
import HolidayView from "./HolidayView.vue";
import { createListResource } from "frappe-ui";
import { provide } from "vue";
import { onUnmounted, provide, ref } from "vue";
import { HolidayListResourceSymbol } from "@/types";
const holidayListData = createListResource({
doctype: "HD Service Holiday List",
fields: ["name", "description"],
orderBy: "creation desc",
cache: ["HolidayList"],
orderBy: "modified desc",
start: 0,
pageLength: 999,
auto: true,
});
const holidaySearchRef = ref("");
provide("holidayList", holidayListData);
provide("holidaySearchRef", holidaySearchRef);
provide(HolidayListResourceSymbol, holidayListData);
onUnmounted(() => {
holidaySearchRef.value = "";
holidayListData.filters = {};
});
</script>

View File

@@ -5,22 +5,43 @@
>
<LoadingIndicator class="w-4" />
</div>
<div v-else>
<div v-else class="-ml-2 grow">
<div
v-if="holidayList.list.data?.length === 0"
class="flex items-center justify-center rounded-md border border-gray-200 p-4"
v-if="!holidayList.list.loading && !holidayList.list.data?.length"
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div class="text-sm text-ink-gray-7">No items in the list</div>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<Briefcase class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No Holiday list found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="goToNew()"
/>
</div>
<div v-else>
<div class="flex text-sm text-gray-600">
<div class="ml-2">Schedule name</div>
<div class="ml-2">{{ __("Schedule name") }}</div>
</div>
<hr class="mx-2 mt-2" />
<div>
<div v-for="holiday in holidayList.list.data" :key="holiday.name">
<div
v-for="(holiday, index) in holidayList.list.data"
:key="holiday.name"
>
<HolidayListItem :data="holiday" />
<hr class="mx-2" />
<hr v-if="index !== holidayList.list.data.length - 1" class="mx-2" />
</div>
</div>
</div>
@@ -29,9 +50,23 @@
<script setup lang="ts">
import HolidayListItem from "./HolidayListItem.vue";
import { LoadingIndicator } from "frappe-ui";
import { Button, LoadingIndicator } from "frappe-ui";
import Briefcase from "~icons/lucide/briefcase";
import { inject } from "vue";
import {
holidayListActiveScreen,
resetHolidayData,
} from "@/stores/holidayList";
import { HolidayListResourceSymbol } from "@/types";
const holidayList = inject<any>("holidayList");
const holidayList = inject(HolidayListResourceSymbol);
const goToNew = () => {
resetHolidayData();
holidayListActiveScreen.value = {
screen: "view",
data: null,
};
};
</script>

View File

@@ -1,13 +1,13 @@
<template>
<div class="flex items-center cursor-pointer hover:bg-gray-50 rounded">
<div
class="w-full py-3 pl-2"
class="w-full pl-2 flex flex-col justify-center h-14"
@click="holidayListActiveScreen = { screen: 'view', data: data }"
>
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
<div
v-if="data.description && data.description.length > 0"
class="text-sm text-ink-gray-5 mt-1 whitespace-nowrap overflow-ellipsis overflow-hidden"
class="text-sm text-ink-gray-5 mt-1 truncate"
>
{{ data.description }}
</div>
@@ -25,15 +25,16 @@
</div>
</div>
<Dialog
:options="{ title: `Duplicate Holiday List` }"
:options="{ title: __('Duplicate Holiday List') }"
v-model="duplicateDialog.show"
>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
label="New Holiday List Name"
:label="__('New Holiday List Name')"
type="text"
v-model="duplicateDialog.name"
v-model="duplicateDialog.newName"
maxlength="100"
/>
</div>
</template>
@@ -41,19 +42,29 @@
<div class="flex gap-2 justify-end">
<Button
variant="subtle"
label="Close"
:label="__('Close')"
@click="duplicateDialog.show = false"
/>
<Button variant="solid" label="Duplicate" @click="duplicate()" />
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, createResource, toast } from "frappe-ui";
import {
Button,
Dialog,
createResource,
Dropdown,
FormControl,
toast,
} from "frappe-ui";
import { inject, ref } from "vue";
import { holidayListActiveScreen } from "@/stores/holidayList";
import { ConfirmDelete } from "@/utils";
import { __ } from "@/translation";
import { HolidayListResourceSymbol } from "@/types";
import { HDServiceHolidayList } from "@/types/doctypes";
const props = defineProps({
data: {
@@ -62,10 +73,11 @@ const props = defineProps({
},
});
const holidayList = inject<any>("holidayList");
const holidayList = inject(HolidayListResourceSymbol);
const duplicateDialog = ref({
show: false,
newName: "",
name: "",
});
@@ -73,10 +85,13 @@ const isConfirmingDelete = ref(false);
const dropdownOptions = [
{
label: "Duplicate",
label: __("Duplicate"),
onClick: () => {
duplicateDialog.value.show = true;
duplicateDialog.value.name = props.data.name + " (Copy)";
duplicateDialog.value = {
show: true,
newName: props.data.name + " (Copy)",
name: props.data.name,
};
},
icon: "copy",
},
@@ -88,24 +103,38 @@ const dropdownOptions = [
const duplicate = () => {
createResource({
url: "helpdesk.api.holiday_list.duplicate_holiday_list",
url: "frappe.client.get",
params: {
docname: props.data.name,
new_name: duplicateDialog.value.name,
doctype: "HD Service Holiday List",
name: duplicateDialog.value.name,
},
onSuccess: (data) => {
holidayList.reload();
toast.success("Holiday list duplicated");
duplicateDialog.value = {
show: false,
name: "",
};
setTimeout(() => {
holidayListActiveScreen.value = {
screen: "view",
data: data,
};
}, 250);
onSuccess: (data: HDServiceHolidayList) => {
createResource({
url: "frappe.client.insert",
params: {
doc: {
...data,
holiday_list_name: duplicateDialog.value.newName,
name: duplicateDialog.value.newName,
},
},
auto: true,
onSuccess(newHolidayListData: HDServiceHolidayList) {
holidayList?.reload();
toast.success(__("Holiday list duplicated"));
duplicateDialog.value = {
show: false,
newName: "",
name: "",
};
setTimeout(() => {
holidayListActiveScreen.value = {
screen: "view",
data: newHolidayListData,
};
}, 250);
},
});
},
auto: true,
});
@@ -117,9 +146,9 @@ const deleteHolidayList = () => {
return;
}
holidayList.delete.submit(props.data.name, {
holidayList?.delete.submit(props.data.name, {
onSuccess: () => {
toast.success("Holiday list deleted");
toast.success(__("Holiday list deleted"));
},
});
};

View File

@@ -1,205 +1,213 @@
<template>
<div
v-if="holidayData.loading"
class="flex items-center h-full justify-center"
>
<LoadingIndicator class="w-4" />
</div>
<div
v-if="!holidayData.loading"
class="flex items-center justify-between sticky top-0 z-10 bg-white px-10 pt-8 pb-4"
>
<div>
<SettingsLayoutBase>
<template #title>
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="holidayData?.holiday_list_name || 'New Business Holiday'"
:label="holidayData?.holiday_list_name || __('New Business Holiday')"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-xl hover:opacity-70 !pr-0"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge
:variant="'subtle'"
:theme="'orange'"
variant="subtle"
theme="orange"
size="sm"
label="Unsaved changes"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div>
</div>
<div class="flex gap-2 items-center">
</template>
<template #header-actions>
<Button
label="Save"
:label="__('Save')"
theme="gray"
variant="solid"
@click="saveHoliday()"
:disabled="Boolean(!isDirty && holidayListActiveScreen.data)"
:loading="
holidayList.loading ||
updateHolidayResource.loading ||
holidayList?.list.loading ||
holidayList?.setValue.loading ||
renameHolidayResource.loading ||
getHolidayData.loading
"
/>
</div>
</div>
<div v-if="!holidayData.loading" class="px-10 pb-8 overflow-y-scroll h-full">
<div class="flex items-center gap-2 mt-2">
<span class="text-sm">
There are in total <b>{{ holidayData.holidays.length }}</b> holidays in
this list</span
>
</div>
<hr class="mb-8 mt-2" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<FormControl
:type="'text'"
size="sm"
variant="subtle"
placeholder="Name"
label="Name"
v-model="holidayData.holiday_list_name"
required
@change="validateHoliday('holiday_list_name')"
/>
<ErrorMessage
:message="holidayDataErrors.holiday_list_name"
class="mt-2"
/>
</div>
<FormControl
:type="'textarea'"
size="sm"
variant="subtle"
placeholder="Description"
label="Description"
v-model="holidayData.description"
/>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">Valid from</span>
<span class="text-p-sm text-ink-gray-6">
Choose the duration of this holiday list.
</span>
</div>
<div class="mt-3.5 flex gap-5 flex-col md:flex-row">
<div class="w-full space-y-1.5">
<FormLabel label="From date" for="from_date" required />
<DatePicker
v-model="holidayData.from_date"
variant="subtle"
placeholder="11/01/2025"
class="w-full"
id="from_date"
:formatter="(date) => getFormattedDate(date)"
:debounce="300"
@update:model-value="updateDuration('from_date')"
</template>
<template #content>
<div v-if="!holidayData.loading" class="h-full">
<div class="flex items-center gap-2 mt-2">
<span class="text-sm">
There are in total <b>{{ holidayData.holidays.length }}</b> holidays
in this list</span
>
<template #prefix>
<LucideCalendar class="size-4" />
</template>
</DatePicker>
<ErrorMessage
:message="
holidayDataErrors.from_date || holidayDataErrors.dateRange
"
</div>
<hr class="mb-8 mt-2" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<FormControl
type="text"
size="sm"
variant="subtle"
:placeholder="__('Name')"
:label="__('Name')"
v-model="holidayData.holiday_list_name"
required
maxlength="50"
@change="validateHoliday('holiday_list_name')"
/>
<ErrorMessage
:message="holidayDataErrors.holiday_list_name"
class="mt-2"
/>
</div>
<FormControl
type="textarea"
size="sm"
variant="subtle"
:placeholder="__('Description')"
:label="__('Description')"
v-model="holidayData.description"
maxlength="140"
/>
</div>
<div class="w-full space-y-1.5">
<FormLabel label="To date" for="to_date" required />
<DatePicker
v-model="holidayData.to_date"
variant="subtle"
placeholder="25/12/2025"
class="w-full"
id="to_date"
:formatter="(date) => getFormattedDate(date)"
:debounce="300"
@update:model-value="updateDuration('to_date')"
>
<template #prefix>
<LucideCalendar class="size-4" />
</template>
</DatePicker>
<ErrorMessage :message="holidayDataErrors.to_date" />
</div>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<div class="text-lg font-semibold text-ink-gray-8">
Recurring holidays
</div>
<div class="text-p-sm text-ink-gray-6">
Add recurring holidays such as weekends.
</div>
</div>
<div class="mt-5">
<RecurringHolidaysList
:holidayData="holidayData"
:holidays="holidayData.recurring_holidays"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex justify-between items-center">
<div class="flex justify-between flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">Holidays</span>
<div class="text-p-sm text-ink-gray-6">
Add holidays here to make sure they’re excluded from SLA
calculations.
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Valid from")
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{ __("Choose the duration of this holiday list.") }}
</span>
</div>
<div class="mt-3.5 flex gap-5 flex-col md:flex-row">
<div class="w-full space-y-1.5">
<FormLabel :label="__('From date')" for="from_date" required />
<DatePicker
v-model="holidayData.from_date"
variant="subtle"
placeholder="11/01/2025"
class="w-full"
id="from_date"
:formatter="(date) => getFormattedDate(date)"
:debounce="300"
@update:model-value="updateDuration('from_date')"
>
<template #prefix>
<LucideCalendar class="size-4" />
</template>
</DatePicker>
<ErrorMessage
:message="
holidayDataErrors.from_date || holidayDataErrors.dateRange
"
/>
</div>
<div class="w-full space-y-1.5">
<FormLabel :label="__('To date')" for="to_date" required />
<DatePicker
v-model="holidayData.to_date"
variant="subtle"
placeholder="25/12/2025"
class="w-full"
id="to_date"
:formatter="(date) => getFormattedDate(date)"
:debounce="300"
@update:model-value="updateDuration('to_date')"
>
<template #prefix>
<LucideCalendar class="size-4" />
</template>
</DatePicker>
<ErrorMessage :message="holidayDataErrors.to_date" />
</div>
</div>
</div>
<TabButtons
:buttons="[
{
value: 'calendar',
icon: 'calendar',
},
{
value: 'list',
icon: 'list',
},
]"
v-model="holidayListView"
/>
</div>
<div class="mt-5">
<HolidaysTableView v-if="holidayListView === 'list'" />
<HolidaysCalendarView v-else />
</div>
<div class="mt-2.5 flex justify-between items-center">
<Button
variant="subtle"
label="Add Holiday"
@click="dialog.show = true"
icon-left="plus"
/>
<!-- Indicators -->
<div class="flex gap-4" v-if="holidayListView === 'calendar'">
<div class="gap-1 flex items-center">
<span class="bg-yellow-100 size-4 rounded-sm" />
<span class="text-sm text-ink-gray-6">Holidays</span>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<div class="text-lg font-semibold text-ink-gray-8">
{{ __("Recurring holidays") }}
</div>
<div class="text-p-sm text-ink-gray-6">
{{ __("Add recurring holidays such as weekends.") }}
</div>
</div>
<div class="gap-1 flex items-center">
<span class="bg-gray-100 size-4 rounded-sm" />
<span class="text-sm text-ink-gray-6">Recurring holidays</span>
<div class="mt-5">
<RecurringHolidaysList
:holidayData="holidayData"
:holidays="holidayData.recurring_holidays"
/>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex justify-between items-center">
<div class="flex justify-between flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">
{{ __("Holidays") }}
</span>
<div class="text-p-sm text-ink-gray-6">
{{
__(
"Add holidays here to make sure they’re excluded from SLA calculations."
)
}}
</div>
</div>
<TabButtons
:buttons="[
{
value: 'calendar',
icon: 'calendar',
},
{
value: 'list',
icon: 'list',
},
]"
v-model="holidayListView"
/>
</div>
<div class="mt-5">
<HolidaysTableView v-if="holidayListView === 'list'" />
<HolidaysCalendarView v-else />
</div>
<div class="mt-2.5 flex justify-between items-center">
<Button
variant="subtle"
:label="__('Add Holiday')"
@click="dialog.show = true"
icon-left="plus"
/>
<!-- Indicators -->
<div class="flex gap-4" v-if="holidayListView === 'calendar'">
<div class="gap-1 flex items-center">
<span class="bg-yellow-100 size-4 rounded-sm" />
<span class="text-sm text-ink-gray-6">{{
__("Holidays")
}}</span>
</div>
<div class="gap-1 flex items-center">
<span class="bg-gray-100 size-4 rounded-sm" />
<span class="text-sm text-ink-gray-6">{{
__("Recurring holidays")
}}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</SettingsLayoutBase>
<AddHolidayModal v-model="dialog" />
<ConfirmDialog
v-model="showConfirmDialog"
title="Unsaved changes"
message="Are you sure you want to go back? Unsaved changes will be lost."
:title="__('Unsaved changes')"
:message="
__('Are you sure you want to go back? Unsaved changes will be lost.')
"
:onConfirm="goBack"
:onCancel="() => (showConfirmDialog = false)"
/>
@@ -230,13 +238,17 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { slaActiveScreen } from "@/stores/sla";
import { getFormattedDate, htmlToText } from "@/utils";
import dayjs from "dayjs";
import FormLabel from "frappe-ui/src/components/FormLabel.vue";
import { FormLabel } from "frappe-ui";
import {
disableSettingModalOutsideClick,
setActiveSettingsTab,
} from "../settingsModal";
import HolidaysCalendarView from "./HolidaysCalendarView.vue";
import AddHolidayModal from "./Modals/AddHolidayModal.vue";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { HolidayListResourceSymbol } from "@/types";
import { HDServiceHolidayList } from "@/types/doctypes";
const dialog = ref({
show: false,
@@ -251,18 +263,19 @@ const holidayListView = ref("calendar");
const showConfirmDialog = ref(false);
const holidayList = inject<any>("holidayList");
const holidayList = inject(HolidayListResourceSymbol);
const getHolidayData = createResource({
url: "helpdesk.api.holiday_list.get_holiday_list",
url: "frappe.client.get",
params: {
docname: holidayListActiveScreen.value.data?.name,
doctype: "HD Service Holiday List",
name: holidayListActiveScreen.value.data?.name,
},
onSuccess(data) {
onSuccess(data: HDServiceHolidayList) {
holidayData.value = data;
initialData.value = JSON.stringify(data);
},
transform(data) {
transform(data: HDServiceHolidayList) {
for (let holiday of data.holidays) {
holiday.description = htmlToText(holiday.description);
}
@@ -271,11 +284,22 @@ const getHolidayData = createResource({
},
});
const renameHolidayResource = createResource({
url: "frappe.client.rename_doc",
makeParams() {
return {
doctype: "HD Service Holiday List",
old_name: holidayData.value.name,
new_name: holidayData.value.holiday_list_name,
};
},
});
if (holidayListActiveScreen.value.data?.name) {
holidayData.value.loading = true;
getHolidayData.fetch();
} else {
disableSettingModalOutsideClick.value = true;
initialData.value = JSON.stringify(holidayData.value);
}
const updateDuration = (key) => {
@@ -293,10 +317,6 @@ const goBack = () => {
showConfirmDialog.value = true;
return;
}
if (!holidayListActiveScreen.value.data && !showConfirmDialog.value) {
showConfirmDialog.value = true;
return;
}
if (holidayListActiveScreen.value.previousScreen) {
setActiveSettingsTab("SLA Policies");
@@ -320,7 +340,7 @@ const saveHoliday = () => {
const validationErrors = validateHoliday();
if (Object.values(validationErrors).some((error) => error)) {
toast.error(
"Invalid fields, check if all are filled in and values are correct."
__("Invalid fields, check if all are filled in and values are correct.")
);
return;
}
@@ -339,7 +359,7 @@ const createHoliday = () => {
holiday_date: dayjs(holiday.holiday_date).format("YYYY-MM-DD"),
};
});
holidayList.insert.submit(
holidayList?.insert.submit(
{
holiday_list_name: holidayData.value.holiday_list_name,
description: holidayData.value.description,
@@ -350,29 +370,19 @@ const createHoliday = () => {
},
{
onSuccess(data) {
toast.success("Holiday list created");
toast.success(__("Holiday list created"));
holidayListActiveScreen.value.data = data;
holidayListActiveScreen.value.screen = "view";
getHolidayData.submit({
docname: data.name,
doctype: "HD Service Holiday List",
name: data.name,
});
},
}
);
};
const updateHolidayResource = createResource({
url: "helpdesk.api.holiday_list.update_holiday_list",
onSuccess(data) {
holidayListActiveScreen.value.data = data;
getHolidayData.submit({
docname: data.name,
});
toast.success("Holiday list updated");
},
});
const updateHoliday = () => {
const updateHoliday = async () => {
const holidays = holidayData.value.holidays.map((holiday) => {
return {
...holiday,
@@ -380,9 +390,9 @@ const updateHoliday = () => {
};
});
updateHolidayResource.submit({
docname: holidayListActiveScreen.value.data.name,
doc: {
await holidayList?.setValue.submit(
{
name: holidayListActiveScreen.value?.data?.name,
holiday_list_name: holidayData.value.holiday_list_name,
description: holidayData.value.description,
from_date: holidayData.value.from_date,
@@ -390,7 +400,40 @@ const updateHoliday = () => {
holidays: holidays,
recurring_holidays: JSON.stringify(holidayData.value.recurring_holidays),
},
});
{
onError(err) {
const message = err?.messages?.[0];
toast.error(
message || __("Some error occurred while updating holiday list")
);
},
}
);
if (holidayData.value.name !== holidayData.value.holiday_list_name) {
await renameHolidayResource.submit().catch(async (err) => {
const error =
err?.messages?.[0] ||
__("Some error occurred while renaming holiday list");
toast.error(error);
// Reset holiday data to previous state
await getHolidayData.reload();
});
// Update the active screen data to reflect the new name
holidayListActiveScreen.value.data.name =
holidayData.value.holiday_list_name;
getHolidayData.submit({
doctype: "HD Service Holiday List",
name: holidayData.value.holiday_list_name,
});
} else {
await getHolidayData.reload();
}
toast.success(__("Holiday list updated"));
holidayList?.reload();
};
watch(

View File

@@ -1,23 +1,49 @@
<template>
<div class="px-10 py-8">
<SettingsLayoutHeader
title="Business Holidays"
description="Set your team’s working days, hours, and holidays using a template or custom schedule."
<SettingsLayoutBase
:title="__('Business Holidays')"
:description="
__(
'Set your team’s working days, hours, and holidays using a template or custom schedule.'
)
"
>
<template #header-actions>
<Button
:label="__('New')"
theme="gray"
variant="solid"
@click="goToNew()"
icon-left="plus"
/>
</template>
<template
v-if="holidayList.data?.length > 9 || holidaySearchRef.length"
#header-bottom
>
<template #actions>
<Button
label="New"
theme="gray"
variant="solid"
@click="goToNew()"
icon-left="plus"
<div class="relative">
<Input
:model-value="holidaySearchRef"
@input="holidaySearchRef = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
</template>
</SettingsLayoutHeader>
</div>
<div class="overflow-y-auto pb-8 px-10">
<HolidayList />
</div>
<Button
v-if="holidaySearchRef"
icon="x"
variant="ghost"
@click="holidaySearchRef = ''"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
</template>
<template #content>
<HolidayList />
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
@@ -26,7 +52,12 @@ import {
resetHolidayData,
} from "@/stores/holidayList";
import HolidayList from "./HolidayList.vue";
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
import { inject, Ref, ref, watch } from "vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { HolidayListResourceSymbol } from "@/types";
const holidayList = inject(HolidayListResourceSymbol);
const holidaySearchRef = inject<Ref<string>>("holidaySearchRef");
const goToNew = () => {
resetHolidayData();
@@ -35,4 +66,15 @@ const goToNew = () => {
data: null,
};
};
watch(holidaySearchRef, (newValue) => {
holidayList.filters = {
name: ["like", `%${newValue}%`],
};
if (!newValue) {
holidayList.start = 0;
holidayList.pageLength = 10;
}
holidayList.reload();
});
</script>

View File

@@ -61,6 +61,7 @@ import dayjs from "dayjs";
import { ConfirmDelete, getFormattedDate } from "@/utils";
import { holidayData } from "@/stores/holidayList";
import AddHolidayModal from "./Modals/AddHolidayModal.vue";
import { Dropdown } from "frappe-ui";
const isConfirmingDelete = ref(false);

View File

@@ -141,7 +141,7 @@ import { ConfirmDelete } from "@/utils";
import dayjs from "dayjs";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import weekday from "dayjs/plugin/weekday";
import { Checkbox, FormLabel, Select, toast } from "frappe-ui";
import { Checkbox, Dropdown, FormLabel, Select, toast } from "frappe-ui";
import { computed, ref } from "vue";
import { getRepetitionText } from "./utils";

View File

@@ -1,98 +1,99 @@
<template>
<div class="px-10 pb-8 h-full overflow-y-auto">
<form @submit.prevent="onSubmit" class="flex flex-col gap-5">
<div class="sticky top-0 z-10 bg-white pt-8 pb-1">
<SettingsLayoutHeader
title="Invite Agents"
description="Easily invite new agents, managers, or admins"
<SettingsLayoutBase
:title="__('Invite Agents')"
:description="__('Easily invite new agents, managers, or admins.')"
>
<template #content>
<form @submit.prevent="onSubmit" class="flex flex-col gap-5">
<FormControl
type="textarea"
:required="true"
:label="__('Invite by email')"
placeholder="user1@example.com, user2@example.com, ..."
v-model="emails"
:debounce="100"
:description="__('Comma separated emails to invite')"
/>
</div>
<FormControl
type="textarea"
:required="true"
label="Invite by email"
placeholder="user1@example.com, user2@example.com, ..."
v-model="emails"
:debounce="100"
description="Comma separated emails to invite"
/>
<FormControl
label="Role"
type="select"
:required="true"
:options="roleOptions"
v-model="role"
:description="roleDescription"
/>
<Button
type="submit"
variant="solid"
class="w-fit mt-1"
:disabled="cancelInviteResource.loading"
:loading="inviteByEmailResource.loading"
>Send Invites</Button
>
</form>
<template v-if="pendingInvitesResource.data?.length">
<h2 class="mt-8 text-base font-semibold">Pending Invites</h2>
<ul class="flex flex-col gap-[0.375rem] mt-3">
<li
v-for="invite in pendingInvitesResource.data"
:key="invite.name"
class="flex items-center justify-between px-3 py-1 rounded-lg bg-surface-gray-2"
<FormControl
:label="__('Role')"
type="select"
:required="true"
:options="roleOptions"
v-model="role"
:description="roleDescription"
/>
<Button
type="submit"
variant="solid"
class="w-fit mt-1"
:disabled="cancelInviteResource.loading"
:loading="inviteByEmailResource.loading"
>{{ __("Send Invites") }}</Button
>
<div class="text-base">
<span class="text-ink-gray-8">
{{ invite.email }}
</span>
<span class="text-ink-gray-5">
({{ rolesToLabel(invite.roles) }})
</span>
</div>
<div>
<Tooltip
:disabled="
inviteByEmailResource.loading || cancelInviteResource.loading
"
text="Cancel Invitation"
>
<div>
<Button
icon="x"
variant="ghost"
:loading="
cancelInviteResource.loading &&
invite.name === cancelInviteResource.params.name
"
@click="
() => {
if (
inviteByEmailResource.loading ||
cancelInviteResource.loading
) {
return;
</form>
<template v-if="pendingInvitesResource.data?.length">
<h2 class="mt-8 text-base font-semibold">
{{ __("Pending Invites") }}
</h2>
<ul class="flex flex-col gap-[0.375rem] mt-3">
<li
v-for="invite in pendingInvitesResource.data"
:key="invite.name"
class="flex items-center justify-between px-3 py-1 rounded-lg bg-surface-gray-2"
>
<div class="text-base">
<span class="text-ink-gray-8">
{{ invite.email }}
</span>
<span class="text-ink-gray-5">
({{ rolesToLabel(invite.roles) }})
</span>
</div>
<div>
<Tooltip
:disabled="
inviteByEmailResource.loading || cancelInviteResource.loading
"
:text="__('Cancel Invitation')"
>
<div>
<Button
icon="x"
variant="ghost"
:loading="
cancelInviteResource.loading &&
invite.name === cancelInviteResource.params.name
"
@click="
() => {
if (
inviteByEmailResource.loading ||
cancelInviteResource.loading
) {
return;
}
cancelInviteResource.submit({
name: invite.name,
app_name: 'helpdesk',
});
}
cancelInviteResource.submit({
name: invite.name,
app_name: 'helpdesk',
});
}
"
v-bind="{
...((cancelInviteResource.loading &&
cancelInviteResource.params.name !== invite.name) ||
inviteByEmailResource.loading
? { 'aria-disabled': true, class: 'opacity-25' }
: {}),
}"
/>
</div>
</Tooltip>
</div>
</li>
</ul>
"
v-bind="{
...((cancelInviteResource.loading &&
cancelInviteResource.params.name !== invite.name) ||
inviteByEmailResource.loading
? { 'aria-disabled': true, class: 'opacity-25' }
: {}),
}"
/>
</div>
</Tooltip>
</div>
</li>
</ul>
</template>
</template>
</div>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
@@ -100,7 +101,9 @@ import { useAuthStore } from "@/stores/auth";
import { FormControl, Button, Tooltip, createResource, toast } from "frappe-ui";
import { computed, ref } from "vue";
import { useOnboarding } from "frappe-ui/frappe";
import SettingsLayoutHeader from "./SettingsLayoutHeader.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
const authStore = useAuthStore();
const { isAdmin, isManager } = authStore;
@@ -134,21 +137,23 @@ const roleOptions: [RoleOption, ...RoleOption[]] = [
{
label: roleToLabel("Agent"),
value: "Agent",
description:
"Can work on tickets, create custom views and manage private views.",
description: __(
"Can work on tickets, create custom views and manage private views."
),
},
];
const managerRoleOption: RoleOption = {
label: roleToLabel("Agent Manager"),
value: "Agent Manager",
description:
"Can invite new agents, manage tickets, create custom views and manage public views.",
description: __(
"Can invite new agents, manage tickets, create custom views and manage public views."
),
};
if (isAdmin) {
roleOptions.push(managerRoleOption, {
label: roleToLabel("System Manager"),
value: "System Manager",
description: "Can manage all aspects of Helpdesk.",
description: __("Can manage all aspects of Helpdesk."),
});
} else if (isManager) {
roleOptions.push(managerRoleOption);
@@ -163,7 +168,7 @@ const roleDescription = computed(
const onSubmit = async () => {
if (emails.value.trim() === "") {
toast.error("At least one email required");
toast.error(__("At least one email required"));
}
await inviteByEmailResource.submit({
emails: emails.value,
@@ -211,6 +216,11 @@ const inviteByEmailResource = createResource({
}
pendingInvitesResource.reload();
updateOnboardingStep("invite_your_team");
capture("agents_invited", {
data: {
role: role.value,
},
});
},
});

View File

@@ -2,10 +2,10 @@
<Dialog
v-model="show"
:options="{
title: 'New team',
title: __('New team'),
actions: [
{
label: 'Create',
label: __('Create'),
variant: 'solid',
onClick: () => {
newTeam.submit();
@@ -18,8 +18,8 @@
<form class="space-y-2" @submit.prevent="newTeam.submit">
<FormControl
v-model="newTeamTitle"
label="Title"
placeholder="Product experts"
:label="__('Title')"
:placeholder="__('Product experts')"
type="text"
/>
</form>
@@ -29,8 +29,8 @@
<script setup lang="ts">
import { createResource, toast } from "frappe-ui";
import { isEmpty } from "lodash";
import { ref } from "vue";
import { __ } from "@/translation";
const emit = defineEmits(["create"]);
const show = defineModel();
@@ -47,11 +47,11 @@ const newTeam = createResource({
};
},
validate(params) {
if (isEmpty(params.doc.team_name)) return "Title is required";
if (!params.doc.team_name) return __("Title is required");
},
auto: false,
onSuccess() {
toast.success("Team created");
toast.success(__("Team created"));
newTeamTitle.value = null;
show.value = false;
emit("create");

View File

@@ -0,0 +1,342 @@
<template>
<SettingsLayoutBase
:title="__('Profile')"
:description="__('Manage your profile information.')"
>
<template #content>
<div class="flex items-center justify-between gap-2">
<FileUploader
:fileTypes="['image/*']"
@success="
(file) => {
updateImage(file.file_url);
}
"
>
<template #default="{ openFileSelector, error: _error, uploading }">
<div class="flex items-center justify-center gap-2">
<div class="group relative !size-14">
<Avatar
class="!size-14"
:image="profile.userImage"
:label="profile.fullName"
/>
<component
:is="profile.userImage ? Dropdown : 'div'"
v-bind="
profile.userImage
? {
options: [
{
icon: 'upload',
label: profile.userImage
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => updateImage(null),
},
],
}
: { onClick: openFileSelector }
"
>
<div
class="z-1 absolute top-0 left-0 flex h-9 cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-40 opacity-0 duration-300 ease-in-out group-hover:opacity-100 !size-14"
>
<CameraIcon class="size-4 cursor-pointer text-white" />
</div>
</component>
<div
v-if="uploading"
class="w-full h-full top-0 left-0 absolute bg-black bg-opacity-20 rounded-full flex items-center justify-center"
>
<LoadingIndicator class="size-4" />
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-col">
<span
class="text-lg sm:text-xl !font-semibold text-ink-gray-8"
>{{ auth?.userName }}</span
>
<span class="text-p-sm text-ink-gray-6">{{
auth?.user
}}</span>
</div>
<ErrorMessage :message="__(_error)" />
</div>
</div>
</template>
</FileUploader>
</div>
<hr class="my-6" />
<div>
<div class="flex items-center justify-between">
<div class="flex gap-2 items-center">
<div class="text-base font-semibold text-ink-gray-9">
{{ __("Account info & security") }}
</div>
<Badge
v-if="isAccountInfoDirty || isLanguageChanged"
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
/>
</div>
<Button
:label="__('Save')"
@click="onSave"
:loading="
setAgent.loading ||
saveLanguageResource.loading ||
saveTimezoneResource.loading
"
:disabled="
!isAccountInfoDirty && !isLanguageChanged && !isTimezoneChanged
"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-6">
<FormControl
class="w-full"
:label="__('First Name')"
maxlength="40"
v-model="profile.firstName"
/>
<FormControl
class="w-full"
:label="__('Last Name')"
maxlength="40"
v-model="profile.lastName"
/>
</div>
<div class="flex items-center justify-between mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Password") }}
</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Change your account password for security.")
}}</span>
</div>
<Button
icon-left="lock"
:label="__('Change Password')"
@click="showChangePasswordModal = true"
/>
</div>
<div class="flex items-center justify-between mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Language") }}
</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Change language of the application.")
}}</span>
</div>
<Link
:model-value="language"
@update:modelValue="language = $event || auth.language"
doctype="Language"
class="w-40"
/>
</div>
<div class="flex items-center justify-between mt-6">
<div class="flex flex-col gap-1">
<span class="text-base font-medium text-ink-gray-8">
{{ __("Timezone") }}
</span>
<span class="text-p-sm text-ink-gray-6">{{
__("Change timezone of the application.")
}}</span>
</div>
<Autocomplete
:options="timezoneOptions"
:model-value="timezone"
@update:modelValue="timezone = $event?.value"
placeholder="Select Timezone"
class="w-40"
/>
</div>
</div>
</template>
</SettingsLayoutBase>
<ChangePasswordModal
v-if="showChangePasswordModal"
v-model="showChangePasswordModal"
/>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import {
Avatar,
Badge,
Button,
createResource,
Dropdown,
FileUploader,
LoadingIndicator,
toast,
} from "frappe-ui";
import { Autocomplete } from "@/components";
import { __ } from "@/translation";
import { useAuthStore } from "@/stores/auth";
import CameraIcon from "~icons/lucide/camera";
import ChangePasswordModal from "./components/ChangePasswordModal.vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import Link from "@/components/frappe-ui/Link.vue";
import { HDAgent } from "@/types/doctypes";
const auth = useAuthStore();
const profile = ref({
fullName: auth.userName,
userImage: auth.userImage,
firstName: auth.userFirstName,
lastName: auth.userLastName,
});
const showChangePasswordModal = ref(false);
const language = ref(auth.language);
const timezone = ref(auth.timezone);
const timezoneOptions = ref([]);
const isLanguageChanged = computed(() => {
return language.value !== auth?.language;
});
const isTimezoneChanged = computed(() => {
return timezone.value !== auth?.timezone;
});
const isAccountInfoDirty = computed(() => {
const agentName = agentData.data?.agent_name?.split(" ");
if (!agentName) return false;
const isDirty =
profile.value.firstName !== agentName[0] ||
profile.value.lastName !== (agentName[1] || "");
if (isDirty) {
disableSettingModalOutsideClick.value = true;
} else {
disableSettingModalOutsideClick.value = false;
}
return isDirty;
});
const agentData = createResource({
url: "frappe.client.get",
auto: true,
makeParams() {
return {
doctype: "HD Agent",
name: auth.userId,
};
},
onSuccess: (data: HDAgent) => {
const fullName = data.agent_name.split(" ");
profile.value = {
fullName: data.agent_name,
firstName: fullName[0],
lastName: fullName[1] || "",
userImage: data.user_image,
};
},
});
const timezoneData = createResource({
url: "frappe.core.doctype.user.user.get_timezones",
auto: true,
onSuccess(data) {
timezoneOptions.value = data.timezones.map((tz: any) => ({
label: tz,
value: tz,
}));
},
});
const setAgent = createResource({
url: "frappe.client.set_value",
validate: () => {
if (!profile.value.firstName.trim()) {
return __("Please enter first name at least");
}
},
makeParams() {
return {
doctype: "HD Agent",
name: agentData.data?.name,
fieldname: {
agent_name: `${profile.value.firstName} ${profile.value.lastName}`,
user_image: profile.value.userImage,
},
};
},
onSuccess: () => {
auth.reloadUser();
agentData.reload();
toast.success(__("Profile updated"));
},
});
const saveLanguageResource = createResource({
url: "frappe.client.set_value",
makeParams() {
return {
doctype: "User",
name: auth.userId,
fieldname: {
language: language.value,
},
};
},
onSuccess() {
toast.success(__("Language updated"));
setTimeout(() => {
window.location.reload(true);
}, 500);
},
});
const saveTimezoneResource = createResource({
url: "frappe.client.set_value",
makeParams() {
return {
doctype: "User",
name: auth.userId,
fieldname: {
time_zone: timezone.value,
},
};
},
onSuccess() {
toast.success(__("Timezone updated"));
setTimeout(() => {
window.location.reload(true);
}, 500);
},
});
const onSave = () => {
if (isAccountInfoDirty.value) {
setAgent.submit();
}
if (isLanguageChanged.value) {
saveLanguageResource.submit();
}
if (isTimezoneChanged.value) {
saveTimezoneResource.submit();
}
};
const updateImage = (file: string | null) => {
profile.value.userImage = file;
setAgent.submit();
};
</script>

View File

@@ -0,0 +1,126 @@
<template>
<Dialog v-model="show" :options="{ title: __('Change Password') }">
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<Password
v-model="newPassword"
:placeholder="__('New Password')"
maxLength="50"
>
<template #prefix>
<LockKeyhole class="size-4 text-ink-gray-4" />
</template>
</Password>
</div>
<div>
<Password
v-model="confirmPassword"
:placeholder="__('Confirm Password')"
maxLength="50"
>
<template #prefix>
<LockKeyhole class="size-4 text-ink-gray-4" />
</template>
</Password>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-between items-center">
<div>
<p
v-if="confirmPasswordMessage"
class="text-sm text-ink-gray-5"
:class="
confirmPasswordMessage === __('Passwords match')
? 'text-ink-green-3'
: 'text-ink-red-3'
"
>
{{ confirmPasswordMessage }}
</p>
</div>
<Button
variant="solid"
:label="__('Update')"
:disabled="
!newPassword ||
!confirmPassword ||
newPassword !== confirmPassword ||
!isStrongPassword(newPassword)
"
:loading="updatePassword.loading"
@click="updatePassword.submit()"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import LockKeyhole from "~icons/lucide/lock-keyhole";
import { Dialog, toast, createResource } from "frappe-ui";
import { ref, watch } from "vue";
import { __ } from "@/translation";
import { useAuthStore } from "@/stores/auth";
const show = defineModel<boolean>();
const auth = useAuthStore();
const newPassword = ref("");
const confirmPassword = ref("");
const confirmPasswordMessage = ref("");
const updatePassword = createResource({
url: "frappe.client.set_value",
makeParams() {
return {
doctype: "User",
name: auth?.user,
fieldname: "new_password",
value: newPassword.value,
};
},
onSuccess: () => {
toast.success(__("Password updated successfully"));
show.value = false;
newPassword.value = "";
confirmPassword.value = "";
confirmPasswordMessage.value = "";
},
});
function isStrongPassword(password) {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d\s]).{8,}$/;
return regex.test(password);
}
watch([newPassword, confirmPassword], () => {
confirmPasswordMessage.value = "";
if (newPassword.value.length < 8) {
confirmPasswordMessage.value = __("Password must be at least 8 characters");
return;
} else if (!isStrongPassword(newPassword.value)) {
confirmPasswordMessage.value = __(
"Password must contain lowercase, uppercase, number, and symbol"
);
return;
}
if (
confirmPassword.value.length &&
newPassword.value !== confirmPassword.value
) {
confirmPasswordMessage.value = __("Passwords do not match");
} else if (
newPassword.value === confirmPassword.value &&
newPassword.value.length &&
confirmPassword.value.length
) {
confirmPasswordMessage.value = __("Passwords match");
}
});
</script>

View File

@@ -0,0 +1,43 @@
<template>
<SavedRepliesList v-if="savedRepliesActiveScreen.screen == 'list'" />
<SavedReplyView v-else-if="savedRepliesActiveScreen.screen == 'view'" />
</template>
<script setup lang="ts">
import { createListResource } from "frappe-ui";
import SavedRepliesList from "./SavedRepliesList.vue";
import SavedReplyView from "./SavedReplyView.vue";
import { onUnmounted, provide, ref } from "vue";
import { SavedReplyListResourceSymbol } from "../../../types";
import { activeFilter } from "./savedReplies";
const savedRepliesActiveScreen = ref({
screen: "list",
data: null,
});
const savedRepliesSearchQuery = ref("");
const savedReplyListResource = createListResource({
doctype: "HD Saved Reply",
fields: ["name", "title", "owner", "scope"],
filters: {
scope: activeFilter.value == "All" ? undefined : ["=", activeFilter.value],
},
cache: ["SavedReplyList"],
auto: true,
orderBy: "modified desc",
start: 0,
pageLength: 999,
});
provide(SavedReplyListResourceSymbol, savedReplyListResource);
provide("savedRepliesActiveScreen", savedRepliesActiveScreen);
provide("savedRepliesSearchQuery", savedRepliesSearchQuery);
onUnmounted(() => {
savedRepliesSearchQuery.value = "";
savedReplyListResource.filters = {
scope: activeFilter.value == "All" ? undefined : ["=", activeFilter.value],
};
});
</script>

View File

@@ -0,0 +1,399 @@
<template>
<SettingsLayoutBase
:title="__('Saved Replies')"
:description="
__(
'Manage pre-defined responses that can be used to respond to customer queries.'
)
"
>
<template #header-actions>
<Button
:label="__('New')"
theme="gray"
variant="solid"
@click="goToNew()"
icon-left="plus"
/>
</template>
<template #header-bottom>
<div class="flex items-center gap-2 justify-between">
<div class="relative w-full">
<Input
:model-value="savedRepliesSearchQuery"
@input="savedRepliesSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
<Button
v-if="savedRepliesSearchQuery"
icon="x"
variant="ghost"
@click="savedRepliesSearchQuery = ''"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
<Dropdown :options="filterOptions" placement="right">
<template #default="{ open }">
<Button
:label="activeFilter"
class="flex items-center justify-between w-fit p-4"
>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4"
/>
</template>
</Button>
</template>
<template #item="{ item }">
<button
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded p-2 text-base hover:bg-surface-gray-3"
@click="item.onSelect"
>
<div class="flex items-center justify-between flex-1">
<span class="whitespace-nowrap">
{{ item.label }}
</span>
<FeatherIcon
v-if="activeFilter === item.value"
name="check"
class="size-4 text-ink-gray-7"
/>
</div>
</button>
</template>
</Dropdown>
</div>
</template>
<template #content>
<div
v-if="savedRepliesListResource?.list?.loading"
class="flex items-center justify-center mt-12"
>
<LoadingIndicator class="w-4" />
</div>
<div
v-if="
!savedRepliesListResource?.list?.loading &&
!savedRepliesListResource?.data?.length
"
class="flex flex-col items-center justify-center gap-4 grow"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<SavedReplyIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No Saved reply found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add your first Saved reply to get started.") }}
</div>
</div>
<Button
:label="__('Add Saved reply')"
variant="outline"
icon-left="plus"
@click="goToNew()"
/>
</div>
<div
v-if="
!savedRepliesListResource?.list?.loading &&
savedRepliesListResource?.data?.length
"
class="-ml-2"
>
<div
class="grid grid-cols-12 items-center gap-3 text-sm text-gray-600 ml-2"
>
<div class="col-span-7">{{ __("Title") }}</div>
<div class="col-span-2">{{ __("Owner") }}</div>
<div class="col-span-3">{{ __("Scope") }}</div>
</div>
<hr class="mt-2 mx-2" />
<div
v-for="(savedReply, index) in savedRepliesListResource?.data"
:key="savedReply.name"
>
<div
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
>
<div
@click="
savedRepliesActiveScreen = {
screen: 'view',
data: savedReply,
}
"
class="w-full px-2 flex flex-col justify-center h-12.5 col-span-7"
>
<div
class="text-base text-ink-gray-7 font-medium w-full truncate"
>
{{ savedReply.title }}
</div>
</div>
<div
class="flex items-center gap-1.5 text-sm text-ink-gray-7 truncate col-span-2"
>
<Avatar
:label="
getUser(savedReply.owner)?.full_name || savedReply.owner
"
:image="getUser(savedReply.owner)?.user_image"
size="xs"
/>
{{ getUser(savedReply.owner)?.full_name }}
</div>
<div
class="flex justify-between items-center w-full pr-2 col-span-3"
>
<div class="flex items-center gap-1 text-sm text-ink-gray-7">
<component
:is="getScopeIcon(savedReply.scope)"
class="size-4"
/>
{{ savedReply.scope }}
</div>
<Dropdown
placement="right"
:options="dropdownOptions(savedReply)"
>
<Button
icon="more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
class="mr-2"
/>
</Dropdown>
</div>
</div>
<hr v-if="index !== savedRepliesList.length - 1" class="mx-2" />
</div>
</div>
</template>
</SettingsLayoutBase>
<Dialog
:options="{ title: __('Duplicate Saved reply') }"
v-model="duplicateDialog.show"
>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
:label="__('New Saved reply Name')"
type="text"
v-model="duplicateDialog.newTitle"
/>
</div>
</template>
<template #actions>
<div class="flex gap-2 justify-end">
<Button
variant="subtle"
:label="__('Close')"
@click="duplicateDialog.show = false"
/>
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
call,
Dropdown,
FeatherIcon,
Input,
LoadingIndicator,
toast,
} from "frappe-ui";
import { computed, inject, ref, Ref, watch } from "vue";
import { __ } from "@/translation";
import { ConfirmDelete } from "@/utils";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import { activeFilter } from "./savedReplies";
import { useUserStore } from "../../../stores/user";
import UserIcon from "~icons/lucide/user";
import UsersIcon from "~icons/lucide/users";
import GlobeIcon from "~icons/lucide/globe";
import { SavedReply, SavedReplyListResourceSymbol } from "../../../types";
import SavedReplyIcon from "../../icons/SavedReplyIcon.vue";
import { storeToRefs } from "pinia";
import { useConfigStore } from "@/stores/config";
const { getUser } = useUserStore();
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
storeToRefs(useConfigStore());
const savedRepliesSearchQuery = inject<Ref<string>>("savedRepliesSearchQuery");
const savedRepliesActiveScreen = inject<any>("savedRepliesActiveScreen");
const duplicateDialog = ref({
show: false,
name: "",
title: "",
newTitle: "",
});
const savedRepliesList = ref([]);
const goToNew = () => {
savedRepliesActiveScreen.value = {
screen: "view",
data: null,
};
};
const isConfirmingDelete = ref(false);
const savedRepliesListResource = inject(SavedReplyListResourceSymbol);
const dropdownOptions = (savedReply: SavedReply) => [
{
label: __("Duplicate"),
onClick: () => {
duplicateDialog.value = {
show: true,
name: savedReply.name,
title: savedReply.title,
newTitle: `${savedReply.title} (Copy)`,
};
},
icon: "copy",
},
...ConfirmDelete({
onConfirmDelete: () => deleteSavedReply(savedReply),
isConfirmingDelete,
}),
];
const deleteSavedReply = (savedReply: SavedReply) => {
if (!isConfirmingDelete.value) {
isConfirmingDelete.value = true;
return;
}
savedRepliesListResource?.delete.submit(savedReply.name, {
onSuccess: () => {
toast.success(__("Saved reply deleted"));
},
});
};
const duplicate = async () => {
await call("frappe.client.get", {
doctype: "HD Saved Reply",
name: duplicateDialog.value.name,
}).then((data: SavedReply) => {
savedRepliesListResource?.insert.submit(
{
...data,
title: duplicateDialog.value.newTitle,
},
{
onSuccess: (data) => {
toast.success(__("Saved reply duplicated"));
duplicateDialog.value = {
show: false,
name: "",
title: "",
newTitle: "",
};
savedRepliesActiveScreen.value = {
screen: "view",
data: { name: data.name },
};
},
}
);
});
};
const filterOptions = computed(() => {
const options = [
{
label: __("All"),
value: "All",
onSelect: () => {
applyFilter("All");
},
},
{
label: __("Personal"),
value: "Personal",
onSelect: () => {
applyFilter("Personal");
},
},
{
label: __("My Team"),
value: "Team",
onSelect: () => {
applyFilter("Team");
},
},
{
label: __("Global"),
value: "Global",
onSelect: () => {
applyFilter("Global");
},
},
];
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
options.pop();
}
return options;
});
const applyFilter = (scope: string) => {
if (!savedRepliesListResource) return;
activeFilter.value = scope;
savedRepliesListResource.filters = {
...savedRepliesListResource?.filters,
scope: scope == "All" ? undefined : ["=", scope],
};
savedRepliesListResource.list.reload();
};
const getScopeIcon = (scope: string) => {
const icons = [
{
label: __("Personal"),
icon: UserIcon,
},
{
label: __("Team"),
icon: UsersIcon,
},
{
label: __("Global"),
icon: GlobeIcon,
},
];
return icons.find((x) => x.label === scope)?.icon;
};
watch(
() => savedRepliesSearchQuery?.value,
(newValue) => {
if (!savedRepliesListResource) return;
savedRepliesListResource.filters = {
...savedRepliesListResource.filters,
title: ["like", `%${newValue}%`],
};
savedRepliesListResource.list.reload();
}
);
</script>

View File

@@ -0,0 +1,482 @@
<template>
<SettingsLayoutBase>
<template #title>
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="savedReplyData.title || __('New Saved Reply')"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved changes')"
v-if="isDirty"
/>
</div>
</template>
<template #header-actions>
<div class="flex items-center gap-2">
<Button
v-if="savedReplyData.name"
:label="__('Preview')"
size="sm"
@click="onShowPreview()"
icon-left="eye"
:disabled="
Boolean(!content?.editor?.state?.doc?.textContent?.trim()?.length)
"
/>
<Button
:label="__('Save')"
variant="solid"
theme="gray"
@click="onSave"
:loading="
savedRepliesListResource?.insert.loading ||
savedRepliesListResource?.setValue.loading ||
renameSavedReplyResource.loading ||
getSavedReplyData.loading
"
:disabled="Boolean(!isDirty)"
/>
</div>
</template>
<template #content>
<div
v-if="getSavedReplyData.loading"
class="flex items-center justify-center mt-12"
>
<LoadingIndicator class="w-4" />
</div>
<div v-else class="flex flex-col gap-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-1.5">
<FormControl
:label="__('Name')"
type="text"
v-model="savedReplyData.title"
required
maxLength="50"
@change="validateData('title')"
/>
<ErrorMessage class="text-p-sm" :message="errors.title" />
</div>
<div class="space-y-1.5">
<FormLabel :label="__('Scope')" />
<Select
v-model="savedReplyData.scope"
:options="scopeDropdownOptions"
required
>
<template #prefix>
<component
:is="getScopeIcon(savedReplyData.scope)"
class="size-4 text-ink-gray-9"
/>
</template>
<template #option="{ option }">
<div class="flex gap-2 items-center cursor-pointer">
<component :is="option.icon" class="size-4 text-ink-gray-9" />
<span>
{{ option.label }}
</span>
</div>
</template>
</Select>
<FormLabel
:label="__('Choose who can view and use this response')"
/>
</div>
</div>
<div v-if="savedReplyData.scope === 'Team'" class="space-y-1.5">
<FormLabel :label="__('Teams')" required />
<MultiSelect
:options="teamsList"
v-model="savedReplyData.teams"
:placeholder="__('Select teams')"
@update:modelValue="validateData('teams')"
/>
<div class="text-xs text-ink-gray-5 cursor-default">
{{ __("Restrict visibility to these teams") }}
</div>
<ErrorMessage class="text-p-sm" :message="errors.teams" />
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<FormLabel :label="__('Response')" required />
<DocumentationButton
url="https://docs.frappe.io/helpdesk/saved-replies"
/>
</div>
<PreviewDialog v-model="previewDialog" />
<TextEditor
editor-class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded-b border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors -mt-0.5"
ref="content"
:bubble-menu="false"
:content="savedReplyData.message"
@change="
(val) => {
savedReplyData.message = val;
validateData('message');
}
"
:fixed-menu="menuButtons"
:extensions="[FieldAutocomplete]"
:placeholder="
__(
'Hello {{ contact }}, \n\nWe are sorry for the inconvenience, we will get back to you soon. \n\nRegards, \n{{ full_name }}'
)
"
/>
<ErrorMessage class="text-p-sm" :message="errors.message" />
</div>
</div>
</template>
</SettingsLayoutBase>
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
:message="showConfirmDialog.message"
:onConfirm="showConfirmDialog.onConfirm"
:onCancel="() => (showConfirmDialog.show = false)"
/>
</template>
<script setup lang="ts">
import {
Badge,
Button,
createListResource,
createResource,
ErrorMessage,
FormControl,
FormLabel,
LoadingIndicator,
MultiSelect,
Select,
TextEditor,
toast,
} from "frappe-ui";
import { computed, inject, onUnmounted, ref, watch } from "vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import { __ } from "@/translation";
import PreviewDialog from "./components/PreviewDialog.vue";
import { menuButtons } from "./savedReplies";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import DocumentationButton from "@/components/DocumentationButton.vue";
import { storeToRefs } from "pinia";
import { useConfigStore } from "@/stores/config";
import { useAuthStore } from "@/stores/auth";
import { FieldAutocomplete } from "../../../tiptap-extensions";
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
import UserIcon from "~icons/lucide/user";
import UsersIcon from "~icons/lucide/users";
import GlobeIcon from "~icons/lucide/globe";
import { SavedReply, SavedReplyListResourceSymbol, Team } from "../../../types";
const showConfirmDialog = ref({
show: false,
title: "",
message: "",
onConfirm: () => {},
});
const savedRepliesActiveScreen = inject<any>("savedRepliesActiveScreen");
const savedRepliesListResource = inject(SavedReplyListResourceSymbol);
const previewDialog = ref({
show: false,
ticketId: "",
savedReply: "",
preview: null,
});
const content = ref();
const { teamRestrictionApplied, disableGlobalScopeForSavedReplies } =
storeToRefs(useConfigStore());
const { userTeams, isAdmin } = storeToRefs(useAuthStore());
const savedReplyData = ref({
name: "",
title: "",
scope: "Personal",
message: "",
teams: [],
});
const initialData = ref("");
const errors = ref({
title: "",
message: "",
teams: "",
});
const scopeDropdownOptions = computed(() => {
const options = [
{
label: __("Personal"),
value: "Personal",
icon: UserIcon,
},
{
label: __("Team"),
value: "Team",
icon: UsersIcon,
},
{
label: __("Global"),
value: "Global",
icon: GlobeIcon,
},
];
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
options.pop();
}
return options;
});
const getSavedReplyData = createResource({
url: "frappe.client.get",
params: {
doctype: "HD Saved Reply",
name: savedRepliesActiveScreen.value.data?.name,
},
auto: false,
onSuccess: (data: SavedReply) => {
savedReplyData.value = {
name: data.name,
title: data.title,
scope: data.scope,
message: data.message,
teams: data.teams?.map((team) => team.team) || [],
};
initialData.value = JSON.stringify(savedReplyData.value);
},
});
const getTeamsListResource = createListResource({
doctype: "HD Team",
auto: true,
fields: ["name"],
start: 0,
pageLength: 999,
transform: (data: Array<Team>) => {
return data.map((item) => ({
value: item.name,
label: item.name,
}));
},
});
const teamsList = computed(() => {
if (!isAdmin.value && teamRestrictionApplied.value) {
return (
userTeams.value?.map((team: string) => ({
value: team,
label: team,
})) || []
);
}
return getTeamsListResource.data || [];
});
const onShowPreview = () => {
previewDialog.value.show = true;
previewDialog.value.savedReply = savedReplyData.value.name;
};
if (savedRepliesActiveScreen.value.data?.name) {
getSavedReplyData.submit();
} else {
initialData.value = JSON.stringify(savedReplyData.value);
}
const isDirty = ref(false);
const goBack = () => {
const confirmDialogInfo = {
show: true,
title: __("Unsaved changes"),
message: __(
"Are you sure you want to go back? Unsaved changes will be lost."
),
onConfirm: goBack,
};
if (isDirty.value && !showConfirmDialog.value.show) {
showConfirmDialog.value = confirmDialogInfo;
return;
}
// Workaround fix for settings modal not closing after going back
setTimeout(() => {
savedRepliesActiveScreen.value = {
screen: "list",
data: null,
};
}, 250);
showConfirmDialog.value.show = false;
};
const onSave = () => {
validateData();
if (Object.values(errors.value).some((e) => e)) {
toast.error(__("Please fill all the required fields"));
return;
}
if (savedRepliesActiveScreen.value.data?.name) {
updateSavedReply();
} else {
createSavedReply();
}
};
const createSavedReply = () => {
savedRepliesListResource?.insert.submit(
{
title: savedReplyData.value.title,
message: savedReplyData.value.message,
scope: savedReplyData.value.scope,
teams: savedReplyData.value.teams.map((team) => ({
team: team,
})),
},
{
onSuccess: (data) => {
toast.success(__("Saved reply saved"));
savedReplyData.value = {
...savedReplyData.value,
name: data.name,
};
initialData.value = JSON.stringify(savedReplyData.value);
savedRepliesActiveScreen.value = {
screen: "view",
data: { name: data.name },
};
},
}
);
};
const renameSavedReplyResource = createResource({
url: "frappe.client.rename_doc",
makeParams() {
return {
doctype: "HD Saved Reply",
old_name: savedRepliesActiveScreen.value.data.name,
new_name: savedReplyData.value.title,
};
},
});
const updateSavedReply = async () => {
await savedRepliesListResource?.setValue.submit({
name: savedReplyData.value.name,
title: savedReplyData.value.title,
subject: savedReplyData.value.title,
message: savedReplyData.value.message,
scope: savedReplyData.value.scope,
teams: savedReplyData.value.teams.map((team) => ({
team: team,
})),
});
if (savedReplyData.value.name !== savedReplyData.value.title) {
await renameSavedReplyResource.submit().catch(async (err: any) => {
const error =
err?.messages?.[0] ||
__("Some error occurred while renaming saved reply");
toast.error(error);
// Reset saved reply to previous state
await getSavedReplyData.reload();
});
await getSavedReplyData.submit({
doctype: "HD Saved Reply",
name: savedReplyData.value.title,
});
savedRepliesActiveScreen.value = {
screen: "view",
data: { name: savedReplyData.value.title },
};
} else {
await getSavedReplyData.reload();
}
savedRepliesListResource?.reload();
isDirty.value = false;
toast.success(__("Saved reply updated"));
};
const getScopeIcon = (scope: string) => {
return scopeDropdownOptions.value.find((x) => x.value === scope)?.icon;
};
const validateData = (key?: string) => {
const validateField = (key: string) => {
switch (key) {
case "title":
if (!savedReplyData.value.title) {
errors.value.title = __("Title is required");
} else {
errors.value.title = "";
}
break;
case "message":
if (!content.value?.editor?.state?.doc?.textContent?.trim()?.length) {
errors.value.message = __("Response is required");
} else {
errors.value.message = "";
}
break;
case "teams":
if (
savedReplyData.value.scope === "Team" &&
!savedReplyData.value.teams.length
) {
errors.value.teams = __("At least one team is required");
} else {
errors.value.teams = "";
}
break;
default:
break;
}
};
if (key) {
validateField(key);
} else {
(Object.keys(errors.value) as string[]).forEach(validateField);
}
return errors.value;
};
watch(
savedReplyData,
(newVal) => {
if (!initialData.value) return;
isDirty.value = JSON.stringify(newVal) != initialData.value;
if (isDirty.value) {
disableSettingModalOutsideClick.value = true;
} else {
disableSettingModalOutsideClick.value = false;
}
},
{ deep: true }
);
onUnmounted(() => {
disableSettingModalOutsideClick.value = false;
});
</script>

View File

@@ -0,0 +1,116 @@
<template>
<div
v-if="props.items.length > 0"
class="max-h-[300px] min-w-40 overflow-y-auto rounded-lg bg-surface-white p-1 text-base shadow-lg pointer-events-auto hide-scrollbar"
>
<button
v-for="(item, index) in props.items"
:key="item.value"
:ref="
(el) => {
if (el) itemRefs[index] = el as HTMLButtonElement
}
"
:class="[
'flex w-full items-center whitespace-nowrap rounded-md px-2 py-1.5 text-sm text-ink-gray-9',
index === selectedIndex ? 'bg-surface-gray-2' : '',
]"
@click.stop.prevent="selectItem(index)"
@mouseover="selectedIndex = index"
>
{{ item.label }}
</button>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUpdate, ref, watch, type PropType } from "vue";
import type { Editor, Range } from "@tiptap/core";
import { FieldItem } from "../../../../tiptap-extensions";
const props = defineProps({
items: {
type: Array as PropType<any[]>,
required: true,
},
editor: {
type: Object as PropType<Editor>,
required: true,
},
range: {
type: Object as PropType<Range>,
required: true,
},
command: {
type: Function as PropType<(item: FieldItem) => void>,
required: true,
},
query: String,
});
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
props.command(item);
}
};
const onKeyDown = ({ event }: { event: KeyboardEvent }) => {
if (!props.items.length) return false;
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
};
const selectedIndex = ref(0);
const itemRefs = ref<HTMLButtonElement[]>([]);
onBeforeUpdate(() => {
itemRefs.value = [];
});
const scrollIntoView = () => {
nextTick(() => {
const selectedElement = itemRefs.value[selectedIndex.value];
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest" });
}
});
};
const upHandler = () => {
selectedIndex.value =
(selectedIndex.value + props.items.length - 1) % props.items.length;
scrollIntoView();
};
const downHandler = () => {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
scrollIntoView();
};
const enterHandler = () => {
selectItem(selectedIndex.value);
};
defineExpose({
onKeyDown,
});
watch(
() => props.items,
() => {
selectedIndex.value = 0;
}
);
</script>

View File

@@ -0,0 +1,108 @@
<template>
<Dialog
v-model="dialogModel.show"
:options="{ title: __('Preview'), size: '2xl' }"
@after-leave="
() => {
dialogModel.ticketId = '';
dialogModel.preview = null;
}
"
>
<template #body-content>
<div class="space-y-4">
<Link
:value="dialogModel.ticketId"
:label="__('Select ticket to preview')"
doctype="HD Ticket"
class="form-control flex-1"
:placeholder="__('Search ticket')"
:show-description="true"
@change="getResponsePreview"
/>
<div class="space-y-1.5">
<FormLabel :label="__('Preview')" />
<div class="relative">
<TextEditor
editor-class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
:bubble-menu="menuButtons"
:content="dialogModel.preview"
:editable="false"
class="pointer-events-none"
/>
<div
v-if="getResponsePreviewResource.loading"
class="absolute top-0 right-0 flex items-center justify-center size-full rounded-md bg-black/20"
>
<LoadingIndicator class="size-4" />
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
createListResource,
createResource,
Dialog,
FormLabel,
TextEditor,
toast,
} from "frappe-ui";
import { LoadingIndicator } from "frappe-ui";
import { menuButtons } from "../savedReplies";
import { Link } from "@/components";
import { watch } from "vue";
import { __ } from "@/translation";
const dialogModel = defineModel<{
show: boolean;
ticketId: string;
savedReply: string;
preview: string;
}>();
const getResponsePreviewResource = createResource({
url: "helpdesk.api.saved_replies.get_rendered_saved_reply",
onSuccess: (data) => {
dialogModel.value.preview = data;
},
});
const getResponsePreview = (ticketId: string) => {
if (!ticketId) return;
dialogModel.value.ticketId = ticketId;
dialogModel.value.preview = null;
getResponsePreviewResource.submit({
ticket_id: ticketId,
saved_reply_id: dialogModel.value.savedReply,
});
};
watch(
() => dialogModel.value.show,
(newShowValue) => {
if (newShowValue) {
createListResource({
doctype: "HD Ticket",
fields: ["name"],
start: 0,
pageLength: 1,
auto: true,
onSuccess: (data) => {
if (data.length === 0) {
toast.error(__("No tickets found to preview."));
return;
}
dialogModel.value.ticketId = data[0].name;
getResponsePreview(data[0].name);
},
});
}
}
);
</script>

View File

@@ -0,0 +1,99 @@
import { __ } from "@/translation";
import { ref } from "vue";
export const menuButtons = [
[
"Heading 1",
"Heading 2",
"Heading 3",
"Heading 4",
"Heading 5",
"Heading 6",
],
"Paragraph",
"Separator",
"Bold",
"Italic",
"Separator",
"Bullet List",
"Numbered List",
"Task List",
"Separator",
"Align Left",
"Align Center",
"Align Right",
"FontColor",
"Separator",
"Image",
"Link",
"Blockquote",
"Code",
"Horizontal Rule",
[
"InsertTable",
"AddColumnBefore",
"AddColumnAfter",
"DeleteColumn",
"AddRowBefore",
"AddRowAfter",
"DeleteRow",
"MergeCells",
"SplitCell",
"ToggleHeaderColumn",
"ToggleHeaderRow",
"ToggleHeaderCell",
"DeleteTable",
],
"Separator",
"Undo",
"Redo",
];
export const userFields = [
{
label: __("Email"),
value: "email",
},
{
label: __("First Name"),
value: "first_name",
},
{
label: __("Middle Name"),
value: "middle_name",
},
{
label: __("Last Name"),
value: "last_name",
},
{
label: __("Full Name"),
value: "full_name",
},
{
label: __("Username"),
value: "username",
},
{
label: __("User Image"),
value: "user_image",
},
{
label: __("Phone"),
value: "phone",
},
{
label: __("Location"),
value: "location",
},
{
label: __("Bio"),
value: "bio",
},
{
label: __("Mobile No"),
value: "mobile_no",
},
];
export const activeFilter = ref("Personal");

View File

@@ -5,15 +5,23 @@
:disableOutsideClickToClose="disableSettingModalOutsideClick"
>
<template #body>
<div class="flex" :style="{ height: 'calc(100vh - 8rem)' }">
<div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2">
<h1 class="px-2 pt-2 text-lg font-semibold mb-3">Settings</h1>
<div class="flex z-50" :style="{ height: 'calc(100vh - 8rem)' }">
<div
class="flex w-52 shrink-0 flex-col bg-gray-50 p-2 overflow-y-auto hide-scrollbar"
>
<h1
class="h-7.5 px-2 py-[7px] my-[3px] flex cursor-pointer gap-1.5 text-base text-ink-gray-5 transition-all duration-300 ease-in-out truncate"
>
{{ __("My Settings") }}
</h1>
<div v-for="tab in tabs">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex gap-1.5 px-1 text-base font-medium text-ink-gray-5"
>
<span>{{ __(tab.label) }}</span>
<Tooltip :text="__(tab.label)" placement="right">
<span class="truncate">{{ __(tab.label) }}</span>
</Tooltip>
</div>
<nav class="space-y-1">
<button
@@ -27,15 +35,20 @@
]"
@click="() => onTabChange(item)"
>
<component :is="item.icon" class="h-4 w-4 text-gray-700" />
<span class="text-base text-gray-800">
{{ item.label }}
</span>
<component
:is="item.icon"
class="h-4 w-4 text-gray-700 shrink-0"
/>
<Tooltip :text="__(item.label)" placement="right">
<span class="text-p-sm text-gray-800 truncate">
{{ __(item.label) }}
</span>
</Tooltip>
</button>
</nav>
</div>
</div>
<div class="flex flex-1 flex-col bg-surface-modal">
<div class="flex flex-1 flex-col bg-surface-modal max-w-[816px]">
<component
:is="activeTab.component"
v-if="activeTab"
@@ -69,7 +82,7 @@
/>
</template>
<script setup lang="ts">
import { Dialog } from "frappe-ui";
import { Dialog, Tooltip } from "frappe-ui";
import { ModelRef, ref, watch } from "vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import {

View File

@@ -1,8 +1,6 @@
<template>
<div class="pb-8">
<SlaPolicies v-if="slaActiveScreen.screen == 'list'" />
<SlaPolicyView v-else-if="slaActiveScreen.screen == 'view'" />
</div>
<SlaPolicies v-if="slaActiveScreen.screen == 'list'" />
<SlaPolicyView v-else-if="slaActiveScreen.screen == 'view'" />
</template>
<script setup lang="ts">
@@ -10,16 +8,25 @@ import { slaActiveScreen } from "@/stores/sla";
import SlaPolicies from "./SlaPolicies.vue";
import SlaPolicyView from "./SlaPolicyView.vue";
import { createListResource } from "frappe-ui";
import { provide } from "vue";
import { onUnmounted, provide, ref } from "vue";
import { SlaPolicyListResourceSymbol } from "@/types";
const slaPolicyListData = createListResource({
doctype: "HD Service Level Agreement",
fields: ["name", "default_sla", "enabled", "description"],
orderBy: "creation desc",
cache: ["SLAPolicyList"],
orderBy: "modified desc",
start: 0,
pageLength: 999,
auto: true,
});
const slaSearchQuery = ref("");
provide("slaPolicyList", slaPolicyListData);
provide("slaSearchQuery", slaSearchQuery);
provide(SlaPolicyListResourceSymbol, slaPolicyListData);
onUnmounted(() => {
slaSearchQuery.value = "";
slaPolicyListData.filters = {};
});
</script>

View File

@@ -2,11 +2,14 @@
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<div class="text-lg font-semibold text-ink-gray-8">
Work schedule and holidays
{{ __("Work schedule and holidays") }}
</div>
<div class="text-p-sm text-ink-gray-6 max-w-lg">
Set working days, hours, and holidays by selecting a predefined schedule
or creating a new one.
{{
__(
"Set working days, hours, and holidays by selecting a predefined schedule or creating a new one"
)
}}
</div>
</div>
<NestedPopover>
@@ -19,13 +22,13 @@
</template>
<template #body>
<div
class="my-2 p-1 min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
class="my-2 min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div>
<div class="max-h-52 overflow-y-auto p-1">
<div
v-for="holiday in holidayListData.data"
:key="holiday.name"
class="flex items-center justify-between gap-4 rounded px-2 py-1.5 text-base text-ink-gray-8 cursor-pointer hover:bg-surface-gray-2"
class="flex items-center justify-between gap-4 rounded px-2 py-1.5 text-base text-ink-gray-8 cursor-pointer hover:bg-surface-gray-3"
@click="slaData.holiday_list = holiday.name"
>
<div class="flex items-center gap-2 w-full">
@@ -44,17 +47,17 @@
/>
</div>
</div>
<div
class="mt-1.5 flex flex-col gap-1 border-t border-outline-gray-modals pt-1.5"
>
<Button
class="w-full !justify-start !text-ink-gray-5"
variant="ghost"
label="Create new business holiday"
@click="createNewHolidayList()"
icon-left="plus"
/>
</div>
</div>
<div
class="mt-1.5 flex flex-col gap-1 border-t border-outline-gray-modals pt-1.5 p-1"
>
<Button
class="w-full !justify-start !text-ink-gray-5"
variant="ghost"
:label="__('Create new business holiday')"
@click="createNewHolidayList()"
icon-left="plus"
/>
</div>
</div>
</template>

View File

@@ -1,44 +1,74 @@
<template>
<div class="px-10 py-8">
<SettingsLayoutHeader>
<template #title>
<h1 class="text-lg font-semibold text-ink-gray-8">
Service Level Agreements (SLAs)
</h1>
</template>
<template #description>
<p class="text-p-sm max-w-md text-ink-gray-6">
SLAs align your team and customers with defined timelines for a
reliable experience.
<a
href="https://docs.frappe.io/helpdesk/service-level-agreement"
target="_blank"
class="underline"
>Learn more about SLA
</a>
</p>
</template>
<template #actions>
<Button
label="New"
theme="gray"
variant="solid"
@click="goToNew()"
icon-left="plus"
<SettingsLayoutBase>
<template #title>
<h1 class="text-lg font-semibold text-ink-gray-8">
{{ __("Service Level Agreements (SLAs)") }}
</h1>
</template>
<template #description>
<p class="text-p-sm max-w-md text-ink-gray-6">
{{
__(
"SLAs align your team and customers with defined timelines for a reliable experience."
)
}}
<a
href="https://docs.frappe.io/helpdesk/service-level-agreement"
target="_blank"
class="underline"
>{{ __("Learn more about SLA") }}
</a>
</p>
</template>
<template #header-actions>
<Button
:label="__('New')"
theme="gray"
variant="solid"
@click="goToNew()"
icon-left="plus"
/>
</template>
<template
v-if="slaPolicyList.data?.length > 9 || slaSearchQuery.length"
#header-bottom
>
<div class="relative">
<Input
:model-value="slaSearchQuery"
@input="slaSearchQuery = $event"
:placeholder="__('Search')"
type="text"
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
</template>
</SettingsLayoutHeader>
</div>
<div class="px-10 pb-8 overflow-y-auto">
<SlaPolicyList />
</div>
<Button
v-if="slaSearchQuery"
icon="x"
variant="ghost"
@click="slaSearchQuery = ''"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
</template>
<template #content>
<SlaPolicyList />
</template>
</SettingsLayoutBase>
</template>
<script setup lang="ts">
import { resetSlaData, slaActiveScreen } from "@/stores/sla";
import { Button } from "frappe-ui";
import SlaPolicyList from "./SlaPolicyList.vue";
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
import { inject, Ref, watch } from "vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { SlaPolicyListResourceSymbol } from "@/types";
const slaPolicyList = inject(SlaPolicyListResourceSymbol);
const slaSearchQuery = inject<Ref>("slaSearchQuery");
const goToNew = () => {
resetSlaData();
@@ -48,4 +78,15 @@ const goToNew = () => {
fetchData: true,
};
};
watch(slaSearchQuery, (newValue) => {
slaPolicyList.filters = {
name: ["like", `%${newValue}%`],
};
if (!newValue) {
slaPolicyList.start = 0;
slaPolicyList.pageLength = 10;
}
slaPolicyList.reload();
});
</script>

View File

@@ -5,33 +5,65 @@
>
<LoadingIndicator class="w-4" />
</div>
<div v-else>
<div v-else class="grow">
<div
v-if="slaPolicyList.list.data?.length === 0"
class="flex items-center justify-center rounded-md border border-gray-200 p-4"
v-if="!slaPolicyList.list.loading && !slaPolicyList.list.data?.length"
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div class="text-sm text-ink-gray-7">No items in the list</div>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<ShieldCheck class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No SLA found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add one to get started.") }}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="goToNew()"
/>
</div>
<div v-else>
<div v-else class="-ml-2">
<div
class="grid grid-cols-6 items-center gap-3 text-sm text-gray-600 ml-2"
>
<div class="col-span-5">Policy Name</div>
<div class="col-span-1">Enabled</div>
<div class="col-span-5">
{{ __("Policy Name") }}
</div>
<div class="col-span-1">{{ __("Enabled") }}</div>
</div>
<hr class="mt-2 mx-2" />
<div v-for="sla in slaPolicyList.list.data" :key="sla.name">
<div v-for="(sla, index) in slaPolicyList.list.data" :key="sla.name">
<SlaPolicyListItem :data="sla" />
<hr class="mx-2" />
<hr v-if="index !== slaPolicyList.list.data.length - 1" class="mx-2" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { LoadingIndicator } from "frappe-ui";
import { Button, LoadingIndicator } from "frappe-ui";
import SlaPolicyListItem from "./SlaPolicyListItem.vue";
import { inject } from "vue";
import { resetSlaData, slaActiveScreen } from "@/stores/sla";
import ShieldCheck from "~icons/lucide/shield-check";
import { SlaPolicyListResourceSymbol } from "@/types";
const slaPolicyList = inject<any>("slaPolicyList");
const slaPolicyList = inject(SlaPolicyListResourceSymbol);
const goToNew = () => {
resetSlaData();
slaActiveScreen.value = {
screen: "view",
data: null,
fetchData: true,
};
};
</script>

View File

@@ -4,7 +4,7 @@
>
<div
@click="slaActiveScreen = { screen: 'view', data: data, fetchData: true }"
class="w-full py-3 pl-2 col-span-5"
class="w-full pl-2 col-span-5 flex flex-col justify-center h-14"
>
<div
class="text-base text-ink-gray-7 font-medium flex items-center gap-2"
@@ -14,7 +14,7 @@
</div>
<div
v-if="data.description && data.description.length > 0"
class="text-sm w-full text-ink-gray-5 mt-1 whitespace-nowrap overflow-ellipsis overflow-hidden"
class="text-sm w-full text-ink-gray-5 mt-1 truncate"
>
{{ data.description }}
</div>
@@ -39,15 +39,15 @@
</div>
</div>
<Dialog
:options="{ title: `Duplicate SLA Policy` }"
:options="{ title: __('Duplicate SLA Policy') }"
v-model="duplicateDialog.show"
>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
label="New SLA Policy Name"
:label="__('New SLA Policy Name')"
type="text"
v-model="duplicateDialog.name"
v-model="duplicateDialog.newName"
/>
</div>
</template>
@@ -55,10 +55,10 @@
<div class="flex gap-2 justify-end">
<Button
variant="subtle"
label="Close"
:label="__('Close')"
@click="duplicateDialog.show = false"
/>
<Button variant="solid" label="Duplicate" @click="duplicate()" />
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
</div>
</template>
</Dialog>
@@ -71,15 +71,20 @@ import {
toast,
Dialog,
Badge,
Dropdown,
} from "frappe-ui";
import { ref, inject } from "vue";
import { slaActiveScreen } from "@/stores/sla";
import { ConfirmDelete } from "@/utils";
import { __ } from "@/translation";
import { SlaPolicyListResourceSymbol } from "@/types";
import { HDServiceLevelAgreement } from "@/types/doctypes";
const slaPolicyList = inject<any>("slaPolicyList");
const slaPolicyList = inject(SlaPolicyListResourceSymbol);
const duplicateDialog = ref({
show: false,
newName: "",
name: "",
});
@@ -94,11 +99,12 @@ const isConfirmingDelete = ref(false);
const dropdownOptions = [
{
label: "Duplicate",
label: __("Duplicate"),
onClick: () => {
duplicateDialog.value = {
show: true,
name: props.data.name + " (Copy)",
newName: props.data.name + " (Copy)",
name: props.data.name,
};
},
icon: "copy",
@@ -111,25 +117,40 @@ const dropdownOptions = [
const duplicate = () => {
createResource({
url: "helpdesk.api.sla.duplicate_sla",
url: "frappe.client.get",
params: {
docname: props.data.name,
new_name: duplicateDialog.value.name,
doctype: "HD Service Level Agreement",
name: duplicateDialog.value.name,
},
onSuccess: (data) => {
slaPolicyList.reload();
toast.success("SLA policy duplicated");
duplicateDialog.value = {
show: false,
name: "",
};
setTimeout(() => {
slaActiveScreen.value = {
screen: "view",
data: data,
fetchData: true,
};
}, 250);
onSuccess: (data: HDServiceLevelAgreement) => {
createResource({
url: "frappe.client.insert",
params: {
doc: {
...data,
default_sla: false,
service_level: duplicateDialog.value.newName,
name: duplicateDialog.value.newName,
},
},
auto: true,
onSuccess(newSlaPolicyData: HDServiceLevelAgreement) {
slaPolicyList?.reload();
toast.success(__("SLA policy duplicated"));
duplicateDialog.value = {
show: false,
newName: "",
name: "",
};
setTimeout(() => {
slaActiveScreen.value = {
screen: "view",
data: newSlaPolicyData,
fetchData: true,
};
}, 250);
},
});
},
auto: true,
});
@@ -141,26 +162,26 @@ const deleteSla = () => {
return;
}
slaPolicyList.delete.submit(props.data.name, {
slaPolicyList?.delete.submit(props.data.name, {
onSuccess: () => {
toast.success("SLA policy deleted");
toast.success(__("SLA policy deleted"));
},
});
};
const onToggle = () => {
if (props.data.default_sla) {
toast.error("SLA set as default cannot be disabled");
toast.error(__("SLA set as default cannot be disabled"));
return;
}
slaPolicyList.setValue.submit(
slaPolicyList?.setValue.submit(
{
name: props.data.name,
enabled: !props.data.enabled,
},
{
onSuccess: () => {
toast.success("SLA policy status updated");
toast.success(__("SLA policy status updated"));
},
}
);

View File

@@ -1,41 +1,37 @@
<template>
<div v-if="slaData.loading" class="flex items-center h-full justify-center">
<LoadingIndicator class="w-4" />
</div>
<div
v-if="!slaData.loading"
class="sticky top-0 z-10 bg-white px-10 pt-8 pb-6"
>
<div class="flex items-center justify-between w-full">
<div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="slaData.service_level || 'New SLA Policy'"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-xl hover:opacity-70 !pr-0"
/>
<Badge
:variant="'subtle'"
:theme="'orange'"
size="sm"
label="Unsaved changes"
v-if="isDirty"
/>
</div>
<SettingsLayoutBase>
<template #title>
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="slaData.service_level || __('New SLA Policy')"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge
:variant="'subtle'"
:theme="'orange'"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div>
</template>
<template #header-actions>
<div class="flex gap-4 items-center">
<div
class="flex items-center justify-between gap-2 cursor-pointer"
@click="toggleEnabled"
>
<Switch size="sm" v-model="slaData.enabled" />
<span class="text-sm text-ink-gray-7 font-medium">Enabled</span>
<span class="text-sm text-ink-gray-7 font-medium">
{{ __("Enabled") }}
</span>
</div>
<Button
label="Save"
:label="__('Save')"
theme="gray"
variant="solid"
@click="saveSla()"
@@ -43,153 +39,170 @@
:loading="slaData.loading || slaPolicyList.setValue.loading"
/>
</div>
</div>
</div>
<div v-if="!slaData.loading" class="overflow-y-auto px-10 pb-10">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<FormControl
:type="'text'"
size="sm"
variant="subtle"
placeholder="Name"
label="Name"
v-model="slaData.service_level"
required
@change="validateSlaData('service_level')"
:disabled="Boolean(slaActiveScreen.data)"
/>
<ErrorMessage :message="slaDataErrors.service_level" class="mt-2" />
</template>
<template #content>
<div
v-if="slaData.loading"
class="flex items-center h-full justify-center"
>
<LoadingIndicator class="w-4" />
</div>
<FormControl
:type="'textarea'"
size="sm"
variant="subtle"
placeholder="Description"
label="Description"
v-model="slaData.description"
/>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8"
>Assignment conditions</span
>
<span class="text-p-sm text-ink-gray-6">
Choose which tickets are affected by this policy.
</span>
</div>
<div class="mt-3">
<div class="flex items-center justify-between">
<Checkbox
label="Set as default SLA"
:model-value="slaData.default_sla"
@update:model-value="toggleDefaultSla"
class="text-ink-gray-6 text-base font-medium"
/>
<div v-if="isOldSla && slaActiveScreen.data && !slaData.default_sla">
<Popover trigger="hover" hoverDelay="0.25" placement="top-end">
<template #target>
<div class="text-sm text-ink-gray-6 flex gap-1 cursor-default">
Old Conditions
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ slaData.condition }}</code>
</div>
</template>
</Popover>
</div>
</div>
<div class="mt-5" v-if="!slaData.default_sla">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="!useNewUI"
>
<span class="text-p-sm">
Conditions for this SLA were created from
<a :href="deskUrl" target="_blank" class="underline">desk</a>
which are not compatible with this UI, you will need to recreate
the conditions here if you want to manage and add new conditions
from this UI.
</span>
<Button
label="I understand, add conditions"
<div v-if="!slaData.loading">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<FormControl
:type="'text'"
size="sm"
variant="subtle"
theme="gray"
@click="useNewUI = true"
:placeholder="__('Name')"
:label="__('Name')"
v-model="slaData.service_level"
required
@change="validateSlaData('service_level')"
:disabled="Boolean(slaActiveScreen.data)"
maxlength="50"
/>
<ErrorMessage :message="slaDataErrors.service_level" class="mt-2" />
</div>
<SlaAssignmentConditions
:conditions="slaData.condition_json"
v-if="useNewUI"
<FormControl
:type="'textarea'"
size="sm"
variant="subtle"
:placeholder="__('Description')"
:label="__('Description')"
v-model="slaData.description"
maxlength="140"
/>
</div>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">Valid from</span>
<span class="text-p-sm text-ink-gray-6">
Choose how long this SLA policy will be active.
</span>
</div>
<div class="mt-3.5 flex gap-5 flex-col md:flex-row">
<div class="w-full space-y-1.5">
<FormLabel label="From date" for="from_date" />
<DatePicker
v-model="slaData.start_date"
variant="subtle"
placeholder="11/01/2025"
class="w-full"
id="from_date"
@change="validateSlaData('start_date')"
:formatter="(date) => getFormattedDate(date)"
>
<template #prefix>
<LucideCalendar class="size-4" />
</template>
</DatePicker>
<ErrorMessage :message="slaDataErrors.start_date" />
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">{{
__("Assignment conditions")
}}</span>
<span class="text-p-sm text-ink-gray-6">
{{ __("Choose which tickets are affected by this policy.") }}
</span>
</div>
<div class="mt-3">
<div class="flex items-center justify-between">
<Checkbox
:label="__('Set as default SLA')"
:model-value="slaData.default_sla"
@update:model-value="toggleDefaultSla"
class="text-ink-gray-6 text-base font-medium"
/>
<div
v-if="isOldSla && slaActiveScreen.data && !slaData.default_sla"
>
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
<template #target>
<div
class="text-sm text-ink-gray-6 flex gap-1 cursor-default"
>
{{ __("Old Conditions") }}
<FeatherIcon name="info" class="size-4" />
</div>
</template>
<template #body-main>
<div
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
>
<code>{{ slaData.condition }}</code>
</div>
</template>
</Popover>
</div>
</div>
<div class="mt-5" v-if="!slaData.default_sla">
<div
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
v-if="!useNewUI"
>
<span class="text-p-sm">
Conditions for this SLA were created from
<a :href="deskUrl" target="_blank" class="underline">desk</a>
which are not compatible with this UI, you will need to
recreate the conditions here if you want to manage and add new
conditions from this UI.
</span>
<Button
:label="__('I understand, add conditions')"
variant="subtle"
theme="gray"
@click="useNewUI = true"
/>
</div>
<SlaAssignmentConditions
:conditions="slaData.condition_json"
v-if="useNewUI"
/>
</div>
</div>
</div>
<div class="w-full space-y-1.5">
<FormLabel label="To date" for="to_date" />
<DatePicker
v-model="slaData.end_date"
variant="subtle"
placeholder="25/12/2025"
class="w-full"
id="to_date"
@change="validateSlaData('end_date')"
:formatter="(date) => getFormattedDate(date)"
>
<template #prefix>
<LucideCalendar class="size-4" />
</template>
</DatePicker>
<ErrorMessage :message="slaDataErrors.end_date" />
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">
{{ __("Valid from") }}
</span>
<span class="text-p-sm text-ink-gray-6">
{{ __("Choose how long this SLA policy will be active.") }}
</span>
</div>
<div class="mt-3.5 flex gap-5 flex-col md:flex-row">
<div class="w-full space-y-1.5">
<FormLabel :label="__('From date')" for="from_date" />
<DatePicker
v-model="slaData.start_date"
variant="subtle"
placeholder="11/01/2025"
class="w-full"
id="from_date"
@change="validateSlaData('start_date')"
:formatter="(date) => getFormattedDate(date)"
>
<template #prefix>
<LucideCalendar class="size-4" />
</template>
</DatePicker>
<ErrorMessage :message="slaDataErrors.start_date" />
</div>
<div class="w-full space-y-1.5">
<FormLabel :label="__('To date')" for="to_date" />
<DatePicker
v-model="slaData.end_date"
variant="subtle"
placeholder="25/12/2025"
class="w-full"
id="to_date"
@change="validateSlaData('end_date')"
:formatter="(date) => getFormattedDate(date)"
>
<template #prefix>
<LucideCalendar class="size-4" />
</template>
</DatePicker>
<ErrorMessage :message="slaDataErrors.end_date" />
</div>
</div>
</div>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8"
>Response and resolution</span
>
<span class="text-p-sm text-ink-gray-6">
Add time targets around support milestones like first reply and
resolution times
</span>
</div>
<div class="mt-5">
<!-- <div class="flex gap-6">
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">
{{ __("Response and resolution") }}
</span>
<span class="text-p-sm text-ink-gray-6">
{{
__(
"Add time targets around support milestones like first reply and resolution times"
)
}}
</span>
</div>
<div class="mt-5">
<!-- <div class="flex gap-6">
<div
class="flex items-center gap-2"
@click="onApplySlaForChange(false)"
@@ -217,30 +230,34 @@
</div>
</div>
</div> -->
<div class="mt-5">
<SlaPriorityList />
<div class="mt-5">
<SlaPriorityList />
</div>
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8">
{{ __("Status details") }}
</span>
<span class="text-p-sm text-ink-gray-6">
{{
__(
"Set the default status assigned when a ticket is created, and the status to apply when a ticket is reopened. If not specified, the default status from HD Settings will be used."
)
}}
</span>
</div>
<div class="mt-5">
<SlaStatusList />
</div>
</div>
<hr class="my-8" />
<SlaHolidays />
</div>
</div>
<hr class="my-8" />
<div>
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-ink-gray-8"
>Status details</span
>
<span class="text-p-sm text-ink-gray-6">
Set the default status assigned when a ticket is created, and the
status to apply when a ticket is reopened. If not specified, the
default status from HD Settings will be used.
</span>
</div>
<div class="mt-5">
<SlaStatusList />
</div>
</div>
<hr class="my-8" />
<SlaHolidays />
</div>
</template>
</SettingsLayoutBase>
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
@@ -280,6 +297,10 @@ import SlaPriorityList from "./SlaPriorityList.vue";
import SlaStatusList from "./SlaStatusList.vue";
import { disableSettingModalOutsideClick } from "../settingsModal";
import { useOnboarding } from "frappe-ui/frappe";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { SlaPolicyListResourceSymbol } from "@/types";
import { HDServiceLevelAgreement } from "@/types/doctypes";
const { updateOnboardingStep } = useOnboarding("helpdesk");
@@ -295,16 +316,25 @@ const initialData = ref(null);
const useNewUI = ref(true);
const isOldSla = ref(false);
const slaPolicyList = inject<any>("slaPolicyList");
const slaPolicyList = inject(SlaPolicyListResourceSymbol);
const deskUrl = `${window.location.origin}/app/hd-service-level-agreement/${slaActiveScreen.value.data?.name}`;
const getSlaData = createResource({
url: "helpdesk.api.sla.get_sla",
url: "frappe.client.get",
params: {
docname: slaActiveScreen.value.data?.name,
doctype: "HD Service Level Agreement",
name: slaActiveScreen.value.data?.name,
},
onSuccess(data) {
const condition_json = JSON.parse(data.condition_json || "[]");
onSuccess(data: HDServiceLevelAgreement) {
let condition_json;
try {
condition_json = JSON.parse(data.condition_json || "[]");
} catch (error) {
toast.error(
"Assignment conditions are invalid or corrupt, recreate the conditions."
);
condition_json = [];
}
const newData = {
...data,
@@ -336,8 +366,10 @@ if (slaActiveScreen.value.data && slaActiveScreen.value.fetchData) {
const goBack = () => {
const confirmDialogInfo = {
show: true,
title: "Unsaved changes",
message: "Are you sure you want to go back? Unsaved changes will be lost.",
title: __("Unsaved changes"),
message: __(
"Are you sure you want to go back? Unsaved changes will be lost."
),
onConfirm: goBack,
};
if (isDirty.value && !showConfirmDialog.value.show) {
@@ -364,7 +396,7 @@ const saveSla = () => {
if (Object.values(validationErrors).some((error) => error)) {
toast.error(
"Invalid fields, check if all are filled in and values are correct."
__("Invalid fields, check if all are filled in and values are correct.")
);
return;
}
@@ -373,9 +405,10 @@ const saveSla = () => {
if (isOldSla.value && useNewUI.value) {
showConfirmDialog.value = {
show: true,
title: "Confirm overwrite",
message:
"Your old conditions will be overwritten. Are you sure you want to save?",
title: __("Confirm overwrite"),
message: __(
"Your old conditions will be overwritten. Are you sure you want to save?"
),
onConfirm: () => {
updateSla();
showConfirmDialog.value.show = false;
@@ -396,7 +429,7 @@ const createSla = () => {
const ticketReopenStatus = slaData.value.reopen_ticket_status
? slaData.value.reopen_ticket_status?.value
: null;
slaPolicyList.insert.submit(
slaPolicyList?.insert.submit(
{
...slaData.value,
default_ticket_status: defaultTicketStatus,
@@ -409,11 +442,12 @@ const createSla = () => {
},
{
onSuccess(data) {
toast.success("SLA policy created");
toast.success(__("SLA policy created"));
slaActiveScreen.value.data = data;
slaActiveScreen.value.screen = "view";
getSlaData.submit({
docname: data.name,
doctype: "HD Service Level Agreement",
name: data.name,
});
updateOnboardingStep("setup_sla", true);
},
@@ -428,10 +462,10 @@ const updateSla = () => {
const ticketReopenStatus = slaData.value.reopen_ticket_status
? slaData.value.reopen_ticket_status?.value
: null;
slaPolicyList.setValue.submit(
slaPolicyList?.setValue.submit(
{
...slaData.value,
name: slaActiveScreen.value.data.name,
name: slaActiveScreen.value.data?.name,
default_ticket_status: defaultTicketStatus,
ticket_reopen_status: ticketReopenStatus,
condition: useNewUI.value
@@ -447,7 +481,7 @@ const updateSla = () => {
{
onSuccess() {
getSlaData.submit();
toast.success("SLA policy updated");
toast.success(__("SLA policy updated"));
slaPolicyList.reload();
},
}
@@ -456,7 +490,7 @@ const updateSla = () => {
const toggleEnabled = () => {
if (slaData.value.default_sla) {
toast.error("SLA set as default cannot be disabled");
toast.error(__("SLA set as default cannot be disabled"));
return;
}
slaData.value.enabled = !slaData.value.enabled;

View File

@@ -37,12 +37,19 @@
</template>
</Popover>
</div>
<div v-else>
<Select
class="w-full bg-transparent cursor-pointer border-0 focus-visible:!ring-0 bg-none"
:options="priorityOptions"
<div v-else class="ml-2">
<select
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md p-0 pl-2 pr-5 bg-transparent -ml-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
v-model="props.row[column.key]"
/>
>
<option
v-for="option in priorityOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
<div class="flex justify-end">
@@ -63,7 +70,7 @@
import DurationPicker from "@/components/frappe-ui/DurationPicker.vue";
import { slaData } from "@/stores/sla";
import { ConfirmDelete } from "@/utils";
import { Button, Checkbox, Popover, Select } from "frappe-ui";
import { Button, Checkbox, Dropdown, Popover } from "frappe-ui";
import { inject, ref } from "vue";
import EditResponseResolutionModal from "./Modals/EditResponseResolutionModal.vue";
import { formatTimeHMS } from "./utils";

View File

@@ -26,13 +26,15 @@ import { HDTicketStatus } from "@/types/doctypes";
const { statuses } = useTicketStatusStore();
const openStatuses: ComputedRef<HDTicketStatus[]> = computed(() => {
return statuses.data
.filter((s: HDTicketStatus) => s.category === "Open")
.map((s: HDTicketStatus) => {
return {
label: s.label_agent,
value: s.label_agent,
};
});
return (
statuses?.data
?.filter((s: HDTicketStatus) => s.category === "Open")
?.map((s: HDTicketStatus) => {
return {
label: s.label_agent,
value: s.label_agent,
};
}) || []
);
});
</script>

View File

@@ -13,12 +13,19 @@
<div v-if="column.key === 'start_time' || column.key === 'end_time'">
{{ formatTime(props.row[column.key]) }}
</div>
<div v-else>
<Select
class="bg-transparent cursor-pointer border-0 focus-visible:!ring-0 bg-none"
:options="workDayOptions"
<div v-else class="ml-2">
<select
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md p-0 pl-2 pr-5 bg-transparent -ml-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
v-model="props.row[column.key]"
/>
>
<option
v-for="option in workDayOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
<div class="flex justify-end">
@@ -40,7 +47,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { Button, Select } from "frappe-ui";
import { Button, Dropdown } from "frappe-ui";
import WorkDayModal from "./Modals/WorkDayModal.vue";
import { ConfirmDelete, getGridTemplateColumnsForTable } from "@/utils";
import { slaData } from "@/stores/sla";

View File

@@ -0,0 +1,178 @@
<template>
<SettingsLayoutBase>
<template #title>
<div class="flex items-center gap-2">
<Button
variant="ghost"
icon-left="chevron-left"
:label="teamData.name || __('New Team')"
size="md"
@click="goBack()"
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
<Badge
variant="subtle"
theme="orange"
size="sm"
:label="__('Unsaved')"
v-if="isDirty"
/>
</div>
</template>
<template #header-actions>
<Button
:label="__('Save')"
variant="solid"
@click="saveTeam()"
:disabled="!isDirty"
:loading="teamsList.insert.loading"
/>
</template>
<template #content>
<div class="flex flex-col gap-4">
<div class="space-y-1.5">
<FormControl
v-model="teamData.name"
:label="__('Team Name')"
:placeholder="__('Product Experts')"
maxlength="50"
required
@change="validateData('name')"
/>
<ErrorMessage :message="errors.name" />
</div>
<div class="flex flex-col gap-1.5">
<FormLabel :label="__('Members')" required />
<div class="flex">
<AgentSelector
v-model="teamData.agents"
@change="validateData('agents')"
/>
</div>
<ErrorMessage :message="errors.agents" />
</div>
</div>
</template>
</SettingsLayoutBase>
<ConfirmDialog
v-model="showConfirmDialog.show"
:title="showConfirmDialog.title"
:message="showConfirmDialog.message"
:onConfirm="showConfirmDialog.onConfirm"
/>
</template>
<script setup lang="ts">
import { computed, inject, onMounted, ref } from "vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { Badge, ErrorMessage, FormControl, FormLabel, toast } from "frappe-ui";
import { __ } from "@/translation";
import AgentSelector from "./components/AgentSelector.vue";
import { useAgentStore } from "@/stores/agent";
import { TeamListResourceSymbol } from "@/types";
import ConfirmDialog from "../../ConfirmDialog.vue";
interface E {
(event: "update:step", step: string, team?: string): void;
}
const emit = defineEmits<E>();
const teamsList = inject(TeamListResourceSymbol);
const { agents } = useAgentStore();
const isDirty = computed(() => {
return teamData.value.name || teamData.value.agents.length;
});
const showConfirmDialog = ref({
show: false,
title: "",
message: "",
onConfirm: () => {},
});
const teamData = ref({
name: "",
agents: [],
});
const errors = ref({
name: "",
agents: "",
});
const goBack = () => {
const confirmDialogInfo = {
show: true,
title: __("Unsaved changes"),
message: __(
"Are you sure you want to go back? Unsaved changes will be lost."
),
onConfirm: goBack,
};
if (isDirty.value && !showConfirmDialog.value.show) {
showConfirmDialog.value = confirmDialogInfo;
return;
}
// Workaround fix for settings modal not closing after going back
setTimeout(() => {
emit("update:step", "team-list");
}, 250);
showConfirmDialog.value.show = false;
};
const saveTeam = () => {
validateData();
if (Object.values(errors.value).some((error) => error)) {
toast.error(__("Please fill all required fields"));
return;
}
teamsList.insert.submit(
{
team_name: teamData.value.name,
users: teamData.value.agents.map((agent) => ({ user: agent })),
},
{
onSuccess: (data) => {
toast.success(__("Team created"));
emit("update:step", "team-edit", data.name);
},
}
);
};
const validateData = (key?: string) => {
const validateField = (field: string) => {
if (key && field !== key) return;
switch (field) {
case "name":
teamData.value.name?.length == 0
? (errors.value.name = "Name is required")
: (errors.value.name = "");
break;
case "agents":
teamData.value.agents.length == 0
? (errors.value.agents = "At least one team member is required")
: (errors.value.agents = "");
break;
}
};
if (key) {
validateField(key);
} else {
(Object.keys(errors.value) as string[]).forEach(validateField);
}
return errors.value;
};
onMounted(() => {
if (agents.loading || agents.data?.length || agents.list.promise) {
return;
}
agents.fetch();
});
</script>

View File

@@ -0,0 +1,72 @@
<template>
<Dialog v-model="dialog.show" :options="renameDialogOptions">
<template #body-content>
<FormControl
v-model="teamName"
:label="__('Title')"
:placeholder="__('Product Experts')"
/>
</template>
<template #actions>
<Button
variant="solid"
@click="renameTeam"
:loading="renameTeamResource.loading"
:disabled="teamName == dialog.teamName || teamName.trim() == ''"
>
Confirm
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { __ } from "@/translation";
import { createResource, Dialog, toast } from "frappe-ui";
import { computed, ref, watch } from "vue";
const emit = defineEmits(["onRename"]);
const dialog = defineModel<{
show: boolean;
teamName: string;
}>();
const teamName = ref(dialog.value.teamName);
const renameTeamResource = createResource({
url: "frappe.client.rename_doc",
makeParams() {
return {
doctype: "HD Team",
old_name: dialog.value.teamName,
new_name: teamName.value,
};
},
validate(params) {
if (!params.new_name) return __("New title is required");
if (params.new_name === params.old_name)
return __("New and old title cannot be same");
},
onSuccess(data) {
toast.success(__("Team renamed"));
dialog.value.show = false;
emit("onRename", data);
},
});
const renameDialogOptions = computed(() => ({
title: __("Rename team"),
message: __("Enter the new name for the team"),
}));
function renameTeam() {
renameTeamResource.submit();
}
watch(
() => dialog.value.show,
() => {
teamName.value = dialog.value.teamName;
}
);
</script>

View File

@@ -1,17 +1,20 @@
<template>
<div class="w-full h-full flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-1 justify-center -ml-[16px]">
<Button
variant="ghost"
icon-left="chevron-left"
:label="teamName"
size="md"
@click="() => emit('update:step', 'team-list')"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
/>
<SettingsLayoutBase>
<template #title>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-1 justify-center -ml-[16px]">
<Button
variant="ghost"
icon-left="chevron-left"
:label="teamName"
size="md"
@click="() => emit('update:step', 'team-list')"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
/>
</div>
</div>
</template>
<template #header-actions>
<Dropdown placement="right" :options="options">
<Button variant="ghost">
<template #icon>
@@ -19,99 +22,103 @@
</template>
</Button>
</Dropdown>
</div>
<!-- Add member -->
<div class="flex flex-col gap-4 h-full">
<div class="flex gap-2 items-end">
</template>
<template #header-bottom>
<!-- Add member -->
<div class="flex gap-2 items-center">
<!-- Form control for search -->
<Link
doctype="HD Agent"
class="form-control flex-1"
placeholder="Search members"
v-model="search"
label="Members"
:hide-me="true"
:filters="agentFilters"
>
<template #prefix>
<LucideSearch class="h-4 w-4 text-gray-500 mr-2" />
</template>
<template #item-label="{ option }">
<div class="flex items-center justify-between !w-full">
<div class="flex items-center gap-1">
<Avatar
:label="option.label || option.value"
:image="getUser(option.label)?.user_image"
size="sm"
/>
<p>{{ getUser(option.label)?.full_name || "User" }}</p>
</div>
<p class="text-gray-600 text-sm">
{{ option.label }}
</p>
<div class="flex flex-col gap-1.5 w-full">
<div class="flex gap-2">
<div class="flex flex-1">
<AgentSelector
v-model="invitees"
:existing-agents="teamMembers.map((m) => m.name)"
/>
</div>
</template>
</Link>
<Button
label="Add"
variant="solid"
:disabled="!search"
@click="addMember(search)"
>
<template #prefix>
<LucidePlus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
<!-- Member List -->
<div
class="flex flex-col divide-y divide-gray-200 overflow-y-scroll hide-scrollbar h-full"
v-if="teamMembers.length"
>
<div
v-for="(member, idx) in teamMembers"
:key="member.user"
class="group py-2"
:class="idx == teamMembers.length - 1 && 'mb-12'"
>
<AgentCard :agent="member" class="!py-0">
<template #right>
<Button
variant="ghost"
@click.stop="() => removeMemberFromTeam(member.name)"
theme="red"
class="opacity-0 group-hover:opacity-100"
:disabled="team.loading"
>
<template #icon>
<LucideX class="h-4 w-4" />
</template>
</Button>
</template>
</AgentCard>
<Button
label="Add Member"
variant="solid"
:disabled="!invitees.length"
@click="addMember(invitees)"
class="h-8"
>
<template #prefix>
<LucidePlus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</div>
</div>
<div v-else class="flex justify-center h-full">
<p class="text-p-base text-gray-500">No members found</p>
</template>
<template #content>
<div class="w-full h-full" v-if="teamMembers?.length > 0">
<div class="grid grid-cols-8 items-center gap-3 text-sm text-gray-600">
<div class="col-span-6 text-p-sm">
{{ __("Members ({0})", teamMembers.length) }}
</div>
</div>
<hr class="mt-2" />
<div v-for="(member, idx) in teamMembers" :key="member.name">
<div class="grid grid-cols-8 items-center gap-4 group">
<div class="w-full p-2 pl-0 col-span-8">
<AgentCard :agent="member" class="!py-0">
<template #right>
<Dropdown
:options="memberDropdownOptions(member)"
placement="right"
>
<Button
icon="more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
</Dropdown>
</template>
</AgentCard>
</div>
</div>
<hr v-if="member !== teamMembers.at(-1)" />
</div>
</div>
</div>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<UserIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No members found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{ __("Add members to this team to get started.") }}
</div>
</div>
</div>
</template>
</SettingsLayoutBase>
<Dialog v-model="showDelete" :options="{ title: 'Delete team' }">
<template #body-content>
<p class="text-p-base text-ink-gray-7">
Are you sure you want to delete this team? This action cannot be
reversed!
{{
__(
"Are you sure you want to delete this team? This action cannot be reversed!"
)
}}
</p>
<Button
variant="solid"
class="mt-4 float-right"
label="Confirm"
:label="__('Confirm')"
theme="red"
@click="
() => {
team.delete.submit();
showDelete = false;
emit('update:step', 'team-list');
}
"
/>
@@ -121,19 +128,24 @@
<template #body-content>
<FormControl
v-model="_teamName"
label="Title"
placeholder="Product Experts"
:label="__('Title')"
:placeholder="__('Product Experts')"
maxlength="50"
/>
</template>
</Dialog>
</template>
<script setup lang="ts">
import Link from "@/components/frappe-ui/Link.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { useAgentStore } from "@/stores/agent";
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
import { useConfigStore } from "@/stores/config";
import { useUserStore } from "@/stores/user";
import { __ } from "@/translation";
import { TeamListResourceSymbol } from "@/types";
import { ConfirmDelete } from "@/utils";
import {
Avatar,
Button,
createDocumentResource,
createResource,
@@ -142,25 +154,31 @@ import {
toast,
Tooltip,
} from "frappe-ui";
import { computed, h, markRaw, ref } from "vue";
import { computed, h, inject, markRaw, onMounted, ref } from "vue";
import LucideLock from "~icons/lucide/lock";
import LucideUnlock from "~icons/lucide/unlock";
import AgentCard from "../AgentCard.vue";
import Settings from "~icons/lucide/settings-2";
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
import LucideUnlock from "~icons/lucide/unlock";
import UserIcon from "~icons/lucide/user";
import AgentCard from "../AgentCard.vue";
import { setActiveSettingsTab } from "../settingsModal";
import AgentSelector from "./components/AgentSelector.vue";
const props = defineProps<{
teamName: string;
}>();
interface E {
(event: "update:step", value: string): void;
(event: "update:step", step: string, team?: string): void;
}
const emit = defineEmits<E>();
const { getUser } = useUserStore();
const { agents } = useAgentStore();
const teamsList = inject(TeamListResourceSymbol);
const { teamRestrictionApplied } = useConfigStore();
const invitees = ref<string[]>([]);
const _teamName = ref(props.teamName);
const team = createDocumentResource({
doctype: "HD Team",
@@ -168,7 +186,7 @@ const team = createDocumentResource({
auto: true,
delete: {
onSuccess() {
toast.success("Team deleted");
toast.success(__("Team deleted"));
emit("update:step", "team-list");
},
},
@@ -185,7 +203,6 @@ const ignoreRestrictions = computed({
});
},
});
const search = ref("");
const teamMembers = computed(() => {
let users = team.doc?.users || [];
@@ -199,12 +216,6 @@ const teamMembers = computed(() => {
});
});
const agentFilters = computed(() => {
return {
name: ["not in", teamMembers.value.map((user) => user.name)],
};
});
function removeMemberFromTeam(member: string) {
const users = team.doc?.users?.filter((u) => u.user !== member);
team.setValue.submit({
@@ -212,22 +223,22 @@ function removeMemberFromTeam(member: string) {
});
}
function addMember(user: string) {
const users = team.doc.users.concat([{ user }]);
function addMember(users: string[]) {
const _users = team.doc.users.concat(users.map((user) => ({ user })));
team.setValue.submit({
users,
users: _users,
});
search.value = "";
invitees.value = [];
}
const showRename = ref(false);
const renameDialogOptions = {
title: "Rename team",
message: "Enter the new name for the team",
title: __("Rename team"),
message: __("Enter the new name for the team"),
actions: [
{
label: "Confirm",
label: __("Confirm"),
variant: "solid",
loading: team.loading,
onClick: ({ close }) => {
@@ -248,12 +259,13 @@ function renameTeam(close) {
};
},
validate(params) {
if (!params.new_name) return "New title is required";
if (!params.new_name) return __("New title is required");
if (params.new_name === params.old_name)
return "New and old title cannot be same";
return __("New and old title cannot be same");
},
onSuccess() {
toast.success("Team renamed");
teamsList.reload();
toast.success(__("Team renamed"));
close();
emit("update:step", "team-list");
},
@@ -263,24 +275,10 @@ function renameTeam(close) {
}
const showDelete = ref(false);
const deleteDialogOptions = {
title: "Delete team",
message: `Are you sure you want to delete this team? This action cannot be reversed!`,
actions: [
{
label: "Confirm",
variant: "solid",
onClick: (ctx) => {
team.delete.submit();
ctx.close();
},
},
],
};
const options = [
{
label: "View Assignment rule",
label: __("View Assignment rule"),
icon: markRaw(h(Settings, { class: "rotate-90" })),
onClick: () => {
assignmentRulesActiveScreen.value = {
@@ -291,22 +289,26 @@ const options = [
},
},
{
label: "Rename",
label: __("Rename"),
icon: "edit-3",
onClick: () => (showRename.value = !showRename.value),
},
{
condition: () => teamRestrictionApplied,
label: team.doc?.ignore_restrictions
? "Disable Bypass Restrictions"
: "Enable Bypass Restrictions",
? __("Disable Bypass Restrictions")
: __("Enable Bypass Restrictions"),
component: () =>
h(
Tooltip,
{
text: ignoreRestrictions.value
? "Members of this team will see the tickets assigned to this team only"
: "Members of this team will be able to see the tickets assigned to all the teams",
? __(
"Members of this team will see the tickets assigned to this team only"
)
: __(
"Members of this team will be able to see the tickets assigned to all the teams"
),
},
{
default: () => [
@@ -332,8 +334,8 @@ const options = [
},
[
team.doc?.ignore_restrictions
? "Access only this team's tickets"
: "Access all team tickets",
? __("Access only this team's tickets")
: __("Access all team tickets"),
]
),
]
@@ -343,9 +345,9 @@ const options = [
),
},
{
label: "Delete",
label: __("Delete"),
component: h(Button, {
label: "Delete",
label: __("Delete"),
variant: "ghost",
iconLeft: "trash-2",
theme: "red",
@@ -356,6 +358,20 @@ const options = [
}),
},
];
</script>
<style scoped></style>
const isConfirmingDelete = ref(false);
const memberDropdownOptions = (member) => {
return ConfirmDelete({
onConfirmDelete: () => removeMemberFromTeam(member.name),
isConfirmingDelete,
});
};
onMounted(() => {
if (agents.loading || agents.data?.length || agents.list.promise) {
return;
}
agents.fetch();
});
</script>

View File

@@ -1,18 +1,22 @@
<template>
<div v-if="step === 'team-list'" class="h-full px-10 py-8">
<TeamsList @update:step="updateStep" />
</div>
<div v-else-if="step === 'team-edit'" class="h-full px-10 py-8">
<TeamEdit @update:step="updateStep" :team-name="teamName" />
</div>
<TeamsList v-if="step === 'team-list'" @update:step="updateStep" />
<NewTeam v-else-if="step === 'new-team'" @update:step="updateStep" />
<TeamEdit
v-else-if="step === 'team-edit'"
@update:step="updateStep"
:team-name="teamName"
/>
</template>
<script setup lang="ts">
import { Ref, ref } from "vue";
import { onUnmounted, provide, Ref, ref } from "vue";
import TeamEdit from "./TeamEdit.vue";
import TeamsList from "./TeamsList.vue";
import { createListResource } from "frappe-ui";
import NewTeam from "./NewTeam.vue";
import { TeamListResourceSymbol } from "@/types";
type TeamStep = "team-list" | "team-edit";
type TeamStep = "team-list" | "team-edit" | "new-team";
const step: Ref<TeamStep> = ref("team-list");
const teamName: Ref<string> = ref("");
@@ -20,6 +24,25 @@ function updateStep(newStep: TeamStep, team?: string): void {
step.value = newStep;
teamName.value = team;
}
const teamsSearchQuery = ref("");
const teams = createListResource({
doctype: "HD Team",
cache: ["Teams"],
fields: ["name"],
auto: true,
orderBy: "modified desc",
start: 0,
pageLength: 20,
});
provide("teamsSearchQuery", teamsSearchQuery);
provide(TeamListResourceSymbol, teams);
onUnmounted(() => {
teamsSearchQuery.value = "";
teams.filters = {};
});
</script>
<style scoped></style>

View File

@@ -1,71 +1,134 @@
<template>
<div class="w-full h-full flex flex-col">
<!-- Header -->
<SettingsLayoutHeader
title="Teams"
description="Create and manage teams and assign agents to specific teams."
<SettingsLayoutBase
:title="__('Teams')"
:description="
__('Create and manage teams and assign agents to specific teams.')
"
>
<template #header-actions>
<Button
:label="__('New')"
theme="gray"
variant="solid"
@click="emit('update:step', 'new-team', '')"
icon-left="plus"
/>
</template>
<template
v-if="teams.data?.length > 9 || teamsSearchQuery.length"
#header-bottom
>
<template #actions>
<Button
label="New"
theme="gray"
variant="solid"
@click="() => (showForm = !showForm)"
icon-left="plus"
/>
</template>
<template #bottom-section>
<FormControl
v-model="search"
:placeholder="'Search'"
<div class="relative">
<Input
:model-value="teamsSearchQuery"
@input="teamsSearchQuery = $event"
:placeholder="__('Search')"
type="text"
:debounce="300"
class="w-60"
>
<template #prefix>
<LucideSearch class="h-4 w-4 text-gray-500" />
</template>
</FormControl>
</template>
</SettingsLayoutHeader>
<!-- List -->
<div
v-if="!teams.loading && teams.data?.length > 0"
class="divide-y w-full h-full hide-scrollbar overflow-y-scroll mt-4"
>
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
icon-left="search"
debounce="300"
inputClass="p-4 pr-12"
/>
<Button
v-if="teamsSearchQuery"
icon="x"
variant="ghost"
@click="teamsSearchQuery = ''"
class="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
</template>
<template #content>
<!-- List -->
<div
v-for="team in teams.data"
:key="team.name"
class="flex items-center gap-2 py-2 group justify-between cursor-pointer"
@click="() => emit('update:step', 'team-edit', team.name)"
v-if="!teams.loading && teams.data?.length > 0"
class="w-full h-full -ml-2"
>
<div class="flex items-center gap-2">
<Avatar :label="team.name" size="sm" />
<p :key="team.name" class="text-p-base text-gray-700">
{{ team.name }}
</p>
<div class="flex text-sm text-gray-600">
<div class="ml-2">{{ __("Team name") }}</div>
</div>
<hr class="mx-2 mt-2" />
<div v-for="(team, index) in teams.data" :key="team.name">
<div
class="flex items-center cursor-pointer hover:bg-gray-50 rounded h-12.5"
>
<div
class="w-full py-3 pl-2"
@click="() => emit('update:step', 'team-edit', team.name)"
>
<div class="text-base text-ink-gray-7 font-medium">
{{ team.name }}
</div>
</div>
<div class="flex justify-between items-center pr-2">
<div>
<Dropdown placement="right" :options="dropdownOptions(team)">
<Button
icon="more-horizontal"
variant="ghost"
@click="isConfirmingDelete = false"
/>
</Dropdown>
</div>
</div>
</div>
<hr v-if="index !== teams.data.length - 1" class="mx-2" />
</div>
<!-- Load More Button -->
<div class="flex justify-center">
<Button
v-if="!teams.loading && teams.hasNextPage"
class="mt-3.5 p-2"
@click="() => teams.next()"
:loading="teams.loading"
:label="__('Load More')"
icon-left="refresh-cw"
/>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="teams.loading" class="flex mt-28 justify-between w-full h-full">
<Button
:loading="teams.loading"
variant="ghost"
class="w-full"
size="2xl"
/>
</div>
<!-- Empty State -->
<div
v-if="!teams.data?.length"
class="flex mt-28 justify-between w-full h-full"
>
<p class="text-sm text-gray-500 w-full flex justify-center">
No teams found
</p>
</div>
</div>
<!-- Loading State -->
<div
v-if="teams.loading"
class="flex mt-28 justify-between w-full h-full"
>
<Button
:loading="teams.loading"
variant="ghost"
class="w-full"
size="2xl"
/>
</div>
<!-- Empty State -->
<div
v-if="!teams.loading && !teams.data?.length"
class="flex flex-col items-center justify-center gap-4 h-full"
>
<div
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
>
<AgentIcon class="size-6 text-ink-gray-6" />
</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-medium text-ink-gray-6">
{{ __("No team found") }}
</div>
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
{{
teamsSearchQuery.length
? __("Change your search terms to find teams.")
: __("Add one to get started.")
}}
</div>
</div>
<Button
:label="__('New')"
variant="outline"
icon-left="plus"
@click="showForm = true"
/>
</div>
</template>
</SettingsLayoutBase>
<NewTeamModal
v-model="showForm"
@create="
@@ -74,40 +137,72 @@
}
"
/>
<RenameTeamModal v-model="showRename" @onRename="() => teams.reload()" />
</template>
<script setup lang="ts">
import { Avatar, FormControl, createListResource } from "frappe-ui";
import { ref, watch } from "vue";
import { Dropdown, Input, toast } from "frappe-ui";
import { inject, markRaw, Ref, ref, watch } from "vue";
import NewTeamModal from "../NewTeamModal.vue";
import { ConfirmDelete } from "@/utils";
import EditIcon from "@/components/icons/EditIcon.vue";
import RenameTeamModal from "./RenameTeamModal.vue";
import { __ } from "@/translation";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { TeamListResourceSymbol } from "@/types";
interface E {
(event: "update:step", step: string, team: string): void;
}
const emit = defineEmits<E>();
const teamsSearchQuery = inject<Ref>("teamsSearchQuery");
const teams = createListResource({
doctype: "HD Team",
cache: ["Teams"],
fields: ["name"],
auto: true,
orderBy: "creation desc",
});
const search = ref("");
const teams = inject(TeamListResourceSymbol);
const showForm = ref(false);
const showRename = ref({
show: false,
teamName: "",
});
const isConfirmingDelete = ref(false);
watch(search, (newValue) => {
const dropdownOptions = (team: any) => {
return [
{
label: __("Rename"),
icon: markRaw(EditIcon),
onClick: () => {
showRename.value = {
show: true,
teamName: team.name,
};
},
},
...ConfirmDelete({
onConfirmDelete: () => deleteTeam(team),
isConfirmingDelete,
}),
];
};
const deleteTeam = (team: any) => {
if (!isConfirmingDelete.value) {
isConfirmingDelete.value = true;
return;
}
teams.delete.submit(team.name, {
onSuccess: () => {
toast.success(__("Team deleted"));
},
});
};
watch(teamsSearchQuery, (newValue) => {
teams.filters = {
...teams.filters,
name: ["like", `%${newValue}%`],
};
if (!newValue) {
teams.start = 0;
teams.pageLength = 10;
}
teams.reload();
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,182 @@
<template>
<div
class="flex flex-wrap gap-1 bg-surface-gray-2 p-0.5 border rounded w-full"
>
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="outline"
@keydown.delete.capture.stop="removeLastValue"
class="active:bg-surface-gray-1 active:border-outline-gray-2 hover:border-outline-gray-2 hover:bg-surface-gray-1"
>
<template #prefix>
<UserAvatar :name="value" size="xs" />
</template>
<template #suffix>
<FeatherIcon class="h-3.5" name="x" @click.stop="removeValue(value)" />
</template>
</Button>
<div class="flex-1">
<ComboboxRoot
:model-value="tempSelection"
:open="showOptions"
@update:open="(o) => (showOptions = o)"
:ignore-filter="true"
@update:modelValue="onSelect"
>
<ComboboxAnchor
class="flex h-7 w-full items-center gap-2 rounded px-2 py-1 border border-transparent bg-surface-gray-2 hover:bg-surface-gray-2"
>
<ComboboxInput
ref="search"
:value="query"
autocomplete="off"
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 focus:outline-none focus:ring-0 focus:border-0 w-full select-none"
placeholder="Type agent name or email"
@focus="showOptions = true"
@click="showOptions = true"
@input="onInput"
@keydown.delete.capture.stop="removeLastValue"
@keydown.enter.prevent="handleEnter"
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
class="z-10 mt-1 min-w-48 w-auto max-w-96 bg-surface-modal overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
position="popper"
:align="'start'"
@openAutoFocus.prevent
@closeAutoFocus.prevent
>
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
<ComboboxEmpty
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
Agent not found
</ComboboxEmpty>
<ComboboxItem
v-for="agent in agentsList"
:key="agent.name"
:value="agent.name"
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
>
<UserAvatar class="mr-1" :name="agent.agent_name" size="lg" />
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ agent.agent_name }}
</div>
<div class="text-sm text-ink-gray-5">
{{ agent.name }}
</div>
</div>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</div>
</div>
</template>
<script setup lang="ts">
import { UserAvatar } from "@/components";
import { useAgentStore } from "@/stores/agent";
import { Button } from "frappe-ui";
import { storeToRefs } from "pinia";
import {
ComboboxRoot,
ComboboxAnchor,
ComboboxInput,
ComboboxPortal,
ComboboxContent,
ComboboxViewport,
ComboboxItem,
ComboboxEmpty,
} from "reka-ui";
import { computed, nextTick, ref } from "vue";
const values = defineModel<any[]>();
const emit = defineEmits(["change"]);
const props = defineProps({
existingAgents: {
type: Array,
default: () => [],
},
});
const emails = ref([]);
const search = ref(null);
const query = ref("");
const showOptions = ref(false);
const tempSelection = ref(null);
const { agents } = storeToRefs(useAgentStore());
const agentsList = computed(() => {
let list = agents.value.data;
if (query.value) {
list = list.filter(
(agent) =>
agent.agent_name.toLowerCase().includes(query.value.toLowerCase()) ||
agent.name.toLowerCase().includes(query.value.toLowerCase())
);
}
if (props.existingAgents.length) {
list = list.filter((agent) => !props.existingAgents.includes(agent.name));
}
if (values.value.length) {
list = list.filter((agent) => !values.value.includes(agent.name));
}
return list;
});
function removeValue(value) {
values.value = values.value.filter((v) => v !== value);
}
function removeLastValue() {
if (query.value) return;
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
if (document.activeElement === emailRef) {
values.value.pop();
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].rootRef;
emailRef?.focus();
} else {
setFocus();
}
});
} else {
emailRef?.focus();
}
}
function onSelect(val) {
if (!val) return;
if (!values.value) values.value.push(val);
else values.value.push(val);
query.value = "";
emit("change", values.value);
}
function onInput(e) {
query.value = e.target.value;
showOptions.value = true;
}
function handleEnter() {
if (query.value) onSelect(query.value);
}
function setFocus() {
search.value?.focus?.();
}
</script>

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