Merge pull request #465 from shariquerik/data-tab

feat: Data Tab
This commit is contained in:
Shariq Ansari 2024-12-10 17:29:37 +05:30 committed by GitHub
commit b4c766f513
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1356 additions and 948 deletions

View File

@ -78,12 +78,7 @@ def get_filterable_fields(doctype: str):
# append standard fields (getting error when using frappe.model.std_fields)
standard_fields = [
{"fieldname": "name", "fieldtype": "Link", "label": "ID", "options": doctype},
{
"fieldname": "owner",
"fieldtype": "Link",
"label": "Created By",
"options": "User"
},
{"fieldname": "owner", "fieldtype": "Link", "label": "Created By", "options": "User"},
{
"fieldname": "modified_by",
"fieldtype": "Link",
@ -98,10 +93,7 @@ def get_filterable_fields(doctype: str):
{"fieldname": "modified", "fieldtype": "Datetime", "label": "Last Updated On"},
]
for field in standard_fields:
if (
field.get("fieldname") not in restricted_fields and
field.get("fieldtype") in allowed_fieldtypes
):
if field.get("fieldname") not in restricted_fields and field.get("fieldtype") in allowed_fieldtypes:
field["name"] = field.get("fieldname")
res.append(field)
@ -128,7 +120,11 @@ def get_group_by_fields(doctype: str):
]
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldtype not in no_value_fields and field.fieldtype in allowed_fieldtypes]
fields = [
field
for field in fields
if field.fieldtype not in no_value_fields and field.fieldtype in allowed_fieldtypes
]
fields = [
{
"label": _(field.label),
@ -176,6 +172,7 @@ def get_doctype_fields_meta(DocField, doctype, allowed_fieldtypes, restricted_fi
.run(as_dict=True)
)
@frappe.whitelist()
def get_quick_filters(doctype: str):
meta = frappe.get_meta(doctype)
@ -183,23 +180,25 @@ def get_quick_filters(doctype: str):
quick_filters = []
for field in fields:
if field.fieldtype == "Select":
field.options = field.options.split("\n")
field.options = [{"label": option, "value": option} for option in field.options]
field.options.insert(0, {"label": "", "value": ""})
quick_filters.append({
"label": _(field.label),
"name": field.fieldname,
"type": field.fieldtype,
"options": field.options,
})
quick_filters.append(
{
"label": _(field.label),
"name": field.fieldname,
"type": field.fieldtype,
"options": field.options,
}
)
if doctype == "CRM Lead":
quick_filters = [filter for filter in quick_filters if filter.get("name") != "converted"]
return quick_filters
@frappe.whitelist()
def get_data(
doctype: str,
@ -223,9 +222,9 @@ def get_data(
kanban_fields = frappe.parse_json(kanban_fields or "[]")
kanban_columns = frappe.parse_json(kanban_columns or "[]")
custom_view_name = view.get('custom_view_name') if view else None
view_type = view.get('view_type') if view else None
group_by_field = view.get('group_by_field') if view else None
custom_view_name = view.get("custom_view_name") if view else None
view_type = view.get("view_type") if view else None
group_by_field = view.get("group_by_field") if view else None
for key in filters:
value = filters[key]
@ -268,7 +267,7 @@ def get_data(
default_view_filters = {
"dt": doctype,
"type": view_type or 'list',
"type": view_type or "list",
"is_default": 1,
"user": frappe.session.user,
}
@ -295,13 +294,16 @@ def get_data(
if group_by_field and group_by_field not in rows:
rows.append(group_by_field)
data = frappe.get_list(
doctype,
fields=rows,
filters=filters,
order_by=order_by,
page_length=page_length,
) or []
data = (
frappe.get_list(
doctype,
fields=rows,
filters=filters,
order_by=order_by,
page_length=page_length,
)
or []
)
if view_type == "kanban":
if not rows:
@ -336,9 +338,9 @@ def get_data(
rows.append(field)
for kc in kanban_columns:
column_filters = { column_field: kc.get('name') }
column_filters = {column_field: kc.get("name")}
order = kc.get("order")
if column_field in filters and filters.get(column_field) != kc.name or kc.get('delete'):
if column_field in filters and filters.get(column_field) != kc.name or kc.get("delete"):
column_data = []
else:
column_filters.update(filters.copy())
@ -348,7 +350,9 @@ def get_data(
page_length = kc.get("page_length")
if order:
column_data = get_records_based_on_order(doctype, rows, column_filters, page_length, order)
column_data = get_records_based_on_order(
doctype, rows, column_filters, page_length, order
)
else:
column_data = frappe.get_list(
doctype,
@ -359,9 +363,11 @@ def get_data(
)
new_filters = filters.copy()
new_filters.update({ column_field: kc.get('name') })
new_filters.update({column_field: kc.get("name")})
all_count = len(frappe.get_list(doctype, filters=convert_filter_to_tuple(doctype, new_filters)))
all_count = len(
frappe.get_list(doctype, filters=convert_filter_to_tuple(doctype, new_filters))
)
kc["all_count"] = all_count
kc["count"] = len(column_data)
@ -371,8 +377,8 @@ def get_data(
if order:
column_data = sorted(
column_data, key=lambda x: order.index(x.get("name"))
if x.get("name") in order else len(order)
column_data,
key=lambda x: order.index(x.get("name")) if x.get("name") in order else len(order),
)
data.append({"column": kc, "fields": kanban_fields, "data": column_data})
@ -406,8 +412,8 @@ def get_data(
]
for field in std_fields:
if field.get('value') not in rows:
rows.append(field.get('value'))
if field.get("value") not in rows:
rows.append(field.get("value"))
if field not in fields:
field["label"] = _(field["label"])
fields.append(field)
@ -416,6 +422,7 @@ def get_data(
is_default = frappe.db.get_value("CRM View Settings", custom_view_name, "load_default_columns")
if group_by_field and view_type == "group_by":
def get_options(type, options):
if type == "Select":
return [option for option in options.split("\n")]
@ -428,7 +435,9 @@ def get_data(
if order_by and group_by_field in order_by:
order_by_fields = order_by.split(",")
order_by_fields = [(field.split(" ")[0], field.split(" ")[1]) for field in order_by_fields]
order_by_fields = [
(field.split(" ")[0], field.split(" ")[1]) for field in order_by_fields
]
if (group_by_field, "asc") in order_by_fields:
options.sort()
elif (group_by_field, "desc") in order_by_fields:
@ -467,6 +476,7 @@ def get_data(
"view_type": view_type,
}
def convert_filter_to_tuple(doctype, filters):
if isinstance(filters, dict):
filters_items = filters.items()
@ -504,6 +514,7 @@ def get_records_based_on_order(doctype, rows, filters, page_length, order):
return records
@frappe.whitelist()
def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
not_allowed_fieldtypes = [
@ -521,12 +532,7 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
standard_fields = [
{"fieldname": "name", "fieldtype": "Link", "label": "ID", "options": doctype},
{
"fieldname": "owner",
"fieldtype": "Link",
"label": "Created By",
"options": "User"
},
{"fieldname": "owner", "fieldtype": "Link", "label": "Created By", "options": "User"},
{
"fieldname": "modified_by",
"fieldtype": "Link",
@ -542,7 +548,7 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
]
for field in standard_fields:
if not restricted_fieldtypes or field.get('fieldtype') not in restricted_fieldtypes:
if not restricted_fieldtypes or field.get("fieldtype") not in restricted_fieldtypes:
fields.append(field)
if as_array:
@ -550,10 +556,11 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
fields_meta = {}
for field in fields:
fields_meta[field.get('fieldname')] = field
fields_meta[field.get("fieldname")] = field
return fields_meta
@frappe.whitelist()
def get_sidebar_fields(doctype, name):
if not frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}):
@ -562,7 +569,7 @@ def get_sidebar_fields(doctype, name):
if not layout:
return []
layout = json.loads(layout)
not_allowed_fieldtypes = [
@ -600,6 +607,7 @@ def get_sidebar_fields(doctype, name):
return layout
def get_field_obj(field):
obj = {
"label": field.label,
@ -641,6 +649,7 @@ def get_type(field):
return "read_only"
return field.fieldtype.lower()
def get_assigned_users(doctype, name, default_assigned_to=None):
assigned_users = frappe.get_all(
"ToDo",
@ -671,32 +680,55 @@ def get_fields(doctype: str, allow_all_fieldtypes: bool = False):
_fields = []
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"),
})
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"),
}
)
return _fields
def getCounts(d, doctype):
d["_email_count"] = frappe.db.count("Communication", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "communication_type": "Communication"}) or 0
d["_email_count"] = d["_email_count"] + frappe.db.count("Communication", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "communication_type": "Automated Message"})
d["_comment_count"] = frappe.db.count("Comment", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "comment_type": "Comment"})
d["_task_count"] = frappe.db.count("CRM Task", filters={"reference_doctype": doctype, "reference_docname": d.get("name")})
d["_note_count"] = frappe.db.count("FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")})
return d
d["_email_count"] = (
frappe.db.count(
"Communication",
filters={
"reference_doctype": doctype,
"reference_name": d.get("name"),
"communication_type": "Communication",
},
)
or 0
)
d["_email_count"] = d["_email_count"] + frappe.db.count(
"Communication",
filters={
"reference_doctype": doctype,
"reference_name": d.get("name"),
"communication_type": "Automated Message",
},
)
d["_comment_count"] = frappe.db.count(
"Comment",
filters={"reference_doctype": doctype, "reference_name": d.get("name"), "comment_type": "Comment"},
)
d["_task_count"] = frappe.db.count(
"CRM Task", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
)
d["_note_count"] = frappe.db.count(
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
)
return d

View File

@ -1,38 +1,19 @@
import frappe
from frappe import _
from crm.api.doc import get_fields_meta, get_assigned_users
from crm.api.doc import get_assigned_users, get_fields_meta
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_deal(name):
Deal = frappe.qb.DocType("CRM Deal")
deal = frappe.get_doc("CRM Deal", name).as_dict()
query = (
frappe.qb.from_(Deal)
.select("*")
.where(Deal.name == name)
.limit(1)
)
deal = query.run(as_dict=True)
if not len(deal):
frappe.throw(_("Deal not found"), frappe.DoesNotExistError)
deal = deal.pop()
deal["contacts"] = frappe.get_all(
"CRM Contacts",
filters={"parenttype": "CRM Deal", "parent": deal.name},
fields=["contact", "is_primary"],
)
deal["doctype"] = "CRM Deal"
deal["fields_meta"] = get_fields_meta("CRM Deal")
deal["_form_script"] = get_form_script('CRM Deal')
deal["fields_meta"] = get_fields_meta("CRM Deal")
deal["_form_script"] = get_form_script("CRM Deal")
deal["_assign"] = get_assigned_users("CRM Deal", deal.name, deal.owner)
return deal
@frappe.whitelist()
def get_deal_contacts(name):
contacts = frappe.get_all(
@ -44,16 +25,19 @@ def get_deal_contacts(name):
for contact in contacts:
is_primary = contact.is_primary
contact = frappe.get_doc("Contact", contact.contact).as_dict()
def get_primary_email(contact):
for email in contact.email_ids:
if email.is_primary:
return email.email_id
return contact.email_ids[0].email_id if contact.email_ids else ""
def get_primary_mobile_no(contact):
for phone in contact.phone_nos:
if phone.is_primary:
return phone.phone
return contact.phone_nos[0].phone if contact.phone_nos else ""
_contact = {
"name": contact.name,
"image": contact.image,
@ -63,4 +47,4 @@ def get_deal_contacts(name):
"is_primary": is_primary,
}
deal_contacts.append(_contact)
return deal_contacts
return deal_contacts

View File

@ -27,7 +27,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Type",
"options": "Quick Entry\nSide Panel"
"options": "Quick Entry\nSide Panel\nData Fields"
},
{
"fieldname": "section_break_ttpm",
@ -46,7 +46,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-06-13 15:10:01.612851",
"modified": "2024-12-05 13:29:37.021412",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Fields Layout",

View File

@ -2,6 +2,7 @@
# For license information, please see license.txt
import json
import frappe
from frappe import _
from frappe.model.document import Document
@ -10,46 +11,54 @@ from frappe.model.document import Document
class CRMFieldsLayout(Document):
pass
@frappe.whitelist()
def get_fields_layout(doctype: str, type: str):
sections = []
tabs = []
if frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": type}):
layout = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": type})
else:
return []
if layout.layout:
sections = json.loads(layout.layout)
tabs = json.loads(layout.layout)
has_tabs = tabs[0].get("sections") if tabs and tabs[0] else False
if not has_tabs:
tabs = [{"no_tabs": True, "sections": tabs}]
allowed_fields = []
for section in sections:
if not section.get("fields"):
continue
allowed_fields.extend(section.get("fields"))
for tab in tabs:
for section in tab.get("sections"):
if not section.get("fields"):
continue
allowed_fields.extend(section.get("fields"))
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldname in allowed_fields]
for section in 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:
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": ""})
field = {
"label": _(field.label),
"name": field.fieldname,
"type": field.fieldtype,
"options": field.options,
"mandatory": field.reqd,
"placeholder": field.get("placeholder"),
"filters": field.get("link_filters")
}
section["fields"][section.get("fields").index(field["name"])] = field
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:
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": ""})
field = {
"label": _(field.label),
"name": field.fieldname,
"type": field.fieldtype,
"options": field.options,
"mandatory": field.reqd,
"placeholder": field.get("placeholder"),
"filters": field.get("link_filters"),
}
section["fields"][section.get("fields").index(field["name"])] = field
return sections or []
return tabs or []
@frappe.whitelist()
@ -59,11 +68,13 @@ def save_fields_layout(doctype: str, type: str, layout: str):
else:
doc = frappe.new_doc("CRM Fields Layout")
doc.update({
"dt": doctype,
"type": type,
"layout": layout,
})
doc.update(
{
"dt": doctype,
"type": type,
"layout": layout,
}
)
doc.save(ignore_permissions=True)
return doc.layout

View File

@ -1,22 +1,14 @@
import frappe
from frappe import _
from crm.api.doc import get_fields_meta, get_assigned_users
from crm.api.doc import get_assigned_users, get_fields_meta
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_lead(name):
Lead = frappe.qb.DocType("CRM Lead")
lead = frappe.get_doc("CRM Lead", name).as_dict()
query = frappe.qb.from_(Lead).select("*").where(Lead.name == name).limit(1)
lead = query.run(as_dict=True)
if not len(lead):
frappe.throw(_("Lead not found"), frappe.DoesNotExistError)
lead = lead.pop()
lead["doctype"] = "CRM Lead"
lead["fields_meta"] = get_fields_meta("CRM Lead")
lead["_form_script"] = get_form_script('CRM Lead')
lead["_form_script"] = get_form_script("CRM Lead")
lead["_assign"] = get_assigned_users("CRM Lead", lead.name, lead.owner)
return lead

View File

@ -14,7 +14,7 @@
"@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.90",
"frappe-ui": "^0.1.91",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -364,6 +364,9 @@
</div>
</div>
</div>
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
<DataFields :doctype="doctype" :docname="doc.data.name" />
</div>
<div
v-else
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-4"
@ -454,9 +457,11 @@ import CallArea from '@/components/Activities/CallArea.vue'
import NoteArea from '@/components/Activities/NoteArea.vue'
import TaskArea from '@/components/Activities/TaskArea.vue'
import AttachmentArea from '@/components/Activities/AttachmentArea.vue'
import DataFields from '@/components/Activities/DataFields.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
@ -719,6 +724,8 @@ const emptyText = computed(() => {
text = 'No Email Communications'
} else if (title.value == 'Comments') {
text = 'No Comments'
} else if (title.value == 'Data') {
text = 'No Data'
} else if (title.value == 'Calls') {
text = 'No Call Logs'
} else if (title.value == 'Notes') {
@ -739,6 +746,8 @@ const emptyTextIcon = computed(() => {
icon = Email2Icon
} else if (title.value == 'Comments') {
icon = CommentIcon
} else if (title.value == 'Data') {
icon = DetailsIcon
} else if (title.value == 'Calls') {
icon = PhoneIcon
} else if (title.value == 'Notes') {

View File

@ -1,5 +1,6 @@
<template>
<div
v-if="title !== 'Data'"
class="mx-4 my-3 flex items-center justify-between text-lg font-medium sm:mx-10 sm:mb-4 sm:mt-8"
>
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">

View File

@ -0,0 +1,108 @@
<template>
<div
class="my-3 flex items-center justify-between text-lg font-medium sm:mb-4 sm:mt-8"
>
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
{{ __('Data') }}
<Badge
v-if="data.isDirty"
class="ml-3"
:label="'Not Saved'"
theme="orange"
/>
</div>
<div class="flex gap-1">
<Button v-if="isManager()" @click="showDataFieldsModal = true">
<EditIcon class="h-4 w-4" />
</Button>
<Button
label="Save"
:disabled="!data.isDirty"
variant="solid"
:loading="data.save.loading"
@click="saveChanges"
/>
</div>
</div>
<div
v-if="data.get.loading"
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
>
<LoadingIndicator class="h-6 w-6" />
<span>{{ __('Loading...') }}</span>
</div>
<div v-else>
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="data.doc" />
</div>
<DataFieldsModal
v-if="showDataFieldsModal"
v-model="showDataFieldsModal"
:doctype="doctype"
@reload="
() => {
tabs.reload()
data.reload()
}
"
/>
</template>
<script setup>
import EditIcon from '@/components/Icons/EditIcon.vue'
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
import FieldLayout from '@/components/FieldLayout.vue'
import { Badge, createResource, createDocumentResource } from 'frappe-ui'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import { createToast } from '@/utils'
import { usersStore } from '@/stores/users'
import { ref } from 'vue'
const props = defineProps({
doctype: {
type: String,
required: true,
},
docname: {
type: String,
required: true,
},
})
const { isManager } = usersStore()
const showDataFieldsModal = ref(false)
const data = createDocumentResource({
doctype: props.doctype,
name: props.docname,
setValue: {
onSuccess: () => {
data.reload()
createToast({
title: 'Data Updated',
icon: 'check',
iconClasses: 'text-green-600',
})
},
onError: (err) => {
createToast({
title: 'Error',
text: err.messages[0],
icon: 'x',
iconClasses: 'text-red-600',
})
},
},
})
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['DataFields', props.doctype],
params: { doctype: props.doctype, type: 'Data Fields' },
auto: true,
})
function saveChanges() {
data.save.submit()
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="space-y-1.5">
<div class="space-y-1.5 p-[2px] -m-[2px]">
<label class="block" :class="labelClasses" v-if="attrs.label">
{{ __(attrs.label) }}
</label>
@ -34,7 +34,7 @@
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(value, close)"
@click="() => attrs.onCreate(value, close)"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />

View File

@ -0,0 +1,271 @@
<template>
<div
class="flex flex-col"
:class="{
'border border-outline-gray-1 rounded-lg': hasTabs,
'border-outline-gray-modals': modal && hasTabs,
}"
>
<Tabs
v-model="tabIndex"
class="!h-full"
:tabs="tabs"
v-slot="{ tab }"
:tablistClass="
!hasTabs ? 'hidden' : modal ? 'border-outline-gray-modals' : ''
"
>
<div :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="grid gap-4"
:class="[
gridClass(section.columns),
{ 'px-3 sm:px-5': hasTabs },
{ 'mt-6': !section.hideLabel },
]"
>
<div v-for="field in section.fields" :key="field.name">
<div
class="settings-field"
v-if="
(field.type == 'Check' ||
(field.read_only && data[field.name]) ||
!field.read_only ||
!field.hidden) &&
(!field.depends_on || field.display_via_depends_on)
"
>
<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"
/>
<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="data[field.name] = !data[field.name]"
>
{{ __(field.label) }}
<span class="text-ink-red-3" v-if="field.mandatory"
>*</span
>
</label>
</div>
<div class="flex gap-1" v-else-if="field.type === 'Link'">
<Link
class="form-control flex-1 truncate"
:value="data[field.name]"
:doctype="field.options"
:filters="field.filters"
@change="(v) => (data[field.name] = 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])"
>
<template #prefix>
<EditIcon class="h-4 w-4" />
</template>
</Button>
</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'].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
type="text"
:placeholder="getPlaceholder(field)"
v-model="data[field.name]"
:disabled="Boolean(field.read_only)"
/>
</div>
</div>
</div>
</Section>
</div>
</div>
</Tabs>
</div>
</template>
<script setup>
import Section from '@/components/Section.vue'
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 { usersStore } from '@/stores/users'
import { getFormat } from '@/utils'
import { Tabs, Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
import { ref, computed } from 'vue'
const { getUser } = usersStore()
const props = defineProps({
tabs: Array,
data: Object,
modal: {
type: Boolean,
default: false,
},
})
const hasTabs = computed(() => !props.tabs[0].no_tabs)
const tabIndex = ref(0)
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]
}
const getPlaceholder = (field) => {
if (field.placeholder) {
return __(field.placeholder)
}
if (['Select', 'Link'].includes(field.type)) {
return __('Select {0}', [__(field.label)])
} else {
return __('Enter {0}', [__(field.label)])
}
}
</script>
<style scoped>
:deep(.form-control.prefix select) {
padding-left: 2rem;
}
.section {
display: none;
}
.section:has(.settings-field) {
display: block;
}
</style>

View File

@ -0,0 +1,366 @@
<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"
>
<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>
</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>
</template>
</Dropdown>
</div>
<div v-show="tabIndex == i" v-for="(tab, i) in tabs" :key="tab.label">
<Draggable
:list="tab.sections"
item-key="label"
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">
<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"
@dblclick="() => (section.editingLabel = true)"
>
<div
v-if="!section.editingLabel"
class="flex items-center gap-2"
:class="{ 'text-ink-gray-3': section.hideLabel }"
>
{{ __(section.label) || __('Untitled') }}
<FeatherIcon
v-if="section.collapsible"
name="chevron-down"
class="h-4 transition-all duration-300 ease-in-out"
/>
</div>
<div v-else class="flex gap-2 items-center">
<Input
v-model="section.label"
@keydown.enter="section.editingLabel = false"
@blur="section.editingLabel = false"
@click.stop
/>
<Button
v-if="section.editingLabel"
icon="check"
variant="ghost"
@click="section.editingLabel = false"
/>
</div>
</div>
<Dropdown :options="getSectionOptions(section)">
<template #default>
<Button variant="ghost">
<FeatherIcon name="more-horizontal" class="h-4" />
</Button>
</template>
</Dropdown>
</div>
<Draggable
:list="section.fields"
group="fields"
item-key="label"
class="grid gap-1.5"
:class="gridClass(section.columns)"
handle=".cursor-grab"
>
<template #item="{ element: field }">
<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">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
</div>
<Button
variant="ghost"
class="!size-4 rounded-sm"
icon="x"
@click="
section.fields.splice(section.fields.indexOf(field), 1)
"
/>
</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>
<div class="mt-5.5">
<Button
class="w-full h-8"
variant="subtle"
:label="__('Add Section')"
@click="
tabs[tabIndex].sections.push({
label: __('New Section'),
opened: true,
fields: [],
})
"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</div>
</div>
</template>
<script setup>
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
import Draggable from 'vuedraggable'
import { Dropdown, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
const props = defineProps({
tabs: Object,
doctype: String,
})
const tabIndex = ref(0)
const restrictedFieldTypes = [
'Table',
'Geolocation',
'Attach',
'Attach Image',
'HTML',
'Signature',
]
const params = computed(() => {
return {
doctype: props.doctype,
restricted_fieldtypes: restrictedFieldTypes,
as_array: true,
}
})
const fields = createResource({
url: 'crm.api.doc.get_fields_meta',
params: params.value,
cache: ['fieldsMeta', props.doctype],
auto: true,
})
function addTab() {
if (props.tabs.length == 1 && props.tabs[0].no_tabs) {
delete props.tabs[0].no_tabs
return
}
props.tabs.push({ label: __('New Tab'), sections: [] })
tabIndex.value = props.tabs.length ? props.tabs.length - 1 : 0
}
function addField(section, field) {
if (!field) return
section.fields.push(field)
}
function getTabOptions(tab) {
return [
{
label: 'Edit',
icon: 'edit',
onClick: () => (tab.editingLabel = true),
},
{
label: 'Remove tab',
icon: 'trash-2',
onClick: () => {
if (props.tabs.length == 1) {
props.tabs[0].no_tabs = true
return
}
props.tabs.splice(tabIndex.value, 1)
tabIndex.value = tabIndex.value ? tabIndex.value - 1 : 0
},
},
]
}
function getSectionOptions(section) {
return [
{
label: 'Edit',
icon: 'edit',
onClick: () => (section.editingLabel = true),
condition: () => section.editable !== false,
},
{
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],
},
]
}
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),
{ immediate: true },
)
</script>

View File

@ -1,217 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<div
v-for="section in sections"
:key="section.label"
class="section first:border-t-0 border-outline-gray-modals first:pt-0"
:class="section.hideBorder ? '' : 'border-t pt-4'"
>
<div
v-if="!section.hideLabel"
class="flex h-7 mb-3 max-w-fit cursor-pointer items-center gap-2 text-base font-semibold leading-5"
>
{{ section.label }}
</div>
<div
class="grid gap-4"
:class="
section.columns
? 'grid-cols-' + section.columns
: 'grid-cols-2 sm:grid-cols-3'
"
>
<div v-for="field in section.fields" :key="field.name">
<div
class="settings-field"
v-if="
(field.type == 'Check' ||
(field.read_only && data[field.name]) ||
!field.read_only ||
!field.hidden) &&
(!field.depends_on || field.display_via_depends_on)
"
>
<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"
/>
<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="data[field.name] = !data[field.name]"
>
{{ __(field.label) }}
<span class="text-ink-red-3" v-if="field.mandatory">*</span>
</label>
</div>
<div class="flex gap-1" v-else-if="field.type === 'Link'">
<Link
class="form-control flex-1"
:value="data[field.name]"
:doctype="field.options"
:filters="field.filters"
@change="(v) => (data[field.name] = 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])"
>
<template #prefix>
<EditIcon class="h-4 w-4" />
</template>
</Button>
</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'].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
type="text"
:placeholder="getPlaceholder(field)"
v-model="data[field.name]"
:disabled="Boolean(field.read_only)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
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 { usersStore } from '@/stores/users'
import { getFormat } from '@/utils'
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
const { getUser } = usersStore()
const props = defineProps({
sections: Array,
data: Object,
})
const getPlaceholder = (field) => {
if (field.placeholder) {
return __(field.placeholder)
}
if (['Select', 'Link'].includes(field.type)) {
return __('Select {0}', [__(field.label)])
} else {
return __('Enter {0}', [__(field.label)])
}
}
</script>
<style scoped>
:deep(.form-control.prefix select) {
padding-left: 2rem;
}
.section {
display: none;
}
.section:has(.settings-field) {
display: block;
}
</style>

View File

@ -37,7 +37,7 @@
<Section
:label="view.name"
:hideLabel="view.hideLabel"
:isOpened="view.opened"
:opened="view.opened"
>
<template #header="{ opened, hide, toggle }">
<div

View File

@ -38,7 +38,7 @@
<Section
:label="view.name"
:hideLabel="view.hideLabel"
:isOpened="view.opened"
:opened="view.opened"
>
<template #header="{ opened, hide, toggle }">
<div

View File

@ -22,8 +22,8 @@
</Button>
</div>
</div>
<div v-if="sections.data">
<Fields :sections="sections.data" :data="_address" />
<div v-if="tabs.data">
<FieldLayout :tabs="tabs.data" :data="_address" />
<ErrorMessage class="mt-2" :message="error" />
</div>
</div>
@ -50,7 +50,7 @@
<script setup>
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import Fields from '@/components/Fields.vue'
import FieldLayout from '@/components/FieldLayout.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import { usersStore } from '@/stores/users'
import { capture } from '@/telemetry'
@ -106,9 +106,9 @@ const dialogOptions = computed(() => {
return { title, size, actions }
})
const sections = createResource({
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['quickEntryFields', 'Address'],
cache: ['QuickEntry', 'Address'],
params: { doctype: 'Address', type: 'Quick Entry' },
auto: true,
})

View File

@ -10,10 +10,10 @@
</div>
<div class="flex items-center gap-1">
<Button
v-if="isManager() || detailMode"
v-if="isManager()"
variant="ghost"
class="w-7"
@click="detailMode ? (detailMode = false) : openQuickEntryModal()"
@click="openQuickEntryModal"
>
<EditIcon class="h-4 w-4" />
</Button>
@ -22,77 +22,36 @@
</Button>
</div>
</div>
<div>
<div v-if="detailMode" class="flex flex-col gap-3.5">
<div
v-for="field in detailFields"
:key="field.name"
class="flex h-7 items-center gap-2 text-base text-ink-gray-8"
>
<div class="grid w-7 place-content-center">
<component :is="field.icon" />
</div>
<div v-if="field.type == 'dropdown'">
<Dropdown
:options="field.options"
class="form-control -ml-2 mr-2 w-full flex-1"
>
<template #default="{ open }">
<Button
variant="ghost"
:label="contact.data[field.name]"
class="dropdown-button w-full justify-between truncate hover:bg-surface-white"
>
<div class="truncate">{{ contact.data[field.name] }}</div>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-ink-gray-5"
/>
</template>
</Button>
</template>
</Dropdown>
</div>
<div v-else>{{ field.value }}</div>
</div>
</div>
<Fields
v-else-if="filteredSections"
:sections="filteredSections"
:data="_contact"
/>
<div v-if="filteredSections.length">
<FieldLayout :tabs="filteredSections" :data="_contact" />
</div>
</div>
<div v-if="!detailMode" class="px-4 pb-7 pt-4 sm:px-6">
<div class="px-4 pb-7 pt-4 sm:px-6">
<div class="space-y-2">
<Button
class="w-full"
v-for="action in dialogOptions.actions"
:key="action.label"
v-bind="action"
>
{{ __(action.label) }}
</Button>
:label="__(action.label)"
/>
</div>
</div>
</template>
</Dialog>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
doctype="Contact"
/>
</template>
<script setup>
import Fields from '@/components/Fields.vue'
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import FieldLayout from '@/components/FieldLayout.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'
import ContactIcon from '@/components/Icons/ContactIcon.vue'
import GenderIcon from '@/components/Icons/GenderIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import AddressIcon from '@/components/Icons/AddressIcon.vue'
import CertificateIcon from '@/components/Icons/CertificateIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import { usersStore } from '@/stores/users'
import { capture } from '@/telemetry'
import { call, createResource } from 'frappe-ui'
@ -108,7 +67,6 @@ const props = defineProps({
type: Object,
default: {
redirect: true,
detailMode: false,
afterInsert: () => {},
},
},
@ -119,7 +77,6 @@ const { isManager } = usersStore()
const router = useRouter()
const show = defineModel()
const detailMode = ref(false)
const editMode = ref(false)
let _contact = ref({})
let _address = ref({})
@ -186,74 +143,28 @@ function handleContactUpdate(doc) {
const dialogOptions = computed(() => {
let title = !editMode.value ? 'New Contact' : _contact.value.full_name
let size = detailMode.value ? '' : 'xl'
let actions = detailMode.value
? []
: [
{
label: editMode.value ? 'Save' : 'Create',
variant: 'solid',
disabled: !dirty.value,
onClick: () => (editMode.value ? updateContact() : callInsertDoc()),
},
]
let size = 'xl'
let actions = [
{
label: editMode.value ? 'Save' : 'Create',
variant: 'solid',
disabled: !dirty.value,
onClick: () => (editMode.value ? updateContact() : callInsertDoc()),
},
]
return { title, size, actions }
})
const detailFields = computed(() => {
let details = [
{
icon: ContactIcon,
name: 'full_name',
value:
(_contact.value.salutation ? _contact.value.salutation + '. ' : '') +
_contact.value.full_name,
},
{
icon: GenderIcon,
name: 'gender',
value: _contact.value.gender,
},
{
icon: Email2Icon,
name: 'email_id',
value: _contact.value.email_id,
},
{
icon: PhoneIcon,
name: 'mobile_no',
value: _contact.value.actual_mobile_no,
},
{
icon: OrganizationsIcon,
name: 'company_name',
value: _contact.value.company_name,
},
{
icon: CertificateIcon,
name: 'designation',
value: _contact.value.designation,
},
{
icon: AddressIcon,
name: 'address',
value: _contact.value.address,
},
]
return details.filter((detail) => detail.value)
})
const sections = createResource({
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['quickEntryFields', 'Contact'],
cache: ['QuickEntry', 'Contact'],
params: { doctype: 'Contact', type: 'Quick Entry' },
auto: true,
})
const filteredSections = computed(() => {
let allSections = sections.data || []
let allSections = tabs.data?.[0]?.sections || []
if (!allSections.length) return []
allSections.forEach((s) => {
@ -276,7 +187,7 @@ const filteredSections = computed(() => {
})
})
return allSections
return [{ no_tabs: true, sections: allSections }]
})
const dirty = computed(() => {
@ -287,7 +198,6 @@ watch(
() => show.value,
(value) => {
if (!value) return
detailMode.value = props.options.detailMode
editMode.value = false
nextTick(() => {
_contact.value = { ...props.contact.data }
@ -298,13 +208,11 @@ watch(
},
)
const showQuickEntryModal = defineModel('quickEntry')
const showQuickEntryModal = ref(false)
function openQuickEntryModal() {
showQuickEntryModal.value = true
nextTick(() => {
show.value = false
})
nextTick(() => (show.value = false))
}
</script>

View File

@ -0,0 +1,135 @@
<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 Data 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">
<FormControl
type="select"
class="w-1/4"
v-model="_doctype"
:options="['CRM Lead', 'CRM Deal']"
@change="reload"
/>
<Switch
v-model="preview"
:label="preview ? __('Hide preview') : __('Show preview')"
size="sm"
/>
</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>
<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>
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 { 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: 'Data Fields' }
}
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['DataFieldsModal', _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: 'Data Fields',
layout: JSON.stringify(_tabs),
},
).then(() => {
loading.value = false
show.value = false
capture('data_fields_layout_builder', { doctype: _doctype.value })
emit('reload')
})
}
</script>

View File

@ -33,10 +33,10 @@
<Switch v-model="chooseExistingContact" />
</div>
</div>
<Fields
v-if="filteredSections"
class="border-t pt-4"
:sections="filteredSections"
<div class="h-px w-full border-t my-5" />
<FieldLayout
v-if="filteredSections.length"
:tabs="filteredSections"
:data="deal"
/>
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
@ -58,7 +58,7 @@
<script setup>
import EditIcon from '@/components/Icons/EditIcon.vue'
import Fields from '@/components/Fields.vue'
import FieldLayout from '@/components/FieldLayout.vue'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { capture } from '@/telemetry'
@ -100,28 +100,30 @@ const isDealCreating = ref(false)
const chooseExistingContact = ref(false)
const chooseExistingOrganization = ref(false)
const sections = createResource({
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['quickEntryFields', 'CRM Deal'],
cache: ['QuickEntry', 'CRM Deal'],
params: { doctype: 'CRM Deal', type: 'Quick Entry' },
auto: true,
transform: (data) => {
return data.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'
}
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'
}
})
})
})
},
})
const filteredSections = computed(() => {
let allSections = sections.data || []
let allSections = tabs.data?.[0]?.sections || []
if (!allSections.length) return []
let _filteredSections = []
@ -159,7 +161,7 @@ const filteredSections = computed(() => {
}
})
return _filteredSections
return [{ no_tabs: true, sections: _filteredSections }]
})
const dealStatuses = computed(() => {

View File

@ -23,7 +23,7 @@
</div>
</div>
<div>
<Fields v-if="sections.data" :sections="sections.data" :data="lead" />
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="lead" />
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
</div>
</div>
@ -43,7 +43,7 @@
<script setup>
import EditIcon from '@/components/Icons/EditIcon.vue'
import Fields from '@/components/Fields.vue'
import FieldLayout from '@/components/FieldLayout.vue'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { capture } from '@/telemetry'
@ -63,21 +63,23 @@ const router = useRouter()
const error = ref(null)
const isLeadCreating = ref(false)
const sections = createResource({
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['quickEntryFields', 'CRM Lead'],
cache: ['QuickEntry', 'CRM Lead'],
params: { doctype: 'CRM Lead', type: 'Quick Entry' },
auto: true,
transform: (data) => {
return data.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'
}
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'
}
})
})
})
},

