Merge pull request #990 from frappe/main-hotfix
This commit is contained in:
commit
e575f516d8
@ -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"
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
11
frontend/components.d.ts
vendored
11
frontend/components.d.ts
vendored
@ -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']
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
154
frontend/src/components/BulkDeleteLinkedDocModal.vue
Normal file
154
frontend/src/components/BulkDeleteLinkedDocModal.vue
Normal 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>
|
||||||
265
frontend/src/components/DeleteLinkedDocModal.vue
Normal file
265
frontend/src/components/DeleteLinkedDocModal.vue
Normal 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>
|
||||||
@ -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'
|
||||||
|
|||||||
30
frontend/src/components/Icons/EmailTemplateIcon.vue
Normal file
30
frontend/src/components/Icons/EmailTemplateIcon.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g clip-path="url(#clip0_1191_1930)">
|
||||||
|
<path
|
||||||
|
d="M1.45001 12.1V12.1C1.45001 11.7869 1.60364 11.4936 1.86111 11.3154L6.87595 7.84359C7.52855 7.39178 8.38658 7.36866 9.06257 7.78465L13.5984 10.5759C14.1276 10.9016 14.45 11.4786 14.45 12.1V12.1"
|
||||||
|
stroke="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14.45 7.60001L11.95 9.60001M4.45001 9.60001L1.45001 7.60001"
|
||||||
|
stroke="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 9V3C4 2.44772 4.44772 2 5 2H11C11.5523 2 12 2.44772 12 3V9"
|
||||||
|
stroke="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 4.49999L2.1786 6C1.71727 6.37992 1.45002 6.94623 1.45002 7.54385L1.45002 12.1C1.45002 13.2046 2.34545 14.1 3.45002 14.1L12.45 14.1C13.5546 14.1 14.45 13.2046 14.45 12.1V7.51988C14.45 6.93603 14.1949 6.38133 13.7516 6.00137L12 4.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
/>
|
||||||
|
<path d="M6 6H10" stroke="currentColor" stroke-linecap="round" />
|
||||||
|
<path d="M6 4H9" stroke="currentColor" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -147,7 +147,6 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
|||||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
import 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(() => {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
|
||||||
139
frontend/src/components/ListViews/LinkedDocsListView.vue
Normal file
139
frontend/src/components/ListViews/LinkedDocsListView.vue
Normal 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>
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -1,239 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{
|
|
||||||
title: editMode ? __(emailTemplate.name) : __('Create Email Template'),
|
|
||||||
size: 'xl',
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: editMode ? __('Update') : __('Create'),
|
|
||||||
variant: 'solid',
|
|
||||||
onClick: () => (editMode ? updateEmailTemplate() : callInsertDoc()),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body-content>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex sm:flex-row flex-col gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<FormControl
|
|
||||||
ref="nameRef"
|
|
||||||
v-model="_emailTemplate.name"
|
|
||||||
:placeholder="__('Payment Reminder')"
|
|
||||||
:label="__('Name')"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<FormControl
|
|
||||||
type="select"
|
|
||||||
v-model="_emailTemplate.reference_doctype"
|
|
||||||
:label="__('Doctype')"
|
|
||||||
:options="['CRM Deal', 'CRM Lead']"
|
|
||||||
:placeholder="__('CRM Deal')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
ref="subjectRef"
|
|
||||||
v-model="_emailTemplate.subject"
|
|
||||||
:label="__('Subject')"
|
|
||||||
:placeholder="__('Payment Reminder from Frappé - (#{{ name }})')"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
type="select"
|
|
||||||
v-model="_emailTemplate.content_type"
|
|
||||||
:label="__('Content Type')"
|
|
||||||
default="Rich Text"
|
|
||||||
:options="['Rich Text', 'HTML']"
|
|
||||||
:placeholder="__('Rich Text')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
v-if="_emailTemplate.content_type === 'HTML'"
|
|
||||||
type="textarea"
|
|
||||||
:label="__('Content')"
|
|
||||||
:required="true"
|
|
||||||
ref="content"
|
|
||||||
:rows="10"
|
|
||||||
v-model="_emailTemplate.response_html"
|
|
||||||
:placeholder="
|
|
||||||
__(
|
|
||||||
'<p>Dear {{ lead_name }},</p>\n\n<p>This is a reminder for the payment of {{ grand_total }}.</p>\n\n<p>Thanks,</p>\n<p>Frappé</p>',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<div v-else>
|
|
||||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
|
||||||
{{ __('Content') }}
|
|
||||||
<span class="text-ink-red-3">*</span>
|
|
||||||
</div>
|
|
||||||
<TextEditor
|
|
||||||
ref="content"
|
|
||||||
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
|
||||||
:bubbleMenu="true"
|
|
||||||
:content="_emailTemplate.response"
|
|
||||||
@change="(val) => (_emailTemplate.response = val)"
|
|
||||||
:placeholder="
|
|
||||||
__(
|
|
||||||
'Dear {{ lead_name }}, \n\nThis is a reminder for the payment of {{ grand_total }}. \n\nThanks, \nFrappé',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Checkbox v-model="_emailTemplate.enabled" :label="__('Enabled')" />
|
|
||||||
</div>
|
|
||||||
<ErrorMessage :message="__(errorMessage)" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
import { Checkbox, TextEditor, call } from 'frappe-ui'
|
|
||||||
import { ref, nextTick, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
emailTemplate: {
|
|
||||||
type: Object,
|
|
||||||
default: {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const show = defineModel()
|
|
||||||
const emailTemplates = defineModel('reloadEmailTemplates')
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
const emit = defineEmits(['after'])
|
|
||||||
|
|
||||||
const subjectRef = ref(null)
|
|
||||||
const nameRef = ref(null)
|
|
||||||
const editMode = ref(false)
|
|
||||||
let _emailTemplate = ref({
|
|
||||||
content_type: 'Rich Text',
|
|
||||||
})
|
|
||||||
|
|
||||||
async function updateEmailTemplate() {
|
|
||||||
if (!validate()) return
|
|
||||||
const old = { ...props.emailTemplate }
|
|
||||||
const newEmailTemplate = { ..._emailTemplate.value }
|
|
||||||
|
|
||||||
const nameChanged = old.name !== newEmailTemplate.name
|
|
||||||
delete old.name
|
|
||||||
delete newEmailTemplate.name
|
|
||||||
|
|
||||||
const otherFieldChanged =
|
|
||||||
JSON.stringify(old) !== JSON.stringify(newEmailTemplate)
|
|
||||||
const values = newEmailTemplate
|
|
||||||
|
|
||||||
if (!nameChanged && !otherFieldChanged) {
|
|
||||||
show.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let name
|
|
||||||
if (nameChanged) {
|
|
||||||
name = await callRenameDoc()
|
|
||||||
}
|
|
||||||
if (otherFieldChanged) {
|
|
||||||
name = await callSetValue(values)
|
|
||||||
}
|
|
||||||
handleEmailTemplateUpdate({ name })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callRenameDoc() {
|
|
||||||
const d = await call('frappe.client.rename_doc', {
|
|
||||||
doctype: 'Email Template',
|
|
||||||
old_name: props.emailTemplate.name,
|
|
||||||
new_name: _emailTemplate.value.name,
|
|
||||||
})
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callSetValue(values) {
|
|
||||||
const d = await call('frappe.client.set_value', {
|
|
||||||
doctype: 'Email Template',
|
|
||||||
name: _emailTemplate.value.name,
|
|
||||||
fieldname: values,
|
|
||||||
})
|
|
||||||
return d.name
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callInsertDoc() {
|
|
||||||
if (!validate()) return
|
|
||||||
const doc = await call('frappe.client.insert', {
|
|
||||||
doc: {
|
|
||||||
doctype: 'Email Template',
|
|
||||||
..._emailTemplate.value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (doc.name) {
|
|
||||||
capture('email_template_created', { doctype: doc.reference_doctype })
|
|
||||||
handleEmailTemplateUpdate(doc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEmailTemplateUpdate(doc) {
|
|
||||||
emailTemplates.value?.reload()
|
|
||||||
show.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate() {
|
|
||||||
_emailTemplate.value.use_html = Boolean(
|
|
||||||
_emailTemplate.value.content_type == 'HTML',
|
|
||||||
)
|
|
||||||
if (!_emailTemplate.value.name) {
|
|
||||||
errorMessage.value = 'Name is required'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!_emailTemplate.value.subject) {
|
|
||||||
errorMessage.value = 'Subject is required'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!_emailTemplate.value.use_html &&
|
|
||||||
(!_emailTemplate.value.response ||
|
|
||||||
_emailTemplate.value.response === '<p></p>')
|
|
||||||
) {
|
|
||||||
errorMessage.value = 'Content is required'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (_emailTemplate.value.use_html && !_emailTemplate.value.response_html) {
|
|
||||||
errorMessage.value = 'Content is required'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => show.value,
|
|
||||||
(value) => {
|
|
||||||
if (!value) return
|
|
||||||
editMode.value = false
|
|
||||||
errorMessage.value = ''
|
|
||||||
nextTick(() => {
|
|
||||||
if (_emailTemplate.value.name) {
|
|
||||||
subjectRef.value?.el?.focus()
|
|
||||||
} else {
|
|
||||||
nameRef.value?.el?.focus()
|
|
||||||
}
|
|
||||||
_emailTemplate.value = { ...props.emailTemplate }
|
|
||||||
_emailTemplate.value.content_type = _emailTemplate.value.use_html
|
|
||||||
? 'HTML'
|
|
||||||
: 'Rich Text'
|
|
||||||
if (_emailTemplate.value.name) {
|
|
||||||
editMode.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
@ -4,19 +4,33 @@
|
|||||||
:options="{ title: __('Email Templates'), size: '4xl' }"
|
: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'])
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex gap-1 -ml-4 w-9/12">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon-left="chevron-left"
|
||||||
|
:label="__(template.name)"
|
||||||
|
size="md"
|
||||||
|
@click="() => emit('updateStep', 'template-list')"
|
||||||
|
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||||
|
<Button
|
||||||
|
:label="__('Update')"
|
||||||
|
icon-left="plus"
|
||||||
|
variant="solid"
|
||||||
|
:disabled="!dirty"
|
||||||
|
:loading="renameDoc.loading || templates.setValue.loading"
|
||||||
|
@click="updateTemplate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fields -->
|
||||||
|
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center cursor-pointer border-b py-3"
|
||||||
|
@click="() => (template.enabled = !template.enabled)"
|
||||||
|
>
|
||||||
|
<div class="text-base text-ink-gray-7">{{ __('Enabled') }}</div>
|
||||||
|
<Switch v-model="template.enabled" @click.stop />
|
||||||
|
</div>
|
||||||
|
<div class="flex sm:flex-row flex-col gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<FormControl
|
||||||
|
size="md"
|
||||||
|
v-model="template.name"
|
||||||
|
:placeholder="__('Payment Reminder')"
|
||||||
|
:label="__('Name')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
size="md"
|
||||||
|
v-model="template.reference_doctype"
|
||||||
|
:label="__('For')"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
label: __('Deal'),
|
||||||
|
value: 'CRM Deal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Lead'),
|
||||||
|
value: 'CRM Lead',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:placeholder="__('Deal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
ref="subjectRef"
|
||||||
|
size="md"
|
||||||
|
v-model="template.subject"
|
||||||
|
:label="__('Subject')"
|
||||||
|
:placeholder="__('Payment Reminder from Frappé - (#{{ name }})')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
size="md"
|
||||||
|
v-model="template.content_type"
|
||||||
|
:label="__('Content Type')"
|
||||||
|
default="Rich Text"
|
||||||
|
:options="['Rich Text', 'HTML']"
|
||||||
|
:placeholder="__('Rich Text')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
v-if="template.content_type === 'HTML'"
|
||||||
|
size="md"
|
||||||
|
type="textarea"
|
||||||
|
:label="__('Content')"
|
||||||
|
:required="true"
|
||||||
|
ref="content"
|
||||||
|
:rows="10"
|
||||||
|
v-model="template.response_html"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'<p>Dear {{ lead_name }},</p>\n\n<p>This is a reminder for the payment of {{ grand_total }}.</p>\n\n<p>Thanks,</p>\n<p>Frappé</p>',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div v-else>
|
||||||
|
<div class="mb-1.5 text-base text-ink-gray-5">
|
||||||
|
{{ __('Content') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
ref="content"
|
||||||
|
editor-class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
||||||
|
:bubbleMenu="true"
|
||||||
|
:content="template.response"
|
||||||
|
@change="(val) => (template.response = val)"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'Dear {{ lead_name }}, \n\nThis is a reminder for the payment of {{ grand_total }}. \n\nThanks, \nFrappé',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage">
|
||||||
|
<ErrorMessage :message="__(errorMessage)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
TextEditor,
|
||||||
|
FormControl,
|
||||||
|
Switch,
|
||||||
|
toast,
|
||||||
|
call,
|
||||||
|
createResource,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
templateData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['updateStep'])
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const templates = inject('templates')
|
||||||
|
const template = ref({})
|
||||||
|
|
||||||
|
const updateTemplate = async () => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
if (!template.value.name) {
|
||||||
|
errorMessage.value = __('Name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!template.value.subject) {
|
||||||
|
errorMessage.value = __('Subject is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (template.value.content_type === 'Rich Text' && !template.value.response) {
|
||||||
|
errorMessage.value = __('Content is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (template.value.content_type === 'HTML' && !template.value.response_html) {
|
||||||
|
errorMessage.value = __('Content is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template.value.use_html = template.value.content_type === 'HTML'
|
||||||
|
|
||||||
|
const old = {
|
||||||
|
...props.templateData,
|
||||||
|
use_html: Boolean(props.templateData.use_html),
|
||||||
|
}
|
||||||
|
const newEmailTemplate = {
|
||||||
|
...template.value,
|
||||||
|
use_html: Boolean(template.value.use_html),
|
||||||
|
}
|
||||||
|
|
||||||
|
delete newEmailTemplate.content_type
|
||||||
|
|
||||||
|
const nameChanged = old.name !== newEmailTemplate.name
|
||||||
|
delete old.name
|
||||||
|
delete newEmailTemplate.name
|
||||||
|
|
||||||
|
const otherFieldChanged =
|
||||||
|
JSON.stringify(old) !== JSON.stringify(newEmailTemplate)
|
||||||
|
const values = newEmailTemplate
|
||||||
|
|
||||||
|
if (!nameChanged && !otherFieldChanged) return
|
||||||
|
|
||||||
|
let name = props.templateData.name
|
||||||
|
|
||||||
|
if (nameChanged) {
|
||||||
|
name = await renameDoc.fetch()
|
||||||
|
if (!otherFieldChanged) {
|
||||||
|
emit('updateStep', 'template-list')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (otherFieldChanged) {
|
||||||
|
templates.setValue.submit(
|
||||||
|
{ ...values, name },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
emit('updateStep', 'template-list')
|
||||||
|
toast.success(__('Template updated successfully'))
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
errorMessage.value =
|
||||||
|
error.messages[0] || __('Failed to update template')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirty = computed(() => {
|
||||||
|
return (
|
||||||
|
template.value.name !== props.templateData.name ||
|
||||||
|
template.value.reference_doctype !== props.templateData.reference_doctype ||
|
||||||
|
template.value.subject !== props.templateData.subject ||
|
||||||
|
template.value.response_html !== props.templateData.response_html ||
|
||||||
|
template.value.response !== props.templateData.response ||
|
||||||
|
template.value.use_html !== props.templateData.use_html ||
|
||||||
|
Boolean(template.value.enabled) !== Boolean(props.templateData.enabled)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const renameDoc = createResource({
|
||||||
|
url: 'frappe.client.rename_doc',
|
||||||
|
method: 'POST',
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
doctype: 'Email Template',
|
||||||
|
old_name: props.templateData.name,
|
||||||
|
new_name: template.value.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
templates.reload()
|
||||||
|
toast.success(__('Template renamed successfully'))
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
errorMessage.value = error.messages[0] || __('Failed to rename template')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
template.value = { ...props.templateData }
|
||||||
|
template.value.content_type = template.value.use_html ? 'HTML' : 'Rich Text'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<NewEmailTemplate
|
||||||
|
v-if="step === 'new-template'"
|
||||||
|
:templateData="template"
|
||||||
|
@updateStep="updateStep"
|
||||||
|
/>
|
||||||
|
<EmailTemplates
|
||||||
|
v-else-if="step === 'template-list'"
|
||||||
|
@updateStep="updateStep"
|
||||||
|
/>
|
||||||
|
<EditEmailTemplate
|
||||||
|
v-else-if="step === 'edit-template'"
|
||||||
|
:templateData="template"
|
||||||
|
@updateStep="updateStep"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import NewEmailTemplate from './NewEmailTemplate.vue'
|
||||||
|
import EditEmailTemplate from './EditEmailTemplate.vue'
|
||||||
|
import EmailTemplates from './EmailTemplates.vue'
|
||||||
|
import { createListResource } from 'frappe-ui'
|
||||||
|
import { provide, ref } from 'vue'
|
||||||
|
|
||||||
|
const step = ref('template-list')
|
||||||
|
const template = ref(null)
|
||||||
|
|
||||||
|
const templates = createListResource({
|
||||||
|
type: 'list',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
cache: 'emailTemplates',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'enabled',
|
||||||
|
'use_html',
|
||||||
|
'reference_doctype',
|
||||||
|
'subject',
|
||||||
|
'response',
|
||||||
|
'response_html',
|
||||||
|
'modified',
|
||||||
|
'owner',
|
||||||
|
],
|
||||||
|
auto: true,
|
||||||
|
filters: { reference_doctype: ['in', ['CRM Lead', 'CRM Deal']] },
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
pageLength: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
provide('templates', templates)
|
||||||
|
|
||||||
|
function updateStep(newStep, data) {
|
||||||
|
step.value = newStep
|
||||||
|
template.value = data
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -0,0 +1,265 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col gap-6 p-6 text-ink-gray-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between px-2 pt-2">
|
||||||
|
<div class="flex flex-col gap-1 w-9/12">
|
||||||
|
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||||
|
{{ __('Email templates') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-p-base text-ink-gray-6">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Add, edit, and manage email templates for various CRM communications',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||||
|
<Button
|
||||||
|
:label="__('New')"
|
||||||
|
icon-left="plus"
|
||||||
|
variant="solid"
|
||||||
|
@click="emit('updateStep', 'new-template')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- loading state -->
|
||||||
|
<div
|
||||||
|
v-if="templates.loading"
|
||||||
|
class="flex mt-28 justify-between w-full h-full"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
:loading="templates.loading"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full"
|
||||||
|
size="2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
v-if="!templates.loading && !templates.data?.length"
|
||||||
|
class="flex justify-between w-full h-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-ink-gray-4 border border-dashed rounded w-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ __('No email templates found') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email template list -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col overflow-hidden"
|
||||||
|
v-if="!templates.loading && templates.data?.length"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="templates.data?.length > 10"
|
||||||
|
class="flex items-center justify-between mb-4 px-2 pt-0.5"
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref="searchRef"
|
||||||
|
v-model="search"
|
||||||
|
:placeholder="__('Search template')"
|
||||||
|
class="w-1/3"
|
||||||
|
:debounce="300"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-6" />
|
||||||
|
</template>
|
||||||
|
</TextInput>
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
v-model="currentDoctype"
|
||||||
|
:options="[
|
||||||
|
{ label: __('All'), value: 'All' },
|
||||||
|
{ label: __('Lead'), value: 'CRM Lead' },
|
||||||
|
{ label: __('Deal'), value: 'CRM Deal' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center py-2 px-4 text-sm text-ink-gray-5">
|
||||||
|
<div class="w-4/6">{{ __('Template name') }}</div>
|
||||||
|
<div class="w-1/6">{{ __('For') }}</div>
|
||||||
|
<div class="w-1/6">{{ __('Enabled') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-px border-t mx-4 border-outline-gray-modals" />
|
||||||
|
<ul class="overflow-y-auto px-2">
|
||||||
|
<template v-for="(template, i) in templatesList" :key="template.name">
|
||||||
|
<li
|
||||||
|
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||||
|
@click="() => emit('updateStep', 'edit-template', { ...template })"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col w-4/6 pr-5">
|
||||||
|
<div class="text-base font-medium text-ink-gray-7 truncate">
|
||||||
|
{{ template.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-p-base text-ink-gray-5 truncate">
|
||||||
|
{{ template.subject }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-base text-ink-gray-6 w-1/6">
|
||||||
|
{{ template.reference_doctype.replace('CRM ', '') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between w-1/6">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
v-model="template.enabled"
|
||||||
|
@update:model-value="toggleEmailTemplate(template)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
class=""
|
||||||
|
:options="getDropdownOptions(template)"
|
||||||
|
placement="right"
|
||||||
|
:button="{
|
||||||
|
icon: 'more-horizontal',
|
||||||
|
variant: 'ghost',
|
||||||
|
onblur: (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
confirmDelete = false
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<div
|
||||||
|
v-if="templatesList.length !== i + 1"
|
||||||
|
class="h-px border-t mx-2 border-outline-gray-modals"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- Load More Button -->
|
||||||
|
<div
|
||||||
|
v-if="!templates.loading && templates.hasNextPage"
|
||||||
|
class="flex justify-center"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
class="mt-3.5 p-2"
|
||||||
|
@click="() => templates.next()"
|
||||||
|
:loading="templates.loading"
|
||||||
|
:label="__('Load More')"
|
||||||
|
icon-left="refresh-cw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { TemplateOption } from '@/utils'
|
||||||
|
import {
|
||||||
|
TextInput,
|
||||||
|
FormControl,
|
||||||
|
Switch,
|
||||||
|
Dropdown,
|
||||||
|
FeatherIcon,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, computed, inject } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['updateStep'])
|
||||||
|
|
||||||
|
const templates = inject('templates')
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const currentDoctype = ref('All')
|
||||||
|
const confirmDelete = ref(false)
|
||||||
|
|
||||||
|
const templatesList = computed(() => {
|
||||||
|
let list = templates.data || []
|
||||||
|
if (search.value) {
|
||||||
|
list = list.filter(
|
||||||
|
(template) =>
|
||||||
|
template.name.toLowerCase().includes(search.value.toLowerCase()) ||
|
||||||
|
template.subject.toLowerCase().includes(search.value.toLowerCase()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (currentDoctype.value !== 'All') {
|
||||||
|
list = list.filter(
|
||||||
|
(template) => template.reference_doctype === currentDoctype.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleEmailTemplate(template) {
|
||||||
|
templates.setValue.submit(
|
||||||
|
{
|
||||||
|
name: template.name,
|
||||||
|
enabled: template.enabled ? 1 : 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(
|
||||||
|
template.enabled
|
||||||
|
? __('Template enabled successfully')
|
||||||
|
: __('Template disabled successfully'),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.messages[0] || __('Failed to update template'))
|
||||||
|
// Revert the change if there was an error
|
||||||
|
template.enabled = !template.enabled
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTemplate(template) {
|
||||||
|
confirmDelete.value = false
|
||||||
|
templates.delete.submit(template.name, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Template deleted successfully'))
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.messages[0] || __('Failed to delete template'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDropdownOptions(template) {
|
||||||
|
let options = [
|
||||||
|
{
|
||||||
|
label: __('Duplicate'),
|
||||||
|
component: (props) =>
|
||||||
|
TemplateOption({
|
||||||
|
option: __('Duplicate'),
|
||||||
|
icon: 'copy',
|
||||||
|
active: props.active,
|
||||||
|
onClick: () => emit('updateStep', 'new-template', { ...template }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
component: (props) =>
|
||||||
|
TemplateOption({
|
||||||
|
option: __('Delete'),
|
||||||
|
icon: 'trash-2',
|
||||||
|
active: props.active,
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
confirmDelete.value = true
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
condition: () => !confirmDelete.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Confirm Delete'),
|
||||||
|
component: (props) =>
|
||||||
|
TemplateOption({
|
||||||
|
option: __('Confirm Delete'),
|
||||||
|
icon: 'trash-2',
|
||||||
|
active: props.active,
|
||||||
|
theme: 'danger',
|
||||||
|
onClick: () => deleteTemplate(template),
|
||||||
|
}),
|
||||||
|
condition: () => confirmDelete.value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return options.filter((option) => option.condition?.() || true)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex gap-1 -ml-4 w-9/12">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon-left="chevron-left"
|
||||||
|
:label="
|
||||||
|
templateData?.name ? __('Duplicate template') : __('New template')
|
||||||
|
"
|
||||||
|
size="md"
|
||||||
|
@click="() => emit('updateStep', 'template-list')"
|
||||||
|
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||||
|
<Button
|
||||||
|
:label="templateData?.name ? __('Duplicate') : __('Create')"
|
||||||
|
icon-left="plus"
|
||||||
|
variant="solid"
|
||||||
|
@click="createTemplate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fields -->
|
||||||
|
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center cursor-pointer border-b py-3"
|
||||||
|
@click="() => (template.enabled = !template.enabled)"
|
||||||
|
>
|
||||||
|
<div class="text-base text-ink-gray-7">{{ __('Enabled') }}</div>
|
||||||
|
<Switch v-model="template.enabled" @click.stop />
|
||||||
|
</div>
|
||||||
|
<div class="flex sm:flex-row flex-col gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<FormControl
|
||||||
|
size="md"
|
||||||
|
v-model="template.name"
|
||||||
|
:placeholder="__('Payment Reminder')"
|
||||||
|
:label="__('Name')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
size="md"
|
||||||
|
v-model="template.reference_doctype"
|
||||||
|
:label="__('For')"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
label: __('Deal'),
|
||||||
|
value: 'CRM Deal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Lead'),
|
||||||
|
value: 'CRM Lead',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:placeholder="__('Deal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
ref="subjectRef"
|
||||||
|
size="md"
|
||||||
|
v-model="template.subject"
|
||||||
|
:label="__('Subject')"
|
||||||
|
:placeholder="__('Payment Reminder from Frappé - (#{{ name }})')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
size="md"
|
||||||
|
v-model="template.content_type"
|
||||||
|
:label="__('Content Type')"
|
||||||
|
default="Rich Text"
|
||||||
|
:options="['Rich Text', 'HTML']"
|
||||||
|
:placeholder="__('Rich Text')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
v-if="template.content_type === 'HTML'"
|
||||||
|
size="md"
|
||||||
|
type="textarea"
|
||||||
|
:label="__('Content')"
|
||||||
|
:required="true"
|
||||||
|
ref="content"
|
||||||
|
:rows="10"
|
||||||
|
v-model="template.response_html"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'<p>Dear {{ lead_name }},</p>\n\n<p>This is a reminder for the payment of {{ grand_total }}.</p>\n\n<p>Thanks,</p>\n<p>Frappé</p>',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div v-else>
|
||||||
|
<div class="mb-1.5 text-base text-ink-gray-5">
|
||||||
|
{{ __('Content') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
ref="content"
|
||||||
|
editor-class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
||||||
|
:bubbleMenu="true"
|
||||||
|
:content="template.response"
|
||||||
|
@change="(val) => (template.response = val)"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'Dear {{ lead_name }}, \n\nThis is a reminder for the payment of {{ grand_total }}. \n\nThanks, \nFrappé',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage">
|
||||||
|
<ErrorMessage :message="__(errorMessage)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { TextEditor, FormControl, Switch, toast } from 'frappe-ui'
|
||||||
|
import { inject, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
templateData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['updateStep'])
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const template = ref({
|
||||||
|
name: '',
|
||||||
|
reference_doctype: 'CRM Deal',
|
||||||
|
subject: '',
|
||||||
|
content_type: 'Rich Text',
|
||||||
|
response_html: '',
|
||||||
|
response: '',
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const templates = inject('templates')
|
||||||
|
|
||||||
|
const createTemplate = () => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
if (!template.value.name) {
|
||||||
|
errorMessage.value = __('Name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!template.value.subject) {
|
||||||
|
errorMessage.value = __('Subject is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (template.value.content_type === 'Rich Text' && !template.value.response) {
|
||||||
|
errorMessage.value = __('Content is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (template.value.content_type === 'HTML' && !template.value.response_html) {
|
||||||
|
errorMessage.value = __('Content is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.insert.submit(
|
||||||
|
{ ...template.value },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
emit('updateStep', 'template-list')
|
||||||
|
toast.success(__('Template created successfully'))
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
errorMessage.value =
|
||||||
|
error.messages[0] || __('Failed to create template')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.templateData?.name) {
|
||||||
|
Object.assign(template.value, props.templateData)
|
||||||
|
template.value.name = template.value.name + ' - Copy'
|
||||||
|
template.value.enabled = false // Default to disabled for new templates
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -45,12 +45,14 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
|||||||
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
|
import 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),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h1>Email Templates</h1>
|
|
||||||
<p>Here is a list of email templates</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
<template>
|
|
||||||
<LayoutHeader>
|
|
||||||
<template #left-header>
|
|
||||||
<ViewBreadcrumbs v-model="viewControls" routeName="Email Templates" />
|
|
||||||
</template>
|
|
||||||
<template #right-header>
|
|
||||||
<CustomActions
|
|
||||||
v-if="emailTemplatesListView?.customListActions"
|
|
||||||
:actions="emailTemplatesListView.customListActions"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
:label="__('Create')"
|
|
||||||
@click="() => showEmailTemplate()"
|
|
||||||
>
|
|
||||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</LayoutHeader>
|
|
||||||
<ViewControls
|
|
||||||
ref="viewControls"
|
|
||||||
v-model="emailTemplates"
|
|
||||||
v-model:loadMore="loadMore"
|
|
||||||
v-model:resizeColumn="triggerResize"
|
|
||||||
v-model:updatedPageCount="updatedPageCount"
|
|
||||||
doctype="Email Template"
|
|
||||||
/>
|
|
||||||
<EmailTemplatesListView
|
|
||||||
ref="emailTemplatesListView"
|
|
||||||
v-if="emailTemplates.data && rows.length"
|
|
||||||
v-model="emailTemplates.data.page_length_count"
|
|
||||||
v-model:list="emailTemplates"
|
|
||||||
:rows="rows"
|
|
||||||
:columns="emailTemplates.data.columns"
|
|
||||||
:options="{
|
|
||||||
showTooltip: false,
|
|
||||||
resizeColumn: true,
|
|
||||||
rowCount: emailTemplates.data.row_count,
|
|
||||||
totalCount: emailTemplates.data.total_count,
|
|
||||||
}"
|
|
||||||
@loadMore="() => loadMore++"
|
|
||||||
@columnWidthUpdated="() => triggerResize++"
|
|
||||||
@updatePageCount="(count) => (updatedPageCount = count)"
|
|
||||||
@showEmailTemplate="showEmailTemplate"
|
|
||||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
|
||||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
|
||||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
|
||||||
@selectionsChanged="
|
|
||||||
(selections) => viewControls.updateSelections(selections)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else-if="emailTemplates.data"
|
|
||||||
class="flex h-full items-center justify-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-3 text-xl font-medium text-ink-gray-4"
|
|
||||||
>
|
|
||||||
<Email2Icon class="h-10 w-10" />
|
|
||||||
<span>{{ __('No {0} Found', [__('Email Templates')]) }}</span>
|
|
||||||
<Button :label="__('Create')" @click="() => showEmailTemplate()">
|
|
||||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EmailTemplateModal
|
|
||||||
v-model="showEmailTemplateModal"
|
|
||||||
v-model:reloadEmailTemplates="emailTemplates"
|
|
||||||
:emailTemplate="emailTemplate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
|
|
||||||
import CustomActions from '@/components/CustomActions.vue'
|
|
||||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
|
||||||
import ViewControls from '@/components/ViewControls.vue'
|
|
||||||
import EmailTemplatesListView from '@/components/ListViews/EmailTemplatesListView.vue'
|
|
||||||
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
|
||||||
import { getMeta } from '@/stores/meta'
|
|
||||||
import { formatDate, timeAgo } from '@/utils'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
|
|
||||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
|
||||||
getMeta('Email Template')
|
|
||||||
|
|
||||||
const emailTemplatesListView = ref(null)
|
|
||||||
|
|
||||||
// emailTemplates data is loaded in the ViewControls component
|
|
||||||
const emailTemplates = ref({})
|
|
||||||
const loadMore = ref(1)
|
|
||||||
const triggerResize = ref(1)
|
|
||||||
const updatedPageCount = ref(20)
|
|
||||||
const viewControls = ref(null)
|
|
||||||
|
|
||||||
const rows = computed(() => {
|
|
||||||
if (
|
|
||||||
!emailTemplates.value?.data?.data ||
|
|
||||||
!['list', 'group_by'].includes(emailTemplates.value.data.view_type)
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
return emailTemplates.value?.data.data.map((emailTemplate) => {
|
|
||||||
let _rows = {}
|
|
||||||
emailTemplates.value?.data.rows.forEach((row) => {
|
|
||||||
_rows[row] = emailTemplate[row]
|
|
||||||
|
|
||||||
let fieldType = emailTemplates.value?.data.columns?.find(
|
|
||||||
(col) => (col.key || col.value) == row,
|
|
||||||
)?.type
|
|
||||||
|
|
||||||
if (
|
|
||||||
fieldType &&
|
|
||||||
['Date', 'Datetime'].includes(fieldType) &&
|
|
||||||
!['modified', 'creation'].includes(row)
|
|
||||||
) {
|
|
||||||
_rows[row] = formatDate(
|
|
||||||
emailTemplate[row],
|
|
||||||
'',
|
|
||||||
true,
|
|
||||||
fieldType == 'Datetime',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType && fieldType == 'Currency') {
|
|
||||||
_rows[row] = getFormattedCurrency(row, emailTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType && fieldType == 'Float') {
|
|
||||||
_rows[row] = getFormattedFloat(row, emailTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType && fieldType == 'Percent') {
|
|
||||||
_rows[row] = getFormattedPercent(row, emailTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['modified', 'creation'].includes(row)) {
|
|
||||||
_rows[row] = {
|
|
||||||
label: formatDate(emailTemplate[row]),
|
|
||||||
timeAgo: timeAgo(emailTemplate[row]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return _rows
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const showEmailTemplateModal = ref(false)
|
|
||||||
|
|
||||||
const emailTemplate = ref({})
|
|
||||||
|
|
||||||
function showEmailTemplate(name) {
|
|
||||||
if (!name) {
|
|
||||||
emailTemplate.value = {
|
|
||||||
subject: '',
|
|
||||||
response: '',
|
|
||||||
response_html: '',
|
|
||||||
name: '',
|
|
||||||
enabled: 1,
|
|
||||||
use_html: 0,
|
|
||||||
owner: '',
|
|
||||||
reference_doctype: 'CRM Deal',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let et = rows.value?.find((row) => row.name === name)
|
|
||||||
emailTemplate.value = {
|
|
||||||
subject: et.subject,
|
|
||||||
response: et.response,
|
|
||||||
response_html: et.response_html,
|
|
||||||
name: et.name,
|
|
||||||
enabled: et.enabled,
|
|
||||||
use_html: et.use_html,
|
|
||||||
owner: et.owner,
|
|
||||||
reference_doctype: et.reference_doctype,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showEmailTemplateModal.value = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@ -134,7 +134,6 @@
|
|||||||
<Tooltip v-if="callEnabled" :text="__('Make a call')">
|
<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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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, '')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user