Merge pull request #971 from shariquerik/email-template-settings
This commit is contained in:
commit
8350c5ee36
@ -44,6 +44,10 @@ class CRMInvitation(Document):
|
||||
|
||||
user = self.create_user_if_not_exists()
|
||||
user.append_roles(self.role)
|
||||
if self.role == "System Manager":
|
||||
user.append_roles("Sales Manager", "Sales User")
|
||||
elif self.role == "Sales Manager":
|
||||
user.append_roles("Sales User")
|
||||
if self.role == "Sales User":
|
||||
self.update_module_in_user(user, "FCRM")
|
||||
user.save(ignore_permissions=True)
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 883bb643d1e662d6467925927e347dd28376960f
|
||||
Subproject commit 424288f77af4779dd3bb71dc3d278fc627f95179
|
||||
7
frontend/components.d.ts
vendored
7
frontend/components.d.ts
vendored
@ -81,6 +81,7 @@ declare module 'vue' {
|
||||
DropdownItem: typeof import('./src/components/DropdownItem.vue')['default']
|
||||
DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default']
|
||||
DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default']
|
||||
EditEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/EditEmailTemplate.vue')['default']
|
||||
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
|
||||
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
|
||||
Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default']
|
||||
@ -95,7 +96,10 @@ declare module 'vue' {
|
||||
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
|
||||
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
||||
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
||||
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
|
||||
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
||||
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
|
||||
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
||||
@ -158,8 +162,10 @@ declare module 'vue' {
|
||||
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
|
||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||
LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
LucideX: typeof import('~icons/lucide/x')['default']
|
||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
||||
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
||||
@ -175,6 +181,7 @@ declare module 'vue' {
|
||||
MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default']
|
||||
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
||||
NestedPopover: typeof import('./src/components/NestedPopover.vue')['default']
|
||||
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.vue')['default']
|
||||
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
|
||||
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
|
||||
NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default']
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"@tiptap/extension-paragraph": "^2.12.0",
|
||||
"@twilio/voice-sdk": "^2.10.2",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"frappe-ui": "^0.1.156",
|
||||
"frappe-ui": "^0.1.162",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
|
||||
@ -9,9 +9,7 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" icon="x" @click="show = false" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -26,22 +24,19 @@
|
||||
</div>
|
||||
<div class="px-4 pb-7 pt-0 sm:px-6">
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button theme="red" variant="solid" @click="confirmDelete()">
|
||||
<div class="flex gap-1">
|
||||
<FeatherIcon name="trash" class="h-4 w-4" />
|
||||
<span>
|
||||
{{ __('Delete {0} items', [props.items.length]) }}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button variant="solid" @click="confirmUnlink()">
|
||||
<div class="flex gap-1">
|
||||
<FeatherIcon name="unlock" class="h-4 w-4" />
|
||||
<span>
|
||||
{{ __('Unlink and delete {0} items', [props.items.length]) }}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
:label="__('Delete {0} items', [props.items.length])"
|
||||
icon-left="trash-2"
|
||||
variant="solid"
|
||||
theme="red"
|
||||
@click="confirmDelete()"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Unlink and delete {0} items', [props.items.length])"
|
||||
icon-left="unlock"
|
||||
variant="solid"
|
||||
@click="confirmUnlink()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -54,9 +49,7 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" icon="x" @click="show = false" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -75,24 +68,20 @@
|
||||
</div>
|
||||
<div class="px-4 pb-7 pt-0 sm:px-6">
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button variant="solid" theme="red" @click="deleteDocs()">
|
||||
<div class="flex gap-1">
|
||||
<span>
|
||||
{{
|
||||
confirmDeleteInfo.delete
|
||||
? __('Delete')
|
||||
: __('Unlink and delete')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button variant="subtle" @click="confirmDeleteInfo.show = false">
|
||||
<div class="flex gap-1">
|
||||
<span>
|
||||
{{ __('Cancel') }}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
:label="
|
||||
confirmDeleteInfo.delete ? __('Delete') : __('Unlink and delete')
|
||||
"
|
||||
:icon-left="confirmDeleteInfo.delete ? 'trash-2' : 'unlock'"
|
||||
variant="solid"
|
||||
theme="red"
|
||||
@click="deleteDocs()"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Cancel')"
|
||||
variant="subtle"
|
||||
@click="confirmDeleteInfo.show = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -13,9 +13,7 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" icon="x" @click="show = false" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -62,43 +60,32 @@
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
v-if="linkedDocs?.length > 0"
|
||||
:label="
|
||||
viewControls?.selections?.length == 0
|
||||
? __('Delete all')
|
||||
: __('Delete {0} item(s)', [viewControls?.selections?.length])
|
||||
"
|
||||
theme="red"
|
||||
variant="solid"
|
||||
icon-left="trash-2"
|
||||
@click="confirmDelete()"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<FeatherIcon name="trash" class="h-4 w-4" />
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
{{
|
||||
viewControls?.selections?.length == 0
|
||||
? __('all')
|
||||
: `${viewControls?.selections?.length} item(s)`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
v-if="linkedDocs?.length > 0"
|
||||
:label="
|
||||
viewControls?.selections?.length == 0
|
||||
? __('Unlink all')
|
||||
: __('Unlink {0} item(s)', [viewControls?.selections?.length])
|
||||
"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
icon-left="unlock"
|
||||
@click="confirmUnlink()"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<FeatherIcon name="unlock" class="h-4 w-4" />
|
||||
<span>
|
||||
{{ __('Unlink') }}
|
||||
{{
|
||||
viewControls?.selections?.length == 0
|
||||
? __('all')
|
||||
: `${viewControls?.selections?.length} item(s)`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
v-if="linkedDocs?.length == 0"
|
||||
variant="solid"
|
||||
icon-left="trash-2"
|
||||
:label="__('Delete')"
|
||||
:loading="isDealCreating"
|
||||
@click="deleteDoc()"
|
||||
@ -116,9 +103,7 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" icon="x" @click="show = false" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-gray-5 text-base">
|
||||
|
||||
@ -150,7 +150,7 @@
|
||||
@click="showEmailTemplateSelectorModal = true"
|
||||
>
|
||||
<template #icon>
|
||||
<Email2Icon class="h-4" />
|
||||
<EmailTemplateIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@ -176,7 +176,7 @@
|
||||
<script setup>
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||
import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'
|
||||
|
||||
30
frontend/src/components/Icons/EmailTemplateIcon.vue
Normal file
30
frontend/src/components/Icons/EmailTemplateIcon.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_1191_1930)">
|
||||
<path
|
||||
d="M1.45001 12.1V12.1C1.45001 11.7869 1.60364 11.4936 1.86111 11.3154L6.87595 7.84359C7.52855 7.39178 8.38658 7.36866 9.06257 7.78465L13.5984 10.5759C14.1276 10.9016 14.45 11.4786 14.45 12.1V12.1"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.45 7.60001L11.95 9.60001M4.45001 9.60001L1.45001 7.60001"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4 9V3C4 2.44772 4.44772 2 5 2H11C11.5523 2 12 2.44772 12 3V9"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4 4.49999L2.1786 6C1.71727 6.37992 1.45002 6.94623 1.45002 7.54385L1.45002 12.1C1.45002 13.2046 2.34545 14.1 3.45002 14.1L12.45 14.1C13.5546 14.1 14.45 13.2046 14.45 12.1V7.51988C14.45 6.93603 14.1949 6.38133 13.7516 6.00137L12 4.5"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path d="M6 6H10" stroke="currentColor" stroke-linecap="round" />
|
||||
<path d="M6 4H9" stroke="currentColor" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@ -147,7 +147,6 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import StepsIcon from '@/components/Icons/StepsIcon.vue'
|
||||
import Section from '@/components/Section.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import PinIcon from '@/components/Icons/PinIcon.vue'
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import SquareAsterisk from '@/components/Icons/SquareAsterisk.vue'
|
||||
@ -233,11 +232,6 @@ const links = [
|
||||
icon: PhoneIcon,
|
||||
to: 'Call Logs',
|
||||
},
|
||||
{
|
||||
label: 'Email Templates',
|
||||
icon: Email2Icon,
|
||||
to: 'Email Templates',
|
||||
},
|
||||
]
|
||||
|
||||
const allViews = computed(() => {
|
||||
|
||||
@ -1,226 +0,0 @@
|
||||
<template>
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
:options="{
|
||||
onRowClick: (row) => emit('showEmailTemplate', row.name),
|
||||
selectable: options.selectable,
|
||||
showTooltip: options.showTooltip,
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@columnWidthUpdated="emit('columnWidthUpdated')"
|
||||
>
|
||||
<ListHeaderItem
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:item="column"
|
||||
@columnWidthUpdated="emit('columnWidthUpdated', column)"
|
||||
>
|
||||
<Button
|
||||
v-if="column.key == '_liked_by'"
|
||||
variant="ghosted"
|
||||
class="!h-4"
|
||||
:class="isLikeFilterApplied ? 'fill-red-500' : 'fill-white'"
|
||||
@click="() => emit('applyLikeFilter')"
|
||||
>
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows
|
||||
class="mx-3 sm:mx-5"
|
||||
:rows="rows"
|
||||
v-slot="{ idx, column, item }"
|
||||
doctype="Email Template"
|
||||
>
|
||||
<ListRowItem :item="item" :align="column.align">
|
||||
<!-- <template #prefix>
|
||||
|
||||
</template> -->
|
||||
<template #default="{ label }">
|
||||
<div
|
||||
v-if="['modified', 'creation'].includes(column.key)"
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
<Tooltip :text="item.label">
|
||||
<div>{{ item.timeAgo }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'status'" class="truncate text-base">
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="item.color"
|
||||
size="md"
|
||||
:label="item.label"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.type === 'Check'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:modelValue="item"
|
||||
:disabled="true"
|
||||
class="text-ink-gray-9"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === '_liked_by'">
|
||||
<Button
|
||||
v-if="column.key == '_liked_by'"
|
||||
variant="ghosted"
|
||||
:class="isLiked(item) ? 'fill-red-500' : 'fill-white'"
|
||||
@click.stop.prevent="
|
||||
() => emit('likeDoc', { name: row.name, liked: isLiked(item) })
|
||||
"
|
||||
>
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
</ListRowItem>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ selections, unselectAll }">
|
||||
<Dropdown
|
||||
:options="listBulkActionsRef.bulkActions(selections, unselectAll)"
|
||||
>
|
||||
<Button icon="more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<ListFooter
|
||||
class="border-t sm:px-5 px-3 py-2"
|
||||
v-model="pageLengthCount"
|
||||
:options="{
|
||||
rowCount: options.rowCount,
|
||||
totalCount: options.totalCount,
|
||||
}"
|
||||
@loadMore="emit('loadMore')"
|
||||
/>
|
||||
<ListBulkActions
|
||||
ref="listBulkActionsRef"
|
||||
v-model="list"
|
||||
doctype="Email Template"
|
||||
:options="{
|
||||
hideAssign: true,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import HeartIcon from '@/components/Icons/HeartIcon.vue'
|
||||
import ListBulkActions from '@/components/ListBulkActions.vue'
|
||||
import ListRows from '@/components/ListViews/ListRows.vue'
|
||||
import {
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRowItem,
|
||||
ListFooter,
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
selectable: true,
|
||||
showTooltip: true,
|
||||
resizeColumn: false,
|
||||
totalCount: 0,
|
||||
rowCount: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'loadMore',
|
||||
'updatePageCount',
|
||||
'showEmailTemplate',
|
||||
'columnWidthUpdated',
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const pageLengthCount = defineModel()
|
||||
const list = defineModel('list')
|
||||
|
||||
const isLikeFilterApplied = computed(() => {
|
||||
return list.value.params?.filters?._liked_by ? true : false
|
||||
})
|
||||
|
||||
const { user } = sessionStore()
|
||||
|
||||
function isLiked(item) {
|
||||
if (item) {
|
||||
let likedByMe = JSON.parse(item)
|
||||
return likedByMe.includes(user)
|
||||
}
|
||||
}
|
||||
|
||||
watch(pageLengthCount, (val, old_value) => {
|
||||
if (val === old_value) return
|
||||
emit('updatePageCount', val)
|
||||
})
|
||||
|
||||
const listBulkActionsRef = ref(null)
|
||||
|
||||
defineExpose({
|
||||
customListActions: computed(
|
||||
() => listBulkActionsRef.value?.customListActions,
|
||||
),
|
||||
})
|
||||
</script>
|
||||
@ -37,7 +37,7 @@
|
||||
</span>
|
||||
<FeatherIcon
|
||||
name="external-link"
|
||||
class="h-4 w-4"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@click.stop="viewLinkedDoc(row)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,239 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: editMode ? __(emailTemplate.name) : __('Create Email Template'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? __('Update') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => (editMode ? updateEmailTemplate() : callInsertDoc()),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex sm:flex-row flex-col gap-4">
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
ref="nameRef"
|
||||
v-model="_emailTemplate.name"
|
||||
:placeholder="__('Payment Reminder')"
|
||||
:label="__('Name')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="_emailTemplate.reference_doctype"
|
||||
:label="__('Doctype')"
|
||||
:options="['CRM Deal', 'CRM Lead']"
|
||||
:placeholder="__('CRM Deal')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
ref="subjectRef"
|
||||
v-model="_emailTemplate.subject"
|
||||
:label="__('Subject')"
|
||||
:placeholder="__('Payment Reminder from Frappé - (#{{ name }})')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="_emailTemplate.content_type"
|
||||
:label="__('Content Type')"
|
||||
default="Rich Text"
|
||||
:options="['Rich Text', 'HTML']"
|
||||
:placeholder="__('Rich Text')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-if="_emailTemplate.content_type === 'HTML'"
|
||||
type="textarea"
|
||||
:label="__('Content')"
|
||||
:required="true"
|
||||
ref="content"
|
||||
:rows="10"
|
||||
v-model="_emailTemplate.response_html"
|
||||
:placeholder="
|
||||
__(
|
||||
'<p>Dear {{ lead_name }},</p>\n\n<p>This is a reminder for the payment of {{ grand_total }}.</p>\n\n<p>Thanks,</p>\n<p>Frappé</p>',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div v-else>
|
||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
||||
{{ __('Content') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
ref="content"
|
||||
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
||||
:bubbleMenu="true"
|
||||
:content="_emailTemplate.response"
|
||||
@change="(val) => (_emailTemplate.response = val)"
|
||||
:placeholder="
|
||||
__(
|
||||
'Dear {{ lead_name }}, \n\nThis is a reminder for the payment of {{ grand_total }}. \n\nThanks, \nFrappé',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox v-model="_emailTemplate.enabled" :label="__('Enabled')" />
|
||||
</div>
|
||||
<ErrorMessage :message="__(errorMessage)" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { capture } from '@/telemetry'
|
||||
import { Checkbox, TextEditor, call } from 'frappe-ui'
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
emailTemplate: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
})
|
||||
|
||||
const show = defineModel()
|
||||
const emailTemplates = defineModel('reloadEmailTemplates')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const emit = defineEmits(['after'])
|
||||
|
||||
const subjectRef = ref(null)
|
||||
const nameRef = ref(null)
|
||||
const editMode = ref(false)
|
||||
let _emailTemplate = ref({
|
||||
content_type: 'Rich Text',
|
||||
})
|
||||
|
||||
async function updateEmailTemplate() {
|
||||
if (!validate()) return
|
||||
const old = { ...props.emailTemplate }
|
||||
const newEmailTemplate = { ..._emailTemplate.value }
|
||||
|
||||
const nameChanged = old.name !== newEmailTemplate.name
|
||||
delete old.name
|
||||
delete newEmailTemplate.name
|
||||
|
||||
const otherFieldChanged =
|
||||
JSON.stringify(old) !== JSON.stringify(newEmailTemplate)
|
||||
const values = newEmailTemplate
|
||||
|
||||
if (!nameChanged && !otherFieldChanged) {
|
||||
show.value = false
|
||||
return
|
||||
}
|
||||
|
||||
let name
|
||||
if (nameChanged) {
|
||||
name = await callRenameDoc()
|
||||
}
|
||||
if (otherFieldChanged) {
|
||||
name = await callSetValue(values)
|
||||
}
|
||||
handleEmailTemplateUpdate({ name })
|
||||
}
|
||||
|
||||
async function callRenameDoc() {
|
||||
const d = await call('frappe.client.rename_doc', {
|
||||
doctype: 'Email Template',
|
||||
old_name: props.emailTemplate.name,
|
||||
new_name: _emailTemplate.value.name,
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
async function callSetValue(values) {
|
||||
const d = await call('frappe.client.set_value', {
|
||||
doctype: 'Email Template',
|
||||
name: _emailTemplate.value.name,
|
||||
fieldname: values,
|
||||
})
|
||||
return d.name
|
||||
}
|
||||
|
||||
async function callInsertDoc() {
|
||||
if (!validate()) return
|
||||
const doc = await call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Email Template',
|
||||
..._emailTemplate.value,
|
||||
},
|
||||
})
|
||||
if (doc.name) {
|
||||
capture('email_template_created', { doctype: doc.reference_doctype })
|
||||
handleEmailTemplateUpdate(doc)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmailTemplateUpdate(doc) {
|
||||
emailTemplates.value?.reload()
|
||||
show.value = false
|
||||
}
|
||||
|
||||
function validate() {
|
||||
_emailTemplate.value.use_html = Boolean(
|
||||
_emailTemplate.value.content_type == 'HTML',
|
||||
)
|
||||
if (!_emailTemplate.value.name) {
|
||||
errorMessage.value = 'Name is required'
|
||||
return false
|
||||
}
|
||||
if (!_emailTemplate.value.subject) {
|
||||
errorMessage.value = 'Subject is required'
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!_emailTemplate.value.use_html &&
|
||||
(!_emailTemplate.value.response ||
|
||||
_emailTemplate.value.response === '<p></p>')
|
||||
) {
|
||||
errorMessage.value = 'Content is required'
|
||||
return false
|
||||
}
|
||||
if (_emailTemplate.value.use_html && !_emailTemplate.value.response_html) {
|
||||
errorMessage.value = 'Content is required'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => show.value,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
editMode.value = false
|
||||
errorMessage.value = ''
|
||||
nextTick(() => {
|
||||
if (_emailTemplate.value.name) {
|
||||
subjectRef.value?.el?.focus()
|
||||
} else {
|
||||
nameRef.value?.el?.focus()
|
||||
}
|
||||
_emailTemplate.value = { ...props.emailTemplate }
|
||||
_emailTemplate.value.content_type = _emailTemplate.value.use_html
|
||||
? 'HTML'
|
||||
: 'Rich Text'
|
||||
if (_emailTemplate.value.name) {
|
||||
editMode.value = true
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@ -4,19 +4,33 @@
|
||||
:options="{ title: __('Email Templates'), size: '4xl' }"
|
||||
>
|
||||
<template #body-content>
|
||||
<TextInput
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="__('Payment Reminder')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<div class="flex items-center gap-2">
|
||||
<TextInput
|
||||
class="w-full"
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="__('Payment Reminder')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<Button
|
||||
:label="__('Create')"
|
||||
icon-left="plus"
|
||||
@click="
|
||||
() => {
|
||||
show = false
|
||||
showSettings = true
|
||||
activeSettingsPage = 'Email Templates'
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredTemplates.length"
|
||||
class="mt-2 grid max-h-[560px] sm:grid-cols-3 gris-cols-1 gap-2 overflow-y-auto"
|
||||
class="mt-4 grid max-h-[560px] sm:grid-cols-3 gris-cols-1 gap-2 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
@ -57,11 +71,8 @@
|
||||
@click="
|
||||
() => {
|
||||
show = false
|
||||
emailTemplate = {
|
||||
reference_doctype: props.doctype,
|
||||
enabled: 1,
|
||||
}
|
||||
showEmailTemplateModal = true
|
||||
showSettings = true
|
||||
activeSettingsPage = 'Email Templates'
|
||||
}
|
||||
"
|
||||
/>
|
||||
@ -69,14 +80,10 @@
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<EmailTemplateModal
|
||||
v-model="showEmailTemplateModal"
|
||||
:emailTemplate="emailTemplate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||
import { showSettings, activeSettingsPage } from '@/composables/settings'
|
||||
import { TextEditor, createListResource } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
|
||||
@ -89,9 +96,6 @@ const props = defineProps({
|
||||
|
||||
const show = defineModel()
|
||||
const searchInput = ref('')
|
||||
const showEmailTemplateModal = ref(false)
|
||||
|
||||
const emailTemplate = ref({})
|
||||
|
||||
const emit = defineEmits(['apply'])
|
||||
|
||||
|
||||
@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-1 -ml-4 w-9/12">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="__(template.name)"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'template-list')"
|
||||
class="text-xl !h-7 font-semibold 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"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('Update')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
:disabled="!dirty"
|
||||
:loading="renameDoc.loading || templates.setValue.loading"
|
||||
@click="updateTemplate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fields -->
|
||||
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||
<div
|
||||
class="flex justify-between items-center cursor-pointer border-b py-3"
|
||||
@click="() => (template.enabled = !template.enabled)"
|
||||
>
|
||||
<div class="text-base text-ink-gray-7">{{ __('Enabled') }}</div>
|
||||
<Switch v-model="template.enabled" @click.stop />
|
||||
</div>
|
||||
<div class="flex sm:flex-row flex-col gap-4">
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
size="md"
|
||||
v-model="template.name"
|
||||
:placeholder="__('Payment Reminder')"
|
||||
:label="__('Name')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
type="select"
|
||||
size="md"
|
||||
v-model="template.reference_doctype"
|
||||
:label="__('For')"
|
||||
:options="[
|
||||
{
|
||||
label: __('Deal'),
|
||||
value: 'CRM Deal',
|
||||
},
|
||||
{
|
||||
label: __('Lead'),
|
||||
value: 'CRM Lead',
|
||||
},
|
||||
]"
|
||||
:placeholder="__('Deal')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
ref="subjectRef"
|
||||
size="md"
|
||||
v-model="template.subject"
|
||||
:label="__('Subject')"
|
||||
:placeholder="__('Payment Reminder from Frappé - (#{{ name }})')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t pt-4">
|
||||
<FormControl
|
||||
type="select"
|
||||
size="md"
|
||||
v-model="template.content_type"
|
||||
:label="__('Content Type')"
|
||||
default="Rich Text"
|
||||
:options="['Rich Text', 'HTML']"
|
||||
:placeholder="__('Rich Text')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-if="template.content_type === 'HTML'"
|
||||
size="md"
|
||||
type="textarea"
|
||||
:label="__('Content')"
|
||||
:required="true"
|
||||
ref="content"
|
||||
:rows="10"
|
||||
v-model="template.response_html"
|
||||
:placeholder="
|
||||
__(
|
||||
'<p>Dear {{ lead_name }},</p>\n\n<p>This is a reminder for the payment of {{ grand_total }}.</p>\n\n<p>Thanks,</p>\n<p>Frappé</p>',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div v-else>
|
||||
<div class="mb-1.5 text-base text-ink-gray-5">
|
||||
{{ __('Content') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
ref="content"
|
||||
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"
|
||||
:bubbleMenu="true"
|
||||
:content="template.response"
|
||||
@change="(val) => (template.response = val)"
|
||||
:placeholder="
|
||||
__(
|
||||
'Dear {{ lead_name }}, \n\nThis is a reminder for the payment of {{ grand_total }}. \n\nThanks, \nFrappé',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorMessage">
|
||||
<ErrorMessage :message="__(errorMessage)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
TextEditor,
|
||||
FormControl,
|
||||
Switch,
|
||||
toast,
|
||||
call,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
templateData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const errorMessage = ref('')
|
||||
|
||||
const templates = inject('templates')
|
||||
const template = ref({})
|
||||
|
||||
const updateTemplate = async () => {
|
||||
errorMessage.value = ''
|
||||
if (!template.value.name) {
|
||||
errorMessage.value = __('Name is required')
|
||||
return
|
||||
}
|
||||
if (!template.value.subject) {
|
||||
errorMessage.value = __('Subject is required')
|
||||
return
|
||||
}
|
||||
if (template.value.content_type === 'Rich Text' && !template.value.response) {
|
||||
errorMessage.value = __('Content is required')
|
||||
return
|
||||
}
|
||||
if (template.value.content_type === 'HTML' && !template.value.response_html) {
|
||||
errorMessage.value = __('Content is required')
|
||||
return
|
||||
}
|
||||
|
||||
template.value.use_html = template.value.content_type === 'HTML'
|
||||
|
||||
const old = {
|
||||
...props.templateData,
|
||||
use_html: Boolean(props.templateData.use_html),
|
||||
}
|
||||
const newEmailTemplate = {
|
||||
...template.value,
|
||||
use_html: Boolean(template.value.use_html),
|
||||
}
|
||||
|
||||
delete newEmailTemplate.content_type
|
||||
|
||||
const nameChanged = old.name !== newEmailTemplate.name
|
||||
delete old.name
|
||||
delete newEmailTemplate.name
|
||||
|
||||
const otherFieldChanged =
|
||||
JSON.stringify(old) !== JSON.stringify(newEmailTemplate)
|
||||
const values = newEmailTemplate
|
||||
|
||||
if (!nameChanged && !otherFieldChanged) return
|
||||
|
||||
let name = props.templateData.name
|
||||
|
||||
if (nameChanged) {
|
||||
name = await renameDoc.fetch()
|
||||
if (!otherFieldChanged) {
|
||||
emit('updateStep', 'template-list')
|
||||
}
|
||||
}
|
||||
if (otherFieldChanged) {
|
||||
templates.setValue.submit(
|
||||
{ ...values, name },
|
||||
{
|
||||
onSuccess: () => {
|
||||
emit('updateStep', 'template-list')
|
||||
toast.success(__('Template updated successfully'))
|
||||
},
|
||||
onError: (error) => {
|
||||
errorMessage.value =
|
||||
error.messages[0] || __('Failed to update template')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = computed(() => {
|
||||
return (
|
||||
template.value.name !== props.templateData.name ||
|
||||
template.value.reference_doctype !== props.templateData.reference_doctype ||
|
||||
template.value.subject !== props.templateData.subject ||
|
||||
template.value.response_html !== props.templateData.response_html ||
|
||||
template.value.response !== props.templateData.response ||
|
||||
template.value.use_html !== props.templateData.use_html ||
|
||||
Boolean(template.value.enabled) !== Boolean(props.templateData.enabled)
|
||||
)
|
||||
})
|
||||
|
||||
const renameDoc = createResource({
|
||||
url: 'frappe.client.rename_doc',
|
||||
method: 'POST',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'Email Template',
|
||||
old_name: props.templateData.name,
|
||||
new_name: template.value.name,
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
templates.reload()
|
||||
toast.success(__('Template renamed successfully'))
|
||||
},
|
||||
onError: (error) => {
|
||||
errorMessage.value = error.messages[0] || __('Failed to rename template')
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
template.value = { ...props.templateData }
|
||||
template.value.content_type = template.value.use_html ? 'HTML' : 'Rich Text'
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<NewEmailTemplate
|
||||
v-if="step === 'new-template'"
|
||||
:templateData="template"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<EmailTemplates
|
||||
v-else-if="step === 'template-list'"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<EditEmailTemplate
|
||||
v-else-if="step === 'edit-template'"
|
||||
:templateData="template"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NewEmailTemplate from './NewEmailTemplate.vue'
|
||||
import EditEmailTemplate from './EditEmailTemplate.vue'
|
||||
import EmailTemplates from './EmailTemplates.vue'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
const step = ref('template-list')
|
||||
const template = ref(null)
|
||||
|
||||
const templates = createListResource({
|
||||
type: 'list',
|
||||
doctype: 'Email Template',
|
||||
cache: 'emailTemplates',
|
||||
fields: [
|
||||
'name',
|
||||
'enabled',
|
||||
'use_html',
|
||||
'reference_doctype',
|
||||
'subject',
|
||||
'response',
|
||||
'response_html',
|
||||
'modified',
|
||||
'owner',
|
||||
],
|
||||
auto: true,
|
||||
filters: { reference_doctype: ['in', ['CRM Lead', 'CRM Deal']] },
|
||||
orderBy: 'modified desc',
|
||||
pageLength: 20,
|
||||
})
|
||||
|
||||
provide('templates', templates)
|
||||
|
||||
function updateStep(newStep, data) {
|
||||
step.value = newStep
|
||||
template.value = data
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between px-2 pt-2">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Email templates') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Add, edit, and manage email templates for various CRM communications',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('New')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
@click="emit('updateStep', 'new-template')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- loading state -->
|
||||
<div
|
||||
v-if="templates.loading"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<Button
|
||||
:loading="templates.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!templates.loading && !templates.data?.length"
|
||||
class="flex justify-between w-full h-full"
|
||||
>
|
||||
<div
|
||||
class="text-ink-gray-4 border border-dashed rounded w-full flex items-center justify-center"
|
||||
>
|
||||
{{ __('No email templates found') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email template list -->
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
v-if="!templates.loading && templates.data?.length"
|
||||
>
|
||||
<div
|
||||
v-if="templates.data?.length > 10"
|
||||
class="flex items-center justify-between mb-4 px-2 pt-0.5"
|
||||
>
|
||||
<TextInput
|
||||
ref="searchRef"
|
||||
v-model="search"
|
||||
:placeholder="__('Search template')"
|
||||
class="w-1/3"
|
||||
:debounce="300"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-6" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="currentDoctype"
|
||||
:options="[
|
||||
{ label: __('All'), value: 'All' },
|
||||
{ label: __('Lead'), value: 'CRM Lead' },
|
||||
{ label: __('Deal'), value: 'CRM Deal' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center py-2 px-4 text-sm text-ink-gray-5">
|
||||
<div class="w-4/6">{{ __('Template name') }}</div>
|
||||
<div class="w-1/6">{{ __('For') }}</div>
|
||||
<div class="w-1/6">{{ __('Enabled') }}</div>
|
||||
</div>
|
||||
<div class="h-px border-t mx-4 border-outline-gray-modals" />
|
||||
<ul class="overflow-y-auto px-2">
|
||||
<template v-for="(template, i) in templatesList" :key="template.name">
|
||||
<li
|
||||
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
@click="() => emit('updateStep', 'edit-template', { ...template })"
|
||||
>
|
||||
<div class="flex flex-col w-4/6 pr-5">
|
||||
<div class="text-base font-medium text-ink-gray-7 truncate">
|
||||
{{ template.name }}
|
||||
</div>
|
||||
<div class="text-p-base text-ink-gray-5 truncate">
|
||||
{{ template.subject }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-base text-ink-gray-6 w-1/6">
|
||||
{{ template.reference_doctype.replace('CRM ', '') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-1/6">
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="template.enabled"
|
||||
@update:model-value="toggleEmailTemplate(template)"
|
||||
@click.stop
|
||||
/>
|
||||
<Dropdown
|
||||
class=""
|
||||
:options="getDropdownOptions(template)"
|
||||
placement="right"
|
||||
:button="{
|
||||
icon: 'more-horizontal',
|
||||
variant: 'ghost',
|
||||
onblur: (e) => {
|
||||
e.stopPropagation()
|
||||
confirmDelete = false
|
||||
},
|
||||
}"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
v-if="templatesList.length !== i + 1"
|
||||
class="h-px border-t mx-2 border-outline-gray-modals"
|
||||
/>
|
||||
</template>
|
||||
<!-- Load More Button -->
|
||||
<div
|
||||
v-if="!templates.loading && templates.hasNextPage"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<Button
|
||||
class="mt-3.5 p-2"
|
||||
@click="() => templates.next()"
|
||||
:loading="templates.loading"
|
||||
:label="__('Load More')"
|
||||
icon-left="refresh-cw"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TemplateOption } from '@/utils'
|
||||
import {
|
||||
TextInput,
|
||||
FormControl,
|
||||
Switch,
|
||||
Dropdown,
|
||||
FeatherIcon,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, computed, inject } from 'vue'
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const templates = inject('templates')
|
||||
|
||||
const search = ref('')
|
||||
const currentDoctype = ref('All')
|
||||
const confirmDelete = ref(false)
|
||||
|
||||
const templatesList = computed(() => {
|
||||
let list = templates.data || []
|
||||
if (search.value) {
|
||||
list = list.filter(
|
||||
(template) =>
|
||||
template.name.toLowerCase().includes(search.value.toLowerCase()) ||
|
||||
template.subject.toLowerCase().includes(search.value.toLowerCase()),
|
||||
)
|
||||
}
|
||||
if (currentDoctype.value !== 'All') {
|
||||
list = list.filter(
|
||||
(template) => template.reference_doctype === currentDoctype.value,
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function toggleEmailTemplate(template) {
|
||||
templates.setValue.submit(
|
||||
{
|
||||
name: template.name,
|
||||
enabled: template.enabled ? 1 : 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
template.enabled
|
||||
? __('Template enabled successfully')
|
||||
: __('Template disabled successfully'),
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.messages[0] || __('Failed to update template'))
|
||||
// Revert the change if there was an error
|
||||
template.enabled = !template.enabled
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function deleteTemplate(template) {
|
||||
confirmDelete.value = false
|
||||
templates.delete.submit(template.name, {
|
||||
onSuccess: () => {
|
||||
toast.success(__('Template deleted successfully'))
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.messages[0] || __('Failed to delete template'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getDropdownOptions(template) {
|
||||
let options = [
|
||||
{
|
||||
label: __('Duplicate'),
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Duplicate'),
|
||||
icon: 'copy',
|
||||
active: props.active,
|
||||
onClick: () => emit('updateStep', 'new-template', { ...template }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: __('Delete'),
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Delete'),
|
||||
icon: 'trash-2',
|
||||
active: props.active,
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
confirmDelete.value = true
|
||||
},
|
||||
}),
|
||||
condition: () => !confirmDelete.value,
|
||||
},
|
||||
{
|
||||
label: __('Confirm Delete'),
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Confirm Delete'),
|
||||
icon: 'trash-2',
|
||||
active: props.active,
|
||||
theme: 'danger',
|
||||
onClick: () => deleteTemplate(template),
|
||||
}),
|
||||
condition: () => confirmDelete.value,
|
||||
},
|
||||
]
|
||||
|
||||
return options.filter((option) => option.condition?.() || true)
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-1 -ml-4 w-9/12">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="
|
||||
templateData?.name ? __('Duplicate template') : __('New template')
|
||||
"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'template-list')"
|
||||
class="text-xl !h-7 font-semibold 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"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="templateData?.name ? __('Duplicate') : __('Create')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
@click="createTemplate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fields -->
|
||||
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||
<div
|
||||
class="flex justify-between items-center cursor-pointer border-b py-3"
|
||||
@click="() => (template.enabled = !template.enabled)"
|
||||
>
|
||||
<div class="text-base text-ink-gray-7">{{ __('Enabled') }}</div>
|
||||
<Switch v-model="template.enabled" @click.stop />
|
||||
</div>
|
||||
<div class="flex sm:flex-row flex-col gap-4">
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
size="md"
|
||||
v-model="template.name"
|
||||
:placeholder="__('Payment Reminder')"
|
||||
:label="__('Name')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
type="select"
|
||||
size="md"
|
||||
v-model="template.reference_doctype"
|
||||
:label="__('For')"
|
||||
:options="[
|
||||
{
|
||||
label: __('Deal'),
|
||||
value: 'CRM Deal',
|
||||
},
|
||||
{
|
||||
label: __('Lead'),
|
||||
value: 'CRM Lead',
|
||||
},
|
||||
]"
|
||||
:placeholder="__('Deal')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
ref="subjectRef"
|
||||
size="md"
|
||||
v-model="template.subject"
|
||||
:label="__('Subject')"
|
||||
:placeholder="__('Payment Reminder from Frappé - (#{{ name }})')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t pt-4">
|
||||
<FormControl
|
||||
type="select"
|
||||
size="md"
|
||||
v-model="template.content_type"
|
||||
:label="__('Content Type')"
|
||||
default="Rich Text"
|
||||
:options="['Rich Text', 'HTML']"
|
||||
:placeholder="__('Rich Text')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-if="template.content_type === 'HTML'"
|
||||
size="md"
|
||||
type="textarea"
|
||||
:label="__('Content')"
|
||||
:required="true"
|
||||
ref="content"
|
||||
:rows="10"
|
||||
v-model="template.response_html"
|
||||
:placeholder="
|
||||
__(
|
||||
'<p>Dear {{ lead_name }},</p>\n\n<p>This is a reminder for the payment of {{ grand_total }}.</p>\n\n<p>Thanks,</p>\n<p>Frappé</p>',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div v-else>
|
||||
<div class="mb-1.5 text-base text-ink-gray-5">
|
||||
{{ __('Content') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
ref="content"
|
||||
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"
|
||||
:bubbleMenu="true"
|
||||
:content="template.response"
|
||||
@change="(val) => (template.response = val)"
|
||||
:placeholder="
|
||||
__(
|
||||
'Dear {{ lead_name }}, \n\nThis is a reminder for the payment of {{ grand_total }}. \n\nThanks, \nFrappé',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorMessage">
|
||||
<ErrorMessage :message="__(errorMessage)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TextEditor, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { inject, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
templateData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const errorMessage = ref('')
|
||||
|
||||
const template = ref({
|
||||
name: '',
|
||||
reference_doctype: 'CRM Deal',
|
||||
subject: '',
|
||||
content_type: 'Rich Text',
|
||||
response_html: '',
|
||||
response: '',
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const templates = inject('templates')
|
||||
|
||||
const createTemplate = () => {
|
||||
errorMessage.value = ''
|
||||
if (!template.value.name) {
|
||||
errorMessage.value = __('Name is required')
|
||||
return
|
||||
}
|
||||
if (!template.value.subject) {
|
||||
errorMessage.value = __('Subject is required')
|
||||
return
|
||||
}
|
||||
if (template.value.content_type === 'Rich Text' && !template.value.response) {
|
||||
errorMessage.value = __('Content is required')
|
||||
return
|
||||
}
|
||||
if (template.value.content_type === 'HTML' && !template.value.response_html) {
|
||||
errorMessage.value = __('Content is required')
|
||||
return
|
||||
}
|
||||
|
||||
templates.insert.submit(
|
||||
{ ...template.value },
|
||||
{
|
||||
onSuccess: () => {
|
||||
emit('updateStep', 'template-list')
|
||||
toast.success(__('Template created successfully'))
|
||||
},
|
||||
onError: (error) => {
|
||||
errorMessage.value =
|
||||
error.messages[0] || __('Failed to create template')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.templateData?.name) {
|
||||
Object.assign(template.value, props.templateData)
|
||||
template.value.name = template.value.name + ' - Copy'
|
||||
template.value.enabled = false // Default to disabled for new templates
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -45,12 +45,14 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
||||
import Users from '@/components/Settings/Users.vue'
|
||||
import GeneralSettings from '@/components/Settings/GeneralSettings.vue'
|
||||
import InviteUserPage from '@/components/Settings/InviteUserPage.vue'
|
||||
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
||||
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
||||
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
|
||||
import EmailTemplatePage from '@/components/Settings/EmailTemplate/EmailTemplatePage.vue'
|
||||
import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
|
||||
import EmailConfig from '@/components/Settings/EmailConfig.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
@ -107,6 +109,11 @@ const tabs = computed(() => {
|
||||
component: markRaw(EmailConfig),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Email Templates'),
|
||||
icon: EmailTemplateIcon,
|
||||
component: markRaw(EmailTemplatePage),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<div class="flex h-full flex-col gap-6 p-6 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between">
|
||||
<div class="flex justify-between px-2 pt-2">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Users') }}
|
||||
@ -63,7 +63,10 @@
|
||||
class="flex flex-col overflow-hidden"
|
||||
v-if="!users.loading && users.data?.crmUsers?.length > 1"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div
|
||||
v-if="users.data?.crmUsers?.length > 10"
|
||||
class="flex items-center justify-between mb-4 px-2 pt-0.5"
|
||||
>
|
||||
<TextInput
|
||||
ref="searchRef"
|
||||
v-model="search"
|
||||
@ -86,7 +89,7 @@
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<ul class="divide-y divide-outline-gray-modals overflow-y-auto">
|
||||
<ul class="divide-y divide-outline-gray-modals overflow-y-auto px-2">
|
||||
<template v-for="user in usersList" :key="user.name">
|
||||
<li class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center">
|
||||
@ -109,14 +112,31 @@
|
||||
:options="getMoreOptions(user)"
|
||||
:button="{
|
||||
icon: 'more-horizontal',
|
||||
onblur: (e) => {
|
||||
e.stopPropagation()
|
||||
confirmRemove = false
|
||||
},
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
<Tooltip
|
||||
v-if="isManager() && user.role == 'System Manager'"
|
||||
:text="__('Cannot change role of user with Admin access')"
|
||||
>
|
||||
<Button :label="__('Admin')" icon-left="shield" />
|
||||
</Tooltip>
|
||||
<Dropdown
|
||||
v-else
|
||||
:options="getDropdownOptions(user)"
|
||||
:button="{
|
||||
label: roleMap[user.role],
|
||||
iconRight: 'chevron-down',
|
||||
iconLeft:
|
||||
user.role === 'System Manager'
|
||||
? 'shield'
|
||||
: user.role === 'Sales Manager'
|
||||
? 'briefcase'
|
||||
: 'user-check',
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
@ -146,12 +166,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LucideCheck from '~icons/lucide/check'
|
||||
import AddExistingUserModal from '@/components/Modals/AddExistingUserModal.vue'
|
||||
import { activeSettingsPage } from '@/composables/settings'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { Avatar, TextInput, toast, call } from 'frappe-ui'
|
||||
import { ref, computed, h, onMounted } from 'vue'
|
||||
import { TemplateOption, DropdownOption } from '@/utils'
|
||||
import { Avatar, TextInput, toast, call, FeatherIcon, Tooltip } from 'frappe-ui'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const { users, isAdmin, isManager } = usersStore()
|
||||
|
||||
@ -182,12 +202,36 @@ const usersList = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
function getMoreOptions(user) {
|
||||
let options = [
|
||||
{
|
||||
label: __('Remove'),
|
||||
icon: 'trash-2',
|
||||
onClick: () => removeUser(user, true),
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Remove'),
|
||||
icon: 'trash-2',
|
||||
active: props.active,
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
confirmRemove.value = true
|
||||
},
|
||||
}),
|
||||
condition: () => !confirmRemove.value,
|
||||
},
|
||||
{
|
||||
label: __('Confirm Remove'),
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Confirm Remove'),
|
||||
icon: 'trash-2',
|
||||
active: props.active,
|
||||
theme: 'danger',
|
||||
onClick: () => removeUser(user, true),
|
||||
}),
|
||||
condition: () => confirmRemove.value,
|
||||
},
|
||||
]
|
||||
|
||||
@ -199,8 +243,9 @@ function getDropdownOptions(user) {
|
||||
{
|
||||
label: __('Admin'),
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: __('Admin'),
|
||||
DropdownOption({
|
||||
option: __('Admin'),
|
||||
icon: 'shield',
|
||||
active: props.active,
|
||||
selected: user.role === 'System Manager',
|
||||
onClick: () => updateRole(user, 'System Manager'),
|
||||
@ -210,8 +255,9 @@ function getDropdownOptions(user) {
|
||||
{
|
||||
label: __('Manager'),
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: __('Manager'),
|
||||
DropdownOption({
|
||||
option: __('Manager'),
|
||||
icon: 'briefcase',
|
||||
active: props.active,
|
||||
selected: user.role === 'Sales Manager',
|
||||
onClick: () => updateRole(user, 'Sales Manager'),
|
||||
@ -221,8 +267,9 @@ function getDropdownOptions(user) {
|
||||
{
|
||||
label: __('Sales User'),
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: __('Sales User'),
|
||||
DropdownOption({
|
||||
option: __('Sales User'),
|
||||
icon: 'user-check',
|
||||
active: props.active,
|
||||
selected: user.role === 'Sales User',
|
||||
onClick: () => updateRole(user, 'Sales User'),
|
||||
@ -233,28 +280,6 @@ function getDropdownOptions(user) {
|
||||
return options.filter((option) => option.condition?.() || true)
|
||||
}
|
||||
|
||||
function RoleOption({ active, role, onClick, selected }) {
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class: [
|
||||
active ? 'bg-surface-gray-2' : 'text-ink-gray-8',
|
||||
'group flex w-full justify-between items-center rounded-md px-2 py-2 text-sm',
|
||||
],
|
||||
onClick: !selected ? onClick : null,
|
||||
},
|
||||
[
|
||||
h('span', { class: 'whitespace-nowrap' }, role),
|
||||
selected
|
||||
? h(LucideCheck, {
|
||||
class: ['h-4 w-4 shrink-0 text-ink-gray-7'],
|
||||
'aria-hidden': true,
|
||||
})
|
||||
: null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function updateRole(user, newRole) {
|
||||
if (user.role === newRole) return
|
||||
|
||||
|
||||
@ -91,64 +91,52 @@
|
||||
<div class="flex gap-1.5">
|
||||
<Tooltip v-if="callEnabled" :text="__('Make a call')">
|
||||
<div>
|
||||
<Button class="h-7 w-7" @click="triggerCall">
|
||||
<template #icon>
|
||||
<PhoneIcon />
|
||||
</template>
|
||||
<Button @click="triggerCall">
|
||||
<template #icon><PhoneIcon /></template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Send an email')">
|
||||
<div>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
deal.data.email
|
||||
? openEmailBox()
|
||||
: toast.error(__('No email set'))
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<Email2Icon />
|
||||
</template>
|
||||
<template #icon><Email2Icon /></template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Go to website')">
|
||||
<div>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
deal.data.website
|
||||
? openWebsite(deal.data.website)
|
||||
: toast.error(__('No website set'))
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<LinkIcon />
|
||||
</template>
|
||||
<template #icon><LinkIcon /></template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Attach a file')">
|
||||
<div>
|
||||
<Button class="size-7" @click="showFilesUploader = true">
|
||||
<template #icon>
|
||||
<AttachmentIcon />
|
||||
</template>
|
||||
<Button @click="showFilesUploader = true">
|
||||
<template #icon><AttachmentIcon /></template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Delete')">
|
||||
<div>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="deleteDealWithModal(deal.data.name)"
|
||||
variant="subtle"
|
||||
icon="trash-2"
|
||||
theme="red"
|
||||
>
|
||||
<FeatherIcon name="trash" class="h-4 w-4" />
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Email Templates</h1>
|
||||
<p>Here is a list of email templates</p>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<LayoutHeader>
|
||||
<template #left-header>
|
||||
<ViewBreadcrumbs v-model="viewControls" routeName="Email Templates" />
|
||||
</template>
|
||||
<template #right-header>
|
||||
<CustomActions
|
||||
v-if="emailTemplatesListView?.customListActions"
|
||||
:actions="emailTemplatesListView.customListActions"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Create')"
|
||||
@click="() => showEmailTemplate()"
|
||||
>
|
||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
||||
</Button>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<ViewControls
|
||||
ref="viewControls"
|
||||
v-model="emailTemplates"
|
||||
v-model:loadMore="loadMore"
|
||||
v-model:resizeColumn="triggerResize"
|
||||
v-model:updatedPageCount="updatedPageCount"
|
||||
doctype="Email Template"
|
||||
/>
|
||||
<EmailTemplatesListView
|
||||
ref="emailTemplatesListView"
|
||||
v-if="emailTemplates.data && rows.length"
|
||||
v-model="emailTemplates.data.page_length_count"
|
||||
v-model:list="emailTemplates"
|
||||
:rows="rows"
|
||||
:columns="emailTemplates.data.columns"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
resizeColumn: true,
|
||||
rowCount: emailTemplates.data.row_count,
|
||||
totalCount: emailTemplates.data.total_count,
|
||||
}"
|
||||
@loadMore="() => loadMore++"
|
||||
@columnWidthUpdated="() => triggerResize++"
|
||||
@updatePageCount="(count) => (updatedPageCount = count)"
|
||||
@showEmailTemplate="showEmailTemplate"
|
||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-else-if="emailTemplates.data"
|
||||
class="flex h-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 text-xl font-medium text-ink-gray-4"
|
||||
>
|
||||
<Email2Icon class="h-10 w-10" />
|
||||
<span>{{ __('No {0} Found', [__('Email Templates')]) }}</span>
|
||||
<Button :label="__('Create')" @click="() => showEmailTemplate()">
|
||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EmailTemplateModal
|
||||
v-model="showEmailTemplateModal"
|
||||
v-model:reloadEmailTemplates="emailTemplates"
|
||||
:emailTemplate="emailTemplate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
|
||||
import CustomActions from '@/components/CustomActions.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import EmailTemplatesListView from '@/components/ListViews/EmailTemplatesListView.vue'
|
||||
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { formatDate, timeAgo } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
getMeta('Email Template')
|
||||
|
||||
const emailTemplatesListView = ref(null)
|
||||
|
||||
// emailTemplates data is loaded in the ViewControls component
|
||||
const emailTemplates = ref({})
|
||||
const loadMore = ref(1)
|
||||
const triggerResize = ref(1)
|
||||
const updatedPageCount = ref(20)
|
||||
const viewControls = ref(null)
|
||||
|
||||
const rows = computed(() => {
|
||||
if (
|
||||
!emailTemplates.value?.data?.data ||
|
||||
!['list', 'group_by'].includes(emailTemplates.value.data.view_type)
|
||||
)
|
||||
return []
|
||||
return emailTemplates.value?.data.data.map((emailTemplate) => {
|
||||
let _rows = {}
|
||||
emailTemplates.value?.data.rows.forEach((row) => {
|
||||
_rows[row] = emailTemplate[row]
|
||||
|
||||
let fieldType = emailTemplates.value?.data.columns?.find(
|
||||
(col) => (col.key || col.value) == row,
|
||||
)?.type
|
||||
|
||||
if (
|
||||
fieldType &&
|
||||
['Date', 'Datetime'].includes(fieldType) &&
|
||||
!['modified', 'creation'].includes(row)
|
||||
) {
|
||||
_rows[row] = formatDate(
|
||||
emailTemplate[row],
|
||||
'',
|
||||
true,
|
||||
fieldType == 'Datetime',
|
||||
)
|
||||
}
|
||||
|
||||
if (fieldType && fieldType == 'Currency') {
|
||||
_rows[row] = getFormattedCurrency(row, emailTemplate)
|
||||
}
|
||||
|
||||
if (fieldType && fieldType == 'Float') {
|
||||
_rows[row] = getFormattedFloat(row, emailTemplate)
|
||||
}
|
||||
|
||||
if (fieldType && fieldType == 'Percent') {
|
||||
_rows[row] = getFormattedPercent(row, emailTemplate)
|
||||
}
|
||||
|
||||
if (['modified', 'creation'].includes(row)) {
|
||||
_rows[row] = {
|
||||
label: formatDate(emailTemplate[row]),
|
||||
timeAgo: timeAgo(emailTemplate[row]),
|
||||
}
|
||||
}
|
||||
})
|
||||
return _rows
|
||||
})
|
||||
})
|
||||
|
||||
const showEmailTemplateModal = ref(false)
|
||||
|
||||
const emailTemplate = ref({})
|
||||
|
||||
function showEmailTemplate(name) {
|
||||
if (!name) {
|
||||
emailTemplate.value = {
|
||||
subject: '',
|
||||
response: '',
|
||||
response_html: '',
|
||||
name: '',
|
||||
enabled: 1,
|
||||
use_html: 0,
|
||||
owner: '',
|
||||
reference_doctype: 'CRM Deal',
|
||||
}
|
||||
} else {
|
||||
let et = rows.value?.find((row) => row.name === name)
|
||||
emailTemplate.value = {
|
||||
subject: et.subject,
|
||||
response: et.response,
|
||||
response_html: et.response_html,
|
||||
name: et.name,
|
||||
enabled: et.enabled,
|
||||
use_html: et.use_html,
|
||||
owner: et.owner,
|
||||
reference_doctype: et.reference_doctype,
|
||||
}
|
||||
}
|
||||
showEmailTemplateModal.value = true
|
||||
}
|
||||
</script>
|
||||
@ -134,7 +134,6 @@
|
||||
<Tooltip v-if="callEnabled" :text="__('Make a call')">
|
||||
<div>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
() =>
|
||||
lead.data.mobile_no
|
||||
@ -151,7 +150,6 @@
|
||||
<Tooltip :text="__('Send an email')">
|
||||
<div>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
lead.data.email
|
||||
? openEmailBox()
|
||||
@ -167,7 +165,6 @@
|
||||
<Tooltip :text="__('Go to website')">
|
||||
<div>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
lead.data.website
|
||||
? openWebsite(lead.data.website)
|
||||
@ -182,7 +179,7 @@
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Attach a file')">
|
||||
<div>
|
||||
<Button class="h-7 w-7" @click="showFilesUploader = true">
|
||||
<Button @click="showFilesUploader = true">
|
||||
<template #icon>
|
||||
<AttachmentIcon />
|
||||
</template>
|
||||
@ -192,13 +189,11 @@
|
||||
<Tooltip :text="__('Delete')">
|
||||
<div>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="deleteLeadWithModal(lead.data.name)"
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
>
|
||||
<FeatherIcon name="trash" class="h-4 w-4" />
|
||||
</Button>
|
||||
icon="trash-2"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@ -79,18 +79,6 @@ const routes = [
|
||||
name: 'Call Logs',
|
||||
component: () => import('@/pages/CallLogs.vue'),
|
||||
},
|
||||
{
|
||||
alias: '/email-templates',
|
||||
path: '/email-templates/view/:viewType?',
|
||||
name: 'Email Templates',
|
||||
component: () => import('@/pages/EmailTemplates.vue'),
|
||||
},
|
||||
{
|
||||
path: '/email-templates/:emailTemplateId',
|
||||
name: 'Email Template',
|
||||
component: () => import('@/pages/EmailTemplate.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/welcome',
|
||||
name: 'Welcome',
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import LucideCheck from '~icons/lucide/check'
|
||||
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
|
||||
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { gemoji } from 'gemoji'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { toast, dayjsLocal, dayjs, getConfig } from 'frappe-ui'
|
||||
import { toast, dayjsLocal, dayjs, getConfig, FeatherIcon } from 'frappe-ui'
|
||||
import { h } from 'vue'
|
||||
|
||||
export function formatTime(seconds) {
|
||||
@ -465,3 +466,66 @@ export function runSequentially(functions) {
|
||||
return promise.then(() => fn())
|
||||
}, Promise.resolve())
|
||||
}
|
||||
|
||||
export function DropdownOption({
|
||||
active,
|
||||
option,
|
||||
theme,
|
||||
icon,
|
||||
onClick,
|
||||
selected,
|
||||
}) {
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class: [
|
||||
active ? 'bg-surface-gray-2' : 'text-ink-gray-8',
|
||||
'group flex w-full justify-between items-center rounded-md px-2 py-2 text-sm',
|
||||
theme == 'danger' ? 'text-ink-red-3 hover:bg-ink-red-1' : '',
|
||||
],
|
||||
onClick: !selected ? onClick : null,
|
||||
},
|
||||
[
|
||||
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' }, option),
|
||||
]),
|
||||
selected
|
||||
? h(LucideCheck, {
|
||||
class: ['h-4 w-4 shrink-0 text-ink-gray-7'],
|
||||
'aria-hidden': true,
|
||||
})
|
||||
: null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function TemplateOption({ active, option, theme, icon, onClick }) {
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class: [
|
||||
active ? 'bg-surface-gray-2 text-ink-gray-8' : 'text-ink-gray-7',
|
||||
'group flex w-full gap-2 items-center rounded-md px-2 py-2 text-sm',
|
||||
theme == 'danger' ? 'text-ink-red-3 hover:bg-ink-red-1' : '',
|
||||
],
|
||||
onClick: onClick,
|
||||
},
|
||||
[
|
||||
icon
|
||||
? h(FeatherIcon, {
|
||||
name: icon,
|
||||
class: ['h-4 w-4 shrink-0'],
|
||||
'aria-hidden': true,
|
||||
})
|
||||
: null,
|
||||
h('span', { class: 'whitespace-nowrap' }, option),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@ -2570,10 +2570,10 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
frappe-ui@^0.1.156:
|
||||
version "0.1.156"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.156.tgz#1a476aec80b0e0f72470f9dc3990bb023b2ebb09"
|
||||
integrity sha512-JsIODLL7YYFhKSYfWJJ9M1+VMmj8M0xZ1D5M7Cx0c+OWg5Qm0xda1592Tr+om1a7u0zWcfjuQnW9mHN1lW5HIA==
|
||||
frappe-ui@^0.1.162:
|
||||
version "0.1.162"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.162.tgz#01a2f06e9db70b1bce6e0b0f2089a9cc1cb8dd51"
|
||||
integrity sha512-LdlEQ1I8oMj2TAmx0FGuJl+AwQ6/jqtwEy3lei3mH6SVArfGnoVDqLm8aeJTwAB6KUjgCj+ffWe6vN7HmZXIcg==
|
||||
dependencies:
|
||||
"@floating-ui/vue" "^1.1.6"
|
||||
"@headlessui/vue" "^1.7.14"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user