Compare commits
No commits in common. "main" and "mergify/bp/main-hotfix/pr-1152" have entirely different histories.
main
...
mergify/bp
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,5 +7,6 @@ dev-dist
|
||||
tags
|
||||
node_modules
|
||||
crm/public/frontend
|
||||
frontend/yarn.lock
|
||||
crm/www/crm.html
|
||||
build
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
|
||||
__version__ = "1.53.1"
|
||||
__version__ = "1.52.2"
|
||||
__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
|
||||
137
crm/api/doc.py
137
crm/api/doc.py
@ -750,11 +750,7 @@ def getCounts(d, doctype):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_docs_of_document(doctype, docname):
|
||||
try:
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
except frappe.DoesNotExistError:
|
||||
return []
|
||||
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
linked_docs = get_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 = []
|
||||
for doc in linked_docs:
|
||||
if not doc.get("reference_doctype") or not doc.get("reference_docname"):
|
||||
continue
|
||||
|
||||
try:
|
||||
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
|
||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||
continue
|
||||
|
||||
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
|
||||
title = data.get("title")
|
||||
if data.doctype == "CRM Call Log":
|
||||
title = f"Call from {data.get('from')} to {data.get('to')}"
|
||||
@ -778,9 +767,6 @@ def get_linked_docs_of_document(doctype, docname):
|
||||
if data.doctype == "CRM Deal":
|
||||
title = data.get("organization")
|
||||
|
||||
if data.doctype == "CRM Notification":
|
||||
title = data.get("message")
|
||||
|
||||
docs_data.append(
|
||||
{
|
||||
"doc": data.doctype,
|
||||
@ -793,51 +779,25 @@ def get_linked_docs_of_document(doctype, docname):
|
||||
|
||||
|
||||
def remove_doc_link(doctype, docname):
|
||||
if not doctype or not docname:
|
||||
return
|
||||
|
||||
try:
|
||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||
if doctype == "CRM Notification":
|
||||
delete_notification_type = {
|
||||
"notification_type_doctype": "",
|
||||
"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
|
||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||
linked_doc_data.update(
|
||||
{
|
||||
"reference_doctype": None,
|
||||
"reference_docname": None,
|
||||
}
|
||||
)
|
||||
linked_doc_data.save(ignore_permissions=True)
|
||||
|
||||
|
||||
def remove_contact_link(doctype, docname):
|
||||
if not doctype or not docname:
|
||||
return
|
||||
|
||||
try:
|
||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||
linked_doc_data.update(
|
||||
{
|
||||
"contact": None,
|
||||
"contacts": [],
|
||||
}
|
||||
)
|
||||
linked_doc_data.save(ignore_permissions=True)
|
||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||
pass
|
||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||
linked_doc_data.update(
|
||||
{
|
||||
"contact": None,
|
||||
"contacts": [],
|
||||
}
|
||||
)
|
||||
linked_doc_data.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@ -846,19 +806,13 @@ def remove_linked_doc_reference(items, remove_contact=None, delete=False):
|
||||
items = frappe.parse_json(items)
|
||||
|
||||
for item in items:
|
||||
if not item.get("doctype") or not item.get("docname"):
|
||||
continue
|
||||
if remove_contact:
|
||||
remove_contact_link(item["doctype"], item["docname"])
|
||||
else:
|
||||
remove_doc_link(item["doctype"], item["docname"])
|
||||
|
||||
try:
|
||||
if remove_contact:
|
||||
remove_contact_link(item["doctype"], item["docname"])
|
||||
else:
|
||||
remove_doc_link(item["doctype"], item["docname"])
|
||||
if delete:
|
||||
frappe.delete_doc(item["doctype"], item["docname"])
|
||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||
# Skip if document doesn't exist or has validation errors
|
||||
continue
|
||||
if delete:
|
||||
frappe.delete_doc(item["doctype"], item["docname"])
|
||||
|
||||
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):
|
||||
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)
|
||||
if not isinstance(items, list):
|
||||
frappe.throw("Items must be a list")
|
||||
|
||||
for doc in items:
|
||||
try:
|
||||
if not frappe.db.exists(doctype, doc):
|
||||
frappe.log_error(f"Document {doctype} {doc} does not exist", "Bulk Delete Error")
|
||||
continue
|
||||
|
||||
linked_docs = get_linked_docs_of_document(doctype, doc)
|
||||
for linked_doc in linked_docs:
|
||||
if not linked_doc.get("reference_doctype") or not linked_doc.get("reference_docname"):
|
||||
continue
|
||||
|
||||
remove_linked_doc_reference(
|
||||
[
|
||||
{
|
||||
"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"
|
||||
linked_docs = get_linked_docs_of_document(doctype, doc)
|
||||
for linked_doc in linked_docs:
|
||||
remove_linked_doc_reference(
|
||||
[
|
||||
{
|
||||
"doctype": linked_doc["reference_doctype"],
|
||||
"docname": linked_doc["reference_docname"],
|
||||
}
|
||||
],
|
||||
remove_contact=doctype == "Contact",
|
||||
delete=delete_linked,
|
||||
)
|
||||
|
||||
if len(items) > 10:
|
||||
|
||||
@ -10,15 +10,8 @@ from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
||||
def validate(doc, method):
|
||||
if doc.type == "Incoming" and doc.get("from"):
|
||||
name, doctype = get_lead_or_deal_from_number(doc.get("from"))
|
||||
if name != None:
|
||||
doc.reference_doctype = doctype
|
||||
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
|
||||
doc.reference_doctype = doctype
|
||||
doc.reference_name = name
|
||||
|
||||
|
||||
def on_update(doc, method):
|
||||
@ -36,7 +29,7 @@ def on_update(doc, method):
|
||||
def notify_agent(doc):
|
||||
if doc.type == "Incoming":
|
||||
doctype = doc.reference_doctype
|
||||
if doctype and doctype.startswith("CRM "):
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
notification_text = f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
|
||||
@ -129,13 +129,15 @@
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Primary Email",
|
||||
"options": "Email"
|
||||
"options": "Email",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Data",
|
||||
"label": "Primary Mobile No",
|
||||
"options": "Phone"
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Qualification",
|
||||
@ -249,7 +251,8 @@
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Primary Phone",
|
||||
"options": "Phone"
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "log_tab",
|
||||
@ -432,7 +435,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-26 12:12:56.324245",
|
||||
"modified": "2025-07-13 11:54:20.608489",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
|
||||
@ -25,7 +25,7 @@ class CRMDeal(Document):
|
||||
add_status_change_log(self)
|
||||
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
|
||||
self.closed_date = frappe.utils.nowdate()
|
||||
self.validate_forecasting_fields()
|
||||
self.validate_forcasting_fields()
|
||||
self.validate_lost_reason()
|
||||
self.update_exchange_rate()
|
||||
|
||||
@ -151,21 +151,9 @@ class CRMDeal(Document):
|
||||
if not self.probability or self.probability == 0:
|
||||
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
||||
|
||||
def update_expected_deal_value(self):
|
||||
"""
|
||||
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):
|
||||
def validate_forcasting_fields(self):
|
||||
self.update_closed_date()
|
||||
self.update_default_probability()
|
||||
self.update_expected_deal_value()
|
||||
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
|
||||
if not self.expected_deal_value or self.expected_deal_value == 0:
|
||||
frappe.throw(_("Expected Deal Value is required."), frappe.MandatoryError)
|
||||
|
||||
@ -63,7 +63,8 @@
|
||||
"fieldname": "twiml_sid",
|
||||
"fieldtype": "Data",
|
||||
"label": "TwiML SID",
|
||||
"permlevel": 1
|
||||
"permlevel": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ssqj",
|
||||
@ -104,7 +105,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-19 13:36:19.823197",
|
||||
"modified": "2025-01-15 19:35:13.406254",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Twilio Settings",
|
||||
@ -151,9 +152,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,6 @@
|
||||
"defaults_tab",
|
||||
"restore_defaults",
|
||||
"enable_forecasting",
|
||||
"auto_update_expected_deal_value",
|
||||
"currency_tab",
|
||||
"currency",
|
||||
"exchange_rate_provider_section",
|
||||
@ -106,19 +105,12 @@
|
||||
{
|
||||
"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,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-16 17:33:26.406549",
|
||||
"modified": "2025-07-29 11:26:50.420614",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "FCRM Settings",
|
||||
|
||||
@ -25,8 +25,6 @@ def after_install(force=False):
|
||||
add_standard_dropdown_items()
|
||||
add_default_scripts()
|
||||
create_default_manager_dashboard(force)
|
||||
create_assignment_rule_custom_fields()
|
||||
add_assignment_rule_property_setters()
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
@ -423,80 +421,3 @@ def add_default_scripts():
|
||||
for doctype in ["CRM Lead", "CRM Deal"]:
|
||||
create_product_details_script(doctype)
|
||||
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")
|
||||
|
||||
2192
crm/locale/ar.po
2192
crm/locale/ar.po
File diff suppressed because it is too large
Load Diff
2198
crm/locale/bs.po
2198
crm/locale/bs.po
File diff suppressed because it is too large
Load Diff
2192
crm/locale/cs.po
2192
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
2194
crm/locale/de.po
2194
crm/locale/de.po
File diff suppressed because it is too large
Load Diff
2198
crm/locale/eo.po
2198
crm/locale/eo.po
File diff suppressed because it is too large
Load Diff
2198
crm/locale/es.po
2198
crm/locale/es.po
File diff suppressed because it is too large
Load Diff
2220
crm/locale/fa.po
2220
crm/locale/fa.po
File diff suppressed because it is too large
Load Diff
2196
crm/locale/fr.po
2196
crm/locale/fr.po
File diff suppressed because it is too large
Load Diff
2198
crm/locale/hr.po
2198
crm/locale/hr.po
File diff suppressed because it is too large
Load Diff
2206
crm/locale/hu.po
2206
crm/locale/hu.po
File diff suppressed because it is too large
Load Diff
2238
crm/locale/id.po
2238
crm/locale/id.po
File diff suppressed because it is too large
Load Diff
2208
crm/locale/it.po
2208
crm/locale/it.po
File diff suppressed because it is too large
Load Diff
2142
crm/locale/main.pot
2142
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
2192
crm/locale/nl.po
2192
crm/locale/nl.po
File diff suppressed because it is too large
Load Diff
2194
crm/locale/pl.po
2194
crm/locale/pl.po
File diff suppressed because it is too large
Load Diff
2254
crm/locale/pt.po
2254
crm/locale/pt.po
File diff suppressed because it is too large
Load Diff
2202
crm/locale/pt_BR.po
2202
crm/locale/pt_BR.po
File diff suppressed because it is too large
Load Diff
2192
crm/locale/ru.po
2192
crm/locale/ru.po
File diff suppressed because it is too large
Load Diff
2204
crm/locale/sr.po
2204
crm/locale/sr.po
File diff suppressed because it is too large
Load Diff
2216
crm/locale/sr_CS.po
2216
crm/locale/sr_CS.po
File diff suppressed because it is too large
Load Diff
2204
crm/locale/sv.po
2204
crm/locale/sv.po
File diff suppressed because it is too large
Load Diff
2234
crm/locale/th.po
2234
crm/locale/th.po
File diff suppressed because it is too large
Load Diff
2198
crm/locale/tr.po
2198
crm/locale/tr.po
File diff suppressed because it is too large
Load Diff
2192
crm/locale/vi.po
2192
crm/locale/vi.po
File diff suppressed because it is too large
Load Diff
2194
crm/locale/zh.po
2194
crm/locale/zh.po
File diff suppressed because it is too large
Load Diff
@ -15,5 +15,4 @@ crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
||||
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_type
|
||||
crm.patches.v1_0.create_default_lost_reasons
|
||||
crm.patches.v1_0.add_fields_in_assignment_rule
|
||||
crm.patches.v1_0.create_default_lost_reasons
|
||||
@ -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 +1 @@
|
||||
Subproject commit c9a0fc937cc897864857271b3708a0c675379015
|
||||
Subproject commit 136f2715c2bd22b7390a2a02f1849a147d16b191
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -2,5 +2,4 @@ node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
components.d.ts
|
||||
*.local
|
||||
14
frontend/components.d.ts
vendored
14
frontend/components.d.ts
vendored
@ -33,7 +33,7 @@ declare module 'vue' {
|
||||
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
|
||||
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.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']
|
||||
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
|
||||
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
|
||||
@ -63,7 +63,7 @@ declare module 'vue' {
|
||||
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
|
||||
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
|
||||
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
||||
CurrencySettings: typeof import('./src/components/Settings/CurrencySettings.vue')['default']
|
||||
CurrencySettings: typeof import('./src/components/Settings/General/CurrencySettings.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']
|
||||
@ -85,6 +85,7 @@ declare module 'vue' {
|
||||
DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default']
|
||||
DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.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']
|
||||
DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default']
|
||||
EditEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/EditEmailTemplate.vue')['default']
|
||||
@ -127,10 +128,11 @@ declare module 'vue' {
|
||||
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
|
||||
Filter: typeof import('./src/components/Filter.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']
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.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']
|
||||
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
|
||||
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
|
||||
@ -141,7 +143,7 @@ declare module 'vue' {
|
||||
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
|
||||
HeartIcon: typeof import('./src/components/Icons/HeartIcon.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']
|
||||
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
|
||||
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
|
||||
@ -168,6 +170,7 @@ declare module 'vue' {
|
||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
||||
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
||||
@ -200,8 +203,6 @@ declare module 'vue' {
|
||||
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
|
||||
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
|
||||
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
|
||||
PrimaryDropdown: typeof import('./src/components/PrimaryDropdown.vue')['default']
|
||||
PrimaryDropdownItem: typeof import('./src/components/PrimaryDropdownItem.vue')['default']
|
||||
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
|
||||
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
|
||||
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
|
||||
@ -228,7 +229,6 @@ declare module 'vue' {
|
||||
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
|
||||
SortBy: typeof import('./src/components/SortBy.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']
|
||||
StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default']
|
||||
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default']
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"@tiptap/extension-paragraph": "^2.12.0",
|
||||
"@twilio/voice-sdk": "^2.10.2",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"frappe-ui": "^0.1.201",
|
||||
"frappe-ui": "^0.1.189",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
|
||||
@ -152,7 +152,6 @@ watch(
|
||||
updateAssignees()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function updateAssignees() {
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
</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?', [
|
||||
props.items?.length,
|
||||
@ -53,7 +53,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-ink-gray-5 text-base">
|
||||
<div class="text-ink-gray-5">
|
||||
{{
|
||||
confirmDeleteInfo.delete
|
||||
? __(
|
||||
|
||||
@ -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
|
||||
},
|
||||
})
|
||||
@ -70,7 +70,6 @@
|
||||
:delay="isTouchScreenDevice() ? 200 : 0"
|
||||
group="rows"
|
||||
item-key="name"
|
||||
@end="reorder"
|
||||
>
|
||||
<template #item="{ element: row, index }">
|
||||
<div
|
||||
@ -347,6 +346,7 @@ import { usersStore } from '@/stores/users'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { createDocument } from '@/composables/document'
|
||||
import {
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
Checkbox,
|
||||
DateTimePicker,
|
||||
@ -516,13 +516,6 @@ const deleteRows = () => {
|
||||
selectedRows.clear()
|
||||
}
|
||||
|
||||
const reorder = () => {
|
||||
rows.value.forEach((row, index) => {
|
||||
row.idx = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function fieldChange(value, field, row) {
|
||||
triggerOnChange(field.fieldname, value, row)
|
||||
}
|
||||
|
||||
@ -72,7 +72,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="dirty"
|
||||
class="w-full"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<FileUploader
|
||||
:file-types="image_type"
|
||||
class="text-base"
|
||||
@success="
|
||||
(file) => {
|
||||
$emit('upload', file.file_url)
|
||||
@ -9,28 +10,21 @@
|
||||
>
|
||||
<template v-slot="{ progress, uploading, openFileSelector }">
|
||||
<div class="flex items-end space-x-1">
|
||||
<Button
|
||||
@click="openFileSelector"
|
||||
:iconLeft="uploading ? 'cloud-upload' : ImageUpIcon"
|
||||
:label="
|
||||
<Button @click="openFileSelector">
|
||||
{{
|
||||
uploading
|
||||
? __('Uploading {0}%', [progress])
|
||||
? `Uploading ${progress}%`
|
||||
: image_url
|
||||
? __('Change')
|
||||
: __('Upload')
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
v-if="image_url"
|
||||
:label="__('Remove')"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
? 'Change'
|
||||
: 'Upload'
|
||||
}}
|
||||
</Button>
|
||||
<Button v-if="image_url" @click="$emit('remove')">Remove</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</template>
|
||||
<script setup>
|
||||
import ImageUpIcon from '~icons/lucide/image-up'
|
||||
import { FileUploader, Button } from 'frappe-ui'
|
||||
|
||||
const prop = defineProps({
|
||||
@ -39,6 +33,10 @@ const prop = defineProps({
|
||||
type: String,
|
||||
default: 'image/*',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['upload', 'remove'])
|
||||
</script>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||
<template #body v-if="!confirmDeleteInfo.show">
|
||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
|
||||
{{
|
||||
@ -32,12 +32,11 @@
|
||||
{
|
||||
label: 'Document',
|
||||
key: 'title',
|
||||
width: '19rem',
|
||||
},
|
||||
{
|
||||
label: 'Master',
|
||||
key: 'reference_doctype',
|
||||
width: '12rem',
|
||||
width: '30%',
|
||||
},
|
||||
]"
|
||||
@selectionsChanged="
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
<script setup>
|
||||
import SuccessIcon from '@/components/Icons/SuccessIcon.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'
|
||||
|
||||
const props = defineProps({
|
||||
@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-settings2-icon lucide-settings-2"
|
||||
>
|
||||
<path d="M14 17H5" />
|
||||
<path d="M19 7h-9" />
|
||||
<circle cx="17" cy="17" r="3" />
|
||||
<circle cx="7" cy="7" r="3" />
|
||||
</svg>
|
||||
</template>
|
||||
@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.37543 1.93494L4.21632 2.21494C4.35232 2.26027 4.44388 2.38738 4.44388 2.53138C4.44388 2.67538 4.35143 2.80249 4.21543 2.84783L3.37454 3.12783L3.09365 3.9696C3.04921 4.10472 2.92121 4.19716 2.7781 4.19716C2.63499 4.19716 2.50787 4.1056 2.46254 3.9696L2.18165 3.12783L1.34076 2.84783C1.20476 2.80249 1.11232 2.67538 1.11232 2.53138C1.11232 2.38738 1.20476 2.26027 1.34076 2.21494L2.18165 1.93494L2.46254 1.09316C2.55321 0.82116 3.00387 0.82116 3.09454 1.09316L3.37543 1.93494ZM8.44852 1.33394C8.3643 1.16325 8.19046 1.05518 8.00012 1.05518C7.80978 1.05518 7.63595 1.16325 7.55173 1.33394L5.67697 5.13368L1.48388 5.74214C1.29552 5.76947 1.13901 5.90137 1.08017 6.08238C1.02133 6.26339 1.07036 6.46211 1.20665 6.59497L4.24065 9.55281L3.52421 13.7284C3.49203 13.916 3.56913 14.1056 3.7231 14.2174C3.87706 14.3293 4.08119 14.3441 4.24966 14.2555L8.11188 12.2253C8.35631 12.0968 8.4503 11.7945 8.32181 11.5501C8.19333 11.3057 7.89102 11.2117 7.64659 11.3402L4.68114 12.899L5.2707 9.46284C5.29853 9.30065 5.24477 9.13514 5.12693 9.02027L2.63025 6.58626L6.08082 6.08555C6.24373 6.06191 6.38457 5.95959 6.45741 5.81196L8.00012 2.6852L9.54284 5.81196C9.61568 5.95959 9.75652 6.06191 9.91943 6.08555L13.37 6.58625L11.6235 8.2887C11.4258 8.48146 11.4218 8.79802 11.6145 8.99575C11.8073 9.19349 12.1239 9.19752 12.3216 9.00476L14.7936 6.59498C14.9299 6.46212 14.9789 6.2634 14.9201 6.08239C14.8612 5.90138 14.7047 5.76947 14.5164 5.74214L10.3233 5.13368L8.44852 1.33394ZM13.4744 11.9911L12.3517 11.6168L11.9775 10.4942C11.8557 10.1315 11.2557 10.1315 11.1339 10.4942L10.7597 11.6168L9.63702 11.9911C9.45569 12.0515 9.33302 12.2213 9.33302 12.4124C9.33302 12.6035 9.45569 12.7733 9.63702 12.8337L10.7597 13.2079L11.1339 14.3306C11.1944 14.5119 11.365 14.6346 11.5561 14.6346C11.7472 14.6346 11.917 14.5119 11.9784 14.3306L12.3526 13.2079L13.4752 12.8337C13.6566 12.7733 13.7792 12.6035 13.7792 12.4124C13.7792 12.2213 13.6566 12.0515 13.4752 11.9911H13.4744ZM13.3333 2.88883C13.3333 3.25702 13.0349 3.5555 12.6667 3.5555C12.2985 3.5555 12 3.25702 12 2.88883C12 2.52064 12.2985 2.22217 12.6667 2.22217C13.0349 2.22217 13.3333 2.52064 13.3333 2.88883Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -106,8 +106,6 @@ function convertToDeal(selections, unselectAll) {
|
||||
}
|
||||
|
||||
function deleteValues(selections, unselectAll) {
|
||||
unselectAllAction.value = unselectAll
|
||||
|
||||
const selectedDocs = Array.from(selections)
|
||||
if (selectedDocs.length == 1) {
|
||||
showDeleteDocModal.value = {
|
||||
@ -219,12 +217,6 @@ function bulkActions(selections, unselectAll) {
|
||||
}
|
||||
|
||||
function reload(unselectAll) {
|
||||
showDeleteDocModal.value = {
|
||||
showLinkedDocsModal: false,
|
||||
showDeleteModal: false,
|
||||
docname: null,
|
||||
}
|
||||
|
||||
unselectAllAction.value?.()
|
||||
unselectAll?.()
|
||||
list.value?.reload()
|
||||
|
||||
@ -26,14 +26,13 @@
|
||||
<ListRowItem
|
||||
:item="item"
|
||||
@click="listViewRef.toggleRow(row['reference_docname'])"
|
||||
class="!w-full"
|
||||
>
|
||||
<template #default="{ label }">
|
||||
<div
|
||||
v-if="column.key === 'title'"
|
||||
class="truncate text-base flex gap-2 w-full"
|
||||
class="truncate text-base flex gap-2"
|
||||
>
|
||||
<span class="max-w-[90%] truncate">
|
||||
<span>
|
||||
{{ label }}
|
||||
</span>
|
||||
<FeatherIcon
|
||||
@ -103,7 +102,6 @@ const listViewRef = ref(null)
|
||||
const viewLinkedDoc = (doc) => {
|
||||
let page = ''
|
||||
let id = ''
|
||||
let openDesk = false
|
||||
switch (doc.reference_doctype) {
|
||||
case 'CRM Lead':
|
||||
page = 'leads'
|
||||
@ -125,11 +123,6 @@ const viewLinkedDoc = (doc) => {
|
||||
page = 'organizations'
|
||||
id = doc.reference_docname
|
||||
break
|
||||
case 'CRM Notification':
|
||||
page = 'crm-notification'
|
||||
id = doc.reference_docname
|
||||
openDesk = true
|
||||
break
|
||||
case 'FCRM Note':
|
||||
page = 'notes'
|
||||
id = `view?open=${doc.reference_docname}`
|
||||
@ -137,11 +130,7 @@ const viewLinkedDoc = (doc) => {
|
||||
default:
|
||||
break
|
||||
}
|
||||
let base = '/crm'
|
||||
if (openDesk) {
|
||||
base = '/app'
|
||||
}
|
||||
window.open(`${base}/${page}/${id}`)
|
||||
window.open(`/crm/${page}/${id}`)
|
||||
}
|
||||
|
||||
const getDoctypeName = (doctype) => {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Convert'),
|
||||
variant: 'solid',
|
||||
onClick: convertToDeal,
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-header>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
@ -78,11 +90,6 @@
|
||||
/>
|
||||
<ErrorMessage class="mt-4" :message="error" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex justify-end">
|
||||
<Button :label="__('Convert')" variant="solid" @click="convertToDeal" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@ -108,7 +108,6 @@ async function createOrganization() {
|
||||
onError: (err) => {
|
||||
if (err.error.exc_type == 'ValidationError') {
|
||||
error.value = err.error?.messages?.[0]
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
},
|
||||
@ -175,5 +174,6 @@ function openAddressModal(_address) {
|
||||
doctype: 'Address',
|
||||
address: _address,
|
||||
}
|
||||
nextTick(() => (show.value = false))
|
||||
}
|
||||
</script>
|
||||
@ -47,7 +47,7 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
|
||||
<Button :label="_task.status">
|
||||
<Button :label="_task.status" class="justify-between w-full">
|
||||
<template #prefix>
|
||||
<TaskStatusIcon :status="_task.status" />
|
||||
</template>
|
||||
@ -88,7 +88,7 @@
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
|
||||
<Button :label="_task.priority">
|
||||
<Button :label="_task.priority" class="justify-between w-full">
|
||||
<template #prefix>
|
||||
<TaskPriorityIcon :priority="_task.priority" />
|
||||
</template>
|
||||
|
||||
@ -42,6 +42,7 @@ const showDropdown = ref(props.options?.length > 1)
|
||||
const activeButton = ref(props.options?.[0] || {})
|
||||
|
||||
const parsedOptions = computed(() => {
|
||||
debugger
|
||||
return (
|
||||
props.options?.map((option) => {
|
||||
return {
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<Popover>
|
||||
<template #target="{ isOpen, togglePopover }">
|
||||
<Button
|
||||
:label="value"
|
||||
class="dropdown-button flex items-center justify-between bg-surface-white !px-2.5 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:bg-surface-white focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0"
|
||||
@click="togglePopover"
|
||||
>
|
||||
<div v-if="value" class="truncate">{{ value }}</div>
|
||||
<div v-else class="text-base leading-5 text-ink-gray-4 truncate">
|
||||
{{ placeholder }}
|
||||
</div>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="isOpen ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4 text-ink-gray-5"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="my-2 p-1.5 min-w-40 space-y-1.5 divide-y divide-outline-gray-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div>
|
||||
<PrimaryDropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.name || option.value"
|
||||
:option="option"
|
||||
/>
|
||||
<div v-if="!options?.length">
|
||||
<div class="p-1.5 pl-3 pr-4 text-base text-ink-gray-4">
|
||||
{{ __('No {0} Available', [label]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
iconLeft="plus"
|
||||
@click="create && create()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PrimaryDropdownItem from '@/components/PrimaryDropdownItem.vue'
|
||||
import { Popover } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [String, Number], default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
options: { type: Array, default: [] },
|
||||
create: { type: Function },
|
||||
label: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-button {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@ -21,10 +21,13 @@
|
||||
<div v-else>{{ s.value }}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Dropdown v-if="s.type == 'Select'" :options="s.options">
|
||||
<Dropdown
|
||||
class="form-control"
|
||||
v-if="s.type == 'Select'"
|
||||
:options="s.options"
|
||||
>
|
||||
<template #default="{ open }">
|
||||
<Button
|
||||
class="form-control bg-surface-white hover:bg-surface-white"
|
||||
:label="s.value"
|
||||
:iconRight="open ? 'chevron-up' : 'chevron-down'"
|
||||
/>
|
||||
|
||||
@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__('Assignee Rules')
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__('Choose how {0} are assigned among salespeople.', [documentType])
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-8 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
__('{0} Routing', [
|
||||
assignmentRuleData.documentType == 'CRM Lead'
|
||||
? __('Lead')
|
||||
: __('Deal'),
|
||||
])
|
||||
}}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-6 mt-1">
|
||||
{{
|
||||
__('Choose how {0} are assigned among the selected assignees.', [
|
||||
documentType,
|
||||
])
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-outline-gray-2 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-40"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
documentRoutingOptions.find(
|
||||
(option) => option.value == assignmentRuleData.rule,
|
||||
)?.label
|
||||
}}
|
||||
</div>
|
||||
<FeatherIcon name="chevron-down" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-7 mt-1 w-48 bg-white shadow-xl rounded"
|
||||
>
|
||||
<div
|
||||
v-for="option in documentRoutingOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-gray-50 text-sm flex items-center justify-between rounded"
|
||||
@click="
|
||||
() => {
|
||||
assignmentRuleData.rule = option.value
|
||||
togglePopover()
|
||||
}
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<FeatherIcon
|
||||
v-if="assignmentRuleData.rule == option.value"
|
||||
name="check"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-7 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ __('Assignees') }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-6 mt-1">
|
||||
{{ __('Select the assignees for {0}.', [documentType]) }}
|
||||
</div>
|
||||
</div>
|
||||
<AssigneeSearch @addAssignee="validateAssignmentRule('users')" />
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.name"
|
||||
class="flex items-center gap-2 text-sm bg-surface-gray-2 rounded-md p-1 w-max px-2 select-none"
|
||||
>
|
||||
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
|
||||
<div class="text-ink-gray-7">
|
||||
{{ user.full_name }}
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="user.email == assignmentRuleData.lastUser"
|
||||
:text="__('Last user assigned by this rule')"
|
||||
:hover-delay="0.35"
|
||||
:placement="'top'"
|
||||
>
|
||||
<div
|
||||
class="text-xs rounded-full select-none bg-blue-600 text-white p-0.5 px-2"
|
||||
>
|
||||
{{ __('Last') }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button variant="ghost" icon="x" @click="removeAssignedUser(user)" />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage :message="assignmentRuleErrors.users" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar, Button, ErrorMessage, Popover, Tooltip } from 'frappe-ui'
|
||||
import AssigneeSearch from './AssigneeSearch.vue'
|
||||
import { computed, inject } from 'vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
|
||||
const { getUser } = usersStore()
|
||||
const assignmentRuleData = inject('assignmentRuleData')
|
||||
const assignmentRuleErrors = inject('assignmentRuleErrors')
|
||||
const validateAssignmentRule = inject('validateAssignmentRule')
|
||||
const documentType = computed(() =>
|
||||
assignmentRuleData.value.documentType == 'CRM Lead'
|
||||
? __('leads')
|
||||
: __('deals'),
|
||||
)
|
||||
|
||||
const documentRoutingOptions = [
|
||||
{
|
||||
label: 'Auto-rotate',
|
||||
value: 'Round Robin',
|
||||
},
|
||||
{
|
||||
label: 'Assign by workload',
|
||||
value: 'Load Balancing',
|
||||
},
|
||||
]
|
||||
|
||||
const removeAssignedUser = (user) => {
|
||||
assignmentRuleData.value.users = assignmentRuleData.value.users.filter(
|
||||
(u) => u.user !== user.name,
|
||||
)
|
||||
validateAssignmentRule('users')
|
||||
}
|
||||
|
||||
const users = computed(() => {
|
||||
const _users = []
|
||||
assignmentRuleData.value.users.forEach((user) => {
|
||||
_users.push(getUser(user.user))
|
||||
})
|
||||
return _users
|
||||
})
|
||||
</script>
|
||||
@ -1,166 +0,0 @@
|
||||
<template>
|
||||
<Combobox :multiple="true">
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
variant="subtle"
|
||||
icon-left="plus"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Assignee')"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl w-60">
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="(e) => debouncedQuery(e.target.value)"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
:placeholder="__('Search')"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="query = ''"
|
||||
>
|
||||
<FeatherIcon name="x" class="w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions class="my-2 max-h-64 overflow-y-auto px-1.5" static>
|
||||
<ComboboxOption
|
||||
v-show="usersList.length > 0"
|
||||
v-for="user in usersList"
|
||||
:key="user.username"
|
||||
:value="user"
|
||||
as="template"
|
||||
v-slot="{ active }"
|
||||
@click="
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
addAssignee(user)
|
||||
}
|
||||
"
|
||||
>
|
||||
<li
|
||||
class="flex items-center rounded p-1.5 w-full text-base"
|
||||
:class="{ 'bg-gray-100': active }"
|
||||
>
|
||||
<div class="flex gap-2 items-center w-full select-none">
|
||||
<Avatar
|
||||
:shape="'circle'"
|
||||
:image="user.user_image"
|
||||
:label="user.full_name"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-semibold text-ink-gray-7">
|
||||
{{ user.full_name }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<li
|
||||
v-if="usersList.length == 0"
|
||||
class="mt-1.5 rounded-md p-1.5 text-base text-gray-600"
|
||||
>
|
||||
{{ __('No results found') }}
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div class="border-t p-1.5 pb-0.5 *:w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="plus"
|
||||
class="w-full"
|
||||
:label="__('Invite agent')"
|
||||
@click="
|
||||
() => {
|
||||
inviteAgent()
|
||||
togglePopover()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from '@headlessui/vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { Avatar, Popover } from 'frappe-ui'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { activeSettingsPage } from '@/composables/settings'
|
||||
|
||||
const emit = defineEmits(['addAssignee'])
|
||||
const query = ref('')
|
||||
const { users } = usersStore()
|
||||
const { $dialog } = globalStore()
|
||||
const assignmentRuleData = inject('assignmentRuleData')
|
||||
|
||||
const debouncedQuery = useDebounceFn((val) => {
|
||||
query.value = val
|
||||
}, 300)
|
||||
|
||||
const usersList = computed(() => {
|
||||
let filteredUsers =
|
||||
users.data?.crmUsers?.filter((user) => user.name !== 'Administrator') || []
|
||||
|
||||
return filteredUsers
|
||||
.filter(
|
||||
(user) =>
|
||||
user.name?.includes(query.value) ||
|
||||
user.full_name?.includes(query.value),
|
||||
)
|
||||
.filter((user) => {
|
||||
return !assignmentRuleData.value.users.some((u) => u.user === user.email)
|
||||
})
|
||||
})
|
||||
|
||||
const addAssignee = (user) => {
|
||||
const userExists = assignmentRuleData.value.users.some(
|
||||
(u) => u.user === user.user,
|
||||
)
|
||||
if (!userExists) {
|
||||
assignmentRuleData.value.users.push({
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
user_image: user.user_image,
|
||||
user: user.email,
|
||||
})
|
||||
emit('addAssignee', user)
|
||||
}
|
||||
}
|
||||
|
||||
const inviteAgent = () => {
|
||||
$dialog({
|
||||
title: __('Invite agent'),
|
||||
message: __(
|
||||
'You will be redirected to invite user page, unsaved changes will be lost.',
|
||||
),
|
||||
variant: 'solid',
|
||||
actions: [
|
||||
{
|
||||
label: __('Go to invite page'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
activeSettingsPage.value = 'Invite User'
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -1,193 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex p-3 items-center justify-between cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
>
|
||||
<div class="w-7/12" @click="updateStep('view', data)">
|
||||
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
|
||||
<div
|
||||
v-if="data.description && data.description.length > 0"
|
||||
class="text-p-base w-full text-ink-gray-5 mt-0.5 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||
>
|
||||
{{ data.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-3/12">
|
||||
<Select
|
||||
class="w-max -ml-2 bg-transparent border-0 text-ink-gray-6 focus-visible:!ring-0 bg-none"
|
||||
:options="priorityOptions"
|
||||
v-model="data.priority"
|
||||
@update:modelValue="onPriorityChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-2/12">
|
||||
<Switch
|
||||
size="sm"
|
||||
:modelValue="!data.disabled"
|
||||
@update:modelValue="onToggle"
|
||||
/>
|
||||
<Dropdown placement="right" :options="dropdownOptions">
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
:options="{ title: __('Duplicate Assignment Rule') }"
|
||||
v-model="duplicateDialog.show"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
:label="__('New Assignment Rule Name')"
|
||||
type="text"
|
||||
v-model="duplicateDialog.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
:label="__('Close')"
|
||||
@click="duplicateDialog.show = false"
|
||||
/>
|
||||
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Select,
|
||||
Switch,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { inject, ref } from 'vue'
|
||||
|
||||
const assignmentRulesList = inject('assignmentRulesList')
|
||||
const updateStep = inject('updateStep')
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: 'Low', value: '0' },
|
||||
{ label: 'Low-Medium', value: '1' },
|
||||
{ label: 'Medium', value: '2' },
|
||||
{ label: 'Medium-High', value: '3' },
|
||||
{ label: 'High', value: '4' },
|
||||
]
|
||||
|
||||
const duplicateDialog = ref({
|
||||
show: false,
|
||||
name: '',
|
||||
})
|
||||
|
||||
const isConfirmingDelete = ref(false)
|
||||
|
||||
const deleteAssignmentRule = () => {
|
||||
createResource({
|
||||
url: 'frappe.client.delete',
|
||||
params: {
|
||||
doctype: 'Assignment Rule',
|
||||
name: props.data.name,
|
||||
},
|
||||
onSuccess: () => {
|
||||
assignmentRulesList.reload()
|
||||
isConfirmingDelete.value = false
|
||||
toast.success(__('Assignment rule deleted'))
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
}
|
||||
|
||||
const dropdownOptions = [
|
||||
{
|
||||
label: __('Duplicate'),
|
||||
onClick: () => {
|
||||
duplicateDialog.value = {
|
||||
show: true,
|
||||
name: props.data.name + ' (Copy)',
|
||||
}
|
||||
},
|
||||
icon: 'copy',
|
||||
},
|
||||
{
|
||||
label: __('Delete'),
|
||||
icon: 'trash-2',
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
isConfirmingDelete.value = true
|
||||
},
|
||||
condition: () => !isConfirmingDelete.value,
|
||||
},
|
||||
{
|
||||
label: __('Confirm Delete'),
|
||||
icon: 'trash-2',
|
||||
theme: 'red',
|
||||
onClick: () => deleteAssignmentRule(),
|
||||
condition: () => isConfirmingDelete.value,
|
||||
},
|
||||
]
|
||||
|
||||
const duplicate = () => {
|
||||
createResource({
|
||||
url: 'crm.api.assignment_rule.duplicate_assignment_rule',
|
||||
params: {
|
||||
docname: props.data.name,
|
||||
new_name: duplicateDialog.value.name,
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
assignmentRulesList.reload()
|
||||
toast.success(__('Assignment rule duplicated'))
|
||||
duplicateDialog.value.show = false
|
||||
duplicateDialog.value.name = ''
|
||||
updateStep('view', data)
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
}
|
||||
|
||||
const onPriorityChange = () => {
|
||||
setAssignmentRuleValue('priority', props.data.priority)
|
||||
}
|
||||
|
||||
const onToggle = () => {
|
||||
if (!props.data.users_exists && props.data.disabled) {
|
||||
toast.error(__('Cannot enable rule without adding users in it'))
|
||||
return
|
||||
}
|
||||
setAssignmentRuleValue('disabled', !props.data.disabled, 'status')
|
||||
}
|
||||
|
||||
const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
|
||||
createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
params: {
|
||||
doctype: 'Assignment Rule',
|
||||
name: props.data.name,
|
||||
fieldname: key,
|
||||
value: value,
|
||||
},
|
||||
onSuccess: () => {
|
||||
assignmentRulesList.reload()
|
||||
toast.success(__('Assignment rule {0} updated', [fieldName || key]))
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<AssignmentRules v-if="step.screen === 'list'" />
|
||||
<AssignmentRuleView v-else-if="step.screen === 'view'" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, provide } from 'vue'
|
||||
import AssignmentRules from './AssignmentRules.vue'
|
||||
import AssignmentRuleView from './AssignmentRuleView.vue'
|
||||
|
||||
const step = ref({ screen: 'list', data: null })
|
||||
|
||||
provide('step', step)
|
||||
provide('updateStep', updateStep)
|
||||
|
||||
function updateStep(newStep, data) {
|
||||
step.value = { screen: newStep, data }
|
||||
}
|
||||
</script>
|
||||
@ -1,782 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!getAssignmentRuleData.loading"
|
||||
class="flex flex-col h-full gap-6 px-6 py-8 text-ink-gray-8"
|
||||
>
|
||||
<div class="flex items-center justify-between px-2 w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="
|
||||
assignmentRuleData.assignmentRuleName || __('New Assignment Rule')
|
||||
"
|
||||
size="md"
|
||||
@click="goBack()"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
|
||||
/>
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
v-if="isDirty"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2"
|
||||
@click="assignmentRuleData.disabled = !assignmentRuleData.disabled"
|
||||
>
|
||||
<Switch size="sm" :model-value="!assignmentRuleData.disabled" />
|
||||
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
:disabled="Boolean(!isDirty && step.data)"
|
||||
:label="__('Save')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="saveAssignmentRule()"
|
||||
:loading="isLoading || getAssignmentRuleData.loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto px-2">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'text'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Name')"
|
||||
:label="__('Name')"
|
||||
v-model="assignmentRuleData.assignmentRuleName"
|
||||
required
|
||||
maxlength="50"
|
||||
@change="validateAssignmentRule('assignmentRuleName')"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRuleErrors.assignmentRuleName"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel :label="__('Priority')" />
|
||||
<Popover>
|
||||
<template #target="{ togglePopover }">
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-outline-gray-2 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
priorityOptions.find(
|
||||
(option) => option.value == assignmentRuleData.priority,
|
||||
)?.label
|
||||
}}
|
||||
</div>
|
||||
<FeatherIcon name="chevron-down" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-6 top-1 absolute w-full bg-white shadow-2xl rounded"
|
||||
>
|
||||
<div
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
|
||||
@click="
|
||||
() => {
|
||||
assignmentRuleData.priority = option.value
|
||||
togglePopover()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ option.label }}
|
||||
<FeatherIcon
|
||||
v-if="assignmentRuleData.priority == option.value"
|
||||
name="check"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'textarea'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Description')"
|
||||
:label="__('Description')"
|
||||
required
|
||||
maxlength="250"
|
||||
@change="validateAssignmentRule('description')"
|
||||
v-model="assignmentRuleData.description"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRuleErrors.description"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel :label="__('Apply on')" />
|
||||
<Select
|
||||
:options="[
|
||||
{
|
||||
label: 'Lead',
|
||||
value: 'CRM Lead',
|
||||
},
|
||||
{
|
||||
label: 'Deal',
|
||||
value: 'CRM Deal',
|
||||
},
|
||||
]"
|
||||
v-model="assignmentRuleData.documentType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__('Assignment condition')
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__('Choose which {0} are affected by this assignment rule.', [
|
||||
documentType,
|
||||
])
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/crm/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __('Learn about conditions') }}</a
|
||||
>
|
||||
</span>
|
||||
<div v-if="isOldSla && step.data">
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap items-center"
|
||||
>
|
||||
<span>{{ __('Old Condition') }}</span>
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.assignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
|
||||
v-if="!useNewUI && assignmentRuleData.assignCondition"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
{{ __('Conditions for this rule were created from') }}
|
||||
<a :href="deskUrl" target="_blank" class="underline">{{
|
||||
__('desk')
|
||||
}}</a>
|
||||
{{
|
||||
__(
|
||||
'which are not compatible with this UI, you will need to recreate the conditions here if you want to manage and add new conditions from this UI.',
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
:label="__('I understand, add conditions')"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUI = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.assignConditionJson"
|
||||
name="assignCondition"
|
||||
:errors="assignmentRuleErrors.assignConditionError"
|
||||
:doctype="assignmentRuleData.documentType"
|
||||
v-else
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<ErrorMessage
|
||||
:message="assignmentRuleErrors.assignCondition"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__('Unassignment condition')
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Choose which {0} are affected by this un-assignment rule.',
|
||||
[documentType],
|
||||
)
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/crm/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __('Learn about conditions') }}</a
|
||||
>
|
||||
</span>
|
||||
<div
|
||||
v-if="
|
||||
isOldSla && step.data && assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap items-center"
|
||||
>
|
||||
<span> {{ __('Old Condition') }} </span>
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.unassignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
v-if="!useNewUI && assignmentRuleData.unassignCondition"
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
{{ __('Conditions for this rule were created from') }}
|
||||
<a :href="deskUrl" target="_blank" class="underline">
|
||||
{{ __('desk') }}
|
||||
</a>
|
||||
{{
|
||||
__(
|
||||
'which are not compatible with this UI, you will need to recreate the conditions here if you want to manage and add new conditions from this UI.',
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
:label="__('I understand, add conditions')"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUI = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
v-else
|
||||
:conditions="assignmentRuleData.unassignConditionJson"
|
||||
name="unassignCondition"
|
||||
:errors="assignmentRuleErrors.unassignConditionError"
|
||||
:doctype="assignmentRuleData.documentType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__('Assignment Schedule')
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__('Choose the days of the week when this rule should be active.')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<AssignmentSchedule />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<AssigneeRules />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center h-full justify-center">
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
:message="showConfirmDialog.message"
|
||||
:onConfirm="showConfirmDialog.onConfirm"
|
||||
:onCancel="() => (showConfirmDialog.show = false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
ErrorMessage,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
LoadingIndicator,
|
||||
Popover,
|
||||
Select,
|
||||
Switch,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
inject,
|
||||
watch,
|
||||
provide,
|
||||
computed,
|
||||
} from 'vue'
|
||||
import AssignmentRulesSection from './AssignmentRulesSection.vue'
|
||||
import AssignmentSchedule from './AssignmentSchedule.vue'
|
||||
import AssigneeRules from './AssigneeRules.vue'
|
||||
import ConfirmDialog from 'frappe-ui/src/components/ConfirmDialog.vue'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { disableSettingModalOutsideClick } from '@/composables/settings'
|
||||
import { convertToConditions, validateConditions } from '@/utils'
|
||||
|
||||
const isDirty = ref(false)
|
||||
const initialData = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const updateStep = inject('updateStep')
|
||||
const step = inject('step')
|
||||
const { $dialog } = globalStore()
|
||||
|
||||
const showConfirmDialog = ref({
|
||||
show: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
})
|
||||
const useNewUI = ref(true)
|
||||
const isOldSla = ref(false)
|
||||
const documentType = computed(() =>
|
||||
assignmentRuleData.value.documentType == 'CRM Lead'
|
||||
? __('leads')
|
||||
: __('deals'),
|
||||
)
|
||||
const deskUrl = `${window.location.origin}/app/assignment-rule/${step.value.data?.name}`
|
||||
|
||||
const defaultAssignmentDays = [
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
'Sunday',
|
||||
]
|
||||
|
||||
const assignmentRuleData = ref({
|
||||
assignCondition: '',
|
||||
unassignCondition: '',
|
||||
assignConditionJson: [],
|
||||
unassignConditionJson: [],
|
||||
rule: 'Round Robin',
|
||||
priority: 1,
|
||||
users: [],
|
||||
disabled: false,
|
||||
description: '',
|
||||
name: '',
|
||||
assignmentRuleName: '',
|
||||
assignmentDays: defaultAssignmentDays,
|
||||
documentType: 'CRM Lead',
|
||||
})
|
||||
|
||||
const validateAssignmentRule = (key, skipConditionCheck = false) => {
|
||||
const validateField = (field) => {
|
||||
if (key && field !== key) return
|
||||
|
||||
switch (field) {
|
||||
case 'assignmentRuleName':
|
||||
if (assignmentRuleData.value.assignmentRuleName?.length == 0) {
|
||||
assignmentRuleErrors.value.assignmentRuleName = __('Name is required')
|
||||
} else {
|
||||
assignmentRuleErrors.value.assignmentRuleName = ''
|
||||
}
|
||||
break
|
||||
case 'description':
|
||||
assignmentRuleErrors.value.description =
|
||||
assignmentRuleData.value.description?.length > 0
|
||||
? ''
|
||||
: __('Description is required')
|
||||
break
|
||||
case 'assignCondition':
|
||||
if (skipConditionCheck) {
|
||||
break
|
||||
}
|
||||
assignmentRuleErrors.value.assignCondition =
|
||||
assignmentRuleData.value.assignConditionJson?.length > 0
|
||||
? ''
|
||||
: __('Assign condition is required')
|
||||
|
||||
if (!validateConditions(assignmentRuleData.value.assignConditionJson)) {
|
||||
assignmentRuleErrors.value.assignConditionError = __(
|
||||
'Assign conditions are invalid',
|
||||
)
|
||||
} else {
|
||||
assignmentRuleErrors.value.assignConditionError = ''
|
||||
}
|
||||
|
||||
break
|
||||
case 'unassignCondition':
|
||||
if (skipConditionCheck) {
|
||||
break
|
||||
}
|
||||
if (
|
||||
assignmentRuleData.value.unassignConditionJson?.length > 0 &&
|
||||
!validateConditions(assignmentRuleData.value.unassignConditionJson)
|
||||
) {
|
||||
assignmentRuleErrors.value.unassignConditionError = __(
|
||||
'Unassign conditions are invalid',
|
||||
)
|
||||
} else {
|
||||
assignmentRuleErrors.value.unassignConditionError = ''
|
||||
}
|
||||
break
|
||||
case 'users':
|
||||
assignmentRuleErrors.value.users =
|
||||
assignmentRuleData.value.users?.length > 0
|
||||
? ''
|
||||
: __('Users are required')
|
||||
break
|
||||
case 'assignmentDays':
|
||||
assignmentRuleErrors.value.assignmentDays =
|
||||
assignmentRuleData.value.assignmentDays?.length > 0
|
||||
? ''
|
||||
: __('Assignment days are required')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (key) {
|
||||
validateField(key)
|
||||
} else {
|
||||
Object.keys(assignmentRuleErrors.value).forEach(validateField)
|
||||
}
|
||||
|
||||
return assignmentRuleErrors.value
|
||||
}
|
||||
|
||||
const resetAssignmentRuleData = () => {
|
||||
assignmentRuleData.value = {
|
||||
assignCondition: '',
|
||||
unassignCondition: '',
|
||||
assignConditionJson: [],
|
||||
unassignConditionJson: [],
|
||||
rule: 'Round Robin',
|
||||
priority: 1,
|
||||
users: [],
|
||||
disabled: false,
|
||||
description: '',
|
||||
name: '',
|
||||
assignmentRuleName: '',
|
||||
assignmentDays: defaultAssignmentDays,
|
||||
documentType: 'CRM Lead',
|
||||
}
|
||||
}
|
||||
|
||||
const assignmentRuleErrors = ref({
|
||||
assignmentRuleName: '',
|
||||
assignCondition: '',
|
||||
assignConditionError: '',
|
||||
unassignConditionError: '',
|
||||
users: '',
|
||||
description: '',
|
||||
assignmentDays: '',
|
||||
})
|
||||
|
||||
const resetAssignmentRuleErrors = () => {
|
||||
Object.keys(assignmentRuleErrors.value).forEach((key) => {
|
||||
assignmentRuleErrors.value[key] = ''
|
||||
})
|
||||
}
|
||||
|
||||
provide('assignmentRuleData', assignmentRuleData)
|
||||
provide('assignmentRuleErrors', assignmentRuleErrors)
|
||||
provide('validateAssignmentRule', validateAssignmentRule)
|
||||
provide('resetAssignmentRuleData', resetAssignmentRuleData)
|
||||
provide('resetAssignmentRuleErrors', resetAssignmentRuleErrors)
|
||||
|
||||
const getAssignmentRuleData = createResource({
|
||||
url: 'frappe.client.get',
|
||||
params: {
|
||||
doctype: 'Assignment Rule',
|
||||
name: step.value.data?.name,
|
||||
},
|
||||
auto: Boolean(step.value.data),
|
||||
onSuccess(data) {
|
||||
assignmentRuleData.value = {
|
||||
assignCondition: data.assign_condition,
|
||||
unassignCondition: data.unassign_condition,
|
||||
assignConditionJson: JSON.parse(data.assign_condition_json || '[]'),
|
||||
unassignConditionJson: JSON.parse(data.unassign_condition_json || '[]'),
|
||||
rule: data.rule,
|
||||
priority: data.priority,
|
||||
users: data.users,
|
||||
disabled: data.disabled,
|
||||
description: data.description,
|
||||
name: data.name,
|
||||
assignmentRuleName: data.name,
|
||||
assignmentDays: data.assignment_days.map((day) => day.day),
|
||||
documentType: data.document_type,
|
||||
}
|
||||
|
||||
initialData.value = JSON.stringify(assignmentRuleData.value)
|
||||
|
||||
const conditionsAvailable =
|
||||
assignmentRuleData.value.assignCondition?.length > 0
|
||||
const conditionsJsonAvailable =
|
||||
assignmentRuleData.value.assignConditionJson?.length > 0
|
||||
|
||||
if (conditionsAvailable && !conditionsJsonAvailable) {
|
||||
useNewUI.value = false
|
||||
isOldSla.value = true
|
||||
} else {
|
||||
useNewUI.value = true
|
||||
isOldSla.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (!step.value.data) {
|
||||
initialData.value = JSON.stringify(assignmentRuleData.value)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
if (isDirty.value && !showConfirmDialog.value.show) {
|
||||
$dialog({
|
||||
title: __('Unsaved changes'),
|
||||
message: __(
|
||||
'Are you sure you want to go back? Unsaved changes will be lost.',
|
||||
),
|
||||
variant: 'solid',
|
||||
actions: [
|
||||
{
|
||||
label: __('Go back'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
updateStep('list', null)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
return
|
||||
}
|
||||
updateStep('list', null)
|
||||
showConfirmDialog.value.show = false
|
||||
}
|
||||
|
||||
const saveAssignmentRule = () => {
|
||||
const validationErrors = validateAssignmentRule(undefined, !useNewUI.value)
|
||||
if (Object.values(validationErrors).some((error) => error)) {
|
||||
toast.error(
|
||||
__('Invalid fields, check if all are filled in and values are correct.'),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (step.value.data) {
|
||||
if (isOldSla.value && useNewUI.value) {
|
||||
showConfirmDialog.value = {
|
||||
show: true,
|
||||
title: __('Confirm overwrite'),
|
||||
message: __(
|
||||
'Your old condition will be overwritten. Are you sure you want to save?',
|
||||
),
|
||||
onConfirm: () => {
|
||||
updateAssignmentRule()
|
||||
showConfirmDialog.value.show = false
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
updateAssignmentRule()
|
||||
} else {
|
||||
createAssignmentRule()
|
||||
}
|
||||
}
|
||||
|
||||
const createAssignmentRule = () => {
|
||||
isLoading.value = true
|
||||
createResource({
|
||||
url: 'frappe.client.insert',
|
||||
params: {
|
||||
doc: {
|
||||
doctype: 'Assignment Rule',
|
||||
document_type: assignmentRuleData.value.documentType,
|
||||
rule: assignmentRuleData.value.rule,
|
||||
priority: assignmentRuleData.value.priority,
|
||||
users: assignmentRuleData.value.users,
|
||||
disabled: assignmentRuleData.value.disabled,
|
||||
description: assignmentRuleData.value.description,
|
||||
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
|
||||
day: day,
|
||||
})),
|
||||
name: assignmentRuleData.value.assignmentRuleName,
|
||||
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
|
||||
assign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.assignConditionJson,
|
||||
}),
|
||||
unassign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.unassignConditionJson,
|
||||
}),
|
||||
assign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.assignConditionJson,
|
||||
),
|
||||
unassign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.unassignConditionJson,
|
||||
),
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
getAssignmentRuleData
|
||||
.submit({
|
||||
doctype: 'Assignment Rule',
|
||||
name: data.name,
|
||||
})
|
||||
.then(() => {
|
||||
isLoading.value = false
|
||||
toast.success(__('Assignment rule created'))
|
||||
})
|
||||
updateStep('view', data)
|
||||
},
|
||||
onError: () => {
|
||||
isLoading.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: 'Low', value: '0' },
|
||||
{ label: 'Low-Medium', value: '1' },
|
||||
{ label: 'Medium', value: '2' },
|
||||
{ label: 'Medium-High', value: '3' },
|
||||
{ label: 'High', value: '4' },
|
||||
]
|
||||
|
||||
const updateAssignmentRule = async () => {
|
||||
isLoading.value = true
|
||||
await call('frappe.client.set_value', {
|
||||
doctype: 'Assignment Rule',
|
||||
name: assignmentRuleData.value.name,
|
||||
fieldname: {
|
||||
rule: assignmentRuleData.value.rule,
|
||||
priority: assignmentRuleData.value.priority,
|
||||
users: assignmentRuleData.value.users,
|
||||
disabled: assignmentRuleData.value.disabled,
|
||||
description: assignmentRuleData.value.description,
|
||||
document_type: assignmentRuleData.value.documentType,
|
||||
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
|
||||
day: day,
|
||||
})),
|
||||
assign_condition: useNewUI.value
|
||||
? convertToConditions({
|
||||
conditions: assignmentRuleData.value.assignConditionJson,
|
||||
})
|
||||
: assignmentRuleData.value.assignCondition,
|
||||
unassign_condition: useNewUI.value
|
||||
? convertToConditions({
|
||||
conditions: assignmentRuleData.value.unassignConditionJson,
|
||||
})
|
||||
: assignmentRuleData.value.unassignCondition,
|
||||
assign_condition_json: useNewUI.value
|
||||
? JSON.stringify(assignmentRuleData.value.assignConditionJson)
|
||||
: null,
|
||||
unassign_condition_json: useNewUI.value
|
||||
? JSON.stringify(assignmentRuleData.value.unassignConditionJson)
|
||||
: null,
|
||||
},
|
||||
}).catch((er) => {
|
||||
const error =
|
||||
er?.messages?.[0] ||
|
||||
__('Some error occurred while updating assignment rule')
|
||||
toast.error(error)
|
||||
isLoading.value = false
|
||||
})
|
||||
if (
|
||||
assignmentRuleData.value.name !==
|
||||
assignmentRuleData.value.assignmentRuleName
|
||||
) {
|
||||
await call('frappe.client.rename_doc', {
|
||||
doctype: 'Assignment Rule',
|
||||
old_name: assignmentRuleData.value.name,
|
||||
new_name: assignmentRuleData.value.assignmentRuleName,
|
||||
}).catch(async (er) => {
|
||||
const error =
|
||||
er?.messages?.[0] ||
|
||||
__('Some error occurred while renaming assignment rule')
|
||||
toast.error(error)
|
||||
// Reset assignment rule to previous state
|
||||
await getAssignmentRuleData.reload()
|
||||
isLoading.value = false
|
||||
})
|
||||
await getAssignmentRuleData.submit({
|
||||
doctype: 'Assignment Rule',
|
||||
name: assignmentRuleData.value.assignmentRuleName,
|
||||
})
|
||||
} else {
|
||||
getAssignmentRuleData.reload()
|
||||
}
|
||||
isLoading.value = false
|
||||
toast.success(__('Assignment rule updated'))
|
||||
}
|
||||
|
||||
watch(
|
||||
assignmentRuleData,
|
||||
(newVal) => {
|
||||
if (!initialData.value) return
|
||||
isDirty.value = JSON.stringify(newVal) != initialData.value
|
||||
if (isDirty.value) {
|
||||
disableSettingModalOutsideClick.value = true
|
||||
} else {
|
||||
disableSettingModalOutsideClick.value = false
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const beforeUnloadHandler = (event) => {
|
||||
if (!isDirty.value) return
|
||||
event.preventDefault()
|
||||
event.returnValue = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resetAssignmentRuleErrors()
|
||||
resetAssignmentRuleData()
|
||||
removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
disableSettingModalOutsideClick.value = false
|
||||
})
|
||||
</script>
|
||||
@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between px-2 pt-2">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Assignment rules') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Assignment rules automatically assign lead/deal to the right sales user based on predefined conditions',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('New')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
@click="goToNew()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment rules list -->
|
||||
<div class="overflow-y-auto">
|
||||
<AssignmentRulesList />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AssignmentRulesList from './AssignmentRulesList.vue'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { inject, provide } from 'vue'
|
||||
|
||||
const updateStep = inject('updateStep')
|
||||
|
||||
const assignmentRulesListData = createResource({
|
||||
url: 'crm.api.assignment_rule.get_assignment_rules_list',
|
||||
cache: ['assignmentRules', 'get_assignment_rules_list'],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
provide('assignmentRulesList', assignmentRulesListData)
|
||||
|
||||
const goToNew = () => {
|
||||
updateStep('view', null)
|
||||
}
|
||||
</script>
|
||||
@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="assignmentRulesList.loading && !assignmentRulesList.data"
|
||||
class="flex items-center justify-center mt-12"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="assignmentRulesList.data?.length === 0"
|
||||
class="flex items-center justify-center rounded-md border border-outline-gray-2 p-4"
|
||||
>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ __('No items in the list') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex items-center py-2 px-4 text-sm text-ink-gray-5">
|
||||
<div class="w-7/12">{{ __('Assignment rule') }}</div>
|
||||
<div class="w-3/12">{{ __('Priority') }}</div>
|
||||
<div class="w-2/12">{{ __('Enabled') }}</div>
|
||||
</div>
|
||||
<div class="h-px border-t mx-4 border-outline-gray-modals" />
|
||||
<div class="overflow-y-auto px-2">
|
||||
<template
|
||||
v-for="(assignmentRule, i) in assignmentRulesList.data"
|
||||
:key="assignmentRule.name"
|
||||
>
|
||||
<AssignmentRuleListItem :data="assignmentRule" />
|
||||
<hr v-if="assignmentRulesList.data.length !== i + 1" class="mx-2" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { LoadingIndicator } from 'frappe-ui'
|
||||
import { inject } from 'vue'
|
||||
import AssignmentRuleListItem from './AssignmentRuleListItem.vue'
|
||||
|
||||
const assignmentRulesList = inject('assignmentRulesList')
|
||||
</script>
|
||||
@ -1,96 +0,0 @@
|
||||
<template>
|
||||
<CFConditions
|
||||
v-if="props.conditions.length > 0"
|
||||
:conditions="props.conditions"
|
||||
:level="0"
|
||||
:disableAddCondition="props.errors !== ''"
|
||||
:doctype="props.doctype"
|
||||
/>
|
||||
<div
|
||||
v-if="props.conditions.length == 0"
|
||||
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-outline-gray-2 text-gray-600 rounded-md"
|
||||
@click="
|
||||
() => {
|
||||
props.conditions.push(['', '', ''])
|
||||
validateAssignmentRule(props.name)
|
||||
}
|
||||
"
|
||||
>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
{{ __('Add a condition') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div class="" v-if="props.conditions.length > 0">
|
||||
<Dropdown v-slot="{ open }" :options="dropdownOptions">
|
||||
<Button
|
||||
:disabled="props.errors !== ''"
|
||||
:icon-right="open ? 'chevron-up' : 'chevron-down'"
|
||||
:label="__('Add condition')"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<ErrorMessage v-if="props.conditions.length > 0" :message="props.errors" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Dropdown, ErrorMessage, FeatherIcon } from 'frappe-ui'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { validateConditions } from '@/utils'
|
||||
import CFConditions from '../../ConditionsFilter/CFConditions.vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
conditions: Array,
|
||||
name: String,
|
||||
errors: String,
|
||||
doctype: String,
|
||||
})
|
||||
|
||||
const validateAssignmentRule = inject('validateAssignmentRule')
|
||||
|
||||
const getConjunction = () => {
|
||||
let conjunction = 'and'
|
||||
props.conditions.forEach((condition) => {
|
||||
if (typeof condition == 'string') {
|
||||
conjunction = condition
|
||||
}
|
||||
})
|
||||
return conjunction
|
||||
}
|
||||
|
||||
const dropdownOptions = [
|
||||
{
|
||||
label: __('Add condition'),
|
||||
onClick: () => {
|
||||
addCondition()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Add condition group'),
|
||||
onClick: () => {
|
||||
const conjunction = getConjunction()
|
||||
props.conditions.push(conjunction, [[]])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const addCondition = () => {
|
||||
const isValid = validateConditions(props.conditions)
|
||||
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
const conjunction = getConjunction()
|
||||
|
||||
props.conditions.push(conjunction, ['', '', ''])
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => [...props.conditions],
|
||||
() => {
|
||||
validateAssignmentRule(props.name)
|
||||
},
|
||||
{ deep: true, debounce: 300 },
|
||||
)
|
||||
</script>
|
||||
@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
|
||||
<div
|
||||
class="grid p-2 px-4 items-center"
|
||||
style="grid-template-columns: 3fr 1fr"
|
||||
>
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ __(column.label) }}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<AssignmentScheduleItem
|
||||
v-for="(day, index) in days"
|
||||
:key="day.day"
|
||||
:data="day"
|
||||
:isLast="index === days.length - 1"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage :message="assignmentRuleErrors.assignmentDays" class="mt-2" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AssignmentScheduleItem from './AssignmentScheduleItem.vue'
|
||||
import { ErrorMessage } from 'frappe-ui'
|
||||
import { onMounted, ref, inject } from 'vue'
|
||||
|
||||
const assignmentRuleData = inject('assignmentRuleData')
|
||||
const assignmentRuleErrors = inject('assignmentRuleErrors')
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: 'Days',
|
||||
key: 'day',
|
||||
},
|
||||
{
|
||||
label: 'Active',
|
||||
key: 'active',
|
||||
},
|
||||
]
|
||||
|
||||
const days = ref([
|
||||
{
|
||||
day: 'Monday',
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
day: 'Tuesday',
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
day: 'Wednesday',
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
day: 'Thursday',
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
day: 'Friday',
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
day: 'Saturday',
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
day: 'Sunday',
|
||||
active: false,
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
assignmentRuleData.value.assignmentDays.forEach((day) => {
|
||||
const workDay = days.value.find((d) => d.day === day)
|
||||
if (workDay) {
|
||||
workDay.active = true
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid py-3.5 px-4 items-center"
|
||||
style="grid-template-columns: 3fr 1fr"
|
||||
>
|
||||
<div class="text-ink-gray-7 font-medium">{{ __(data.day) }}</div>
|
||||
<div class="flex justify-start">
|
||||
<Switch v-model="data.active" @update:model-value="toggleDay" />
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="!isLast" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Switch } from 'frappe-ui'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const assignmentRuleData = inject('assignmentRuleData')
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLast: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const toggleDay = (isActive) => {
|
||||
const dayIndex = assignmentRuleData.value.assignmentDays.findIndex(
|
||||
(d) => d === props.data.day,
|
||||
)
|
||||
|
||||
if (isActive && dayIndex === -1) {
|
||||
assignmentRuleData.value.assignmentDays.push(props.data.day)
|
||||
} else {
|
||||
assignmentRuleData.value.assignmentDays.splice(dayIndex, 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -9,14 +9,10 @@
|
||||
:label="__(template.name)"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'template-list')"
|
||||
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
|
||||
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-4 w-3/12 justify-end">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch size="sm" v-model="template.enabled" />
|
||||
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('Update')"
|
||||
icon-left="plus"
|
||||
@ -30,6 +26,13 @@
|
||||
|
||||
<!-- Fields -->
|
||||
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||
<div
|
||||
class="flex justify-between items-center cursor-pointer border-b py-3"
|
||||
@click="() => (template.enabled = !template.enabled)"
|
||||
>
|
||||
<div class="text-base text-ink-gray-7">{{ __('Enabled') }}</div>
|
||||
<Switch v-model="template.enabled" @click.stop />
|
||||
</div>
|
||||
<div class="flex sm:flex-row flex-col gap-4">
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
|
||||
@ -148,6 +148,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TemplateOption } from '@/utils'
|
||||
import {
|
||||
TextInput,
|
||||
FormControl,
|
||||
@ -222,28 +223,43 @@ function getDropdownOptions(template) {
|
||||
let options = [
|
||||
{
|
||||
label: __('Duplicate'),
|
||||
icon: 'copy',
|
||||
onClick: () => emit('updateStep', 'new-template', { ...template }),
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Duplicate'),
|
||||
icon: 'copy',
|
||||
active: props.active,
|
||||
onClick: () => emit('updateStep', 'new-template', { ...template }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: __('Delete'),
|
||||
icon: 'trash-2',
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
confirmDelete.value = true
|
||||
},
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Delete'),
|
||||
icon: 'trash-2',
|
||||
active: props.active,
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
confirmDelete.value = true
|
||||
},
|
||||
}),
|
||||
condition: () => !confirmDelete.value,
|
||||
},
|
||||
{
|
||||
label: __('Confirm Delete'),
|
||||
icon: 'trash-2',
|
||||
theme: 'red',
|
||||
onClick: () => deleteTemplate(template),
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Confirm Delete'),
|
||||
icon: 'trash-2',
|
||||
active: props.active,
|
||||
theme: 'danger',
|
||||
onClick: () => deleteTemplate(template),
|
||||
}),
|
||||
condition: () => confirmDelete.value,
|
||||
},
|
||||
]
|
||||
|
||||
return options
|
||||
return options.filter((option) => option.condition?.() || true)
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -11,14 +11,10 @@
|
||||
"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'template-list')"
|
||||
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
|
||||
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-4 w-3/12 justify-end">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch size="sm" v-model="template.enabled" />
|
||||
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="templateData?.name ? __('Duplicate') : __('Create')"
|
||||
icon-left="plus"
|
||||
@ -30,6 +26,13 @@
|
||||
|
||||
<!-- Fields -->
|
||||
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||
<div
|
||||
class="flex justify-between items-center cursor-pointer border-b py-3"
|
||||
@click="() => (template.enabled = !template.enabled)"
|
||||
>
|
||||
<div class="text-base text-ink-gray-7">{{ __('Enabled') }}</div>
|
||||
<Switch v-model="template.enabled" @click.stop />
|
||||
</div>
|
||||
<div class="flex sm:flex-row flex-col gap-4">
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 py-8 px-6 text-ink-gray-8">
|
||||
<div class="flex flex-col gap-1 px-2">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Forecasting') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Configure forecasting feature to help predict sales performance and growth',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col overflow-y-auto">
|
||||
<div class="flex items-center justify-between py-3 px-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __('Enable forecasting') }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 truncate">
|
||||
{{
|
||||
__(
|
||||
'Makes "Expected Closure Date" and "Expected Deal Value" mandatory for deal value forecasting',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="settings.doc.enable_forecasting"
|
||||
@click.stop="toggleForecasting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px border-t mx-2 border-outline-gray-modals" />
|
||||
<div class="flex items-center justify-between py-3 px-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __('Auto update expected deal value') }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 truncate">
|
||||
{{
|
||||
__(
|
||||
'Automatically update "Expected Deal Value" based on the total value of associated products in a deal',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="settings.doc.auto_update_expected_deal_value"
|
||||
@click.stop="autoUpdateExpectedDealValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { Switch, toast } from 'frappe-ui'
|
||||
|
||||
const { _settings: settings } = getSettings()
|
||||
|
||||
function toggleForecasting() {
|
||||
settings.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
settings.doc.enable_forecasting
|
||||
? __('Forecasting enabled successfully')
|
||||
: __('Forecasting disabled successfully'),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function autoUpdateExpectedDealValue() {
|
||||
settings.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
settings.doc.auto_update_expected_deal_value
|
||||
? __('Auto update of expected deal value enabled')
|
||||
: __('Auto update of expected deal value disabled'),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -1,18 +1,27 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 px-6 py-8 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between px-2 text-ink-gray-8">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Brand settings') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{ __('Configure your brand name, logo, and favicon') }}
|
||||
</p>
|
||||
<div class="flex px-2 justify-between">
|
||||
<div class="flex items-center gap-1 -ml-4 w-9/12">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="__('Brand settings')"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'general-settings')"
|
||||
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||
/>
|
||||
<Badge
|
||||
v-if="settings.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('Update')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
:disabled="!settings.isDirty"
|
||||
:loading="settings.loading"
|
||||
@ -27,30 +36,35 @@
|
||||
<FormControl
|
||||
type="text"
|
||||
class="w-1/2"
|
||||
size="md"
|
||||
v-model="settings.doc.brand_name"
|
||||
:label="__('Brand name')"
|
||||
:placeholder="__('Enter brand name')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- logo -->
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<div class="flex items-center flex-1 gap-5">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Logo') }}
|
||||
</span>
|
||||
<div class="flex flex-1 gap-5">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-modals size-20"
|
||||
class="flex items-center justify-center rounded border border-outline-gray-modals px-10 py-2"
|
||||
>
|
||||
<img
|
||||
v-if="settings.doc?.brand_logo"
|
||||
:src="settings.doc?.brand_logo"
|
||||
:src="settings.doc?.brand_logo || '/assets/crm/images/logo.png'"
|
||||
alt="Logo"
|
||||
class="size-8 rounded"
|
||||
/>
|
||||
<ImageIcon v-else class="size-5 text-ink-gray-4" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-base font-medium">{{ __('Brand logo') }}</span>
|
||||
<span class="text-p-base text-ink-gray-6">
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<ImageUploader
|
||||
label="Favicon"
|
||||
image_type="image/ico"
|
||||
:image_url="settings.doc?.brand_logo"
|
||||
@upload="(url) => (settings.doc.brand_logo = url)"
|
||||
@remove="() => (settings.doc.brand_logo = '')"
|
||||
/>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Appears in the left sidebar. Recommended size is 32x32 px in PNG or SVG',
|
||||
@ -58,34 +72,33 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<ImageUploader
|
||||
image_type="image/ico"
|
||||
:image_url="settings.doc?.brand_logo"
|
||||
@upload="(url) => (settings.doc.brand_logo = url)"
|
||||
@remove="() => (settings.doc.brand_logo = '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- favicon -->
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<div class="flex items-center flex-1 gap-5">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Favicon') }}
|
||||
</span>
|
||||
<div class="flex flex-1 gap-5">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-modals size-20"
|
||||
class="flex items-center justify-center rounded border border-outline-gray-modals px-10 py-2"
|
||||
>
|
||||
<img
|
||||
v-if="settings.doc?.favicon"
|
||||
:src="settings.doc?.favicon"
|
||||
:src="settings.doc?.favicon || '/assets/crm/images/logo.png'"
|
||||
alt="Favicon"
|
||||
class="size-8 rounded"
|
||||
/>
|
||||
<ImageIcon v-else class="size-5 text-ink-gray-4" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-base font-medium">{{ __('Favicon') }}</span>
|
||||
<span class="text-p-base text-ink-gray-6">
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<ImageUploader
|
||||
label="Favicon"
|
||||
image_type="image/ico"
|
||||
:image_url="settings.doc?.favicon"
|
||||
@upload="(url) => (settings.doc.favicon = url)"
|
||||
@remove="() => (settings.doc.favicon = '')"
|
||||
/>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Appears next to the title in your browser tab. Recommended size is 32x32 px in PNG or ICO',
|
||||
@ -93,25 +106,20 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<ImageUploader
|
||||
image_type="image/ico"
|
||||
:image_url="settings.doc?.favicon"
|
||||
@upload="(url) => (settings.doc.favicon = url)"
|
||||
@remove="() => (settings.doc.favicon = '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorMessage">
|
||||
<ErrorMessage :message="__(errorMessage)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ImageIcon from '~icons/lucide/image'
|
||||
import ImageUploader from '@/components/Controls/ImageUploader.vue'
|
||||
import { FormControl } from 'frappe-ui'
|
||||
import { FormControl, ErrorMessage } from 'frappe-ui'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { showSettings } from '@/composables/settings'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { _settings: settings, setupBrand } = getSettings()
|
||||
|
||||
@ -123,4 +131,7 @@ function updateSettings() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const errorMessage = ref('')
|
||||
</script>
|
||||
@ -1,20 +1,27 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 px-6 py-8 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between px-2 text-ink-gray-8">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Currency & Exchange rate provider') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{
|
||||
__('Configure the currency and exchange rate provider for your CRM')
|
||||
}}
|
||||
</p>
|
||||
<div class="flex px-2 justify-between">
|
||||
<div class="flex items-center gap-1 -ml-4 w-9/12">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="__('Currency & Exchange rate provider')"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'general-settings')"
|
||||
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||
/>
|
||||
<Badge
|
||||
v-if="settings.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('Update')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
:disabled="!settings.isDirty"
|
||||
:loading="settings.loading"
|
||||
@ -25,7 +32,7 @@
|
||||
|
||||
<!-- Fields -->
|
||||
<div class="flex flex-1 flex-col overflow-y-auto">
|
||||
<div class="flex items-center justify-between gap-8 py-3 px-2">
|
||||
<div class="flex items-center justify-between gap-8 p-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __('Currency') }}
|
||||
@ -54,7 +61,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px border-t mx-2 border-outline-gray-modals" />
|
||||
<div class="flex items-center justify-between gap-8 py-3 px-2">
|
||||
<div class="flex items-center justify-between gap-8 p-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __('Exchange rate provider') }}
|
||||
@ -124,15 +131,17 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ErrorMessage, FormControl, toast } from 'frappe-ui'
|
||||
import { ErrorMessage, toast } from 'frappe-ui'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { showSettings } from '@/composables/settings'
|
||||
import { ref } from 'vue'
|
||||
import FormControl from 'frappe-ui/src/components/FormControl/FormControl.vue'
|
||||
|
||||
const { _settings: settings } = getSettings()
|
||||
const { $dialog } = globalStore()
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const errorMessage = ref('')
|
||||
|
||||
function updateSettings() {
|
||||
105
frontend/src/components/Settings/General/GeneralSettings.vue
Normal file
105
frontend/src/components/Settings/General/GeneralSettings.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('General') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{ __('Configure general settings for your CRM') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col overflow-y-auto">
|
||||
<div
|
||||
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
@click="toggleForecasting()"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __('Enable forecasting') }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 truncate">
|
||||
{{
|
||||
__(
|
||||
'Makes "Expected Closure Date" and "Expected Deal Value" mandatory for deal value forecasting',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="settings.doc.enable_forecasting"
|
||||
@click.stop="toggleForecasting(settings.doc.enable_forecasting)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px border-t mx-2 border-outline-gray-modals" />
|
||||
<template v-for="(setting, i) in settingsList" :key="setting.name">
|
||||
<li
|
||||
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
@click="() => emit('updateStep', setting.name)"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ __(setting.label) }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 truncate">
|
||||
{{ __(setting.description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FeatherIcon name="chevron-right" class="text-ink-gray-7 size-4" />
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
v-if="settingsList.length !== i + 1"
|
||||
class="h-px border-t mx-2 border-outline-gray-modals"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { Switch, toast } from 'frappe-ui'
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const { _settings: settings } = getSettings()
|
||||
|
||||
const settingsList = [
|
||||
{
|
||||
name: 'currency-settings',
|
||||
label: 'Currency & Exchange rate provider',
|
||||
description:
|
||||
'Configure the currency and exchange rate provider for your CRM',
|
||||
},
|
||||
{
|
||||
name: 'brand-settings',
|
||||
label: 'Brand settings',
|
||||
description: 'Configure your brand name, logo and favicon',
|
||||
},
|
||||
{
|
||||
name: 'home-actions',
|
||||
label: 'Home actions',
|
||||
description: 'Configure actions that appear on the home dropdown',
|
||||
},
|
||||
]
|
||||
|
||||
function toggleForecasting(value) {
|
||||
settings.doc.enable_forecasting =
|
||||
value !== undefined ? value : !settings.doc.enable_forecasting
|
||||
|
||||
settings.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
settings.doc.enable_forecasting
|
||||
? __('Forecasting enabled successfully')
|
||||
: __('Forecasting disabled successfully'),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<component :is="getComponent(step)" :data="data" @updateStep="updateStep" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GeneralSettings from './GeneralSettings.vue'
|
||||
import CurrencySettings from './CurrencySettings.vue'
|
||||
import BrandSettings from './BrandSettings.vue'
|
||||
import HomeActions from './HomeActions.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const step = ref('general-settings')
|
||||
const data = ref(null)
|
||||
|
||||
function updateStep(newStep, _data) {
|
||||
step.value = newStep
|
||||
data.value = _data
|
||||
}
|
||||
|
||||
function getComponent(step) {
|
||||
switch (step) {
|
||||
case 'general-settings':
|
||||
return GeneralSettings
|
||||
case 'currency-settings':
|
||||
return CurrencySettings
|
||||
case 'brand-settings':
|
||||
return BrandSettings
|
||||
case 'home-actions':
|
||||
return HomeActions
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -1,30 +1,33 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between text-ink-gray-8">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Home actions') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{ __('Configure actions that appear on the home dropdown') }}
|
||||
</p>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-1 -ml-4 w-9/12">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="__('Home actions')"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'general-settings')"
|
||||
class="text-xl !h-7 font-semibold hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('Update')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
:disabled="!document.isDirty"
|
||||
:loading="document.loading"
|
||||
:disabled="!settings.isDirty"
|
||||
:loading="settings.loading"
|
||||
@click="updateSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fields -->
|
||||
<div class="flex flex-1 flex-col overflow-y-auto">
|
||||
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
|
||||
<Grid
|
||||
v-model="document.doc.dropdown_items"
|
||||
v-model="settings.doc.dropdown_items"
|
||||
doctype="CRM Dropdown Item"
|
||||
parentDoctype="FCRM Settings"
|
||||
parentFieldname="dropdown_items"
|
||||
@ -38,22 +41,17 @@
|
||||
<script setup>
|
||||
import Grid from '@/components/Controls/Grid.vue'
|
||||
import { ErrorMessage } from 'frappe-ui'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { showSettings } from '@/composables/settings'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { ref, provide } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { document, triggerOnChange } = useDocument(
|
||||
'FCRM Settings',
|
||||
'FCRM Settings',
|
||||
)
|
||||
|
||||
provide('triggerOnChange', triggerOnChange)
|
||||
const { _settings: settings } = getSettings()
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const errorMessage = ref('')
|
||||
|
||||
function updateSettings() {
|
||||
document.save.submit(null, {
|
||||
settings.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
showSettings.value = false
|
||||
},
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 py-8 px-6 text-ink-gray-8">
|
||||
<div class="flex px-2 justify-between">
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Send invites to') }}
|
||||
@ -23,21 +23,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col px-2 gap-8 overflow-y-auto">
|
||||
<div class="flex-1 flex flex-col gap-8 overflow-y-auto">
|
||||
<div>
|
||||
<FormControl
|
||||
type="textarea"
|
||||
label="Invite by email"
|
||||
placeholder="user1@example.com, user2@example.com, ..."
|
||||
@input="updateInvitees($event.target.value)"
|
||||
:debounce="100"
|
||||
:disabled="inviteByEmail.loading"
|
||||
:description="
|
||||
__(
|
||||
'You can invite multiple users by comma separating their email addresses',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<label class="block text-xs text-ink-gray-5 mb-1.5">
|
||||
{{ __('Invite by email') }}
|
||||
</label>
|
||||
<div
|
||||
class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded"
|
||||
>
|
||||
<MultiSelectUserInput
|
||||
class="flex-1"
|
||||
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
|
||||
:placeholder="__('john@doe.com')"
|
||||
v-model="invitees"
|
||||
:validate="validateEmail"
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
"
|
||||
:fetchUsers="false"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="userExistMessage || inviteeExistMessage"
|
||||
class="text-xs text-ink-red-3 mt-1.5"
|
||||
@ -95,9 +100,15 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
|
||||
import { validateEmail, convertArrayToString } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { createListResource, createResource, FormControl } from 'frappe-ui'
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
FormControl,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
@ -197,15 +208,6 @@ const pendingInvitations = createListResource({
|
||||
doctype: 'CRM Invitation',
|
||||
filters: { status: 'Pending' },
|
||||
fields: ['name', 'email', 'role'],
|
||||
pageLength: 999,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
function updateInvitees(value) {
|
||||
const emails = value
|
||||
.split(',')
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => validateEmail(email))
|
||||
invitees.value = emails
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -3,37 +3,34 @@
|
||||
v-model="showSettings"
|
||||
:options="{ size: '5xl' }"
|
||||
@close="activeSettingsPage = ''"
|
||||
:disableOutsideClickToClose="disableSettingModalOutsideClick"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex h-[calc(100vh_-_8rem)]">
|
||||
<div class="flex flex-col p-1 w-52 shrink-0 bg-surface-gray-2">
|
||||
<h1 class="px-3 pt-3 pb-2 text-lg font-semibold text-ink-gray-8">
|
||||
<div class="flex flex-col p-2 w-52 shrink-0 bg-surface-gray-2">
|
||||
<h1 class="px-2 pt-2 mb-3 text-lg font-semibold text-ink-gray-8">
|
||||
{{ __('Settings') }}
|
||||
</h1>
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<template v-for="tab in tabs" :key="tab.label">
|
||||
<div
|
||||
v-if="!tab.hideLabel"
|
||||
class="py-[7px] px-2 my-1 flex cursor-pointer gap-1.5 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<span>{{ __(tab.label) }}</span>
|
||||
</div>
|
||||
<nav class="space-y-1 px-1">
|
||||
<SidebarLink
|
||||
v-for="i in tab.items"
|
||||
:icon="i.icon"
|
||||
:label="__(i.label)"
|
||||
class="w-full"
|
||||
:class="
|
||||
activeTab?.label == i.label
|
||||
? 'bg-surface-selected shadow-sm hover:bg-surface-selected'
|
||||
: 'hover:bg-surface-gray-3'
|
||||
"
|
||||
@click="activeSettingsPage = i.label"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
<div v-for="tab in tabs">
|
||||
<div
|
||||
v-if="!tab.hideLabel"
|
||||
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<span>{{ __(tab.label) }}</span>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
<SidebarLink
|
||||
v-for="i in tab.items"
|
||||
:icon="i.icon"
|
||||
:label="__(i.label)"
|
||||
class="w-full"
|
||||
:class="
|
||||
activeTab?.label == i.label
|
||||
? 'bg-surface-selected shadow-sm hover:bg-surface-selected'
|
||||
: 'hover:bg-surface-gray-3'
|
||||
"
|
||||
@click="activeSettingsPage = i.label"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 overflow-y-auto bg-surface-modal">
|
||||
@ -44,24 +41,17 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import CircleDollarSignIcon from '~icons/lucide/circle-dollar-sign'
|
||||
import TrendingUpDownIcon from '~icons/lucide/trending-up-down'
|
||||
import SparkleIcon from '@/components/Icons/SparkleIcon.vue'
|
||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
||||
import SettingsIcon2 from '@/components/Icons/SettingsIcon2.vue'
|
||||
import Users from '@/components/Settings/Users.vue'
|
||||
import GeneralSettingsPage from '@/components/Settings/General/GeneralSettingsPage.vue'
|
||||
import InviteUserPage from '@/components/Settings/InviteUserPage.vue'
|
||||
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
||||
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
||||
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
|
||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||
import HomeActions from '@/components/Settings/HomeActions.vue'
|
||||
import ForecastingSettings from '@/components/Settings/ForecastingSettings.vue'
|
||||
import CurrencySettings from '@/components/Settings/CurrencySettings.vue'
|
||||
import EmailTemplatePage from '@/components/Settings/EmailTemplate/EmailTemplatePage.vue'
|
||||
import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
|
||||
import EmailConfig from '@/components/Settings/EmailConfig.vue'
|
||||
@ -71,11 +61,9 @@ import {
|
||||
isWhatsappInstalled,
|
||||
showSettings,
|
||||
activeSettingsPage,
|
||||
disableSettingModalOutsideClick,
|
||||
} from '@/composables/settings'
|
||||
import { Dialog, Avatar } from 'frappe-ui'
|
||||
import { ref, markRaw, computed, watch, h } from 'vue'
|
||||
import AssignmentRulePage from './AssignmentRules/AssignmentRulePage.vue'
|
||||
|
||||
const { isManager, isTelephonyAgent, getUser } = usersStore()
|
||||
|
||||
@ -84,7 +72,7 @@ const user = computed(() => getUser() || {})
|
||||
const tabs = computed(() => {
|
||||
let _tabs = [
|
||||
{
|
||||
label: __('Personal Settings'),
|
||||
label: __('Settings'),
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
@ -97,32 +85,12 @@ const tabs = computed(() => {
|
||||
}),
|
||||
component: markRaw(ProfileSettings),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: __('System Configuration'),
|
||||
items: [
|
||||
{
|
||||
label: __('Forecasting'),
|
||||
component: markRaw(ForecastingSettings),
|
||||
icon: TrendingUpDownIcon,
|
||||
label: __('General'),
|
||||
icon: 'settings',
|
||||
component: markRaw(GeneralSettingsPage),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Currency & Exchange Rate'),
|
||||
icon: CircleDollarSignIcon,
|
||||
component: markRaw(CurrencySettings),
|
||||
},
|
||||
{
|
||||
label: __('Brand Settings'),
|
||||
icon: SparkleIcon,
|
||||
component: markRaw(BrandSettings),
|
||||
},
|
||||
],
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('User Management'),
|
||||
items: [
|
||||
{
|
||||
label: __('Users'),
|
||||
icon: 'user',
|
||||
@ -135,12 +103,6 @@ const tabs = computed(() => {
|
||||
component: markRaw(InviteUserPage),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
],
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Email Settings'),
|
||||
items: [
|
||||
{
|
||||
label: __('Email Accounts'),
|
||||
icon: Email2Icon,
|
||||
@ -154,27 +116,6 @@ const tabs = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: __('Automation & Rules'),
|
||||
items: [
|
||||
{
|
||||
label: __('Assignment rules'),
|
||||
icon: markRaw(h(SettingsIcon2, { class: 'rotate-90' })),
|
||||
component: markRaw(AssignmentRulePage),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: __('Customization'),
|
||||
items: [
|
||||
{
|
||||
label: __('Home Actions'),
|
||||
component: markRaw(HomeActions),
|
||||
icon: 'home',
|
||||
},
|
||||
],
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Integrations', null, 'FCRM'),
|
||||
items: [
|
||||
|
||||
@ -169,16 +169,8 @@
|
||||
import AddExistingUserModal from '@/components/Modals/AddExistingUserModal.vue'
|
||||
import { activeSettingsPage } from '@/composables/settings'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { DropdownOption } from '@/utils'
|
||||
import {
|
||||
Dropdown,
|
||||
Avatar,
|
||||
TextInput,
|
||||
toast,
|
||||
call,
|
||||
FeatherIcon,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { TemplateOption, DropdownOption } from '@/utils'
|
||||
import { Avatar, TextInput, toast, call, FeatherIcon, Tooltip } from 'frappe-ui'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const { users, isAdmin, isManager } = usersStore()
|
||||
@ -216,19 +208,29 @@ function getMoreOptions(user) {
|
||||
let options = [
|
||||
{
|
||||
label: __('Remove'),
|
||||
icon: 'trash-2',
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
confirmRemove.value = true
|
||||
},
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Remove'),
|
||||
icon: 'trash-2',
|
||||
active: props.active,
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
confirmRemove.value = true
|
||||
},
|
||||
}),
|
||||
condition: () => !confirmRemove.value,
|
||||
},
|
||||
{
|
||||
label: __('Confirm Remove'),
|
||||
icon: 'trash-2',
|
||||
theme: 'red',
|
||||
onClick: () => removeUser(user, true),
|
||||
component: (props) =>
|
||||
TemplateOption({
|
||||
option: __('Confirm Remove'),
|
||||
icon: 'trash-2',
|
||||
active: props.active,
|
||||
theme: 'danger',
|
||||
onClick: () => removeUser(user, true),
|
||||
}),
|
||||
condition: () => confirmRemove.value,
|
||||
},
|
||||
]
|
||||
@ -240,35 +242,38 @@ function getDropdownOptions(user) {
|
||||
let options = [
|
||||
{
|
||||
label: __('Admin'),
|
||||
component: () =>
|
||||
component: (props) =>
|
||||
DropdownOption({
|
||||
option: __('Admin'),
|
||||
icon: 'shield',
|
||||
active: props.active,
|
||||
selected: user.role === 'System Manager',
|
||||
onClick: () => updateRole(user, 'System Manager'),
|
||||
}),
|
||||
onClick: () => updateRole(user, 'System Manager'),
|
||||
condition: () => isAdmin(),
|
||||
},
|
||||
{
|
||||
label: __('Manager'),
|
||||
component: () =>
|
||||
component: (props) =>
|
||||
DropdownOption({
|
||||
option: __('Manager'),
|
||||
icon: 'briefcase',
|
||||
active: props.active,
|
||||
selected: user.role === 'Sales Manager',
|
||||
onClick: () => updateRole(user, 'Sales Manager'),
|
||||
}),
|
||||
onClick: () => updateRole(user, 'Sales Manager'),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Sales User'),
|
||||
component: () =>
|
||||
component: (props) =>
|
||||
DropdownOption({
|
||||
option: __('Sales User'),
|
||||
icon: 'user-check',
|
||||
active: props.active,
|
||||
selected: user.role === 'Sales User',
|
||||
onClick: () => updateRole(user, 'Sales User'),
|
||||
}),
|
||||
onClick: () => updateRole(user, 'Sales User'),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -79,14 +79,70 @@
|
||||
<div>{{ doc[field.fieldname] }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<PrimaryDropdown
|
||||
v-else-if="field.fieldtype === 'Dropdown'"
|
||||
:value="doc[field.fieldname]"
|
||||
:placeholder="field.placeholder"
|
||||
:options="field.options"
|
||||
:create="field.create"
|
||||
:label="field.label"
|
||||
/>
|
||||
<div v-else-if="field.fieldtype === 'Dropdown'">
|
||||
<Popover>
|
||||
<template #target="{ isOpen, togglePopover }">
|
||||
<Button
|
||||
:label="doc[field.fieldname]"
|
||||
class="dropdown-button flex items-center justify-between bg-surface-white !px-2.5 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:bg-surface-white focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0"
|
||||
@click="togglePopover"
|
||||
>
|
||||
<div
|
||||
v-if="doc[field.fieldname]"
|
||||
class="truncate"
|
||||
>
|
||||
{{ doc[field.fieldname] }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-base leading-5 text-ink-gray-4 truncate"
|
||||
>
|
||||
{{ field.placeholder }}
|
||||
</div>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="
|
||||
isOpen ? 'chevron-up' : 'chevron-down'
|
||||
"
|
||||
class="h-4 text-ink-gray-5"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="my-2 p-1.5 min-w-40 space-y-1.5 divide-y divide-outline-gray-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div>
|
||||
<DropdownItem
|
||||
v-if="field.options?.length"
|
||||
v-for="option in field.options"
|
||||
:key="option.name"
|
||||
:option="option"
|
||||
/>
|
||||
<div v-else>
|
||||
<div
|
||||
class="p-1.5 pl-3 pr-4 text-base text-ink-gray-4"
|
||||
>
|
||||
{{
|
||||
__('No {0} Available', [field.label])
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
iconLeft="plus"
|
||||
@click="field.create()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype == 'Check'"
|
||||
class="form-control"
|
||||
@ -310,7 +366,7 @@
|
||||
import Password from '@/components/Controls/Password.vue'
|
||||
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||
import Section from '@/components/Section.vue'
|
||||
import PrimaryDropdown from '@/components/PrimaryDropdown.vue'
|
||||
import DropdownItem from '@/components/DropdownItem.vue'
|
||||
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
|
||||
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
@ -322,7 +378,7 @@ import { usersStore } from '@/stores/users'
|
||||
import { isMobileView } from '@/composables/settings'
|
||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
|
||||
import { Tooltip, DateTimePicker, DatePicker, Popover } from 'frappe-ui'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { ref, computed, getCurrentInstance } from 'vue'
|
||||
|
||||
|
||||
@ -72,13 +72,10 @@
|
||||
theme="red"
|
||||
:label="__('Cancel')"
|
||||
@click="cancelCall"
|
||||
class="rounded-lg text-ink-white"
|
||||
class="rounded-lg rotate-[135deg] text-ink-white"
|
||||
:disabled="callStatus == 'initiating'"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneIcon class="rotate-[135deg]" />
|
||||
</template>
|
||||
</Button>
|
||||
:iconLeft="PhoneIcon"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex gap-2">
|
||||
<Button
|
||||
@ -95,13 +92,10 @@
|
||||
variant="solid"
|
||||
theme="red"
|
||||
:label="__('Reject')"
|
||||
class="rounded-lg text-ink-white"
|
||||
class="rounded-lg rotate-[135deg] text-ink-white"
|
||||
:iconLeft="PhoneIcon"
|
||||
@click="rejectIncomingCall"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneIcon class="rotate-[135deg]" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -939,9 +939,9 @@ async function updateKanbanSettings(data) {
|
||||
value: data.to,
|
||||
})
|
||||
}
|
||||
let isDirty = viewUpdated.value
|
||||
|
||||
viewUpdated.value = true
|
||||
|
||||
if (!defaultParams.value) {
|
||||
defaultParams.value = getParams()
|
||||
}
|
||||
@ -969,6 +969,26 @@ async function updateKanbanSettings(data) {
|
||||
|
||||
if (!route.query.view) {
|
||||
createOrUpdateStandardView()
|
||||
} else if (!data.column_field) {
|
||||
if (isDirty) {
|
||||
$dialog({
|
||||
title: __('Unsaved Changes'),
|
||||
message: __('You have unsaved changes. Do you want to save them?'),
|
||||
variant: 'danger',
|
||||
actions: [
|
||||
{
|
||||
label: __('Update'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
updateCustomView()
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
updateCustomView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1022,6 +1042,31 @@ function createOrUpdateStandardView() {
|
||||
})
|
||||
}
|
||||
|
||||
function updateCustomView() {
|
||||
viewUpdated.value = false
|
||||
view.value = {
|
||||
doctype: props.doctype,
|
||||
label: view.value.label,
|
||||
type: view.value.type || 'list',
|
||||
icon: view.value.icon,
|
||||
name: view.value.name,
|
||||
filters: defaultParams.value.filters,
|
||||
order_by: defaultParams.value.order_by,
|
||||
group_by_field: defaultParams.value.view.group_by_field,
|
||||
column_field: defaultParams.value.column_field,
|
||||
title_field: defaultParams.value.title_field,
|
||||
kanban_columns: defaultParams.value.kanban_columns,
|
||||
kanban_fields: defaultParams.value.kanban_fields,
|
||||
columns: defaultParams.value.columns,
|
||||
rows: defaultParams.value.rows,
|
||||
route_name: route.name,
|
||||
load_default_columns: view.value.load_default_columns,
|
||||
}
|
||||
call('crm.fcrm.doctype.crm_view_settings.crm_view_settings.update', {
|
||||
view: view.value,
|
||||
}).then(() => reloadView())
|
||||
}
|
||||
|
||||
function updatePageLength(value, loadMore = false) {
|
||||
if (list.value.loading) return
|
||||
if (!defaultParams.value) {
|
||||
|
||||
@ -41,7 +41,4 @@ export const mobileSidebarOpened = ref(false)
|
||||
export const isMobileView = computed(() => window.innerWidth < 768)
|
||||
|
||||
export const showSettings = ref(false)
|
||||
|
||||
export const disableSettingModalOutsideClick = ref(false)
|
||||
|
||||
export const activeSettingsPage = ref('')
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { getScript } from '@/data/script'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { showSettings, activeSettingsPage } from '@/composables/settings'
|
||||
import { runSequentially, parseAssignees, evaluateExpression } from '@/utils'
|
||||
import { runSequentially, parseAssignees } from '@/utils'
|
||||
import { createDocumentResource, createResource, toast } from 'frappe-ui'
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
@ -12,7 +11,6 @@ const assigneesCache = {}
|
||||
|
||||
export function useDocument(doctype, docname) {
|
||||
const { setupScript, scripts } = getScript(doctype)
|
||||
const meta = getMeta(doctype)
|
||||
|
||||
documentsCache[doctype] = documentsCache[doctype] || {}
|
||||
|
||||
@ -39,7 +37,6 @@ export function useDocument(doctype, docname) {
|
||||
}
|
||||
},
|
||||
setValue: {
|
||||
validate,
|
||||
onSuccess: () => {
|
||||
triggerOnSave()
|
||||
toast.success(__('Document updated successfully'))
|
||||
@ -155,42 +152,6 @@ export function useDocument(doctype, docname) {
|
||||
return []
|
||||
}
|
||||
|
||||
function validate(d) {
|
||||
checkMandatory(d.doc || d.fieldname)
|
||||
}
|
||||
|
||||
function checkMandatory(doc) {
|
||||
let fields = meta?.getFields() || []
|
||||
|
||||
if (!fields || fields.length === 0) return
|
||||
|
||||
let missingFields = []
|
||||
|
||||
fields.forEach((df) => {
|
||||
let parent = meta?.doctypeMeta?.[df.parent] || null
|
||||
if (evaluateExpression(df.mandatory_depends_on, doc, parent)) {
|
||||
const value = doc[df.fieldname]
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && value.trim() === '') ||
|
||||
(Array.isArray(value) && value.length === 0)
|
||||
) {
|
||||
missingFields.push(df.label || df.fieldname)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
toast.error(
|
||||
__('Mandatory fields required: {0}', [missingFields.join(', ')]),
|
||||
)
|
||||
throw new Error(
|
||||
__('Mandatory fields required: {0}', [missingFields.join(', ')]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerOnLoad() {
|
||||
const handler = async function () {
|
||||
await (this.onLoad?.() || this.on_load?.() || this.onload?.())
|
||||
@ -319,7 +280,6 @@ export function useDocument(doctype, docname) {
|
||||
assignees: assigneesCache[doctype][docname || ''],
|
||||
scripts,
|
||||
error,
|
||||
validate,
|
||||
getControllers,
|
||||
triggerOnLoad,
|
||||
triggerOnBeforeCreate,
|
||||
|
||||
@ -113,7 +113,7 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
:sections="parsedSections"
|
||||
:sections="sections.data"
|
||||
doctype="Contact"
|
||||
:docname="contact.doc.name"
|
||||
@reload="sections.reload"
|
||||
@ -293,7 +293,9 @@ const tabs = [
|
||||
const deals = createResource({
|
||||
url: 'crm.api.contact.get_linked_deals',
|
||||
cache: ['deals', props.contactId],
|
||||
params: { contact: props.contactId },
|
||||
params: {
|
||||
contact: props.contactId,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@ -308,110 +310,118 @@ const sections = createResource({
|
||||
cache: ['sidePanelSections', 'Contact'],
|
||||
params: { doctype: 'Contact' },
|
||||
auto: true,
|
||||
transform: (data) => computed(() => getParsedSections(data)),
|
||||
})
|
||||
|
||||
const parsedSections = computed(() => {
|
||||
if (!sections.data) return []
|
||||
return sections.data.map((section) => ({
|
||||
...section,
|
||||
columns: section.columns.map((column) => ({
|
||||
...column,
|
||||
fields: column.fields.map((field) => {
|
||||
function getParsedSections(_sections) {
|
||||
return _sections.map((section) => {
|
||||
section.columns = section.columns.map((column) => {
|
||||
column.fields = column.fields.map((field) => {
|
||||
if (field.fieldname === 'email_id') {
|
||||
return {
|
||||
...field,
|
||||
read_only: false,
|
||||
fieldtype: 'Dropdown',
|
||||
options: (contact.doc?.email_ids || []).map((email) => ({
|
||||
name: email.name,
|
||||
value: email.email_id,
|
||||
selected: email.email_id === contact.doc.email_id,
|
||||
placeholder: 'john@doe.com',
|
||||
onClick: () => setAsPrimary('email', email.email_id),
|
||||
onSave: (option, isNew) =>
|
||||
isNew
|
||||
? createNew('email', option.value)
|
||||
: editOption(
|
||||
'Contact Email',
|
||||
option.name,
|
||||
'email_id',
|
||||
option.value,
|
||||
),
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.doc.email_ids = contact.doc.email_ids.filter(
|
||||
(e) => e.name !== option.name,
|
||||
)
|
||||
if (!isNew) await deleteOption('Contact Email', option.name)
|
||||
},
|
||||
})),
|
||||
options:
|
||||
contact.doc?.email_ids?.map((email) => {
|
||||
return {
|
||||
name: email.name,
|
||||
value: email.email_id,
|
||||
selected: email.email_id === contact.doc.email_id,
|
||||
placeholder: 'john@doe.com',
|
||||
onClick: () => {
|
||||
setAsPrimary('email', email.email_id)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('email', option.value)
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Email',
|
||||
option.name,
|
||||
'email_id',
|
||||
option.value
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.doc.email_ids = contact.doc.email_ids.filter(
|
||||
(email) => email.name !== option.name,
|
||||
)
|
||||
!isNew && (await deleteOption('Contact Email', option.name))
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
// Add a temporary new option locally (mirrors original behavior)
|
||||
contact.doc.email_ids = [
|
||||
...(contact.doc.email_ids || []),
|
||||
{
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
},
|
||||
]
|
||||
contact.doc?.email_ids?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
if (field.fieldname === 'mobile_no') {
|
||||
} else if (field.fieldname === 'mobile_no') {
|
||||
return {
|
||||
...field,
|
||||
read_only: false,
|
||||
fieldtype: 'Dropdown',
|
||||
options: (contact.doc?.phone_nos || []).map((phone) => ({
|
||||
name: phone.name,
|
||||
value: phone.phone,
|
||||
selected: phone.phone === contact.doc.mobile_no,
|
||||
onClick: () => setAsPrimary('mobile_no', phone.phone),
|
||||
onSave: (option, isNew) =>
|
||||
isNew
|
||||
? createNew('phone', option.value)
|
||||
: editOption(
|
||||
'Contact Phone',
|
||||
option.name,
|
||||
'phone',
|
||||
option.value,
|
||||
),
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.doc.phone_nos = contact.doc.phone_nos.filter(
|
||||
(p) => p.name !== option.name,
|
||||
)
|
||||
if (!isNew) await deleteOption('Contact Phone', option.name)
|
||||
},
|
||||
})),
|
||||
options:
|
||||
contact.doc?.phone_nos?.map((phone) => {
|
||||
return {
|
||||
name: phone.name,
|
||||
value: phone.phone,
|
||||
selected: phone.phone === contact.doc.mobile_no,
|
||||
onClick: () => {
|
||||
setAsPrimary('mobile_no', phone.phone)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('phone', option.value)
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Phone',
|
||||
option.name,
|
||||
'phone',
|
||||
option.value
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.doc.phone_nos = contact.doc.phone_nos.filter(
|
||||
(phone) => phone.name !== option.name,
|
||||
)
|
||||
!isNew && (await deleteOption('Contact Phone', option.name))
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.doc.phone_nos = [
|
||||
...(contact.doc.phone_nos || []),
|
||||
{
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
},
|
||||
]
|
||||
contact.doc?.phone_nos?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
if (field.fieldname === 'address') {
|
||||
} else if (field.fieldname === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (_value, close) => {
|
||||
create: (value, close) => {
|
||||
openAddressModal()
|
||||
close && close()
|
||||
close()
|
||||
},
|
||||
edit: (address) => openAddressModal(address),
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
}
|
||||
return field
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
})
|
||||
})
|
||||
return column
|
||||
})
|
||||
return section
|
||||
})
|
||||
}
|
||||
|
||||
async function setAsPrimary(field, value) {
|
||||
let d = await call('crm.api.contact.set_as_primary', {
|
||||
|
||||
@ -234,6 +234,7 @@ const options = computed(() => [
|
||||
|
||||
const dashboardItems = createResource({
|
||||
url: 'crm.api.dashboard.get_dashboard',
|
||||
cache: ['Analytics', 'ManagerDashboard'],
|
||||
makeParams() {
|
||||
return {
|
||||
from_date: fromDate.value,
|
||||
|
||||
@ -45,12 +45,12 @@
|
||||
onClick: () => deleteNote(note.name),
|
||||
},
|
||||
]"
|
||||
@click.stop
|
||||
>
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghosted"
|
||||
class="hover:bg-surface-white"
|
||||
@click.stop
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@ -30,14 +30,14 @@
|
||||
:image="organization.doc.organization_logo"
|
||||
/>
|
||||
<component
|
||||
:is="organization.doc.organization_logo ? Dropdown : 'div'"
|
||||
:is="organization.doc.image ? Dropdown : 'div'"
|
||||
v-bind="
|
||||
organization.doc.organization_logo
|
||||
organization.doc.image
|
||||
? {
|
||||
options: [
|
||||
{
|
||||
icon: 'upload',
|
||||
label: organization.doc.organization_logo
|
||||
label: organization.doc.image
|
||||
? __('Change image')
|
||||
: __('Upload image'),
|
||||
onClick: openFileSelector,
|
||||
@ -105,7 +105,6 @@
|
||||
doctype="CRM Organization"
|
||||
:docname="organization.doc.name"
|
||||
@reload="sections.reload"
|
||||
@beforeFieldChange="beforeFieldChange"
|
||||
/>
|
||||
</div>
|
||||
</Resizer>
|
||||
@ -181,7 +180,6 @@ import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
|
||||
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||
import DeleteLinkedDocModal from '@/components/DeleteLinkedDocModal.vue'
|
||||
import { showAddressModal, addressProps } from '@/composables/modals'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
@ -191,19 +189,21 @@ import { statusesStore } from '@/stores/statuses'
|
||||
import { getView } from '@/utils/view'
|
||||
import { formatDate, timeAgo, validateIsImageFile } from '@/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
Breadcrumbs,
|
||||
Avatar,
|
||||
FileUploader,
|
||||
Dropdown,
|
||||
Tabs,
|
||||
call,
|
||||
createListResource,
|
||||
usePageMeta,
|
||||
createResource,
|
||||
toast,
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import { h, computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import DeleteLinkedDocModal from '@/components/DeleteLinkedDocModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
organizationId: {
|
||||
@ -218,7 +218,6 @@ const { getDealStatus } = statusesStore()
|
||||
const { doctypeMeta } = getMeta('CRM Organization')
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const errorTitle = ref('')
|
||||
const errorMessage = ref('')
|
||||
@ -278,27 +277,14 @@ async function deleteOrganization() {
|
||||
showDeleteLinkedDocModal.value = true
|
||||
}
|
||||
|
||||
function changeOrganizationImage(file) {
|
||||
organization.setValue.submit({
|
||||
organization_logo: file?.file_url || null,
|
||||
async function changeOrganizationImage(file) {
|
||||
await call('frappe.client.set_value', {
|
||||
doctype: 'CRM Organization',
|
||||
name: props.organizationId,
|
||||
fieldname: 'organization_logo',
|
||||
value: file?.file_url || '',
|
||||
})
|
||||
}
|
||||
|
||||
function beforeFieldChange(data) {
|
||||
if (data?.hasOwnProperty('organization_name')) {
|
||||
call('frappe.client.rename_doc', {
|
||||
doctype: 'CRM Organization',
|
||||
old_name: props.organizationId,
|
||||
new_name: data.organization_name,
|
||||
}).then(() => {
|
||||
router.push({
|
||||
name: 'Organization',
|
||||
params: { organizationId: data.organization_name },
|
||||
})
|
||||
})
|
||||
} else {
|
||||
organization.save.submit()
|
||||
}
|
||||
organization.reload()
|
||||
}
|
||||
|
||||
function website(url) {
|
||||
|
||||
@ -421,36 +421,6 @@ export function evaluateDependsOnValue(expression, doc) {
|
||||
return out
|
||||
}
|
||||
|
||||
export function evaluateExpression(expression, doc, parent) {
|
||||
if (!expression) return false
|
||||
if (!doc) return false
|
||||
|
||||
let out = null
|
||||
if (typeof expression === 'boolean') {
|
||||
out = expression
|
||||
} else if (typeof expression === 'function') {
|
||||
out = expression(doc)
|
||||
} else if (expression.substr(0, 5) == 'eval:') {
|
||||
try {
|
||||
out = _eval(expression.substr(5), { doc, parent })
|
||||
if (parent && parent.istable && expression.includes('is_submittable')) {
|
||||
out = true
|
||||
}
|
||||
} catch (e) {
|
||||
out = true
|
||||
}
|
||||
} else {
|
||||
let value = doc[expression]
|
||||
if (Array.isArray(value)) {
|
||||
out = !!value.length
|
||||
} else {
|
||||
out = !!value
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function convertSize(size) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let unitIndex = 0
|
||||
@ -492,12 +462,23 @@ export function runSequentially(functions) {
|
||||
}, Promise.resolve())
|
||||
}
|
||||
|
||||
export function DropdownOption({ option, icon, selected }) {
|
||||
export function DropdownOption({
|
||||
active,
|
||||
option,
|
||||
theme,
|
||||
icon,
|
||||
onClick,
|
||||
selected,
|
||||
}) {
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class:
|
||||
'group flex w-full text-ink-gray-8 justify-between items-center rounded-md px-2 py-2 text-sm hover:bg-surface-gray-2',
|
||||
class: [
|
||||
active ? 'bg-surface-gray-2' : 'text-ink-gray-8',
|
||||
'group flex w-full justify-between items-center rounded-md px-2 py-2 text-sm',
|
||||
theme == 'danger' ? 'text-ink-red-3 hover:bg-ink-red-1' : '',
|
||||
],
|
||||
onClick: !selected ? onClick : null,
|
||||
},
|
||||
[
|
||||
h('div', { class: 'flex gap-2' }, [
|
||||
@ -520,167 +501,31 @@ export function DropdownOption({ option, icon, selected }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TemplateOption({ active, option, theme, icon, onClick }) {
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class: [
|
||||
active ? 'bg-surface-gray-2 text-ink-gray-8' : 'text-ink-gray-7',
|
||||
'group flex w-full gap-2 items-center rounded-md px-2 py-2 text-sm',
|
||||
theme == 'danger' ? 'text-ink-red-3 hover:bg-ink-red-1' : '',
|
||||
],
|
||||
onClick: onClick,
|
||||
},
|
||||
[
|
||||
icon
|
||||
? h(FeatherIcon, {
|
||||
name: icon,
|
||||
class: ['h-4 w-4 shrink-0'],
|
||||
'aria-hidden': true,
|
||||
})
|
||||
: null,
|
||||
h('span', { class: 'whitespace-nowrap' }, option),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function copy(obj) {
|
||||
if (!obj) return obj
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
export const convertToConditions = ({ conditions, fieldPrefix }) => {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const processCondition = (condition) => {
|
||||
if (typeof condition === 'string') {
|
||||
return condition.toLowerCase()
|
||||
}
|
||||
|
||||
if (Array.isArray(condition)) {
|
||||
// Nested condition group
|
||||
if (Array.isArray(condition[0])) {
|
||||
const nestedStr = convertToConditions({
|
||||
conditions: condition,
|
||||
fieldPrefix,
|
||||
})
|
||||
return `(${nestedStr})`
|
||||
}
|
||||
|
||||
// Simple condition: [fieldname, operator, value]
|
||||
const [field, operator, value] = condition
|
||||
const fieldAccess = fieldPrefix ? `${fieldPrefix}.${field}` : field
|
||||
|
||||
const operatorMap = {
|
||||
equals: '==',
|
||||
'=': '==',
|
||||
'==': '==',
|
||||
'!=': '!=',
|
||||
'not equals': '!=',
|
||||
'<': '<',
|
||||
'<=': '<=',
|
||||
'>': '>',
|
||||
'>=': '>=',
|
||||
in: 'in',
|
||||
'not in': 'not in',
|
||||
like: 'like',
|
||||
'not like': 'not like',
|
||||
is: 'is',
|
||||
'is not': 'is not',
|
||||
between: 'between',
|
||||
}
|
||||
|
||||
let op = operatorMap[operator.toLowerCase()] || operator
|
||||
|
||||
if (
|
||||
(op === '==' || op === '!=') &&
|
||||
(String(value).toLowerCase() === 'yes' ||
|
||||
String(value).toLowerCase() === 'no')
|
||||
) {
|
||||
let checkVal = String(value).toLowerCase() === 'yes'
|
||||
if (op === '!=') {
|
||||
checkVal = !checkVal
|
||||
}
|
||||
return checkVal ? fieldAccess : `not ${fieldAccess}`
|
||||
}
|
||||
|
||||
if (op === 'is' && String(value).toLowerCase() === 'set') {
|
||||
return fieldAccess
|
||||
}
|
||||
if (
|
||||
(op === 'is' && String(value).toLowerCase() === 'not set') ||
|
||||
(op === 'is not' && String(value).toLowerCase() === 'set')
|
||||
) {
|
||||
return `not ${fieldAccess}`
|
||||
}
|
||||
|
||||
if (op === 'like') {
|
||||
return `(${fieldAccess} and "${value}" in ${fieldAccess})`
|
||||
}
|
||||
if (op === 'not like') {
|
||||
return `(${fieldAccess} and "${value}" not in ${fieldAccess})`
|
||||
}
|
||||
|
||||
if (
|
||||
op === 'between' &&
|
||||
typeof value === 'string' &&
|
||||
value.includes(',')
|
||||
) {
|
||||
const [start, end] = value.split(',').map((v) => v.trim())
|
||||
return `(${fieldAccess} >= "${start}" and ${fieldAccess} <= "${end}")`
|
||||
}
|
||||
|
||||
let valueStr = ''
|
||||
if (op === 'in' || op === 'not in') {
|
||||
let items
|
||||
if (Array.isArray(value)) {
|
||||
items = value.map((v) => `"${String(v).trim()}"`)
|
||||
} else if (typeof value === 'string') {
|
||||
items = value.split(',').map((v) => `"${v.trim()}"`)
|
||||
} else {
|
||||
items = [`"${String(value).trim()}"`]
|
||||
}
|
||||
valueStr = `[${items.join(', ')}]`
|
||||
return `(${fieldAccess} and ${fieldAccess} ${op} ${valueStr})`
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
valueStr = `"${value.replace(/"/g, '\\"')}"`
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
valueStr = String(value)
|
||||
} else if (value === null || value === undefined) {
|
||||
return op === '==' || op === 'is' ? `not ${fieldAccess}` : fieldAccess
|
||||
} else {
|
||||
valueStr = `"${String(value).replace(/"/g, '\\"')}"`
|
||||
}
|
||||
|
||||
return `${fieldAccess} ${op} ${valueStr}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const parts = conditions.map(processCondition)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function validateConditions(conditions) {
|
||||
if (!Array.isArray(conditions)) return false
|
||||
|
||||
// Handle simple condition [field, operator, value]
|
||||
if (
|
||||
conditions.length === 3 &&
|
||||
typeof conditions[0] === 'string' &&
|
||||
typeof conditions[1] === 'string'
|
||||
) {
|
||||
return conditions[0] !== '' && conditions[1] !== '' && conditions[2] !== ''
|
||||
}
|
||||
|
||||
// Iterate through conditions and logical operators
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const item = conditions[i]
|
||||
|
||||
// Skip logical operators (they will be validated by their position)
|
||||
if (item === 'and' || item === 'or') {
|
||||
// Ensure logical operators are not at start/end and not consecutive
|
||||
if (
|
||||
i === 0 ||
|
||||
i === conditions.length - 1 ||
|
||||
conditions[i - 1] === 'and' ||
|
||||
conditions[i - 1] === 'or'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle nested conditions (arrays)
|
||||
if (Array.isArray(item)) {
|
||||
if (!validateConditions(item)) {
|
||||
return false
|
||||
}
|
||||
} else if (item !== undefined && item !== null) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return conditions.length > 0
|
||||
}
|
||||
|
||||
@ -2,122 +2,136 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import frappeui from 'frappe-ui/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
const isDev = mode === 'development'
|
||||
const frappeui = await importFrappeUIPlugin(isDev)
|
||||
|
||||
const config = {
|
||||
plugins: [
|
||||
frappeui({
|
||||
frappeProxy: true,
|
||||
lucideIcons: true,
|
||||
jinjaBootData: true,
|
||||
buildConfig: {
|
||||
indexHtmlPath: '../crm/www/crm.html',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
}),
|
||||
vue(),
|
||||
vueJsx(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
manifest: {
|
||||
display: 'standalone',
|
||||
name: 'Frappe CRM',
|
||||
short_name: 'Frappe CRM',
|
||||
start_url: '/crm',
|
||||
description:
|
||||
'Modern & 100% Open-source CRM tool to supercharge your sales operations',
|
||||
icons: [
|
||||
{
|
||||
src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
{
|
||||
src: '/assets/crm/manifest/manifest-icon-512.maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/assets/crm/manifest/manifest-icon-512.maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'feather-icons',
|
||||
'showdown',
|
||||
'tailwind.config.js',
|
||||
'prosemirror-state',
|
||||
'prosemirror-view',
|
||||
'lowlight',
|
||||
'interactjs',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Add local frappe-ui alias only in development if the local frappe-ui exists
|
||||
if (isDev) {
|
||||
try {
|
||||
// Check if the local frappe-ui directory exists
|
||||
const fs = await import('node:fs')
|
||||
const localFrappeUIPath = path.resolve(__dirname, '../frappe-ui')
|
||||
if (fs.existsSync(localFrappeUIPath)) {
|
||||
config.resolve.alias['frappe-ui'] = localFrappeUIPath
|
||||
} else {
|
||||
console.warn('Local frappe-ui directory not found, using npm package')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Error checking for local frappe-ui, using npm package:',
|
||||
error.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
async function importFrappeUIPlugin(isDev) {
|
||||
if (isDev) {
|
||||
try {
|
||||
const module = await import('../frappe-ui/vite')
|
||||
return module.default
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Local frappe-ui not found, falling back to npm package:',
|
||||
error.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Fall back to npm package if local import fails
|
||||
const module = await import('frappe-ui/vite')
|
||||
return module.default
|
||||
function appPath(app) {
|
||||
const root = path.resolve(__dirname, '../..') // points to apps
|
||||
const frontendPaths = [
|
||||
// Standard frontend structure: appname/frontend/src
|
||||
path.join(root, app, 'frontend', 'src'),
|
||||
// Desk-based apps: appname/desk/src
|
||||
path.join(root, app, 'desk', 'src'),
|
||||
// Alternative frontend structures
|
||||
path.join(root, app, 'client', 'src'),
|
||||
path.join(root, app, 'ui', 'src'),
|
||||
// Direct src structure: appname/src
|
||||
path.join(root, app, 'src'),
|
||||
]
|
||||
return frontendPaths.find((srcPath) => fs.existsSync(srcPath)) || null
|
||||
}
|
||||
|
||||
function hasApp(app) {
|
||||
return fs.existsSync(appPath(app))
|
||||
}
|
||||
|
||||
// List of frontend apps used in this project
|
||||
let apps = []
|
||||
|
||||
const alias = [
|
||||
// Default "@" for this app
|
||||
{
|
||||
find: '@',
|
||||
replacement: path.resolve(__dirname, 'src'),
|
||||
},
|
||||
|
||||
// App-specific aliases like @helpdesk, @hrms, etc.
|
||||
...apps.map((app) =>
|
||||
hasApp(app)
|
||||
? { find: `@${app}`, replacement: appPath(app) }
|
||||
: { find: `@${app}`, replacement: `virtual:${app}` },
|
||||
),
|
||||
]
|
||||
|
||||
const defineFlags = Object.fromEntries(
|
||||
apps.map((app) => [
|
||||
`__HAS_${app.toUpperCase()}__`,
|
||||
JSON.stringify(hasApp(app)),
|
||||
]),
|
||||
)
|
||||
|
||||
const virtualStubPlugin = {
|
||||
name: 'virtual-empty-modules',
|
||||
resolveId(id) {
|
||||
if (id.startsWith('virtual:')) return '\0' + id
|
||||
},
|
||||
load(id) {
|
||||
if (id.startsWith('\0virtual:')) {
|
||||
return 'export default {}; export const missing = true;'
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
console.log('Generated app aliases:', alias)
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
define: defineFlags,
|
||||
plugins: [
|
||||
frappeui({
|
||||
frappeProxy: true,
|
||||
lucideIcons: true,
|
||||
jinjaBootData: true,
|
||||
buildConfig: {
|
||||
indexHtmlPath: '../crm/www/crm.html',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
}),
|
||||
vue(),
|
||||
vueJsx(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
manifest: {
|
||||
display: 'standalone',
|
||||
name: 'Frappe CRM',
|
||||
short_name: 'Frappe CRM',
|
||||
start_url: '/crm',
|
||||
description:
|
||||
'Modern & 100% Open-source CRM tool to supercharge your sales operations',
|
||||
icons: [
|
||||
{
|
||||
src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
{
|
||||
src: '/assets/crm/manifest/manifest-icon-512.maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/assets/crm/manifest/manifest-icon-512.maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
virtualStubPlugin,
|
||||
],
|
||||
resolve: { alias },
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'feather-icons',
|
||||
'showdown',
|
||||
'tailwind.config.js',
|
||||
'prosemirror-state',
|
||||
'prosemirror-view',
|
||||
'lowlight',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["frontend", "frappe-ui"],
|
||||
"scripts": {
|
||||
"postinstall": "cd frontend && yarn install",
|
||||
"dev": "cd frontend && yarn dev",
|
||||
"build": "cd frontend && yarn build",
|
||||
"upgrade-frappeui": "cd frontend && yarn add frappe-ui@latest && cd .."
|
||||
"disable-workspaces": "sed -i '' 's/\"workspaces\"/\"aworkspaces\"/g' package.json",
|
||||
"enable-workspaces": "sed -i '' 's/\"aworkspaces\"/\"workspaces\"/g' package.json && rm -rf node_modules ./frontend/node_modules/ frappe-ui/node_modules/ && yarn install",
|
||||
"upgrade-frappeui": "cd frontend && yarn add frappe-ui@latest && cd ..",
|
||||
"disable-workspaces-and-upgrade-frappeui": "yarn disable-workspaces && yarn upgrade-frappeui"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user