Merge pull request #507 from frappe/develop

This commit is contained in:
Shariq Ansari 2025-01-03 02:18:04 +05:30 committed by GitHub
commit bb55fbb5f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 2167 additions and 1767 deletions

View File

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

View File

@ -80,7 +80,7 @@ class CRMDeal(Document):
# the agent is already set as an assignee
return
assign({"assign_to": [agent], "doctype": "CRM Deal", "name": self.name})
assign({"assign_to": [agent], "doctype": "CRM Deal", "name": self.name}, ignore_permissions=True)
def share_with_agent(self, agent):
if not agent:

View File

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

View File

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

View File

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

View File

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

View File

@ -388,11 +388,10 @@ def convert_to_deal(lead, doc=None):
lead = frappe.get_cached_doc("CRM Lead", lead)
if frappe.db.exists("CRM Lead Status", "Qualified"):
lead.status = "Qualified"
lead.converted = 1
lead.db_set("status", "Qualified")
lead.db_set("converted", 1)
if lead.sla and frappe.db.exists("CRM Communication Status", "Replied"):
lead.communication_status = "Replied"
lead.save(ignore_permissions=True)
lead.db_set("communication_status", "Replied")
contact = lead.create_contact(False)
organization = lead.create_organization()
deal = lead.create_deal(contact, organization)

View File

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

View File

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

View File

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

View File

@ -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"]}]}]',
},
}

View File

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

View 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 new_layout[0]:
new_layout = new_layout[0].get("sections")
return json.dumps(new_layout)

View File

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

View File

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

View File

@ -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,
})

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

@ -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] = []
}
})
})
})
})

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
})

View File

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

View File

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

View File

@ -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,
})

View File

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

View File

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

View File

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

View File

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

View File

@ -306,7 +306,7 @@ export function isImage(extention) {
)
}
export function getRandom(len) {
export function getRandom(len=4) {
let text = ''
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'

View File

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