Merge pull request #990 from frappe/main-hotfix

This commit is contained in:
Shariq Ansari 2025-06-30 20:20:06 +05:30 committed by GitHub
commit e575f516d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2748 additions and 1259 deletions

View File

@ -11,6 +11,7 @@ from pypika import Criterion
from crm.api.views import get_views from crm.api.views import get_views
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
from crm.utils import get_dynamic_linked_docs, get_linked_docs
@frappe.whitelist() @frappe.whitelist()
@ -676,6 +677,7 @@ def remove_assignments(doctype, name, assignees, ignore_permissions=False):
ignore_permissions=ignore_permissions, ignore_permissions=ignore_permissions,
) )
@frappe.whitelist() @frappe.whitelist()
def get_assigned_users(doctype, name, default_assigned_to=None): def get_assigned_users(doctype, name, default_assigned_to=None):
assigned_users = frappe.get_all( assigned_users = frappe.get_all(
@ -744,3 +746,98 @@ def getCounts(d, doctype):
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")} "FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
) )
return d return d
@frappe.whitelist()
def get_linked_docs_of_document(doctype, docname):
doc = frappe.get_doc(doctype, docname)
linked_docs = get_linked_docs(doc)
dynamic_linked_docs = get_dynamic_linked_docs(doc)
linked_docs.extend(dynamic_linked_docs)
linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values())
docs_data = []
for doc in linked_docs:
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
title = data.get("title")
if data.doctype == "CRM Call Log":
title = f"Call from {data.get('from')} to {data.get('to')}"
if data.doctype == "CRM Deal":
title = data.get("organization")
docs_data.append(
{
"doc": data.doctype,
"title": title or data.get("name"),
"reference_docname": doc["reference_docname"],
"reference_doctype": doc["reference_doctype"],
}
)
return docs_data
def remove_doc_link(doctype, docname):
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"reference_doctype": None,
"reference_docname": None,
}
)
linked_doc_data.save(ignore_permissions=True)
def remove_contact_link(doctype, docname):
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"contact": None,
"contacts": [],
}
)
linked_doc_data.save(ignore_permissions=True)
@frappe.whitelist()
def remove_linked_doc_reference(items, remove_contact=None, delete=False):
if isinstance(items, str):
items = frappe.parse_json(items)
for item in items:
if remove_contact:
remove_contact_link(item["doctype"], item["docname"])
else:
remove_doc_link(item["doctype"], item["docname"])
if delete:
frappe.delete_doc(item["doctype"], item["docname"])
return "success"
@frappe.whitelist()
def delete_bulk_docs(doctype, items, delete_linked=False):
from frappe.desk.reportview import delete_bulk
items = frappe.parse_json(items)
for doc in items:
linked_docs = get_linked_docs_of_document(doctype, doc)
for linked_doc in linked_docs:
remove_linked_doc_reference(
[
{
"doctype": linked_doc["reference_doctype"],
"docname": linked_doc["reference_docname"],
}
],
remove_contact=doctype == "Contact",
delete=delete_linked,
)
if len(items) > 10:
frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
else:
delete_bulk(doctype, items)
return "success"

View File

@ -44,6 +44,10 @@ class CRMInvitation(Document):
user = self.create_user_if_not_exists() user = self.create_user_if_not_exists()
user.append_roles(self.role) 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": if self.role == "Sales User":
self.update_module_in_user(user, "FCRM") self.update_module_in_user(user, "FCRM")
user.save(ignore_permissions=True) user.save(ignore_permissions=True)

File diff suppressed because it is too large Load Diff

View File

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

@ -1 +1 @@
Subproject commit 883bb643d1e662d6467925927e347dd28376960f Subproject commit 424288f77af4779dd3bb71dc3d278fc627f95179

View File

@ -31,6 +31,7 @@ declare module 'vue' {
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default'] Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default'] AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default'] BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default'] CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default'] CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default'] CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
@ -66,6 +67,7 @@ declare module 'vue' {
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default'] DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default'] DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default'] DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default']
DeleteLinkedDocModal: typeof import('./src/components/DeleteLinkedDocModal.vue')['default']
DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default'] DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default']
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default'] DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default'] DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default']
@ -79,6 +81,7 @@ declare module 'vue' {
DropdownItem: typeof import('./src/components/DropdownItem.vue')['default'] DropdownItem: typeof import('./src/components/DropdownItem.vue')['default']
DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default'] DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default']
DurationIcon: typeof import('./src/components/Icons/DurationIcon.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'] EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default'] EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default'] Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default']
@ -93,7 +96,10 @@ declare module 'vue' {
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default'] EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default'] EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.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'] 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'] EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default'] EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default'] ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
@ -149,14 +155,18 @@ declare module 'vue' {
LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default'] LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default']
LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default'] LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default'] Link: typeof import('./src/components/Controls/Link.vue')['default']
LinkedDocsListView: typeof import('./src/components/ListViews/LinkedDocsListView.vue')['default']
LinkIcon: typeof import('./src/components/Icons/LinkIcon.vue')['default'] LinkIcon: typeof import('./src/components/Icons/LinkIcon.vue')['default']
ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default'] ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default']
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default'] ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default'] ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default'] LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideInfo: typeof import('~icons/lucide/info')['default'] LucideInfo: typeof import('~icons/lucide/info')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default'] LucidePlus: typeof import('~icons/lucide/plus')['default']
LucideSearch: typeof import('~icons/lucide/search')['default'] LucideSearch: typeof import('~icons/lucide/search')['default']
LucideX: typeof import('~icons/lucide/x')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default'] MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default'] MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default'] MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
@ -172,6 +182,7 @@ declare module 'vue' {
MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default'] MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default']
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default'] MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
NestedPopover: typeof import('./src/components/NestedPopover.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'] NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default'] NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default'] NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default']

View File

@ -13,7 +13,7 @@
"@tiptap/extension-paragraph": "^2.12.0", "@tiptap/extension-paragraph": "^2.12.0",
"@twilio/voice-sdk": "^2.10.2", "@twilio/voice-sdk": "^2.10.2",
"@vueuse/integrations": "^10.3.0", "@vueuse/integrations": "^10.3.0",
"frappe-ui": "^0.1.156", "frappe-ui": "^0.1.162",
"gemoji": "^8.1.0", "gemoji": "^8.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime": "^4.0.1", "mime": "^4.0.1",

View File

