Merge pull request #504 from shariquerik/section-column-layout
This commit is contained in:
commit
76616cb02d
106
crm/api/doc.py
106
crm/api/doc.py
@ -565,95 +565,6 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
|
||||
return fields_meta
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_sidebar_fields(doctype, name):
|
||||
if not frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}):
|
||||
return []
|
||||
layout = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}).layout
|
||||
|
||||
if not layout:
|
||||
return []
|
||||
|
||||
layout = json.loads(layout)
|
||||
|
||||
not_allowed_fieldtypes = [
|
||||
"Tab Break",
|
||||
"Section Break",
|
||||
"Column Break",
|
||||
]
|
||||
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
|
||||
|
||||
doc = frappe.get_cached_doc(doctype, name)
|
||||
has_high_permlevel_fields = any(df.permlevel > 0 for df in fields)
|
||||
if has_high_permlevel_fields:
|
||||
has_read_access_to_permlevels = doc.get_permlevel_access("read")
|
||||
has_write_access_to_permlevels = doc.get_permlevel_access("write")
|
||||
|
||||
for section in layout:
|
||||
section["name"] = section.get("name") or section.get("label")
|
||||
for field in section.get("fields") if section.get("fields") else []:
|
||||
field_obj = next((f for f in fields if f.fieldname == field), None)
|
||||
if field_obj:
|
||||
if field_obj.permlevel > 0:
|
||||
field_has_write_access = field_obj.permlevel in has_write_access_to_permlevels
|
||||
field_has_read_access = field_obj.permlevel in has_read_access_to_permlevels
|
||||
if not field_has_write_access and field_has_read_access:
|
||||
field_obj.read_only = 1
|
||||
if not field_has_read_access and not field_has_write_access:
|
||||
field_obj.hidden = 1
|
||||
section["fields"][section.get("fields").index(field)] = get_field_obj(field_obj)
|
||||
|
||||
fields_meta = {}
|
||||
for field in fields:
|
||||
fields_meta[field.fieldname] = field
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def get_field_obj(field):
|
||||
obj = {
|
||||
"label": field.label,
|
||||
"type": get_type(field),
|
||||
"name": field.fieldname,
|
||||
"hidden": field.hidden,
|
||||
"reqd": field.reqd,
|
||||
"read_only": field.read_only,
|
||||
"all_properties": field,
|
||||
}
|
||||
|
||||
obj["placeholder"] = field.get("placeholder") or "Add " + field.label + "..."
|
||||
|
||||
if field.fieldtype == "Link":
|
||||
obj["placeholder"] = field.get("placeholder") or "Select " + field.label + "..."
|
||||
obj["doctype"] = field.options
|
||||
elif field.fieldtype == "Select" and field.options:
|
||||
obj["placeholder"] = field.get("placeholder") or "Select " + field.label + "..."
|
||||
obj["options"] = [{"label": option, "value": option} for option in field.options.split("\n")]
|
||||
|
||||
if field.read_only:
|
||||
obj["tooltip"] = "This field is read only and cannot be edited."
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def get_type(field):
|
||||
if field.fieldtype == "Data" and field.options == "Phone":
|
||||
return "phone"
|
||||
elif field.fieldtype == "Data" and field.options == "Email":
|
||||
return "email"
|
||||
elif field.fieldtype == "Check":
|
||||
return "checkbox"
|
||||
elif field.fieldtype == "Int":
|
||||
return "number"
|
||||
elif field.fieldtype in ["Small Text", "Text", "Long Text"]:
|
||||
return "textarea"
|
||||
elif field.read_only:
|
||||
return "read_only"
|
||||
return field.fieldtype.lower()
|
||||
|
||||
|
||||
def get_assigned_users(doctype, name, default_assigned_to=None):
|
||||
assigned_users = frappe.get_all(
|
||||
"ToDo",
|
||||
@ -685,22 +596,7 @@ def get_fields(doctype: str, allow_all_fieldtypes: bool = False):
|
||||
|
||||
for field in fields:
|
||||
if field.fieldtype not in not_allowed_fieldtypes and field.fieldname:
|
||||
_fields.append(
|
||||
{
|
||||
"label": field.label,
|
||||
"type": field.fieldtype,
|
||||
"value": field.fieldname,
|
||||
"options": field.options,
|
||||
"mandatory": field.reqd,
|
||||
"read_only": field.read_only,
|
||||
"hidden": field.hidden,
|
||||
"depends_on": field.depends_on,
|
||||
"mandatory_depends_on": field.mandatory_depends_on,
|
||||
"read_only_depends_on": field.read_only_depends_on,
|
||||
"link_filters": field.get("link_filters"),
|
||||
"placeholder": field.get("placeholder"),
|
||||
}
|
||||
)
|
||||
_fields.append(field)
|
||||
|
||||
return _fields
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
"fieldname": "layout",
|
||||
"fieldtype": "Code",
|
||||
"label": "Layout",
|
||||
"options": "JS"
|
||||
"options": "JSON"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_post",
|
||||
@ -46,7 +46,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-29 12:58:54.280569",
|
||||
"modified": "2025-01-02 22:12:51.663011",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Fields Layout",
|
||||
@ -64,6 +64,15 @@
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "All",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
|
||||
@ -6,6 +6,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import random_string
|
||||
|
||||
|
||||
class CRMFieldsLayout(Document):
|
||||
@ -13,7 +14,7 @@ class CRMFieldsLayout(Document):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_fields_layout(doctype: str, type: str):
|
||||
def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None):
|
||||
tabs = []
|
||||
layout = None
|
||||
|
||||
@ -29,38 +30,116 @@ def get_fields_layout(doctype: str, type: str):
|
||||
has_tabs = tabs[0].get("sections") if tabs and tabs[0] else False
|
||||
|
||||
if not has_tabs:
|
||||
tabs = [{"no_tabs": True, "sections": tabs}]
|
||||
tabs = [{"name": "first_tab", "sections": tabs}]
|
||||
|
||||
allowed_fields = []
|
||||
for tab in tabs:
|
||||
for section in tab.get("sections"):
|
||||
if not section.get("fields"):
|
||||
if "columns" not in section:
|
||||
continue
|
||||
allowed_fields.extend(section.get("fields"))
|
||||
for column in section.get("columns"):
|
||||
if not column.get("fields"):
|
||||
continue
|
||||
allowed_fields.extend(column.get("fields"))
|
||||
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldname in allowed_fields]
|
||||
|
||||
for tab in tabs:
|
||||
for section in tab.get("sections"):
|
||||
for field in section.get("fields") if section.get("fields") else []:
|
||||
field = next((f for f in fields if f.fieldname == field), None)
|
||||
if field:
|
||||
field = {
|
||||
"label": _(field.label),
|
||||
"name": field.fieldname,
|
||||
"type": field.fieldtype,
|
||||
"options": getOptions(field),
|
||||
"mandatory": field.reqd,
|
||||
"read_only": field.read_only,
|
||||
"placeholder": field.get("placeholder"),
|
||||
"filters": field.get("link_filters"),
|
||||
}
|
||||
section["fields"][section.get("fields").index(field["name"])] = field
|
||||
for column in section.get("columns") if section.get("columns") else []:
|
||||
for field in column.get("fields") if column.get("fields") else []:
|
||||
field = next((f for f in fields if f.fieldname == field), None)
|
||||
if field:
|
||||
field = field.as_dict()
|
||||
handle_perm_level_restrictions(field, doctype, parent_doctype)
|
||||
column["fields"][column.get("fields").index(field["fieldname"])] = field
|
||||
|
||||
return tabs or []
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_sidepanel_sections(doctype):
|
||||
if not frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}):
|
||||
return []
|
||||
layout = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}).layout
|
||||
|
||||
if not layout:
|
||||
return []
|
||||
|
||||
layout = json.loads(layout)
|
||||
|
||||
not_allowed_fieldtypes = [
|
||||
"Tab Break",
|
||||
"Section Break",
|
||||
"Column Break",
|
||||
]
|
||||
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
|
||||
|
||||
for section in layout:
|
||||
section["name"] = section.get("name") or section.get("label")
|
||||
for column in section.get("columns") if section.get("columns") else []:
|
||||
for field in column.get("fields") if column.get("fields") else []:
|
||||
field_obj = next((f for f in fields if f.fieldname == field), None)
|
||||
if field_obj:
|
||||
field_obj = field_obj.as_dict()
|
||||
handle_perm_level_restrictions(field_obj, doctype)
|
||||
column["fields"][column.get("fields").index(field)] = get_field_obj(field_obj)
|
||||
|
||||
fields_meta = {}
|
||||
for field in fields:
|
||||
fields_meta[field.fieldname] = field
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
|
||||
if field.permlevel == 0:
|
||||
return
|
||||
field_has_write_access = field.permlevel in get_permlevel_access("write", doctype, parent_doctype)
|
||||
field_has_read_access = field.permlevel in get_permlevel_access("read", doctype, parent_doctype)
|
||||
|
||||
if not field_has_write_access and field_has_read_access:
|
||||
field.read_only = 1
|
||||
if not field_has_read_access and not field_has_write_access:
|
||||
field.hidden = 1
|
||||
|
||||
|
||||
def get_permlevel_access(permission_type="write", doctype=None, parent_doctype=None):
|
||||
allowed_permlevels = []
|
||||
roles = frappe.get_roles()
|
||||
|
||||
meta = frappe.get_meta(doctype)
|
||||
|
||||
if meta.istable and parent_doctype:
|
||||
meta = frappe.get_meta(parent_doctype)
|
||||
elif meta.istable and not parent_doctype:
|
||||
return [1, 0]
|
||||
|
||||
for perm in meta.permissions:
|
||||
if perm.role in roles and perm.get(permission_type) and perm.permlevel not in allowed_permlevels:
|
||||
allowed_permlevels.append(perm.permlevel)
|
||||
|
||||
return allowed_permlevels
|
||||
|
||||
|
||||
def get_field_obj(field):
|
||||
field["placeholder"] = field.get("placeholder") or "Add " + field.label + "..."
|
||||
|
||||
if field.fieldtype == "Link":
|
||||
field["placeholder"] = field.get("placeholder") or "Select " + field.label + "..."
|
||||
elif field.fieldtype == "Select" and field.options:
|
||||
field["placeholder"] = field.get("placeholder") or "Select " + field.label + "..."
|
||||
field["options"] = [{"label": option, "value": option} for option in field.options.split("\n")]
|
||||
|
||||
if field.read_only:
|
||||
field["tooltip"] = "This field is read only and cannot be edited."
|
||||
|
||||
return field
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_fields_layout(doctype: str, type: str, layout: str):
|
||||
if frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": type}):
|
||||
@ -82,18 +161,45 @@ def save_fields_layout(doctype: str, type: str, layout: str):
|
||||
|
||||
def get_default_layout(doctype: str):
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [
|
||||
field.fieldname
|
||||
for field in fields
|
||||
if field.fieldtype not in ["Tab Break", "Section Break", "Column Break"]
|
||||
]
|
||||
|
||||
return [{"no_tabs": True, "sections": [{"hideLabel": True, "fields": fields}]}]
|
||||
tabs = []
|
||||
|
||||
if fields and fields[0].fieldtype != "Tab Break":
|
||||
sections = []
|
||||
if fields and fields[0].fieldtype != "Section Break":
|
||||
sections.append(
|
||||
{
|
||||
"name": "section_" + str(random_string(4)),
|
||||
"columns": [{"name": "column_" + str(random_string(4)), "fields": []}],
|
||||
}
|
||||
)
|
||||
tabs.append({"name": "tab_" + str(random_string(4)), "sections": sections})
|
||||
|
||||
def getOptions(field):
|
||||
if field.fieldtype == "Select" and field.options:
|
||||
field.options = field.options.split("\n")
|
||||
field.options = [{"label": _(option), "value": option} for option in field.options]
|
||||
field.options.insert(0, {"label": "", "value": ""})
|
||||
return field.options
|
||||
for field in fields:
|
||||
if field.fieldtype == "Tab Break":
|
||||
tabs.append(
|
||||
{
|
||||
"name": "tab_" + str(random_string(4)),
|
||||
"sections": [
|
||||
{
|
||||
"name": "section_" + str(random_string(4)),
|
||||
"columns": [{"name": "column_" + str(random_string(4)), "fields": []}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
elif field.fieldtype == "Section Break":
|
||||
tabs[-1]["sections"].append(
|
||||
{
|
||||
"name": "section_" + str(random_string(4)),
|
||||
"columns": [{"name": "column_" + str(random_string(4)), "fields": []}],
|
||||
}
|
||||
)
|
||||
elif field.fieldtype == "Column Break":
|
||||
tabs[-1]["sections"][-1]["columns"].append(
|
||||
{"name": "column_" + str(random_string(4)), "fields": []}
|
||||
)
|
||||
else:
|
||||
tabs[-1]["sections"][-1]["columns"][-1]["fields"].append(field.fieldname)
|
||||
|
||||
return tabs
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-19 21:57:02.025918",
|
||||
"modified": "2025-01-02 22:14:28.686821",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Industry",
|
||||
@ -51,6 +51,15 @@
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "All",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@ -290,7 +290,7 @@
|
||||
"image_field": "image",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-17 18:36:57.289897",
|
||||
"modified": "2025-01-02 22:14:01.991054",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead",
|
||||
@ -320,6 +320,15 @@
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "All",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"sender_field": "email",
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-19 21:56:04.702254",
|
||||
"modified": "2025-01-02 22:13:30.498404",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead Source",
|
||||
@ -59,6 +59,15 @@
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "All",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-19 21:56:16.872924",
|
||||
"modified": "2025-01-02 22:13:43.038656",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead Status",
|
||||
@ -67,6 +67,15 @@
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "All",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
|
||||
@ -6,13 +6,13 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enabled",
|
||||
"is_erpnext_in_different_site",
|
||||
"column_break_vfru",
|
||||
"erpnext_company",
|
||||
"section_break_oubd",
|
||||
"column_break_vfru",
|
||||
"is_erpnext_in_different_site",
|
||||
"erpnext_site_url",
|
||||
"column_break_fllx",
|
||||
"section_break_oubd",
|
||||
"api_key",
|
||||
"column_break_fllx",
|
||||
"api_secret",
|
||||
"section_break_jnbn",
|
||||
"create_customer_on_status_change",
|
||||
@ -37,7 +37,8 @@
|
||||
{
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "section_break_oubd",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"label": "ERPNext Site API's"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_fllx",
|
||||
@ -101,7 +102,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-30 19:22:07.929304",
|
||||
"modified": "2024-12-31 23:24:29.636820",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "ERPNext CRM Settings",
|
||||
|
||||
@ -120,53 +120,53 @@ def add_default_fields_layout(force=False):
|
||||
quick_entry_layouts = {
|
||||
"CRM Lead-Quick Entry": {
|
||||
"doctype": "CRM Lead",
|
||||
"layout": '[{"label":"Person","fields":["salutation","first_name","last_name","email","mobile_no", "gender"],"hideLabel":true},{"label":"Organization","fields":["organization","website","no_of_employees","territory","annual_revenue","industry"],"hideLabel":true,"hideBorder":false},{"label":"Other","columns":2,"fields":["status","lead_owner"],"hideLabel":true,"hideBorder":false}]',
|
||||
"layout": '[{"name": "person_section", "columns": [{"name": "column_5jrk", "fields": ["salutation", "email"]}, {"name": "column_5CPV", "fields": ["first_name", "mobile_no"]}, {"name": "column_gXOy", "fields": ["last_name", "gender"]}]}, {"name": "organization_section", "columns": [{"name": "column_GHfX", "fields": ["organization", "territory"]}, {"name": "column_hXjS", "fields": ["website", "annual_revenue"]}, {"name": "column_RDNA", "fields": ["no_of_employees", "industry"]}]}, {"name": "lead_section", "columns": [{"name": "column_EO1H", "fields": ["status"]}, {"name": "column_RWBe", "fields": ["lead_owner"]}]}]',
|
||||
},
|
||||
"CRM Deal-Quick Entry": {
|
||||
"doctype": "CRM Deal",
|
||||
"layout": '[{"label": "Select Organization", "fields": ["organization"], "hideLabel": true, "editable": true}, {"label": "Organization Details", "fields": ["organization_name", "website", "no_of_employees", "territory", "annual_revenue", "industry"], "hideLabel": true, "editable": true}, {"label": "Select Contact", "fields": ["contact"], "hideLabel": true, "editable": true}, {"label": "Contact Details", "fields": ["salutation", "first_name", "last_name", "email", "mobile_no", "gender"], "hideLabel": true, "editable": true}, {"label": "Other", "columns": 2, "fields": ["status", "deal_owner"], "hideLabel": true}]',
|
||||
"layout": '[{"name": "organization_section", "hidden": true, "editable": false, "columns": [{"name": "column_GpMP", "fields": ["organization"]}, {"name": "column_FPTn", "fields": []}]}, {"name": "organization_details_section", "editable": false, "columns": [{"name": "column_S3tQ", "fields": ["organization_name", "territory"]}, {"name": "column_KqV1", "fields": ["website", "annual_revenue"]}, {"name": "column_1r67", "fields": ["no_of_employees", "industry"]}]}, {"name": "contact_section", "hidden": true, "editable": false, "columns": [{"name": "column_CeXr", "fields": ["contact"]}, {"name": "column_yHbk", "fields": []}]}, {"name": "contact_details_section", "editable": false, "columns": [{"name": "column_ZTWr", "fields": ["salutation", "email"]}, {"name": "column_tabr", "fields": ["first_name", "mobile_no"]}, {"name": "column_Qjdx", "fields": ["last_name", "gender"]}]}, {"name": "deal_section", "columns": [{"name": "column_mdps", "fields": ["status"]}, {"name": "column_H40H", "fields": ["deal_owner"]}]}]',
|
||||
},
|
||||
"Contact-Quick Entry": {
|
||||
"doctype": "Contact",
|
||||
"layout": '[{"label":"Salutation","columns":1,"fields":["salutation"],"hideLabel":true},{"label":"Full Name","columns":2,"hideBorder":true,"fields":["first_name","last_name"],"hideLabel":true},{"label":"Email","columns":1,"hideBorder":true,"fields":["email_id"],"hideLabel":true},{"label":"Mobile No. & Gender","columns":2,"hideBorder":true,"fields":["mobile_no","gender"],"hideLabel":true},{"label":"Organization","columns":1,"hideBorder":true,"fields":["company_name"],"hideLabel":true},{"label":"Designation","columns":1,"hideBorder":true,"fields":["designation"],"hideLabel":true},{"label":"Address","columns":1,"hideBorder":true,"fields":["address"],"hideLabel":true}]',
|
||||
"layout": '[{"name": "salutation_section", "columns": [{"name": "column_eXks", "fields": ["salutation"]}]}, {"name": "full_name_section", "hideBorder": true, "columns": [{"name": "column_cSxf", "fields": ["first_name"]}, {"name": "column_yBc7", "fields": ["last_name"]}]}, {"name": "email_section", "hideBorder": true, "columns": [{"name": "column_tH3L", "fields": ["email_id"]}]}, {"name": "mobile_gender_section", "hideBorder": true, "columns": [{"name": "column_lrfI", "fields": ["mobile_no"]}, {"name": "column_Tx3n", "fields": ["gender"]}]}, {"name": "organization_section", "hideBorder": true, "columns": [{"name": "column_S0J8", "fields": ["company_name"]}]}, {"name": "designation_section", "hideBorder": true, "columns": [{"name": "column_bsO8", "fields": ["designation"]}]}, {"name": "address_section", "hideBorder": true, "columns": [{"name": "column_W3VY", "fields": ["address"]}]}]',
|
||||
},
|
||||
"CRM Organization-Quick Entry": {
|
||||
"doctype": "CRM Organization",
|
||||
"layout": '[{"label":"Organization Name","columns":1,"fields":["organization_name"],"hideLabel":true},{"label":"Website & Revenue","columns":2,"hideBorder":true,"fields":["website","annual_revenue"],"hideLabel":true},{"label":"Territory","columns":1,"hideBorder":true,"fields":["territory"],"hideLabel":true},{"label":"No of Employees & Industry","columns":2,"hideBorder":true,"fields":["no_of_employees","industry"],"hideLabel":true},{"label":"Address","columns":1,"hideBorder":true,"fields":["address"],"hideLabel":true}]',
|
||||
"layout": '[{"name": "organization_section", "columns": [{"name": "column_zOuv", "fields": ["organization_name"]}]}, {"name": "website_revenue_section", "hideBorder": true, "columns": [{"name": "column_I5Dy", "fields": ["website"]}, {"name": "column_Rgss", "fields": ["annual_revenue"]}]}, {"name": "territory_section", "hideBorder": true, "columns": [{"name": "column_w6ap", "fields": ["territory"]}]}, {"name": "employee_industry_section", "hideBorder": true, "columns": [{"name": "column_u5tZ", "fields": ["no_of_employees"]}, {"name": "column_FFrT", "fields": ["industry"]}]}, {"name": "address_section", "hideBorder": true, "columns": [{"name": "column_O2dk", "fields": ["address"]}]}]',
|
||||
},
|
||||
"Address-Quick Entry": {
|
||||
"doctype": "Address",
|
||||
"layout": '[{"label":"Address","columns":1,"fields":["address_title","address_type","address_line1","address_line2","city","state","country","pincode"],"hideLabel":true}]',
|
||||
"layout": '[{"name": "details_section", "columns": [{"name": "column_uSSG", "fields": ["address_title", "address_type", "address_line1", "address_line2", "city", "state", "country", "pincode"]}]}]',
|
||||
},
|
||||
}
|
||||
|
||||
sidebar_fields_layouts = {
|
||||
"CRM Lead-Side Panel": {
|
||||
"doctype": "CRM Lead",
|
||||
"layout": '[{"label": "Details", "name": "details", "opened": true, "fields": ["organization", "website", "territory", "industry", "job_title", "source", "lead_owner"]}, {"label": "Person", "name": "person_tab", "opened": true, "fields": ["salutation", "first_name", "last_name", "email", "mobile_no"]}]',
|
||||
"layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_kl92", "fields": ["organization", "website", "territory", "industry", "job_title", "source", "lead_owner"]}]}, {"label": "Person", "name": "person_section", "opened": true, "columns": [{"name": "column_XmW2", "fields": ["salutation", "first_name", "last_name", "email", "mobile_no"]}]}]',
|
||||
},
|
||||
"CRM Deal-Side Panel": {
|
||||
"doctype": "CRM Deal",
|
||||
"layout": '[{"label":"Contacts","name":"contacts_section","opened":true,"editable":false,"contacts":[]},{"label":"Organization Details","name":"organization_tab","opened":true,"fields":["organization","website","territory","annual_revenue","close_date","probability","next_step","deal_owner"]}]',
|
||||
"layout": '[{"label": "Contacts", "name": "contacts_section", "opened": true, "editable": false, "contacts": []}, {"label": "Organization Details", "name": "organization_section", "opened": true, "columns": [{"name": "column_na2Q", "fields": ["organization", "website", "territory", "annual_revenue", "close_date", "probability", "next_step", "deal_owner"]}]}]',
|
||||
},
|
||||
"Contact-Side Panel": {
|
||||
"doctype": "Contact",
|
||||
"layout": '[{"label":"Details","name":"details","opened":true,"fields":["salutation","first_name","last_name","email_id","mobile_no","gender","company_name","designation","address"]}]',
|
||||
"layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_eIWl", "fields": ["salutation", "first_name", "last_name", "email_id", "mobile_no", "gender", "company_name", "designation", "address"]}]}]',
|
||||
},
|
||||
"CRM Organization-Side Panel": {
|
||||
"doctype": "CRM Organization",
|
||||
"layout": '[{"label":"Details","name":"details","opened":true,"fields":["organization_name","website","territory","industry","no_of_employees","address"]}]',
|
||||
"layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_IJOV", "fields": ["organization_name", "website", "territory", "industry", "no_of_employees", "address"]}]}]',
|
||||
},
|
||||
}
|
||||
|
||||
data_fields_layouts = {
|
||||
"CRM Lead-Data Fields": {
|
||||
"doctype": "CRM Lead",
|
||||
"layout": '[{"no_tabs":true,"sections":[{"label": "Details", "name": "details", "opened": true, "fields": ["organization", "website", "territory", "industry", "job_title", "source", "lead_owner"]}, {"label": "Person", "name": "person_tab", "opened": true, "fields": ["salutation", "first_name", "last_name", "email", "mobile_no"]}]}]',
|
||||
"layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_ZgLG", "fields": ["organization", "industry", "lead_owner"]}, {"name": "column_TbYq", "fields": ["website", "job_title"]}, {"name": "column_OKSX", "fields": ["territory", "source"]}]}, {"label": "Person", "name": "person_section", "opened": true, "columns": [{"name": "column_6c5g", "fields": ["salutation", "email"]}, {"name": "column_1n7Q", "fields": ["first_name", "mobile_no"]}, {"name": "column_cT6C", "fields": ["last_name"]}]}]',
|
||||
},
|
||||
"CRM Deal-Data Fields": {
|
||||
"doctype": "CRM Deal",
|
||||
"layout": '[{"no_tabs":true,"sections":[{"label":"Organization Details","name":"organization_tab","opened":true,"fields":["organization","website","territory","annual_revenue","close_date","probability","next_step","deal_owner"]}]}]',
|
||||
"layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_z9XL", "fields": ["organization", "annual_revenue", "next_step"]}, {"name": "column_gM4w", "fields": ["website", "close_date", "deal_owner"]}, {"name": "column_gWmE", "fields": ["territory", "probability"]}]}]',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -8,4 +8,5 @@ crm.patches.v1_0.move_crm_note_data_to_fcrm_note
|
||||
crm.patches.v1_0.create_email_template_custom_fields
|
||||
crm.patches.v1_0.create_default_fields_layout #10/12/2024
|
||||
crm.patches.v1_0.create_default_sidebar_fields_layout
|
||||
crm.patches.v1_0.update_deal_quick_entry_layout
|
||||
crm.patches.v1_0.update_deal_quick_entry_layout
|
||||
crm.patches.v1_0.update_fields_layout_to_new_format
|
||||
101
crm/patches/v1_0/update_fields_layout_to_new_format.py
Normal file
101
crm/patches/v1_0/update_fields_layout_to_new_format.py
Normal file
@ -0,0 +1,101 @@
|
||||
import json
|
||||
from math import ceil
|
||||
|
||||
import frappe
|
||||
from frappe.utils import random_string
|
||||
|
||||
|
||||
def execute():
|
||||
layouts = frappe.get_all("CRM Fields Layout", fields=["name", "layout", "type"])
|
||||
|
||||
for layout in layouts:
|
||||
old_layout = layout.layout
|
||||
new_layout = get_new_layout(old_layout, layout.type)
|
||||
|
||||
frappe.db.set_value("CRM Fields Layout", layout.name, "layout", new_layout)
|
||||
|
||||
|
||||
def get_new_layout(old_layout, type):
|
||||
if isinstance(old_layout, str):
|
||||
old_layout = json.loads(old_layout)
|
||||
new_layout = []
|
||||
already_converted = False
|
||||
|
||||
starts_with_sections = False
|
||||
|
||||
if not old_layout[0].get("sections"):
|
||||
starts_with_sections = True
|
||||
|
||||
if starts_with_sections:
|
||||
old_layout = [{"sections": old_layout}]
|
||||
|
||||
for tab in old_layout:
|
||||
new_tab = tab.copy()
|
||||
if "no_tabs" in new_tab:
|
||||
new_tab.pop("no_tabs")
|
||||
new_tab["sections"] = []
|
||||
new_tab["name"] = "tab_" + str(random_string(4))
|
||||
for section in tab.get("sections"):
|
||||
section["name"] = section.get("name") or "section_" + str(random_string(4))
|
||||
|
||||
if section.get("label") == "Select Organization":
|
||||
section["name"] = "organization_section"
|
||||
section["hidden"] = 1
|
||||
elif section.get("label") == "Organization Details":
|
||||
section["name"] = "organization_details_section"
|
||||
elif section.get("label") == "Select Contact":
|
||||
section["name"] = "contact_section"
|
||||
section["hidden"] = 1
|
||||
elif section.get("label") == "Contact Details":
|
||||
section["name"] = "contact_details_section"
|
||||
|
||||
if "contacts" in section:
|
||||
new_tab["sections"].append(section)
|
||||
continue
|
||||
if isinstance(section.get("columns"), list):
|
||||
already_converted = True
|
||||
break
|
||||
column_count = section.get("columns") or 3
|
||||
if type == "Side Panel":
|
||||
column_count = 1
|
||||
fields = section.get("fields") or []
|
||||
|
||||
new_section = section.copy()
|
||||
|
||||
if "fields" in new_section:
|
||||
new_section.pop("fields")
|
||||
new_section["columns"] = []
|
||||
|
||||
if len(fields) == 0:
|
||||
new_section["columns"].append({"name": "column_" + str(random_string(4)), "fields": []})
|
||||
new_tab["sections"].append(new_section)
|
||||
continue
|
||||
|
||||
if len(fields) == 1 and column_count > 1:
|
||||
new_section["columns"].append(
|
||||
{"name": "column_" + str(random_string(4)), "fields": [fields[0]]}
|
||||
)
|
||||
new_section["columns"].append({"name": "column_" + str(random_string(4)), "fields": []})
|
||||
new_tab["sections"].append(new_section)
|
||||
continue
|
||||
|
||||
fields_per_column = ceil(len(fields) / column_count)
|
||||
for i in range(column_count):
|
||||
new_column = {
|
||||
"name": "column_" + str(random_string(4)),
|
||||
"fields": fields[i * fields_per_column : (i + 1) * fields_per_column],
|
||||
}
|
||||
new_section["columns"].append(new_column)
|
||||
new_tab["sections"].append(new_section)
|
||||
new_layout.append(new_tab)
|
||||
|
||||
if starts_with_sections:
|
||||
new_layout = new_layout[0].get("sections")
|
||||
|
||||
if already_converted:
|
||||
new_layout = old_layout
|
||||
|
||||
if type == "Side Panel" and "sections" in old_layout[0]:
|
||||
new_layout = new_layout[0].get("sections")
|
||||
|
||||
return json.dumps(new_layout)
|
||||
@ -33,7 +33,7 @@
|
||||
<div
|
||||
v-for="field in fields"
|
||||
class="border-r border-outline-gray-2 p-2 truncate"
|
||||
:key="field.name"
|
||||
:key="field.fieldname"
|
||||
:title="field.label"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
@ -84,36 +84,71 @@
|
||||
<div
|
||||
class="border-r border-outline-gray-modals h-full"
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
:key="field.fieldname"
|
||||
>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.fieldtype !== 'Check'"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
v-model="row[field.fieldname]"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Link
|
||||
v-if="field.type === 'Link'"
|
||||
v-else-if="field.fieldtype === 'Link'"
|
||||
class="text-sm text-ink-gray-8"
|
||||
v-model="row[field.name]"
|
||||
v-model="row[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'User'"
|
||||
class="form-control"
|
||||
:value="getUser(row[field.fieldname]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (row[field.fieldname] = v)"
|
||||
:placeholder="field.placeholder"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="row[field.fieldname]"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<div
|
||||
v-else-if="field.type === 'Check'"
|
||||
v-else-if="field.fieldtype === 'Check'"
|
||||
class="flex h-full bg-surface-white justify-center items-center"
|
||||
>
|
||||
<Checkbox
|
||||
class="cursor-pointer duration-300"
|
||||
v-model="row[field.name]"
|
||||
v-model="row[field.fieldname]"
|
||||
:disabled="!gridSettings.editable_grid"
|
||||
/>
|
||||
</div>
|
||||
<DatePicker
|
||||
v-else-if="field.type === 'Date'"
|
||||
v-model="row[field.name]"
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
v-model="row[field.fieldname]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
/>
|
||||
<DateTimePicker
|
||||
v-else-if="field.type === 'Datetime'"
|
||||
v-model="row[field.name]"
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
v-model="row[field.fieldname]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
@ -122,26 +157,26 @@
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(
|
||||
field.type,
|
||||
field.fieldtype,
|
||||
)
|
||||
"
|
||||
rows="1"
|
||||
type="textarea"
|
||||
variant="outline"
|
||||
v-model="row[field.name]"
|
||||
v-model="row[field.fieldname]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.type)"
|
||||
v-else-if="['Int'].includes(field.fieldtype)"
|
||||
type="number"
|
||||
variant="outline"
|
||||
v-model="row[field.name]"
|
||||
v-model="row[field.fieldname]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Select'"
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
class="text-sm text-ink-gray-8"
|
||||
type="select"
|
||||
variant="outline"
|
||||
v-model="row[field.name]"
|
||||
v-model="row[field.fieldname]"
|
||||
:options="field.options"
|
||||
/>
|
||||
<FormControl
|
||||
@ -149,7 +184,7 @@
|
||||
class="text-sm text-ink-gray-8"
|
||||
type="text"
|
||||
variant="outline"
|
||||
v-model="row[field.name]"
|
||||
v-model="row[field.fieldname]"
|
||||
:options="field.options"
|
||||
/>
|
||||
</div>
|
||||
@ -170,6 +205,7 @@
|
||||
:index="index"
|
||||
:data="row"
|
||||
:doctype="doctype"
|
||||
:parentDoctype="parentDoctype"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -198,6 +234,7 @@
|
||||
v-if="showGridRowFieldsModal"
|
||||
v-model="showGridRowFieldsModal"
|
||||
:doctype="doctype"
|
||||
:parentDoctype="parentDoctype"
|
||||
/>
|
||||
<GridFieldsEditorModal
|
||||
v-if="showGridFieldsEditorModal"
|
||||
@ -214,7 +251,9 @@ import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
|
||||
import GridRowModal from '@/components/Controls/GridRowModal.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { getRandom, getFormat } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import {
|
||||
FeatherIcon,
|
||||
@ -222,6 +261,7 @@ import {
|
||||
Checkbox,
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
@ -245,6 +285,7 @@ const { getGridViewSettings, getFields, getGridSettings } = getMeta(
|
||||
props.doctype,
|
||||
)
|
||||
getMeta(props.parentDoctype)
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const rows = defineModel()
|
||||
const showRowList = ref(new Array(rows.value?.length || []).fill(false))
|
||||
@ -271,11 +312,9 @@ const fields = computed(() => {
|
||||
|
||||
function getFieldObj(field) {
|
||||
return {
|
||||
label: field.label,
|
||||
name: field.fieldname,
|
||||
type: field.fieldtype,
|
||||
options: field.options,
|
||||
in_list_view: field.in_list_view,
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
placeholder: field.placeholder || field.label,
|
||||
}
|
||||
}
|
||||
|
||||
@ -317,8 +356,8 @@ const toggleSelectRow = (row) => {
|
||||
const addRow = () => {
|
||||
const newRow = {}
|
||||
fields.value?.forEach((field) => {
|
||||
if (field.type === 'Check') newRow[field.name] = false
|
||||
else newRow[field.name] = ''
|
||||
if (field.fieldtype === 'Check') newRow[field.fieldname] = false
|
||||
else newRow[field.fieldname] = ''
|
||||
})
|
||||
newRow.name = getRandom(10)
|
||||
showRowList.value.push(false)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '3xl' }">
|
||||
<Dialog v-model="show" :options="{ size: '4xl' }">
|
||||
<template #body-title>
|
||||
<h3
|
||||
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-ink-gray-9"
|
||||
@ -36,7 +36,13 @@
|
||||
:tabs="tabs.data"
|
||||
:doctype="_doctype"
|
||||
/>
|
||||
<FieldLayout v-else :tabs="tabs.data" :data="{}" :modal="true" />
|
||||
<FieldLayout
|
||||
v-else
|
||||
:tabs="tabs.data"
|
||||
:data="{}"
|
||||
:modal="true"
|
||||
:preview="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -55,6 +61,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'CRM Lead',
|
||||
},
|
||||
parentDoctype: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reload'])
|
||||
@ -66,12 +76,16 @@ const dirty = ref(false)
|
||||
const preview = ref(false)
|
||||
|
||||
function getParams() {
|
||||
return { doctype: _doctype.value, type: 'Grid Row' }
|
||||
return {
|
||||
doctype: _doctype.value,
|
||||
type: 'Grid Row',
|
||||
parent_doctype: props.parentDoctype,
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
cache: ['GridRowFieldsModal', _doctype.value],
|
||||
cache: ['GridRowFieldsModal', _doctype.value, props.parentDoctype],
|
||||
params: getParams(),
|
||||
onSuccess(data) {
|
||||
tabs.originalData = JSON.parse(JSON.stringify(data))
|
||||
@ -101,10 +115,10 @@ function saveChanges() {
|
||||
_tabs.forEach((tab) => {
|
||||
if (!tab.sections) return
|
||||
tab.sections.forEach((section) => {
|
||||
if (!section.fields) return
|
||||
section.fields = section.fields.map(
|
||||
(field) => field.fieldname || field.name,
|
||||
)
|
||||
section.columns.forEach((column) => {
|
||||
if (!column.fields) return
|
||||
column.fields = column.fields.map((field) => field.fieldname)
|
||||
})
|
||||
})
|
||||
})
|
||||
loading.value = true
|
||||
|
||||
@ -41,6 +41,7 @@ const props = defineProps({
|
||||
index: Number,
|
||||
data: Object,
|
||||
doctype: String,
|
||||
parentDoctype: String,
|
||||
})
|
||||
|
||||
const { isManager } = usersStore()
|
||||
@ -50,8 +51,12 @@ const showGridRowFieldsModal = defineModel('showGridRowFieldsModal')
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
cache: ['Grid Row', props.doctype],
|
||||
params: { doctype: props.doctype, type: 'Grid Row' },
|
||||
cache: ['Grid Row', props.doctype, props.parentDoctype],
|
||||
params: {
|
||||
doctype: props.doctype,
|
||||
type: 'Grid Row',
|
||||
parent_doctype: props.parentDoctype,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
|
||||
@ -15,222 +15,240 @@
|
||||
!hasTabs ? 'hidden' : modal ? 'border-outline-gray-modals' : ''
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden" :class="{ 'my-4 sm:my-6': hasTabs }">
|
||||
<div
|
||||
v-for="(section, i) in tab.sections"
|
||||
:key="section.label"
|
||||
class="section"
|
||||
>
|
||||
<div
|
||||
v-if="i != 0"
|
||||
class="w-full"
|
||||
:class="[section.hideBorder ? 'mt-4' : 'h-px border-t my-5']"
|
||||
/>
|
||||
<Section
|
||||
class="text-lg font-medium"
|
||||
:class="{ 'px-3 sm:px-5': hasTabs }"
|
||||
:label="section.label"
|
||||
:hideLabel="section.hideLabel"
|
||||
:opened="section.opened"
|
||||
:collapsible="section.collapsible"
|
||||
collapseIconPosition="right"
|
||||
>
|
||||
<div
|
||||
class="sections overflow-hidden"
|
||||
:class="{ 'my-4 sm:my-6': hasTabs }"
|
||||
>
|
||||
<template v-for="(section, i) in tab.sections" :key="section.name">
|
||||
<div v-if="section.visible" class="section" :data-name="section.name">
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:class="[
|
||||
gridClass(section.columns),
|
||||
{ 'px-3 sm:px-5': hasTabs },
|
||||
{ 'mt-6': !section.hideLabel },
|
||||
]"
|
||||
v-if="i !== firstVisibleIndex(tab.sections)"
|
||||
class="w-full section-border"
|
||||
:class="[section.hideBorder ? 'mt-4' : 'h-px border-t my-5']"
|
||||
/>
|
||||
<Section
|
||||
class="flex sm:flex-row flex-col gap-4 text-lg font-medium"
|
||||
:class="{
|
||||
'px-3 sm:px-5': hasTabs,
|
||||
'mt-6': section.label && !section.hideLabel,
|
||||
}"
|
||||
:labelClass="['text-lg font-medium', { 'px-3 sm:px-5': hasTabs }]"
|
||||
:label="section.label"
|
||||
:hideLabel="section.hideLabel || !section.label"
|
||||
:opened="section.opened"
|
||||
:collapsible="section.collapsible"
|
||||
collapseIconPosition="right"
|
||||
>
|
||||
<div v-for="field in section.fields" :key="field.name">
|
||||
<div class="settings-field">
|
||||
<div
|
||||
v-if="field.type != 'Check'"
|
||||
class="mb-2 text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span
|
||||
class="text-ink-red-3"
|
||||
v-if="
|
||||
field.mandatory ||
|
||||
(field.mandatory_depends_on &&
|
||||
field.mandatory_via_depends_on)
|
||||
"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.type !== 'Check'"
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Grid
|
||||
v-else-if="field.type === 'Table'"
|
||||
v-model="data[field.name]"
|
||||
:doctype="field.options"
|
||||
:parentDoctype="doctype"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Select'"
|
||||
type="select"
|
||||
class="form-control"
|
||||
:class="field.prefix ? 'prefix' : ''"
|
||||
:options="field.options"
|
||||
v-model="data[field.name]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
>
|
||||
<template v-if="field.prefix" #prefix>
|
||||
<IndicatorIcon :class="field.prefix" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div
|
||||
v-else-if="field.type == 'Check'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<FormControl
|
||||
class="form-control"
|
||||
type="checkbox"
|
||||
v-model="data[field.name]"
|
||||
@change="(e) => (data[field.name] = e.target.checked)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<label
|
||||
class="text-sm text-ink-gray-5"
|
||||
@click="
|
||||
() => {
|
||||
if (!Boolean(field.read_only)) {
|
||||
data[field.name] = !data[field.name]
|
||||
}
|
||||
}
|
||||
"
|
||||
<div
|
||||
class="column flex flex-col gap-4 w-full"
|
||||
v-for="column in section.columns"
|
||||
:key="column.name"
|
||||
:data-name="column.name"
|
||||
>
|
||||
<div
|
||||
v-if="column.label && !column.hideLabel"
|
||||
class="text-ink-gray-9 max-w-fit text-base"
|
||||
>
|
||||
{{ column.label }}
|
||||
</div>
|
||||
<template v-for="field in column.fields" :key="field.fieldname">
|
||||
<div v-if="field.visible" class="field">
|
||||
<div
|
||||
v-if="field.fieldtype != 'Check'"
|
||||
class="mb-2 text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span class="text-ink-red-3" v-if="field.mandatory"
|
||||
<span
|
||||
v-if="
|
||||
field.reqd ||
|
||||
(field.mandatory_depends_on &&
|
||||
field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-3"
|
||||
>*</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-1" v-else-if="field.type === 'Link'">
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.fieldtype !== 'Check'"
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Grid
|
||||
v-else-if="field.fieldtype === 'Table'"
|
||||
v-model="data[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:parentDoctype="doctype"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
type="select"
|
||||
class="form-control"
|
||||
:class="field.prefix ? 'prefix' : ''"
|
||||
:options="field.options"
|
||||
v-model="data[field.fieldname]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
>
|
||||
<template v-if="field.prefix" #prefix>
|
||||
<IndicatorIcon :class="field.prefix" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div
|
||||
v-else-if="field.fieldtype == 'Check'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<FormControl
|
||||
class="form-control"
|
||||
type="checkbox"
|
||||
v-model="data[field.fieldname]"
|
||||
@change="
|
||||
(e) => (data[field.fieldname] = e.target.checked)
|
||||
"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<label
|
||||
class="text-sm text-ink-gray-5"
|
||||
@click="
|
||||
() => {
|
||||
if (!Boolean(field.read_only)) {
|
||||
data[field.fieldname] = !data[field.fieldname]
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span class="text-ink-red-3" v-if="field.mandatory"
|
||||
>*</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
v-else-if="field.fieldtype === 'Link'"
|
||||
>
|
||||
<Link
|
||||
class="form-control flex-1 truncate"
|
||||
:value="data[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.fieldname] = v)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
<Button
|
||||
v-if="data[field.fieldname] && field.edit"
|
||||
class="shrink-0"
|
||||
:label="__('Edit')"
|
||||
@click="field.edit(data[field.fieldname])"
|
||||
>
|
||||
<template #prefix>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
class="form-control flex-1 truncate"
|
||||
:value="data[field.name]"
|
||||
v-else-if="field.fieldtype === 'User'"
|
||||
class="form-control"
|
||||
:value="getUser(data[field.fieldname]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.name] = v)"
|
||||
@change="(v) => (data[field.fieldname] = v)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
<Button
|
||||
v-if="data[field.name] && field.edit"
|
||||
class="shrink-0"
|
||||
:label="__('Edit')"
|
||||
@click="field.edit(data[field.name])"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="data[field.fieldname]"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="option.value"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<DateTimePicker
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
v-model="data[field.fieldname]"
|
||||
icon-left=""
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
icon-left=""
|
||||
v-model="data[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(
|
||||
field.fieldtype,
|
||||
)
|
||||
"
|
||||
type="textarea"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.fieldtype)"
|
||||
type="number"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Percent'"
|
||||
type="text"
|
||||
:value="getFormattedPercent(field.fieldname, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Float'"
|
||||
type="text"
|
||||
:value="getFormattedFloat(field.fieldname, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Currency'"
|
||||
type="text"
|
||||
:value="getFormattedCurrency(field.fieldname, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
v-else-if="field.type === 'User'"
|
||||
class="form-control"
|
||||
:value="getUser(data[field.name]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.name] = v)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="data[field.name]"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<DateTimePicker
|
||||
v-else-if="field.type === 'Datetime'"
|
||||
v-model="data[field.name]"
|
||||
icon-left=""
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.type === 'Date'"
|
||||
icon-left=""
|
||||
v-model="data[field.name]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(
|
||||
field.type,
|
||||
)
|
||||
"
|
||||
type="textarea"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.type)"
|
||||
type="number"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Percent'"
|
||||
type="text"
|
||||
:value="getFormattedPercent(field.name, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.name] = flt($event.target.value)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Float'"
|
||||
type="text"
|
||||
:value="getFormattedFloat(field.name, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.name] = flt($event.target.value)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Currency'"
|
||||
type="text"
|
||||
:value="getFormattedCurrency(field.name, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.name] = flt($event.target.value)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
@ -245,7 +263,7 @@ import Link from '@/components/Controls/Link.vue'
|
||||
import Grid from '@/components/Controls/Grid.vue'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getFormat } from '@/utils'
|
||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { Tabs, Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
@ -261,49 +279,105 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
preview: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
getMeta(props.doctype)
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const hasTabs = computed(() => !props.tabs[0].no_tabs)
|
||||
const hasTabs = computed(() => {
|
||||
return (
|
||||
props.tabs.length > 1 || (props.tabs.length == 1 && props.tabs[0].label)
|
||||
)
|
||||
})
|
||||
|
||||
const _tabs = computed(() => {
|
||||
return props.tabs.map((tab) => {
|
||||
tab.sections = tab.sections.map((section) => {
|
||||
section.fields = section.fields.filter(
|
||||
(field) =>
|
||||
(field.type == 'Check' ||
|
||||
(field.read_only && props.data[field.name]) ||
|
||||
!field.read_only) &&
|
||||
(!field.depends_on || field.display_via_depends_on) &&
|
||||
!field.hidden,
|
||||
)
|
||||
return section
|
||||
section.columns = section.columns.map((column) => {
|
||||
column.fields = column.fields.map((field) => {
|
||||
return parsedField(field)
|
||||
})
|
||||
return column
|
||||
})
|
||||
return parsedSection(section)
|
||||
})
|
||||
return tab
|
||||
})
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
function parsedField(field) {
|
||||
if (field.fieldtype == 'Select' && typeof field.options === 'string') {
|
||||
field.options = field.options.split('\n').map((option) => {
|
||||
return { label: option, value: option }
|
||||
})
|
||||
|
||||
function gridClass(columns) {
|
||||
columns = columns || 3
|
||||
let griColsMap = {
|
||||
1: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
if (field.options[0].value !== '') {
|
||||
field.options.unshift({ label: '', value: '' })
|
||||
}
|
||||
}
|
||||
return griColsMap[columns]
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options === 'User') {
|
||||
field.options = field.options
|
||||
field.fieldtype = 'User'
|
||||
}
|
||||
|
||||
let _field = {
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
placeholder: field.placeholder || field.label,
|
||||
display_via_depends_on: evaluateDependsOnValue(
|
||||
field.depends_on,
|
||||
props.data,
|
||||
),
|
||||
mandatory_via_depends_on: evaluateDependsOnValue(
|
||||
field.mandatory_depends_on,
|
||||
props.data,
|
||||
),
|
||||
}
|
||||
|
||||
_field.visible = isFieldVisible(_field)
|
||||
return _field
|
||||
}
|
||||
|
||||
function parsedSection(section) {
|
||||
section.visible = section.columns.some((column) =>
|
||||
column.fields.some((field) => field.visible),
|
||||
)
|
||||
|
||||
// to handle special case
|
||||
if (section.hidden) {
|
||||
section.visible = false
|
||||
}
|
||||
return section
|
||||
}
|
||||
|
||||
function isFieldVisible(field) {
|
||||
if (props.preview) return true
|
||||
return (
|
||||
(field.fieldtype == 'Check' ||
|
||||
(field.read_only && props.data[field.fieldname]) ||
|
||||
!field.read_only) &&
|
||||
(!field.depends_on || field.display_via_depends_on) &&
|
||||
!field.hidden
|
||||
)
|
||||
}
|
||||
|
||||
function firstVisibleIndex(sections) {
|
||||
return sections.findIndex((section) => section.visible)
|
||||
}
|
||||
|
||||
const tabIndex = ref(0)
|
||||
|
||||
const getPlaceholder = (field) => {
|
||||
if (field.placeholder) {
|
||||
return __(field.placeholder)
|
||||
}
|
||||
if (['Select', 'Link'].includes(field.type)) {
|
||||
if (['Select', 'Link'].includes(field.fieldtype)) {
|
||||
return __('Select {0}', [__(field.label)])
|
||||
} else {
|
||||
return __('Enter {0}', [__(field.label)])
|
||||
@ -315,12 +389,4 @@ const getPlaceholder = (field) => {
|
||||
:deep(.form-control.prefix select) {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section:has(.settings-field) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
class="flex items-center gap-2 text-base bg-surface-gray-2 rounded py-2 px-2.5"
|
||||
>
|
||||
<Draggable
|
||||
v-if="tabs.length && !tabs[tabIndex].no_tabs"
|
||||
v-if="tabs.length && tabs[tabIndex].label"
|
||||
:list="tabs"
|
||||
item-key="label"
|
||||
item-key="name"
|
||||
class="flex items-center gap-2"
|
||||
@end="(e) => (tabIndex = e.newIndex)"
|
||||
>
|
||||
@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-if="!tab.no_tabs && tabIndex == i"
|
||||
v-if="tab.label && tabIndex == i"
|
||||
:options="getTabOptions(tab)"
|
||||
class="!h-4"
|
||||
@click.stop
|
||||
@ -66,14 +66,16 @@
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-show="tabIndex == i" v-for="(tab, i) in tabs" :key="tab.label">
|
||||
<div v-show="tabIndex == i" v-for="(tab, i) in tabs" :key="tab.name">
|
||||
<Draggable
|
||||
:list="tab.sections"
|
||||
item-key="label"
|
||||
item-key="name"
|
||||
class="flex flex-col gap-5.5"
|
||||
>
|
||||
<template #item="{ element: section }">
|
||||
<div class="flex flex-col gap-1.5 p-2.5 bg-surface-gray-2 rounded">
|
||||
<template #item="{ element: section, index: i }">
|
||||
<div
|
||||
class="section flex flex-col gap-1.5 p-2.5 bg-surface-gray-2 rounded cursor-grab"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex h-7 max-w-fit cursor-pointer items-center gap-2 text-base font-medium leading-4 text-ink-gray-9"
|
||||
@ -82,9 +84,12 @@
|
||||
<div
|
||||
v-if="!section.editingLabel"
|
||||
class="flex items-center gap-2"
|
||||
:class="{ 'text-ink-gray-3': section.hideLabel }"
|
||||
:class="{
|
||||
'text-ink-gray-3': section.hideLabel || !section.label,
|
||||
italic: !section.label,
|
||||
}"
|
||||
>
|
||||
{{ __(section.label) || __('Untitled') }}
|
||||
{{ __(section.label) || __('No label') }}
|
||||
<FeatherIcon
|
||||
v-if="section.collapsible"
|
||||
name="chevron-down"
|
||||
@ -106,7 +111,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown :options="getSectionOptions(section)">
|
||||
<Dropdown :options="getSectionOptions(i, section, tab)">
|
||||
<template #default>
|
||||
<Button variant="ghost">
|
||||
<FeatherIcon name="more-horizontal" class="h-4" />
|
||||
@ -115,61 +120,76 @@
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Draggable
|
||||
:list="section.fields"
|
||||
group="fields"
|
||||
item-key="label"
|
||||
class="grid gap-1.5"
|
||||
:class="gridClass(section.columns)"
|
||||
handle=".cursor-grab"
|
||||
class="flex gap-2"
|
||||
:list="section.columns"
|
||||
group="columns"
|
||||
item-key="name"
|
||||
>
|
||||
<template #item="{ element: field }">
|
||||
<template #item="{ element: column }">
|
||||
<div
|
||||
class="px-2.5 py-2 border border-outline-gray-2 rounded text-base bg-surface-modal text-ink-gray-8 flex items-center leading-4 justify-between gap-2"
|
||||
class="flex flex-col gap-1.5 flex-1 p-2 border border-dashed border-outline-gray-2 rounded bg-surface-modal cursor-grab"
|
||||
>
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
||||
<div class="truncate">{{ field.label }}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!size-4 rounded-sm"
|
||||
icon="x"
|
||||
@click="
|
||||
section.fields.splice(section.fields.indexOf(field), 1)
|
||||
"
|
||||
/>
|
||||
<Draggable
|
||||
:list="column.fields"
|
||||
group="fields"
|
||||
item-key="fieldname"
|
||||
class="flex flex-col gap-1.5"
|
||||
handle=".cursor-grab"
|
||||
>
|
||||
<template #item="{ element: field }">
|
||||
<div
|
||||
class="field px-2.5 py-2 border border-outline-gray-2 rounded text-base bg-surface-modal text-ink-gray-8 flex items-center leading-4 justify-between gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
||||
<div class="truncate">{{ field.label }}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!size-4 rounded-sm"
|
||||
icon="x"
|
||||
@click="
|
||||
column.fields.splice(
|
||||
column.fields.indexOf(field),
|
||||
1,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<Autocomplete
|
||||
v-if="fields.data"
|
||||
value=""
|
||||
:options="fields.data"
|
||||
@change="(e) => addField(column, e)"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<div class="gap-2 w-full">
|
||||
<Button
|
||||
class="w-full !h-8 !bg-surface-modal"
|
||||
variant="outline"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Field')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<div class="flex flex-col gap-1 text-ink-gray-9">
|
||||
<div>{{ option.label }}</div>
|
||||
<div class="text-ink-gray-4 text-sm">
|
||||
{{ `${option.fieldname} - ${option.fieldtype}` }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<Autocomplete
|
||||
v-if="fields.data"
|
||||
value=""
|
||||
:options="fields.data"
|
||||
@change="(e) => addField(section, e)"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<div class="gap-2 w-full">
|
||||
<Button
|
||||
class="w-full !h-8 !bg-surface-modal"
|
||||
variant="outline"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Field')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<div class="flex flex-col gap-1 text-ink-gray-9">
|
||||
<div>{{ option.label }}</div>
|
||||
<div class="text-ink-gray-4 text-sm">
|
||||
{{ `${option.fieldname} - ${option.fieldtype}` }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
@ -181,8 +201,9 @@
|
||||
@click="
|
||||
tabs[tabIndex].sections.push({
|
||||
label: __('New Section'),
|
||||
name: 'section_' + getRandom(),
|
||||
opened: true,
|
||||
fields: [],
|
||||
columns: [{ name: 'column_' + getRandom(), fields: [] }],
|
||||
})
|
||||
"
|
||||
>
|
||||
@ -198,6 +219,7 @@
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { getRandom } from '@/utils'
|
||||
import { Dropdown, createResource } from 'frappe-ui'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
@ -208,7 +230,7 @@ const props = defineProps({
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const slotName = computed(() => {
|
||||
if (props.tabs.length == 1 && props.tabs[0].no_tabs) {
|
||||
if (props.tabs.length == 1 && !props.tabs[0].label) {
|
||||
return 'prefix'
|
||||
}
|
||||
return 'default'
|
||||
@ -252,13 +274,15 @@ const fields = createResource({
|
||||
|
||||
for (let tab of props.tabs) {
|
||||
for (let section of tab.sections) {
|
||||
existingFields = existingFields.concat(section.fields)
|
||||
for (let column of section.columns) {
|
||||
existingFields = existingFields.concat(column.fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data.filter((field) => {
|
||||
return (
|
||||
!existingFields.find((f) => f.name === field.fieldname) &&
|
||||
!existingFields.find((f) => f.fieldname === field.fieldname) &&
|
||||
!restrictedFields.includes(field.fieldname)
|
||||
)
|
||||
})
|
||||
@ -266,37 +290,37 @@ const fields = createResource({
|
||||
})
|
||||
|
||||
function addTab() {
|
||||
if (props.tabs.length == 1 && props.tabs[0].no_tabs) {
|
||||
delete props.tabs[0].no_tabs
|
||||
if (props.tabs.length == 1 && !props.tabs[0].label) {
|
||||
props.tabs[0].label = __('New Tab')
|
||||
return
|
||||
}
|
||||
props.tabs.push({ label: __('New Tab'), sections: [] })
|
||||
|
||||
props.tabs.push({
|
||||
label: __('New Tab'),
|
||||
name: 'tab_' + getRandom(),
|
||||
sections: [],
|
||||
})
|
||||
tabIndex.value = props.tabs.length ? props.tabs.length - 1 : 0
|
||||
}
|
||||
|
||||
function addField(section, field) {
|
||||
function addField(column, field) {
|
||||
if (!field) return
|
||||
let newFieldObj = {
|
||||
...field,
|
||||
name: field.fieldname,
|
||||
type: field.fieldtype,
|
||||
}
|
||||
section.fields.push(newFieldObj)
|
||||
column.fields.push(field)
|
||||
}
|
||||
|
||||
function getTabOptions(tab) {
|
||||
return [
|
||||
{
|
||||
label: 'Edit',
|
||||
label: __('Edit'),
|
||||
icon: 'edit',
|
||||
onClick: () => (tab.editingLabel = true),
|
||||
},
|
||||
{
|
||||
label: 'Remove tab',
|
||||
label: __('Remove tab'),
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
if (props.tabs.length == 1) {
|
||||
props.tabs[0].no_tabs = true
|
||||
props.tabs[0].label = ''
|
||||
return
|
||||
}
|
||||
props.tabs.splice(tabIndex.value, 1)
|
||||
@ -306,96 +330,146 @@ function getTabOptions(tab) {
|
||||
]
|
||||
}
|
||||
|
||||
function getSectionOptions(section) {
|
||||
function getSectionOptions(i, section, tab) {
|
||||
let column = section.columns[section.columns.length - 1]
|
||||
return [
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'edit',
|
||||
onClick: () => (section.editingLabel = true),
|
||||
condition: () => section.editable !== false,
|
||||
group: __('Section'),
|
||||
items: [
|
||||
{
|
||||
label: __('Edit'),
|
||||
icon: 'edit',
|
||||
onClick: () => (section.editingLabel = true),
|
||||
},
|
||||
{
|
||||
label: section.collapsible ? __('Uncollapsible') : __('Collapsible'),
|
||||
icon: section.collapsible ? 'chevron-up' : 'chevron-down',
|
||||
onClick: () => (section.collapsible = !section.collapsible),
|
||||
},
|
||||
{
|
||||
label: section.hideLabel ? __('Show label') : __('Hide label'),
|
||||
icon: section.hideLabel ? 'eye' : 'eye-off',
|
||||
onClick: () => (section.hideLabel = !section.hideLabel),
|
||||
},
|
||||
{
|
||||
label: section.hideBorder ? __('Show border') : __('Hide border'),
|
||||
icon: 'minus',
|
||||
onClick: () => (section.hideBorder = !section.hideBorder),
|
||||
},
|
||||
{
|
||||
label: __('Remove section'),
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
tab.sections.splice(tab.sections.indexOf(section), 1)
|
||||
},
|
||||
condition: () => section.editable !== false,
|
||||
},
|
||||
{
|
||||
label: __('Remove and move columns to {0} section', [
|
||||
i == 0 ? __('next') : __('previous'),
|
||||
]),
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
let targetSection = tab.sections[i == 0 ? i + 1 : i - 1]
|
||||
if (i == 0) {
|
||||
targetSection.columns = section.columns.concat(
|
||||
targetSection.columns,
|
||||
)
|
||||
} else {
|
||||
targetSection.columns = targetSection.columns.concat(
|
||||
section.columns,
|
||||
)
|
||||
}
|
||||
tab.sections.splice(tab.sections.indexOf(section), 1)
|
||||
},
|
||||
condition: () => section.editable !== false && section.columns.length,
|
||||
},
|
||||
{
|
||||
label: __('Move to previous tab'),
|
||||
icon: 'corner-up-left',
|
||||
onClick: () => {
|
||||
let previousTab = props.tabs[tabIndex.value - 1]
|
||||
previousTab.sections.push(section)
|
||||
props.tabs[tabIndex.value].sections.splice(
|
||||
props.tabs[tabIndex.value].sections.indexOf(section),
|
||||
1,
|
||||
)
|
||||
tabIndex.value -= 1
|
||||
},
|
||||
condition: () => props.tabs[tabIndex.value - 1],
|
||||
},
|
||||
{
|
||||
label: __('Move to next tab'),
|
||||
icon: 'corner-up-right',
|
||||
onClick: () => {
|
||||
let nextTab = props.tabs[tabIndex.value + 1]
|
||||
nextTab.sections.push(section)
|
||||
props.tabs[tabIndex.value].sections.splice(
|
||||
props.tabs[tabIndex.value].sections.indexOf(section),
|
||||
1,
|
||||
)
|
||||
tabIndex.value += 1
|
||||
},
|
||||
condition: () => props.tabs[tabIndex.value + 1],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: section.collapsible ? 'Uncollapsible' : 'Collapsible',
|
||||
icon: section.collapsible ? 'chevron-up' : 'chevron-down',
|
||||
onClick: () => (section.collapsible = !section.collapsible),
|
||||
},
|
||||
{
|
||||
label: section.hideLabel ? 'Show label' : 'Hide label',
|
||||
icon: section.hideLabel ? 'eye' : 'eye-off',
|
||||
onClick: () => (section.hideLabel = !section.hideLabel),
|
||||
},
|
||||
{
|
||||
label: section.hideBorder ? 'Show border' : 'Hide border',
|
||||
icon: 'minus',
|
||||
onClick: () => (section.hideBorder = !section.hideBorder),
|
||||
},
|
||||
{
|
||||
label: 'Add column',
|
||||
icon: 'columns',
|
||||
onClick: () =>
|
||||
(section.columns = section.columns ? section.columns + 1 : 4),
|
||||
condition: () => !section.columns || section.columns < 4,
|
||||
},
|
||||
{
|
||||
label: 'Remove column',
|
||||
icon: 'columns',
|
||||
onClick: () =>
|
||||
(section.columns = section.columns ? section.columns - 1 : 2),
|
||||
condition: () => !section.columns || section.columns > 1,
|
||||
},
|
||||
{
|
||||
label: 'Remove section',
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
let currentTab = props.tabs[tabIndex.value]
|
||||
currentTab.sections.splice(currentTab.sections.indexOf(section), 1)
|
||||
},
|
||||
condition: () => section.editable !== false,
|
||||
},
|
||||
{
|
||||
label: 'Move to previous tab',
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
let previousTab = props.tabs[tabIndex.value - 1]
|
||||
previousTab.sections.push(section)
|
||||
props.tabs[tabIndex.value].sections.splice(
|
||||
props.tabs[tabIndex.value].sections.indexOf(section),
|
||||
1,
|
||||
)
|
||||
tabIndex.value -= 1
|
||||
},
|
||||
condition: () =>
|
||||
section.editable !== false && props.tabs[tabIndex.value - 1],
|
||||
},
|
||||
{
|
||||
label: 'Move to next tab',
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
let nextTab = props.tabs[tabIndex.value + 1]
|
||||
nextTab.sections.push(section)
|
||||
props.tabs[tabIndex.value].sections.splice(
|
||||
props.tabs[tabIndex.value].sections.indexOf(section),
|
||||
1,
|
||||
)
|
||||
tabIndex.value += 1
|
||||
},
|
||||
condition: () =>
|
||||
section.editable !== false && props.tabs[tabIndex.value + 1],
|
||||
group: __('Column'),
|
||||
items: [
|
||||
{
|
||||
label: __('Add column'),
|
||||
icon: 'columns',
|
||||
onClick: () => {
|
||||
section.columns.push({
|
||||
label: '',
|
||||
name: 'column_' + getRandom(),
|
||||
fields: [],
|
||||
})
|
||||
},
|
||||
condition: () => section.columns.length < 4,
|
||||
},
|
||||
{
|
||||
label: __('Remove column'),
|
||||
icon: 'trash-2',
|
||||
onClick: () => section.columns.pop(),
|
||||
condition: () => section.columns.length > 1,
|
||||
},
|
||||
{
|
||||
label: __('Remove and move fields to previous column'),
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
let previousColumn = section.columns[section.columns.length - 2]
|
||||
previousColumn.fields = previousColumn.fields.concat(column.fields)
|
||||
section.columns.pop()
|
||||
},
|
||||
condition: () => section.columns.length > 1 && column.fields.length,
|
||||
},
|
||||
{
|
||||
label: __('Move to next section'),
|
||||
icon: 'corner-up-right',
|
||||
onClick: () => {
|
||||
let nextSection = tab.sections[i + 1]
|
||||
nextSection.columns.push(column)
|
||||
section.columns.pop()
|
||||
},
|
||||
condition: () => tab.sections[i + 1],
|
||||
},
|
||||
{
|
||||
label: __('Move to previous section'),
|
||||
icon: 'corner-up-left',
|
||||
onClick: () => {
|
||||
let previousSection = tab.sections[i - 1]
|
||||
previousSection.columns.push(column)
|
||||
section.columns.pop()
|
||||
},
|
||||
condition: () => tab.sections[i - 1],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function gridClass(columns) {
|
||||
columns = columns || 3
|
||||
let griColsMap = {
|
||||
1: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
return griColsMap[columns]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.doctype,
|
||||
() => fields.fetch(params.value),
|
||||
|
||||
@ -22,13 +22,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredSections.length">
|
||||
<FieldLayout
|
||||
:tabs="filteredSections"
|
||||
:data="_contact"
|
||||
doctype="Contact"
|
||||
/>
|
||||
</div>
|
||||
<FieldLayout
|
||||
v-if="tabs.data?.length"
|
||||
:tabs="tabs.data"
|
||||
:data="_contact"
|
||||
doctype="Contact"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 pb-7 pt-4 sm:px-6">
|
||||
<div class="space-y-2">
|
||||
@ -43,17 +42,15 @@
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<AddressModal v-model="showAddressModal" v-model:address="_address" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FieldLayout from '@/components/FieldLayout.vue'
|
||||
import AddressModal from '@/components/Modals/AddressModal.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { capture } from '@/telemetry'
|
||||
import { call, createResource } from 'frappe-ui'
|
||||
import { ref, nextTick, watch, computed } from 'vue'
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
@ -70,6 +67,8 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openAddressModal'])
|
||||
|
||||
const { isManager } = usersStore()
|
||||
|
||||
const router = useRouter()
|
||||
@ -78,9 +77,6 @@ const show = defineModel()
|
||||
const loading = ref(false)
|
||||
|
||||
let _contact = ref({})
|
||||
let _address = ref({})
|
||||
|
||||
const showAddressModal = ref(false)
|
||||
|
||||
async function createContact() {
|
||||
if (_contact.value.email_id) {
|
||||
@ -125,43 +121,33 @@ const tabs = createResource({
|
||||
transform: (_tabs) => {
|
||||
return _tabs.forEach((tab) => {
|
||||
tab.sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
if (field.type === 'Table') {
|
||||
_contact.value[field.name] = []
|
||||
}
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (field.fieldname == 'email_id') {
|
||||
field.read_only = false
|
||||
} else if (field.fieldname == 'mobile_no') {
|
||||
field.read_only = false
|
||||
} else if (field.fieldname == 'address') {
|
||||
field.create = (value, close) => {
|
||||
_contact.value.address = value
|
||||
emit('openAddressModal')
|
||||
show.value = false
|
||||
close()
|
||||
}
|
||||
field.edit = (address) => {
|
||||
emit('openAddressModal', address)
|
||||
show.value = false
|
||||
}
|
||||
} else if (field.fieldtype === 'Table') {
|
||||
_contact.value[field.fieldname] = []
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const filteredSections = computed(() => {
|
||||
let allSections = tabs.data?.[0]?.sections || []
|
||||
if (!allSections.length) return []
|
||||
|
||||
allSections.forEach((s) => {
|
||||
s.fields.forEach((field) => {
|
||||
if (field.name == 'address') {
|
||||
field.create = (value, close) => {
|
||||
_contact.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
}
|
||||
field.edit = async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return [{ no_tabs: true, sections: allSections }]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => show.value,
|
||||
(value) => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '3xl' }">
|
||||
<Dialog v-model="show" :options="{ size: '4xl' }">
|
||||
<template #body-title>
|
||||
<h3
|
||||
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-ink-gray-9"
|
||||
@ -36,7 +36,13 @@
|
||||
:tabs="tabs.data"
|
||||
:doctype="_doctype"
|
||||
/>
|
||||
<FieldLayout v-else :tabs="tabs.data" :data="{}" :modal="true" />
|
||||
<FieldLayout
|
||||
v-else
|
||||
:tabs="tabs.data"
|
||||
:data="{}"
|
||||
:modal="true"
|
||||
:preview="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -101,10 +107,12 @@ function saveChanges() {
|
||||
_tabs.forEach((tab) => {
|
||||
if (!tab.sections) return
|
||||
tab.sections.forEach((section) => {
|
||||
if (!section.fields) return
|
||||
section.fields = section.fields.map(
|
||||
(field) => field.fieldname || field.name,
|
||||
)
|
||||
section.columns.forEach((column) => {
|
||||
if (!column.fields) return
|
||||
column.fields = column.fields.map(
|
||||
(field) => field.fieldname || field.name,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
loading.value = true
|
||||
|
||||
@ -23,20 +23,33 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="flex items-center gap-3 text-sm text-ink-gray-5">
|
||||
<div
|
||||
v-if="hasOrganizationSections || hasContactSections"
|
||||
class="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-if="hasOrganizationSections"
|
||||
class="flex items-center gap-3 text-sm text-ink-gray-5"
|
||||
>
|
||||
<div>{{ __('Choose Existing Organization') }}</div>
|
||||
<Switch v-model="chooseExistingOrganization" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-ink-gray-5">
|
||||
<div
|
||||
v-if="hasContactSections"
|
||||
class="flex items-center gap-3 text-sm text-ink-gray-5"
|
||||
>
|
||||
<div>{{ __('Choose Existing Contact') }}</div>
|
||||
<Switch v-model="chooseExistingContact" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px w-full border-t my-5" />
|
||||
<div
|
||||
v-if="hasOrganizationSections || hasContactSections"
|
||||
class="h-px w-full border-t my-5"
|
||||
/>
|
||||
<FieldLayout
|
||||
v-if="filteredSections.length"
|
||||
:tabs="filteredSections"
|
||||
ref="fieldLayoutRef"
|
||||
v-if="tabs.data?.length"
|
||||
:tabs="tabs.data"
|
||||
:data="deal"
|
||||
doctype="CRM Deal"
|
||||
/>
|
||||
@ -64,7 +77,7 @@ import { usersStore } from '@/stores/users'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Switch, createResource } from 'frappe-ui'
|
||||
import { computed, ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { computed, ref, reactive, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
@ -97,9 +110,32 @@ const deal = reactive({
|
||||
deal_owner: '',
|
||||
})
|
||||
|
||||
const hasOrganizationSections = ref(false)
|
||||
const hasContactSections = ref(false)
|
||||
|
||||
const isDealCreating = ref(false)
|
||||
const chooseExistingContact = ref(false)
|
||||
const chooseExistingOrganization = ref(false)
|
||||
const fieldLayoutRef = ref(null)
|
||||
|
||||
watch(
|
||||
[chooseExistingOrganization, chooseExistingContact],
|
||||
([organization, contact]) => {
|
||||
tabs.data.forEach((tab) => {
|
||||
tab.sections.forEach((section) => {
|
||||
if (section.name === 'organization_section') {
|
||||
section.hidden = !organization
|
||||
} else if (section.name === 'organization_details_section') {
|
||||
section.hidden = organization
|
||||
} else if (section.name === 'contact_section') {
|
||||
section.hidden = !contact
|
||||
} else if (section.name === 'contact_details_section') {
|
||||
section.hidden = contact
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
@ -109,66 +145,37 @@ const tabs = createResource({
|
||||
transform: (_tabs) => {
|
||||
return _tabs.forEach((tab) => {
|
||||
tab.sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
if (field.name == 'status') {
|
||||
field.type = 'Select'
|
||||
field.options = dealStatuses.value
|
||||
field.prefix = getDealStatus(deal.status).iconColorClass
|
||||
} else if (field.name == 'deal_owner') {
|
||||
field.type = 'User'
|
||||
section.columns.forEach((column) => {
|
||||
if (
|
||||
['organization_section', 'organization_details_section'].includes(
|
||||
section.name,
|
||||
)
|
||||
) {
|
||||
hasOrganizationSections.value = true
|
||||
} else if (
|
||||
['contact_section', 'contact_details_section'].includes(
|
||||
section.name,
|
||||
)
|
||||
) {
|
||||
hasContactSections.value = true
|
||||
}
|
||||
column.fields.forEach((field) => {
|
||||
if (field.fieldname == 'status') {
|
||||
field.fieldtype = 'Select'
|
||||
field.options = dealStatuses.value
|
||||
field.prefix = getDealStatus(deal.status).iconColorClass
|
||||
}
|
||||
|
||||
if (field.type === 'Table') {
|
||||
deal[field.name] = []
|
||||
}
|
||||
if (field.fieldtype === 'Table') {
|
||||
deal[field.fieldname] = []
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const filteredSections = computed(() => {
|
||||
let allSections = tabs.data?.[0]?.sections || []
|
||||
if (!allSections.length) return []
|
||||
|
||||
let _filteredSections = []
|
||||
|
||||
if (chooseExistingOrganization.value) {
|
||||
_filteredSections.push(
|
||||
allSections.find((s) => s.label === 'Select Organization'),
|
||||
)
|
||||
} else {
|
||||
_filteredSections.push(
|
||||
allSections.find((s) => s.label === 'Organization Details'),
|
||||
)
|
||||
}
|
||||
|
||||
if (chooseExistingContact.value) {
|
||||
_filteredSections.push(
|
||||
allSections.find((s) => s.label === 'Select Contact'),
|
||||
)
|
||||
} else {
|
||||
_filteredSections.push(
|
||||
allSections.find((s) => s.label === 'Contact Details'),
|
||||
)
|
||||
}
|
||||
|
||||
allSections.forEach((s) => {
|
||||
if (
|
||||
![
|
||||
'Select Organization',
|
||||
'Organization Details',
|
||||
'Select Contact',
|
||||
'Contact Details',
|
||||
].includes(s.label)
|
||||
) {
|
||||
_filteredSections.push(s)
|
||||
}
|
||||
})
|
||||
|
||||
return [{ no_tabs: true, sections: _filteredSections }]
|
||||
})
|
||||
|
||||
const dealStatuses = computed(() => {
|
||||
let statuses = statusOptions('deal')
|
||||
if (!deal.status) {
|
||||
|
||||
@ -71,18 +71,18 @@ const tabs = createResource({
|
||||
transform: (_tabs) => {
|
||||
return _tabs.forEach((tab) => {
|
||||
tab.sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
if (field.name == 'status') {
|
||||
field.type = 'Select'
|
||||
field.options = leadStatuses.value
|
||||
field.prefix = getLeadStatus(lead.status).iconColorClass
|
||||
} else if (field.name == 'lead_owner') {
|
||||
field.type = 'User'
|
||||
}
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (field.fieldname == 'status') {
|
||||
field.fieldtype = 'Select'
|
||||
field.options = leadStatuses.value
|
||||
field.prefix = getLeadStatus(lead.status).iconColorClass
|
||||
}
|
||||
|
||||
if (field.type === 'Table') {
|
||||
lead[field.name] = []
|
||||
}
|
||||
if (field.fieldtype === 'Table') {
|
||||
lead[field.fieldname] = []
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -22,13 +22,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredSections.length">
|
||||
<FieldLayout
|
||||
:tabs="filteredSections"
|
||||
:data="_organization"
|
||||
doctype="CRM Organization"
|
||||
/>
|
||||
</div>
|
||||
<FieldLayout
|
||||
v-if="tabs.data?.length"
|
||||
:tabs="tabs.data"
|
||||
:data="_organization"
|
||||
doctype="CRM Organization"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 pb-7 pt-4 sm:px-6">
|
||||
<div class="space-y-2">
|
||||
@ -43,17 +42,15 @@
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<AddressModal v-model="showAddressModal" v-model:address="_address" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FieldLayout from '@/components/FieldLayout.vue'
|
||||
import AddressModal from '@/components/Modals/AddressModal.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { capture } from '@/telemetry'
|
||||
import { call, FeatherIcon, createResource } from 'frappe-ui'
|
||||
import { ref, nextTick, watch, computed } from 'vue'
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
@ -66,6 +63,8 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openAddressModal'])
|
||||
|
||||
const { isManager } = usersStore()
|
||||
|
||||
const router = useRouter()
|
||||
@ -75,8 +74,6 @@ const organization = defineModel('organization')
|
||||
const loading = ref(false)
|
||||
const title = ref(null)
|
||||
|
||||
let _address = ref({})
|
||||
|
||||
let _organization = ref({
|
||||
organization_name: '',
|
||||
website: '',
|
||||
@ -85,8 +82,6 @@ let _organization = ref({
|
||||
industry: '',
|
||||
})
|
||||
|
||||
const showAddressModal = ref(false)
|
||||
|
||||
let doc = ref({})
|
||||
|
||||
async function createOrganization() {
|
||||
@ -124,43 +119,29 @@ const tabs = createResource({
|
||||
transform: (_tabs) => {
|
||||
return _tabs.forEach((tab) => {
|
||||
tab.sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
if (field.type === 'Table') {
|
||||
_organization.value[field.name] = []
|
||||
}
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (field.fieldname == 'address') {
|
||||
field.create = (value, close) => {
|
||||
_organization.value.address = value
|
||||
emit('openAddressModal')
|
||||
show.value = false
|
||||
close()
|
||||
}
|
||||
field.edit = (address) => {
|
||||
emit('openAddressModal', address)
|
||||
show.value = false
|
||||
}
|
||||
} else if (field.fieldtype === 'Table') {
|
||||
_organization.value[field.fieldname] = []
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const filteredSections = computed(() => {
|
||||
let allSections = tabs.data?.[0]?.sections || []
|
||||
if (!allSections.length) return []
|
||||
|
||||
allSections.forEach((s) => {
|
||||
s.fields.forEach((field) => {
|
||||
if (field.name == 'address') {
|
||||
field.create = (value, close) => {
|
||||
_organization.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
}
|
||||
field.edit = async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return [{ no_tabs: true, sections: allSections }]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => show.value,
|
||||
(value) => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '3xl' }">
|
||||
<Dialog v-model="show" :options="{ size: '4xl' }">
|
||||
<template #body-title>
|
||||
<h3
|
||||
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-ink-gray-9"
|
||||
@ -36,7 +36,7 @@
|
||||
:tabs="tabs.data"
|
||||
:doctype="_doctype"
|
||||
/>
|
||||
<FieldLayout v-else :tabs="tabs.data" :data="{}" />
|
||||
<FieldLayout v-else :tabs="tabs.data" :data="{}" :preview="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -99,10 +99,10 @@ function saveChanges() {
|
||||
_tabs.forEach((tab) => {
|
||||
if (!tab.sections) return
|
||||
tab.sections.forEach((section) => {
|
||||
if (!section.fields) return
|
||||
section.fields = section.fields.map(
|
||||
(field) => field.fieldname || field.name,
|
||||
)
|
||||
section.columns.forEach((column) => {
|
||||
if (!column.fields) return
|
||||
column.fields = column.fields.map((field) => field.fieldname)
|
||||
})
|
||||
})
|
||||
})
|
||||
loading.value = true
|
||||
|
||||
@ -37,26 +37,20 @@
|
||||
:doctype="_doctype"
|
||||
/>
|
||||
<div v-if="preview" class="flex flex-1 flex-col border rounded">
|
||||
<div
|
||||
v-for="(section, i) in tabs.data[0].sections"
|
||||
:key="section.label"
|
||||
class="flex flex-col py-1.5 px-1"
|
||||
:class="{
|
||||
'border-b': i !== tabs.data[0].sections?.length - 1,
|
||||
}"
|
||||
<SidePanelLayout
|
||||
v-model="data"
|
||||
:sections="tabs.data[0].sections"
|
||||
:doctype="_doctype"
|
||||
:preview="true"
|
||||
v-slot="{ section }"
|
||||
>
|
||||
<Section
|
||||
class="p-2"
|
||||
:label="section.label"
|
||||
:opened="section.opened"
|
||||
<div
|
||||
v-if="section.name == 'contacts_section'"
|
||||
class="flex h-16 items-center justify-center text-base text-ink-gray-5"
|
||||
>
|
||||
<SidePanelLayout
|
||||
:fields="section.fields"
|
||||
:isLastSection="i == section.data?.length - 1"
|
||||
v-model="data"
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
{{ __('No contacts added') }}
|
||||
</div>
|
||||
</SidePanelLayout>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@ -70,7 +64,6 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import Section from '@/components/Section.vue'
|
||||
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||
import SidePanelLayoutEditor from '@/components/SidePanelLayoutEditor.vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
@ -129,10 +122,13 @@ function saveChanges() {
|
||||
let _tabs = JSON.parse(JSON.stringify(tabs.data))
|
||||
_tabs.forEach((tab) => {
|
||||
tab.sections.forEach((section) => {
|
||||
if (!section.fields) return
|
||||
section.fields = section.fields
|
||||
.map((field) => field.fieldname || field.name)
|
||||
.filter(Boolean)
|
||||
if (!section.columns) return
|
||||
section.columns.forEach((column) => {
|
||||
if (!column.fields) return
|
||||
column.fields = column.fields
|
||||
.map((field) => field.fieldname)
|
||||
.filter(Boolean)
|
||||
})
|
||||
})
|
||||
})
|
||||
loading.value = true
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot name="header" v-bind="{ opened, hide, open, close, toggle }">
|
||||
<div v-if="!hide" class="flex items-center justify-between">
|
||||
<div v-if="!hide" class="column-header flex items-center justify-between">
|
||||
<div
|
||||
class="flex text-ink-gray-9 max-w-fit cursor-pointer items-center gap-2 text-base"
|
||||
v-bind="$attrs"
|
||||
:class="labelClass"
|
||||
@click="collapsible && toggle()"
|
||||
>
|
||||
<FeatherIcon
|
||||
@ -34,7 +34,7 @@
|
||||
enter-from-class="max-h-0 overflow-hidden"
|
||||
leave-to-class="max-h-0 overflow-hidden"
|
||||
>
|
||||
<div v-show="opened">
|
||||
<div class="columns" v-bind="$attrs" v-show="opened">
|
||||
<slot v-bind="{ opened, open, close, toggle }" />
|
||||
</div>
|
||||
</transition>
|
||||
@ -64,6 +64,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'left',
|
||||
},
|
||||
labelClass: {
|
||||
type: [String, Object, Array],
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const hide = ref(props.hideLabel)
|
||||
|
||||
@ -42,7 +42,7 @@ import {
|
||||
Badge,
|
||||
ErrorMessage,
|
||||
} from 'frappe-ui'
|
||||
import { evaluateDependsOnValue, createToast } from '@/utils'
|
||||
import { createToast, getRandom } from '@/utils'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
@ -107,53 +107,42 @@ const tabs = computed(() => {
|
||||
let _sections = []
|
||||
if (fieldsData[0].type != 'Section Break') {
|
||||
_sections.push({
|
||||
hideLabel: true,
|
||||
columns: 1,
|
||||
fields: [],
|
||||
name: 'first_section',
|
||||
columns: [{ name: 'first_column', fields: [] }],
|
||||
})
|
||||
}
|
||||
_tabs.push({
|
||||
no_tabs: true,
|
||||
sections: _sections,
|
||||
})
|
||||
_tabs.push({ name: 'first_tab', sections: _sections })
|
||||
}
|
||||
|
||||
fieldsData.forEach((field) => {
|
||||
let _sections = _tabs.length ? _tabs[_tabs.length - 1].sections : []
|
||||
if (field.type === 'Tab Break') {
|
||||
let last_tab = _tabs[_tabs.length - 1]
|
||||
let _sections = _tabs.length ? last_tab.sections : []
|
||||
if (field.fieldtype === 'Tab Break') {
|
||||
_tabs.push({
|
||||
label: field.label,
|
||||
name: field.fieldname,
|
||||
sections: [
|
||||
{
|
||||
hideLabel: true,
|
||||
columns: 1,
|
||||
fields: [],
|
||||
name: 'section_' + getRandom(),
|
||||
columns: [{ name: 'column_' + getRandom(), fields: [] }],
|
||||
},
|
||||
],
|
||||
})
|
||||
} else if (field.type === 'Section Break') {
|
||||
} else if (field.fieldtype === 'Section Break') {
|
||||
_sections.push({
|
||||
label: field.value,
|
||||
hideLabel: true,
|
||||
columns: 1,
|
||||
label: field.label,
|
||||
name: field.fieldname,
|
||||
columns: [{ name: 'column_' + getRandom(), fields: [] }],
|
||||
})
|
||||
} else if (field.fieldtype === 'Column Break') {
|
||||
_sections[_sections.length - 1].columns.push({
|
||||
name: field.fieldname,
|
||||
fields: [],
|
||||
})
|
||||
} else if (field.type === 'Column Break') {
|
||||
_sections[_sections.length - 1].columns += 1
|
||||
} else {
|
||||
_sections[_sections.length - 1].fields.push({
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
display_via_depends_on: evaluateDependsOnValue(
|
||||
field.depends_on,
|
||||
data.doc,
|
||||
),
|
||||
mandatory_via_depends_on: evaluateDependsOnValue(
|
||||
field.mandatory_depends_on,
|
||||
data.doc,
|
||||
),
|
||||
name: field.value,
|
||||
})
|
||||
let last_section = _sections[_sections.length - 1]
|
||||
let last_column = last_section.columns[last_section.columns.length - 1]
|
||||
last_column.fields.push(field)
|
||||
}
|
||||
})
|
||||
|
||||
@ -169,14 +158,16 @@ function update() {
|
||||
function validateMandatoryFields() {
|
||||
if (!tabs.value) return false
|
||||
for (let section of tabs.value[0].sections) {
|
||||
for (let field of section.fields) {
|
||||
if (
|
||||
(field.mandatory ||
|
||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)) &&
|
||||
!data.doc[field.name]
|
||||
) {
|
||||
error.value = __('{0} is mandatory', [__(field.label)])
|
||||
return true
|
||||
for (let column of section.columns) {
|
||||
for (let field of column.fields) {
|
||||
if (
|
||||
(field.mandatory ||
|
||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)) &&
|
||||
!data.doc[field.name]
|
||||
) {
|
||||
error.value = __('{0} is mandatory', [__(field.label)])
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,228 +1,391 @@
|
||||
<template>
|
||||
<FadedScrollableDiv
|
||||
class="flex flex-col gap-1.5 overflow-y-auto"
|
||||
:class="[isLastSection ? '' : 'max-h-[300px]']"
|
||||
>
|
||||
<div
|
||||
v-for="field in _fields"
|
||||
:key="field.label"
|
||||
:class="[field.hidden && 'hidden']"
|
||||
class="section-field flex items-center gap-2 px-3 leading-5 first:mt-3"
|
||||
>
|
||||
<Tooltip :text="__(field.label)" :hoverDelay="1">
|
||||
<div class="w-[35%] min-w-20 shrink-0 truncate text-sm text-ink-gray-5">
|
||||
<span>{{ __(field.label) }}</span>
|
||||
<span class="text-ink-red-3">{{ field.reqd ? ' *' : '' }}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex items-center justify-between w-[65%]">
|
||||
<div class="sections flex flex-col overflow-y-auto">
|
||||
<template v-for="(section, i) in _sections" :key="section.name">
|
||||
<div v-if="section.visible" class="section flex flex-col">
|
||||
<div
|
||||
class="grid min-h-[28px] flex-1 items-center overflow-hidden text-base"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
field.read_only && !['checkbox', 'dropdown'].includes(field.type)
|
||||
"
|
||||
class="flex h-7 cursor-pointer items-center px-2 py-1 text-ink-gray-5"
|
||||
v-if="i !== firstVisibleIndex()"
|
||||
class="w-full section-border h-px border-t"
|
||||
/>
|
||||
<div class="p-3">
|
||||
<Section
|
||||
labelClass="px-2 font-semibold"
|
||||
:label="section.label"
|
||||
:hideLabel="!section.label"
|
||||
:opened="section.opened"
|
||||
>
|
||||
<Tooltip :text="__(field.tooltip)">
|
||||
<div>{{ data[field.name] }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'dropdown'">
|
||||
<NestedPopover>
|
||||
<template #target="{ open }">
|
||||
<Button
|
||||
:label="data[field.name]"
|
||||
class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-surface-gray-2 px-2 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
||||
<template v-if="!preview" #actions>
|
||||
<div v-if="section.name == 'contacts_section'" class="pr-2">
|
||||
<Link
|
||||
value=""
|
||||
doctype="Contact"
|
||||
@change="(e) => addContact(e)"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
_contact = {
|
||||
first_name: value,
|
||||
company_name: deal.data.organization,
|
||||
}
|
||||
showContactModal = true
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<div v-if="data[field.name]" class="truncate">
|
||||
{{ data[field.name] }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-base leading-5 text-ink-gray-4 truncate"
|
||||
>
|
||||
{{ field.placeholder }}
|
||||
</div>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4 text-ink-gray-5"
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
class="h-7 px-3"
|
||||
variant="ghost"
|
||||
icon="plus"
|
||||
@click="togglePopover()"
|
||||
/>
|
||||
</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"
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
v-else-if="section.showEditButton"
|
||||
variant="ghost"
|
||||
class="w-7 mr-2"
|
||||
@click="showSidePanelModal = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<slot v-bind="{ section }">
|
||||
<FadedScrollableDiv
|
||||
v-if="section.columns?.[0].fields.length"
|
||||
class="column flex flex-col gap-1.5 overflow-y-auto"
|
||||
>
|
||||
<template
|
||||
v-for="field in section.columns[0].fields || []"
|
||||
:key="field.fieldname"
|
||||
>
|
||||
<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 px-7 text-base text-ink-gray-4">
|
||||
{{ __('No {0} Available', [field.label]) }}
|
||||
<div
|
||||
v-if="field.visible"
|
||||
class="field flex items-center gap-2 px-3 leading-5 first:mt-3"
|
||||
>
|
||||
<Tooltip :text="__(field.label)" :hoverDelay="1">
|
||||
<div
|
||||
class="w-[35%] min-w-20 shrink-0 truncate text-sm text-ink-gray-5"
|
||||
>
|
||||
<span>{{ __(field.label) }}</span>
|
||||
<span
|
||||
v-if="
|
||||
field.reqd ||
|
||||
(field.mandatory_depends_on &&
|
||||
field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-3"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex items-center justify-between w-[65%]">
|
||||
<div
|
||||
class="grid min-h-[28px] flex-1 items-center overflow-hidden text-base"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
field.read_only &&
|
||||
!['Check', 'Dropdown'].includes(field.fieldtype)
|
||||
"
|
||||
class="flex h-7 cursor-pointer items-center px-2 py-1 text-ink-gray-5"
|
||||
>
|
||||
<Tooltip :text="__(field.tooltip)">
|
||||
<div>{{ data[field.fieldname] }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else-if="field.fieldtype === 'Dropdown'">
|
||||
<NestedPopover>
|
||||
<template #target="{ open }">
|
||||
<Button
|
||||
:label="data[field.name]"
|
||||
class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-surface-gray-2 px-2 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
||||
>
|
||||
<div v-if="data[field.name]" class="truncate">
|
||||
{{ data[field.name] }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-base leading-5 text-ink-gray-4 truncate"
|
||||
>
|
||||
{{ field.placeholder }}
|
||||
</div>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? '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 px-7 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')"
|
||||
@click="field.create()"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NestedPopover>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype == 'Check'"
|
||||
class="form-control"
|
||||
type="checkbox"
|
||||
v-model="data[field.fieldname]"
|
||||
@change.stop="
|
||||
emit(
|
||||
'update',
|
||||
field.fieldname,
|
||||
$event.target.checked,
|
||||
)
|
||||
"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
[
|
||||
'Small Text',
|
||||
'Text',
|
||||
'Long Text',
|
||||
'Code',
|
||||
].includes(field.fieldtype)
|
||||
"
|
||||
class="form-control"
|
||||
type="textarea"
|
||||
:value="data[field.fieldname]"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit('update', field.fieldname, $event.target.value)
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
class="form-control cursor-pointer [&_select]:cursor-pointer truncate"
|
||||
type="select"
|
||||
v-model="data[field.fieldname]"
|
||||
:options="field.options"
|
||||
:placeholder="field.placeholder"
|
||||
@change.stop="
|
||||
emit('update', field.fieldname, $event.target.value)
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'User'"
|
||||
class="form-control"
|
||||
:value="
|
||||
data[field.fieldname] &&
|
||||
getUser(data[field.fieldname]).full_name
|
||||
"
|
||||
doctype="User"
|
||||
:filters="field.filters"
|
||||
@change="
|
||||
(data) => emit('update', field.fieldname, data)
|
||||
"
|
||||
:placeholder="'Select' + ' ' + field.label + '...'"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template v-if="data[field.fieldname]" #prefix>
|
||||
<UserAvatar
|
||||
class="mr-1.5"
|
||||
:user="data[field.fieldname]"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar
|
||||
class="mr-1.5"
|
||||
:user="option.value"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'Link'"
|
||||
class="form-control select-text"
|
||||
:value="data[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
:placeholder="field.placeholder"
|
||||
@change="
|
||||
(data) => emit('update', field.fieldname, data)
|
||||
"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
class="form-control"
|
||||
>
|
||||
<DateTimePicker
|
||||
icon-left=""
|
||||
:value="data[field.fieldname]"
|
||||
:formatter="
|
||||
(date) => getFormat(date, '', true, true)
|
||||
"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
@change="
|
||||
(data) => emit('update', field.fieldname, data)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
class="form-control"
|
||||
>
|
||||
<DatePicker
|
||||
icon-left=""
|
||||
:value="data[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
@change="
|
||||
(data) => emit('update', field.fieldname, data)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Percent'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedPercent(field.fieldname, data)"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit(
|
||||
'update',
|
||||
field.fieldname,
|
||||
flt($event.target.value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Int'"
|
||||
class="form-control"
|
||||
type="number"
|
||||
v-model="data[field.fieldname]"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit('update', field.fieldname, $event.target.value)
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Float'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedFloat(field.fieldname, data)"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit(
|
||||
'update',
|
||||
field.fieldname,
|
||||
flt($event.target.value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Currency'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedCurrency(field.fieldname, data)"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit(
|
||||
'update',
|
||||
field.fieldname,
|
||||
flt($event.target.value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="data[field.fieldname]"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit('update', field.fieldname, $event.target.value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-1">
|
||||
<ArrowUpRightIcon
|
||||
v-if="
|
||||
field.fieldtype === 'Link' &&
|
||||
field.link &&
|
||||
data[field.fieldname]
|
||||
"
|
||||
class="h-4 w-4 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
|
||||
@click.stop="field.link(data[field.fieldname])"
|
||||
/>
|
||||
<EditIcon
|
||||
v-if="
|
||||
field.fieldtype === 'Link' &&
|
||||
field.edit &&
|
||||
data[field.fieldname]
|
||||
"
|
||||
class="size-3.5 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
|
||||
@click.stop="field.edit(data[field.fieldname])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="field.create()"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NestedPopover>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else-if="field.type == 'checkbox'"
|
||||
class="form-control"
|
||||
:type="field.type"
|
||||
v-model="data[field.name]"
|
||||
@change.stop="emit('update', field.name, $event.target.checked)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['email', 'number', 'password', 'textarea'].includes(field.type)
|
||||
"
|
||||
class="form-control"
|
||||
:type="field.type"
|
||||
:value="data[field.name]"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="emit('update', field.name, $event.target.value)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'select'"
|
||||
class="form-control cursor-pointer [&_select]:cursor-pointer truncate"
|
||||
type="select"
|
||||
v-model="data[field.name]"
|
||||
:options="field.options"
|
||||
:placeholder="field.placeholder"
|
||||
@change.stop="emit('update', field.name, $event.target.value)"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="['lead_owner', 'deal_owner'].includes(field.name)"
|
||||
class="form-control"
|
||||
:value="data[field.name] && getUser(data[field.name]).full_name"
|
||||
doctype="User"
|
||||
:filters="field.filters"
|
||||
@change="(data) => emit('update', field.name, data)"
|
||||
:placeholder="'Select' + ' ' + field.label + '...'"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template v-if="data[field.name]" #prefix>
|
||||
<UserAvatar class="mr-1.5" :user="data[field.name]" size="sm" />
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-1.5" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<Link
|
||||
v-else-if="field.type === 'link'"
|
||||
class="form-control select-text"
|
||||
:value="data[field.name]"
|
||||
:doctype="field.doctype"
|
||||
:filters="field.filters"
|
||||
:placeholder="field.placeholder"
|
||||
@change="(data) => emit('update', field.name, data)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
<div v-else-if="field.type === 'datetime'" class="form-control">
|
||||
<DateTimePicker
|
||||
icon-left=""
|
||||
:value="data[field.name]"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
@change="(data) => emit('update', field.name, data)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'date'" class="form-control">
|
||||
<DatePicker
|
||||
icon-left=""
|
||||
:value="data[field.name]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
@change="(data) => emit('update', field.name, data)"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'percent'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedPercent(field.name, data)"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="emit('update', field.name, flt($event.target.value))"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'float'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedFloat(field.name, data)"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="emit('update', field.name, flt($event.target.value))"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'currency'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedCurrency(field.name, data)"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="emit('update', field.name, flt($event.target.value))"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="data[field.name]"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="emit('update', field.name, $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-1">
|
||||
<ArrowUpRightIcon
|
||||
v-if="field.type === 'link' && field.link && data[field.name]"
|
||||
class="h-4 w-4 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
|
||||
@click.stop="field.link(data[field.name])"
|
||||
/>
|
||||
<EditIcon
|
||||
v-if="field.type === 'link' && field.edit && data[field.name]"
|
||||
class="size-3.5 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
|
||||
@click.stop="field.edit(data[field.name])"
|
||||
/>
|
||||
</template>
|
||||
</FadedScrollableDiv>
|
||||
</slot>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadedScrollableDiv>
|
||||
</template>
|
||||
</div>
|
||||
<SidePanelModal
|
||||
v-if="showSidePanelModal"
|
||||
v-model="showSidePanelModal"
|
||||
:doctype="doctype"
|
||||
@reload="() => emit('reload')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Section from '@/components/Section.vue'
|
||||
import NestedPopover from '@/components/NestedPopover.vue'
|
||||
import DropdownItem from '@/components/DropdownItem.vue'
|
||||
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
|
||||
@ -230,22 +393,23 @@ import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import SidePanelModal from '@/components/Modals/SidePanelModal.vue'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getFormat } from '@/utils'
|
||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
sections: {
|
||||
type: Object,
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
default: 'CRM Lead',
|
||||
},
|
||||
isLastSection: {
|
||||
preview: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@ -253,51 +417,92 @@ const props = defineProps({
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
getMeta(props.doctype)
|
||||
const { getUser } = usersStore()
|
||||
const { isManager, getUser } = usersStore()
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
const emit = defineEmits(['update', 'reload'])
|
||||
|
||||
const data = defineModel()
|
||||
const showSidePanelModal = ref(false)
|
||||
|
||||
const _fields = computed(() => {
|
||||
let all_fields = []
|
||||
props.fields?.forEach((field) => {
|
||||
let df = field?.all_properties
|
||||
if (df?.depends_on) evaluate_depends_on(df.depends_on, field)
|
||||
all_fields.push({
|
||||
...field,
|
||||
filters: df?.link_filters && JSON.parse(df.link_filters),
|
||||
placeholder: field.placeholder || field.label,
|
||||
})
|
||||
const _sections = computed(() => {
|
||||
if (!props.sections?.length) return []
|
||||
let editButtonAdded = false
|
||||
return props.sections.map((section) => {
|
||||
if (section.columns?.length) {
|
||||
section.columns[0].fields = section.columns[0].fields.map((field) => {
|
||||
return parsedField(field)
|
||||
})
|
||||
}
|
||||
let _section = parsedSection(section, editButtonAdded)
|
||||
if (_section.showEditButton) {
|
||||
editButtonAdded = true
|
||||
}
|
||||
return _section
|
||||
})
|
||||
return all_fields
|
||||
})
|
||||
|
||||
function evaluate_depends_on(expression, field) {
|
||||
if (expression.substr(0, 5) == 'eval:') {
|
||||
try {
|
||||
let out = evaluate(expression.substr(5), { doc: data.value })
|
||||
if (!out) {
|
||||
field.hidden = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
function parsedField(field) {
|
||||
if (field.fieldtype == 'Select' && typeof field.options === 'string') {
|
||||
field.options = field.options.split('\n').map((option) => {
|
||||
return { label: option, value: option }
|
||||
})
|
||||
|
||||
if (field.options[0].value !== '') {
|
||||
field.options.unshift({ label: '', value: '' })
|
||||
}
|
||||
}
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options === 'User') {
|
||||
field.options = field.options
|
||||
field.fieldtype = 'User'
|
||||
}
|
||||
|
||||
let _field = {
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
placeholder: field.placeholder || field.label,
|
||||
display_via_depends_on: evaluateDependsOnValue(
|
||||
field.depends_on,
|
||||
data.value,
|
||||
),
|
||||
mandatory_via_depends_on: evaluateDependsOnValue(
|
||||
field.mandatory_depends_on,
|
||||
data.value,
|
||||
),
|
||||
}
|
||||
|
||||
_field.visible = isFieldVisible(_field)
|
||||
return _field
|
||||
}
|
||||
|
||||
function evaluate(code, context = {}) {
|
||||
let variable_names = Object.keys(context)
|
||||
let variables = Object.values(context)
|
||||
code = `let out = ${code}; return out`
|
||||
try {
|
||||
let expression_function = new Function(...variable_names, code)
|
||||
return expression_function(...variables)
|
||||
} catch (error) {
|
||||
console.log('Error evaluating the following expression:')
|
||||
console.error(code)
|
||||
throw error
|
||||
}
|
||||
function parsedSection(section, editButtonAdded) {
|
||||
let isContactSection = section.name == 'contacts_section'
|
||||
section.showEditButton = !(
|
||||
!isManager() ||
|
||||
isContactSection ||
|
||||
editButtonAdded
|
||||
)
|
||||
|
||||
section.visible =
|
||||
isContactSection ||
|
||||
section.columns?.[0].fields.filter((f) => f.visible).length
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
function isFieldVisible(field) {
|
||||
if (props.preview) return true
|
||||
return (
|
||||
(field.fieldtype == 'Check' ||
|
||||
(field.read_only && data.value[field.fieldname]) ||
|
||||
!field.read_only) &&
|
||||
(!field.depends_on || field.display_via_depends_on) &&
|
||||
!field.hidden
|
||||
)
|
||||
}
|
||||
|
||||
function firstVisibleIndex() {
|
||||
return _sections.value.findIndex((section) => section.visible)
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -333,4 +538,11 @@ function evaluate(code, context = {}) {
|
||||
color: white;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.sections .section .column {
|
||||
max-height: 300px;
|
||||
}
|
||||
.sections .section:last-of-type .column {
|
||||
max-height: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<Draggable :list="sections" item-key="label" class="flex flex-col gap-5.5">
|
||||
<Draggable :list="sections" item-key="name" class="flex flex-col gap-5.5">
|
||||
<template #item="{ element: section }">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
@ -54,9 +54,9 @@
|
||||
</div>
|
||||
<div v-show="section.opened">
|
||||
<Draggable
|
||||
:list="section.fields"
|
||||
:list="section.columns?.[0].fields || []"
|
||||
group="fields"
|
||||
item-key="label"
|
||||
item-key="fieldname"
|
||||
class="flex flex-col gap-1.5"
|
||||
handle=".cursor-grab"
|
||||
>
|
||||
@ -73,7 +73,10 @@
|
||||
icon="x"
|
||||
class="!size-4 rounded-sm"
|
||||
@click="
|
||||
section.fields.splice(section.fields.indexOf(field), 1)
|
||||
section.columns[0].fields.splice(
|
||||
section.columns[0].fields.indexOf(field),
|
||||
1,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@ -124,7 +127,12 @@
|
||||
variant="subtle"
|
||||
:label="__('Add Section')"
|
||||
@click="
|
||||
sections.push({ label: __('New Section'), opened: true, fields: [] })
|
||||
sections.push({
|
||||
label: __('New Section'),
|
||||
opened: true,
|
||||
name: 'section_' + getRandom(),
|
||||
columns: [{ name: 'column_' + getRandom(), fields: [] }],
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
@ -138,6 +146,7 @@
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
|
||||
import { getRandom } from '@/utils'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Input, createResource } from 'frappe-ui'
|
||||
import { computed, watch } from 'vue'
|
||||
@ -173,7 +182,7 @@ const fields = createResource({
|
||||
|
||||
function addField(section, field) {
|
||||
if (!field) return
|
||||
section.fields.push(field)
|
||||
section.columns[0].fields.push(field)
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@ -117,38 +117,16 @@
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div
|
||||
v-if="fieldsLayout.data"
|
||||
v-if="sections.data"
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
v-for="(section, i) in fieldsLayout.data"
|
||||
:key="section.label"
|
||||
class="flex flex-col p-3"
|
||||
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
|
||||
>
|
||||
<Section :label="section.label" :opened="section.opened">
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="i == 0 && isManager()"
|
||||
variant="ghost"
|
||||
class="w-7"
|
||||
@click="showSidePanelModal = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<SidePanelLayout
|
||||
v-if="section.fields"
|
||||
:fields="section.fields"
|
||||
:isLastSection="i == fieldsLayout.data.length - 1"
|
||||
doctype="Contact"
|
||||
v-model="contact.data"
|
||||
@update="updateField"
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<SidePanelLayout
|
||||
v-model="contact.data"
|
||||
:sections="sections.data"
|
||||
doctype="Contact"
|
||||
@update="updateField"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
</Resizer>
|
||||
<Tabs class="!h-full" v-model="tabIndex" :tabs="tabs">
|
||||
@ -190,27 +168,18 @@
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
<SidePanelModal
|
||||
v-if="showSidePanelModal"
|
||||
v-model="showSidePanelModal"
|
||||
doctype="Contact"
|
||||
@reload="() => fieldsLayout.reload()"
|
||||
/>
|
||||
<AddressModal v-model="showAddressModal" v-model:address="_address" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import Section from '@/components/Section.vue'
|
||||
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import DealsListView from '@/components/ListViews/DealsListView.vue'
|
||||
import SidePanelModal from '@/components/Modals/SidePanelModal.vue'
|
||||
import AddressModal from '@/components/Modals/AddressModal.vue'
|
||||
import { formatDate, timeAgo, createToast } from '@/utils'
|
||||
import { getView } from '@/utils/view'
|
||||
@ -237,7 +206,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
const { brand } = getSettings()
|
||||
const { $dialog, makeCall } = globalStore()
|
||||
|
||||
const { getUser, isManager } = usersStore()
|
||||
const { getUser } = usersStore()
|
||||
const { getOrganization } = organizationsStore()
|
||||
const { getDealStatus } = statusesStore()
|
||||
|
||||
@ -252,7 +221,6 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const showAddressModal = ref(false)
|
||||
const showSidePanelModal = ref(false)
|
||||
const _contact = ref({})
|
||||
const _address = ref({})
|
||||
|
||||
@ -367,158 +335,157 @@ const rows = computed(() => {
|
||||
return deals.data.map((row) => getDealRowObject(row))
|
||||
})
|
||||
|
||||
const fieldsLayout = createResource({
|
||||
url: 'crm.api.doc.get_sidebar_fields',
|
||||
cache: ['fieldsLayout', props.contactId],
|
||||
params: { doctype: 'Contact', name: props.contactId },
|
||||
const sections = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_sidepanel_sections',
|
||||
cache: ['sidePanelSections', 'Contact'],
|
||||
params: { doctype: 'Contact' },
|
||||
auto: true,
|
||||
transform: (data) => getParsedFields(data),
|
||||
transform: (data) => getParsedSections(data),
|
||||
})
|
||||
|
||||
function getParsedFields(data) {
|
||||
return data.map((section) => {
|
||||
return {
|
||||
...section,
|
||||
fields: computed(() =>
|
||||
section.fields.map((field) => {
|
||||
if (field.name === 'email_id') {
|
||||
return {
|
||||
...field,
|
||||
type: 'dropdown',
|
||||
options:
|
||||
contact.data?.email_ids?.map((email) => {
|
||||
return {
|
||||
name: email.name,
|
||||
value: email.email_id,
|
||||
selected: email.email_id === contact.data.email_id,
|
||||
placeholder: 'john@doe.com',
|
||||
onClick: () => {
|
||||
_contact.value.email_id = email.email_id
|
||||
setAsPrimary('email', email.email_id)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('email', option.value)
|
||||
if (contact.data.email_ids.length === 1) {
|
||||
_contact.value.email_id = option.value
|
||||
}
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Email',
|
||||
option.name,
|
||||
'email_id',
|
||||
option.value,
|
||||
)
|
||||
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.data?.email_ids?.map((email) => {
|
||||
return {
|
||||
name: email.name,
|
||||
value: email.email_id,
|
||||
selected: email.email_id === contact.data.email_id,
|
||||
placeholder: 'john@doe.com',
|
||||
onClick: () => {
|
||||
_contact.value.email_id = email.email_id
|
||||
setAsPrimary('email', email.email_id)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('email', option.value)
|
||||
if (contact.data.email_ids.length === 1) {
|
||||
_contact.value.email_id = option.value
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.data.email_ids = contact.data.email_ids.filter(
|
||||
(email) => email.name !== option.name,
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Email',
|
||||
option.name,
|
||||
'email_id',
|
||||
option.value,
|
||||
)
|
||||
!isNew &&
|
||||
(await deleteOption('Contact Email', option.name))
|
||||
if (_contact.value.email_id === option.value) {
|
||||
if (contact.data.email_ids.length === 0) {
|
||||
_contact.value.email_id = ''
|
||||
} else {
|
||||
_contact.value.email_id = contact.data.email_ids.find(
|
||||
(email) => email.is_primary,
|
||||
)?.email_id
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.data?.email_ids?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
} else if (field.name === 'mobile_no') {
|
||||
return {
|
||||
...field,
|
||||
type: 'dropdown',
|
||||
options:
|
||||
contact.data?.phone_nos?.map((phone) => {
|
||||
return {
|
||||
name: phone.name,
|
||||
value: phone.phone,
|
||||
selected: phone.phone === contact.data.actual_mobile_no,
|
||||
onClick: () => {
|
||||
_contact.value.actual_mobile_no = phone.phone
|
||||
_contact.value.mobile_no = phone.phone
|
||||
setAsPrimary('mobile_no', phone.phone)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('phone', option.value)
|
||||
if (contact.data.phone_nos.length === 1) {
|
||||
_contact.value.actual_mobile_no = option.value
|
||||
}
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.data.email_ids = contact.data.email_ids.filter(
|
||||
(email) => email.name !== option.name,
|
||||
)
|
||||
!isNew && (await deleteOption('Contact Email', option.name))
|
||||
if (_contact.value.email_id === option.value) {
|
||||
if (contact.data.email_ids.length === 0) {
|
||||
_contact.value.email_id = ''
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Phone',
|
||||
option.name,
|
||||
'phone',
|
||||
option.value,
|
||||
)
|
||||
_contact.value.email_id = contact.data.email_ids.find(
|
||||
(email) => email.is_primary,
|
||||
)?.email_id
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.data.phone_nos = contact.data.phone_nos.filter(
|
||||
(phone) => phone.name !== option.name,
|
||||
)
|
||||
!isNew &&
|
||||
(await deleteOption('Contact Phone', option.name))
|
||||
if (_contact.value.actual_mobile_no === option.value) {
|
||||
if (contact.data.phone_nos.length === 0) {
|
||||
_contact.value.actual_mobile_no = ''
|
||||
} else {
|
||||
_contact.value.actual_mobile_no =
|
||||
contact.data.phone_nos.find(
|
||||
(phone) => phone.is_primary_mobile_no,
|
||||
)?.phone
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.data?.phone_nos?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
} else if (field.name === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (value, close) => {
|
||||
_contact.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
},
|
||||
edit: async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
}
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.data?.email_ids?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
}
|
||||
} else if (field.fieldname === 'mobile_no') {
|
||||
return {
|
||||
...field,
|
||||
read_only: false,
|
||||
fieldtype: 'Dropdown',
|
||||
options:
|
||||
contact.data?.phone_nos?.map((phone) => {
|
||||
return {
|
||||
name: phone.name,
|
||||
value: phone.phone,
|
||||
selected: phone.phone === contact.data.actual_mobile_no,
|
||||
onClick: () => {
|
||||
_contact.value.actual_mobile_no = phone.phone
|
||||
_contact.value.mobile_no = phone.phone
|
||||
setAsPrimary('mobile_no', phone.phone)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('phone', option.value)
|
||||
if (contact.data.phone_nos.length === 1) {
|
||||
_contact.value.actual_mobile_no = option.value
|
||||
}
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Phone',
|
||||
option.name,
|
||||
'phone',
|
||||
option.value,
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.data.phone_nos = contact.data.phone_nos.filter(
|
||||
(phone) => phone.name !== option.name,
|
||||
)
|
||||
!isNew && (await deleteOption('Contact Phone', option.name))
|
||||
if (_contact.value.actual_mobile_no === option.value) {
|
||||
if (contact.data.phone_nos.length === 0) {
|
||||
_contact.value.actual_mobile_no = ''
|
||||
} else {
|
||||
_contact.value.actual_mobile_no =
|
||||
contact.data.phone_nos.find(
|
||||
(phone) => phone.is_primary_mobile_no,
|
||||
)?.phone
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.data?.phone_nos?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
} else if (field.fieldname === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (value, close) => {
|
||||
_contact.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
},
|
||||
edit: async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
}
|
||||
})
|
||||
return column
|
||||
})
|
||||
return section
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -60,16 +60,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<ContactModal
|
||||
v-if="showContactModal"
|
||||
v-model="showContactModal"
|
||||
v-model:showQuickEntryModal="showQuickEntryModal"
|
||||
:contact="{}"
|
||||
@openAddressModal="(_address) => openAddressModal(_address)"
|
||||
/>
|
||||
<QuickEntryModal
|
||||
v-if="showQuickEntryModal"
|
||||
v-model="showQuickEntryModal"
|
||||
doctype="Contact"
|
||||
/>
|
||||
<AddressModal v-model="showAddressModal" v-model:address="address" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -79,11 +80,13 @@ import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import ContactModal from '@/components/Modals/ContactModal.vue'
|
||||
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
|
||||
import AddressModal from '@/components/Modals/AddressModal.vue'
|
||||
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { organizationsStore } from '@/stores/organizations.js'
|
||||
import { formatDate, timeAgo } from '@/utils'
|
||||
import { call } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
@ -92,11 +95,13 @@ const { getOrganization } = organizationsStore()
|
||||
|
||||
const showContactModal = ref(false)
|
||||
const showQuickEntryModal = ref(false)
|
||||
const showAddressModal = ref(false)
|
||||
|
||||
const contactsListView = ref(null)
|
||||
|
||||
// contacts data is loaded in the ViewControls component
|
||||
const contacts = ref({})
|
||||
const address = ref({})
|
||||
const loadMore = ref(1)
|
||||
const triggerResize = ref(1)
|
||||
const updatedPageCount = ref(20)
|
||||
@ -158,4 +163,15 @@ const rows = computed(() => {
|
||||
return _rows
|
||||
})
|
||||
})
|
||||
|
||||
async function openAddressModal(_address) {
|
||||
if (_address) {
|
||||
_address = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: _address,
|
||||
})
|
||||
}
|
||||
showAddressModal.value = true
|
||||
address.value = _address || {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -114,169 +114,110 @@
|
||||
@updateField="updateField"
|
||||
/>
|
||||
<div
|
||||
v-if="fieldsLayout.data"
|
||||
v-if="sections.data"
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
v-for="(section, i) in fieldsLayout.data"
|
||||
:key="section.label"
|
||||
class="section flex flex-col p-3"
|
||||
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
|
||||
>
|
||||
<Section
|
||||
class="px-2 font-semibold"
|
||||
:label="section.label"
|
||||
:opened="section.opened"
|
||||
<SidePanelLayout
|
||||
v-model="deal.data"
|
||||
:sections="sections.data"
|
||||
doctype="CRM Deal"
|
||||
v-slot="{ section }"
|
||||
@update="updateField"
|
||||
@reload="sections.reload"
|
||||
>
|
||||
<div v-if="section.name == 'contacts_section'" class="contacts-area">
|
||||
<div
|
||||
v-if="dealContacts?.loading && dealContacts?.data?.length == 0"
|
||||
class="flex min-h-20 flex-1 items-center justify-center gap-3 text-base text-ink-gray-4"
|
||||
>
|
||||
<template #actions>
|
||||
<div v-if="section.contacts" class="pr-2">
|
||||
<Link
|
||||
value=""
|
||||
doctype="Contact"
|
||||
@change="(e) => addContact(e)"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
_contact = {
|
||||
first_name: value,
|
||||
company_name: deal.data.organization,
|
||||
}
|
||||
showContactModal = true
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
class="h-7 px-3"
|
||||
variant="ghost"
|
||||
icon="plus"
|
||||
@click="togglePopover()"
|
||||
/>
|
||||
</template>
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
v-else-if="
|
||||
((!section.contacts && i == 1) || i == 0) && isManager()
|
||||
"
|
||||
variant="ghost"
|
||||
class="w-7 mr-2"
|
||||
@click="showSidePanelModal = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<SidePanelLayout
|
||||
v-if="section.fields"
|
||||
:fields="section.fields"
|
||||
:isLastSection="i == fieldsLayout.data.length - 1"
|
||||
doctype="CRM Deal"
|
||||
v-model="deal.data"
|
||||
@update="updateField"
|
||||
/>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="
|
||||
dealContacts?.loading && dealContacts?.data?.length == 0
|
||||
"
|
||||
class="flex min-h-20 flex-1 items-center justify-center gap-3 text-base text-ink-gray-4"
|
||||
>
|
||||
<LoadingIndicator class="h-4 w-4" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="dealContacts?.data?.length"
|
||||
v-for="(contact, i) in dealContacts.data"
|
||||
:key="contact.name"
|
||||
>
|
||||
<div
|
||||
class="px-2 pb-2.5"
|
||||
:class="[i == 0 ? 'pt-5' : 'pt-2.5']"
|
||||
>
|
||||
<Section :opened="contact.opened">
|
||||
<template #header="{ opened, toggle }">
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between gap-2 pr-1 text-base leading-5 text-ink-gray-7"
|
||||
>
|
||||
<div
|
||||
class="flex h-7 items-center gap-2 truncate"
|
||||
@click="toggle()"
|
||||
>
|
||||
<Avatar
|
||||
:label="contact.full_name"
|
||||
:image="contact.image"
|
||||
size="md"
|
||||
/>
|
||||
<div class="truncate">
|
||||
{{ contact.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="contact.is_primary"
|
||||
class="ml-2"
|
||||
variant="outline"
|
||||
:label="__('Primary')"
|
||||
theme="green"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Dropdown :options="contactOptions(contact)">
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
class="text-ink-gray-5"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Dropdown>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'Contact',
|
||||
params: { contactId: contact.name },
|
||||
})
|
||||
"
|
||||
>
|
||||
<ArrowUpRightIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" @click="toggle()">
|
||||
<FeatherIcon
|
||||
name="chevron-right"
|
||||
class="h-4 w-4 text-ink-gray-9 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<LoadingIndicator class="h-4 w-4" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="dealContacts?.data?.length"
|
||||
v-for="(contact, i) in dealContacts.data"
|
||||
:key="contact.name"
|
||||
>
|
||||
<div class="px-2 pb-2.5" :class="[i == 0 ? 'pt-5' : 'pt-2.5']">
|
||||
<Section :opened="contact.opened">
|
||||
<template #header="{ opened, toggle }">
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between gap-2 pr-1 text-base leading-5 text-ink-gray-7"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-1.5 text-base text-ink-gray-8"
|
||||
class="flex h-7 items-center gap-2 truncate"
|
||||
@click="toggle()"
|
||||
>
|
||||
<div class="flex items-center gap-3 pb-1.5 pl-1 pt-4">
|
||||
<Email2Icon class="h-4 w-4" />
|
||||
{{ contact.email }}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-1 py-1.5">
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
{{ contact.mobile_no }}
|
||||
<Avatar
|
||||
:label="contact.full_name"
|
||||
:image="contact.image"
|
||||
size="md"
|
||||
/>
|
||||
<div class="truncate">
|
||||
{{ contact.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="contact.is_primary"
|
||||
class="ml-2"
|
||||
variant="outline"
|
||||
:label="__('Primary')"
|
||||
theme="green"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
<div class="flex items-center">
|
||||
<Dropdown :options="contactOptions(contact)">
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
class="text-ink-gray-5"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Dropdown>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'Contact',
|
||||
params: { contactId: contact.name },
|
||||
})
|
||||
"
|
||||
>
|
||||
<ArrowUpRightIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" @click="toggle()">
|
||||
<FeatherIcon
|
||||
name="chevron-right"
|
||||
class="h-4 w-4 text-ink-gray-9 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-1.5 text-base text-ink-gray-8">
|
||||
<div class="flex items-center gap-3 pb-1.5 pl-1 pt-4">
|
||||
<Email2Icon class="h-4 w-4" />
|
||||
{{ contact.email }}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-1 py-1.5">
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
{{ contact.mobile_no }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="i != dealContacts.data.length - 1"
|
||||
class="mx-2 h-px border-t border-outline-gray-modals"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-20 items-center justify-center text-base text-ink-gray-5"
|
||||
>
|
||||
{{ __('No contacts added') }}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</Section>
|
||||
<div
|
||||
v-if="i != dealContacts.data.length - 1"
|
||||
class="mx-2 h-px border-t border-outline-gray-modals"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-20 items-center justify-center text-base text-ink-gray-5"
|
||||
>
|
||||
{{ __('No contacts added') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanelLayout>
|
||||
</div>
|
||||
</Resizer>
|
||||
</div>
|
||||
@ -303,12 +244,6 @@
|
||||
:doc="deal.data"
|
||||
doctype="CRM Deal"
|
||||
/>
|
||||
<SidePanelModal
|
||||
v-if="showSidePanelModal"
|
||||
v-model="showSidePanelModal"
|
||||
doctype="CRM Deal"
|
||||
@reload="() => fieldsLayout.reload()"
|
||||
/>
|
||||
<FilesUploader
|
||||
v-if="deal.data?.name"
|
||||
v-model="showFilesUploader"
|
||||
@ -326,7 +261,6 @@
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
|
||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
@ -348,8 +282,6 @@ import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||
import ContactModal from '@/components/Modals/ContactModal.vue'
|
||||
import SidePanelModal from '@/components/Modals/SidePanelModal.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Section from '@/components/Section.vue'
|
||||
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||
import SLASection from '@/components/SLASection.vue'
|
||||
@ -366,7 +298,6 @@ import { getView } from '@/utils/view'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||
import {
|
||||
createResource,
|
||||
@ -385,7 +316,6 @@ import { useActiveTabManager } from '@/composables/useActiveTabManager'
|
||||
const { brand } = getSettings()
|
||||
const { $dialog, $socket, makeCall } = globalStore()
|
||||
const { statusOptions, getDealStatus } = statusesStore()
|
||||
const { isManager } = usersStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@ -422,7 +352,7 @@ const deal = createResource({
|
||||
resource: {
|
||||
deal,
|
||||
dealContacts,
|
||||
fieldsLayout,
|
||||
sections,
|
||||
},
|
||||
call,
|
||||
}
|
||||
@ -461,7 +391,6 @@ onBeforeUnmount(() => {
|
||||
const reload = ref(false)
|
||||
const showOrganizationModal = ref(false)
|
||||
const showAssignmentModal = ref(false)
|
||||
const showSidePanelModal = ref(false)
|
||||
const showFilesUploader = ref(false)
|
||||
const _organization = ref({})
|
||||
|
||||
@ -600,19 +529,19 @@ const tabs = computed(() => {
|
||||
})
|
||||
const { tabIndex } = useActiveTabManager(tabs, 'lastDealTab')
|
||||
|
||||
const fieldsLayout = createResource({
|
||||
url: 'crm.api.doc.get_sidebar_fields',
|
||||
cache: ['fieldsLayout', props.dealId],
|
||||
params: { doctype: 'CRM Deal', name: props.dealId },
|
||||
const sections = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_sidepanel_sections',
|
||||
cache: ['sidePanelSections', 'CRM Deal'],
|
||||
params: { doctype: 'CRM Deal' },
|
||||
auto: true,
|
||||
transform: (data) => getParsedFields(data),
|
||||
transform: (data) => getParsedSections(data),
|
||||
})
|
||||
|
||||
function getParsedFields(sections) {
|
||||
sections.forEach((section) => {
|
||||
function getParsedSections(_sections) {
|
||||
_sections.forEach((section) => {
|
||||
if (section.name == 'contacts_section') return
|
||||
section.fields.forEach((field) => {
|
||||
if (field.name == 'organization') {
|
||||
section.columns[0].fields.forEach((field) => {
|
||||
if (field.fieldname == 'organization') {
|
||||
field.create = (value, close) => {
|
||||
_organization.value.organization_name = value
|
||||
showOrganizationModal.value = true
|
||||
@ -626,7 +555,7 @@ function getParsedFields(sections) {
|
||||
}
|
||||
})
|
||||
})
|
||||
return sections
|
||||
return _sections
|
||||
}
|
||||
|
||||
const showContactModal = ref(false)
|
||||
@ -748,12 +677,3 @@ function openEmailBox() {
|
||||
activities.value.emailBox.show = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.section:has(.section-field.hidden)) {
|
||||
display: none;
|
||||
}
|
||||
:deep(.section:has(.section-field:not(.hidden))) {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -167,39 +167,16 @@
|
||||
@updateField="updateField"
|
||||
/>
|
||||
<div
|
||||
v-if="fieldsLayout.data"
|
||||
v-if="sections.data"
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
v-for="(section, i) in fieldsLayout.data"
|
||||
:key="section.label"
|
||||
class="flex flex-col p-3"
|
||||
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
|
||||
>
|
||||
<Section
|
||||
class="px-2 font-semibold"
|
||||
:label="section.label"
|
||||
:opened="section.opened"
|
||||
>
|
||||
<SidePanelLayout
|
||||
:fields="section.fields"
|
||||
:isLastSection="i == fieldsLayout.data.length - 1"
|
||||
v-model="lead.data"
|
||||
@update="updateField"
|
||||
/>
|
||||
<template v-if="i == 0 && isManager()" #actions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-7 mr-2"
|
||||
@click="showSidePanelModal = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<SidePanelLayout
|
||||
v-model="lead.data"
|
||||
:sections="sections.data"
|
||||
doctype="CRM Lead"
|
||||
@update="updateField"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
</Resizer>
|
||||
</div>
|
||||
@ -276,11 +253,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<SidePanelModal
|
||||
v-if="showSidePanelModal"
|
||||
v-model="showSidePanelModal"
|
||||
@reload="() => fieldsLayout.reload()"
|
||||
/>
|
||||
<FilesUploader
|
||||
v-if="lead.data?.name"
|
||||
v-model="showFilesUploader"
|
||||
@ -297,7 +269,6 @@
|
||||
<script setup>
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
|
||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
@ -317,10 +288,8 @@ import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import Activities from '@/components/Activities/Activities.vue'
|
||||
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||
import SidePanelModal from '@/components/Modals/SidePanelModal.vue'
|
||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Section from '@/components/Section.vue'
|
||||
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||
import SLASection from '@/components/SLASection.vue'
|
||||
import CustomActions from '@/components/CustomActions.vue'
|
||||
@ -337,7 +306,6 @@ import { getSettings } from '@/stores/settings'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { contactsStore } from '@/stores/contacts'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||
import { capture } from '@/telemetry'
|
||||
import {
|
||||
@ -360,7 +328,6 @@ const { brand } = getSettings()
|
||||
const { $dialog, $socket, makeCall } = globalStore()
|
||||
const { getContactByName, contacts } = contactsStore()
|
||||
const { statusOptions, getLeadStatus } = statusesStore()
|
||||
const { isManager } = usersStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@ -389,7 +356,7 @@ const lead = createResource({
|
||||
deleteDoc: deleteLead,
|
||||
resource: {
|
||||
lead,
|
||||
fieldsLayout,
|
||||
sections,
|
||||
},
|
||||
call,
|
||||
}
|
||||
@ -407,7 +374,6 @@ onMounted(() => {
|
||||
|
||||
const reload = ref(false)
|
||||
const showAssignmentModal = ref(false)
|
||||
const showSidePanelModal = ref(false)
|
||||
const showFilesUploader = ref(false)
|
||||
|
||||
function updateLead(fieldname, value, callback) {
|
||||
@ -564,10 +530,10 @@ function validateFile(file) {
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsLayout = createResource({
|
||||
url: 'crm.api.doc.get_sidebar_fields',
|
||||
cache: ['fieldsLayout', props.leadId],
|
||||
params: { doctype: 'CRM Lead', name: props.leadId },
|
||||
const sections = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_sidepanel_sections',
|
||||
cache: ['sidePanelSections', 'CRM Lead'],
|
||||
params: { doctype: 'CRM Lead' },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
|
||||
@ -137,13 +137,13 @@
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
v-for="(section, i) in fieldsLayout.data"
|
||||
:key="section.label"
|
||||
:key="section.name"
|
||||
class="flex flex-col px-2 py-3 sm:p-3"
|
||||
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
|
||||
>
|
||||
<Section :label="section.label" :opened="section.opened">
|
||||
<SidePanelLayout
|
||||
:fields="section.fields"
|
||||
:fields="section.columns[0].fields"
|
||||
:isLastSection="i == fieldsLayout.data.length - 1"
|
||||
doctype="Contact"
|
||||
v-model="contact.data"
|
||||
@ -348,157 +348,154 @@ const rows = computed(() => {
|
||||
})
|
||||
|
||||
const fieldsLayout = createResource({
|
||||
url: 'crm.api.doc.get_sidebar_fields',
|
||||
cache: ['fieldsLayout', props.contactId],
|
||||
params: { doctype: 'Contact', name: props.contactId },
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_sidepanel_sections',
|
||||
cache: ['sidePanelSections', 'Contact'],
|
||||
params: { doctype: 'Contact' },
|
||||
auto: true,
|
||||
transform: (data) => getParsedFields(data),
|
||||
})
|
||||
|
||||
function getParsedFields(data) {
|
||||
return data.map((section) => {
|
||||
return {
|
||||
...section,
|
||||
fields: computed(() =>
|
||||
section.fields.map((field) => {
|
||||
if (field.name === 'email_id') {
|
||||
return {
|
||||
...field,
|
||||
type: 'dropdown',
|
||||
options:
|
||||
contact.data?.email_ids?.map((email) => {
|
||||
return {
|
||||
name: email.name,
|
||||
value: email.email_id,
|
||||
selected: email.email_id === contact.data.email_id,
|
||||
placeholder: 'john@doe.com',
|
||||
onClick: () => {
|
||||
_contact.value.email_id = email.email_id
|
||||
setAsPrimary('email', email.email_id)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('email', option.value)
|
||||
if (contact.data.email_ids.length === 1) {
|
||||
_contact.value.email_id = option.value
|
||||
}
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Email',
|
||||
option.name,
|
||||
'email_id',
|
||||
option.value,
|
||||
)
|
||||
section.columns = section.columns.map((column) => {
|
||||
column.fields = column.fields.map((field) => {
|
||||
if (field.name === 'email_id') {
|
||||
return {
|
||||
...field,
|
||||
type: 'dropdown',
|
||||
options:
|
||||
contact.data?.email_ids?.map((email) => {
|
||||
return {
|
||||
name: email.name,
|
||||
value: email.email_id,
|
||||
selected: email.email_id === contact.data.email_id,
|
||||
placeholder: 'john@doe.com',
|
||||
onClick: () => {
|
||||
_contact.value.email_id = email.email_id
|
||||
setAsPrimary('email', email.email_id)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('email', option.value)
|
||||
if (contact.data.email_ids.length === 1) {
|
||||
_contact.value.email_id = option.value
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.data.email_ids = contact.data.email_ids.filter(
|
||||
(email) => email.name !== option.name,
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Email',
|
||||
option.name,
|
||||
'email_id',
|
||||
option.value,
|
||||
)
|
||||
!isNew &&
|
||||
(await deleteOption('Contact Email', option.name))
|
||||
if (_contact.value.email_id === option.value) {
|
||||
if (contact.data.email_ids.length === 0) {
|
||||
_contact.value.email_id = ''
|
||||
} else {
|
||||
_contact.value.email_id = contact.data.email_ids.find(
|
||||
(email) => email.is_primary,
|
||||
)?.email_id
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.data?.email_ids?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
} else if (field.name === 'mobile_no') {
|
||||
return {
|
||||
...field,
|
||||
type: 'dropdown',
|
||||
options:
|
||||
contact.data?.phone_nos?.map((phone) => {
|
||||
return {
|
||||
name: phone.name,
|
||||
value: phone.phone,
|
||||
selected: phone.phone === contact.data.actual_mobile_no,
|
||||
onClick: () => {
|
||||
_contact.value.actual_mobile_no = phone.phone
|
||||
_contact.value.mobile_no = phone.phone
|
||||
setAsPrimary('mobile_no', phone.phone)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('phone', option.value)
|
||||
if (contact.data.phone_nos.length === 1) {
|
||||
_contact.value.actual_mobile_no = option.value
|
||||
}
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.data.email_ids = contact.data.email_ids.filter(
|
||||
(email) => email.name !== option.name,
|
||||
)
|
||||
!isNew && (await deleteOption('Contact Email', option.name))
|
||||
if (_contact.value.email_id === option.value) {
|
||||
if (contact.data.email_ids.length === 0) {
|
||||
_contact.value.email_id = ''
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Phone',
|
||||
option.name,
|
||||
'phone',
|
||||
option.value,
|
||||
)
|
||||
_contact.value.email_id = contact.data.email_ids.find(
|
||||
(email) => email.is_primary,
|
||||
)?.email_id
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.data.phone_nos = contact.data.phone_nos.filter(
|
||||
(phone) => phone.name !== option.name,
|
||||
)
|
||||
!isNew &&
|
||||
(await deleteOption('Contact Phone', option.name))
|
||||
if (_contact.value.actual_mobile_no === option.value) {
|
||||
if (contact.data.phone_nos.length === 0) {
|
||||
_contact.value.actual_mobile_no = ''
|
||||
} else {
|
||||
_contact.value.actual_mobile_no =
|
||||
contact.data.phone_nos.find(
|
||||
(phone) => phone.is_primary_mobile_no,
|
||||
)?.phone
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.data?.phone_nos?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
} else if (field.name === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (value, close) => {
|
||||
_contact.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
},
|
||||
edit: async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
}
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.data?.email_ids?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
}
|
||||
} else if (field.name === 'mobile_no') {
|
||||
return {
|
||||
...field,
|
||||
type: 'dropdown',
|
||||
options:
|
||||
contact.data?.phone_nos?.map((phone) => {
|
||||
return {
|
||||
name: phone.name,
|
||||
value: phone.phone,
|
||||
selected: phone.phone === contact.data.actual_mobile_no,
|
||||
onClick: () => {
|
||||
_contact.value.actual_mobile_no = phone.phone
|
||||
_contact.value.mobile_no = phone.phone
|
||||
setAsPrimary('mobile_no', phone.phone)
|
||||
},
|
||||
onSave: (option, isNew) => {
|
||||
if (isNew) {
|
||||
createNew('phone', option.value)
|
||||
if (contact.data.phone_nos.length === 1) {
|
||||
_contact.value.actual_mobile_no = option.value
|
||||
}
|
||||
} else {
|
||||
editOption(
|
||||
'Contact Phone',
|
||||
option.name,
|
||||
'phone',
|
||||
option.value,
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete: async (option, isNew) => {
|
||||
contact.data.phone_nos = contact.data.phone_nos.filter(
|
||||
(phone) => phone.name !== option.name,
|
||||
)
|
||||
!isNew && (await deleteOption('Contact Phone', option.name))
|
||||
if (_contact.value.actual_mobile_no === option.value) {
|
||||
if (contact.data.phone_nos.length === 0) {
|
||||
_contact.value.actual_mobile_no = ''
|
||||
} else {
|
||||
_contact.value.actual_mobile_no =
|
||||
contact.data.phone_nos.find(
|
||||
(phone) => phone.is_primary_mobile_no,
|
||||
)?.phone
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}) || [],
|
||||
create: () => {
|
||||
contact.data?.phone_nos?.push({
|
||||
name: 'new-1',
|
||||
value: '',
|
||||
selected: false,
|
||||
isNew: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
} else if (field.name === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (value, close) => {
|
||||
_contact.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
},
|
||||
edit: async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
}
|
||||
})
|
||||
return column
|
||||
})
|
||||
return section
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
v-for="(section, i) in fieldsLayout.data"
|
||||
:key="section.label"
|
||||
:key="section.name"
|
||||
class="flex flex-col px-2 py-3 sm:p-3"
|
||||
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
|
||||
>
|
||||
@ -99,8 +99,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<SidePanelLayout
|
||||
v-if="section.fields"
|
||||
:fields="section.fields"
|
||||
v-if="section.columns?.[0].fields"
|
||||
:fields="section.columns[0].fields"
|
||||
:isLastSection="i == fieldsLayout.data.length - 1"
|
||||
doctype="CRM Deal"
|
||||
v-model="deal.data"
|
||||
@ -289,7 +289,7 @@ import {
|
||||
Tabs,
|
||||
Breadcrumbs,
|
||||
call,
|
||||
usePageMeta
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { ref, computed, h, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@ -501,9 +501,9 @@ const tabs = computed(() => {
|
||||
const { tabIndex } = useActiveTabManager(tabs, 'lastDealTab')
|
||||
|
||||
const fieldsLayout = createResource({
|
||||
url: 'crm.api.doc.get_sidebar_fields',
|
||||
cache: ['fieldsLayout', props.dealId],
|
||||
params: { doctype: 'CRM Deal', name: props.dealId },
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_sidepanel_sections',
|
||||
cache: ['sidePanelSections', 'CRM Deal'],
|
||||
params: { doctype: 'CRM Deal' },
|
||||
auto: true,
|
||||
transform: (data) => getParsedFields(data),
|
||||
})
|
||||
@ -511,7 +511,7 @@ const fieldsLayout = createResource({
|
||||
function getParsedFields(sections) {
|
||||
sections.forEach((section) => {
|
||||
if (section.name == 'contacts_section') return
|
||||
section.fields.forEach((field) => {
|
||||
section.columns[0].fields.forEach((field) => {
|
||||
if (field.name == 'organization') {
|
||||
field.create = (value, close) => {
|
||||
_organization.value.organization_name = value
|
||||
|
||||
@ -70,13 +70,13 @@
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
v-for="(section, i) in fieldsLayout.data"
|
||||
:key="section.label"
|
||||
:key="section.name"
|
||||
class="flex flex-col px-2 py-3 sm:p-3"
|
||||
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
|
||||
>
|
||||
<Section :label="section.label" :opened="section.opened">
|
||||
<SidePanelLayout
|
||||
:fields="section.fields"
|
||||
:fields="section.columns[0].fields"
|
||||
:isLastSection="i == fieldsLayout.data.length - 1"
|
||||
v-model="lead.data"
|
||||
@update="updateField"
|
||||
@ -421,9 +421,9 @@ watch(tabs, (value) => {
|
||||
})
|
||||
|
||||
const fieldsLayout = createResource({
|
||||
url: 'crm.api.doc.get_sidebar_fields',
|
||||
cache: ['fieldsLayout', props.leadId],
|
||||
params: { doctype: 'CRM Lead', name: props.leadId },
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_sidepanel_sections',
|
||||
cache: ['sidePanelSections', 'CRM Lead'],
|
||||
params: { doctype: 'CRM Lead' },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
|
||||
@ -119,14 +119,15 @@
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
v-for="(section, i) in fieldsLayout.data"
|
||||
:key="section.label"
|
||||
:key="section.name"
|
||||
class="flex flex-col px-2 py-3 sm:p-3"
|
||||
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
|
||||
>
|
||||
<Section :label="section.label" :opened="section.opened">
|
||||
<SidePanelLayout
|
||||
v-if="section.columns?.[0].fields"
|
||||
v-model="organization.doc"
|
||||
:fields="section.fields"
|
||||
:fields="section.columns[0].fields"
|
||||
:isLastSection="i == fieldsLayout.data.length - 1"
|
||||
doctype="CRM Organization"
|
||||
@update="updateField"
|
||||
@ -326,42 +327,41 @@ const _organization = ref({})
|
||||
const _address = ref({})
|
||||
|
||||
const fieldsLayout = createResource({
|
||||
url: 'crm.api.doc.get_sidebar_fields',
|
||||
cache: ['fieldsLayout', props.organizationId],
|
||||
params: { doctype: 'CRM Organization', name: props.organizationId },
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_sidepanel_sections',
|
||||
cache: ['sidePanelSections', 'CRM Organization'],
|
||||
params: { doctype: 'CRM Organization' },
|
||||
auto: true,
|
||||
transform: (data) => getParsedFields(data),
|
||||
})
|
||||
|
||||
function getParsedFields(data) {
|
||||
return data.map((section) => {
|
||||
return {
|
||||
...section,
|
||||
fields: computed(() =>
|
||||
section.fields.map((field) => {
|
||||
if (field.name === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (value, close) => {
|
||||
_organization.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
},
|
||||
edit: async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
section.columns = section.columns.map((column) => {
|
||||
column.fields = column.fields.map((field) => {
|
||||
if (field.name === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (value, close) => {
|
||||
_organization.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
},
|
||||
edit: async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
}
|
||||
})
|
||||
return column
|
||||
})
|
||||
return section
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -102,38 +102,16 @@
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div
|
||||
v-if="fieldsLayout.data"
|
||||
v-if="sections.data"
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
v-for="(section, i) in fieldsLayout.data"
|
||||
:key="section.label"
|
||||
class="flex flex-col p-3"
|
||||
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
|
||||
>
|
||||
<Section :label="section.label" :opened="section.opened">
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="i == 0 && isManager()"
|
||||
variant="ghost"
|
||||
class="w-7"
|
||||
@click="showSidePanelModal = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<SidePanelLayout
|
||||
v-if="section.fields"
|
||||
v-model="organization.doc"
|
||||
:fields="section.fields"
|
||||
:isLastSection="i == fieldsLayout.data.length - 1"
|
||||
doctype="CRM Organization"
|
||||
@update="updateField"
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<SidePanelLayout
|
||||
v-model="organization.doc"
|
||||
:sections="sections.data"
|
||||
doctype="CRM Organization"
|
||||
@update="updateField"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
</Resizer>
|
||||
<Tabs class="!h-full" v-model="tabIndex" :tabs="tabs">
|
||||
@ -182,12 +160,6 @@
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
<SidePanelModal
|
||||
v-if="showSidePanelModal"
|
||||
v-model="showSidePanelModal"
|
||||
doctype="CRM Organization"
|
||||
@reload="() => fieldsLayout.reload()"
|
||||
/>
|
||||
<QuickEntryModal
|
||||
v-if="showQuickEntryModal"
|
||||
v-model="showQuickEntryModal"
|
||||
@ -198,9 +170,7 @@
|
||||
|
||||
<script setup>
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import Section from '@/components/Section.vue'
|
||||
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||
import SidePanelModal from '@/components/Modals/SidePanelModal.vue'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
|
||||
@ -208,7 +178,6 @@ import AddressModal from '@/components/Modals/AddressModal.vue'
|
||||
import DealsListView from '@/components/ListViews/DealsListView.vue'
|
||||
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
|
||||
import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||
@ -243,10 +212,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const { brand } = getSettings()
|
||||
const { getUser, isManager } = usersStore()
|
||||
const { getUser } = usersStore()
|
||||
const { $dialog } = globalStore()
|
||||
const { getDealStatus } = statusesStore()
|
||||
const showSidePanelModal = ref(false)
|
||||
const showQuickEntryModal = ref(false)
|
||||
|
||||
const route = useRoute()
|
||||
@ -367,43 +335,42 @@ const showAddressModal = ref(false)
|
||||
const _organization = ref({})
|
||||
const _address = ref({})
|
||||
|
||||
const fieldsLayout = createResource({
|
||||
url: 'crm.api.doc.get_sidebar_fields',
|
||||
cache: ['fieldsLayout', props.organizationId],
|
||||
params: { doctype: 'CRM Organization', name: props.organizationId },
|
||||
const sections = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_sidepanel_sections',
|
||||
cache: ['sidePanelSections', 'CRM Organization'],
|
||||
params: { doctype: 'CRM Organization' },
|
||||
auto: true,
|
||||
transform: (data) => getParsedFields(data),
|
||||
transform: (data) => getParsedSections(data),
|
||||
})
|
||||
|
||||
function getParsedFields(data) {
|
||||
return data.map((section) => {
|
||||
return {
|
||||
...section,
|
||||
fields: computed(() =>
|
||||
section.fields.map((field) => {
|
||||
if (field.name === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (value, close) => {
|
||||
_organization.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
},
|
||||
edit: async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
function getParsedSections(_sections) {
|
||||
return _sections.map((section) => {
|
||||
section.columns = section.columns.map((column) => {
|
||||
column.fields = column.fields.map((field) => {
|
||||
if (field.fieldname === 'address') {
|
||||
return {
|
||||
...field,
|
||||
create: (value, close) => {
|
||||
_organization.value.address = value
|
||||
_address.value = {}
|
||||
showAddressModal.value = true
|
||||
close()
|
||||
},
|
||||
edit: async (addr) => {
|
||||
_address.value = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: addr,
|
||||
})
|
||||
showAddressModal.value = true
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
return field
|
||||
}
|
||||
})
|
||||
return column
|
||||
})
|
||||
return section
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -60,15 +60,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<OrganizationModal
|
||||
v-if="showOrganizationModal"
|
||||
v-model="showOrganizationModal"
|
||||
v-model:showQuickEntryModal="showQuickEntryModal"
|
||||
@openAddressModal="(_address) => openAddressModal(_address)"
|
||||
/>
|
||||
<QuickEntryModal
|
||||
v-if="showQuickEntryModal"
|
||||
v-model="showQuickEntryModal"
|
||||
doctype="CRM Organization"
|
||||
/>
|
||||
<AddressModal v-model="showAddressModal" v-model:address="address" />
|
||||
</template>
|
||||
<script setup>
|
||||
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
|
||||
@ -77,10 +78,12 @@ import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
||||
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
|
||||
import AddressModal from '@/components/Modals/AddressModal.vue'
|
||||
import OrganizationsListView from '@/components/ListViews/OrganizationsListView.vue'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { formatDate, timeAgo, website } from '@/utils'
|
||||
import { call } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
@ -89,9 +92,11 @@ const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
const organizationsListView = ref(null)
|
||||
const showOrganizationModal = ref(false)
|
||||
const showQuickEntryModal = ref(false)
|
||||
const showAddressModal = ref(false)
|
||||
|
||||
// organizations data is loaded in the ViewControls component
|
||||
const organizations = ref({})
|
||||
const address = ref({})
|
||||
const loadMore = ref(1)
|
||||
const triggerResize = ref(1)
|
||||
const updatedPageCount = ref(20)
|
||||
@ -154,4 +159,15 @@ const rows = computed(() => {
|
||||
return _rows
|
||||
})
|
||||
})
|
||||
|
||||
async function openAddressModal(_address) {
|
||||
if (_address) {
|
||||
_address = await call('frappe.client.get', {
|
||||
doctype: 'Address',
|
||||
name: _address,
|
||||
})
|
||||
}
|
||||
showAddressModal.value = true
|
||||
address.value = _address || {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -75,6 +75,16 @@ export function getMeta(doctype) {
|
||||
value: option,
|
||||
}
|
||||
})
|
||||
|
||||
if (f.options[0]?.value !== '') {
|
||||
f.options.unshift({
|
||||
label: '',
|
||||
value: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (f.fieldtype === 'Link' && f.options == 'User') {
|
||||
f.fieldtype = 'User'
|
||||
}
|
||||
return f
|
||||
})
|
||||
|
||||
@ -306,7 +306,7 @@ export function isImage(extention) {
|
||||
)
|
||||
}
|
||||
|
||||
export function getRandom(len) {
|
||||
export function getRandom(len=4) {
|
||||
let text = ''
|
||||
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
|
||||
@ -169,18 +169,18 @@ export function formatCurrency(value, format, currency = 'USD', precision = 2) {
|
||||
}
|
||||
|
||||
// If you change anything below, it's going to hurt a company in UAE, a bit.
|
||||
if (precision > 2) {
|
||||
let parts = cstr(value).split('.') // should be minimum 2, comes from the DB
|
||||
let decimals = parts.length > 1 ? parts[1] : '' // parts.length == 2 ???
|
||||
// if (precision > 2) {
|
||||
// let parts = cstr(value).split('.') // should be minimum 2, comes from the DB
|
||||
// let decimals = parts.length > 1 ? parts[1] : '' // parts.length == 2 ???
|
||||
|
||||
if (decimals.length < 3 || decimals.length < precision) {
|
||||
const fraction = 100
|
||||
// if (decimals.length < 3 || decimals.length < precision) {
|
||||
// const fraction = 100
|
||||
|
||||
if (decimals.length < cstr(fraction).length) {
|
||||
precision = cstr(fraction).length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (decimals.length < cstr(fraction).length) {
|
||||
// precision = cstr(fraction).length - 1
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
format = getNumberFormat(format)
|
||||
let symbol = getCurrencySymbol(currency)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user