Merge pull request #485 from shariquerik/child-table-support
This commit is contained in:
commit
2be808b511
@ -1,10 +1,11 @@
|
||||
import frappe
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import get_controller
|
||||
from frappe.model import no_value_fields
|
||||
from pypika import Criterion
|
||||
from frappe.model.document import get_controller
|
||||
from frappe.utils import make_filter_tuple
|
||||
from pypika import Criterion
|
||||
|
||||
from crm.api.views import get_views
|
||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||
@ -557,6 +558,9 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
|
||||
fields_meta = {}
|
||||
for field in fields:
|
||||
fields_meta[field.get("fieldname")] = field
|
||||
if field.get("fieldtype") == "Table":
|
||||
_fields = frappe.get_meta(field.get("options")).fields
|
||||
fields_meta[field.get("fieldname")] = {"df": field, "fields": _fields}
|
||||
|
||||
return fields_meta
|
||||
|
||||
@ -672,7 +676,7 @@ def get_assigned_users(doctype, name, default_assigned_to=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_fields(doctype: str, allow_all_fieldtypes: bool = False):
|
||||
not_allowed_fieldtypes = list(frappe.model.no_value_fields) + ["Read Only"]
|
||||
not_allowed_fieldtypes = [*list(frappe.model.no_value_fields), "Read Only"]
|
||||
if allow_all_fieldtypes:
|
||||
not_allowed_fieldtypes = []
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
@ -8,7 +7,9 @@ from frappe.desk.form.assign_to import add as assign
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
||||
add_status_change_log,
|
||||
)
|
||||
|
||||
|
||||
class CRMDeal(Document):
|
||||
@ -94,19 +95,26 @@ class CRMDeal(Document):
|
||||
shared_with = [d.user for d in docshares] + [agent]
|
||||
|
||||
for user in shared_with:
|
||||
if user == agent and not frappe.db.exists("DocShare", {"user": agent, "share_name": self.name, "share_doctype": self.doctype}):
|
||||
if user == agent and not frappe.db.exists(
|
||||
"DocShare",
|
||||
{"user": agent, "share_name": self.name, "share_doctype": self.doctype},
|
||||
):
|
||||
frappe.share.add_docshare(
|
||||
self.doctype, self.name, agent, write=1, flags={"ignore_share_permission": True}
|
||||
self.doctype,
|
||||
self.name,
|
||||
agent,
|
||||
write=1,
|
||||
flags={"ignore_share_permission": True},
|
||||
)
|
||||
elif user != agent:
|
||||
frappe.share.remove(self.doctype, self.name, user)
|
||||
|
||||
|
||||
def set_sla(self):
|
||||
"""
|
||||
Find an SLA to apply to the deal.
|
||||
"""
|
||||
if self.sla: return
|
||||
if self.sla:
|
||||
return
|
||||
|
||||
sla = get_sla(self)
|
||||
if not sla:
|
||||
@ -129,48 +137,48 @@ class CRMDeal(Document):
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
'label': 'Organization',
|
||||
'type': 'Link',
|
||||
'key': 'organization',
|
||||
'options': 'CRM Organization',
|
||||
'width': '11rem',
|
||||
"label": "Organization",
|
||||
"type": "Link",
|
||||
"key": "organization",
|
||||
"options": "CRM Organization",
|
||||
"width": "11rem",
|
||||
},
|
||||
{
|
||||
'label': 'Amount',
|
||||
'type': 'Currency',
|
||||
'key': 'annual_revenue',
|
||||
'align': 'right',
|
||||
'width': '9rem',
|
||||
"label": "Annual Revenue",
|
||||
"type": "Currency",
|
||||
"key": "annual_revenue",
|
||||
"align": "right",
|
||||
"width": "9rem",
|
||||
},
|
||||
{
|
||||
'label': 'Status',
|
||||
'type': 'Select',
|
||||
'key': 'status',
|
||||
'width': '10rem',
|
||||
"label": "Status",
|
||||
"type": "Select",
|
||||
"key": "status",
|
||||
"width": "10rem",
|
||||
},
|
||||
{
|
||||
'label': 'Email',
|
||||
'type': 'Data',
|
||||
'key': 'email',
|
||||
'width': '12rem',
|
||||
"label": "Email",
|
||||
"type": "Data",
|
||||
"key": "email",
|
||||
"width": "12rem",
|
||||
},
|
||||
{
|
||||
'label': 'Mobile No',
|
||||
'type': 'Data',
|
||||
'key': 'mobile_no',
|
||||
'width': '11rem',
|
||||
"label": "Mobile No",
|
||||
"type": "Data",
|
||||
"key": "mobile_no",
|
||||
"width": "11rem",
|
||||
},
|
||||
{
|
||||
'label': 'Assigned To',
|
||||
'type': 'Text',
|
||||
'key': '_assign',
|
||||
'width': '10rem',
|
||||
"label": "Assigned To",
|
||||
"type": "Text",
|
||||
"key": "_assign",
|
||||
"width": "10rem",
|
||||
},
|
||||
{
|
||||
'label': 'Last Modified',
|
||||
'type': 'Datetime',
|
||||
'key': 'modified',
|
||||
'width': '8rem',
|
||||
"label": "Last Modified",
|
||||
"type": "Datetime",
|
||||
"key": "modified",
|
||||
"width": "8rem",
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
@ -189,16 +197,17 @@ class CRMDeal(Document):
|
||||
"modified",
|
||||
"_assign",
|
||||
]
|
||||
return {'columns': columns, 'rows': rows}
|
||||
return {"columns": columns, "rows": rows}
|
||||
|
||||
@staticmethod
|
||||
def default_kanban_settings():
|
||||
return {
|
||||
"column_field": "status",
|
||||
"title_field": "organization",
|
||||
"kanban_fields": '["annual_revenue", "email", "mobile_no", "_assign", "modified"]'
|
||||
"kanban_fields": '["annual_revenue", "email", "mobile_no", "_assign", "modified"]',
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_contact(deal, contact):
|
||||
if not frappe.has_permission("CRM Deal", "write", deal):
|
||||
@ -209,6 +218,7 @@ def add_contact(deal, contact):
|
||||
deal.save()
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_contact(deal, contact):
|
||||
if not frappe.has_permission("CRM Deal", "write", deal):
|
||||
@ -219,6 +229,7 @@ def remove_contact(deal, contact):
|
||||
deal.save()
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_primary_contact(deal, contact):
|
||||
if not frappe.has_permission("CRM Deal", "write", deal):
|
||||
@ -229,11 +240,14 @@ def set_primary_contact(deal, contact):
|
||||
deal.save()
|
||||
return True
|
||||
|
||||
|
||||
def create_organization(doc):
|
||||
if not doc.get("organization_name"):
|
||||
return
|
||||
|
||||
existing_organization = frappe.db.exists("CRM Organization", {"organization_name": doc.get("organization_name")})
|
||||
existing_organization = frappe.db.exists(
|
||||
"CRM Organization", {"organization_name": doc.get("organization_name")}
|
||||
)
|
||||
if existing_organization:
|
||||
return existing_organization
|
||||
|
||||
@ -250,6 +264,7 @@ def create_organization(doc):
|
||||
organization.insert(ignore_permissions=True)
|
||||
return organization.name
|
||||
|
||||
|
||||
def contact_exists(doc):
|
||||
email_exist = frappe.db.exists("Contact Email", {"email_id": doc.get("email")})
|
||||
mobile_exist = frappe.db.exists("Contact Phone", {"phone": doc.get("mobile_no")})
|
||||
@ -262,6 +277,7 @@ def contact_exists(doc):
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_contact(doc):
|
||||
existing_contact = contact_exists(doc)
|
||||
if existing_contact:
|
||||
@ -288,18 +304,23 @@ def create_contact(doc):
|
||||
|
||||
return contact.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_deal(args):
|
||||
deal = frappe.new_doc("CRM Deal")
|
||||
|
||||
contact = args.get("contact")
|
||||
if not contact and (args.get("first_name") or args.get("last_name") or args.get("email") or args.get("mobile_no")):
|
||||
if not contact and (
|
||||
args.get("first_name") or args.get("last_name") or args.get("email") or args.get("mobile_no")
|
||||
):
|
||||
contact = create_contact(args)
|
||||
|
||||
deal.update({
|
||||
"organization": args.get("organization") or create_organization(args),
|
||||
"contacts": [{"contact": contact, "is_primary": 1}] if contact else [],
|
||||
})
|
||||
deal.update(
|
||||
{
|
||||
"organization": args.get("organization") or create_organization(args),
|
||||
"contacts": [{"contact": contact, "is_primary": 1}] if contact else [],
|
||||
}
|
||||
)
|
||||
|
||||
args.pop("organization", None)
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Quick Entry\nSide Panel\nData Fields"
|
||||
"options": "Quick Entry\nSide Panel\nData Fields\nGrid Row"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ttpm",
|
||||
@ -46,7 +46,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-05 13:29:37.021412",
|
||||
"modified": "2024-12-29 12:58:54.280569",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Fields Layout",
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.form.assign_to import add as assign
|
||||
from frappe.model.document import Document
|
||||
|
||||
from frappe.utils import has_gravatar, validate_email_address
|
||||
|
||||
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
||||
add_status_change_log,
|
||||
)
|
||||
|
||||
|
||||
class CRMLead(Document):
|
||||
@ -37,7 +38,15 @@ class CRMLead(Document):
|
||||
def set_full_name(self):
|
||||
if self.first_name:
|
||||
self.lead_name = " ".join(
|
||||
filter(None, [self.salutation, self.first_name, self.middle_name, self.last_name])
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
self.salutation,
|
||||
self.first_name,
|
||||
self.middle_name,
|
||||
self.last_name,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def set_lead_name(self):
|
||||
@ -92,9 +101,16 @@ class CRMLead(Document):
|
||||
shared_with = [d.user for d in docshares] + [agent]
|
||||
|
||||
for user in shared_with:
|
||||
if user == agent and not frappe.db.exists("DocShare", {"user": agent, "share_name": self.name, "share_doctype": self.doctype}):
|
||||
if user == agent and not frappe.db.exists(
|
||||
"DocShare",
|
||||
{"user": agent, "share_name": self.name, "share_doctype": self.doctype},
|
||||
):
|
||||
frappe.share.add_docshare(
|
||||
self.doctype, self.name, agent, write=1, flags={"ignore_share_permission": True}
|
||||
self.doctype,
|
||||
self.name,
|
||||
agent,
|
||||
write=1,
|
||||
flags={"ignore_share_permission": True},
|
||||
)
|
||||
elif user != agent:
|
||||
frappe.share.remove(self.doctype, self.name, user)
|
||||
@ -188,8 +204,36 @@ class CRMLead(Document):
|
||||
"lead_owner": "deal_owner",
|
||||
}
|
||||
|
||||
restricted_fieldtypes = ["Tab Break", "Section Break", "Column Break", "HTML", "Button", "Attach", "Table"]
|
||||
restricted_map_fields = ["name", "naming_series", "creation", "owner", "modified", "modified_by", "idx", "docstatus", "status", "email", "mobile_no", "phone", "sla", "sla_status", "response_by", "first_response_time", "first_responded_on", "communication_status", "sla_creation"]
|
||||
restricted_fieldtypes = [
|
||||
"Tab Break",
|
||||
"Section Break",
|
||||
"Column Break",
|
||||
"HTML",
|
||||
"Button",
|
||||
"Attach",
|
||||
"Table",
|
||||
]
|
||||
restricted_map_fields = [
|
||||
"name",
|
||||
"naming_series",
|
||||
"creation",
|
||||
"owner",
|
||||
"modified",
|
||||
"modified_by",
|
||||
"idx",
|
||||
"docstatus",
|
||||
"status",
|
||||
"email",
|
||||
"mobile_no",
|
||||
"phone",
|
||||
"sla",
|
||||
"sla_status",
|
||||
"response_by",
|
||||
"first_response_time",
|
||||
"first_responded_on",
|
||||
"communication_status",
|
||||
"sla_creation",
|
||||
]
|
||||
|
||||
for field in self.meta.fields:
|
||||
if field.fieldtype in restricted_fieldtypes:
|
||||
@ -222,7 +266,7 @@ class CRMLead(Document):
|
||||
"sla_status": self.sla_status,
|
||||
"communication_status": self.communication_status,
|
||||
"first_response_time": self.first_response_time,
|
||||
"first_responded_on": self.first_responded_on
|
||||
"first_responded_on": self.first_responded_on,
|
||||
}
|
||||
)
|
||||
|
||||
@ -233,7 +277,8 @@ class CRMLead(Document):
|
||||
"""
|
||||
Find an SLA to apply to the lead.
|
||||
"""
|
||||
if self.sla: return
|
||||
if self.sla:
|
||||
return
|
||||
|
||||
sla = get_sla(self)
|
||||
if not sla:
|
||||
@ -263,47 +308,47 @@ class CRMLead(Document):
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
'label': 'Name',
|
||||
'type': 'Data',
|
||||
'key': 'lead_name',
|
||||
'width': '12rem',
|
||||
"label": "Name",
|
||||
"type": "Data",
|
||||
"key": "lead_name",
|
||||
"width": "12rem",
|
||||
},
|
||||
{
|
||||
'label': 'Organization',
|
||||
'type': 'Link',
|
||||
'key': 'organization',
|
||||
'options': 'CRM Organization',
|
||||
'width': '10rem',
|
||||
"label": "Organization",
|
||||
"type": "Link",
|
||||
"key": "organization",
|
||||
"options": "CRM Organization",
|
||||
"width": "10rem",
|
||||
},
|
||||
{
|
||||
'label': 'Status',
|
||||
'type': 'Select',
|
||||
'key': 'status',
|
||||
'width': '8rem',
|
||||
"label": "Status",
|
||||
"type": "Select",
|
||||
"key": "status",
|
||||
"width": "8rem",
|
||||
},
|
||||
{
|
||||
'label': 'Email',
|
||||
'type': 'Data',
|
||||
'key': 'email',
|
||||
'width': '12rem',
|
||||
"label": "Email",
|
||||
"type": "Data",
|
||||
"key": "email",
|
||||
"width": "12rem",
|
||||
},
|
||||
{
|
||||
'label': 'Mobile No',
|
||||
'type': 'Data',
|
||||
'key': 'mobile_no',
|
||||
'width': '11rem',
|
||||
"label": "Mobile No",
|
||||
"type": "Data",
|
||||
"key": "mobile_no",
|
||||
"width": "11rem",
|
||||
},
|
||||
{
|
||||
'label': 'Assigned To',
|
||||
'type': 'Text',
|
||||
'key': '_assign',
|
||||
'width': '10rem',
|
||||
"label": "Assigned To",
|
||||
"type": "Text",
|
||||
"key": "_assign",
|
||||
"width": "10rem",
|
||||
},
|
||||
{
|
||||
'label': 'Last Modified',
|
||||
'type': 'Datetime',
|
||||
'key': 'modified',
|
||||
'width': '8rem',
|
||||
"label": "Last Modified",
|
||||
"type": "Datetime",
|
||||
"key": "modified",
|
||||
"width": "8rem",
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
@ -323,20 +368,22 @@ class CRMLead(Document):
|
||||
"_assign",
|
||||
"image",
|
||||
]
|
||||
return {'columns': columns, 'rows': rows}
|
||||
return {"columns": columns, "rows": rows}
|
||||
|
||||
@staticmethod
|
||||
def default_kanban_settings():
|
||||
return {
|
||||
"column_field": "status",
|
||||
"title_field": "lead_name",
|
||||
"kanban_fields": '["organization", "email", "mobile_no", "_assign", "modified"]'
|
||||
"kanban_fields": '["organization", "email", "mobile_no", "_assign", "modified"]',
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def convert_to_deal(lead, doc=None):
|
||||
if not (doc and doc.flags.get("ignore_permissions")) and not frappe.has_permission("CRM Lead", "write", lead):
|
||||
if not (doc and doc.flags.get("ignore_permissions")) and not frappe.has_permission(
|
||||
"CRM Lead", "write", lead
|
||||
):
|
||||
frappe.throw(_("Not allowed to convert Lead to Deal"), frappe.PermissionError)
|
||||
|
||||
lead = frappe.get_cached_doc("CRM Lead", lead)
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 5a4f3c8d4f12efba37b9a83a51a59b53fa758be0
|
||||
Subproject commit 46086c524bc218d989c68ca54cd13a37e693fab9
|
||||
@ -14,7 +14,7 @@
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.91",
|
||||
"frappe-ui": "^0.1.94",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
<LoadingIndicator class="h-6 w-6" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="pb-8">
|
||||
<FieldLayout
|
||||
v-if="tabs.data"
|
||||
:tabs="tabs.data"
|
||||
@ -86,7 +86,7 @@ const data = createDocumentResource({
|
||||
createToast({
|
||||
title: 'Data Updated',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600',
|
||||
iconClasses: 'text-ink-green-3',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
366
frontend/src/components/Controls/Grid.vue
Normal file
366
frontend/src/components/Controls/Grid.vue
Normal file
@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-base">
|
||||
<div v-if="label" class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="fields?.length"
|
||||
class="rounded border border-outline-gray-modals"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="grid-header flex items-center rounded-t-[7px] bg-surface-gray-2 text-ink-gray-5 truncate"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center justify-center border-r border-outline-gray-2 h-8 p-2 w-12"
|
||||
>
|
||||
<Checkbox
|
||||
class="cursor-pointer duration-300"
|
||||
:modelValue="allRowsSelected"
|
||||
@click.stop="toggleSelectAllRows($event.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex items-center justify-center border-r border-outline-gray-2 py-2 px-1 w-12"
|
||||
>
|
||||
{{ __('No') }}
|
||||
</div>
|
||||
<div
|
||||
class="grid w-full truncate"
|
||||
:style="{ gridTemplateColumns: gridTemplateColumns }"
|
||||
>
|
||||
<div
|
||||
v-for="field in fields"
|
||||
class="border-r border-outline-gray-2 p-2 truncate"
|
||||
:key="field.name"
|
||||
:title="field.label"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12">
|
||||
<Button
|
||||
class="flex w-full items-center justify-center rounded !bg-surface-gray-2 border-0"
|
||||
variant="outline"
|
||||
@click="showGridFieldsEditorModal = true"
|
||||
>
|
||||
<FeatherIcon name="settings" class="h-4 w-4 text-ink-gray-7" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rows -->
|
||||
<template v-if="rows.length">
|
||||
<Draggable class="w-full" v-model="rows" group="rows" item-key="name">
|
||||
<template #item="{ element: row, index }">
|
||||
<div
|
||||
class="grid-row flex cursor-pointer items-center border-b border-outline-gray-modals bg-surface-modals last:rounded-b last:border-b-0"
|
||||
>
|
||||
<div
|
||||
class="inline-flex h-9.5 items-center justify-center border-r border-outline-gray-modals p-2 w-12"
|
||||
>
|
||||
<Checkbox
|
||||
class="cursor-pointer duration-300"
|
||||
:modelValue="selectedRows.has(row.name)"
|
||||
@click.stop="toggleSelectRow(row)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-9.5 items-center justify-center border-r border-outline-gray-modals py-2 px-1 text-sm text-ink-gray-8 w-12"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div
|
||||
class="grid w-full h-9.5"
|
||||
:style="{ gridTemplateColumns: gridTemplateColumns }"
|
||||
>
|
||||
<div
|
||||
class="border-r border-outline-gray-modals h-full"
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
>
|
||||
<Link
|
||||
v-if="field.type === 'Link'"
|
||||
class="text-sm text-ink-gray-8"
|
||||
v-model="row[field.name]"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
/>
|
||||
<div
|
||||
v-else-if="field.type === 'Check'"
|
||||
class="flex h-full justify-center items-center"
|
||||
>
|
||||
<Checkbox
|
||||
class="cursor-pointer duration-300"
|
||||
v-model="row[field.name]"
|
||||
/>
|
||||
</div>
|
||||
<DatePicker
|
||||
v-else-if="field.type === 'Date'"
|
||||
v-model="row[field.name]"
|
||||
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]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(
|
||||
field.type,
|
||||
)
|
||||
"
|
||||
rows="1"
|
||||
type="textarea"
|
||||
variant="outline"
|
||||
v-model="row[field.name]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.type)"
|
||||
type="number"
|
||||
variant="outline"
|
||||
v-model="row[field.name]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Select'"
|
||||
class="text-sm text-ink-gray-8"
|
||||
type="select"
|
||||
variant="outline"
|
||||
v-model="row[field.name]"
|
||||
:options="field.options"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
class="text-sm text-ink-gray-8"
|
||||
type="text"
|
||||
variant="outline"
|
||||
v-model="row[field.name]"
|
||||
:options="field.options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-row w-12">
|
||||
<Button
|
||||
class="flex w-full items-center justify-center rounded border-0"
|
||||
variant="outline"
|
||||
@click="showRowList[index] = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4 text-ink-gray-7" />
|
||||
</Button>
|
||||
</div>
|
||||
<GridRowModal
|
||||
v-if="showRowList[index]"
|
||||
v-model="showRowList[index]"
|
||||
v-model:showGridRowFieldsModal="showGridRowFieldsModal"
|
||||
:index="index"
|
||||
:data="row"
|
||||
:doctype="doctype"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center rounded p-5 text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ __('No Data') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fields?.length" class="mt-2 flex flex-row gap-2">
|
||||
<Button
|
||||
v-if="showDeleteBtn"
|
||||
:label="__('Delete')"
|
||||
variant="solid"
|
||||
theme="red"
|
||||
@click="deleteRows"
|
||||
/>
|
||||
<Button :label="__('Add Row')" @click="addRow" />
|
||||
</div>
|
||||
</div>
|
||||
<GridRowFieldsModal
|
||||
v-if="showGridRowFieldsModal"
|
||||
v-model="showGridRowFieldsModal"
|
||||
:doctype="doctype"
|
||||
/>
|
||||
<GridFieldsEditorModal
|
||||
v-if="showGridFieldsEditorModal"
|
||||
v-model="showGridFieldsEditorModal"
|
||||
:doctype="doctype"
|
||||
:parentDoctype="parentDoctype"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
|
||||
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 { getRandom, getFormat } from '@/utils'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import {
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
Checkbox,
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
} from 'frappe-ui'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
parentDoctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const { getGridSettings, getFields } = getMeta(props.doctype)
|
||||
|
||||
const rows = defineModel()
|
||||
const showRowList = ref(new Array(rows.value.length).fill(false))
|
||||
const selectedRows = reactive(new Set())
|
||||
|
||||
const showGridFieldsEditorModal = ref(false)
|
||||
const showGridRowFieldsModal = ref(false)
|
||||
|
||||
const fields = computed(() => {
|
||||
let gridSettings = getGridSettings(props.parentDoctype)
|
||||
let gridFields = getFields()
|
||||
if (gridSettings.length) {
|
||||
let d = gridSettings.map((gs) =>
|
||||
getFieldObj(gridFields.find((f) => f.fieldname === gs.fieldname)),
|
||||
)
|
||||
return d
|
||||
}
|
||||
return gridFields?.map((f) => getFieldObj(f)) || []
|
||||
})
|
||||
|
||||
function getFieldObj(field) {
|
||||
return {
|
||||
label: field.label,
|
||||
name: field.fieldname,
|
||||
type: field.fieldtype,
|
||||
options: field.options,
|
||||
in_list_view: field.in_list_view,
|
||||
}
|
||||
}
|
||||
|
||||
const gridTemplateColumns = computed(() => {
|
||||
if (!fields.value?.length) return '1fr'
|
||||
// for the checkbox & sr no. columns
|
||||
let gridSettings = getGridSettings(props.parentDoctype)
|
||||
if (gridSettings.length) {
|
||||
return gridSettings.map((gs) => `minmax(0, ${gs.columns || 2}fr)`).join(' ')
|
||||
}
|
||||
return fields.value.map(() => `minmax(0, 2fr)`).join(' ')
|
||||
})
|
||||
|
||||
const allRowsSelected = computed(() => {
|
||||
if (!rows.value.length) return false
|
||||
return rows.value.length === selectedRows.size
|
||||
})
|
||||
|
||||
const showDeleteBtn = computed(() => selectedRows.size > 0)
|
||||
|
||||
const toggleSelectAllRows = (iSelected) => {
|
||||
if (iSelected) {
|
||||
rows.value.forEach((row) => selectedRows.add(row.name))
|
||||
} else {
|
||||
selectedRows.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectRow = (row) => {
|
||||
if (selectedRows.has(row.name)) {
|
||||
selectedRows.delete(row.name)
|
||||
} else {
|
||||
selectedRows.add(row.name)
|
||||
}
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
const newRow = {}
|
||||
fields.value?.forEach((field) => {
|
||||
if (field.type === 'Check') newRow[field.name] = false
|
||||
else newRow[field.name] = ''
|
||||
})
|
||||
newRow.name = getRandom(10)
|
||||
showRowList.value.push(false)
|
||||
newRow['__islocal'] = true
|
||||
rows.value.push(newRow)
|
||||
}
|
||||
|
||||
const deleteRows = () => {
|
||||
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
|
||||
showRowList.value.pop()
|
||||
selectedRows.clear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* For Input fields */
|
||||
:deep(.grid-row input:not([type='checkbox'])),
|
||||
:deep(.grid-row textarea) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
:deep(.grid-row input:focus),
|
||||
:deep(.grid-row input:hover),
|
||||
:deep(.grid-row textarea:focus),
|
||||
:deep(.grid-row textarea:hover) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.grid-row input:focus-within) :deep(.grid-row textarea:focus-within) {
|
||||
border: 1px solid var(--outline-gray-2);
|
||||
}
|
||||
|
||||
/* For select field */
|
||||
:deep(.grid-row select) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/* For Autocomplete */
|
||||
:deep(.grid-row button) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: var(--surface-white);
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
:deep(.grid-row .edit-row button) {
|
||||
border-bottom-right-radius: 7px;
|
||||
}
|
||||
|
||||
:deep(.grid-row button:focus) :deep(.grid-row button:hover) {
|
||||
box-shadow: none;
|
||||
background-color: var(--surface-white);
|
||||
}
|
||||
|
||||
:deep(.grid-row button:focus-within) {
|
||||
border: 1px solid var(--outline-gray-2);
|
||||
}
|
||||
</style>
|
||||
193
frontend/src/components/Controls/GridFieldsEditorModal.vue
Normal file
193
frontend/src/components/Controls/GridFieldsEditorModal.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<Dialog v-model="show">
|
||||
<template #body-title>
|
||||
<h3
|
||||
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-ink-gray-9"
|
||||
>
|
||||
<div>{{ __('Edit Grid Fields Layout') }}</div>
|
||||
<Badge
|
||||
v-if="dirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h3>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="mt-4">
|
||||
<div class="text-base text-ink-gray-8 mb-2">
|
||||
{{ __('Fields Order') }}
|
||||
</div>
|
||||
<Draggable
|
||||
v-if="oldFields?.length"
|
||||
:list="fields"
|
||||
@end="reorder"
|
||||
group="fields"
|
||||
item-key="fieldname"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<template #item="{ element: field }">
|
||||
<div
|
||||
class="px-1 py-0.5 bg-surface-gray-2 border border-outline-gray-modals rounded text-base text-ink-gray-8 flex items-center justify-between gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
||||
<div>{{ field.label }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<TextInput
|
||||
variant="outline"
|
||||
type="number"
|
||||
v-model="field.columns"
|
||||
class="w-20"
|
||||
/>
|
||||
<Button variant="ghost" icon="x" @click="removeField(field)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<Autocomplete
|
||||
v-if="dropdownFields?.length"
|
||||
value=""
|
||||
:options="dropdownFields"
|
||||
@change="(e) => addField(e)"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
class="w-full mt-2"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Field')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</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>
|
||||
<ErrorMessage class="mt-3" v-if="error" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="dirty"
|
||||
class="w-full"
|
||||
:label="__('Reset')"
|
||||
@click="reset"
|
||||
/>
|
||||
<Button
|
||||
class="w-full"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="update"
|
||||
:loading="loading"
|
||||
:disabled="!dirty"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Dialog, ErrorMessage } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: String,
|
||||
parentDoctype: String,
|
||||
})
|
||||
|
||||
const { userSettings, getFields, getGridSettings, saveUserSettings } = getMeta(
|
||||
props.doctype,
|
||||
)
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const dirty = computed(() => {
|
||||
return JSON.stringify(fields.value) !== JSON.stringify(oldFields.value)
|
||||
})
|
||||
|
||||
const oldFields = computed(() => {
|
||||
let _fields = getFields()
|
||||
let gridSettings = getGridSettings(props.parentDoctype)
|
||||
|
||||
if (gridSettings.length) {
|
||||
return gridSettings.map((field) => {
|
||||
let f = _fields.find((f) => f.fieldname === field.fieldname)
|
||||
if (f) {
|
||||
f.columns = field.columns
|
||||
return fieldObj(f)
|
||||
}
|
||||
})
|
||||
}
|
||||
return _fields?.filter((field) => field.in_list_view).map((f) => fieldObj(f))
|
||||
})
|
||||
|
||||
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
|
||||
|
||||
const dropdownFields = computed(() => {
|
||||
return getFields()?.filter(
|
||||
(field) => !fields.value.find((f) => f.fieldname === field.fieldname),
|
||||
)
|
||||
})
|
||||
|
||||
function reset() {
|
||||
fields.value = JSON.parse(JSON.stringify(oldFields.value || []))
|
||||
}
|
||||
|
||||
function addField(field) {
|
||||
fields.value.push(fieldObj(field))
|
||||
}
|
||||
|
||||
function removeField(field) {
|
||||
const index = fields.value.findIndex((f) => f.fieldname === field.fieldname)
|
||||
fields.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function update() {
|
||||
loading.value = true
|
||||
|
||||
let updateFields = fields.value.map((field) => {
|
||||
return {
|
||||
fieldname: field.fieldname,
|
||||
columns: field.columns,
|
||||
}
|
||||
})
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
error.value = __('At least one field is required')
|
||||
return
|
||||
}
|
||||
|
||||
saveUserSettings(props.parentDoctype, 'GridView', updateFields, () => {
|
||||
loading.value = false
|
||||
show.value = false
|
||||
userSettings[props.parentDoctype]['GridView'][props.doctype] = updateFields
|
||||
})
|
||||
}
|
||||
|
||||
function fieldObj(field) {
|
||||
return {
|
||||
label: field.label,
|
||||
fieldname: field.fieldname,
|
||||
fieldtype: field.fieldtype,
|
||||
options: field.options,
|
||||
in_list_view: field.in_list_view,
|
||||
columns: field.columns || 2,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
125
frontend/src/components/Controls/GridRowFieldsModal.vue
Normal file
125
frontend/src/components/Controls/GridRowFieldsModal.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '3xl' }">
|
||||
<template #body-title>
|
||||
<h3
|
||||
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-ink-gray-9"
|
||||
>
|
||||
<div>{{ __('Edit Grid Row Fields Layout') }}</div>
|
||||
<Badge
|
||||
v-if="dirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h3>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between gap-2">
|
||||
<Button
|
||||
:label="preview ? __('Hide preview') : __('Show preview')"
|
||||
@click="preview = !preview"
|
||||
/>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabs?.data">
|
||||
<FieldLayoutEditor
|
||||
v-if="!preview"
|
||||
:tabs="tabs.data"
|
||||
:doctype="_doctype"
|
||||
/>
|
||||
<FieldLayout v-else :tabs="tabs.data" :data="{}" :modal="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import FieldLayout from '@/components/FieldLayout.vue'
|
||||
import FieldLayoutEditor from '@/components/FieldLayoutEditor.vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Dialog, Badge, call, createResource } from 'frappe-ui'
|
||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: 'CRM Lead',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reload'])
|
||||
|
||||
const show = defineModel()
|
||||
const _doctype = ref(props.doctype)
|
||||
const loading = ref(false)
|
||||
const dirty = ref(false)
|
||||
const preview = ref(false)
|
||||
|
||||
function getParams() {
|
||||
return { doctype: _doctype.value, type: 'Grid Row' }
|
||||
}
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
cache: ['GridRowFieldsModal', _doctype.value],
|
||||
params: getParams(),
|
||||
onSuccess(data) {
|
||||
tabs.originalData = JSON.parse(JSON.stringify(data))
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => tabs?.data,
|
||||
() => {
|
||||
dirty.value =
|
||||
JSON.stringify(tabs?.data) !== JSON.stringify(tabs?.originalData)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => useDebounceFn(reload, 100)())
|
||||
|
||||
function reload() {
|
||||
nextTick(() => {
|
||||
tabs.params = getParams()
|
||||
tabs.reload()
|
||||
})
|
||||
}
|
||||
|
||||
function saveChanges() {
|
||||
let _tabs = JSON.parse(JSON.stringify(tabs.data))
|
||||
_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,
|
||||
)
|
||||
})
|
||||
})
|
||||
loading.value = true
|
||||
call(
|
||||
'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.save_fields_layout',
|
||||
{
|
||||
doctype: _doctype.value,
|
||||
type: 'Grid Row',
|
||||
layout: JSON.stringify(_tabs),
|
||||
},
|
||||
).then(() => {
|
||||
loading.value = false
|
||||
show.value = false
|
||||
capture('data_fields_layout_builder', { doctype: _doctype.value })
|
||||
emit('reload')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
62
frontend/src/components/Controls/GridRowModal.vue
Normal file
62
frontend/src/components/Controls/GridRowModal.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '4xl' }">
|
||||
<template #body>
|
||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('Editing Row {0}', [index + 1]) }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="isManager()"
|
||||
variant="ghost"
|
||||
class="w-7"
|
||||
@click="openGridRowFieldsModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="data" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import FieldLayout from '@/components/FieldLayout.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
index: Number,
|
||||
data: Object,
|
||||
doctype: String,
|
||||
})
|
||||
|
||||
const { isManager } = usersStore()
|
||||
|
||||
const show = defineModel()
|
||||
const showGridRowFieldsModal = defineModel('showGridRowFieldsModal')
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
cache: ['GridRow', props.doctype],
|
||||
params: { doctype: props.doctype, type: 'Grid Row' },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
function openGridRowFieldsModal() {
|
||||
showGridRowFieldsModal.value = true
|
||||
nextTick(() => (show.value = false))
|
||||
}
|
||||
</script>
|
||||
@ -15,7 +15,7 @@
|
||||
!hasTabs ? 'hidden' : modal ? 'border-outline-gray-modals' : ''
|
||||
"
|
||||
>
|
||||
<div :class="{ 'my-4 sm:my-6': hasTabs }">
|
||||
<div class="overflow-hidden" :class="{ 'my-4 sm:my-6': hasTabs }">
|
||||
<div
|
||||
v-for="(section, i) in tab.sections"
|
||||
:key="section.label"
|
||||
@ -76,6 +76,13 @@
|
||||
v-model="data[field.name]"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Grid
|
||||
v-else-if="field.type === 'Table'"
|
||||
v-model="data[field.name]"
|
||||
:fields="field.fields"
|
||||
:doctype="field.options"
|
||||
:parentDoctype="doctype"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Select'"
|
||||
type="select"
|
||||
@ -178,7 +185,9 @@
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text'].includes(field.type)
|
||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(
|
||||
field.type,
|
||||
)
|
||||
"
|
||||
type="textarea"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
@ -237,7 +246,8 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { getMeta } from '../stores/meta'
|
||||
import Grid from '@/components/Controls/Grid.vue'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getFormat } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
|
||||
@ -1,70 +1,70 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5.5">
|
||||
<div
|
||||
class="flex justify-between items-center gap-1 text-base bg-surface-gray-2 rounded py-2 px-2.5"
|
||||
class="flex items-center gap-2 text-base bg-surface-gray-2 rounded py-2 px-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<Draggable
|
||||
v-if="tabs.length && !tabs[tabIndex].no_tabs"
|
||||
:list="tabs"
|
||||
item-key="label"
|
||||
class="flex items-center gap-1"
|
||||
@end="(e) => (tabIndex = e.newIndex)"
|
||||
>
|
||||
<template #item="{ element: tab, index: i }">
|
||||
<div
|
||||
class="cursor-pointer rounded"
|
||||
:class="[
|
||||
tabIndex == i
|
||||
? 'text-ink-gray-9 bg-surface-white shadow-sm'
|
||||
: 'text-ink-gray-5 hover:text-ink-gray-9 hover:bg-surface-white hover:shadow-sm',
|
||||
tab.editingLabel ? 'p-1' : 'px-2 py-1',
|
||||
]"
|
||||
@click="tabIndex = i"
|
||||
>
|
||||
<div @dblclick="() => (tab.editingLabel = true)">
|
||||
<div v-if="!tab.editingLabel" class="flex items-center gap-2">
|
||||
{{ __(tab.label) || __('Untitled') }}
|
||||
</div>
|
||||
<div v-else class="flex gap-1 items-center">
|
||||
<Input
|
||||
v-model="tab.label"
|
||||
@keydown.enter="tab.editingLabel = false"
|
||||
@blur="tab.editingLabel = false"
|
||||
@click.stop
|
||||
/>
|
||||
<Button
|
||||
v-if="tab.editingLabel"
|
||||
icon="check"
|
||||
variant="ghost"
|
||||
@click="tab.editingLabel = false"
|
||||
/>
|
||||
</div>
|
||||
<Draggable
|
||||
v-if="tabs.length && !tabs[tabIndex].no_tabs"
|
||||
:list="tabs"
|
||||
item-key="label"
|
||||
class="flex items-center gap-2"
|
||||
@end="(e) => (tabIndex = e.newIndex)"
|
||||
>
|
||||
<template #item="{ element: tab, index: i }">
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer rounded"
|
||||
:class="[
|
||||
tabIndex == i
|
||||
? 'text-ink-gray-9 bg-surface-white shadow-sm'
|
||||
: 'text-ink-gray-5 hover:text-ink-gray-9 hover:bg-surface-white hover:shadow-sm',
|
||||
tab.editingLabel ? 'p-1' : 'px-2 py-1',
|
||||
]"
|
||||
@click="tabIndex = i"
|
||||
>
|
||||
<div @dblclick="() => (tab.editingLabel = true)">
|
||||
<div v-if="!tab.editingLabel" class="flex items-center gap-2">
|
||||
{{ __(tab.label) || __('Untitled') }}
|
||||
</div>
|
||||
<div v-else class="flex gap-1 items-center">
|
||||
<Input
|
||||
v-model="tab.label"
|
||||
@keydown.enter="tab.editingLabel = false"
|
||||
@blur="tab.editingLabel = false"
|
||||
@click.stop
|
||||
/>
|
||||
<Button
|
||||
v-if="tab.editingLabel"
|
||||
icon="check"
|
||||
variant="ghost"
|
||||
@click="tab.editingLabel = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!h-6.5 !text-ink-gray-5 hover:!text-ink-gray-9"
|
||||
@click="addTab"
|
||||
:label="__('Add Tab')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-if="tabs.length && !tabs[tabIndex].no_tabs"
|
||||
:options="getTabOptions(tabs[tabIndex])"
|
||||
>
|
||||
<template #default>
|
||||
<Button variant="ghost">
|
||||
<FeatherIcon name="more-horizontal" class="h-4" />
|
||||
</Button>
|
||||
<Dropdown
|
||||
v-if="!tab.no_tabs && tabIndex == i"
|
||||
:options="getTabOptions(tab)"
|
||||
class="!h-4"
|
||||
@click.stop
|
||||
>
|
||||
<template #default>
|
||||
<Button variant="ghost" class="!p-1 !h-4">
|
||||
<FeatherIcon name="more-horizontal" class="h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</Draggable>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!h-6.5 !text-ink-gray-5 hover:!text-ink-gray-9"
|
||||
@click="addTab"
|
||||
:label="__('Add Tab')"
|
||||
>
|
||||
<template v-slot:[slotName]>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-show="tabIndex == i" v-for="(tab, i) in tabs" :key="tab.label">
|
||||
<Draggable
|
||||
@ -126,9 +126,9 @@
|
||||
<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"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
||||
<div>{{ field.label }}</div>
|
||||
<div class="truncate">{{ field.label }}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -207,9 +207,14 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const slotName = computed(() => {
|
||||
if (props.tabs.length == 1 && props.tabs[0].no_tabs) {
|
||||
return 'prefix'
|
||||
}
|
||||
return 'default'
|
||||
})
|
||||
|
||||
const restrictedFieldTypes = [
|
||||
'Table',
|
||||
'Geolocation',
|
||||
'Attach',
|
||||
'Attach Image',
|
||||
@ -230,6 +235,34 @@ const fields = createResource({
|
||||
params: params.value,
|
||||
cache: ['fieldsMeta', props.doctype],
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
let restrictedFields = [
|
||||
'name',
|
||||
'owner',
|
||||
'creation',
|
||||
'modified',
|
||||
'modified_by',
|
||||
'docstatus',
|
||||
'_comments',
|
||||
'_user_tags',
|
||||
'_assign',
|
||||
'_liked_by',
|
||||
]
|
||||
let existingFields = []
|
||||
|
||||
for (let tab of props.tabs) {
|
||||
for (let section of tab.sections) {
|
||||
existingFields = existingFields.concat(section.fields)
|
||||
}
|
||||
}
|
||||
|
||||
return data.filter((field) => {
|
||||
return (
|
||||
!existingFields.find((f) => f.name === field.fieldname) &&
|
||||
!restrictedFields.includes(field.fieldname)
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function addTab() {
|
||||
@ -243,7 +276,12 @@ function addTab() {
|
||||
|
||||
function addField(section, field) {
|
||||
if (!field) return
|
||||
section.fields.push(field)
|
||||
let newFieldObj = {
|
||||
...field,
|
||||
name: field.fieldname,
|
||||
type: field.fieldtype,
|
||||
}
|
||||
section.fields.push(newFieldObj)
|
||||
}
|
||||
|
||||
function getTabOptions(tab) {
|
||||
|
||||
@ -89,7 +89,7 @@
|
||||
</Button>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<div class="flex flex-col gap-1">
|
||||
<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}` }}
|
||||
@ -166,9 +166,6 @@ const fields = createResource({
|
||||
params: { doctype: props.doctype, as_array: true },
|
||||
cache: ['kanban_fields', props.doctype],
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
data
|
||||
},
|
||||
})
|
||||
|
||||
const allFields = computed({
|
||||
|
||||
@ -16,18 +16,19 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between gap-2">
|
||||
<FormControl
|
||||
type="select"
|
||||
class="w-1/4"
|
||||
v-model="_doctype"
|
||||
:options="['CRM Lead', 'CRM Deal']"
|
||||
@change="reload"
|
||||
/>
|
||||
<Switch
|
||||
v-model="preview"
|
||||
<Button
|
||||
:label="preview ? __('Hide preview') : __('Show preview')"
|
||||
size="sm"
|
||||
@click="preview = !preview"
|
||||
/>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabs?.data">
|
||||
<FieldLayoutEditor
|
||||
@ -39,17 +40,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
@ -57,7 +47,7 @@ import FieldLayout from '@/components/FieldLayout.vue'
|
||||
import FieldLayoutEditor from '@/components/FieldLayoutEditor.vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Dialog, Badge, Switch, call, createResource } from 'frappe-ui'
|
||||
import { Dialog, Badge, call, createResource } from 'frappe-ui'
|
||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@ -16,24 +16,19 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between gap-2">
|
||||
<FormControl
|
||||
type="select"
|
||||
class="w-1/4"
|
||||
v-model="_doctype"
|
||||
:options="[
|
||||
'CRM Lead',
|
||||
'CRM Deal',
|
||||
'Contact',
|
||||
'CRM Organization',
|
||||
'Address',
|
||||
]"
|
||||
@change="reload"
|
||||
/>
|
||||
<Switch
|
||||
v-model="preview"
|
||||
<Button
|
||||
:label="preview ? __('Hide preview') : __('Show preview')"
|
||||
size="sm"
|
||||
@click="preview = !preview"
|
||||
/>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabs?.data">
|
||||
<FieldLayoutEditor
|
||||
@ -45,17 +40,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@ -16,18 +16,19 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-5.5">
|
||||
<div class="flex justify-between gap-2">
|
||||
<FormControl
|
||||
type="select"
|
||||
class="w-1/4"
|
||||
v-model="_doctype"
|
||||
:options="['CRM Lead', 'CRM Deal', 'Contact', 'CRM Organization']"
|
||||
@change="reload"
|
||||
/>
|
||||
<Switch
|
||||
v-model="preview"
|
||||
<Button
|
||||
:label="preview ? __('Hide preview') : __('Show preview')"
|
||||
size="sm"
|
||||
@click="preview = !preview"
|
||||
/>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabs.data?.[0]?.sections" class="flex gap-4">
|
||||
<SidePanelLayoutEditor
|
||||
@ -66,17 +67,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
@ -85,7 +75,7 @@ import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||
import SidePanelLayoutEditor from '@/components/SidePanelLayoutEditor.vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Dialog, Badge, Switch, call, createResource } from 'frappe-ui'
|
||||
import { Dialog, Badge, call, createResource } from 'frappe-ui'
|
||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { formatCurrency, formatNumber } from '@/utils/numberFormat.js'
|
||||
import { reactive } from 'vue'
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const doctypeMeta = reactive({})
|
||||
const userSettings = reactive({})
|
||||
|
||||
export function getMeta(doctype) {
|
||||
const meta = createResource({
|
||||
@ -18,6 +19,8 @@ export function getMeta(doctype) {
|
||||
for (let dtMeta of dtMetas) {
|
||||
doctypeMeta[dtMeta.name] = dtMeta
|
||||
}
|
||||
|
||||
userSettings[doctype] = JSON.parse(res.user_settings)
|
||||
},
|
||||
})
|
||||
|
||||
@ -52,9 +55,54 @@ export function getMeta(doctype) {
|
||||
return formatCurrency(doc[fieldname], '', currency, precision)
|
||||
}
|
||||
|
||||
function getGridSettings(parentDoctype, dt = null) {
|
||||
dt = dt || doctype
|
||||
if (!userSettings[parentDoctype]['GridView']?.[doctype]) return {}
|
||||
return userSettings[parentDoctype]['GridView'][doctype]
|
||||
}
|
||||
|
||||
function getFields(dt = null) {
|
||||
dt = dt || doctype
|
||||
return doctypeMeta[dt]?.fields.map((f) => {
|
||||
if (f.fieldtype === 'Select' && typeof f.options === 'string') {
|
||||
f.options = f.options.split('\n').map((option) => {
|
||||
return {
|
||||
label: option,
|
||||
value: option,
|
||||
}
|
||||
})
|
||||
}
|
||||
return f
|
||||
})
|
||||
}
|
||||
|
||||
function saveUserSettings(parentDoctype, key, value, callback) {
|
||||
let oldUserSettings = userSettings[parentDoctype]
|
||||
let newUserSettings = JSON.parse(JSON.stringify(oldUserSettings))
|
||||
|
||||
newUserSettings[key][doctype] = value
|
||||
|
||||
if (JSON.stringify(oldUserSettings) !== JSON.stringify(newUserSettings)) {
|
||||
return createResource({
|
||||
url: 'frappe.model.utils.user_settings.save',
|
||||
params: {
|
||||
doctype: parentDoctype,
|
||||
user_settings: JSON.stringify(newUserSettings),
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: () => callback?.(),
|
||||
})
|
||||
}
|
||||
return callback?.()
|
||||
}
|
||||
|
||||
return {
|
||||
meta,
|
||||
doctypeMeta,
|
||||
userSettings,
|
||||
getFields,
|
||||
getGridSettings,
|
||||
saveUserSettings,
|
||||
getFormattedFloat,
|
||||
getFormattedPercent,
|
||||
getFormattedCurrency,
|
||||
|
||||
@ -305,3 +305,14 @@ export function isImage(extention) {
|
||||
extention.toLowerCase(),
|
||||
)
|
||||
}
|
||||
|
||||
export function getRandom(len) {
|
||||
let text = ''
|
||||
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
Array.from({ length: len }).forEach(() => {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length))
|
||||
})
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
@ -2388,10 +2388,10 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
frappe-ui@^0.1.91:
|
||||
version "0.1.93"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.93.tgz#0443800e195cddcff88ba875989148a92a270206"
|
||||
integrity sha512-21VUcBBB9g1o7Iv/TW8ng3SdCxsm9G/g0rEdC9Y1Vqx1A1Ucf8VtVFECX2MgoDrLAwVOBKayU5BMaS1fuphcnA==
|
||||
frappe-ui@^0.1.94:
|
||||
version "0.1.94"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.94.tgz#574a193ce51b47cb2747a5d5f9e9fe91b164e3a6"
|
||||
integrity sha512-WV7nApCrDBqtKPPiVVxqFgA0JdWQz9kATZylfmMJQrSWszHCx0k0eriTSvT9+0vmtT0T9UYqLfXnhNj0lnqYNA==
|
||||
dependencies:
|
||||
"@headlessui/vue" "^1.7.14"
|
||||
"@popperjs/core" "^2.11.2"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user