@ -19,6 +19,7 @@
v-if="showCallLogModal" v-if="showCallLogModal"
v-model="showCallLogModal" v-model="showCallLogModal"
:data="callLog" :data="callLog"
:referenceDoc="referenceDoc"
:options="{ afterInsert: () => activities.reload() }" :options="{ afterInsert: () => activities.reload() }"
/> />
</template> </template>
@ -87,10 +88,12 @@ function showNote(n) {
// Call Logs // Call Logs
const showCallLogModal = ref(false) const showCallLogModal = ref(false)
const callLog = ref({}) const callLog = ref({})
const referenceDoc = ref({})
function createCallLog() { function createCallLog() {
let doctype = props.doctype let doctype = props.doctype
let docname = props.doc.data?.name let docname = props.doc.data?.name
referenceDoc.value = { ...props.doc.data }
callLog.value = { callLog.value = {
reference_doctype: doctype, reference_doctype: doctype,
reference_docname: docname, reference_docname: docname,

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1191_1930)">
<path
d="M1.45001 12.1V12.1C1.45001 11.7869 1.60364 11.4936 1.86111 11.3154L6.87595 7.84359C7.52855 7.39178 8.38658 7.36866 9.06257 7.78465L13.5984 10.5759C14.1276 10.9016 14.45 11.4786 14.45 12.1V12.1"
stroke="currentColor"
/>
<path
d="M14.45 7.60001L11.95 9.60001M4.45001 9.60001L1.45001 7.60001"
stroke="currentColor"
/>
<path
d="M4 9V3C4 2.44772 4.44772 2 5 2H11C11.5523 2 12 2.44772 12 3V9"
stroke="currentColor"
/>
<path
d="M4 4.49999L2.1786 6C1.71727 6.37992 1.45002 6.94623 1.45002 7.54385L1.45002 12.1C1.45002 13.2046 2.34545 14.1 3.45002 14.1L12.45 14.1C13.5546 14.1 14.45 13.2046 14.45 12.1V7.51988C14.45 6.93603 14.1949 6.38133 13.7516 6.00137L12 4.5"
stroke="currentColor"
/>
<path d="M6 6H10" stroke="currentColor" stroke-linecap="round" />
<path d="M6 4H9" stroke="currentColor" stroke-linecap="round" />
</g>
</svg>
</template>

View File

@ -147,7 +147,6 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue' import EmailIcon from '@/components/Icons/EmailIcon.vue'
import StepsIcon from '@/components/Icons/StepsIcon.vue' import StepsIcon from '@/components/Icons/StepsIcon.vue'
import Section from '@/components/Section.vue' import Section from '@/components/Section.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import PinIcon from '@/components/Icons/PinIcon.vue' import PinIcon from '@/components/Icons/PinIcon.vue'
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import SquareAsterisk from '@/components/Icons/SquareAsterisk.vue' import SquareAsterisk from '@/components/Icons/SquareAsterisk.vue'
@ -233,11 +232,6 @@ const links = [
icon: PhoneIcon, icon: PhoneIcon,
to: 'Call Logs', to: 'Call Logs',
}, },
{
label: 'Email Templates',
icon: Email2Icon,
to: 'Email Templates',
},
] ]
const allViews = computed(() => { const allViews = computed(() => {

View File

@ -14,6 +14,20 @@
:doctype="doctype" :doctype="doctype"
@reload="reload" @reload="reload"
/> />
<DeleteLinkedDocModal
v-if="showDeleteDocModal.showLinkedDocsModal"
v-model="showDeleteDocModal.showLinkedDocsModal"
:doctype="props.doctype"
:docname="showDeleteDocModal.docname"
:reload="reload"
/>
<BulkDeleteLinkedDocModal
v-if="showDeleteDocModal.showDeleteModal"
v-model="showDeleteDocModal.showDeleteModal"
:doctype="props.doctype"
:items="showDeleteDocModal.items"
:reload="reload"
/>
</template> </template>
<script setup> <script setup>
@ -50,7 +64,11 @@ const { $dialog, $socket } = globalStore()
const showEditModal = ref(false) const showEditModal = ref(false)
const selectedValues = ref([]) const selectedValues = ref([])
const unselectAllAction = ref(() => {}) const unselectAllAction = ref(() => {})
const showDeleteDocModal = ref({
showLinkedDocsModal: false,
showDeleteModal: false,
docname: null,
})
function editValues(selections, unselectAll) { function editValues(selections, unselectAll) {
selectedValues.value = selections selectedValues.value = selections
showEditModal.value = true showEditModal.value = true
@ -88,33 +106,18 @@ function convertToDeal(selections, unselectAll) {
} }
function deleteValues(selections, unselectAll) { function deleteValues(selections, unselectAll) {
$dialog({ const selectedDocs = Array.from(selections)
title: __('Delete'), if (selectedDocs.length == 1) {
message: __('Are you sure you want to delete {0} item(s)?', [ showDeleteDocModal.value = {
selections.size, showLinkedDocsModal: true,
]), docname: selectedDocs[0],
variant: 'solid', }
theme: 'red', } else {
actions: [ showDeleteDocModal.value = {
{ showDeleteModal: true,
label: __('Delete'), items: selectedDocs,
variant: 'solid', }
theme: 'red', }
onClick: (close) => {
capture('bulk_delete')
call('frappe.desk.reportview.delete_items', {
items: JSON.stringify(Array.from(selections)),
doctype: props.doctype,
}).then(() => {
toast.success(__('Deleted successfully'))
unselectAll()
list.value.reload()
close()
})
},
},
],
})
} }
const showAssignmentModal = ref(false) const showAssignmentModal = ref(false)

View File

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

View File

@ -0,0 +1,139 @@
<template>
<ListView
:class="$attrs.class"
:columns="columns"
:rows="rows"
:options="{
selectable: true,
showTooltip: true,
resizeColumn: true,
}"
row-key="reference_docname"
@update:selections="(selections) => emit('selectionsChanged', selections)"
ref="listViewRef"
>
<ListHeader @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeaderItem
v-for="column in columns"
:key="column.key"
:item="column"
@columnWidthUpdated="emit('columnWidthUpdated', column)"
>
</ListHeaderItem>
</ListHeader>
<div class="*:mx-0 *:sm:mx-0">
<ListRows :rows="rows" v-slot="{ idx, column, item, row }">
<ListRowItem
:item="item"
@click="listViewRef.toggleRow(row['reference_docname'])"
>
<template #default="{ label }">
<div
v-if="column.key === 'title'"
class="truncate text-base flex gap-2"
>
<span>
{{ label }}
</span>
<FeatherIcon
name="external-link"
class="h-4 w-4 cursor-pointer"
@click.stop="viewLinkedDoc(row)"
/>
</div>
<span
v-if="column.key === 'reference_doctype'"
class="truncate text-base flex gap-2"
>
{{ getDoctypeName(row.reference_doctype) }}
</span>
</template>
</ListRowItem>
</ListRows>
</div>
</ListView>
</template>
<script setup>
import ListRows from '@/components/ListViews/ListRows.vue'
import { ListView, ListHeader, ListHeaderItem, ListRowItem } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
rows: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
linkedDocsResource: {
type: Object,
required: true,
},
unlinkLinkedDoc: {
type: Function,
required: true,
},
options: {
type: Object,
default: () => ({
selectable: true,
showTooltip: true,
resizeColumn: false,
totalCount: 0,
rowCount: 0,
}),
},
})
const emit = defineEmits([
'loadMore',
'updatePageCount',
'columnWidthUpdated',
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const listViewRef = ref(null)
const viewLinkedDoc = (doc) => {
let page = ''
let id = ''
switch (doc.reference_doctype) {
case 'CRM Lead':
page = 'leads'
id = doc.reference_docname
break
case 'CRM Call Log':
page = 'call-logs'
id = `view?open=${doc.reference_docname}`
break
case 'CRM Task':
page = 'tasks'
id = `view?open=${doc.reference_docname}`
break
case 'Contact':
page = 'contacts'
id = doc.reference_docname
break
case 'CRM Organization':
page = 'organizations'
id = doc.reference_docname
break
case 'FCRM Note':
page = 'notes'
id = `view?open=${doc.reference_docname}`
break
default:
break
}
window.open(`/crm/${page}/${id}`)
}
const getDoctypeName = (doctype) => {
return doctype.replace(/^(CRM|FCRM)\s*/, '')
}
</script>

View File

@ -84,7 +84,10 @@ const error = ref(null)
const title = ref(null) const title = ref(null)
const editMode = ref(false) const editMode = ref(false)
const { document: _address } = useDocument('Address', props.address || '') const { document: _address, triggerOnBeforeCreate } = useDocument(
'Address',
props.address || '',
)
const dialogOptions = computed(() => { const dialogOptions = computed(() => {
let title = !editMode.value let title = !editMode.value
@ -95,8 +98,7 @@ const dialogOptions = computed(() => {
{ {
label: editMode.value ? __('Save') : __('Create'), label: editMode.value ? __('Save') : __('Create'),
variant: 'solid', variant: 'solid',
onClick: () => onClick: () => (editMode.value ? updateAddress() : createAddress()),
editMode.value ? updateAddress() : createAddress.submit(),
}, },
] ]
@ -133,16 +135,22 @@ async function updateAddress() {
await _address.save.submit(null, callBacks) await _address.save.submit(null, callBacks)
} }
const createAddress = createResource({ async function createAddress() {
loading.value = true
error.value = null
await triggerOnBeforeCreate?.()
await _createAddress.submit({
doc: {
doctype: 'Address',
..._address.doc,
},
})
}
const _createAddress = createResource({
url: 'frappe.client.insert', url: 'frappe.client.insert',
makeParams() {
return {
doc: {
doctype: 'Address',
..._address.doc,
},
}
},
onSuccess(doc) { onSuccess(doc) {
loading.value = false loading.value = false
if (doc.name) { if (doc.name) {

View File

@ -69,6 +69,10 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
referenceDoc: {
type: Object,
default: () => ({}),
},
options: { options: {
type: Object, type: Object,
default: { default: {
@ -85,7 +89,7 @@ const loading = ref(false)
const error = ref(null) const error = ref(null)
const editMode = ref(false) const editMode = ref(false)
const { document: callLog } = useDocument( const { document: callLog, triggerOnBeforeCreate } = useDocument(
'CRM Call Log', 'CRM Call Log',
props.data?.name || '', props.data?.name || '',
) )
@ -97,8 +101,7 @@ const dialogOptions = computed(() => {
{ {
label: editMode.value ? __('Save') : __('Create'), label: editMode.value ? __('Save') : __('Create'),
variant: 'solid', variant: 'solid',
onClick: () => onClick: () => (editMode.value ? updateCallLog() : createCallLog()),
editMode.value ? updateCallLog() : createCallLog.submit(),
}, },
] ]
@ -135,18 +138,21 @@ async function updateCallLog() {
await callLog.save.submit(null, callBacks) await callLog.save.submit(null, callBacks)
} }
const createCallLog = createResource({ async function createCallLog() {
Object.assign(callLog.doc, {
doctype: 'CRM Call Log',
id: getRandom(6),
telephony_medium: 'Manual',
})
await triggerOnBeforeCreate?.(props.referenceDoc)
await _createCallLog.submit({
doc: callLog.doc,
})
}
const _createCallLog = createResource({
url: 'frappe.client.insert', url: 'frappe.client.insert',
makeParams() {
return {
doc: {
doctype: 'CRM Call Log',
id: getRandom(6),
telephony_medium: 'Manual',
...callLog.doc,
},
}
},
onSuccess(doc) { onSuccess(doc) {
loading.value = false loading.value = false
if (doc.name) { if (doc.name) {

View File

@ -86,7 +86,7 @@ const show = defineModel()
const loading = ref(false) const loading = ref(false)
const { document: _contact } = useDocument('Contact') const { document: _contact, triggerOnBeforeCreate } = useDocument('Contact')
async function createContact() { async function createContact() {
if (_contact.doc.email_id) { if (_contact.doc.email_id) {
@ -99,6 +99,8 @@ async function createContact() {
delete _contact.doc.mobile_no delete _contact.doc.mobile_no
} }
await triggerOnBeforeCreate?.()
const doc = await call('frappe.client.insert', { const doc = await call('frappe.client.insert', {
doc: { doc: {
doctype: 'Contact', doctype: 'Contact',

View File

@ -27,7 +27,7 @@
</div> </div>
</div> </div>
<div v-if="tabs.data"> <div v-if="tabs.data">
<FieldLayout :tabs="tabs.data" :data="_data" :doctype="doctype" /> <FieldLayout :tabs="tabs.data" :data="_data.doc" :doctype="doctype" />
<ErrorMessage class="mt-2" :message="error" /> <ErrorMessage class="mt-2" :message="error" />
</div> </div>
</div> </div>
@ -51,6 +51,7 @@
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue' import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import EditIcon from '@/components/Icons/EditIcon.vue' import EditIcon from '@/components/Icons/EditIcon.vue'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { isMobileView } from '@/composables/settings' import { isMobileView } from '@/composables/settings'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals' import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { FeatherIcon, createResource, ErrorMessage, call } from 'frappe-ui' import { FeatherIcon, createResource, ErrorMessage, call } from 'frappe-ui'
@ -76,7 +77,7 @@ const show = defineModel()
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
let _data = ref({}) const { document: _data, triggerOnBeforeCreate } = useDocument(props.doctype)
const dialogOptions = computed(() => { const dialogOptions = computed(() => {
let doctype = props.doctype let doctype = props.doctype
@ -109,12 +110,14 @@ async function create() {
loading.value = true loading.value = true
error.value = null error.value = null
await triggerOnBeforeCreate?.()
let doc = await call( let doc = await call(
'frappe.client.insert', 'frappe.client.insert',
{ {
doc: { doc: {
doctype: props.doctype, doctype: props.doctype,
..._data.value, ..._data.doc,
}, },
}, },
{ {
@ -138,7 +141,7 @@ watch(
if (!value) return if (!value) return
nextTick(() => { nextTick(() => {
_data.value = { ...props.data } _data.doc = { ...props.data }
}) })
}, },
) )

View File

@ -98,7 +98,11 @@ const show = defineModel()
const router = useRouter() const router = useRouter()
const error = ref(null) const error = ref(null)
const { document: deal, triggerOnChange } = useDocument('CRM Deal') const {
document: deal,
triggerOnChange,
triggerOnBeforeCreate,
} = useDocument('CRM Deal')
const hasOrganizationSections = ref(true) const hasOrganizationSections = ref(true)
const hasContactSections = ref(true) const hasContactSections = ref(true)
@ -175,7 +179,7 @@ const dealStatuses = computed(() => {
return statuses return statuses
}) })
function createDeal() { async function createDeal() {
if (deal.doc.website && !deal.doc.website.startsWith('http')) { if (deal.doc.website && !deal.doc.website.startsWith('http')) {
deal.doc.website = 'https://' + deal.doc.website deal.doc.website = 'https://' + deal.doc.website
} }
@ -186,6 +190,8 @@ function createDeal() {
deal.doc['mobile_no'] = null deal.doc['mobile_no'] = null
} else deal.doc['contact'] = null } else deal.doc['contact'] = null
await triggerOnBeforeCreate?.()
createResource({ createResource({
url: 'crm.fcrm.doctype.crm_deal.crm_deal.create_deal', url: 'crm.fcrm.doctype.crm_deal.crm_deal.create_deal',
params: { args: deal.doc }, params: { args: deal.doc },

View File

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

View File

@ -4,19 +4,33 @@
:options="{ title: __('Email Templates'), size: '4xl' }" :options="{ title: __('Email Templates'), size: '4xl' }"
> >
<template #body-content> <template #body-content>
<TextInput <div class="flex items-center gap-2">
ref="searchInput" <TextInput
v-model="search" class="w-full"
type="text" ref="searchInput"
:placeholder="__('Payment Reminder')" v-model="search"
> type="text"
<template #prefix> :placeholder="__('Payment Reminder')"
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-4" /> >
</template> <template #prefix>
</TextInput> <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 <div
v-if="filteredTemplates.length" 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 <div
v-for="template in filteredTemplates" v-for="template in filteredTemplates"
@ -57,11 +71,8 @@
@click=" @click="
() => { () => {
show = false show = false
emailTemplate = { showSettings = true
reference_doctype: props.doctype, activeSettingsPage = 'Email Templates'
enabled: 1,
}
showEmailTemplateModal = true
} }
" "
/> />
@ -69,14 +80,10 @@
</div> </div>
</template> </template>
</Dialog> </Dialog>
<EmailTemplateModal
v-model="showEmailTemplateModal"
:emailTemplate="emailTemplate"
/>
</template> </template>
<script setup> <script setup>
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue' import { showSettings, activeSettingsPage } from '@/composables/settings'
import { TextEditor, createListResource } from 'frappe-ui' import { TextEditor, createListResource } from 'frappe-ui'
import { ref, computed, nextTick, watch, onMounted } from 'vue' import { ref, computed, nextTick, watch, onMounted } from 'vue'
@ -89,9 +96,6 @@ const props = defineProps({
const show = defineModel() const show = defineModel()
const searchInput = ref('') const searchInput = ref('')
const showEmailTemplateModal = ref(false)
const emailTemplate = ref({})
const emit = defineEmits(['apply']) const emit = defineEmits(['apply'])

View File

@ -74,7 +74,11 @@ const router = useRouter()
const error = ref(null) const error = ref(null)
const isLeadCreating = ref(false) const isLeadCreating = ref(false)
const { document: lead, triggerOnChange } = useDocument('CRM Lead') const {
document: lead,
triggerOnChange,
triggerOnBeforeCreate,
} = useDocument('CRM Lead')
const leadStatuses = computed(() => { const leadStatuses = computed(() => {
let statuses = statusOptions('lead', null, [], triggerOnChange) let statuses = statusOptions('lead', null, [], triggerOnChange)
@ -112,71 +116,73 @@ const tabs = createResource({
const createLead = createResource({ const createLead = createResource({
url: 'frappe.client.insert', url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'CRM Lead',
...values,
},
}
},
}) })
function createNewLead() { async function createNewLead() {
if (lead.doc.website && !lead.doc.website.startsWith('http')) { if (lead.doc.website && !lead.doc.website.startsWith('http')) {
lead.doc.website = 'https://' + lead.doc.website lead.doc.website = 'https://' + lead.doc.website
} }
createLead.submit(lead.doc, { await triggerOnBeforeCreate?.()
validate() {
error.value = null createLead.submit(
if (!lead.doc.first_name) { {
error.value = __('First Name is mandatory') doc: {
return error.value doctype: 'CRM Lead',
} ...lead.doc,
if (lead.doc.annual_revenue) { },
if (typeof lead.doc.annual_revenue === 'string') { },
lead.doc.annual_revenue = lead.doc.annual_revenue.replace(/,/g, '') {
} else if (isNaN(lead.doc.annual_revenue)) { validate() {
error.value = __('Annual Revenue should be a number') error.value = null
if (!lead.doc.first_name) {
error.value = __('First Name is mandatory')
return error.value return error.value
} }
} if (lead.doc.annual_revenue) {
if ( if (typeof lead.doc.annual_revenue === 'string') {
lead.doc.mobile_no && lead.doc.annual_revenue = lead.doc.annual_revenue.replace(/,/g, '')
isNaN(lead.doc.mobile_no.replace(/[-+() ]/g, '')) } else if (isNaN(lead.doc.annual_revenue)) {
) { error.value = __('Annual Revenue should be a number')
error.value = __('Mobile No should be a number') return error.value
return error.value }
} }
if (lead.doc.email && !lead.doc.email.includes('@')) { if (
error.value = __('Invalid Email') lead.doc.mobile_no &&
return error.value isNaN(lead.doc.mobile_no.replace(/[-+() ]/g, ''))
} ) {
if (!lead.doc.status) { error.value = __('Mobile No should be a number')
error.value = __('Status is required') return error.value
return error.value }
} if (lead.doc.email && !lead.doc.email.includes('@')) {
isLeadCreating.value = true error.value = __('Invalid Email')
return error.value
}
if (!lead.doc.status) {
error.value = __('Status is required')
return error.value
}
isLeadCreating.value = true
},
onSuccess(data) {
capture('lead_created')
isLeadCreating.value = false
show.value = false
router.push({ name: 'Lead', params: { leadId: data.name } })
updateOnboardingStep('create_first_lead', true, false, () => {
localStorage.setItem('firstLead' + user, data.name)
})
},
onError(err) {
isLeadCreating.value = false
if (!err.messages) {
error.value = err.message
return
}
error.value = err.messages.join('\n')
},
}, },
onSuccess(data) { )
capture('lead_created')
isLeadCreating.value = false
show.value = false
router.push({ name: 'Lead', params: { leadId: data.name } })
updateOnboardingStep('create_first_lead', true, false, () => {
localStorage.setItem('firstLead' + user, data.name)
})
},
onError(err) {
isLeadCreating.value = false
if (!err.messages) {
error.value = err.message
return
}
error.value = err.messages.join('\n')
},
})
} }
function openQuickEntryModal() { function openQuickEntryModal() {

View File

@ -88,9 +88,15 @@ const show = defineModel()
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const { document: organization } = useDocument('CRM Organization') const { document: organization, triggerOnBeforeCreate } =
useDocument('CRM Organization')
async function createOrganization() { async function createOrganization() {
loading.value = true
error.value = null
await triggerOnBeforeCreate?.()
const doc = await call( const doc = await call(
'frappe.client.insert', 'frappe.client.insert',
{ {

View File

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

View File

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

View File

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

View File

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

View File

@ -45,12 +45,14 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue' import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue' import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue' import Email2Icon from '@/components/Icons/Email2Icon.vue'
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
import Users from '@/components/Settings/Users.vue' import Users from '@/components/Settings/Users.vue'
import GeneralSettings from '@/components/Settings/GeneralSettings.vue' import GeneralSettings from '@/components/Settings/GeneralSettings.vue'
import InviteUserPage from '@/components/Settings/InviteUserPage.vue' import InviteUserPage from '@/components/Settings/InviteUserPage.vue'
import ProfileSettings from '@/components/Settings/ProfileSettings.vue' import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue' import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue' import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
import EmailTemplatePage from '@/components/Settings/EmailTemplate/EmailTemplatePage.vue'
import TelephonySettings from '@/components/Settings/TelephonySettings.vue' import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
import EmailConfig from '@/components/Settings/EmailConfig.vue' import EmailConfig from '@/components/Settings/EmailConfig.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
@ -107,6 +109,11 @@ const tabs = computed(() => {
component: markRaw(EmailConfig), component: markRaw(EmailConfig),
condition: () => isManager(), condition: () => isManager(),
}, },
{
label: __('Email Templates'),
icon: EmailTemplateIcon,
component: markRaw(EmailTemplatePage),
},
], ],
}, },
{ {

View File

@ -1,7 +1,7 @@
<template> <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 --> <!-- 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"> <div class="flex flex-col gap-1 w-9/12">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5"> <h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Users') }} {{ __('Users') }}
@ -63,7 +63,10 @@
class="flex flex-col overflow-hidden" class="flex flex-col overflow-hidden"
v-if="!users.loading && users.data?.crmUsers?.length > 1" 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 <TextInput
ref="searchRef" ref="searchRef"
v-model="search" v-model="search"
@ -86,7 +89,7 @@
]" ]"
/> />
</div> </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"> <template v-for="user in usersList" :key="user.name">
<li class="flex items-center justify-between py-2"> <li class="flex items-center justify-between py-2">
<div class="flex items-center"> <div class="flex items-center">
@ -109,14 +112,31 @@
:options="getMoreOptions(user)" :options="getMoreOptions(user)"
:button="{ :button="{
icon: 'more-horizontal', icon: 'more-horizontal',
onblur: (e) => {
e.stopPropagation()
confirmRemove = false
},
}" }"
placement="right" 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 <Dropdown
v-else
:options="getDropdownOptions(user)" :options="getDropdownOptions(user)"
:button="{ :button="{
label: roleMap[user.role], label: roleMap[user.role],
iconRight: 'chevron-down', iconRight: 'chevron-down',
iconLeft:
user.role === 'System Manager'
? 'shield'
: user.role === 'Sales Manager'
? 'briefcase'
: 'user-check',
}" }"
placement="right" placement="right"
/> />
@ -146,12 +166,12 @@
</template> </template>
<script setup> <script setup>
import LucideCheck from '~icons/lucide/check'
import AddExistingUserModal from '@/components/Modals/AddExistingUserModal.vue' import AddExistingUserModal from '@/components/Modals/AddExistingUserModal.vue'
import { activeSettingsPage } from '@/composables/settings' import { activeSettingsPage } from '@/composables/settings'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { Avatar, TextInput, toast, call } from 'frappe-ui' import { TemplateOption, DropdownOption } from '@/utils'
import { ref, computed, h, onMounted } from 'vue' import { Avatar, TextInput, toast, call, FeatherIcon, Tooltip } from 'frappe-ui'
import { ref, computed, onMounted } from 'vue'
const { users, isAdmin, isManager } = usersStore() const { users, isAdmin, isManager } = usersStore()
@ -182,12 +202,36 @@ const usersList = computed(() => {
}) })
}) })
const confirmRemove = ref(false)
function getMoreOptions(user) { function getMoreOptions(user) {
let options = [ let options = [
{ {
label: __('Remove'), label: __('Remove'),
icon: 'trash-2', component: (props) =>
onClick: () => removeUser(user, true), 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'), label: __('Admin'),
component: (props) => component: (props) =>
RoleOption({ DropdownOption({
role: __('Admin'), option: __('Admin'),
icon: 'shield',
active: props.active, active: props.active,
selected: user.role === 'System Manager', selected: user.role === 'System Manager',
onClick: () => updateRole(user, 'System Manager'), onClick: () => updateRole(user, 'System Manager'),
@ -210,8 +255,9 @@ function getDropdownOptions(user) {
{ {
label: __('Manager'), label: __('Manager'),
component: (props) => component: (props) =>
RoleOption({ DropdownOption({
role: __('Manager'), option: __('Manager'),
icon: 'briefcase',
active: props.active, active: props.active,
selected: user.role === 'Sales Manager', selected: user.role === 'Sales Manager',
onClick: () => updateRole(user, 'Sales Manager'), onClick: () => updateRole(user, 'Sales Manager'),
@ -221,8 +267,9 @@ function getDropdownOptions(user) {
{ {
label: __('Sales User'), label: __('Sales User'),
component: (props) => component: (props) =>
RoleOption({ DropdownOption({
role: __('Sales User'), option: __('Sales User'),
icon: 'user-check',
active: props.active, active: props.active,
selected: user.role === 'Sales User', selected: user.role === 'Sales User',
onClick: () => updateRole(user, 'Sales User'), onClick: () => updateRole(user, 'Sales User'),
@ -233,28 +280,6 @@ function getDropdownOptions(user) {
return options.filter((option) => option.condition?.() || true) 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) { function updateRole(user, newRole) {
if (user.role === newRole) return if (user.role === newRole) return

View File

@ -110,6 +110,14 @@ export function useDocument(doctype, docname) {
await trigger(handler) await trigger(handler)
} }
async function triggerOnBeforeCreate() {
const args = Array.from(arguments)
const handler = async function () {
await (this.onBeforeCreate?.(...args) || this.on_before_create?.(...args))
}
await trigger(handler)
}
async function triggerOnSave() { async function triggerOnSave() {
const handler = async function () { const handler = async function () {
await (this.onSave?.() || this.on_save?.()) await (this.onSave?.() || this.on_save?.())
@ -202,26 +210,12 @@ export function useDocument(doctype, docname) {
await runSequentially(tasks) await runSequentially(tasks)
} }
function getOldValue(fieldname, row) {
if (!documentsCache[doctype][docname || '']) return ''
const document = documentsCache[doctype][docname || '']
const oldDoc = document.originalDoc
if (row?.name) {
return oldDoc?.[row.parentfield]?.find((r) => r.name === row.name)?.[
fieldname
]
}
return oldDoc?.[fieldname] || document.doc[fieldname]
}
return { return {
document: documentsCache[doctype][docname || ''], document: documentsCache[doctype][docname || ''],
assignees, assignees,
getControllers, getControllers,
triggerOnLoad, triggerOnLoad,
triggerOnBeforeCreate,
triggerOnSave, triggerOnSave,
triggerOnRefresh, triggerOnRefresh,
triggerOnChange, triggerOnChange,

View File

@ -80,7 +80,7 @@ import CallLogDetailModal from '@/components/Modals/CallLogDetailModal.vue'
import CallLogModal from '@/components/Modals/CallLogModal.vue' import CallLogModal from '@/components/Modals/CallLogModal.vue'
import { getCallLogDetail } from '@/utils/callLog' import { getCallLogDetail } from '@/utils/callLog'
import { createResource } from 'frappe-ui' import { createResource } from 'frappe-ui'
import { computed, ref } from 'vue' import { computed, ref, onMounted } from 'vue'
const callLogsListView = ref(null) const callLogsListView = ref(null)
const showCallLogModal = ref(false) const showCallLogModal = ref(false)
@ -124,4 +124,19 @@ function createCallLog() {
callLog.value = {} callLog.value = {}
showCallLogModal.value = true showCallLogModal.value = true
} }
const openCallLogFromURL = () => {
const searchParams = new URLSearchParams(window.location.search)
const callLogName = searchParams.get('open')
if (callLogName) {
showCallLog(callLogName)
searchParams.delete('open')
window.history.replaceState(null, '', window.location.pathname)
}
}
onMounted(() => {
openCallLogFromURL()
})
</script> </script>

View File

@ -105,7 +105,7 @@
:label="__('Delete')" :label="__('Delete')"
theme="red" theme="red"
size="sm" size="sm"
@click="deleteContact" @click="deleteContact()"
> >
<template #prefix> <template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" /> <FeatherIcon name="trash-2" class="h-4 w-4" />
@ -172,6 +172,13 @@
:errorTitle="errorTitle" :errorTitle="errorTitle"
:errorMessage="errorMessage" :errorMessage="errorMessage"
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'Contact'"
:docname="contact.data.name"
name="Contacts"
/>
</template> </template>
<script setup> <script setup>
@ -293,6 +300,11 @@ usePageMeta(() => {
icon: brand.favicon, icon: brand.favicon,
} }
}) })
const showDeleteLinkedDocModal = ref(false)
async function deleteContact() {
showDeleteLinkedDocModal.value = true
}
async function changeContactImage(file) { async function changeContactImage(file) {
await call('frappe.client.set_value', { await call('frappe.client.set_value', {
@ -304,28 +316,6 @@ async function changeContactImage(file) {
contact.reload() contact.reload()
} }
async function deleteContact() {
$dialog({
title: __('Delete contact'),
message: __('Are you sure you want to delete this contact?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
async onClick(close) {
await call('frappe.client.delete', {
doctype: 'Contact',
name: props.contactId,
})
close()
router.push({ name: 'Contacts' })
},
},
],
})
}
const tabIndex = ref(0) const tabIndex = ref(0)
const tabs = [ const tabs = [
{ {
@ -389,13 +379,13 @@ function getParsedSections(_sections) {
'Contact Email', 'Contact Email',
option.name, option.name,
'email_id', 'email_id',
option.value, option.value
) )
} }
}, },
onDelete: async (option, isNew) => { onDelete: async (option, isNew) => {
contact.data.email_ids = contact.data.email_ids.filter( contact.data.email_ids = contact.data.email_ids.filter(
(email) => email.name !== option.name, (email) => email.name !== option.name
) )
!isNew && (await deleteOption('Contact Email', option.name)) !isNew && (await deleteOption('Contact Email', option.name))
if (_contact.value.email_id === option.value) { if (_contact.value.email_id === option.value) {
@ -403,7 +393,7 @@ function getParsedSections(_sections) {
_contact.value.email_id = '' _contact.value.email_id = ''
} else { } else {
_contact.value.email_id = contact.data.email_ids.find( _contact.value.email_id = contact.data.email_ids.find(
(email) => email.is_primary, (email) => email.is_primary
)?.email_id )?.email_id
} }
} }
@ -446,13 +436,13 @@ function getParsedSections(_sections) {
'Contact Phone', 'Contact Phone',
option.name, option.name,
'phone', 'phone',
option.value, option.value
) )
} }
}, },
onDelete: async (option, isNew) => { onDelete: async (option, isNew) => {
contact.data.phone_nos = contact.data.phone_nos.filter( contact.data.phone_nos = contact.data.phone_nos.filter(
(phone) => phone.name !== option.name, (phone) => phone.name !== option.name
) )
!isNew && (await deleteOption('Contact Phone', option.name)) !isNew && (await deleteOption('Contact Phone', option.name))
if (_contact.value.actual_mobile_no === option.value) { if (_contact.value.actual_mobile_no === option.value) {
@ -461,7 +451,7 @@ function getParsedSections(_sections) {
} else { } else {
_contact.value.actual_mobile_no = _contact.value.actual_mobile_no =
contact.data.phone_nos.find( contact.data.phone_nos.find(
(phone) => phone.is_primary_mobile_no, (phone) => phone.is_primary_mobile_no
)?.phone )?.phone
} }
} }

View File

@ -91,51 +91,41 @@
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<Tooltip v-if="callEnabled" :text="__('Make a call')"> <Tooltip v-if="callEnabled" :text="__('Make a call')">
<div> <div>
<Button class="h-7 w-7" @click="triggerCall"> <Button @click="triggerCall">
<template #icon> <template #icon><PhoneIcon /></template>
<PhoneIcon />
</template>
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Send an email')"> <Tooltip :text="__('Send an email')">
<div> <div>
<Button <Button
class="h-7 w-7"
@click=" @click="
deal.data.email deal.data.email
? openEmailBox() ? openEmailBox()
: toast.error(__('No email set')) : toast.error(__('No email set'))
" "
> >
<template #icon> <template #icon><Email2Icon /></template>
<Email2Icon />
</template>
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Go to website')"> <Tooltip :text="__('Go to website')">
<div> <div>
<Button <Button
class="h-7 w-7"
@click=" @click="
deal.data.website deal.data.website
? openWebsite(deal.data.website) ? openWebsite(deal.data.website)
: toast.error(__('No website set')) : toast.error(__('No website set'))
" "
> >
<template #icon> <template #icon><LinkIcon /></template>
<LinkIcon />
</template>
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Attach a file')"> <Tooltip :text="__('Attach a file')">
<div> <div>
<Button class="size-7" @click="showFilesUploader = true"> <Button @click="showFilesUploader = true">
<template #icon> <template #icon><AttachmentIcon /></template>
<AttachmentIcon />
</template>
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>
@ -329,6 +319,13 @@
} }
" "
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Deal'"
:docname="props.dealId"
name="Deals"
/>
</template> </template>
<script setup> <script setup>
import ErrorPage from '@/components/ErrorPage.vue' import ErrorPage from '@/components/ErrorPage.vue'
@ -472,7 +469,11 @@ const reload = ref(false)
const showOrganizationModal = ref(false) const showOrganizationModal = ref(false)
const showFilesUploader = ref(false) const showFilesUploader = ref(false)
const _organization = ref({}) const _organization = ref({})
const showDeleteLinkedDocModal = ref(false)
async function deleteDealWithModal() {
showDeleteLinkedDocModal.value = true
}
function updateDeal(fieldname, value, callback) { function updateDeal(fieldname, value, callback) {
value = Array.isArray(fieldname) ? '' : value value = Array.isArray(fieldname) ? '' : value

View File

@ -1,6 +0,0 @@
<template>
<div>
<h1>Email Templates</h1>
<p>Here is a list of email templates</p>
</div>
</template>

View File

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

View File

@ -134,7 +134,6 @@
<Tooltip v-if="callEnabled" :text="__('Make a call')"> <Tooltip v-if="callEnabled" :text="__('Make a call')">
<div> <div>
<Button <Button
class="h-7 w-7"
@click=" @click="
() => () =>
lead.data.mobile_no lead.data.mobile_no
@ -151,7 +150,6 @@
<Tooltip :text="__('Send an email')"> <Tooltip :text="__('Send an email')">
<div> <div>
<Button <Button
class="h-7 w-7"
@click=" @click="
lead.data.email lead.data.email
? openEmailBox() ? openEmailBox()
@ -167,7 +165,6 @@
<Tooltip :text="__('Go to website')"> <Tooltip :text="__('Go to website')">
<div> <div>
<Button <Button
class="h-7 w-7"
@click=" @click="
lead.data.website lead.data.website
? openWebsite(lead.data.website) ? openWebsite(lead.data.website)
@ -182,7 +179,7 @@
</Tooltip> </Tooltip>
<Tooltip :text="__('Attach a file')"> <Tooltip :text="__('Attach a file')">
<div> <div>
<Button class="h-7 w-7" @click="showFilesUploader = true"> <Button @click="showFilesUploader = true">
<template #icon> <template #icon>
<AttachmentIcon /> <AttachmentIcon />
</template> </template>
@ -333,6 +330,13 @@
} }
" "
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Lead'"
:docname="props.leadId"
name="Leads"
/>
</template> </template>
<script setup> <script setup>
import ErrorPage from '@/components/ErrorPage.vue' import ErrorPage from '@/components/ErrorPage.vue'
@ -423,6 +427,7 @@ const props = defineProps({
const errorTitle = ref('') const errorTitle = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const showDeleteLinkedDocModal = ref(false)
const lead = createResource({ const lead = createResource({
url: 'crm.fcrm.doctype.crm_lead.api.get_lead', url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
@ -621,6 +626,10 @@ async function deleteLead(name) {
router.push({ name: 'Leads' }) router.push({ name: 'Leads' })
} }
async function deleteLeadWithModal(name) {
showDeleteLinkedDocModal.value = true
}
// Convert to Deal // Convert to Deal
const showConvertToDealModal = ref(false) const showConvertToDealModal = ref(false)
const existingContactChecked = ref(false) const existingContactChecked = ref(false)

View File

@ -127,6 +127,7 @@ const viewControls = ref(null)
watch( watch(
() => notes.value?.data?.page_length_count, () => notes.value?.data?.page_length_count,
(val, old_value) => { (val, old_value) => {
openNoteFromURL()
if (!val || val === old_value) return if (!val || val === old_value) return
updatedPageCount.value = val updatedPageCount.value = val
}, },
@ -152,4 +153,20 @@ async function deleteNote(name) {
}) })
notes.value.reload() notes.value.reload()
} }
const openNoteFromURL = () => {
const searchParams = new URLSearchParams(window.location.search)
const noteName = searchParams.get('open')
if (noteName && notes.value?.data?.data) {
const foundNote = notes.value.data.data.find(
(note) => note.name === noteName,
)
if (foundNote) {
editNote(foundNote)
}
searchParams.delete('open')
window.history.replaceState(null, '', window.location.pathname)
}
}
</script> </script>

View File

@ -83,7 +83,7 @@
:label="__('Delete')" :label="__('Delete')"
theme="red" theme="red"
size="sm" size="sm"
@click="deleteOrganization" @click="deleteOrganization()"
> >
<template #prefix> <template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" /> <FeatherIcon name="trash-2" class="h-4 w-4" />
@ -164,6 +164,13 @@
:errorTitle="errorTitle" :errorTitle="errorTitle"
:errorMessage="errorMessage" :errorMessage="errorMessage"
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Organization'"
:docname="props.organizationId"
name="Organizations"
/>
</template> </template>
<script setup> <script setup>
@ -202,6 +209,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { h, computed, ref } from 'vue' import { h, computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import DeleteLinkedDocModal from '@/components/DeleteLinkedDocModal.vue'
const props = defineProps({ const props = defineProps({
organizationId: { organizationId: {
@ -222,6 +230,8 @@ const router = useRouter()
const errorTitle = ref('') const errorTitle = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const showDeleteLinkedDocModal = ref(false)
const organization = createDocumentResource({ const organization = createDocumentResource({
doctype: 'CRM Organization', doctype: 'CRM Organization',
name: props.organizationId, name: props.organizationId,
@ -249,7 +259,7 @@ const breadcrumbs = computed(() => {
let view = getView( let view = getView(
route.query.view, route.query.view,
route.query.viewType, route.query.viewType,
'CRM Organization', 'CRM Organization'
) )
if (view) { if (view) {
items.push({ items.push({
@ -286,6 +296,10 @@ usePageMeta(() => {
} }
}) })
async function deleteOrganization() {
showDeleteLinkedDocModal.value = true
}
async function changeOrganizationImage(file) { async function changeOrganizationImage(file) {
await call('frappe.client.set_value', { await call('frappe.client.set_value', {
doctype: 'CRM Organization', doctype: 'CRM Organization',
@ -296,28 +310,6 @@ async function changeOrganizationImage(file) {
organization.reload() organization.reload()
} }
async function deleteOrganization() {
$dialog({
title: __('Delete organization'),
message: __('Are you sure you want to delete this organization?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
async onClick(close) {
await call('frappe.client.delete', {
doctype: 'CRM Organization',
name: props.organizationId,
})
close()
router.push({ name: 'Organizations' })
},
},
],
})
}
function website(url) { function website(url) {
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '') return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
} }

View File

@ -211,7 +211,7 @@ import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { formatDate, timeAgo } from '@/utils' import { formatDate, timeAgo } from '@/utils'
import { Tooltip, Avatar, TextEditor, Dropdown, call } from 'frappe-ui' import { Tooltip, Avatar, TextEditor, Dropdown, call } from 'frappe-ui'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } = const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
@ -246,6 +246,7 @@ const rows = computed(() => {
return getKanbanRows(tasks.value.data.data, tasks.value.data.fields) return getKanbanRows(tasks.value.data.data, tasks.value.data.fields)
} }
openTaskFromURL()
return parseRows(tasks.value?.data.data, tasks.value?.data.columns) return parseRows(tasks.value?.data.data, tasks.value?.data.columns)
}) })
@ -391,4 +392,15 @@ function redirect(doctype, docname) {
} }
router.push({ name: name, params: params }) router.push({ name: name, params: params })
} }
const openTaskFromURL = () => {
const searchParams = new URLSearchParams(window.location.search)
const taskName = searchParams.get('open')
if (taskName && rows.value?.length) {
showTask(parseInt(taskName))
searchParams.delete('open')
window.history.replaceState(null, '', window.location.pathname)
}
}
</script> </script>

View File

@ -79,18 +79,6 @@ const routes = [
name: 'Call Logs', name: 'Call Logs',
component: () => import('@/pages/CallLogs.vue'), 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', path: '/welcome',
name: 'Welcome', name: 'Welcome',

View File

@ -1,9 +1,10 @@
import LucideCheck from '~icons/lucide/check'
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue' import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue' import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { gemoji } from 'gemoji' import { gemoji } from 'gemoji'
import { getMeta } from '@/stores/meta' 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' import { h } from 'vue'
export function formatTime(seconds) { export function formatTime(seconds) {
@ -465,3 +466,66 @@ export function runSequentially(functions) {
return promise.then(() => fn()) return promise.then(() => fn())
}, Promise.resolve()) }, 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),
],
)
}

View File

@ -2,11 +2,72 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import path from 'path' import path from 'path'
import fs from 'fs'
import frappeui from 'frappe-ui/vite' import frappeui from 'frappe-ui/vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
function appPath(app) {
const root = path.resolve(__dirname, '../..') // points to apps
const frontendPaths = [
// Standard frontend structure: appname/frontend/src
path.join(root, app, 'frontend', 'src'),
// Desk-based apps: appname/desk/src
path.join(root, app, 'desk', 'src'),
// Alternative frontend structures
path.join(root, app, 'client', 'src'),
path.join(root, app, 'ui', 'src'),
// Direct src structure: appname/src
path.join(root, app, 'src'),
]
return frontendPaths.find((srcPath) => fs.existsSync(srcPath)) || null
}
function hasApp(app) {
return fs.existsSync(appPath(app))
}
// List of frontend apps used in this project
let apps = []
const alias = [
// Default "@" for this app
{
find: '@',
replacement: path.resolve(__dirname, 'src'),
},
// App-specific aliases like @helpdesk, @hrms, etc.
...apps.map((app) =>
hasApp(app)
? { find: `@${app}`, replacement: appPath(app) }
: { find: `@${app}`, replacement: `virtual:${app}` },
),
]
const defineFlags = Object.fromEntries(
apps.map((app) => [
`__HAS_${app.toUpperCase()}__`,
JSON.stringify(hasApp(app)),
]),
)
const virtualStubPlugin = {
name: 'virtual-empty-modules',
resolveId(id) {
if (id.startsWith('virtual:')) return '\0' + id
},
load(id) {
if (id.startsWith('\0virtual:')) {
return 'export default {}; export const missing = true;'
}
},
}
console.log('Generated app aliases:', alias)
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
define: defineFlags,
plugins: [ plugins: [
frappeui({ frappeui({
frappeProxy: true, frappeProxy: true,
@ -60,12 +121,9 @@ export default defineConfig({
], ],
}, },
}), }),
virtualStubPlugin,
], ],
resolve: { resolve: { alias },
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
optimizeDeps: { optimizeDeps: {
include: [ include: [
'feather-icons', 'feather-icons',

View File

@ -2570,10 +2570,10 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.156: frappe-ui@^0.1.162:
version "0.1.156" version "0.1.162"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.156.tgz#1a476aec80b0e0f72470f9dc3990bb023b2ebb09" resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.162.tgz#01a2f06e9db70b1bce6e0b0f2089a9cc1cb8dd51"
integrity sha512-JsIODLL7YYFhKSYfWJJ9M1+VMmj8M0xZ1D5M7Cx0c+OWg5Qm0xda1592Tr+om1a7u0zWcfjuQnW9mHN1lW5HIA== integrity sha512-LdlEQ1I8oMj2TAmx0FGuJl+AwQ6/jqtwEy3lei3mH6SVArfGnoVDqLm8aeJTwAB6KUjgCj+ffWe6vN7HmZXIcg==
dependencies: dependencies:
"@floating-ui/vue" "^1.1.6" "@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14" "@headlessui/vue" "^1.7.14"