Compare commits
No commits in common. "main" and "mergify/bp/main-hotfix/pr-1034" have entirely different histories.
main
...
mergify/bp
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,5 +7,6 @@ dev-dist
|
|||||||
tags
|
tags
|
||||||
node_modules
|
node_modules
|
||||||
crm/public/frontend
|
crm/public/frontend
|
||||||
|
frontend/yarn.lock
|
||||||
crm/www/crm.html
|
crm/www/crm.html
|
||||||
build
|
build
|
||||||
|
|||||||
@ -84,14 +84,6 @@ The motivation behind building Frappe CRM stems from the need for a simple, cust
|
|||||||
- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework.
|
- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework.
|
||||||
- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
|
- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
|
||||||
|
|
||||||
### Compatibility
|
|
||||||
This app is compatible with the following versions of Frappe and ERPNext:
|
|
||||||
|
|
||||||
| CRM branch | Stability | Frappe branch | ERPNext branch |
|
|
||||||
| :-------------------- | :-------- | :------------------- | :------------------- |
|
|
||||||
| main - v1.x | stable | v15.x | v15.x |
|
|
||||||
| develop - future/v2.x | unstable | develop - future/v16 | develop - future/v16 |
|
|
||||||
|
|
||||||
## Getting Started (Production)
|
## Getting Started (Production)
|
||||||
|
|
||||||
### Managed Hosting
|
### Managed Hosting
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
__version__ = "1.53.1"
|
__version__ = "1.48.2"
|
||||||
__title__ = "Frappe CRM"
|
__title__ = "Frappe CRM"
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_assignment_rules_list():
|
|
||||||
assignment_rules = []
|
|
||||||
for docname in frappe.get_all(
|
|
||||||
"Assignment Rule", filters={"document_type": ["in", ["CRM Lead", "CRM Deal"]]}
|
|
||||||
):
|
|
||||||
doc = frappe.get_value(
|
|
||||||
"Assignment Rule",
|
|
||||||
docname,
|
|
||||||
fieldname=[
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"disabled",
|
|
||||||
"priority",
|
|
||||||
],
|
|
||||||
as_dict=True,
|
|
||||||
)
|
|
||||||
users_exists = bool(frappe.db.exists("Assignment Rule User", {"parent": docname.name}))
|
|
||||||
assignment_rules.append({**doc, "users_exists": users_exists})
|
|
||||||
return assignment_rules
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def duplicate_assignment_rule(docname, new_name):
|
|
||||||
doc = frappe.get_doc("Assignment Rule", docname)
|
|
||||||
doc.name = new_name
|
|
||||||
doc.assignment_rule_name = new_name
|
|
||||||
doc.insert()
|
|
||||||
return doc
|
|
||||||
File diff suppressed because it is too large
Load Diff
139
crm/api/doc.py
139
crm/api/doc.py
@ -662,7 +662,7 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False, only_re
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def remove_assignments(doctype, name, assignees, ignore_permissions=False):
|
def remove_assignments(doctype, name, assignees, ignore_permissions=False):
|
||||||
assignees = frappe.parse_json(assignees)
|
assignees = json.loads(assignees)
|
||||||
|
|
||||||
if not assignees:
|
if not assignees:
|
||||||
return
|
return
|
||||||
@ -750,11 +750,7 @@ def getCounts(d, doctype):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_linked_docs_of_document(doctype, docname):
|
def get_linked_docs_of_document(doctype, docname):
|
||||||
try:
|
doc = frappe.get_doc(doctype, docname)
|
||||||
doc = frappe.get_doc(doctype, docname)
|
|
||||||
except frappe.DoesNotExistError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
linked_docs = get_linked_docs(doc)
|
linked_docs = get_linked_docs(doc)
|
||||||
dynamic_linked_docs = get_dynamic_linked_docs(doc)
|
dynamic_linked_docs = get_dynamic_linked_docs(doc)
|
||||||
|
|
||||||
@ -763,14 +759,7 @@ def get_linked_docs_of_document(doctype, docname):
|
|||||||
|
|
||||||
docs_data = []
|
docs_data = []
|
||||||
for doc in linked_docs:
|
for doc in linked_docs:
|
||||||
if not doc.get("reference_doctype") or not doc.get("reference_docname"):
|
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
|
|
||||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
title = data.get("title")
|
title = data.get("title")
|
||||||
if data.doctype == "CRM Call Log":
|
if data.doctype == "CRM Call Log":
|
||||||
title = f"Call from {data.get('from')} to {data.get('to')}"
|
title = f"Call from {data.get('from')} to {data.get('to')}"
|
||||||
@ -778,9 +767,6 @@ def get_linked_docs_of_document(doctype, docname):
|
|||||||
if data.doctype == "CRM Deal":
|
if data.doctype == "CRM Deal":
|
||||||
title = data.get("organization")
|
title = data.get("organization")
|
||||||
|
|
||||||
if data.doctype == "CRM Notification":
|
|
||||||
title = data.get("message")
|
|
||||||
|
|
||||||
docs_data.append(
|
docs_data.append(
|
||||||
{
|
{
|
||||||
"doc": data.doctype,
|
"doc": data.doctype,
|
||||||
@ -793,51 +779,25 @@ def get_linked_docs_of_document(doctype, docname):
|
|||||||
|
|
||||||
|
|
||||||
def remove_doc_link(doctype, docname):
|
def remove_doc_link(doctype, docname):
|
||||||
if not doctype or not docname:
|
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||||
return
|
linked_doc_data.update(
|
||||||
|
{
|
||||||
try:
|
"reference_doctype": None,
|
||||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
"reference_docname": None,
|
||||||
if doctype == "CRM Notification":
|
}
|
||||||
delete_notification_type = {
|
)
|
||||||
"notification_type_doctype": "",
|
linked_doc_data.save(ignore_permissions=True)
|
||||||
"notification_type_doc": "",
|
|
||||||
}
|
|
||||||
delete_references = {
|
|
||||||
"reference_doctype": "",
|
|
||||||
"reference_name": "",
|
|
||||||
}
|
|
||||||
if linked_doc_data.get("notification_type_doctype") == linked_doc_data.get("reference_doctype"):
|
|
||||||
delete_references.update(delete_notification_type)
|
|
||||||
|
|
||||||
linked_doc_data.update(delete_references)
|
|
||||||
else:
|
|
||||||
linked_doc_data.update(
|
|
||||||
{
|
|
||||||
"reference_doctype": "",
|
|
||||||
"reference_docname": "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
linked_doc_data.save(ignore_permissions=True)
|
|
||||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def remove_contact_link(doctype, docname):
|
def remove_contact_link(doctype, docname):
|
||||||
if not doctype or not docname:
|
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||||
return
|
linked_doc_data.update(
|
||||||
|
{
|
||||||
try:
|
"contact": None,
|
||||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
"contacts": [],
|
||||||
linked_doc_data.update(
|
}
|
||||||
{
|
)
|
||||||
"contact": None,
|
linked_doc_data.save(ignore_permissions=True)
|
||||||
"contacts": [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
linked_doc_data.save(ignore_permissions=True)
|
|
||||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@ -846,19 +806,13 @@ def remove_linked_doc_reference(items, remove_contact=None, delete=False):
|
|||||||
items = frappe.parse_json(items)
|
items = frappe.parse_json(items)
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
if not item.get("doctype") or not item.get("docname"):
|
if remove_contact:
|
||||||
continue
|
remove_contact_link(item["doctype"], item["docname"])
|
||||||
|
else:
|
||||||
|
remove_doc_link(item["doctype"], item["docname"])
|
||||||
|
|
||||||
try:
|
if delete:
|
||||||
if remove_contact:
|
frappe.delete_doc(item["doctype"], item["docname"])
|
||||||
remove_contact_link(item["doctype"], item["docname"])
|
|
||||||
else:
|
|
||||||
remove_doc_link(item["doctype"], item["docname"])
|
|
||||||
if delete:
|
|
||||||
frappe.delete_doc(item["doctype"], item["docname"])
|
|
||||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
|
||||||
# Skip if document doesn't exist or has validation errors
|
|
||||||
continue
|
|
||||||
|
|
||||||
return "success"
|
return "success"
|
||||||
|
|
||||||
@ -867,40 +821,19 @@ def remove_linked_doc_reference(items, remove_contact=None, delete=False):
|
|||||||
def delete_bulk_docs(doctype, items, delete_linked=False):
|
def delete_bulk_docs(doctype, items, delete_linked=False):
|
||||||
from frappe.desk.reportview import delete_bulk
|
from frappe.desk.reportview import delete_bulk
|
||||||
|
|
||||||
if not doctype:
|
|
||||||
frappe.throw("Doctype is required")
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
frappe.throw("Items are required")
|
|
||||||
|
|
||||||
items = frappe.parse_json(items)
|
items = frappe.parse_json(items)
|
||||||
if not isinstance(items, list):
|
|
||||||
frappe.throw("Items must be a list")
|
|
||||||
|
|
||||||
for doc in items:
|
for doc in items:
|
||||||
try:
|
linked_docs = get_linked_docs_of_document(doctype, doc)
|
||||||
if not frappe.db.exists(doctype, doc):
|
for linked_doc in linked_docs:
|
||||||
frappe.log_error(f"Document {doctype} {doc} does not exist", "Bulk Delete Error")
|
remove_linked_doc_reference(
|
||||||
continue
|
[
|
||||||
|
{
|
||||||
linked_docs = get_linked_docs_of_document(doctype, doc)
|
"doctype": linked_doc["reference_doctype"],
|
||||||
for linked_doc in linked_docs:
|
"docname": linked_doc["reference_docname"],
|
||||||
if not linked_doc.get("reference_doctype") or not linked_doc.get("reference_docname"):
|
}
|
||||||
continue
|
],
|
||||||
|
remove_contact=doctype == "Contact",
|
||||||
remove_linked_doc_reference(
|
delete=delete_linked,
|
||||||
[
|
|
||||||
{
|
|
||||||
"doctype": linked_doc["reference_doctype"],
|
|
||||||
"docname": linked_doc["reference_docname"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
remove_contact=doctype == "Contact",
|
|
||||||
delete=delete_linked,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
frappe.log_error(
|
|
||||||
f"Error processing linked docs for {doctype} {doc}: {str(e)}", "Bulk Delete Error"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(items) > 10:
|
if len(items) > 10:
|
||||||
|
|||||||
137
crm/api/todo.py
137
crm/api/todo.py
@ -1,79 +1,90 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
||||||
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
if doc.reference_type in ["CRM Lead", "CRM Deal"] and doc.reference_name and doc.allocated_to:
|
if (
|
||||||
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
|
doc.reference_type in ["CRM Lead", "CRM Deal"]
|
||||||
owner = frappe.db.get_value(doc.reference_type, doc.reference_name, fieldname)
|
and doc.reference_name
|
||||||
if not owner:
|
and doc.allocated_to
|
||||||
frappe.db.set_value(
|
):
|
||||||
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to, update_modified=False
|
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
|
||||||
)
|
lead_owner = frappe.db.get_value(
|
||||||
|
doc.reference_type, doc.reference_name, fieldname
|
||||||
|
)
|
||||||
|
if not lead_owner:
|
||||||
|
frappe.db.set_value(
|
||||||
|
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to
|
||||||
|
)
|
||||||
|
|
||||||
if doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_name and doc.allocated_to:
|
if (
|
||||||
notify_assigned_user(doc)
|
doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
||||||
|
and doc.reference_name
|
||||||
|
and doc.allocated_to
|
||||||
|
):
|
||||||
|
notify_assigned_user(doc)
|
||||||
|
|
||||||
|
|
||||||
def on_update(doc, method):
|
def on_update(doc, method):
|
||||||
if (
|
if (
|
||||||
doc.has_value_changed("status")
|
doc.has_value_changed("status")
|
||||||
and doc.status == "Cancelled"
|
and doc.status == "Cancelled"
|
||||||
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
||||||
and doc.reference_name
|
and doc.reference_name
|
||||||
and doc.allocated_to
|
and doc.allocated_to
|
||||||
):
|
):
|
||||||
notify_assigned_user(doc, is_cancelled=True)
|
notify_assigned_user(doc, is_cancelled=True)
|
||||||
|
|
||||||
|
|
||||||
def notify_assigned_user(doc, is_cancelled=False):
|
def notify_assigned_user(doc, is_cancelled=False):
|
||||||
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||||
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
|
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
|
||||||
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
|
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
_("Your assignment on {0} {1} has been removed by {2}").format(
|
_("Your assignment on {0} {1} has been removed by {2}").format(
|
||||||
doc.reference_type, doc.reference_name, owner
|
doc.reference_type, doc.reference_name, owner
|
||||||
)
|
)
|
||||||
if is_cancelled
|
if is_cancelled
|
||||||
else _("{0} assigned a {1} {2} to you").format(owner, doc.reference_type, doc.reference_name)
|
else _("{0} assigned a {1} {2} to you").format(
|
||||||
)
|
owner, doc.reference_type, doc.reference_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
|
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
|
||||||
|
|
||||||
notify_user(
|
notify_user(
|
||||||
{
|
{
|
||||||
"owner": frappe.session.user,
|
"owner": frappe.session.user,
|
||||||
"assigned_to": doc.allocated_to,
|
"assigned_to": doc.allocated_to,
|
||||||
"notification_type": "Assignment",
|
"notification_type": "Assignment",
|
||||||
"message": message,
|
"message": message,
|
||||||
"notification_text": notification_text,
|
"notification_text": notification_text,
|
||||||
"reference_doctype": doc.reference_type,
|
"reference_doctype": doc.reference_type,
|
||||||
"reference_docname": doc.reference_name,
|
"reference_docname": doc.reference_name,
|
||||||
"redirect_to_doctype": redirect_to_doctype,
|
"redirect_to_doctype": redirect_to_doctype,
|
||||||
"redirect_to_docname": redirect_to_name,
|
"redirect_to_docname": redirect_to_name,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
||||||
name = doc.reference_name
|
name = doc.reference_name
|
||||||
doctype = doc.reference_type
|
doctype = doc.reference_type
|
||||||
|
|
||||||
if doctype.startswith("CRM "):
|
if doctype.startswith("CRM "):
|
||||||
doctype = doctype[4:].lower()
|
doctype = doctype[4:].lower()
|
||||||
|
|
||||||
if doctype in ["lead", "deal"]:
|
if doctype in ["lead", "deal"]:
|
||||||
name = (
|
name = (
|
||||||
reference_doc.lead_name or name
|
reference_doc.lead_name or name
|
||||||
if doctype == "lead"
|
if doctype == "lead"
|
||||||
else reference_doc.organization or reference_doc.lead_name or name
|
else reference_doc.organization or reference_doc.lead_name or name
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_cancelled:
|
if is_cancelled:
|
||||||
return f"""
|
return f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
|
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
|
||||||
doctype,
|
doctype,
|
||||||
@ -83,7 +94,7 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||||
<span>{ _('assigned a {0} {1} to you').format(
|
<span>{ _('assigned a {0} {1} to you').format(
|
||||||
@ -93,9 +104,9 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if doctype == "task":
|
if doctype == "task":
|
||||||
if is_cancelled:
|
if is_cancelled:
|
||||||
return f"""
|
return f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
|
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
|
||||||
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
|
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
|
||||||
@ -103,7 +114,7 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
|||||||
) }</span>
|
) }</span>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
return f"""
|
return f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||||
<span>{ _('assigned a new task {0} to you').format(
|
<span>{ _('assigned a new task {0} to you').format(
|
||||||
@ -114,8 +125,8 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
|||||||
|
|
||||||
|
|
||||||
def get_redirect_to_doc(doc):
|
def get_redirect_to_doc(doc):
|
||||||
if doc.reference_type == "CRM Task":
|
if doc.reference_type == "CRM Task":
|
||||||
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||||
return reference_doc.reference_doctype, reference_doc.reference_docname
|
return reference_doc.reference_doctype, reference_doc.reference_docname
|
||||||
|
|
||||||
return doc.reference_type, doc.reference_name
|
return doc.reference_type, doc.reference_name
|
||||||
|
|||||||
@ -10,15 +10,8 @@ from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
|||||||
def validate(doc, method):
|
def validate(doc, method):
|
||||||
if doc.type == "Incoming" and doc.get("from"):
|
if doc.type == "Incoming" and doc.get("from"):
|
||||||
name, doctype = get_lead_or_deal_from_number(doc.get("from"))
|
name, doctype = get_lead_or_deal_from_number(doc.get("from"))
|
||||||
if name != None:
|
doc.reference_doctype = doctype
|
||||||
doc.reference_doctype = doctype
|
doc.reference_name = name
|
||||||
doc.reference_name = name
|
|
||||||
|
|
||||||
if doc.type == "Outgoing" and doc.get("to"):
|
|
||||||
name, doctype = get_lead_or_deal_from_number(doc.get("to"))
|
|
||||||
if name != None:
|
|
||||||
doc.reference_doctype = doctype
|
|
||||||
doc.reference_name = name
|
|
||||||
|
|
||||||
|
|
||||||
def on_update(doc, method):
|
def on_update(doc, method):
|
||||||
@ -36,7 +29,7 @@ def on_update(doc, method):
|
|||||||
def notify_agent(doc):
|
def notify_agent(doc):
|
||||||
if doc.type == "Incoming":
|
if doc.type == "Incoming":
|
||||||
doctype = doc.reference_doctype
|
doctype = doc.reference_doctype
|
||||||
if doctype and doctype.startswith("CRM "):
|
if doctype.startswith("CRM "):
|
||||||
doctype = doctype[4:].lower()
|
doctype = doctype[4:].lower()
|
||||||
notification_text = f"""
|
notification_text = f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
|
||||||
// For license information, please see license.txt
|
|
||||||
|
|
||||||
// frappe.ui.form.on("CRM Dashboard", {
|
|
||||||
// refresh(frm) {
|
|
||||||
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
{
|
|
||||||
"actions": [],
|
|
||||||
"allow_rename": 1,
|
|
||||||
"autoname": "field:title",
|
|
||||||
"creation": "2025-07-14 12:19:49.725022",
|
|
||||||
"doctype": "DocType",
|
|
||||||
"engine": "InnoDB",
|
|
||||||
"field_order": [
|
|
||||||
"title",
|
|
||||||
"private",
|
|
||||||
"column_break_exbw",
|
|
||||||
"user",
|
|
||||||
"section_break_hfza",
|
|
||||||
"layout"
|
|
||||||
],
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_exbw",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "section_break_hfza",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": "[]",
|
|
||||||
"fieldname": "layout",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Layout",
|
|
||||||
"options": "JSON"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "title",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"label": "Name",
|
|
||||||
"unique": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"depends_on": "private",
|
|
||||||
"fieldname": "user",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "User",
|
|
||||||
"mandatory_depends_on": "private",
|
|
||||||
"options": "User"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": "0",
|
|
||||||
"fieldname": "private",
|
|
||||||
"fieldtype": "Check",
|
|
||||||
"label": "Private"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"grid_page_length": 50,
|
|
||||||
"index_web_pages_for_search": 1,
|
|
||||||
"links": [],
|
|
||||||
"modified": "2025-07-14 12:36:10.831351",
|
|
||||||
"modified_by": "Administrator",
|
|
||||||
"module": "FCRM",
|
|
||||||
"name": "CRM Dashboard",
|
|
||||||
"naming_rule": "By fieldname",
|
|
||||||
"owner": "Administrator",
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
|
||||||
"export": 1,
|
|
||||||
"print": 1,
|
|
||||||
"read": 1,
|
|
||||||
"report": 1,
|
|
||||||
"role": "System Manager",
|
|
||||||
"share": 1,
|
|
||||||
"write": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
|
||||||
"export": 1,
|
|
||||||
"print": 1,
|
|
||||||
"read": 1,
|
|
||||||
"report": 1,
|
|
||||||
"role": "Sales Manager",
|
|
||||||
"share": 1,
|
|
||||||
"write": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
|
||||||
"export": 1,
|
|
||||||
"print": 1,
|
|
||||||
"read": 1,
|
|
||||||
"report": 1,
|
|
||||||
"role": "Sales User",
|
|
||||||
"share": 1,
|
|
||||||
"write": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"row_format": "Dynamic",
|
|
||||||
"sort_field": "creation",
|
|
||||||
"sort_order": "DESC",
|
|
||||||
"states": [],
|
|
||||||
"title_field": "title"
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
|
||||||
# For license information, please see license.txt
|
|
||||||
|
|
||||||
import frappe
|
|
||||||
from frappe import _
|
|
||||||
from frappe.model.document import Document
|
|
||||||
|
|
||||||
|
|
||||||
class CRMDashboard(Document):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def default_manager_dashboard_layout():
|
|
||||||
"""
|
|
||||||
Returns the default layout for the CRM Manager Dashboard.
|
|
||||||
"""
|
|
||||||
return '[{"name":"total_leads","type":"number_chart","tooltip":"Total number of leads","layout":{"x":0,"y":0,"w":4,"h":3,"i":"total_leads"}},{"name":"ongoing_deals","type":"number_chart","tooltip":"Total number of ongoing deals","layout":{"x":8,"y":0,"w":4,"h":3,"i":"ongoing_deals"}},{"name":"won_deals","type":"number_chart","tooltip":"Total number of won deals","layout":{"x":12,"y":0,"w":4,"h":3,"i":"won_deals"}},{"name":"average_won_deal_value","type":"number_chart","tooltip":"Average value of won deals","layout":{"x":16,"y":0,"w":4,"h":3,"i":"average_won_deal_value"}},{"name":"average_deal_value","type":"number_chart","tooltip":"Average deal value of ongoing and won deals","layout":{"x":0,"y":2,"w":4,"h":3,"i":"average_deal_value"}},{"name":"average_time_to_close_a_lead","type":"number_chart","tooltip":"Average time taken to close a lead","layout":{"x":4,"y":0,"w":4,"h":3,"i":"average_time_to_close_a_lead"}},{"name":"average_time_to_close_a_deal","type":"number_chart","layout":{"x":4,"y":2,"w":4,"h":3,"i":"average_time_to_close_a_deal"}},{"name":"spacer","type":"spacer","layout":{"x":8,"y":2,"w":12,"h":3,"i":"spacer"}},{"name":"sales_trend","type":"axis_chart","layout":{"x":0,"y":4,"w":10,"h":9,"i":"sales_trend"}},{"name":"forecasted_revenue","type":"axis_chart","layout":{"x":10,"y":4,"w":10,"h":9,"i":"forecasted_revenue"}},{"name":"funnel_conversion","type":"axis_chart","layout":{"x":0,"y":11,"w":10,"h":9,"i":"funnel_conversion"}},{"name":"deals_by_stage_donut","type":"donut_chart","layout":{"x":10,"y":11,"w":10,"h":9,"i":"deals_by_stage_donut"}},{"name":"lost_deal_reasons","type":"axis_chart","layout":{"x":0,"y":32,"w":20,"h":9,"i":"lost_deal_reasons"}},{"name":"leads_by_source","type":"donut_chart","layout":{"x":0,"y":18,"w":10,"h":9,"i":"leads_by_source"}},{"name":"deals_by_source","type":"donut_chart","layout":{"x":10,"y":18,"w":10,"h":9,"i":"deals_by_source"}},{"name":"deals_by_territory","type":"axis_chart","layout":{"x":0,"y":25,"w":10,"h":9,"i":"deals_by_territory"}},{"name":"deals_by_salesperson","type":"axis_chart","layout":{"x":10,"y":25,"w":10,"h":9,"i":"deals_by_salesperson"}}]'
|
|
||||||
|
|
||||||
|
|
||||||
def create_default_manager_dashboard(force=False):
|
|
||||||
"""
|
|
||||||
Creates the default CRM Manager Dashboard if it does not exist.
|
|
||||||
"""
|
|
||||||
if not frappe.db.exists("CRM Dashboard", "Manager Dashboard"):
|
|
||||||
doc = frappe.new_doc("CRM Dashboard")
|
|
||||||
doc.title = "Manager Dashboard"
|
|
||||||
doc.layout = default_manager_dashboard_layout()
|
|
||||||
doc.insert(ignore_permissions=True)
|
|
||||||
else:
|
|
||||||
doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard")
|
|
||||||
if force:
|
|
||||||
doc.layout = default_manager_dashboard_layout()
|
|
||||||
doc.save(ignore_permissions=True)
|
|
||||||
return doc.layout
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
|
||||||
# See license.txt
|
|
||||||
|
|
||||||
# import frappe
|
|
||||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
|
||||||
|
|
||||||
|
|
||||||
# On IntegrationTestCase, the doctype test records and all
|
|
||||||
# link-field test record dependencies are recursively loaded
|
|
||||||
# Use these module variables to add/remove to/from that list
|
|
||||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
|
||||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
|
||||||
|
|
||||||
|
|
||||||
class UnitTestCRMDashboard(UnitTestCase):
|
|
||||||
"""
|
|
||||||
Unit tests for CRMDashboard.
|
|
||||||
Use this class for testing individual functions and methods.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationTestCRMDashboard(IntegrationTestCase):
|
|
||||||
"""
|
|
||||||
Integration tests for CRMDashboard.
|
|
||||||
Use this class for testing interactions between multiple components.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
@ -1,5 +1,20 @@
|
|||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
|
from crm.api.doc import get_fields_meta
|
||||||
|
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_deal(name):
|
||||||
|
deal = frappe.get_doc("CRM Deal", name)
|
||||||
|
deal.check_permission("read")
|
||||||
|
|
||||||
|
deal = deal.as_dict()
|
||||||
|
|
||||||
|
deal["fields_meta"] = get_fields_meta("CRM Deal")
|
||||||
|
deal["_form_script"] = get_form_script("CRM Deal")
|
||||||
|
return deal
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_deal_contacts(name):
|
def get_deal_contacts(name):
|
||||||
|
|||||||
@ -129,13 +129,15 @@
|
|||||||
"fieldname": "email",
|
"fieldname": "email",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Primary Email",
|
"label": "Primary Email",
|
||||||
"options": "Email"
|
"options": "Email",
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "mobile_no",
|
"fieldname": "mobile_no",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Primary Mobile No",
|
"label": "Primary Mobile No",
|
||||||
"options": "Phone"
|
"options": "Phone",
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "Qualification",
|
"default": "Qualification",
|
||||||
@ -249,7 +251,8 @@
|
|||||||
"fieldname": "phone",
|
"fieldname": "phone",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Primary Phone",
|
"label": "Primary Phone",
|
||||||
"options": "Phone"
|
"options": "Phone",
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "log_tab",
|
"fieldname": "log_tab",
|
||||||
@ -432,7 +435,7 @@
|
|||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-08-26 12:12:56.324245",
|
"modified": "2025-07-13 11:54:20.608489",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Deal",
|
"name": "CRM Deal",
|
||||||
|
|||||||
@ -7,8 +7,10 @@ from frappe.desk.form.assign_to import add as assign
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
||||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log
|
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
||||||
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate
|
add_status_change_log,
|
||||||
|
)
|
||||||
|
from crm.utils import get_exchange_rate
|
||||||
|
|
||||||
|
|
||||||
class CRMDeal(Document):
|
class CRMDeal(Document):
|
||||||
@ -25,7 +27,7 @@ class CRMDeal(Document):
|
|||||||
add_status_change_log(self)
|
add_status_change_log(self)
|
||||||
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
|
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
|
||||||
self.closed_date = frappe.utils.nowdate()
|
self.closed_date = frappe.utils.nowdate()
|
||||||
self.validate_forecasting_fields()
|
self.validate_forcasting_fields()
|
||||||
self.validate_lost_reason()
|
self.validate_lost_reason()
|
||||||
self.update_exchange_rate()
|
self.update_exchange_rate()
|
||||||
|
|
||||||
@ -137,12 +139,12 @@ class CRMDeal(Document):
|
|||||||
if sla:
|
if sla:
|
||||||
sla.apply(self)
|
sla.apply(self)
|
||||||
|
|
||||||
def update_closed_date(self):
|
def update_close_date(self):
|
||||||
"""
|
"""
|
||||||
Update the closed date based on the "Won" status.
|
Update the close date based on the "Won" status.
|
||||||
"""
|
"""
|
||||||
if self.status == "Won" and not self.closed_date:
|
if self.status == "Won" and not self.close_date:
|
||||||
self.closed_date = frappe.utils.nowdate()
|
self.close_date = frappe.utils.nowdate()
|
||||||
|
|
||||||
def update_default_probability(self):
|
def update_default_probability(self):
|
||||||
"""
|
"""
|
||||||
@ -151,26 +153,14 @@ class CRMDeal(Document):
|
|||||||
if not self.probability or self.probability == 0:
|
if not self.probability or self.probability == 0:
|
||||||
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
||||||
|
|
||||||
def update_expected_deal_value(self):
|
def validate_forcasting_fields(self):
|
||||||
"""
|
self.update_close_date()
|
||||||
Update the expected deal value based on the net total or total.
|
|
||||||
"""
|
|
||||||
if (
|
|
||||||
frappe.db.get_single_value("FCRM Settings", "auto_update_expected_deal_value")
|
|
||||||
and (self.net_total or self.total)
|
|
||||||
and self.expected_deal_value
|
|
||||||
):
|
|
||||||
self.expected_deal_value = self.net_total or self.total
|
|
||||||
|
|
||||||
def validate_forecasting_fields(self):
|
|
||||||
self.update_closed_date()
|
|
||||||
self.update_default_probability()
|
self.update_default_probability()
|
||||||
self.update_expected_deal_value()
|
|
||||||
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
|
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
|
||||||
if not self.expected_deal_value or self.expected_deal_value == 0:
|
if not self.deal_value or self.deal_value == 0:
|
||||||
frappe.throw(_("Expected Deal Value is required."), frappe.MandatoryError)
|
frappe.throw(_("Deal Value is required."), frappe.MandatoryError)
|
||||||
if not self.expected_closure_date:
|
if not self.close_date:
|
||||||
frappe.throw(_("Expected Closure Date is required."), frappe.MandatoryError)
|
frappe.throw(_("Close Date is required."), frappe.MandatoryError)
|
||||||
|
|
||||||
def validate_lost_reason(self):
|
def validate_lost_reason(self):
|
||||||
"""
|
"""
|
||||||
@ -187,7 +177,7 @@ class CRMDeal(Document):
|
|||||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||||
exchange_rate = 1
|
exchange_rate = 1
|
||||||
if self.currency and self.currency != system_currency:
|
if self.currency and self.currency != system_currency:
|
||||||
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
exchange_rate = get_exchange_rate(self.currency, system_currency, frappe.utils.nowdate())
|
||||||
|
|
||||||
self.db_set("exchange_rate", exchange_rate)
|
self.db_set("exchange_rate", exchange_rate)
|
||||||
|
|
||||||
|
|||||||
16
crm/fcrm/doctype/crm_lead/api.py
Normal file
16
crm/fcrm/doctype/crm_lead/api.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
from crm.api.doc import get_fields_meta
|
||||||
|
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_lead(name):
|
||||||
|
lead = frappe.get_doc("CRM Lead", name)
|
||||||
|
lead.check_permission("read")
|
||||||
|
|
||||||
|
lead = lead.as_dict()
|
||||||
|
|
||||||
|
lead["fields_meta"] = get_fields_meta("CRM Lead")
|
||||||
|
lead["_form_script"] = get_form_script("CRM Lead")
|
||||||
|
return lead
|
||||||
@ -4,7 +4,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate
|
from crm.utils import get_exchange_rate
|
||||||
|
|
||||||
|
|
||||||
class CRMOrganization(Document):
|
class CRMOrganization(Document):
|
||||||
@ -16,7 +16,7 @@ class CRMOrganization(Document):
|
|||||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||||
exchange_rate = 1
|
exchange_rate = 1
|
||||||
if self.currency and self.currency != system_currency:
|
if self.currency and self.currency != system_currency:
|
||||||
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
exchange_rate = get_exchange_rate(self.currency, system_currency, frappe.utils.nowdate())
|
||||||
|
|
||||||
self.db_set("exchange_rate", exchange_rate)
|
self.db_set("exchange_rate", exchange_rate)
|
||||||
|
|
||||||
|
|||||||
@ -22,8 +22,6 @@ def get_duration(from_date, to_date):
|
|||||||
|
|
||||||
|
|
||||||
def add_status_change_log(doc):
|
def add_status_change_log(doc):
|
||||||
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type") if doc.status else None
|
|
||||||
|
|
||||||
if not doc.is_new():
|
if not doc.is_new():
|
||||||
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
|
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
|
||||||
previous_status_type = (
|
previous_status_type = (
|
||||||
@ -35,7 +33,7 @@ def add_status_change_log(doc):
|
|||||||
"status_change_log",
|
"status_change_log",
|
||||||
{
|
{
|
||||||
"from": previous_status,
|
"from": previous_status,
|
||||||
"from_type": previous_status_type or "",
|
"from_type": previous_status_type,
|
||||||
"to": "",
|
"to": "",
|
||||||
"to_type": "",
|
"to_type": "",
|
||||||
"from_date": now_minus_one_minute,
|
"from_date": now_minus_one_minute,
|
||||||
@ -43,9 +41,10 @@ def add_status_change_log(doc):
|
|||||||
"log_owner": frappe.session.user,
|
"log_owner": frappe.session.user,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type")
|
||||||
last_status_change = doc.status_change_log[-1]
|
last_status_change = doc.status_change_log[-1]
|
||||||
last_status_change.to = doc.status
|
last_status_change.to = doc.status
|
||||||
last_status_change.to_type = to_status_type or ""
|
last_status_change.to_type = to_status_type
|
||||||
last_status_change.to_date = datetime.now()
|
last_status_change.to_date = datetime.now()
|
||||||
last_status_change.log_owner = frappe.session.user
|
last_status_change.log_owner = frappe.session.user
|
||||||
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
|
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
|
||||||
@ -54,7 +53,7 @@ def add_status_change_log(doc):
|
|||||||
"status_change_log",
|
"status_change_log",
|
||||||
{
|
{
|
||||||
"from": doc.status,
|
"from": doc.status,
|
||||||
"from_type": to_status_type or "",
|
"from_type": to_status_type,
|
||||||
"to": "",
|
"to": "",
|
||||||
"to_type": "",
|
"to_type": "",
|
||||||
"from_date": datetime.now(),
|
"from_date": datetime.now(),
|
||||||
|
|||||||
@ -63,7 +63,8 @@
|
|||||||
"fieldname": "twiml_sid",
|
"fieldname": "twiml_sid",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "TwiML SID",
|
"label": "TwiML SID",
|
||||||
"permlevel": 1
|
"permlevel": 1,
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "section_break_ssqj",
|
"fieldname": "section_break_ssqj",
|
||||||
@ -104,7 +105,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-08-19 13:36:19.823197",
|
"modified": "2025-01-15 19:35:13.406254",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Twilio Settings",
|
"name": "CRM Twilio Settings",
|
||||||
@ -151,7 +152,6 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"row_format": "Dynamic",
|
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
|
|||||||
@ -128,35 +128,14 @@ def get_quotation_url(crm_deal, organization):
|
|||||||
address = address.get("name") if address else None
|
address = address.get("name") if address else None
|
||||||
|
|
||||||
if not erpnext_crm_settings.is_erpnext_in_different_site:
|
if not erpnext_crm_settings.is_erpnext_in_different_site:
|
||||||
base_url = f"{get_url_to_list('Quotation')}/new"
|
quotation_url = get_url_to_list("Quotation")
|
||||||
params = {
|
return f"{quotation_url}/new?quotation_to=CRM Deal&crm_deal={crm_deal}&party_name={crm_deal}&company={erpnext_crm_settings.erpnext_company}&contact_person={contact}&customer_address={address}"
|
||||||
"quotation_to": "CRM Deal",
|
|
||||||
"crm_deal": crm_deal,
|
|
||||||
"party_name": crm_deal,
|
|
||||||
"company": erpnext_crm_settings.erpnext_company,
|
|
||||||
"contact_person": contact,
|
|
||||||
"customer_address": address
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
site_url = erpnext_crm_settings.get("erpnext_site_url")
|
site_url = erpnext_crm_settings.get("erpnext_site_url")
|
||||||
base_url = f"{site_url}/app/quotation/new"
|
quotation_url = f"{site_url}/app/quotation"
|
||||||
|
|
||||||
prospect = create_prospect_in_remote_site(crm_deal, erpnext_crm_settings)
|
prospect = create_prospect_in_remote_site(crm_deal, erpnext_crm_settings)
|
||||||
params = {
|
return f"{quotation_url}/new?quotation_to=Prospect&crm_deal={crm_deal}&party_name={prospect}&company={erpnext_crm_settings.erpnext_company}&contact_person={contact}&customer_address={address}"
|
||||||
"quotation_to": "Prospect",
|
|
||||||
"crm_deal": crm_deal,
|
|
||||||
"party_name": prospect,
|
|
||||||
"company": erpnext_crm_settings.erpnext_company,
|
|
||||||
"contact_person": contact,
|
|
||||||
"customer_address": address
|
|
||||||
}
|
|
||||||
|
|
||||||
# Filter out None values and build query string
|
|
||||||
query_string = "&".join(
|
|
||||||
f"{key}={value}" for key, value in params.items()
|
|
||||||
if value is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"{base_url}?{query_string}"
|
|
||||||
|
|
||||||
|
|
||||||
def create_prospect_in_remote_site(crm_deal, erpnext_crm_settings):
|
def create_prospect_in_remote_site(crm_deal, erpnext_crm_settings):
|
||||||
|
|||||||
@ -8,13 +8,7 @@
|
|||||||
"defaults_tab",
|
"defaults_tab",
|
||||||
"restore_defaults",
|
"restore_defaults",
|
||||||
"enable_forecasting",
|
"enable_forecasting",
|
||||||
"auto_update_expected_deal_value",
|
|
||||||
"currency_tab",
|
|
||||||
"currency",
|
"currency",
|
||||||
"exchange_rate_provider_section",
|
|
||||||
"service_provider",
|
|
||||||
"column_break_vqck",
|
|
||||||
"access_key",
|
|
||||||
"branding_tab",
|
"branding_tab",
|
||||||
"brand_name",
|
"brand_name",
|
||||||
"brand_logo",
|
"brand_logo",
|
||||||
@ -78,47 +72,12 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Currency",
|
"label": "Currency",
|
||||||
"options": "Currency"
|
"options": "Currency"
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "currency_tab",
|
|
||||||
"fieldtype": "Tab Break",
|
|
||||||
"label": "Currency"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "exchange_rate_provider_section",
|
|
||||||
"fieldtype": "Section Break",
|
|
||||||
"label": "Exchange Rate Provider"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": "frankfurter.app",
|
|
||||||
"fieldname": "service_provider",
|
|
||||||
"fieldtype": "Select",
|
|
||||||
"label": "Service Provider",
|
|
||||||
"options": "frankfurter.app\nexchangerate.host"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"depends_on": "eval:doc.service_provider == 'exchangerate.host';",
|
|
||||||
"fieldname": "access_key",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"label": "Access Key",
|
|
||||||
"mandatory_depends_on": "eval:doc.service_provider == 'exchangerate.host';"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_vqck",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": "1",
|
|
||||||
"description": "Automatically update \"Expected Deal Value\" based on the total value of associated products in a deal",
|
|
||||||
"fieldname": "auto_update_expected_deal_value",
|
|
||||||
"fieldtype": "Check",
|
|
||||||
"label": "Auto Update Expected Deal Value"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-09-16 17:33:26.406549",
|
"modified": "2025-07-13 11:58:34.857638",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "FCRM Settings",
|
"name": "FCRM Settings",
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
import requests
|
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
|
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
@ -133,76 +132,3 @@ def get_forecasting_script():
|
|||||||
this.doc.probability = status.probability
|
this.doc.probability = status.probability
|
||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
|
|
||||||
def get_exchange_rate(from_currency, to_currency, date=None):
|
|
||||||
if not date:
|
|
||||||
date = "latest"
|
|
||||||
|
|
||||||
api_used = "frankfurter"
|
|
||||||
|
|
||||||
api_endpoint = f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}"
|
|
||||||
res = requests.get(api_endpoint, timeout=5)
|
|
||||||
if res.ok:
|
|
||||||
data = res.json()
|
|
||||||
return data["rates"][to_currency]
|
|
||||||
|
|
||||||
# Fallback to exchangerate.host if Frankfurter API fails
|
|
||||||
settings = FCRMSettings("FCRM Settings")
|
|
||||||
if settings and settings.service_provider == "exchangerate.host":
|
|
||||||
api_used = "exchangerate.host"
|
|
||||||
if not settings.access_key:
|
|
||||||
frappe.throw(
|
|
||||||
_("Access Key is required for Service Provider: {0}").format(
|
|
||||||
frappe.bold(settings.service_provider)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"access_key": settings.access_key,
|
|
||||||
"from": from_currency,
|
|
||||||
"to": to_currency,
|
|
||||||
"amount": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
if date != "latest":
|
|
||||||
params["date"] = date
|
|
||||||
|
|
||||||
api_endpoint = "https://api.exchangerate.host/convert"
|
|
||||||
|
|
||||||
res = requests.get(api_endpoint, params=params, timeout=5)
|
|
||||||
if res.ok:
|
|
||||||
data = res.json()
|
|
||||||
return data["result"]
|
|
||||||
|
|
||||||
frappe.log_error(
|
|
||||||
title="Exchange Rate Fetch Error",
|
|
||||||
message=f"Failed to fetch exchange rate from {from_currency} to {to_currency} using {api_used} API.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if api_used == "frankfurter":
|
|
||||||
user = frappe.session.user
|
|
||||||
is_manager = (
|
|
||||||
"System Manager" in frappe.get_roles(user)
|
|
||||||
or "Sales Manager" in frappe.get_roles(user)
|
|
||||||
or user == "Administrator"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_manager:
|
|
||||||
frappe.throw(
|
|
||||||
_(
|
|
||||||
"Ask your manager to set up the Exchange Rate Provider, as default provider does not support currency conversion for {0} to {1}."
|
|
||||||
).format(from_currency, to_currency)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
frappe.throw(
|
|
||||||
_(
|
|
||||||
"Setup the Exchange Rate Provider as 'Exchangerate Host' in settings, as default provider does not support currency conversion for {0} to {1}."
|
|
||||||
).format(from_currency, to_currency)
|
|
||||||
)
|
|
||||||
|
|
||||||
frappe.throw(
|
|
||||||
_(
|
|
||||||
"Failed to fetch exchange rate from {0} to {1} on {2}. Please check your internet connection or try again later."
|
|
||||||
).format(from_currency, to_currency, date)
|
|
||||||
)
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import click
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||||
|
|
||||||
from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard
|
|
||||||
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
|
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
|
||||||
|
|
||||||
|
|
||||||
@ -24,9 +23,6 @@ def after_install(force=False):
|
|||||||
add_default_lost_reasons()
|
add_default_lost_reasons()
|
||||||
add_standard_dropdown_items()
|
add_standard_dropdown_items()
|
||||||
add_default_scripts()
|
add_default_scripts()
|
||||||
create_default_manager_dashboard(force)
|
|
||||||
create_assignment_rule_custom_fields()
|
|
||||||
add_assignment_rule_property_setters()
|
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
|
|
||||||
|
|
||||||
@ -194,7 +190,7 @@ def add_default_fields_layout(force=False):
|
|||||||
},
|
},
|
||||||
"CRM Deal-Data Fields": {
|
"CRM Deal-Data Fields": {
|
||||||
"doctype": "CRM Deal",
|
"doctype": "CRM Deal",
|
||||||
"layout": '[{"name":"first_tab","sections":[{"label":"Details","name":"details_section","opened":true,"columns":[{"name":"column_z9XL","fields":["organization","annual_revenue","next_step"]},{"name":"column_gM4w","fields":["website","closed_date","deal_owner"]},{"name":"column_gWmE","fields":["territory","probability"]}]},{"label":"Products","name":"section_jHhQ","opened":true,"columns":[{"name":"column_xiNF","fields":["products"]}],"editingLabel":false,"hideLabel":true},{"label":"New Section","name":"section_WNOQ","opened":true,"columns":[{"name":"column_ziBW","fields":["total"]},{"label":"","name":"column_wuwA","fields":["net_total"]}],"hideBorder":true,"hideLabel":true}]}]',
|
"layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_z9XL", "fields": ["organization", "annual_revenue", "next_step"]}, {"name": "column_gM4w", "fields": ["website", "close_date", "deal_owner"]}, {"name": "column_gWmE", "fields": ["territory", "probability"]}]}]',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,80 +419,3 @@ def add_default_scripts():
|
|||||||
for doctype in ["CRM Lead", "CRM Deal"]:
|
for doctype in ["CRM Lead", "CRM Deal"]:
|
||||||
create_product_details_script(doctype)
|
create_product_details_script(doctype)
|
||||||
create_forecasting_script()
|
create_forecasting_script()
|
||||||
|
|
||||||
|
|
||||||
def add_assignment_rule_property_setters():
|
|
||||||
"""Add a property setter to the Assignment Rule DocType for assign_condition and unassign_condition."""
|
|
||||||
|
|
||||||
default_fields = {
|
|
||||||
"doctype": "Property Setter",
|
|
||||||
"doctype_or_field": "DocField",
|
|
||||||
"doc_type": "Assignment Rule",
|
|
||||||
"property_type": "Data",
|
|
||||||
"is_system_generated": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-assign_condition-depends_on"}):
|
|
||||||
frappe.get_doc(
|
|
||||||
{
|
|
||||||
**default_fields,
|
|
||||||
"name": "Assignment Rule-assign_condition-depends_on",
|
|
||||||
"field_name": "assign_condition",
|
|
||||||
"property": "depends_on",
|
|
||||||
"value": "eval: !doc.assign_condition_json",
|
|
||||||
}
|
|
||||||
).insert()
|
|
||||||
else:
|
|
||||||
frappe.db.set_value(
|
|
||||||
"Property Setter",
|
|
||||||
{"name": "Assignment Rule-assign_condition-depends_on"},
|
|
||||||
"value",
|
|
||||||
"eval: !doc.assign_condition_json",
|
|
||||||
)
|
|
||||||
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-unassign_condition-depends_on"}):
|
|
||||||
frappe.get_doc(
|
|
||||||
{
|
|
||||||
**default_fields,
|
|
||||||
"name": "Assignment Rule-unassign_condition-depends_on",
|
|
||||||
"field_name": "unassign_condition",
|
|
||||||
"property": "depends_on",
|
|
||||||
"value": "eval: !doc.unassign_condition_json",
|
|
||||||
}
|
|
||||||
).insert()
|
|
||||||
else:
|
|
||||||
frappe.db.set_value(
|
|
||||||
"Property Setter",
|
|
||||||
{"name": "Assignment Rule-unassign_condition-depends_on"},
|
|
||||||
"value",
|
|
||||||
"eval: !doc.unassign_condition_json",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_assignment_rule_custom_fields():
|
|
||||||
if not frappe.get_meta("Assignment Rule").has_field("assign_condition_json"):
|
|
||||||
click.secho("* Installing Custom Fields in Assignment Rule")
|
|
||||||
|
|
||||||
create_custom_fields(
|
|
||||||
{
|
|
||||||
"Assignment Rule": [
|
|
||||||
{
|
|
||||||
"description": "Autogenerated field by CRM App",
|
|
||||||
"fieldname": "assign_condition_json",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Assign Condition JSON",
|
|
||||||
"insert_after": "assign_condition",
|
|
||||||
"depends_on": "eval: doc.assign_condition_json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Autogenerated field by CRM App",
|
|
||||||
"fieldname": "unassign_condition_json",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Unassign Condition JSON",
|
|
||||||
"insert_after": "unassign_condition",
|
|
||||||
"depends_on": "eval: doc.unassign_condition_json",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
frappe.clear_cache(doctype="Assignment Rule")
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def set_default_calling_medium(medium):
|
|||||||
frappe.get_doc(
|
frappe.get_doc(
|
||||||
{
|
{
|
||||||
"doctype": "CRM Telephony Agent",
|
"doctype": "CRM Telephony Agent",
|
||||||
"user": frappe.session.user,
|
"agent": frappe.session.user,
|
||||||
"default_medium": medium,
|
"default_medium": medium,
|
||||||
}
|
}
|
||||||
).insert(ignore_permissions=True)
|
).insert(ignore_permissions=True)
|
||||||
|
|||||||
@ -242,18 +242,19 @@ def get_call_log_status(call_payload, direction="inbound"):
|
|||||||
elif status == "failed":
|
elif status == "failed":
|
||||||
return "Failed"
|
return "Failed"
|
||||||
|
|
||||||
|
status = call_payload.get("DialCallStatus")
|
||||||
call_type = call_payload.get("CallType")
|
call_type = call_payload.get("CallType")
|
||||||
status = call_payload.get("DialCallStatus") or call_payload.get("Status")
|
dial_call_status = call_payload.get("DialCallStatus")
|
||||||
|
|
||||||
if call_type == "incomplete" and status == "no-answer":
|
if call_type == "incomplete" and dial_call_status == "no-answer":
|
||||||
status = "No Answer"
|
status = "No Answer"
|
||||||
elif call_type == "client-hangup" and status == "canceled":
|
elif call_type == "client-hangup" and dial_call_status == "canceled":
|
||||||
status = "Canceled"
|
status = "Canceled"
|
||||||
elif call_type == "incomplete" and status == "failed":
|
elif call_type == "incomplete" and dial_call_status == "failed":
|
||||||
status = "Failed"
|
status = "Failed"
|
||||||
elif call_type == "completed":
|
elif call_type == "completed":
|
||||||
status = "Completed"
|
status = "Completed"
|
||||||
elif status == "busy":
|
elif dial_call_status == "busy":
|
||||||
status = "Ringing"
|
status = "Ringing"
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|||||||
3717
crm/locale/ar.po
3717
crm/locale/ar.po
File diff suppressed because it is too large
Load Diff
3787
crm/locale/bs.po
3787
crm/locale/bs.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/cs.po
6392
crm/locale/cs.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/da.po
6392
crm/locale/da.po
File diff suppressed because it is too large
Load Diff
4089
crm/locale/de.po
4089
crm/locale/de.po
File diff suppressed because it is too large
Load Diff
3751
crm/locale/eo.po
3751
crm/locale/eo.po
File diff suppressed because it is too large
Load Diff
3733
crm/locale/es.po
3733
crm/locale/es.po
File diff suppressed because it is too large
Load Diff
3971
crm/locale/fa.po
3971
crm/locale/fa.po
File diff suppressed because it is too large
Load Diff
3727
crm/locale/fr.po
3727
crm/locale/fr.po
File diff suppressed because it is too large
Load Diff
5177
crm/locale/hr.po
5177
crm/locale/hr.po
File diff suppressed because it is too large
Load Diff
3735
crm/locale/hu.po
3735
crm/locale/hu.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/id.po
6392
crm/locale/id.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/it.po
6392
crm/locale/it.po
File diff suppressed because it is too large
Load Diff
2637
crm/locale/main.pot
2637
crm/locale/main.pot
File diff suppressed because it is too large
Load Diff
6392
crm/locale/nb.po
6392
crm/locale/nb.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/nl.po
6392
crm/locale/nl.po
File diff suppressed because it is too large
Load Diff
3795
crm/locale/pl.po
3795
crm/locale/pl.po
File diff suppressed because it is too large
Load Diff
4407
crm/locale/pt.po
4407
crm/locale/pt.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/pt_BR.po
6392
crm/locale/pt_BR.po
File diff suppressed because it is too large
Load Diff
3755
crm/locale/ru.po
3755
crm/locale/ru.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/sr.po
6392
crm/locale/sr.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/sr_CS.po
6392
crm/locale/sr_CS.po
File diff suppressed because it is too large
Load Diff
3801
crm/locale/sv.po
3801
crm/locale/sv.po
File diff suppressed because it is too large
Load Diff
4119
crm/locale/th.po
4119
crm/locale/th.po
File diff suppressed because it is too large
Load Diff
3719
crm/locale/tr.po
3719
crm/locale/tr.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/vi.po
6392
crm/locale/vi.po
File diff suppressed because it is too large
Load Diff
4747
crm/locale/zh.po
4747
crm/locale/zh.po
File diff suppressed because it is too large
Load Diff
@ -15,5 +15,3 @@ crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
|||||||
crm.patches.v1_0.create_default_scripts # 13-06-2025
|
crm.patches.v1_0.create_default_scripts # 13-06-2025
|
||||||
crm.patches.v1_0.update_deal_status_probabilities
|
crm.patches.v1_0.update_deal_status_probabilities
|
||||||
crm.patches.v1_0.update_deal_status_type
|
crm.patches.v1_0.update_deal_status_type
|
||||||
crm.patches.v1_0.create_default_lost_reasons
|
|
||||||
crm.patches.v1_0.add_fields_in_assignment_rule
|
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
from crm.install import (
|
|
||||||
add_assignment_rule_property_setters,
|
|
||||||
create_assignment_rule_custom_fields,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def execute():
|
|
||||||
create_assignment_rule_custom_fields()
|
|
||||||
add_assignment_rule_property_setters()
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
from crm.install import add_default_lost_reasons
|
|
||||||
|
|
||||||
|
|
||||||
def execute():
|
|
||||||
add_default_lost_reasons()
|
|
||||||
@ -27,7 +27,7 @@ def execute():
|
|||||||
]
|
]
|
||||||
|
|
||||||
for status in deal_statuses:
|
for status in deal_statuses:
|
||||||
if not status.type or status.type is None or status.type == "Open":
|
if status.type is None or status.type == "":
|
||||||
if status.deal_status in openStatuses:
|
if status.deal_status in openStatuses:
|
||||||
type = "Open"
|
type = "Open"
|
||||||
elif status.deal_status in ongoingStatuses:
|
elif status.deal_status in ongoingStatuses:
|
||||||
|
|||||||
@ -267,3 +267,20 @@ def sales_user_only(fn):
|
|||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def get_exchange_rate(from_currency, to_currency, date=None):
|
||||||
|
if not date:
|
||||||
|
date = "latest"
|
||||||
|
|
||||||
|
url = f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}"
|
||||||
|
|
||||||
|
response = requests.get(url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
rate = data["rates"].get(to_currency)
|
||||||
|
return rate
|
||||||
|
else:
|
||||||
|
frappe.throw(_("Failed to fetch historical exchange rate from external API. Please try again later."))
|
||||||
|
return None
|
||||||
|
|||||||
@ -1,8 +1,3 @@
|
|||||||
files:
|
files:
|
||||||
- source: /crm/locale/main.pot
|
- source: /crm/locale/main.pot
|
||||||
translation: /crm/locale/%two_letters_code%.po
|
translation: /crm/locale/%two_letters_code%.po
|
||||||
pull_request_title: "chore: sync translations from crowdin"
|
|
||||||
pull_request_labels:
|
|
||||||
- translation
|
|
||||||
commit_message: "chore: %language% translations"
|
|
||||||
append_commit_message: false
|
|
||||||
@ -8,21 +8,21 @@ else
|
|||||||
echo "Creating new bench..."
|
echo "Creating new bench..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
bench init --skip-redis-config-generation frappe-bench --version version-15
|
bench init --skip-redis-config-generation frappe-bench
|
||||||
|
|
||||||
cd frappe-bench
|
cd frappe-bench
|
||||||
|
|
||||||
# Use containers instead of localhost
|
# Use containers instead of localhost
|
||||||
bench set-mariadb-host mariadb
|
bench set-mariadb-host mariadb
|
||||||
bench set-redis-cache-host redis://redis:6379
|
bench set-redis-cache-host redis:6379
|
||||||
bench set-redis-queue-host redis://redis:6379
|
bench set-redis-queue-host redis:6379
|
||||||
bench set-redis-socketio-host redis://redis:6379
|
bench set-redis-socketio-host redis:6379
|
||||||
|
|
||||||
# Remove redis, watch from Procfile
|
# Remove redis, watch from Procfile
|
||||||
sed -i '/redis/d' ./Procfile
|
sed -i '/redis/d' ./Procfile
|
||||||
sed -i '/watch/d' ./Procfile
|
sed -i '/watch/d' ./Procfile
|
||||||
|
|
||||||
bench get-app crm --branch main
|
bench get-app crm --branch develop
|
||||||
|
|
||||||
bench new-site crm.localhost \
|
bench new-site crm.localhost \
|
||||||
--force \
|
--force \
|
||||||
@ -32,9 +32,8 @@ bench new-site crm.localhost \
|
|||||||
|
|
||||||
bench --site crm.localhost install-app crm
|
bench --site crm.localhost install-app crm
|
||||||
bench --site crm.localhost set-config developer_mode 1
|
bench --site crm.localhost set-config developer_mode 1
|
||||||
bench --site crm.localhost set-config mute_emails 1
|
|
||||||
bench --site crm.localhost set-config server_script_enabled 1
|
|
||||||
bench --site crm.localhost clear-cache
|
bench --site crm.localhost clear-cache
|
||||||
|
bench --site crm.localhost set-config mute_emails 1
|
||||||
bench use crm.localhost
|
bench use crm.localhost
|
||||||
|
|
||||||
bench start
|
bench start
|
||||||
@ -1 +1 @@
|
|||||||
Subproject commit c9a0fc937cc897864857271b3708a0c675379015
|
Subproject commit 424288f77af4779dd3bb71dc3d278fc627f95179
|
||||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@ -3,4 +3,3 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
components.d.ts
|
|
||||||
10
frontend/auto-imports.d.ts
vendored
10
frontend/auto-imports.d.ts
vendored
@ -1,10 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/* prettier-ignore */
|
|
||||||
// @ts-nocheck
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
// Generated by unplugin-auto-import
|
|
||||||
// biome-ignore lint: disable
|
|
||||||
export {}
|
|
||||||
declare global {
|
|
||||||
|
|
||||||
}
|
|
||||||
25
frontend/components.d.ts
vendored
25
frontend/components.d.ts
vendored
@ -12,7 +12,6 @@ declare module 'vue' {
|
|||||||
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
|
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
|
||||||
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
|
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
|
||||||
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
|
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
|
||||||
AddChartModal: typeof import('./src/components/Dashboard/AddChartModal.vue')['default']
|
|
||||||
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
|
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
|
||||||
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
|
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
|
||||||
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
|
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
|
||||||
@ -25,7 +24,6 @@ declare module 'vue' {
|
|||||||
AscendingIcon: typeof import('./src/components/Icons/AscendingIcon.vue')['default']
|
AscendingIcon: typeof import('./src/components/Icons/AscendingIcon.vue')['default']
|
||||||
AssignmentModal: typeof import('./src/components/Modals/AssignmentModal.vue')['default']
|
AssignmentModal: typeof import('./src/components/Modals/AssignmentModal.vue')['default']
|
||||||
AssignTo: typeof import('./src/components/AssignTo.vue')['default']
|
AssignTo: typeof import('./src/components/AssignTo.vue')['default']
|
||||||
AssignToBody: typeof import('./src/components/AssignToBody.vue')['default']
|
|
||||||
AttachmentArea: typeof import('./src/components/Activities/AttachmentArea.vue')['default']
|
AttachmentArea: typeof import('./src/components/Activities/AttachmentArea.vue')['default']
|
||||||
AttachmentIcon: typeof import('./src/components/Icons/AttachmentIcon.vue')['default']
|
AttachmentIcon: typeof import('./src/components/Icons/AttachmentIcon.vue')['default']
|
||||||
AttachmentItem: typeof import('./src/components/AttachmentItem.vue')['default']
|
AttachmentItem: typeof import('./src/components/AttachmentItem.vue')['default']
|
||||||
@ -33,7 +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']
|
||||||
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
BrandSettings: typeof import('./src/components/Settings/General/BrandSettings.vue')['default']
|
||||||
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.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']
|
||||||
@ -63,11 +61,8 @@ declare module 'vue' {
|
|||||||
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
|
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
|
||||||
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
|
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
|
||||||
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
||||||
CurrencySettings: typeof import('./src/components/Settings/CurrencySettings.vue')['default']
|
|
||||||
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
|
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
|
||||||
DashboardGrid: typeof import('./src/components/Dashboard/DashboardGrid.vue')['default']
|
|
||||||
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
|
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
|
||||||
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
|
|
||||||
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
|
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
|
||||||
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
|
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
|
||||||
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
|
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
|
||||||
@ -85,6 +80,7 @@ declare module 'vue' {
|
|||||||
DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default']
|
DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default']
|
||||||
DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.vue')['default']
|
DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.vue')['default']
|
||||||
Dropdown: typeof import('./src/components/frappe-ui/Dropdown.vue')['default']
|
Dropdown: typeof import('./src/components/frappe-ui/Dropdown.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']
|
EditEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/EditEmailTemplate.vue')['default']
|
||||||
@ -103,9 +99,11 @@ declare module 'vue' {
|
|||||||
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']
|
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
||||||
|
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||||
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
||||||
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.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']
|
||||||
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
||||||
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
|
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
|
||||||
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
|
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
|
||||||
@ -127,10 +125,11 @@ declare module 'vue' {
|
|||||||
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
|
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
|
||||||
Filter: typeof import('./src/components/Filter.vue')['default']
|
Filter: typeof import('./src/components/Filter.vue')['default']
|
||||||
FilterIcon: typeof import('./src/components/Icons/FilterIcon.vue')['default']
|
FilterIcon: typeof import('./src/components/Icons/FilterIcon.vue')['default']
|
||||||
ForecastingSettings: typeof import('./src/components/Settings/ForecastingSettings.vue')['default']
|
|
||||||
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
|
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
|
||||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||||
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
|
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
|
||||||
|
GeneralSettings: typeof import('./src/components/Settings/General/GeneralSettings.vue')['default']
|
||||||
|
GeneralSettingsPage: typeof import('./src/components/Settings/General/GeneralSettingsPage.vue')['default']
|
||||||
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
|
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
|
||||||
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
|
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
|
||||||
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
|
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
|
||||||
@ -141,7 +140,7 @@ declare module 'vue' {
|
|||||||
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
|
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
|
||||||
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
|
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
|
||||||
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
|
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
|
||||||
HomeActions: typeof import('./src/components/Settings/HomeActions.vue')['default']
|
HomeActions: typeof import('./src/components/Settings/General/HomeActions.vue')['default']
|
||||||
Icon: typeof import('./src/components/Icon.vue')['default']
|
Icon: typeof import('./src/components/Icon.vue')['default']
|
||||||
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
|
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
|
||||||
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
|
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
|
||||||
@ -168,6 +167,11 @@ declare module 'vue' {
|
|||||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||||
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
||||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||||
|
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||||
|
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
|
||||||
|
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||||
|
LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||||
|
LucideX: typeof import('~icons/lucide/x')['default']
|
||||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
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']
|
||||||
@ -182,6 +186,7 @@ declare module 'vue' {
|
|||||||
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
||||||
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']
|
||||||
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.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']
|
||||||
@ -200,8 +205,7 @@ declare module 'vue' {
|
|||||||
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
|
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
|
||||||
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
|
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
|
||||||
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
|
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
|
||||||
PrimaryDropdown: typeof import('./src/components/PrimaryDropdown.vue')['default']
|
ProfileImageEditor: typeof import('./src/components/Settings/ProfileImageEditor.vue')['default']
|
||||||
PrimaryDropdownItem: typeof import('./src/components/PrimaryDropdownItem.vue')['default']
|
|
||||||
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
|
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
|
||||||
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
|
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
|
||||||
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
|
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
|
||||||
@ -228,7 +232,6 @@ declare module 'vue' {
|
|||||||
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
|
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
|
||||||
SortBy: typeof import('./src/components/SortBy.vue')['default']
|
SortBy: typeof import('./src/components/SortBy.vue')['default']
|
||||||
SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default']
|
SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default']
|
||||||
SparkleIcon: typeof import('./src/components/Icons/SparkleIcon.vue')['default']
|
|
||||||
SquareAsterisk: typeof import('./src/components/Icons/SquareAsterisk.vue')['default']
|
SquareAsterisk: typeof import('./src/components/Icons/SquareAsterisk.vue')['default']
|
||||||
StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default']
|
StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default']
|
||||||
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default']
|
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.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.201",
|
"frappe-ui": "^0.1.166",
|
||||||
"gemoji": "^8.1.0",
|
"gemoji": "^8.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mime": "^4.0.1",
|
"mime": "^4.0.1",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<FrappeUIProvider>
|
<FrappeUIProvider>
|
||||||
<Layout v-if="session().isLoggedIn">
|
<Layout v-if="session().isLoggedIn">
|
||||||
<router-view :key="$route.fullPath"/>
|
<router-view />
|
||||||
</Layout>
|
</Layout>
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
</FrappeUIProvider>
|
</FrappeUIProvider>
|
||||||
|
|||||||
@ -50,13 +50,11 @@
|
|||||||
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-2 px-3 sm:gap-4 sm:px-10"
|
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-2 px-3 sm:gap-4 sm:px-10"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="z-0 relative flex justify-center before:absolute before:left-[50%] before:-z-[1] before:top-0 before:border-l before:border-outline-gray-modals"
|
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-outline-gray-modals"
|
||||||
:class="
|
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
|
||||||
i != activities.length - 1 ? 'before:h-full' : 'before:h-4'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-8 w-7 items-center justify-center bg-surface-white"
|
class="z-10 flex h-8 w-7 items-center justify-center bg-surface-white"
|
||||||
>
|
>
|
||||||
<CommentIcon class="text-ink-gray-8" />
|
<CommentIcon class="text-ink-gray-8" />
|
||||||
</div>
|
</div>
|
||||||
@ -74,13 +72,11 @@
|
|||||||
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-3 sm:px-10"
|
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-3 sm:px-10"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="z-0 relative flex justify-center before:absolute before:left-[50%] before:-z-[1] before:top-0 before:border-l before:border-outline-gray-modals"
|
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-outline-gray-modals"
|
||||||
:class="
|
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
|
||||||
i != activities.length - 1 ? 'before:h-full' : 'before:h-4'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-8 w-7 items-center justify-center bg-surface-white text-ink-gray-8"
|
class="z-10 flex h-8 w-7 items-center justify-center bg-surface-white text-ink-gray-8"
|
||||||
>
|
>
|
||||||
<MissedCallIcon
|
<MissedCallIcon
|
||||||
v-if="call.status == 'No Answer'"
|
v-if="call.status == 'No Answer'"
|
||||||
@ -120,11 +116,11 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="['Activity', 'Emails'].includes(title)"
|
v-if="['Activity', 'Emails'].includes(title)"
|
||||||
class="z-0 relative flex justify-center before:absolute before:left-[50%] before:-z-[1] before:top-0 before:border-l before:border-outline-gray-modals"
|
class="relative flex justify-center before:absolute before:left-[50%] before:top-0 before:-z-10 before:border-l before:border-outline-gray-modals"
|
||||||
:class="[i != activities.length - 1 ? 'before:h-full' : 'before:h-4']"
|
:class="[i != activities.length - 1 ? 'before:h-full' : 'before:h-4']"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-7 w-7 items-center justify-center bg-surface-white"
|
class="z-10 flex h-7 w-7 items-center justify-center bg-surface-white"
|
||||||
:class="{
|
:class="{
|
||||||
'mt-2.5': ['communication'].includes(activity.activity_type),
|
'mt-2.5': ['communication'].includes(activity.activity_type),
|
||||||
'bg-surface-white': ['added', 'removed', 'changed'].includes(
|
'bg-surface-white': ['added', 'removed', 'changed'].includes(
|
||||||
@ -238,9 +234,12 @@
|
|||||||
<Button
|
<Button
|
||||||
class="!size-4"
|
class="!size-4"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:icon="SelectIcon"
|
|
||||||
@click="activity.show_others = !activity.show_others"
|
@click="activity.show_others = !activity.show_others"
|
||||||
/>
|
>
|
||||||
|
<template #icon>
|
||||||
|
<SelectIcon />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@ -368,7 +367,7 @@
|
|||||||
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
|
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
|
||||||
<DataFields
|
<DataFields
|
||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
:docname="docname"
|
:docname="doc.data.name"
|
||||||
@beforeSave="(data) => emit('beforeSave', data)"
|
@beforeSave="(data) => emit('beforeSave', data)"
|
||||||
@afterSave="(data) => emit('afterSave', data)"
|
@afterSave="(data) => emit('afterSave', data)"
|
||||||
/>
|
/>
|
||||||
@ -439,9 +438,10 @@
|
|||||||
:doc="doc"
|
:doc="doc"
|
||||||
/>
|
/>
|
||||||
<FilesUploader
|
<FilesUploader
|
||||||
|
v-if="doc.data?.name"
|
||||||
v-model="showFilesUploader"
|
v-model="showFilesUploader"
|
||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
:docname="docname"
|
:docname="doc.data.name"
|
||||||
@after="
|
@after="
|
||||||
() => {
|
() => {
|
||||||
all_activities.reload()
|
all_activities.reload()
|
||||||
@ -490,7 +490,6 @@ import { timeAgo, formatDate, startCase } from '@/utils'
|
|||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||||
import { useDocument } from '@/data/document'
|
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { Button, Tooltip, createResource } from 'frappe-ui'
|
import { Button, Tooltip, createResource } from 'frappe-ui'
|
||||||
import { useElementVisibility } from '@vueuse/core'
|
import { useElementVisibility } from '@vueuse/core'
|
||||||
@ -514,10 +513,6 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'CRM Lead',
|
default: 'CRM Lead',
|
||||||
},
|
},
|
||||||
docname: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
tabs: {
|
tabs: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@ -528,13 +523,10 @@ const emit = defineEmits(['beforeSave', 'afterSave'])
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
const doc = defineModel()
|
||||||
const reload = defineModel('reload')
|
const reload = defineModel('reload')
|
||||||
const tabIndex = defineModel('tabIndex')
|
const tabIndex = defineModel('tabIndex')
|
||||||
|
|
||||||
const { document: _document } = useDocument(props.doctype, props.docname)
|
|
||||||
|
|
||||||
const doc = computed(() => _document.doc || {})
|
|
||||||
|
|
||||||
const reload_email = ref(false)
|
const reload_email = ref(false)
|
||||||
const modalRef = ref(null)
|
const modalRef = ref(null)
|
||||||
const showFilesUploader = ref(false)
|
const showFilesUploader = ref(false)
|
||||||
@ -550,25 +542,24 @@ const changeTabTo = (tabName) => {
|
|||||||
|
|
||||||
const all_activities = createResource({
|
const all_activities = createResource({
|
||||||
url: 'crm.api.activities.get_activities',
|
url: 'crm.api.activities.get_activities',
|
||||||
params: { name: props.docname },
|
params: { name: doc.value.data.name },
|
||||||
cache: ['activity', props.docname],
|
cache: ['activity', doc.value.data.name],
|
||||||
auto: true,
|
auto: true,
|
||||||
transform: ([versions, calls, notes, tasks, attachments]) => {
|
transform: ([versions, calls, notes, tasks, attachments]) => {
|
||||||
return { versions, calls, notes, tasks, attachments }
|
return { versions, calls, notes, tasks, attachments }
|
||||||
},
|
},
|
||||||
onSuccess: () => nextTick(() => scroll()),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const showWhatsappTemplates = ref(false)
|
const showWhatsappTemplates = ref(false)
|
||||||
|
|
||||||
const whatsappMessages = createResource({
|
const whatsappMessages = createResource({
|
||||||
url: 'crm.api.whatsapp.get_whatsapp_messages',
|
url: 'crm.api.whatsapp.get_whatsapp_messages',
|
||||||
cache: ['whatsapp_messages', props.docname],
|
cache: ['whatsapp_messages', doc.value.data.name],
|
||||||
params: {
|
params: {
|
||||||
reference_doctype: props.doctype,
|
reference_doctype: props.doctype,
|
||||||
reference_name: props.docname,
|
reference_name: doc.value.data.name,
|
||||||
},
|
},
|
||||||
auto: whatsappEnabled.value,
|
auto: true,
|
||||||
transform: (data) => sortByCreation(data),
|
transform: (data) => sortByCreation(data),
|
||||||
onSuccess: () => nextTick(() => scroll()),
|
onSuccess: () => nextTick(() => scroll()),
|
||||||
})
|
})
|
||||||
@ -581,7 +572,7 @@ onMounted(() => {
|
|||||||
$socket.on('whatsapp_message', (data) => {
|
$socket.on('whatsapp_message', (data) => {
|
||||||
if (
|
if (
|
||||||
data.reference_doctype === props.doctype &&
|
data.reference_doctype === props.doctype &&
|
||||||
data.reference_name === props.docname
|
data.reference_name === doc.value.data.name
|
||||||
) {
|
) {
|
||||||
whatsappMessages.reload()
|
whatsappMessages.reload()
|
||||||
}
|
}
|
||||||
@ -603,8 +594,8 @@ function sendTemplate(template) {
|
|||||||
url: 'crm.api.whatsapp.send_whatsapp_template',
|
url: 'crm.api.whatsapp.send_whatsapp_template',
|
||||||
params: {
|
params: {
|
||||||
reference_doctype: props.doctype,
|
reference_doctype: props.doctype,
|
||||||
reference_name: props.docname,
|
reference_name: doc.value.data.name,
|
||||||
to: doc.value.mobile_no,
|
to: doc.value.data.mobile_no,
|
||||||
template,
|
template,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
@ -776,7 +767,6 @@ const whatsappBox = ref(null)
|
|||||||
watch([reload, reload_email], ([reload_value, reload_email_value]) => {
|
watch([reload, reload_email], ([reload_value, reload_email_value]) => {
|
||||||
if (reload_value || reload_email_value) {
|
if (reload_value || reload_email_value) {
|
||||||
all_activities.reload()
|
all_activities.reload()
|
||||||
_document.reload()
|
|
||||||
reload.value = false
|
reload.value = false
|
||||||
reload_email.value = false
|
reload_email.value = false
|
||||||
}
|
}
|
||||||
@ -802,12 +792,12 @@ function scroll(hash) {
|
|||||||
const callActions = computed(() => {
|
const callActions = computed(() => {
|
||||||
let actions = [
|
let actions = [
|
||||||
{
|
{
|
||||||
label: __('Log a Call'),
|
label: __('Create Call Log'),
|
||||||
onClick: () => modalRef.value.createCallLog(),
|
onClick: () => modalRef.value.createCallLog(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __('Make a Call'),
|
label: __('Make a Call'),
|
||||||
onClick: () => makeCall(doc.value.mobile_no),
|
onClick: () => makeCall(doc.data.mobile_no),
|
||||||
condition: () => callEnabled.value,
|
condition: () => callEnabled.value,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -9,17 +9,23 @@
|
|||||||
<Button
|
<Button
|
||||||
v-if="title == 'Emails'"
|
v-if="title == 'Emails'"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
:label="__('New Email')"
|
|
||||||
iconLeft="plus"
|
|
||||||
@click="emailBox.show = true"
|
@click="emailBox.show = true"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
<span>{{ __('New Email') }}</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-else-if="title == 'Comments'"
|
v-else-if="title == 'Comments'"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
:label="__('New Comment')"
|
|
||||||
iconLeft="plus"
|
|
||||||
@click="emailBox.showComment = true"
|
@click="emailBox.showComment = true"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
<span>{{ __('New Comment') }}</span>
|
||||||
|
</Button>
|
||||||
<MultiActionButton
|
<MultiActionButton
|
||||||
v-else-if="title == 'Calls'"
|
v-else-if="title == 'Calls'"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@ -28,45 +34,59 @@
|
|||||||
<Button
|
<Button
|
||||||
v-else-if="title == 'Notes'"
|
v-else-if="title == 'Notes'"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
:label="__('New Note')"
|
|
||||||
iconLeft="plus"
|
|
||||||
@click="modalRef.showNote()"
|
@click="modalRef.showNote()"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
<span>{{ __('New Note') }}</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-else-if="title == 'Tasks'"
|
v-else-if="title == 'Tasks'"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
:label="__('New Task')"
|
|
||||||
iconLeft="plus"
|
|
||||||
@click="modalRef.showTask()"
|
@click="modalRef.showTask()"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
<span>{{ __('New Task') }}</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-else-if="title == 'Attachments'"
|
v-else-if="title == 'Attachments'"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
:label="__('Upload Attachment')"
|
|
||||||
iconLeft="plus"
|
|
||||||
@click="showFilesUploader = true"
|
@click="showFilesUploader = true"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
<span>{{ __('Upload Attachment') }}</span>
|
||||||
|
</Button>
|
||||||
<div class="flex gap-2 shrink-0" v-else-if="title == 'WhatsApp'">
|
<div class="flex gap-2 shrink-0" v-else-if="title == 'WhatsApp'">
|
||||||
<Button
|
<Button
|
||||||
:label="__('Send Template')"
|
:label="__('Send Template')"
|
||||||
@click="showWhatsappTemplates = true"
|
@click="showWhatsappTemplates = true"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button variant="solid" @click="whatsappBox.show()">
|
||||||
variant="solid"
|
<template #prefix>
|
||||||
:label="__('New Message')"
|
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||||
iconLeft="plus"
|
</template>
|
||||||
@click="whatsappBox.show()"
|
<span>{{ __('New Message') }}</span>
|
||||||
/>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Dropdown v-else :options="defaultActions" @click.stop>
|
<Dropdown v-else :options="defaultActions" @click.stop>
|
||||||
<template v-slot="{ open }">
|
<template v-slot="{ open }">
|
||||||
<Button
|
<Button variant="solid" class="flex items-center gap-1">
|
||||||
variant="solid"
|
<template #prefix>
|
||||||
class="flex items-center gap-1"
|
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||||
:label="__('New')"
|
</template>
|
||||||
iconLeft="plus"
|
<span>{{ __('New') }}</span>
|
||||||
:iconRight="open ? 'chevron-up' : 'chevron-down'"
|
<template #suffix>
|
||||||
/>
|
<FeatherIcon
|
||||||
|
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
@ -114,13 +134,13 @@ const defaultActions = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||||
label: __('Log a Call'),
|
label: __('Create Call Log'),
|
||||||
onClick: () => props.modalRef.createCallLog(),
|
onClick: () => props.modalRef.createCallLog(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||||
label: __('Make a Call'),
|
label: __('Make a Call'),
|
||||||
onClick: () => makeCall(props.doc.mobile_no),
|
onClick: () => makeCall(props.doc.data.mobile_no),
|
||||||
condition: () => callEnabled.value,
|
condition: () => callEnabled.value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -157,14 +177,14 @@ function getTabIndex(name) {
|
|||||||
const callActions = computed(() => {
|
const callActions = computed(() => {
|
||||||
let actions = [
|
let actions = [
|
||||||
{
|
{
|
||||||
label: __('Log a Call'),
|
label: __('Create Call Log'),
|
||||||
icon: 'plus',
|
icon: 'plus',
|
||||||
onClick: () => props.modalRef.createCallLog(),
|
onClick: () => props.modalRef.createCallLog(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __('Make a Call'),
|
label: __('Make a Call'),
|
||||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||||
onClick: () => makeCall(props.doc.mobile_no),
|
onClick: () => makeCall(props.doc.data.mobile_no),
|
||||||
condition: () => callEnabled.value,
|
condition: () => callEnabled.value,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
v-model:reloadTasks="activities"
|
v-model:reloadTasks="activities"
|
||||||
:task="task"
|
:task="task"
|
||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
:doc="doc?.name"
|
:doc="doc.data?.name"
|
||||||
@after="redirect('tasks')"
|
@after="redirect('tasks')"
|
||||||
/>
|
/>
|
||||||
<NoteModal
|
<NoteModal
|
||||||
@ -12,7 +12,7 @@
|
|||||||
v-model:reloadNotes="activities"
|
v-model:reloadNotes="activities"
|
||||||
:note="note"
|
:note="note"
|
||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
:doc="doc?.name"
|
:doc="doc.data?.name"
|
||||||
@after="redirect('notes')"
|
@after="redirect('notes')"
|
||||||
/>
|
/>
|
||||||
<CallLogModal
|
<CallLogModal
|
||||||
@ -92,8 +92,8 @@ const referenceDoc = ref({})
|
|||||||
|
|
||||||
function createCallLog() {
|
function createCallLog() {
|
||||||
let doctype = props.doctype
|
let doctype = props.doctype
|
||||||
let docname = props.doc?.name
|
let docname = props.doc.data?.name
|
||||||
referenceDoc.value = { ...props.doc }
|
referenceDoc.value = { ...props.doc.data }
|
||||||
callLog.value = {
|
callLog.value = {
|
||||||
reference_doctype: doctype,
|
reference_doctype: doctype,
|
||||||
reference_docname: docname,
|
reference_docname: docname,
|
||||||
|
|||||||
@ -38,31 +38,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Tooltip
|
||||||
:tooltip="
|
:text="
|
||||||
attachment.is_private ? __('Make public') : __('Make private')
|
attachment.is_private ? __('Make public') : __('Make private')
|
||||||
"
|
"
|
||||||
class="!size-5"
|
|
||||||
@click.stop="
|
|
||||||
togglePrivate(attachment.name, attachment.is_private)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<template #icon>
|
<div>
|
||||||
<FeatherIcon
|
<Button
|
||||||
:name="attachment.is_private ? 'lock' : 'unlock'"
|
class="!size-5"
|
||||||
class="size-3 text-ink-gray-7"
|
@click.stop="
|
||||||
/>
|
togglePrivate(attachment.name, attachment.is_private)
|
||||||
</template>
|
"
|
||||||
</Button>
|
>
|
||||||
<Button
|
<FeatherIcon
|
||||||
:tooltip="__('Delete attachment')"
|
:name="attachment.is_private ? 'lock' : 'unlock'"
|
||||||
class="!size-5"
|
class="size-3 text-ink-gray-7"
|
||||||
@click.stop="() => deleteAttachment(attachment.name)"
|
/>
|
||||||
>
|
</Button>
|
||||||
<template #icon>
|
</div>
|
||||||
<FeatherIcon name="trash-2" class="size-3 text-ink-gray-7" />
|
</Tooltip>
|
||||||
</template>
|
<Tooltip :text="__('Delete attachment')">
|
||||||
</Button>
|
<div>
|
||||||
|
<Button
|
||||||
|
class="!size-5"
|
||||||
|
@click.stop="() => deleteAttachment(attachment.name)"
|
||||||
|
>
|
||||||
|
<FeatherIcon name="trash-2" class="size-3 text-ink-gray-7" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full text-sm text-ink-gray-5">
|
<div class="w-full text-sm text-ink-gray-5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button variant="ghost" @click="playPause">
|
||||||
variant="ghost"
|
<template #icon>
|
||||||
class="text-ink-gray-5"
|
<PlayIcon v-if="isPaused" class="size-4 text-ink-gray-5" />
|
||||||
:icon="isPaused ? PlayIcon : PauseIcon"
|
<PauseIcon v-else class="size-4 text-ink-gray-5" />
|
||||||
@click="playPause"
|
</template>
|
||||||
/>
|
</Button>
|
||||||
<div class="flex gap-2 items-center justify-between flex-1">
|
<div class="flex gap-2 items-center justify-between flex-1">
|
||||||
<input
|
<input
|
||||||
class="w-full slider !h-[0.5] bg-surface-gray-3 [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb:hover]:outline [&::-webkit-slider-thumb:hover]:outline-[0.5px]"
|
class="w-full slider !h-[0.5] bg-surface-gray-3 [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb:hover]:outline [&::-webkit-slider-thumb:hover]:outline-[0.5px]"
|
||||||
@ -61,11 +61,11 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Dropdown :options="options">
|
<Dropdown :options="options">
|
||||||
<Button
|
<Button variant="ghost" @click="showPlaybackSpeed = false">
|
||||||
icon="more-horizontal"
|
<template #icon>
|
||||||
variant="ghost"
|
<FeatherIcon class="size-4" name="more-horizontal" />
|
||||||
@click="showPlaybackSpeed = false"
|
</template>
|
||||||
/>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div @click="showCallLogDetailModal = true" class="cursor-pointer">
|
||||||
<div class="mb-1 flex items-center justify-stretch gap-2 py-1 text-base">
|
<div class="mb-1 flex items-center justify-stretch gap-2 py-1 text-base">
|
||||||
<div class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5">
|
<div class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5">
|
||||||
<Avatar
|
<Avatar
|
||||||
@ -25,8 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@click="showCallLogDetailModal = true"
|
class="flex flex-col gap-2 border border-outline-gray-modals rounded-md bg-surface-cards px-3 py-2.5 text-ink-gray-9"
|
||||||
class="flex flex-col gap-2 border cursor-pointer border-outline-gray-modals rounded-md bg-surface-cards px-3 py-2.5 text-ink-gray-9"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="inline-flex gap-2 items-center text-base font-medium">
|
<div class="inline-flex gap-2 items-center text-base font-medium">
|
||||||
|
|||||||
@ -14,10 +14,12 @@
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
v-if="isManager() && !isMobileView"
|
v-if="isManager() && !isMobileView"
|
||||||
:tooltip="__('Edit fields layout')"
|
|
||||||
:icon="EditIcon"
|
|
||||||
@click="showDataFieldsModal = true"
|
@click="showDataFieldsModal = true"
|
||||||
/>
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EditIcon />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
label="Save"
|
label="Save"
|
||||||
:disabled="!document.isDirty"
|
:disabled="!document.isDirty"
|
||||||
|
|||||||
@ -2,9 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
class="cursor-pointer flex flex-col rounded-md shadow bg-surface-cards px-3 py-1.5 text-base transition-all duration-300 ease-in-out"
|
class="cursor-pointer flex flex-col rounded-md shadow bg-surface-cards px-3 py-1.5 text-base transition-all duration-300 ease-in-out"
|
||||||
>
|
>
|
||||||
<div
|
<div class="-mb-0.5 flex items-center justify-between gap-2 truncate text-ink-gray-9">
|
||||||
class="-mb-0.5 flex items-center justify-between gap-2 truncate text-ink-gray-9"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 truncate">
|
<div class="flex items-center gap-2 truncate">
|
||||||
<span>{{ activity.data.sender_full_name }}</span>
|
<span>{{ activity.data.sender_full_name }}</span>
|
||||||
<span class="sm:flex hidden text-sm text-ink-gray-5">
|
<span class="sm:flex hidden text-sm text-ink-gray-5">
|
||||||
@ -30,20 +28,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="flex gap-0.5">
|
<div class="flex gap-0.5">
|
||||||
<Button
|
<Tooltip :text="__('Reply')">
|
||||||
:tooltip="__('Reply')"
|
<div>
|
||||||
variant="ghost"
|
<Button
|
||||||
class="text-ink-gray-7"
|
variant="ghost"
|
||||||
:icon="ReplyIcon"
|
class="text-ink-gray-7"
|
||||||
@click="reply(activity.data)"
|
@click="reply(activity.data)"
|
||||||
/>
|
>
|
||||||
<Button
|
<template #icon>
|
||||||
:tooltip="__('Reply All')"
|
<ReplyIcon />
|
||||||
variant="ghost"
|
</template>
|
||||||
:icon="ReplyAllIcon"
|
</Button>
|
||||||
class="text-ink-gray-7"
|
</div>
|
||||||
@click="reply(activity.data, true)"
|
</Tooltip>
|
||||||
/>
|
<Tooltip :text="__('Reply All')">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="text-ink-gray-7"
|
||||||
|
@click="reply(activity.data, true)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<ReplyAllIcon />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,13 +41,13 @@
|
|||||||
:options="taskStatusOptions(modalRef.updateTaskStatus, task)"
|
:options="taskStatusOptions(modalRef.updateTaskStatus, task)"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip :text="__('Change Status')">
|
||||||
:tooltip="__('Change status')"
|
<div>
|
||||||
variant="ghosted"
|
<Button variant="ghosted" class="hover:bg-surface-gray-4">
|
||||||
class="hover:bg-surface-gray-4"
|
<TaskStatusIcon :status="task.status" />
|
||||||
>
|
</Button>
|
||||||
<TaskStatusIcon :status="task.status" />
|
</div>
|
||||||
</Button>
|
</Tooltip>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
:options="[
|
:options="[
|
||||||
|
|||||||
@ -107,9 +107,9 @@ function sendTextMessage(event) {
|
|||||||
async function sendWhatsAppMessage() {
|
async function sendWhatsAppMessage() {
|
||||||
let args = {
|
let args = {
|
||||||
reference_doctype: props.doctype,
|
reference_doctype: props.doctype,
|
||||||
reference_name: doc.value.name,
|
reference_name: doc.value.data.name,
|
||||||
message: content.value,
|
message: content.value,
|
||||||
to: doc.value.mobile_no,
|
to: doc.value.data.mobile_no,
|
||||||
attach: whatsapp.value.attach || '',
|
attach: whatsapp.value.attach || '',
|
||||||
reply_to: reply.value?.name || '',
|
reply_to: reply.value?.name || '',
|
||||||
content_type: whatsapp.value.content_type,
|
content_type: whatsapp.value.content_type,
|
||||||
|
|||||||
@ -1,99 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<Popover placement="bottom-end">
|
<component
|
||||||
<template #target="{ togglePopover }">
|
v-if="assignees?.length"
|
||||||
<div class="flex items-center" @click="togglePopover">
|
:is="assignees?.length == 1 ? 'Button' : 'div'"
|
||||||
<component
|
>
|
||||||
v-if="assignees?.length"
|
<MultipleAvatar :avatars="assignees" @click="showAssignmentModal = true" />
|
||||||
:is="assignees?.length == 1 ? 'Button' : 'div'"
|
</component>
|
||||||
>
|
<Button v-else @click="showAssignmentModal = true">
|
||||||
<MultipleAvatar :avatars="assignees" />
|
{{ __('Assign to') }}
|
||||||
</component>
|
</Button>
|
||||||
<Button v-else :label="__('Assign to')" />
|
<AssignmentModal
|
||||||
</div>
|
v-if="showAssignmentModal"
|
||||||
</template>
|
v-model="showAssignmentModal"
|
||||||
<template #body="{ isOpen }">
|
v-model:assignees="assignees"
|
||||||
<AssignToBody
|
:doctype="doctype"
|
||||||
v-show="isOpen"
|
:doc="data"
|
||||||
v-model="assignees"
|
/>
|
||||||
:docname="docname"
|
|
||||||
:doctype="doctype"
|
|
||||||
:open="isOpen"
|
|
||||||
:onUpdate="ownerField && saveAssignees"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Popover>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||||
import AssignToBody from '@/components/AssignToBody.vue'
|
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||||
import { useDocument } from '@/data/document'
|
import { ref } from 'vue'
|
||||||
import { toast, Popover } from 'frappe-ui'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
data: Object,
|
||||||
doctype: String,
|
doctype: String,
|
||||||
docname: String,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { document } = useDocument(props.doctype, props.docname)
|
const showAssignmentModal = ref(false)
|
||||||
|
|
||||||
const assignees = defineModel()
|
const assignees = defineModel()
|
||||||
|
|
||||||
const ownerField = computed(() => {
|
|
||||||
if (props.doctype === 'CRM Lead') {
|
|
||||||
return 'lead_owner'
|
|
||||||
} else if (props.doctype === 'CRM Deal') {
|
|
||||||
return 'deal_owner'
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function saveAssignees(
|
|
||||||
addedAssignees,
|
|
||||||
removedAssignees,
|
|
||||||
addAssignees,
|
|
||||||
removeAssignees,
|
|
||||||
) {
|
|
||||||
removedAssignees.length && (await removeAssignees.submit(removedAssignees))
|
|
||||||
addedAssignees.length && (await addAssignees.submit(addedAssignees))
|
|
||||||
|
|
||||||
const nextAssignee = assignees.value.find(
|
|
||||||
(a) => a.name !== document.doc[ownerField.value],
|
|
||||||
)
|
|
||||||
|
|
||||||
let owner = ownerField.value.replace('_', ' ')
|
|
||||||
|
|
||||||
if (
|
|
||||||
document.doc[ownerField.value] &&
|
|
||||||
removedAssignees.includes(document.doc[ownerField.value])
|
|
||||||
) {
|
|
||||||
document.doc[ownerField.value] = nextAssignee ? nextAssignee.name : ''
|
|
||||||
document.save.submit()
|
|
||||||
|
|
||||||
if (nextAssignee) {
|
|
||||||
toast.info(
|
|
||||||
__(
|
|
||||||
'Since you removed {0} from the assignee, the {0} has been changed to the next available assignee {1}.',
|
|
||||||
[owner, nextAssignee.label || nextAssignee.name],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
toast.info(
|
|
||||||
__(
|
|
||||||
'Since you removed {0} from the assignee, the {0} has also been removed.',
|
|
||||||
[owner],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (!document.doc[ownerField.value] && nextAssignee) {
|
|
||||||
document.doc[ownerField.value] = nextAssignee ? nextAssignee.name : ''
|
|
||||||
toast.info(
|
|
||||||
__('Since you added a new assignee, the {0} has been set to {1}.', [
|
|
||||||
owner,
|
|
||||||
nextAssignee.label || nextAssignee.name,
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,211 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 my-2 w-[470px] rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black p-3 ring-opacity-5 focus:outline-none"
|
|
||||||
>
|
|
||||||
<div class="text-base text-ink-gray-5">{{ __('Assign to') }}</div>
|
|
||||||
<Link
|
|
||||||
class="form-control"
|
|
||||||
value=""
|
|
||||||
doctype="User"
|
|
||||||
@change="(option) => addValue(option) && ($refs.input.value = '')"
|
|
||||||
:placeholder="__('John Doe')"
|
|
||||||
:filters="{
|
|
||||||
name: ['in', users.data.crmUsers?.map((user) => user.name)],
|
|
||||||
}"
|
|
||||||
:hideMe="true"
|
|
||||||
>
|
|
||||||
<template #target="{ togglePopover }">
|
|
||||||
<div
|
|
||||||
class="w-full min-h-12 flex flex-wrap items-center gap-1.5 p-1.5 pb-5 rounded-lg bg-surface-gray-2 cursor-text"
|
|
||||||
@click.stop="togglePopover"
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
:text="assignee.name"
|
|
||||||
v-for="assignee in assignees"
|
|
||||||
:key="assignee.name"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center text-sm p-0.5 text-ink-gray-6 border border-outline-gray-1 bg-surface-modal rounded-full cursor-pointer"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<UserAvatar :user="assignee.name" size="sm" />
|
|
||||||
<div class="ml-1">{{ getUser(assignee.name).full_name }}</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="rounded-full !size-4 m-1"
|
|
||||||
@click.stop="removeValue(assignee.name)"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<FeatherIcon name="x" class="h-3 w-3 text-ink-gray-6" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #item-prefix="{ option }">
|
|
||||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
|
||||||
</template>
|
|
||||||
<template #item-label="{ option }">
|
|
||||||
<Tooltip :text="option.value">
|
|
||||||
<div class="cursor-pointer text-ink-gray-9">
|
|
||||||
{{ getUser(option.value).full_name }}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</template>
|
|
||||||
</Link>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div
|
|
||||||
class="text-base text-ink-gray-5 cursor-pointer select-none"
|
|
||||||
@click="assignToMe = !assignToMe"
|
|
||||||
>
|
|
||||||
{{ __('Assign to me') }}
|
|
||||||
</div>
|
|
||||||
<Switch v-model="assignToMe" @click.stop />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
import { usersStore } from '@/stores/users'
|
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
import { Tooltip, Switch, createResource } from 'frappe-ui'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
doctype: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
docname: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
open: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
onUpdate: {
|
|
||||||
type: Function,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['reload'])
|
|
||||||
|
|
||||||
const assignees = defineModel()
|
|
||||||
const oldAssignees = ref([])
|
|
||||||
const assignToMe = ref(false)
|
|
||||||
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
const { users, getUser } = usersStore()
|
|
||||||
|
|
||||||
const removeValue = (value) => {
|
|
||||||
if (value === getUser('').name) {
|
|
||||||
assignToMe.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
assignees.value = assignees.value.filter(
|
|
||||||
(assignee) => assignee.name !== value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addValue = (value) => {
|
|
||||||
if (value === getUser('').name) {
|
|
||||||
assignToMe.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
error.value = ''
|
|
||||||
let obj = {
|
|
||||||
name: value,
|
|
||||||
image: getUser(value).user_image,
|
|
||||||
label: getUser(value).full_name,
|
|
||||||
}
|
|
||||||
if (!assignees.value.find((assignee) => assignee.name === value)) {
|
|
||||||
assignees.value.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(assignToMe, (val) => {
|
|
||||||
let user = getUser('')
|
|
||||||
if (val) {
|
|
||||||
addValue(user.name)
|
|
||||||
} else {
|
|
||||||
removeValue(user.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.open,
|
|
||||||
(val) => {
|
|
||||||
if (val) {
|
|
||||||
oldAssignees.value = [...(assignees.value || [])]
|
|
||||||
|
|
||||||
assignToMe.value = assignees.value.some(
|
|
||||||
(assignee) => assignee.name === getUser('').name,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
updateAssignees()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
async function updateAssignees() {
|
|
||||||
if (JSON.stringify(oldAssignees.value) === JSON.stringify(assignees.value))
|
|
||||||
return
|
|
||||||
|
|
||||||
const removedAssignees = oldAssignees.value
|
|
||||||
.filter(
|
|
||||||
(assignee) => !assignees.value.find((a) => a.name === assignee.name),
|
|
||||||
)
|
|
||||||
.map((assignee) => assignee.name)
|
|
||||||
|
|
||||||
const addedAssignees = assignees.value
|
|
||||||
.filter(
|
|
||||||
(assignee) => !oldAssignees.value.find((a) => a.name === assignee.name),
|
|
||||||
)
|
|
||||||
.map((assignee) => assignee.name)
|
|
||||||
|
|
||||||
if (props.onUpdate) {
|
|
||||||
props.onUpdate(
|
|
||||||
addedAssignees,
|
|
||||||
removedAssignees,
|
|
||||||
addAssignees,
|
|
||||||
removeAssignees,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
if (removedAssignees.length) {
|
|
||||||
await removeAssignees.submit(removedAssignees)
|
|
||||||
}
|
|
||||||
if (addedAssignees.length) {
|
|
||||||
addAssignees.submit(addedAssignees)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addAssignees = createResource({
|
|
||||||
url: 'frappe.desk.form.assign_to.add',
|
|
||||||
makeParams: (addedAssignees) => ({
|
|
||||||
doctype: props.doctype,
|
|
||||||
name: props.docname,
|
|
||||||
assign_to: addedAssignees,
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
capture('assign_to', { doctype: props.doctype })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeAssignees = createResource({
|
|
||||||
url: 'crm.api.doc.remove_assignments',
|
|
||||||
makeParams: (removedAssignees) => ({
|
|
||||||
doctype: props.doctype,
|
|
||||||
name: props.docname,
|
|
||||||
assignees: removedAssignees,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@ -5,9 +5,11 @@
|
|||||||
:label="label"
|
:label="label"
|
||||||
theme="gray"
|
theme="gray"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:iconLeft="getIcon()"
|
|
||||||
@click="toggleDialog()"
|
@click="toggleDialog()"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<component :is="getIcon()" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-ink-gray-5 text-base">
|
<div class="text-ink-gray-5">
|
||||||
{{
|
{{
|
||||||
__('Are you sure you want to delete {0} items?', [
|
__('Are you sure you want to delete {0} items?', [
|
||||||
props.items?.length,
|
props.items?.length,
|
||||||
@ -53,7 +53,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-ink-gray-5 text-base">
|
<div class="text-ink-gray-5">
|
||||||
{{
|
{{
|
||||||
confirmDeleteInfo.delete
|
confirmDeleteInfo.delete
|
||||||
? __(
|
? __(
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Popover placement="bottom-end">
|
<NestedPopover>
|
||||||
<template #target="{ togglePopover }">
|
<template #target>
|
||||||
<Button :label="__('Columns')" @click="togglePopover">
|
<Button :label="__('Columns')">
|
||||||
<template v-if="hideLabel">
|
<template v-if="hideLabel">
|
||||||
<ColumnsIcon class="h-4" />
|
<ColumnsIcon class="h-4" />
|
||||||
</template>
|
</template>
|
||||||
@ -65,28 +65,37 @@
|
|||||||
<Button
|
<Button
|
||||||
class="w-full !justify-start !text-ink-gray-5"
|
class="w-full !justify-start !text-ink-gray-5"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
@click="togglePopover()"
|
||||||
:label="__('Add Column')"
|
:label="__('Add Column')"
|
||||||
iconLeft="plus"
|
>
|
||||||
@click="togglePopover"
|
<template #prefix>
|
||||||
/>
|
<FeatherIcon name="plus" class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Autocomplete>
|
</Autocomplete>
|
||||||
<Button
|
<Button
|
||||||
v-if="columnsUpdated"
|
v-if="columnsUpdated"
|
||||||
class="w-full !justify-start !text-ink-gray-5"
|
class="w-full !justify-start !text-ink-gray-5"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:label="__('Reset Changes')"
|
|
||||||
:iconLeft="ReloadIcon"
|
|
||||||
@click="reset(close)"
|
@click="reset(close)"
|
||||||
/>
|
:label="__('Reset Changes')"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<ReloadIcon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="!is_default"
|
v-if="!is_default"
|
||||||
class="w-full !justify-start !text-ink-gray-5"
|
class="w-full !justify-start !text-ink-gray-5"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:label="__('Reset to Default')"
|
|
||||||
:iconLeft="ReloadIcon"
|
|
||||||
@click="resetToDefault(close)"
|
@click="resetToDefault(close)"
|
||||||
/>
|
:label="__('Reset to Default')"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<ReloadIcon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@ -135,7 +144,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Popover>
|
</NestedPopover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -143,9 +152,9 @@ import ColumnsIcon from '@/components/Icons/ColumnsIcon.vue'
|
|||||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
import DragIcon from '@/components/Icons/DragIcon.vue'
|
import DragIcon from '@/components/Icons/DragIcon.vue'
|
||||||
import ReloadIcon from '@/components/Icons/ReloadIcon.vue'
|
import ReloadIcon from '@/components/Icons/ReloadIcon.vue'
|
||||||
|
import NestedPopover from '@/components/NestedPopover.vue'
|
||||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||||
import { isTouchScreenDevice } from '@/utils'
|
import { isTouchScreenDevice } from '@/utils'
|
||||||
import { Popover } from 'frappe-ui'
|
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { watchOnce } from '@vueuse/core'
|
import { watchOnce } from '@vueuse/core'
|
||||||
@ -210,7 +219,6 @@ const fields = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function addColumn(c) {
|
function addColumn(c) {
|
||||||
if (!c) return
|
|
||||||
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type)
|
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type)
|
||||||
? 'right'
|
? 'right'
|
||||||
: 'left'
|
: 'left'
|
||||||
|
|||||||
@ -45,12 +45,11 @@
|
|||||||
v-slot="{ togglePopover }"
|
v-slot="{ togglePopover }"
|
||||||
@update:modelValue="() => appendEmoji()"
|
@update:modelValue="() => appendEmoji()"
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="ghost" @click="togglePopover()">
|
||||||
:tooltip="__('Insert Emoji')"
|
<template #icon>
|
||||||
:icon="SmileIcon"
|
<SmileIcon class="h-4" />
|
||||||
variant="ghost"
|
</template>
|
||||||
@click="togglePopover()"
|
</Button>
|
||||||
/>
|
|
||||||
</IconPicker>
|
</IconPicker>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
:upload-args="{
|
:upload-args="{
|
||||||
@ -62,11 +61,14 @@
|
|||||||
>
|
>
|
||||||
<template #default="{ openFileSelector }">
|
<template #default="{ openFileSelector }">
|
||||||
<Button
|
<Button
|
||||||
:tooltip="__('Attach a file')"
|
theme="gray"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:icon="AttachmentIcon"
|
|
||||||
@click="openFileSelector()"
|
@click="openFileSelector()"
|
||||||
/>
|
>
|
||||||
|
<template #icon>
|
||||||
|
<AttachmentIcon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,18 +8,24 @@
|
|||||||
showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
|
showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
|
||||||
]"
|
]"
|
||||||
:label="__('Reply')"
|
:label="__('Reply')"
|
||||||
:iconLeft="Email2Icon"
|
|
||||||
@click="toggleEmailBox()"
|
@click="toggleEmailBox()"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Email2Icon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:label="__('Comment')"
|
:label="__('Comment')"
|
||||||
:class="[
|
:class="[
|
||||||
showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
|
showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
|
||||||
]"
|
]"
|
||||||
:iconLeft="CommentIcon"
|
|
||||||
@click="toggleCommentBox()"
|
@click="toggleCommentBox()"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<CommentIcon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -39,7 +45,7 @@
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
showEmailBox = false
|
showEmailBox = false
|
||||||
newEmailEditor.subject = subject
|
newEmailEditor.subject = subject
|
||||||
newEmailEditor.toEmails = doc.email ? [doc.email] : []
|
newEmailEditor.toEmails = doc.data.email ? [doc.data.email] : []
|
||||||
newEmailEditor.ccEmails = []
|
newEmailEditor.ccEmails = []
|
||||||
newEmailEditor.bccEmails = []
|
newEmailEditor.bccEmails = []
|
||||||
newEmailEditor.cc = false
|
newEmailEditor.cc = false
|
||||||
@ -48,7 +54,7 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
:editable="showEmailBox"
|
:editable="showEmailBox"
|
||||||
v-model="doc"
|
v-model="doc.data"
|
||||||
v-model:attachments="attachments"
|
v-model:attachments="attachments"
|
||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
:subject="subject"
|
:subject="subject"
|
||||||
@ -73,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
:editable="showCommentBox"
|
:editable="showCommentBox"
|
||||||
v-model="doc"
|
v-model="doc.data"
|
||||||
v-model:attachments="attachments"
|
v-model:attachments="attachments"
|
||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
:placeholder="__('@John, can you please check this?')"
|
:placeholder="__('@John, can you please check this?')"
|
||||||
@ -119,12 +125,12 @@ const attachments = ref([])
|
|||||||
|
|
||||||
const subject = computed(() => {
|
const subject = computed(() => {
|
||||||
let prefix = ''
|
let prefix = ''
|
||||||
if (doc.value?.lead_name) {
|
if (doc.value.data?.lead_name) {
|
||||||
prefix = doc.value.lead_name
|
prefix = doc.value.data.lead_name
|
||||||
} else if (doc.value?.organization) {
|
} else if (doc.value.data?.organization) {
|
||||||
prefix = doc.value.organization
|
prefix = doc.value.data.organization
|
||||||
}
|
}
|
||||||
return `${prefix} (#${doc.value.name})`
|
return `${prefix} (#${doc.value.data.name})`
|
||||||
})
|
})
|
||||||
|
|
||||||
const signature = createResource({
|
const signature = createResource({
|
||||||
@ -193,7 +199,7 @@ async function sendMail() {
|
|||||||
subject: subject,
|
subject: subject,
|
||||||
content: newEmail.value,
|
content: newEmail.value,
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
name: doc.value.name,
|
name: doc.value.data.name,
|
||||||
send_email: 1,
|
send_email: 1,
|
||||||
sender: getUser().email,
|
sender: getUser().email,
|
||||||
sender_full_name: getUser()?.full_name || undefined,
|
sender_full_name: getUser()?.full_name || undefined,
|
||||||
@ -203,7 +209,7 @@ async function sendMail() {
|
|||||||
async function sendComment() {
|
async function sendComment() {
|
||||||
let comment = await call('frappe.desk.form.utils.add_comment', {
|
let comment = await call('frappe.desk.form.utils.add_comment', {
|
||||||
reference_doctype: props.doctype,
|
reference_doctype: props.doctype,
|
||||||
reference_name: doc.value.name,
|
reference_name: doc.value.data.name,
|
||||||
content: newComment.value,
|
content: newComment.value,
|
||||||
comment_email: getUser().email,
|
comment_email: getUser().email,
|
||||||
comment_by: getUser()?.full_name || undefined,
|
comment_by: getUser()?.full_name || undefined,
|
||||||
|
|||||||
@ -1,454 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex gap-2"
|
|
||||||
:class="[
|
|
||||||
{
|
|
||||||
'items-center': !props.isGroup,
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex gap-2 w-full"
|
|
||||||
:class="[
|
|
||||||
{
|
|
||||||
'items-center justify-between': !props.isGroup,
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div :class="'text-end text-base text-gray-600'">
|
|
||||||
<div v-if="props.itemIndex == 0" class="min-w-[66px] text-start">
|
|
||||||
{{ __('Where') }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="min-w-[66px] flex items-start">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
class="w-max"
|
|
||||||
@click="toggleConjunction"
|
|
||||||
icon-right="refresh-cw"
|
|
||||||
:disabled="props.itemIndex > 2"
|
|
||||||
:label="conjunction"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!props.isGroup" class="flex items-center gap-2 w-full">
|
|
||||||
<div id="fieldname" class="w-full">
|
|
||||||
<Autocomplete
|
|
||||||
:options="filterableFields.data"
|
|
||||||
v-model="props.condition[0]"
|
|
||||||
:placeholder="__('Field')"
|
|
||||||
@update:modelValue="updateField"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div id="operator">
|
|
||||||
<FormControl
|
|
||||||
v-if="!props.condition[0]"
|
|
||||||
disabled
|
|
||||||
type="text"
|
|
||||||
:placeholder="__('operator')"
|
|
||||||
class="w-[100px]"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-else
|
|
||||||
:disabled="!props.condition[0]"
|
|
||||||
type="select"
|
|
||||||
v-model="props.condition[1]"
|
|
||||||
@change="updateOperator"
|
|
||||||
:options="getOperators()"
|
|
||||||
class="w-max min-w-[100px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div id="value" class="w-full">
|
|
||||||
<FormControl
|
|
||||||
v-if="!props.condition[0]"
|
|
||||||
disabled
|
|
||||||
type="text"
|
|
||||||
:placeholder="__('condition')"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
v-else
|
|
||||||
:is="getValueControl()"
|
|
||||||
v-model="props.condition[2]"
|
|
||||||
@change="updateValue"
|
|
||||||
:placeholder="__('condition')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CFConditions
|
|
||||||
v-if="props.isGroup && !(props.level == 2 || props.level == 4)"
|
|
||||||
:conditions="props.condition"
|
|
||||||
:isChild="true"
|
|
||||||
:level="props.level"
|
|
||||||
:disableAddCondition="props.disableAddCondition"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
v-if="props.isGroup && (props.level == 2 || props.level == 4)"
|
|
||||||
@click="show = true"
|
|
||||||
:label="__('Open nested conditions')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div :class="'w-max'">
|
|
||||||
<Dropdown placement="right" :options="dropdownOptions">
|
|
||||||
<Button variant="ghost" icon="more-horizontal" />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{ size: '3xl', title: __('Nested conditions') }"
|
|
||||||
>
|
|
||||||
<template #body-content>
|
|
||||||
<CFConditions
|
|
||||||
:conditions="props.condition"
|
|
||||||
:isChild="true"
|
|
||||||
:level="props.level"
|
|
||||||
:disableAddCondition="props.disableAddCondition"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Autocomplete,
|
|
||||||
Button,
|
|
||||||
DatePicker,
|
|
||||||
DateRangePicker,
|
|
||||||
DateTimePicker,
|
|
||||||
Dialog,
|
|
||||||
Dropdown,
|
|
||||||
FormControl,
|
|
||||||
Rating,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { computed, defineEmits, h, ref } from 'vue'
|
|
||||||
import GroupIcon from '~icons/lucide/group'
|
|
||||||
import UnGroupIcon from '~icons/lucide/ungroup'
|
|
||||||
import CFConditions from './CFConditions.vue'
|
|
||||||
import { filterableFields } from './filterableFields'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
|
|
||||||
const show = ref(false)
|
|
||||||
const emit = defineEmits([
|
|
||||||
'remove',
|
|
||||||
'unGroupConditions',
|
|
||||||
'toggleConjunction',
|
|
||||||
'turnIntoGroup',
|
|
||||||
])
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
condition: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isChild: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
itemIndex: {
|
|
||||||
type: Number,
|
|
||||||
},
|
|
||||||
level: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
isGroup: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
conjunction: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
disableAddCondition: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const dropdownOptions = computed(() => {
|
|
||||||
const options = []
|
|
||||||
|
|
||||||
if (!props.isGroup && props.level < 4) {
|
|
||||||
options.push({
|
|
||||||
label: __('Turn into a group'),
|
|
||||||
icon: () => h(GroupIcon),
|
|
||||||
onClick: () => {
|
|
||||||
emit('turnIntoGroup')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.isGroup) {
|
|
||||||
options.push({
|
|
||||||
label: __('Ungroup conditions'),
|
|
||||||
icon: () => h(UnGroupIcon),
|
|
||||||
onClick: () => {
|
|
||||||
emit('unGroupConditions')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
options.push({
|
|
||||||
label: __('Remove'),
|
|
||||||
icon: 'trash-2',
|
|
||||||
variant: 'red',
|
|
||||||
onClick: () => emit('remove'),
|
|
||||||
condition: () => !props.isGroup,
|
|
||||||
})
|
|
||||||
|
|
||||||
options.push({
|
|
||||||
label: __('Remove group'),
|
|
||||||
icon: 'trash-2',
|
|
||||||
variant: 'red',
|
|
||||||
onClick: () => emit('remove'),
|
|
||||||
condition: () => props.isGroup,
|
|
||||||
})
|
|
||||||
|
|
||||||
return options
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeCheck = ['Check']
|
|
||||||
const typeLink = ['Link', 'Dynamic Link']
|
|
||||||
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
|
|
||||||
const typeSelect = ['Select']
|
|
||||||
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
|
||||||
const typeDate = ['Date', 'Datetime']
|
|
||||||
const typeRating = ['Rating']
|
|
||||||
|
|
||||||
function toggleConjunction() {
|
|
||||||
emit('toggleConjunction', props.conjunction)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateField = (field) => {
|
|
||||||
props.condition[0] = field?.fieldname
|
|
||||||
resetConditionValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetConditionValue = () => {
|
|
||||||
props.condition[2] = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValueControl() {
|
|
||||||
const [field, operator] = props.condition
|
|
||||||
if (!field) return null
|
|
||||||
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
|
|
||||||
if (!fieldData) return null
|
|
||||||
const { fieldtype, options } = fieldData
|
|
||||||
if (operator == 'is') {
|
|
||||||
return h(FormControl, {
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
label: 'Set',
|
|
||||||
value: 'set',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Not Set',
|
|
||||||
value: 'not set',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
} else if (['like', 'not like', 'in', 'not in'].includes(operator)) {
|
|
||||||
return h(FormControl, { type: 'text' })
|
|
||||||
} else if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
|
|
||||||
const _options =
|
|
||||||
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
|
|
||||||
return h(FormControl, {
|
|
||||||
type: 'select',
|
|
||||||
options: _options.map((o) => ({
|
|
||||||
label: o,
|
|
||||||
value: o,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
} else if (typeLink.includes(fieldtype)) {
|
|
||||||
if (fieldtype == 'Dynamic Link') {
|
|
||||||
return h(FormControl, { type: 'text' })
|
|
||||||
}
|
|
||||||
return h(Link, {
|
|
||||||
class: 'form-control',
|
|
||||||
doctype: options,
|
|
||||||
value: props.condition[2],
|
|
||||||
})
|
|
||||||
} else if (typeNumber.includes(fieldtype)) {
|
|
||||||
return h(FormControl, { type: 'number' })
|
|
||||||
} else if (typeDate.includes(fieldtype) && operator == 'between') {
|
|
||||||
return h(DateRangePicker, { value: props.condition[2], iconLeft: '' })
|
|
||||||
} else if (typeDate.includes(fieldtype)) {
|
|
||||||
return h(fieldtype == 'Date' ? DatePicker : DateTimePicker, {
|
|
||||||
value: props.condition[2],
|
|
||||||
iconLeft: '',
|
|
||||||
})
|
|
||||||
} else if (typeRating.includes(fieldtype)) {
|
|
||||||
return h(Rating, {
|
|
||||||
modelValue: props.condition[2] || 0,
|
|
||||||
class: 'truncate',
|
|
||||||
'update:modelValue': (v) => updateValue(v),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return h(FormControl, { type: 'text' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateValue(value) {
|
|
||||||
value = value.target ? value.target.value : value
|
|
||||||
if (props.condition[1] === 'between') {
|
|
||||||
props.condition[2] = [value.split(',')[0], value.split(',')[1]]
|
|
||||||
} else {
|
|
||||||
props.condition[2] = value + ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectOptions(options) {
|
|
||||||
return options.split('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOperator(event) {
|
|
||||||
let oldOperatorValue = event.target._value
|
|
||||||
let newOperatorValue = event.target.value
|
|
||||||
props.condition[1] = event.target.value
|
|
||||||
if (!isSameTypeOperator(oldOperatorValue, newOperatorValue)) {
|
|
||||||
props.condition[2] = getDefaultValue(props.condition[0])
|
|
||||||
}
|
|
||||||
resetConditionValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOperators() {
|
|
||||||
let options = []
|
|
||||||
const field = props.condition[0]
|
|
||||||
if (!field) return options
|
|
||||||
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
|
|
||||||
if (!fieldData) return options
|
|
||||||
const { fieldtype, fieldname } = fieldData
|
|
||||||
if (typeString.includes(fieldtype)) {
|
|
||||||
options.push(
|
|
||||||
...[
|
|
||||||
{ label: 'Equals', value: '==' },
|
|
||||||
{ label: 'Not Equals', value: '!=' },
|
|
||||||
{ label: 'Like', value: 'like' },
|
|
||||||
{ label: 'Not Like', value: 'not like' },
|
|
||||||
{ label: 'In', value: 'in' },
|
|
||||||
{ label: 'Not In', value: 'not in' },
|
|
||||||
{ label: 'Is', value: 'is' },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (fieldname === '_assign') {
|
|
||||||
options = [
|
|
||||||
{ label: 'Like', value: 'like' },
|
|
||||||
{ label: 'Not Like', value: 'not like' },
|
|
||||||
{ label: 'Is', value: 'is' },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if (typeNumber.includes(fieldtype)) {
|
|
||||||
options.push(
|
|
||||||
...[
|
|
||||||
{ label: 'Equals', value: '==' },
|
|
||||||
{ label: 'Not Equals', value: '!=' },
|
|
||||||
{ label: 'Like', value: 'like' },
|
|
||||||
{ label: 'Not Like', value: 'not like' },
|
|
||||||
{ label: 'In', value: 'in' },
|
|
||||||
{ label: 'Not In', value: 'not in' },
|
|
||||||
{ label: 'Is', value: 'is' },
|
|
||||||
{ label: '<', value: '<' },
|
|
||||||
{ label: '>', value: '>' },
|
|
||||||
{ label: '<=', value: '<=' },
|
|
||||||
{ label: '>=', value: '>=' },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (typeSelect.includes(fieldtype)) {
|
|
||||||
options.push(
|
|
||||||
...[
|
|
||||||
{ label: 'Equals', value: '==' },
|
|
||||||
{ label: 'Not Equals', value: '!=' },
|
|
||||||
{ label: 'In', value: 'in' },
|
|
||||||
{ label: 'Not In', value: 'not in' },
|
|
||||||
{ label: 'Is', value: 'is' },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (typeLink.includes(fieldtype)) {
|
|
||||||
options.push(
|
|
||||||
...[
|
|
||||||
{ label: 'Equals', value: '==' },
|
|
||||||
{ label: 'Not Equals', value: '!=' },
|
|
||||||
{ label: 'Like', value: 'like' },
|
|
||||||
{ label: 'Not Like', value: 'not like' },
|
|
||||||
{ label: 'In', value: 'in' },
|
|
||||||
{ label: 'Not In', value: 'not in' },
|
|
||||||
{ label: 'Is', value: 'is' },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (typeCheck.includes(fieldtype)) {
|
|
||||||
options.push(...[{ label: 'Equals', value: '==' }])
|
|
||||||
}
|
|
||||||
if (['Duration'].includes(fieldtype)) {
|
|
||||||
options.push(
|
|
||||||
...[
|
|
||||||
{ label: 'Like', value: 'like' },
|
|
||||||
{ label: 'Not Like', value: 'not like' },
|
|
||||||
{ label: 'In', value: 'in' },
|
|
||||||
{ label: 'Not In', value: 'not in' },
|
|
||||||
{ label: 'Is', value: 'is' },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (typeDate.includes(fieldtype)) {
|
|
||||||
options.push(
|
|
||||||
...[
|
|
||||||
{ label: 'Equals', value: '==' },
|
|
||||||
{ label: 'Not Equals', value: '!=' },
|
|
||||||
{ label: 'Is', value: 'is' },
|
|
||||||
{ label: '>', value: '>' },
|
|
||||||
{ label: '<', value: '<' },
|
|
||||||
{ label: '>=', value: '>=' },
|
|
||||||
{ label: '<=', value: '<=' },
|
|
||||||
{ label: 'Between', value: 'between' },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (typeRating.includes(fieldtype)) {
|
|
||||||
options.push(
|
|
||||||
...[
|
|
||||||
{ label: 'Equals', value: '==' },
|
|
||||||
{ label: 'Not Equals', value: '!=' },
|
|
||||||
{ label: 'Is', value: 'is' },
|
|
||||||
{ label: '>', value: '>' },
|
|
||||||
{ label: '<', value: '<' },
|
|
||||||
{ label: '>=', value: '>=' },
|
|
||||||
{ label: '<=', value: '<=' },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const op = options.find((o) => o.value == props.condition[1])
|
|
||||||
props.condition[1] = op?.value || options[0].value
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultValue(field) {
|
|
||||||
if (typeSelect.includes(field.fieldtype)) {
|
|
||||||
return getSelectOptions(field.options)[0]
|
|
||||||
}
|
|
||||||
if (typeCheck.includes(field.fieldtype)) {
|
|
||||||
return 'Yes'
|
|
||||||
}
|
|
||||||
if (typeDate.includes(field.fieldtype)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (typeRating.includes(field.fieldtype)) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameTypeOperator(oldOperator, newOperator) {
|
|
||||||
let textOperators = ['==', '!=', 'in', 'not in', '>', '<', '>=', '<=']
|
|
||||||
if (
|
|
||||||
textOperators.includes(oldOperator) &&
|
|
||||||
textOperators.includes(newOperator)
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="rounded-lg border border-outline-gray-2 p-3 flex flex-col gap-4 w-full">
|
|
||||||
<template v-for="(condition, i) in props.conditions" :key="condition.field">
|
|
||||||
<CFCondition
|
|
||||||
v-if="Array.isArray(condition)"
|
|
||||||
:condition="condition"
|
|
||||||
:isChild="props.isChild"
|
|
||||||
:itemIndex="i"
|
|
||||||
@remove="removeCondition(condition)"
|
|
||||||
@unGroupConditions="unGroupConditions(condition)"
|
|
||||||
:level="props.level + 1"
|
|
||||||
@toggleConjunction="toggleConjunction"
|
|
||||||
:isGroup="isGroupCondition(condition[0])"
|
|
||||||
:conjunction="getConjunction()"
|
|
||||||
@turnIntoGroup="turnIntoGroup(condition)"
|
|
||||||
:disableAddCondition="props.disableAddCondition"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<div v-if="props.isChild" class="flex">
|
|
||||||
<Dropdown v-slot="{ open }" :options="dropdownOptions">
|
|
||||||
<Button
|
|
||||||
:disabled="props.disableAddCondition"
|
|
||||||
:label="__('Add condition')"
|
|
||||||
icon-left="plus"
|
|
||||||
:icon-right="open ? 'chevron-up' : 'chevron-down'"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Button, Dropdown } from 'frappe-ui'
|
|
||||||
import { computed, watch } from 'vue'
|
|
||||||
import CFCondition from './CFCondition.vue'
|
|
||||||
import { filterableFields } from './filterableFields'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
conditions: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isChild: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
level: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
disableAddCondition: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
doctype: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const getConjunction = () => {
|
|
||||||
let conjunction = 'and'
|
|
||||||
props.conditions.forEach((condition) => {
|
|
||||||
if (typeof condition == 'string') {
|
|
||||||
conjunction = condition
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return conjunction
|
|
||||||
}
|
|
||||||
|
|
||||||
const turnIntoGroup = (condition) => {
|
|
||||||
props.conditions.splice(props.conditions.indexOf(condition), 1, [condition])
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGroupCondition = (condition) => {
|
|
||||||
return Array.isArray(condition)
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownOptions = computed(() => {
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
label: __('Add condition'),
|
|
||||||
onClick: () => {
|
|
||||||
const conjunction = getConjunction()
|
|
||||||
props.conditions.push(conjunction, ['', '', ''])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
if (props.level < 3) {
|
|
||||||
options.push({
|
|
||||||
label: __('Add condition group'),
|
|
||||||
onClick: () => {
|
|
||||||
const conjunction = getConjunction()
|
|
||||||
props.conditions.push(conjunction, [[]])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
})
|
|
||||||
|
|
||||||
function removeCondition(condition) {
|
|
||||||
const conditionIndex = props.conditions.indexOf(condition)
|
|
||||||
if (conditionIndex == 0) {
|
|
||||||
props.conditions.splice(conditionIndex, 2)
|
|
||||||
} else {
|
|
||||||
props.conditions.splice(conditionIndex - 1, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unGroupConditions(condition) {
|
|
||||||
const conjunction = getConjunction()
|
|
||||||
const newConditions = condition.map((c) => {
|
|
||||||
if (typeof c == 'string') {
|
|
||||||
return conjunction
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
})
|
|
||||||
|
|
||||||
const index = props.conditions.indexOf(condition)
|
|
||||||
if (index !== -1) {
|
|
||||||
props.conditions.splice(index, 1, ...newConditions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleConjunction(conjunction) {
|
|
||||||
for (let i = 0; i < props.conditions.length; i++) {
|
|
||||||
if (typeof props.conditions[i] == 'string') {
|
|
||||||
props.conditions[i] = conjunction == 'and' ? 'or' : 'and'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.doctype,
|
|
||||||
(doctype) => {
|
|
||||||
filterableFields.submit({
|
|
||||||
doctype,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { createResource } from 'frappe-ui'
|
|
||||||
|
|
||||||
export const filterableFields = createResource({
|
|
||||||
url: 'crm.api.doc.get_filterable_fields',
|
|
||||||
transform: (data) => {
|
|
||||||
data = data
|
|
||||||
.filter((field) => !field.fieldname.startsWith('_'))
|
|
||||||
.map((field) => {
|
|
||||||
return {
|
|
||||||
label: field.label,
|
|
||||||
value: field.fieldname,
|
|
||||||
...field,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -52,14 +52,16 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center w-12">
|
<div class="w-12">
|
||||||
<Button
|
<Button
|
||||||
:tooltip="__('Edit grid fields')"
|
class="flex w-full items-center justify-center rounded !bg-surface-gray-2 border-0"
|
||||||
class="rounded !bg-surface-gray-2 border-0 !text-ink-gray-5"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon="settings"
|
|
||||||
@click="showGridFieldsEditorModal = true"
|
@click="showGridFieldsEditorModal = true"
|
||||||
/>
|
>
|
||||||
|
<template #icon>
|
||||||
|
<FeatherIcon name="settings" class="size-4 text-ink-gray-7" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Rows -->
|
<!-- Rows -->
|
||||||
@ -70,7 +72,6 @@
|
|||||||
:delay="isTouchScreenDevice() ? 200 : 0"
|
:delay="isTouchScreenDevice() ? 200 : 0"
|
||||||
group="rows"
|
group="rows"
|
||||||
item-key="name"
|
item-key="name"
|
||||||
@end="reorder"
|
|
||||||
>
|
>
|
||||||
<template #item="{ element: row, index }">
|
<template #item="{ element: row, index }">
|
||||||
<div
|
<div
|
||||||
@ -276,14 +277,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-row flex items-center justify-center w-12">
|
<div class="edit-row w-12">
|
||||||
<Button
|
<Button
|
||||||
:tooltip="__('Edit row')"
|
class="flex w-full items-center justify-center rounded border-0"
|
||||||
class="rounded border-0 !text-ink-gray-7"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:icon="EditIcon"
|
|
||||||
@click="showRowList[index] = true"
|
@click="showRowList[index] = true"
|
||||||
/>
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EditIcon class="text-ink-gray-7" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<GridRowModal
|
<GridRowModal
|
||||||
v-if="showRowList[index]"
|
v-if="showRowList[index]"
|
||||||
@ -347,6 +350,7 @@ import { usersStore } from '@/stores/users'
|
|||||||
import { getMeta } from '@/stores/meta'
|
import { getMeta } from '@/stores/meta'
|
||||||
import { createDocument } from '@/composables/document'
|
import { createDocument } from '@/composables/document'
|
||||||
import {
|
import {
|
||||||
|
FeatherIcon,
|
||||||
FormControl,
|
FormControl,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
DateTimePicker,
|
DateTimePicker,
|
||||||
@ -516,13 +520,6 @@ const deleteRows = () => {
|
|||||||
selectedRows.clear()
|
selectedRows.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const reorder = () => {
|
|
||||||
rows.value.forEach((row, index) => {
|
|
||||||
row.idx = index + 1
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function fieldChange(value, field, row) {
|
function fieldChange(value, field, row) {
|
||||||
triggerOnChange(field.fieldname, value, row)
|
triggerOnChange(field.fieldname, value, row)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,10 +54,13 @@
|
|||||||
<template #target="{ togglePopover }">
|
<template #target="{ togglePopover }">
|
||||||
<Button
|
<Button
|
||||||
class="w-full mt-2"
|
class="w-full mt-2"
|
||||||
:label="__('Add Field')"
|
|
||||||
iconLeft="plus"
|
|
||||||
@click="togglePopover()"
|
@click="togglePopover()"
|
||||||
/>
|
:label="__('Add Field')"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<template #item-label="{ option }">
|
<template #item-label="{ option }">
|
||||||
<div class="flex flex-col gap-1 text-ink-gray-9">
|
<div class="flex flex-col gap-1 text-ink-gray-9">
|
||||||
@ -72,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="flex items-center gap-2 justify-end">
|
<div class="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
v-if="dirty"
|
v-if="dirty"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|||||||
@ -11,18 +11,19 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
v-if="isManager()"
|
v-if="isManager()"
|
||||||
:tooltip="__('Edit fields layout')"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-7"
|
class="w-7"
|
||||||
:icon="EditIcon"
|
|
||||||
@click="openGridRowFieldsModal"
|
@click="openGridRowFieldsModal"
|
||||||
/>
|
>
|
||||||
<Button
|
<template #icon>
|
||||||
icon="x"
|
<EditIcon />
|
||||||
variant="ghost"
|
</template>
|
||||||
class="w-7"
|
</Button>
|
||||||
@click="show = false"
|
<Button variant="ghost" class="w-7" @click="show = false">
|
||||||
/>
|
<template #icon>
|
||||||
|
<FeatherIcon name="x" class="size-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
:file-types="image_type"
|
:file-types="image_type"
|
||||||
|
class="text-base"
|
||||||
@success="
|
@success="
|
||||||
(file) => {
|
(file) => {
|
||||||
$emit('upload', file.file_url)
|
$emit('upload', file.file_url)
|
||||||
@ -9,28 +10,21 @@
|
|||||||
>
|
>
|
||||||
<template v-slot="{ progress, uploading, openFileSelector }">
|
<template v-slot="{ progress, uploading, openFileSelector }">
|
||||||
<div class="flex items-end space-x-1">
|
<div class="flex items-end space-x-1">
|
||||||
<Button
|
<Button @click="openFileSelector">
|
||||||
@click="openFileSelector"
|
{{
|
||||||
:iconLeft="uploading ? 'cloud-upload' : ImageUpIcon"
|
|
||||||
:label="
|
|
||||||
uploading
|
uploading
|
||||||
? __('Uploading {0}%', [progress])
|
? `Uploading ${progress}%`
|
||||||
: image_url
|
: image_url
|
||||||
? __('Change')
|
? 'Change'
|
||||||
: __('Upload')
|
: 'Upload'
|
||||||
"
|
}}
|
||||||
/>
|
</Button>
|
||||||
<Button
|
<Button v-if="image_url" @click="$emit('remove')">Remove</Button>
|
||||||
v-if="image_url"
|
|
||||||
:label="__('Remove')"
|
|
||||||
@click="$emit('remove')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import ImageUpIcon from '~icons/lucide/image-up'
|
|
||||||
import { FileUploader, Button } from 'frappe-ui'
|
import { FileUploader, Button } from 'frappe-ui'
|
||||||
|
|
||||||
const prop = defineProps({
|
const prop = defineProps({
|
||||||
@ -39,6 +33,10 @@ const prop = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'image/*',
|
default: 'image/*',
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['upload', 'remove'])
|
const emit = defineEmits(['upload', 'remove'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -48,18 +48,24 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full !justify-start"
|
class="w-full !justify-start"
|
||||||
:label="__('Create New')"
|
:label="__('Create New')"
|
||||||
iconLeft="plus"
|
|
||||||
@click="() => attrs.onCreate(value, close)"
|
@click="() => attrs.onCreate(value, close)"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full !justify-start"
|
class="w-full !justify-start"
|
||||||
:label="__('Clear')"
|
:label="__('Clear')"
|
||||||
iconLeft="x"
|
|
||||||
@click="() => clearValue(close)"
|
@click="() => clearValue(close)"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="x" class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Autocomplete>
|
</Autocomplete>
|
||||||
|
|||||||
@ -18,10 +18,14 @@
|
|||||||
:key="g.label"
|
:key="g.label"
|
||||||
>
|
>
|
||||||
<Dropdown :options="g.action" v-slot="{ open }">
|
<Dropdown :options="g.action" v-slot="{ open }">
|
||||||
<Button
|
<Button :label="g.label">
|
||||||
:label="g.label"
|
<template #suffix>
|
||||||
:iconRight="open ? 'chevron-up' : 'chevron-down'"
|
<FeatherIcon
|
||||||
/>
|
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||||
|
class="h-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{ title: __('Add chart') }"
|
|
||||||
@close="show = false"
|
|
||||||
>
|
|
||||||
<template #body-content>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<FormControl
|
|
||||||
v-model="chartType"
|
|
||||||
type="select"
|
|
||||||
:label="__('Chart Type')"
|
|
||||||
:options="chartTypes"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-if="chartType === 'number_chart'"
|
|
||||||
v-model="numberChart"
|
|
||||||
type="select"
|
|
||||||
:label="__('Number chart')"
|
|
||||||
:options="numberCharts"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-if="chartType === 'axis_chart'"
|
|
||||||
v-model="axisChart"
|
|
||||||
type="select"
|
|
||||||
:label="__('Axis chart')"
|
|
||||||
:options="axisCharts"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-if="chartType === 'donut_chart'"
|
|
||||||
v-model="donutChart"
|
|
||||||
type="select"
|
|
||||||
:label="__('Donut chart')"
|
|
||||||
:options="donutCharts"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<Button variant="outline" :label="__('Cancel')" @click="show = false" />
|
|
||||||
<Button variant="solid" :label="__('Add')" @click="addChart" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { getRandom } from '@/utils'
|
|
||||||
import { createResource, Dialog, FormControl } from 'frappe-ui'
|
|
||||||
import { ref, reactive, inject } from 'vue'
|
|
||||||
|
|
||||||
const show = defineModel({
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = defineModel('items', {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const fromDate = inject('fromDate', ref(''))
|
|
||||||
const toDate = inject('toDate', ref(''))
|
|
||||||
const filters = inject('filters', reactive({ period: '', user: '' }))
|
|
||||||
|
|
||||||
const chartType = ref('spacer')
|
|
||||||
const chartTypes = [
|
|
||||||
{ label: __('Spacer'), value: 'spacer' },
|
|
||||||
{ label: __('Number chart'), value: 'number_chart' },
|
|
||||||
{ label: __('Axis chart'), value: 'axis_chart' },
|
|
||||||
{ label: __('Donut chart'), value: 'donut_chart' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const numberChart = ref('')
|
|
||||||
const numberCharts = [
|
|
||||||
{ label: __('Total leads'), value: 'total_leads' },
|
|
||||||
{ label: __('Ongoing deals'), value: 'ongoing_deals' },
|
|
||||||
{ label: __('Avg ongoing deal value'), value: 'average_ongoing_deal_value' },
|
|
||||||
{ label: __('Won deals'), value: 'won_deals' },
|
|
||||||
{ label: __('Avg won deal value'), value: 'average_won_deal_value' },
|
|
||||||
{ label: __('Avg deal value'), value: 'average_deal_value' },
|
|
||||||
{
|
|
||||||
label: __('Avg time to close a lead'),
|
|
||||||
value: 'average_time_to_close_a_lead',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: __('Avg time to close a deal'),
|
|
||||||
value: 'average_time_to_close_a_deal',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const axisChart = ref('sales_trend')
|
|
||||||
const axisCharts = [
|
|
||||||
{ label: __('Sales trend'), value: 'sales_trend' },
|
|
||||||
{ label: __('Forecasted revenue'), value: 'forecasted_revenue' },
|
|
||||||
{ label: __('Funnel conversion'), value: 'funnel_conversion' },
|
|
||||||
{ label: __('Deals by ongoing & won stage'), value: 'deals_by_stage_axis' },
|
|
||||||
{ label: __('Lost deal reasons'), value: 'lost_deal_reasons' },
|
|
||||||
{ label: __('Deals by territory'), value: 'deals_by_territory' },
|
|
||||||
{ label: __('Deals by salesperson'), value: 'deals_by_salesperson' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const donutChart = ref('deals_by_stage_donut')
|
|
||||||
const donutCharts = [
|
|
||||||
{ label: __('Deals by stage'), value: 'deals_by_stage_donut' },
|
|
||||||
{ label: __('Leads by source'), value: 'leads_by_source' },
|
|
||||||
{ label: __('Deals by source'), value: 'deals_by_source' },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function addChart() {
|
|
||||||
show.value = false
|
|
||||||
if (chartType.value == 'spacer') {
|
|
||||||
items.value.push({
|
|
||||||
name: 'spacer',
|
|
||||||
type: 'spacer',
|
|
||||||
layout: { x: 0, y: 0, w: 4, h: 2, i: 'spacer_' + getRandom(4) },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await getChart(chartType.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getChart(type: string) {
|
|
||||||
let name =
|
|
||||||
type == 'number_chart'
|
|
||||||
? numberChart.value
|
|
||||||
: type == 'axis_chart'
|
|
||||||
? axisChart.value
|
|
||||||
: donutChart.value
|
|
||||||
|
|
||||||
await createResource({
|
|
||||||
url: 'crm.api.dashboard.get_chart',
|
|
||||||
params: {
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
from_date: fromDate.value,
|
|
||||||
to_date: toDate.value,
|
|
||||||
user: filters.user,
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
onSuccess: (data = {}) => {
|
|
||||||
let width = 4
|
|
||||||
let height = 2
|
|
||||||
|
|
||||||
if (['axis_chart', 'donut_chart'].includes(type)) {
|
|
||||||
width = 10
|
|
||||||
height = 7
|
|
||||||
}
|
|
||||||
|
|
||||||
items.value.push({
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
layout: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
w: width,
|
|
||||||
h: height,
|
|
||||||
i: name + '_' + getRandom(4),
|
|
||||||
},
|
|
||||||
data: data,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex-1 overflow-y-auto p-3">
|
|
||||||
<GridLayout
|
|
||||||
v-if="items.length > 0"
|
|
||||||
class="h-fit w-full"
|
|
||||||
:class="[editing ? 'mb-[20rem] !select-none' : '']"
|
|
||||||
:cols="20"
|
|
||||||
:rowHeight="42"
|
|
||||||
:disabled="!editing"
|
|
||||||
:modelValue="items.map((item) => item.layout)"
|
|
||||||
@update:modelValue="
|
|
||||||
(newLayout) => {
|
|
||||||
items.forEach((item, idx) => {
|
|
||||||
item.layout = newLayout[idx]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template #item="{ index }">
|
|
||||||
<div class="group relative flex h-full w-full p-2 text-ink-gray-8">
|
|
||||||
<div
|
|
||||||
class="flex h-full w-full items-center justify-center"
|
|
||||||
:class="
|
|
||||||
editing
|
|
||||||
? 'pointer-events-none [&>div:first-child]:rounded [&>div:first-child]:group-hover:ring-2 [&>div:first-child]:group-hover:ring-outline-gray-2'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<DashboardItem
|
|
||||||
:index="index"
|
|
||||||
:item="items[index]"
|
|
||||||
:editing="editing"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="editing"
|
|
||||||
class="flex absolute right-0 top-0 bg-surface-gray-6 rounded cursor-pointer opacity-0 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="rounded p-1 hover:bg-surface-gray-5"
|
|
||||||
@click="items.splice(index, 1)"
|
|
||||||
>
|
|
||||||
<FeatherIcon name="trash-2" class="size-3 text-ink-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</GridLayout>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { GridLayout } from 'frappe-ui'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
editing: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = defineModel()
|
|
||||||
</script>
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full w-full">
|
|
||||||
<div
|
|
||||||
v-if="item.type == 'number_chart'"
|
|
||||||
class="flex h-full w-full rounded shadow overflow-hidden cursor-pointer"
|
|
||||||
>
|
|
||||||
<Tooltip :text="__(item.data.tooltip)">
|
|
||||||
<NumberChart
|
|
||||||
class="!items-start"
|
|
||||||
v-if="item.data"
|
|
||||||
:key="index"
|
|
||||||
:config="item.data"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="item.type == 'spacer'"
|
|
||||||
class="rounded bg-surface-white h-full overflow-hidden text-ink-gray-5 flex items-center justify-center"
|
|
||||||
:class="editing ? 'border border-dashed border-outline-gray-2' : ''"
|
|
||||||
>
|
|
||||||
{{ editing ? __('Spacer') : '' }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="item.type == 'axis_chart'"
|
|
||||||
class="h-full w-full rounded-md bg-surface-white shadow"
|
|
||||||
>
|
|
||||||
<AxisChart v-if="item.data" :config="item.data" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="item.type == 'donut_chart'"
|
|
||||||
class="h-full w-full rounded-md bg-surface-white shadow overflow-hidden"
|
|
||||||
>
|
|
||||||
<DonutChart v-if="item.data" :config="item.data" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { AxisChart, DonutChart, NumberChart, Tooltip } from 'frappe-ui'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
index: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
item: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
editing: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@ -2,7 +2,7 @@
|
|||||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||||
<template #body v-if="!confirmDeleteInfo.show">
|
<template #body v-if="!confirmDeleteInfo.show">
|
||||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
|
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
|
||||||
{{
|
{{
|
||||||
@ -32,12 +32,11 @@
|
|||||||
{
|
{
|
||||||
label: 'Document',
|
label: 'Document',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
width: '19rem',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Master',
|
label: 'Master',
|
||||||
key: 'reference_doctype',
|
key: 'reference_doctype',
|
||||||
width: '12rem',
|
width: '30%',
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@selectionsChanged="
|
@selectionsChanged="
|
||||||
|
|||||||
@ -19,36 +19,53 @@
|
|||||||
v-if="editMode"
|
v-if="editMode"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:label="__('Save')"
|
:label="__('Save')"
|
||||||
|
size="sm"
|
||||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||||
@click="saveOption"
|
@click="saveOption"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Tooltip text="Set As Primary" v-if="!isNew && !option.selected">
|
||||||
v-if="!isNew && !option.selected"
|
<div>
|
||||||
:tooltip="__('Set As Primary')"
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:icon="SuccessIcon"
|
size="sm"
|
||||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||||
@click="option.onClick"
|
@click="option.onClick"
|
||||||
/>
|
>
|
||||||
<Button
|
<template #icon>
|
||||||
v-if="!editMode"
|
<SuccessIcon />
|
||||||
:tooltip="__('Edit')"
|
</template>
|
||||||
variant="ghost"
|
</Button>
|
||||||
:icon="EditIcon"
|
</div>
|
||||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
</Tooltip>
|
||||||
@click="toggleEditMode"
|
<Tooltip v-if="!editMode" text="Edit">
|
||||||
/>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
:tooltip="__('Delete')"
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
icon="x"
|
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
@click="toggleEditMode"
|
||||||
@click="() => option.onDelete(option, isNew)"
|
>
|
||||||
/>
|
<template #icon>
|
||||||
|
<EditIcon />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Delete">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="x"
|
||||||
|
size="sm"
|
||||||
|
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||||
|
@click="() => option.onDelete(option, isNew)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="option.selected">
|
<div v-if="option.selected">
|
||||||
<FeatherIcon name="check" class="text-ink-gray-5 h-4 w-6" />
|
<FeatherIcon name="check" class="text-ink-gray-5 h-4 w-6" size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -56,7 +73,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
|
import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
|
||||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
import { TextInput } from 'frappe-ui'
|
import { TextInput, Tooltip } from 'frappe-ui'
|
||||||
import { nextTick, ref, onMounted } from 'vue'
|
import { nextTick, ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -123,12 +123,11 @@
|
|||||||
v-slot="{ togglePopover }"
|
v-slot="{ togglePopover }"
|
||||||
@update:modelValue="() => appendEmoji()"
|
@update:modelValue="() => appendEmoji()"
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="ghost" @click="togglePopover()">
|
||||||
:tooltip="__('Insert Emoji')"
|
<template #icon>
|
||||||
:icon="SmileIcon"
|
<SmileIcon class="h-4" />
|
||||||
variant="ghost"
|
</template>
|
||||||
@click="togglePopover()"
|
</Button>
|
||||||
/>
|
|
||||||
</IconPicker>
|
</IconPicker>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
:upload-args="{
|
:upload-args="{
|
||||||
@ -139,20 +138,21 @@
|
|||||||
@success="(f) => attachments.push(f)"
|
@success="(f) => attachments.push(f)"
|
||||||
>
|
>
|
||||||
<template #default="{ openFileSelector }">
|
<template #default="{ openFileSelector }">
|
||||||
<Button
|
<Button variant="ghost" @click="openFileSelector()">
|
||||||
:tooltip="__('Attach a file')"
|
<template #icon>
|
||||||
:icon="AttachmentIcon"
|
<AttachmentIcon class="h-4" />
|
||||||
variant="ghost"
|
</template>
|
||||||
@click="openFileSelector()"
|
</Button>
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
<Button
|
<Button
|
||||||
:tooltip="__('Insert Email Template')"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:icon="EmailTemplateIcon"
|
|
||||||
@click="showEmailTemplateSelectorModal = true"
|
@click="showEmailTemplateSelectorModal = true"
|
||||||
/>
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EmailTemplateIcon class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
|
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
|
||||||
<Button v-bind="discardButtonProps || {}" :label="__('Discard')" />
|
<Button v-bind="discardButtonProps || {}" :label="__('Discard')" />
|
||||||
|
|||||||
@ -89,9 +89,12 @@
|
|||||||
v-if="data[field.fieldname] && field.edit"
|
v-if="data[field.fieldname] && field.edit"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
:label="__('Edit')"
|
:label="__('Edit')"
|
||||||
:iconLeft="EditIcon"
|
|
||||||
@click="field.edit(data[field.fieldname])"
|
@click="field.edit(data[field.fieldname])"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<EditIcon class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TableMultiselectInput
|
<TableMultiselectInput
|
||||||
|
|||||||
@ -169,10 +169,13 @@
|
|||||||
<Button
|
<Button
|
||||||
class="w-full !h-8 !bg-surface-modal"
|
class="w-full !h-8 !bg-surface-modal"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:label="__('Add Field')"
|
|
||||||
iconLeft="plus"
|
|
||||||
@click="togglePopover()"
|
@click="togglePopover()"
|
||||||
/>
|
:label="__('Add Field')"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #item-label="{ option }">
|
<template #item-label="{ option }">
|
||||||
@ -195,7 +198,6 @@
|
|||||||
class="w-full h-8"
|
class="w-full h-8"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
:label="__('Add Section')"
|
:label="__('Add Section')"
|
||||||
iconLeft="plus"
|
|
||||||
@click="
|
@click="
|
||||||
tabs[tabIndex].sections.push({
|
tabs[tabIndex].sections.push({
|
||||||
label: __('New Section'),
|
label: __('New Section'),
|
||||||
@ -204,7 +206,11 @@
|
|||||||
columns: [{ name: 'column_' + getRandom(), fields: [] }],
|
columns: [{ name: 'column_' + getRandom(), fields: [] }],
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user