View File

@ -10,10 +10,10 @@
</div>
<div class="flex items-center gap-1">
<Button
v-if="isManager() || detailMode"
v-if="isManager()"
variant="ghost"
class="w-7"
@click="detailMode ? (detailMode = false) : openQuickEntryModal()"
@click="openQuickEntryModal"
>
<EditIcon class="h-4 w-4" />
</Button>
@ -22,27 +22,11 @@
</Button>
</div>
</div>
<div>
<div v-if="detailMode" class="flex flex-col gap-3.5">
<div
class="flex h-7 items-center gap-2 text-base text-ink-gray-8"
v-for="field in fields"
:key="field.name"
>
<div class="grid w-7 place-content-center">
<component :is="field.icon" />
</div>
<div>{{ field.value }}</div>
</div>
</div>
<Fields
v-else-if="filteredSections"
:sections="filteredSections"
:data="_organization"
/>
<div v-if="filteredSections.length">
<FieldLayout :tabs="filteredSections" :data="_organization" />
</div>
</div>
<div v-if="!detailMode" class="px-4 pb-7 pt-4 sm:px-6">
<div class="px-4 pb-7 pt-4 sm:px-6">
<div class="space-y-2">
<Button
class="w-full"
@ -57,21 +41,22 @@
</template>
</Dialog>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
doctype="CRM Organization"
/>
</template>
<script setup>
import Fields from '@/components/Fields.vue'
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import FieldLayout from '@/components/FieldLayout.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import MoneyIcon from '@/components/Icons/MoneyIcon.vue'
import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import TerritoryIcon from '@/components/Icons/TerritoryIcon.vue'
import { usersStore } from '@/stores/users'
import { formatNumberIntoCurrency } from '@/utils'
import { capture } from '@/telemetry'
import { call, FeatherIcon, createResource } from 'frappe-ui'
import { ref, nextTick, watch, computed, h } from 'vue'
import { ref, nextTick, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
@ -79,7 +64,6 @@ const props = defineProps({
type: Object,
default: {
redirect: true,
detailMode: false,
afterInsert: () => {},
},
},
@ -93,7 +77,6 @@ const organization = defineModel('organization')
const loading = ref(false)
const title = ref(null)
const detailMode = ref(false)
const editMode = ref(false)
let _address = ref({})
let _organization = ref({
@ -186,70 +169,27 @@ const dialogOptions = computed(() => {
let title = !editMode.value
? __('New Organization')
: __(_organization.value.organization_name)
let size = detailMode.value ? '' : 'xl'
let actions = detailMode.value
? []
: [
{
label: editMode.value ? __('Save') : __('Create'),
variant: 'solid',
onClick: () =>
editMode.value ? updateOrganization() : callInsertDoc(),
},
]
let size = 'xl'
let actions = [
{
label: editMode.value ? __('Save') : __('Create'),
variant: 'solid',
onClick: () => (editMode.value ? updateOrganization() : callInsertDoc()),
},
]
return { title, size, actions }
})
const fields = computed(() => {
let details = [
{
icon: OrganizationsIcon,
name: 'organization_name',
value: _organization.value.organization_name,
},
{
icon: WebsiteIcon,
name: 'website',
value: _organization.value.website,
},
{
icon: TerritoryIcon,
name: 'territory',
value: _organization.value.territory,
},
{
icon: MoneyIcon,
name: 'annual_revenue',
value: formatNumberIntoCurrency(
_organization.value.annual_revenue,
_organization.value.currency,
),
},
{
icon: h(FeatherIcon, { name: 'hash', class: 'h-4 w-4' }),
name: 'no_of_employees',
value: _organization.value.no_of_employees,
},
{
icon: h(FeatherIcon, { name: 'briefcase', class: 'h-4 w-4' }),
name: 'industry',
value: _organization.value.industry,
},
]
return details.filter((field) => field.value)
})
const sections = createResource({
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['quickEntryFields', 'CRM Organization'],
cache: ['QuickEntry', 'CRM Organization'],
params: { doctype: 'CRM Organization', type: 'Quick Entry' },
auto: true,
})
const filteredSections = computed(() => {
let allSections = sections.data || []
let allSections = tabs.data?.[0]?.sections || []
if (!allSections.length) return []
allSections.forEach((s) => {
@ -272,7 +212,7 @@ const filteredSections = computed(() => {
})
})
return allSections
return [{ no_tabs: true, sections: allSections }]
})
watch(
@ -280,7 +220,6 @@ watch(
(value) => {
if (!value) return
editMode.value = false
detailMode.value = props.options.detailMode
nextTick(() => {
// TODO: Issue with FormControl
// title.value.el.focus()
@ -293,12 +232,10 @@ watch(
},
)
const showQuickEntryModal = defineModel('quickEntry')
const showQuickEntryModal = ref(false)
function openQuickEntryModal() {
showQuickEntryModal.value = true
nextTick(() => {
show.value = false
})
nextTick(() => (show.value = false))
}
</script>

View File

@ -35,13 +35,13 @@
size="sm"
/>
</div>
<div v-if="sections?.data">
<QuickEntryLayoutBuilder
<div v-if="tabs?.data">
<FieldLayoutEditor
v-if="!preview"
:sections="sections.data"
:tabs="tabs.data"
:doctype="_doctype"
/>
<Fields v-else :sections="sections.data" :data="{}" />
<FieldLayout v-else :tabs="tabs.data" :data="{}" />
</div>
</div>
</template>
@ -59,8 +59,8 @@
</Dialog>
</template>
<script setup>
import Fields from '@/components/Fields.vue'
import QuickEntryLayoutBuilder from '@/components/QuickEntryLayoutBuilder.vue'
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'
@ -83,20 +83,20 @@ function getParams() {
return { doctype: _doctype.value, type: 'Quick Entry' }
}
const sections = createResource({
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['quick-entry-sections', _doctype.value],
cache: ['QuickEntryModal', _doctype.value],
params: getParams(),
onSuccess(data) {
sections.originalData = JSON.parse(JSON.stringify(data))
tabs.originalData = JSON.parse(JSON.stringify(data))
},
})
watch(
() => sections?.data,
() => tabs?.data,
() => {
dirty.value =
JSON.stringify(sections?.data) !== JSON.stringify(sections?.originalData)
JSON.stringify(tabs?.data) !== JSON.stringify(tabs?.originalData)
},
{ deep: true },
)
@ -105,18 +105,21 @@ onMounted(() => useDebounceFn(reload, 100)())
function reload() {
nextTick(() => {
sections.params = getParams()
sections.reload()
tabs.params = getParams()
tabs.reload()
})
}
function saveChanges() {
let _sections = JSON.parse(JSON.stringify(sections.data))
_sections.forEach((section) => {
if (!section.fields) return
section.fields = section.fields.map(
(field) => field.fieldname || field.name,
)
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(
@ -124,7 +127,7 @@ function saveChanges() {
{
doctype: _doctype.value,
type: 'Quick Entry',
layout: JSON.stringify(_sections),
layout: JSON.stringify(_tabs),
},
).then(() => {
loading.value = false

View File

@ -29,21 +29,27 @@
size="sm"
/>
</div>
<div v-if="sections.data" class="flex gap-4">
<SidePanelLayoutBuilder
<div v-if="tabs.data?.[0]?.sections" class="flex gap-4">
<SidePanelLayoutEditor
class="flex flex-1 flex-col pr-2"
:sections="sections.data"
:sections="tabs.data[0].sections"
:doctype="_doctype"
/>
<div v-if="preview" class="flex flex-1 flex-col border rounded">
<div
v-for="(section, i) in sections.data"
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 !== sections.data?.length - 1 }"
:class="{
'border-b': i !== tabs.data[0].sections?.length - 1,
}"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
<Section
class="p-2"
:label="section.label"
:opened="section.opened"
>
<SidePanelLayout
:fields="section.fields"
:isLastSection="i == section.data?.length - 1"
v-model="data"
@ -75,8 +81,8 @@
</template>
<script setup>
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SidePanelLayoutBuilder from '@/components/Settings/SidePanelLayoutBuilder.vue'
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'
@ -102,20 +108,20 @@ function getParams() {
return { doctype: _doctype.value, type: 'Side Panel' }
}
const sections = createResource({
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['sidebar-sections', _doctype.value],
cache: ['SidePanel', _doctype.value],
params: getParams(),
onSuccess(data) {
sections.originalData = JSON.parse(JSON.stringify(data))
tabs.originalData = JSON.parse(JSON.stringify(data))
},
})
watch(
() => sections?.data,
() => tabs?.data,
() => {
dirty.value =
JSON.stringify(sections?.data) !== JSON.stringify(sections?.originalData)
JSON.stringify(tabs?.data) !== JSON.stringify(tabs?.originalData)
},
{ deep: true },
)
@ -124,18 +130,20 @@ onMounted(() => useDebounceFn(reload, 100)())
function reload() {
nextTick(() => {
sections.params = getParams()
sections.reload()
tabs.params = getParams()
tabs.reload()
})
}
function saveChanges() {
let _sections = JSON.parse(JSON.stringify(sections.data))
_sections.forEach((section) => {
if (!section.fields) return
section.fields = section.fields
.map((field) => field.fieldname || field.name)
.filter(Boolean)
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)
})
})
loading.value = true
call(
@ -143,7 +151,7 @@ function saveChanges() {
{
doctype: _doctype.value,
type: 'Side Panel',
layout: JSON.stringify(_sections),
layout: JSON.stringify(_tabs),
},
).then(() => {
loading.value = false

View File

@ -1,207 +0,0 @@
<template>
<div>
<Draggable :list="sections" item-key="label" 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">
<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"
>
<div
v-if="!section.editingLabel"
:class="{ 'text-ink-gray-3': section.hideLabel }"
>
{{ __(section.label) || __('Untitled') }}
</div>
<div v-else class="flex gap-2 items-center">
<Input
v-model="section.label"
@keydown.enter="section.editingLabel = false"
@blur="section.editingLabel = false"
@click.stop
/>
<Button
v-if="section.editingLabel"
icon="check"
variant="ghost"
@click="section.editingLabel = false"
/>
</div>
</div>
<Dropdown :options="getOptions(section)">
<template #default>
<Button variant="ghost">
<FeatherIcon name="more-horizontal" class="h-4" />
</Button>
</template>
</Dropdown>
</div>
<Draggable
:list="section.fields"
group="fields"
item-key="label"
class="grid gap-1.5"
:class="
section.columns ? 'grid-cols-' + section.columns : 'grid-cols-3'
"
handle=".cursor-grab"
>
<template #item="{ element: field }">
<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">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
</div>
<Button
variant="ghost"
class="!size-4 rounded-sm"
icon="x"
@click="
section.fields.splice(section.fields.indexOf(field), 1)
"
/>
</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>
<div class="mt-5.5">
<Button
class="w-full h-8"
variant="subtle"
:label="__('Add Section')"
@click="
sections.push({
label: __('New Section'),
opened: true,
fields: [],
})
"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</div>
</template>
<script setup>
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
import Draggable from 'vuedraggable'
import { Dropdown, createResource } from 'frappe-ui'
import { computed, watch } from 'vue'
const props = defineProps({
sections: Object,
doctype: String,
})
const restrictedFieldTypes = [
'Table',
'Geolocation',
'Attach',
'Attach Image',
'HTML',
'Signature',
]
const params = computed(() => {
return {
doctype: props.doctype,
restricted_fieldtypes: restrictedFieldTypes,
as_array: true,
}
})
const fields = createResource({
url: 'crm.api.doc.get_fields_meta',
params: params.value,
cache: ['fieldsMeta', props.doctype],
auto: true,
})
function addField(section, field) {
if (!field) return
section.fields.push(field)
}
function getOptions(section) {
return [
{
label: 'Edit',
icon: 'edit',
onClick: () => (section.editingLabel = true),
condition: () => section.editable !== false,
},
{
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: () => props.sections.splice(props.sections.indexOf(section), 1),
condition: () => section.editable !== false,
},
]
}
watch(
() => props.doctype,
() => fields.fetch(params.value),
{ immediate: true },
)
</script>

View File

@ -2,15 +2,25 @@
<slot name="header" v-bind="{ opened, hide, open, close, toggle }">
<div v-if="!hide" class="flex items-center justify-between">
<div
class="flex h-7 text-ink-gray-9 max-w-fit cursor-pointer items-center gap-2 pl-2 pr-3 text-base font-semibold leading-5"
@click="toggle()"
class="flex text-ink-gray-9 max-w-fit cursor-pointer items-center gap-2 text-base"
v-bind="$attrs"
@click="collapsible && toggle()"
>
<FeatherIcon
v-if="collapsible && collapseIconPosition === 'left'"
name="chevron-right"
class="h-4 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
<span>
{{ __(label) || __('Untitled') }}
</span>
<FeatherIcon
v-if="collapsible && collapseIconPosition === 'right'"
name="chevron-right"
class="h-4 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
/>
{{ __(label) || __('Untitled') }}
</div>
<slot name="actions"></slot>
</div>
@ -23,13 +33,14 @@
enter-from-class="max-h-0 overflow-hidden"
leave-to-class="max-h-0 overflow-hidden"
>
<div v-if="opened">
<div v-show="opened">
<slot v-bind="{ opened, open, close, toggle }" />
</div>
</transition>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
label: {
type: String,
@ -39,11 +50,23 @@ const props = defineProps({
type: Boolean,
default: false,
},
isOpened: {
opened: {
type: Boolean,
default: true,
},
collapsible: {
type: Boolean,
default: true,
},
collapseIconPosition: {
type: String,
default: 'left',
},
})
const hide = ref(props.hideLabel)
const opened = ref(props.opened)
function toggle() {
opened.value = !opened.value
}
@ -55,7 +78,4 @@ function open() {
function close() {
opened.value = false
}
let opened = ref(props.isOpened)
let hide = ref(props.hideLabel)
</script>

View File

@ -1,6 +1,8 @@
<template>
<div class="flex h-full flex-col gap-8">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-9">
<h2
class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-9"
>
<div>{{ title || __(doctype) }}</div>
<Badge
v-if="data.isDirty"
@ -10,11 +12,7 @@
/>
</h2>
<div v-if="!data.get.loading" class="flex-1 overflow-y-auto">
<Fields
v-if="data?.doc && sections"
:sections="sections"
:data="data.doc"
/>
<FieldLayout v-if="data?.doc && tabs" :tabs="tabs" :data="data.doc" />
<ErrorMessage class="mt-2" :message="error" />
</div>
<div v-else class="flex flex-1 items-center justify-center">
@ -31,7 +29,7 @@
</div>
</template>
<script setup>
import Fields from '@/components/Fields.vue'
import FieldLayout from '@/components/FieldLayout.vue'
import {
createDocumentResource,
createResource,
@ -96,7 +94,7 @@ const data = createDocumentResource({
},
})
const sections = computed(() => {
const tabs = computed(() => {
if (!fields.data) return []
let _sections = []
let fieldsData = fields.data
@ -136,7 +134,7 @@ const sections = computed(() => {
}
})
return _sections
return [{ no_tabs: true, sections: _sections }]
})
function update() {
@ -146,7 +144,8 @@ function update() {
}
function validateMandatoryFields() {
for (let section of sections.value) {
if (!tabs.value) return false
for (let section of tabs.value[0].sections) {
for (let field of section.fields) {
if (
(field.mandatory ||

View File

@ -18,15 +18,12 @@
:class="inputClasses"
@click="() => togglePopover()"
>
<div class="flex items-center">
<div class="flex text-base leading-5 items-center truncate">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
v-if="selectedValue"
>
<span v-if="selectedValue" class="truncate">
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
<span v-else class="text-ink-gray-4 truncate">
{{ placeholder || '' }}
</span>
</div>
@ -66,7 +63,7 @@
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
class="my-1 max-h-[12rem] overflow-y-auto p-1.5 pt-0"
static
>
<div

View File

@ -60,9 +60,7 @@
clip-path: inset(22px 0 0 0);
"
>
<CameraIcon
class="h-6 w-6 cursor-pointer text-white"
/>
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
</div>
</component>
</div>
@ -129,7 +127,7 @@
class="flex flex-col p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<Section :label="section.label" :opened="section.opened">
<template #actions>
<Button
v-if="i == 0 && isManager()"
@ -140,7 +138,7 @@
<EditIcon class="h-4 w-4" />
</Button>
</template>
<SectionFields
<SidePanelLayout
v-if="section.fields"
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
@ -204,14 +202,14 @@
import Resizer from '@/components/Resizer.vue'
import Icon from '@/components/Icon.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.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/Settings/SidePanelModal.vue'
import SidePanelModal from '@/components/Modals/SidePanelModal.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'
import {
formatDate,

View File

@ -59,16 +59,7 @@
</Button>
</div>
</div>
<ContactModal
v-model="showContactModal"
v-model:quickEntry="showQuickEntryModal"
:contact="{}"
/>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
doctype="Contact"
/>
<ContactModal v-model="showContactModal" :contact="{}" />
</template>
<script setup>
@ -77,7 +68,6 @@ import CustomActions from '@/components/CustomActions.vue'
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 ContactsListView from '@/components/ListViews/ContactsListView.vue'
import ViewControls from '@/components/ViewControls.vue'
import { organizationsStore } from '@/stores/organizations.js'
@ -87,7 +77,6 @@ import { ref, computed } from 'vue'
const { getOrganization } = organizationsStore()
const showContactModal = ref(false)
const showQuickEntryModal = ref(false)
const contactsListView = ref(null)

View File

@ -124,7 +124,11 @@
class="section flex flex-col p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<Section
class="px-2 font-semibold"
:label="section.label"
:opened="section.opened"
>
<template #actions>
<div v-if="section.contacts" class="pr-2">
<Link
@ -163,7 +167,7 @@
<EditIcon class="h-4 w-4" />
</Button>
</template>
<SectionFields
<SidePanelLayout
v-if="section.fields"
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
@ -189,7 +193,7 @@
class="px-2 pb-2.5"
:class="[i == 0 ? 'pt-5' : 'pt-2.5']"
>
<Section :is-opened="contact.opened">
<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"
@ -326,6 +330,7 @@ import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
@ -342,10 +347,10 @@ 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/Settings/SidePanelModal.vue'
import SidePanelModal from '@/components/Modals/SidePanelModal.vue'
import Link from '@/components/Controls/Link.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SidePanelLayout from '@/components/SidePanelLayout.vue'
import SLASection from '@/components/SLASection.vue'
import CustomActions from '@/components/CustomActions.vue'
import {
@ -554,6 +559,11 @@ const tabs = computed(() => {
label: __('Comments'),
icon: CommentIcon,
},
{
name: 'Data',
label: __('Data'),
icon: DetailsIcon,
},
{
name: 'Calls',
label: __('Calls'),

View File

@ -177,8 +177,12 @@
class="flex flex-col p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
<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"
@ -298,6 +302,7 @@ import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
@ -312,11 +317,11 @@ 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/Settings/SidePanelModal.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 SectionFields from '@/components/SectionFields.vue'
import SidePanelLayout from '@/components/SidePanelLayout.vue'
import SLASection from '@/components/SLASection.vue'
import CustomActions from '@/components/CustomActions.vue'
import {
@ -500,6 +505,11 @@ const tabs = computed(() => {
label: __('Comments'),
icon: CommentIcon,
},
{
name: 'Data',
label: __('Data'),
icon: DetailsIcon,
},
{
name: 'Calls',
label: __('Calls'),

View File

@ -141,8 +141,8 @@
class="flex flex-col px-2 py-3 sm:p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
<Section :label="section.label" :opened="section.opened">
<SidePanelLayout
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
v-model="contact.data"
@ -178,7 +178,7 @@
<script setup>
import Icon from '@/components/Icon.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SidePanelLayout from '@/components/SidePanelLayout.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'

View File

@ -69,7 +69,7 @@
class="flex flex-col px-2 py-3 sm:p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<Section :label="section.label" :opened="section.opened">
<template #actions>
<div v-if="section.contacts" class="pr-2">
<Link
@ -98,7 +98,7 @@
</Link>
</div>
</template>
<SectionFields
<SidePanelLayout
v-if="section.fields"
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
@ -124,7 +124,7 @@
class="px-2 pb-2.5"
:class="[i == 0 ? 'pt-5' : 'pt-2.5']"
>
<Section :is-opened="contact.opened">
<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"
@ -267,7 +267,7 @@ import MultipleAvatar from '@/components/MultipleAvatar.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
import Link from '@/components/Controls/Link.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SidePanelLayout from '@/components/SidePanelLayout.vue'
import SLASection from '@/components/SLASection.vue'
import CustomActions from '@/components/CustomActions.vue'
import { createToast, setupAssignees, setupCustomizations } from '@/utils'
@ -452,6 +452,11 @@ const tabs = computed(() => {
label: __('Comments'),
icon: CommentIcon,
},
{
name: 'Data',
label: __('Data'),
icon: DetailsIcon,
},
{
name: 'Calls',
label: __('Calls'),

View File

@ -74,8 +74,8 @@
class="flex flex-col px-2 py-3 sm:p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
<Section :label="section.label" :opened="section.opened">
<SidePanelLayout
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
v-model="lead.data"
@ -190,7 +190,7 @@ import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SidePanelLayout from '@/components/SidePanelLayout.vue'
import SLASection from '@/components/SLASection.vue'
import CustomActions from '@/components/CustomActions.vue'
import { createToast, setupAssignees, setupCustomizations } from '@/utils'
@ -362,6 +362,11 @@ const tabs = computed(() => {
label: __('Comments'),
icon: CommentIcon,
},
{
name: 'Data',
label: __('Data'),
icon: DetailsIcon,
},
{
name: 'Calls',
label: __('Calls'),

View File

@ -123,8 +123,8 @@
class="flex flex-col px-2 py-3 sm:p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
<Section :label="section.label" :opened="section.opened">
<SidePanelLayout
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
v-model="organization.doc"
@ -166,7 +166,7 @@
<script setup>
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SidePanelLayout from '@/components/SidePanelLayout.vue'
import Icon from '@/components/Icon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'

View File

@ -112,7 +112,7 @@
class="flex flex-col p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<Section :label="section.label" :opened="section.opened">
<template #actions>
<Button
v-if="i == 0 && isManager()"
@ -123,7 +123,7 @@
<EditIcon class="h-4 w-4" />
</Button>
</template>
<SectionFields
<SidePanelLayout
v-if="section.fields"
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
@ -198,8 +198,8 @@
<script setup>
import Resizer from '@/components/Resizer.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SidePanelModal from '@/components/Settings/SidePanelModal.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'

View File

@ -59,15 +59,7 @@
</Button>
</div>
</div>
<OrganizationModal
v-model="showOrganizationModal"
v-model:quickEntry="showQuickEntryModal"
/>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
doctype="CRM Organization"
/>
<OrganizationModal v-model="showOrganizationModal" />
</template>
<script setup>
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
@ -75,7 +67,6 @@ import CustomActions from '@/components/CustomActions.vue'
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 OrganizationsListView from '@/components/ListViews/OrganizationsListView.vue'
import ViewControls from '@/components/ViewControls.vue'
import { formatDate, timeAgo, website, formatNumberIntoCurrency } from '@/utils'
@ -83,7 +74,6 @@ import { ref, computed } from 'vue'
const organizationsListView = ref(null)
const showOrganizationModal = ref(false)
const showQuickEntryModal = ref(false)
// organizations data is loaded in the ViewControls component
const organizations = ref({})

View File

@ -6,10 +6,7 @@ module.exports = {
'./node_modules/frappe-ui/src/components/**/*.{vue,js,ts,jsx,tsx}',
'../node_modules/frappe-ui/src/components/**/*.{vue,js,ts,jsx,tsx}',
],
safelist: [
{ pattern: /!(text|bg)-/, variants: ['hover', 'active'] },
{ pattern: /^grid-cols-/ },
],
safelist: [{ pattern: /!(text|bg)-/, variants: ['hover', 'active'] }],
theme: {
extend: {},
},

View File

@ -19,3 +19,46 @@ build-backend = "flit_core.buildapi"
# These dependencies are only installed when developer mode is enabled
[tool.bench.dev-dependencies]
# package_name = "~=1.1.0"
[tool.ruff]
line-length = 110
target-version = "py310"
exclude = [
"**/doctype/*/boilerplate/*.py" # boilerplate are template strings, not valid python
]
[tool.ruff.lint]
select = [
"F",
"E",
"W",
"I",
"UP",
"B",
"RUF",
]
ignore = [
"B017", # assertRaises(Exception) - should be more specific
"B018", # useless expression, not assigned to anything
"B023", # function doesn't bind loop variable - will have last iteration's value
"B904", # raise inside except without from
"E101", # indentation contains mixed spaces and tabs
"E402", # module level import not at top of file
"E501", # line too long
"E741", # ambiguous variable name
"F401", # "unused" imports
"F403", # can't detect undefined names from * import
"F405", # can't detect undefined names from * import
"F722", # syntax error in forward type annotation
"W191", # indentation contains tabs
"RUF001", # string contains ambiguous unicode character
"UP030", # Use implicit references for positional format fields (translations)
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call (translations)
]
typing-modules = ["frappe.types.DF"]
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
docstring-code-format = true