Merge pull request #46 from shariquerik/views

feat: Custom Views
This commit is contained in:
Shariq Ansari 2024-01-01 12:37:25 +05:30 committed by GitHub
commit 6842affa31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1120 additions and 1015 deletions

View File

@ -30,6 +30,11 @@ def get_filterable_fields(doctype: str):
"Text", "Text",
] ]
c = get_controller(doctype)
restricted_fields = []
if hasattr(c, "get_non_filterable_fields"):
restricted_fields = c.get_non_filterable_fields()
from_doc_fields = ( from_doc_fields = (
frappe.qb.from_(DocField) frappe.qb.from_(DocField)
.select( .select(
@ -42,6 +47,7 @@ def get_filterable_fields(doctype: str):
.where(DocField.parent == doctype) .where(DocField.parent == doctype)
.where(DocField.hidden == False) .where(DocField.hidden == False)
.where(Criterion.any([DocField.fieldtype == i for i in allowed_fieldtypes])) .where(Criterion.any([DocField.fieldtype == i for i in allowed_fieldtypes]))
.where(Criterion.all([DocField.fieldname != i for i in restricted_fields]))
.run(as_dict=True) .run(as_dict=True)
) )
res = [] res = []
@ -50,21 +56,31 @@ def get_filterable_fields(doctype: str):
@frappe.whitelist() @frappe.whitelist()
def get_list_data(doctype: str, filters: dict, order_by: str): def get_list_data(doctype: str, filters: dict, order_by: str, columns=None , rows=None, custom_view_name=None):
columns = [ custom_view = False
{"label": "Name", "type": "Data", "key": "name", "width": "16rem"}, filters = frappe._dict(filters)
{"label": "Last Modified", "type": "Datetime", "key": "modified", "width": "8rem"},
]
rows = ["name"]
is_default = True is_default = True
if columns or rows:
custom_view = True
is_default = False
columns = frappe.parse_json(columns)
rows = frappe.parse_json(rows)
if frappe.db.exists("CRM List View Settings", doctype): if not columns:
list_view_settings = frappe.get_doc("CRM List View Settings", doctype) columns = [
{"label": "Name", "type": "Data", "key": "name", "width": "16rem"},
{"label": "Last Modified", "type": "Datetime", "key": "modified", "width": "8rem"},
]
if not rows:
rows = ["name"]
if not custom_view and frappe.db.exists("CRM View Settings", doctype):
list_view_settings = frappe.get_doc("CRM View Settings", doctype)
columns = frappe.parse_json(list_view_settings.columns) columns = frappe.parse_json(list_view_settings.columns)
rows = frappe.parse_json(list_view_settings.rows) rows = frappe.parse_json(list_view_settings.rows)
is_default = False is_default = False
else: elif not custom_view or is_default:
list = get_controller(doctype) list = get_controller(doctype)
if hasattr(list, "default_list_data"): if hasattr(list, "default_list_data"):
@ -117,14 +133,26 @@ def get_list_data(doctype: str, filters: dict, order_by: str):
if field not in fields: if field not in fields:
fields.append(field) fields.append(field)
if not is_default and custom_view_name:
is_default = frappe.db.get_value("CRM View Settings", custom_view_name, "default_columns")
return { return {
"data": data, "data": data,
"columns": columns, "columns": columns,
"rows": rows, "rows": rows,
"fields": fields, "fields": fields,
"is_default": is_default, "is_default": is_default,
"views": get_views(doctype),
} }
def get_views(doctype):
views = frappe.get_all(
"CRM View Settings",
fields=["*"],
filters={"dt": doctype, "user": frappe.session.user}
)
return views
def get_doctype_fields(doctype): def get_doctype_fields(doctype):
not_allowed_fieldtypes = [ not_allowed_fieldtypes = [

View File

@ -196,6 +196,10 @@ class CRMLead(Document):
{ "label": 'Mobile no', "value": 'mobile_no' }, { "label": 'Mobile no', "value": 'mobile_no' },
] ]
@staticmethod
def get_non_filterable_fields():
return ["converted"]
@staticmethod @staticmethod
def default_list_data(): def default_list_data():
columns = [ columns = [

View File

@ -1,58 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "prompt",
"creation": "2023-11-27 16:29:10.993403",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"user",
"columns",
"rows"
],
"fields": [
{
"fieldname": "columns",
"fieldtype": "Code",
"label": "Columns"
},
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User"
},
{
"fieldname": "rows",
"fieldtype": "Code",
"label": "Rows"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-28 00:17:42.675332",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM List View Settings",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -1,50 +0,0 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import frappe
from frappe.model.document import Document, get_controller
class CRMListViewSettings(Document):
pass
@frappe.whitelist()
def update(doctype, columns, rows):
default_rows = sync_default_list_rows(doctype)
if default_rows:
rows = rows + default_rows
rows = remove_duplicates(rows)
if not frappe.db.exists("CRM List View Settings", doctype):
# create new CRM List View Settings
doc = frappe.new_doc("CRM List View Settings")
doc.name = doctype
doc.columns = json.dumps(columns)
doc.rows = json.dumps(rows)
doc.insert()
else:
# update existing CRM List View Settings
doc = frappe.get_doc("CRM List View Settings", doctype)
doc.columns = json.dumps(columns)
doc.rows = json.dumps(rows)
doc.save()
def remove_duplicates(l):
return list(dict.fromkeys(l))
def sync_default_list_rows(doctype):
list = get_controller(doctype)
rows = []
if hasattr(list, "default_list_data"):
rows = list.default_list_data().get("rows")
return rows
@frappe.whitelist()
def reset_to_default(doctype):
if frappe.db.exists("CRM List View Settings", doctype):
frappe.delete_doc("CRM List View Settings", doctype)

View File

@ -1,7 +1,7 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // For license information, please see license.txt
// frappe.ui.form.on("CRM List View Settings", { // frappe.ui.form.on("CRM View Settings", {
// refresh(frm) { // refresh(frm) {
// }, // },

View File

@ -0,0 +1,116 @@
{
"actions": [],
"autoname": "autoincrement",
"creation": "2023-11-27 16:29:10.993403",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"label",
"user",
"column_break_zacm",
"dt",
"columns_tab",
"default_columns",
"columns",
"rows",
"filters_tab",
"filters",
"order_by_tab",
"order_by"
],
"fields": [
{
"fieldname": "columns",
"fieldtype": "Code",
"label": "Columns"
},
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User"
},
{
"fieldname": "rows",
"fieldtype": "Code",
"label": "Rows"
},
{
"fieldname": "filters",
"fieldtype": "Code",
"label": "Filters"
},
{
"fieldname": "columns_tab",
"fieldtype": "Tab Break",
"label": "Columns"
},
{
"fieldname": "filters_tab",
"fieldtype": "Tab Break",
"label": "Filters"
},
{
"fieldname": "label",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Label"
},
{
"fieldname": "column_break_zacm",
"fieldtype": "Column Break"
},
{
"fieldname": "dt",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "DocType",
"options": "DocType"
},
{
"fieldname": "order_by_tab",
"fieldtype": "Tab Break",
"label": "Order By"
},
{
"fieldname": "order_by",
"fieldtype": "Code",
"label": "Order By"
},
{
"default": "0",
"fieldname": "default_columns",
"fieldtype": "Check",
"label": "Default Columns"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-12-30 19:28:02.541487",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM View Settings",
"naming_rule": "Autoincrement",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -0,0 +1,85 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import frappe
from frappe.model.document import Document, get_controller
class CRMViewSettings(Document):
pass
@frappe.whitelist()
def create(view, duplicate=False):
view = frappe._dict(view)
if duplicate:
view.filters = json.loads(view.filters)
view.columns = json.loads(view.columns)
view.rows = json.loads(view.rows)
doc = frappe.new_doc("CRM View Settings")
doc.name = view.label
doc.label = view.label
doc.dt = view.doctype
doc.user = frappe.session.user
doc.filters = json.dumps(view.filters)
doc.order_by = view.order_by
doc.default_columns = view.default_columns or False
if not view.columns:
view.columns = []
if not view.rows:
view.rows = []
default_rows = sync_default_list_rows(view.doctype)
if default_rows:
view.rows = view.rows + default_rows
view.rows = remove_duplicates(view.rows)
doc.columns = json.dumps(view.columns)
doc.rows = json.dumps(view.rows)
doc.insert()
return doc
@frappe.whitelist()
def update(view):
view = frappe._dict(view)
default_rows = sync_default_list_rows(view.doctype)
columns = view.columns or []
filters = view.filters
rows = view.rows or []
default_columns = view.default_columns or False
if default_rows:
rows = rows + default_rows
rows = remove_duplicates(rows)
doc = frappe.get_doc("CRM View Settings", view.name)
doc.label = view.label
doc.default_columns = default_columns
doc.filters = json.dumps(filters)
doc.order_by = view.order_by
doc.columns = json.dumps(columns)
doc.rows = json.dumps(rows)
doc.save()
return doc
@frappe.whitelist()
def delete(name):
if frappe.db.exists("CRM View Settings", name):
frappe.delete_doc("CRM View Settings", name)
def remove_duplicates(l):
return list(dict.fromkeys(l))
def sync_default_list_rows(doctype):
list = get_controller(doctype)
rows = []
if hasattr(list, "default_list_data"):
rows = list.default_list_data().get("rows")
return rows

View File

@ -5,5 +5,5 @@
from frappe.tests.utils import FrappeTestCase from frappe.tests.utils import FrappeTestCase
class TestCRMListViewSettings(FrappeTestCase): class TestCRMViewSettings(FrappeTestCase):
pass pass

View File

@ -88,17 +88,17 @@ watchDebounced(
val = val || '' val = val || ''
if (text.value === val) return if (text.value === val) return
text.value = val text.value = val
options.update({ reload(val)
params: {
txt: val,
doctype: props.doctype,
},
})
options.reload()
}, },
{ debounce: 300, immediate: true } { debounce: 300, immediate: true }
) )
watchDebounced(
() => props.doctype,
() => reload(''),
{ debounce: 300, immediate: true }
)
const options = createResource({ const options = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value], cache: [props.doctype, text.value],
@ -117,6 +117,16 @@ const options = createResource({
}, },
}) })
function reload(val) {
options.update({
params: {
txt: val,
doctype: props.doctype,
},
})
options.reload()
}
const labelClasses = computed(() => { const labelClasses = computed(() => {
return [ return [
{ {

View File

@ -3,27 +3,27 @@
<template #target> <template #target>
<Button label="Filter"> <Button label="Filter">
<template #prefix><FilterIcon class="h-4" /></template> <template #prefix><FilterIcon class="h-4" /></template>
<template v-if="storage.size" #suffix> <template v-if="filters?.size" #suffix>
<div <div
class="flex justify-center items-center w-5 h-5 text-2xs font-medium pt-[1px] bg-gray-900 text-white rounded" class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
> >
{{ storage.size }} {{ filters.size }}
</div> </div>
</template> </template>
</Button> </Button>
</template> </template>
<template #body="{ close }"> <template #body="{ close }">
<div class="rounded-lg border border-gray-100 bg-white shadow-xl my-2"> <div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
<div class="p-2 min-w-[400px]"> <div class="min-w-[400px] p-2">
<div <div
v-if="storage.size" v-if="filters?.size"
v-for="(f, i) in storage" v-for="(f, i) in filters"
:key="i" :key="i"
id="filter-list" id="filter-list"
class="flex items-center justify-between gap-2 mb-3" class="mb-3 flex items-center justify-between gap-2"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="text-gray-600 text-base pl-2 w-13 text-end"> <div class="w-13 pl-2 text-end text-base text-gray-600">
{{ i == 0 ? 'Where' : 'And' }} {{ i == 0 ? 'Where' : 'And' }}
</div> </div>
<div id="fieldname" class="!min-w-[140px]"> <div id="fieldname" class="!min-w-[140px]">
@ -37,23 +37,26 @@
<div id="operator"> <div id="operator">
<FormControl <FormControl
type="select" type="select"
v-model="f.operator" :value="f.operator"
@change="(e) => updateOperator(e, f)"
:options="getOperators(f.field.fieldtype)" :options="getOperators(f.field.fieldtype)"
placeholder="Operator" placeholder="Operator"
/> />
</div> </div>
<div id="value" class="!min-w-[140px]"> <div id="value" class="!min-w-[140px]">
<SearchComplete <Link
v-if="typeLink.includes(f.field.fieldtype)" v-if="typeLink.includes(f.field.fieldtype)"
:doctype="f.field.options" class="form-control"
:value="f.value" :value="f.value"
@change="(v) => (f.value = v.value)" :doctype="f.field.options"
@change="(v) => updateValue(v, f)"
placeholder="Value" placeholder="Value"
/> />
<component <component
v-else v-else
:is="getValSelect(f.field.fieldtype, f.field.options)" :is="getValSelect(f.field.fieldtype, f.field.options)"
v-model="f.value" :value="f.value"
@change="(e) => updateValue(e.target.value, f)"
placeholder="Value" placeholder="Value"
/> />
</div> </div>
@ -62,7 +65,7 @@
</div> </div>
<div <div
v-else v-else
class="text-gray-600 flex items-center text-sm px-3 h-7 mb-3" class="mb-3 flex h-7 items-center px-3 text-sm text-gray-600"
> >
Empty - Choose a field to filter by Empty - Choose a field to filter by
</div> </div>
@ -87,7 +90,7 @@
</template> </template>
</Autocomplete> </Autocomplete>
<Button <Button
v-if="storage.size" v-if="filters?.size"
class="!text-gray-600" class="!text-gray-600"
variant="ghost" variant="ghost"
label="Clear all Filter" label="Clear all Filter"
@ -102,16 +105,20 @@
<script setup> <script setup>
import NestedPopover from '@/components/NestedPopover.vue' import NestedPopover from '@/components/NestedPopover.vue'
import FilterIcon from '@/components/Icons/FilterIcon.vue' import FilterIcon from '@/components/Icons/FilterIcon.vue'
import SearchComplete from '@/components/SearchComplete.vue' import Link from '@/components/Controls/Link.vue'
import { useDebounceFn } from '@vueuse/core'
import { useFilter } from '@/composables/filter'
import { import {
FeatherIcon, FeatherIcon,
Autocomplete, Autocomplete,
FormControl, FormControl,
createResource, createResource,
} from 'frappe-ui' } from 'frappe-ui'
import { h, watch } from 'vue' import { h, defineModel, computed } from 'vue'
const typeCheck = ['Check']
const typeLink = ['Link']
const typeNumber = ['Float', 'Int']
const typeSelect = ['Select']
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
const props = defineProps({ const props = defineProps({
doctype: { doctype: {
@ -120,6 +127,10 @@ const props = defineProps({
}, },
}) })
const emit = defineEmits(['update'])
const list = defineModel()
const filterableFields = createResource({ const filterableFields = createResource({
url: 'crm.api.doc.get_filterable_fields', url: 'crm.api.doc.get_filterable_fields',
auto: true, auto: true,
@ -139,18 +150,49 @@ const filterableFields = createResource({
}, },
}) })
const { apply, storage } = useFilter(() => filterableFields.data) const filters = computed(() => {
const typeCheck = ['Check'] if (!list.value?.data) return new Set()
const typeLink = ['Link'] let allFilters = list.value?.params?.filters
const typeNumber = ['Float', 'Int'] if (!allFilters || !filterableFields.data) return new Set()
const typeSelect = ['Select'] // remove default filters
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text'] if (list.value.data._defaultFilters) {
allFilters = removeCommonFilters(list.value.data._defaultFilters, allFilters)
}
return convertFilters(filterableFields.data, allFilters)
})
watch( function removeCommonFilters(commonFilters, allFilters) {
storage, for (const key in commonFilters) {
useDebounceFn(() => apply(), 300), if (commonFilters.hasOwnProperty(key) && allFilters.hasOwnProperty(key)) {
{ deep: true } if (commonFilters[key] === allFilters[key]) {
) delete allFilters[key];
}
}
}
return allFilters;
}
function convertFilters(data, allFilters) {
let f = []
for (let [key, value] of Object.entries(allFilters)) {
let field = data.find((f) => f.fieldname === key)
if (typeof value !== 'object') {
value = ['=', value]
if (field.fieldtype === 'Check') {
value = ['equals', value[1] ? 'Yes' : 'No']
}
}
if (field) {
f.push({
field,
fieldname: key,
operator: oppositeOperatorMap[value[0]],
value: value[1],
})
}
}
return new Set(f)
}
function getOperators(fieldtype) { function getOperators(fieldtype) {
let options = [] let options = []
@ -231,7 +273,7 @@ function getSelectOptions(options) {
} }
function setfilter(data) { function setfilter(data) {
storage.value.add({ filters.value.add({
field: { field: {
label: data.label, label: data.label,
fieldname: data.value, fieldname: data.value,
@ -242,11 +284,12 @@ function setfilter(data) {
operator: getDefaultOperator(data.fieldtype), operator: getDefaultOperator(data.fieldtype),
value: getDefaultValue(data), value: getDefaultValue(data),
}) })
apply()
} }
function updateFilter(data, index) { function updateFilter(data, index) {
storage.value.delete(Array.from(storage.value)[index]) filters.value.delete(Array.from(filters.value)[index])
storage.value.add({ filters.value.add({
fieldname: data.value, fieldname: data.value,
operator: getDefaultOperator(data.fieldtype), operator: getDefaultOperator(data.fieldtype),
value: getDefaultValue(data), value: getDefaultValue(data),
@ -257,14 +300,93 @@ function updateFilter(data, index) {
options: data.options, options: data.options,
}, },
}) })
apply()
} }
function removeFilter(index) { function removeFilter(index) {
storage.value.delete(Array.from(storage.value)[index]) filters.value.delete(Array.from(filters.value)[index])
apply()
} }
function clearfilter(close) { function clearfilter(close) {
storage.value.clear() filters.value.clear()
apply()
close() close()
} }
function updateValue(value, filter) {
filter.value = value
apply()
}
function updateOperator(event, filter) {
filter.operator = event.target.value
apply()
}
function apply() {
let _filters = []
filters.value.forEach((f) => {
_filters.push({
fieldname: f.fieldname,
operator: f.operator,
value: f.value,
})
})
emit('update', parseFilters(_filters))
}
function parseFilters(filters) {
const l__ = Array.from(filters)
const obj = l__.map(transformIn).reduce((p, c) => {
if (['equals', '='].includes(c.operator)) {
p[c.fieldname] =
c.value == 'Yes' ? true : c.value == 'No' ? false : c.value
} else {
p[c.fieldname] = [operatorMap[c.operator.toLowerCase()], c.value]
}
return p
}, {})
const merged = { ...obj }
return merged
}
function transformIn(f) {
if (f.fieldname === '_assign') {
f.operator = f.operator === 'is' ? 'like' : 'not like'
}
if (f.operator.includes('like') && !f.value.includes('%')) {
f.value = `%${f.value}%`
}
return f
}
const operatorMap = {
is: '=',
'is not': '!=',
equals: '=',
'not equals': '!=',
yes: true,
no: false,
like: 'LIKE',
'not like': 'NOT LIKE',
'>': '>',
'<': '<',
'>=': '>=',
'<=': '<=',
}
const oppositeOperatorMap = {
'=': 'is',
equals: 'equals',
'!=': 'is not',
true: 'yes',
false: 'no',
LIKE: 'like',
'NOT LIKE': 'not like',
'>': '>',
'<': '<',
'>=': '>=',
'<=': '<=',
}
</script> </script>

View File

@ -0,0 +1,101 @@
<template>
<Dialog
v-model="show"
:options="{
title: editMode
? 'Edit View'
: duplicateMode
? 'Duplicate View'
: 'Create View',
actions: [
{
label: editMode ? 'Save Changes' : duplicateMode ? 'Duplicate' : 'Create',
variant: 'solid',
onClick: () => (editMode ? update() : create()),
},
],
}"
>
<template #body-content>
<FormControl
variant="outline"
size="md"
type="text"
label="View Name"
placeholder="View Name"
v-model="view.label"
/>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, call } from 'frappe-ui'
import { ref, watch, defineModel, nextTick } from 'vue'
const show = defineModel()
const editMode = ref(false)
const duplicateMode = ref(false)
const _view = ref({
name: '',
label: '',
filters: {},
order_by: 'modified desc',
columns: '',
rows: '',
})
const props = defineProps({
doctype: {
type: String,
required: true,
},
view: {
type: Object,
default: () => {},
},
options: {
type: Object,
default: {
afterCreate: () => {},
afterUpdate: () => {},
},
},
})
async function create() {
props.view.doctype = props.doctype
let v = await call(
'crm.fcrm.doctype.crm_view_settings.crm_view_settings.create',
{
view: props.view,
duplicate: duplicateMode.value,
}
)
show.value = false
props.options.afterCreate?.(v, props.view)
}
async function update() {
props.view.doctype = props.doctype
await call('crm.fcrm.doctype.crm_view_settings.crm_view_settings.update', {
view: props.view,
})
show.value = false
props.options.afterUpdate?.(props.view)
}
watch(show, (value) => {
if (!value) return
editMode.value = false
duplicateMode.value = false
nextTick(() => {
_view.value = { ...props.view }
if (_view.value.name) {
editMode.value = true
} else if (_view.value.label) {
duplicateMode.value = true
}
})
})
</script>

View File

@ -1,85 +0,0 @@
<template>
<Autocomplete
placeholder="Select an option"
:options="options"
:value="selection"
@update:query="(q) => onUpdateQuery(q)"
@change="(v) => (selection = v)"
/>
</template>
<script setup>
import { Autocomplete, createListResource } from 'frappe-ui'
import { computed, ref, watch } from 'vue'
const props = defineProps({
value: {
type: String,
required: false,
default: '',
},
doctype: {
type: String,
required: true,
},
searchField: {
type: String,
required: false,
default: 'name',
},
labelField: {
type: String,
required: false,
default: 'name',
},
valueField: {
type: String,
required: false,
default: 'name',
},
pageLength: {
type: Number,
required: false,
default: 10,
},
})
watch(
() => props.doctype,
(value) => {
r.doctype = value
r.reload()
}
)
const r = createListResource({
doctype: props.doctype,
pageLength: props.pageLength,
cache: ['link_doctype', props.doctype],
auto: true,
fields: [props.labelField, props.searchField, props.valueField],
onSuccess: () => {
selection.value = props.value
? options.value.find((o) => o.value === props.value)
: null
},
})
const options = computed(
() =>
r.data?.map((result) => ({
label: result[props.labelField],
value: result[props.valueField],
})) || []
)
const selection = ref(null)
function onUpdateQuery(query) {
r.update({
filters: {
[props.searchField]: ['like', `%${query}%`],
},
})
r.reload()
}
</script>

View File

@ -3,29 +3,29 @@
<template #target> <template #target>
<Button label="Sort" ref="sortButtonRef"> <Button label="Sort" ref="sortButtonRef">
<template #prefix><SortIcon class="h-4" /></template> <template #prefix><SortIcon class="h-4" /></template>
<template v-if="sortValues.length" #suffix> <template v-if="sortValues?.size" #suffix>
<div <div
class="flex justify-center items-center w-5 h-5 text-2xs font-medium pt-[1px] bg-gray-900 text-white rounded" class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
> >
{{ sortValues.length }} {{ sortValues.size }}
</div> </div>
</template> </template>
</Button> </Button>
</template> </template>
<template #body="{ close }"> <template #body="{ close }">
<div class="rounded-lg border border-gray-100 bg-white shadow-xl my-2"> <div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
<div class="p-2 min-w-[352px]"> <div class="min-w-[352px] p-2">
<div <div
v-if="sortValues.length" v-if="sortValues?.size"
id="sort-list" id="sort-list"
class="flex flex-col gap-2 mb-3" class="mb-3 flex flex-col gap-2"
> >
<div <div
v-for="(sort, i) in sortValues" v-for="(sort, i) in sortValues"
:key="sort.fieldname" :key="sort.fieldname"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<div class="flex items-center justify-center h-7 w-7 handle"> <div class="handle flex h-7 w-7 items-center justify-center">
<DragIcon class="h-4 w-4 cursor-grab text-gray-600" /> <DragIcon class="h-4 w-4 cursor-grab text-gray-600" />
</div> </div>
<Autocomplete <Autocomplete
@ -38,11 +38,17 @@
<FormControl <FormControl
class="!w-32" class="!w-32"
type="select" type="select"
v-model="sort.direction" :value="sort.direction"
:options="[ :options="[
{ label: 'Ascending', value: 'asc' }, { label: 'Ascending', value: 'asc' },
{ label: 'Descending', value: 'desc' }, { label: 'Descending', value: 'desc' },
]" ]"
@change="
(e) => {
sort.direction = e.target.value
apply()
}
"
placeholder="Sort by" placeholder="Sort by"
/> />
<Button variant="ghost" icon="x" @click="removeSort(i)" /> <Button variant="ghost" icon="x" @click="removeSort(i)" />
@ -50,7 +56,7 @@
</div> </div>
<div <div
v-else v-else
class="text-gray-600 flex items-center text-sm px-3 h-7 mb-3" class="mb-3 flex h-7 items-center px-3 text-sm text-gray-600"
> >
Empty - Choose a field to sort by Empty - Choose a field to sort by
</div> </div>
@ -75,7 +81,7 @@
</template> </template>
</Autocomplete> </Autocomplete>
<Button <Button
v-if="sortValues.length" v-if="sortValues?.size"
class="!text-gray-600" class="!text-gray-600"
variant="ghost" variant="ghost"
label="Clear Sort" label="Clear Sort"
@ -93,7 +99,6 @@ import NestedPopover from '@/components/NestedPopover.vue'
import SortIcon from '@/components/Icons/SortIcon.vue' import SortIcon from '@/components/Icons/SortIcon.vue'
import DragIcon from '@/components/Icons/DragIcon.vue' import DragIcon from '@/components/Icons/DragIcon.vue'
import { useSortable } from '@vueuse/integrations/useSortable' import { useSortable } from '@vueuse/integrations/useSortable'
import { useOrderBy } from '@/composables/orderby'
import { import {
FeatherIcon, FeatherIcon,
Button, Button,
@ -101,7 +106,7 @@ import {
FormControl, FormControl,
createResource, createResource,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, nextTick } from 'vue'
const props = defineProps({ const props = defineProps({
doctype: { doctype: {
@ -110,10 +115,10 @@ const props = defineProps({
}, },
}) })
const { get: getOrderBy, set: setOrderBy } = useOrderBy() const emit = defineEmits(['update'])
const list = defineModel()
const sortButtonRef = ref(null) const sortButtonRef = ref(null)
const sortValues = ref(initialOrderBy())
const sortOptions = createResource({ const sortOptions = createResource({
url: 'crm.api.doc.sort_options', url: 'crm.api.doc.sort_options',
@ -123,75 +128,83 @@ const sortOptions = createResource({
}, },
}) })
const sortValues = computed({
get: () => {
if (!list.value?.data) return new Set()
let allSortValues = list.value?.params?.order_by
if (!allSortValues || !sortOptions.data) return new Set()
if (allSortValues.trim() === 'modified desc') return new Set()
allSortValues = allSortValues.split(', ').map((sortValue) => {
const [fieldname, direction] = sortValue.split(' ')
return { fieldname, direction }
})
return new Set(allSortValues)
},
set: (value) => {
list.value.params.order_by = convertToString(value)
},
})
const options = computed(() => { const options = computed(() => {
if (!sortOptions.data) return [] if (!sortOptions.data) return []
const selectedOptions = sortValues.value.map((sort) => sort.fieldname) if (!sortValues.value.size) return sortOptions.data
const selectedOptions = [...sortValues.value].map((sort) => sort.fieldname)
restartSort()
return sortOptions.data.filter((option) => { return sortOptions.data.filter((option) => {
return !selectedOptions.includes(option.value) return !selectedOptions.includes(option.value)
}) })
}) })
function initialOrderBy() {
const orderBy = getOrderBy()
if (!orderBy) return []
const sortOptions = orderBy.split(', ')
return sortOptions.map((sortOption) => {
const [fieldname, direction] = sortOption.split(' ')
return { fieldname, direction }
})
}
const sortSortable = useSortable('#sort-list', sortValues, { const sortSortable = useSortable('#sort-list', sortValues, {
handle: '.handle', handle: '.handle',
animation: 200, animation: 200,
onEnd: () => apply(),
}) })
watch(
() => sortValues.value,
(value) => {
const updatedSort = value
.map((sort) => {
const option = sortOptions.data.find((o) => o.value === sort.fieldname)
return `${option.value} ${sort.direction}`
})
.join(', ')
setOrderBy(updatedSort)
},
{
deep: true,
}
)
watch(
() => getOrderBy(),
(value) => {
if (!value) {
sortValues.value = []
}
}
)
function setSort(data) { function setSort(data) {
sortValues.value = [ sortValues.value.add({ fieldname: data.value, direction: 'asc' })
...sortValues.value, restartSort()
{ fieldname: data.value, direction: 'asc' }, apply()
]
sortSortable.start()
} }
function updateSort(data, index) { function updateSort(data, index) {
sortValues.value[index] = { let oldSort = Array.from(sortValues.value)[index]
sortValues.value.delete(oldSort)
sortValues.value.add({
fieldname: data.value, fieldname: data.value,
direction: sortValues.value[index].direction, direction: oldSort.direction,
} })
apply()
} }
function removeSort(index) { function removeSort(index) {
sortValues.value.splice(index, 1) sortValues.value.delete(Array.from(sortValues.value)[index])
apply()
} }
function clearSort(close) { function clearSort(close) {
sortValues.value = [] sortValues.value.clear()
apply()
close() close()
} }
function apply() {
nextTick(() => {
emit('update', convertToString(sortValues.value))
})
}
function convertToString(values) {
let _sortValues = ''
values.forEach((f) => {
_sortValues += `${f.fieldname} ${f.direction}, `
})
_sortValues = _sortValues.slice(0, -2)
return _sortValues
}
function restartSort() {
sortSortable.stop()
sortSortable.start()
}
</script> </script>

View File

@ -0,0 +1,362 @@
<template>
<div class="flex items-center justify-between px-5 pb-4 pt-3">
<div class="flex items-center gap-2">
<Dropdown :options="viewsDropdownOptions">
<template #default="{ open }">
<Button :label="currentView.label">
<template #prefix>
<FeatherIcon :name="currentView.icon" class="h-4" />
</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Dropdown>
</div>
<div class="flex items-center gap-2">
<div v-if="viewUpdated" class="flex items-center gap-2 border-r pr-2">
<Button label="Cancel" @click="cancelChanges" />
<Button
:label="view?.name ? 'Save Changes' : 'Create View'"
@click="saveView"
/>
</div>
<div class="flex items-center gap-2">
<Filter v-model="list" :doctype="doctype" @update="updateFilter" />
<SortBy v-model="list" :doctype="doctype" @update="updateSort" />
<ViewSettings
v-model="list"
:doctype="doctype"
@update="(isDefault) => updateColumns(isDefault)"
/>
<Dropdown :options="viewActions">
<template #default>
<Button>
<FeatherIcon name="more-horizontal" class="h-4 w-4" />
</Button>
</template>
</Dropdown>
</div>
</div>
</div>
<ViewModal
:doctype="doctype"
:view="view"
:options="{
afterCreate: (v) => {
viewUpdated = false
router.push({ name: route.name, query: { view: v.name } })
},
afterUpdate: (v) => {
viewUpdated = false
currentView = {
label: v.label,
icon: v.icon || 'list',
}
},
}"
v-model="showViewModal"
/>
</template>
<script setup>
import ViewModal from '@/components/Modals/ViewModal.vue'
import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue'
import ViewSettings from '@/components/ViewSettings.vue'
import { globalStore } from '@/stores/global'
import { viewsStore } from '@/stores/views'
import { useDebounceFn } from '@vueuse/core'
import { createResource, FeatherIcon, Dropdown, call } from 'frappe-ui'
import { computed, ref, defineModel, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const props = defineProps({
doctype: {
type: String,
required: true,
},
filters: {
type: Object,
default: {},
},
})
const { $dialog } = globalStore()
const { getView } = viewsStore()
const list = defineModel()
const route = useRoute()
const router = useRouter()
const defaultParams = ref('')
const viewUpdated = ref(false)
const showViewModal = ref(false)
const currentView = ref({
label: 'List View',
icon: 'list',
})
const view = ref({
name: '',
label: '',
filters: props.filters,
order_by: 'modified desc',
columns: '',
rows: '',
default_columns: false,
})
function getParams() {
let _view = getView(route.query.view)
const filters = (_view?.filters && JSON.parse(_view.filters)) || props.filters
const order_by = _view?.order_by || 'modified desc'
const columns = _view?.columns || ''
const rows = _view?.rows || ''
if (_view) {
view.value = {
name: _view.name,
label: _view.label,
filters: _view.filters,
order_by: _view.order_by,
columns: _view.columns,
rows: _view.rows,
default_columns: _view.row,
}
} else {
view.value = {
name: '',
label: '',
filters: props.filters,
order_by: 'modified desc',
columns: '',
rows: '',
default_columns: true,
}
}
return {
doctype: props.doctype,
filters: filters,
order_by: order_by,
columns: columns,
rows: rows,
custom_view_name: _view?.name || '',
}
}
list.value = createResource({
url: 'crm.api.doc.get_list_data',
params: getParams(),
onSuccess(data) {
setupViews(data.views)
setupDefaults(data)
},
})
onMounted(() => {
useDebounceFn(() => reload(), 100)()
})
function reload() {
list.value.params = getParams()
list.value.reload()
}
const viewsDropdownOptions = ref([])
const defaultViews = [
{
label: 'List View',
icon: 'list',
onClick() {
viewUpdated.value = false
router.push({ name: route.name })
},
},
]
function setupViews(views) {
viewsDropdownOptions.value = [
{
group: 'Default Views',
hideLabel: true,
items: defaultViews,
},
]
views?.forEach((view) => {
view.icon = view.icon || 'list'
view.filters = JSON.parse(view.filters)
view.onClick = () => {
viewUpdated.value = false
router.push({ ...route, query: { view: view.name } })
}
})
if (views.length) {
viewsDropdownOptions.value.push({
group: 'Saved Views',
items: views,
})
}
}
function setupDefaults(data) {
let cv = getView(route.query.view)
currentView.value = {
label: cv?.label || 'List View',
icon: cv?.icon || 'list',
}
defaultParams.value = {
doctype: props.doctype,
filters: list.value.params.filters,
order_by: list.value.params.order_by,
columns: data.columns,
rows: data.rows,
custom_view_name: cv?.name || '',
}
data._defaultFilters = props.filters
}
function updateFilter(filters) {
viewUpdated.value = true
if (!defaultParams.value) {
defaultParams.value = getParams()
}
list.value.params = defaultParams.value
list.value.params.filters = filters
list.value.reload()
}
function updateSort(order_by) {
viewUpdated.value = true
if (!defaultParams.value) {
defaultParams.value = getParams()
}
list.value.params = defaultParams.value
list.value.params.order_by = order_by
list.value.reload()
}
function updateColumns(obj) {
defaultParams.value.columns = obj.isDefault ? '' : obj.columns
defaultParams.value.rows = obj.isDefault ? '' : obj.rows
view.value.default_columns = obj.isDefault
if (obj.reset) {
defaultParams.value.columns = getParams().columns
defaultParams.value.rows = getParams().rows
}
if (obj.reload) {
list.value.params = defaultParams.value
list.value.reload()
}
viewUpdated.value = true
}
// View Actions
const viewActions = computed(() => {
let o = [
{
group: 'Default Views',
hideLabel: true,
items: [
{
label: 'Duplicate View',
icon: 'copy',
onClick: () => {
view.value.name = ''
view.value.label = view.value.label + ' New'
showViewModal.value = true
},
},
],
},
]
if (route.query.view) {
o.push({
group: 'Delete View',
hideLabel: true,
items: [
{
label: 'Delete',
icon: 'trash-2',
onClick: () =>
$dialog({
title: 'Delete View',
message: 'Are you sure you want to delete this view?',
variant: 'danger',
actions: [
{
label: 'Delete',
variant: 'solid',
theme: 'red',
onClick: (close) => {
close()
call(
'crm.fcrm.doctype.crm_view_settings.crm_view_settings.delete',
{
name: route.query.view,
}
).then(() => {
router.push({ name: route.name })
})
},
},
],
}),
},
],
})
}
return o
})
function cancelChanges() {
reload()
viewUpdated.value = false
}
function saveView() {
view.value = {
label: view.value.label,
name: view.value.name,
filters: defaultParams.value.filters,
order_by: defaultParams.value.order_by,
columns: defaultParams.value.columns,
rows: defaultParams.value.rows,
default_columns: view.value.default_columns,
}
showViewModal.value = true
}
// Watchers
watch(
() => getView(route.query.view),
(value, old_value) => {
if (JSON.stringify(value) === JSON.stringify(old_value)) return
reload()
},
{ deep: true }
)
watch(
() => route,
(value, old_value) => {
if (value === old_value) return
reload()
}
)
</script>

View File

@ -7,14 +7,14 @@
</template> </template>
</Button> </Button>
</template> </template>
<template #body> <template #body="{ close }">
<div <div
class="my-2 rounded-lg border border-gray-100 bg-white p-1.5 shadow-xl" class="my-2 rounded-lg border border-gray-100 bg-white p-1.5 shadow-xl"
> >
<div v-if="!edit"> <div v-if="!edit">
<Draggable <Draggable
:list="columns" :list="columns"
@end="updateColumnDetails" @end="apply"
item-key="key" item-key="key"
class="list-group" class="list-group"
> >
@ -64,11 +64,22 @@
</Button> </Button>
</template> </template>
</Autocomplete> </Autocomplete>
<Button
v-if="columnsUpdated"
class="w-full !justify-start !text-gray-600"
variant="ghost"
@click="reset(close)"
label="Reset Changes"
>
<template #prefix>
<ReloadIcon class="h-4" />
</template>
</Button>
<Button <Button
v-if="!is_default" v-if="!is_default"
class="w-full !justify-start !text-gray-600" class="w-full !justify-start !text-gray-600"
variant="ghost" variant="ghost"
@click="resetToDefault" @click="resetToDefault(close)"
label="Reset to Default" label="Reset to Default"
> >
<template #prefix> <template #prefix>
@ -131,7 +142,8 @@ import NestedPopover from '@/components/NestedPopover.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue' import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { computed, defineModel, ref } from 'vue' import { computed, defineModel, ref } from 'vue'
import { FeatherIcon, FormControl, call } from 'frappe-ui' import { FeatherIcon, FormControl } from 'frappe-ui'
import { watchOnce } from '@vueuse/core'
const props = defineProps({ const props = defineProps({
doctype: { doctype: {
@ -140,6 +152,15 @@ const props = defineProps({
}, },
}) })
const emit = defineEmits(['update'])
const columnsUpdated = ref(false)
const oldValues = ref({
columns: [],
rows: [],
isDefault: false,
})
const list = defineModel() const list = defineModel()
const edit = ref(false) const edit = ref(false)
const column = ref({ const column = ref({
@ -179,7 +200,7 @@ const fields = computed(() => {
}) })
}) })
async function addColumn(c) { function addColumn(c) {
let _column = { let _column = {
label: c.label, label: c.label,
type: c.type, type: c.type,
@ -188,8 +209,7 @@ async function addColumn(c) {
} }
columns.value.push(_column) columns.value.push(_column)
rows.value.push(c.value) rows.value.push(c.value)
await updateColumnDetails() apply(true)
list.value.reload()
} }
function removeColumn(c) { function removeColumn(c) {
@ -197,7 +217,7 @@ function removeColumn(c) {
if (c.key !== 'name') { if (c.key !== 'name') {
rows.value = rows.value.filter((row) => row !== c.key) rows.value = rows.value.filter((row) => row !== c.key)
} }
updateColumnDetails() apply()
} }
function editColumn(c) { function editColumn(c) {
@ -215,7 +235,7 @@ function updateColumn(c) {
if (columns.value[index].old) { if (columns.value[index].old) {
delete columns.value[index].old delete columns.value[index].old
} }
updateColumnDetails() apply()
} }
function cancelUpdate() { function cancelUpdate() {
@ -225,25 +245,43 @@ function cancelUpdate() {
delete column.value.old delete column.value.old
} }
async function updateColumnDetails() { function reset(close) {
is_default.value = false apply(true, false, true)
await call( close()
'crm.fcrm.doctype.crm_list_view_settings.crm_list_view_settings.update',
{
doctype: props.doctype,
columns: columns.value,
rows: rows.value,
}
)
} }
async function resetToDefault() { function resetToDefault(close) {
await call( apply(true, true)
'crm.fcrm.doctype.crm_list_view_settings.crm_list_view_settings.reset_to_default', close()
{
doctype: props.doctype,
}
)
list.value.reload()
} }
function apply(reload = false, isDefault = false, reset = false) {
is_default.value = isDefault
columnsUpdated.value = true
let obj = {
columns: reset ? oldValues.value.columns : columns.value,
rows: reset ? oldValues.value.rows : rows.value,
isDefault: reset ? oldValues.value.isDefault : isDefault,
reload,
reset,
}
emit('update', obj)
if (reload) {
setTimeout(() => {
is_default.value = reset ? oldValues.value.isDefault : isDefault
columnsUpdated.value = !reset
}, 100)
}
}
watchOnce(
() => list.value.data,
(val) => {
if (!val) return
oldValues.value.columns = JSON.parse(JSON.stringify(val.columns))
oldValues.value.rows = JSON.parse(JSON.stringify(val.rows))
oldValues.value.isDefault = val.is_default
}
)
</script> </script>

View File

@ -1,104 +0,0 @@
import { ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toValue } from '@vueuse/core'
import { usersStore } from '@/stores/users'
const operatorMap = {
is: '=',
'is not': '!=',
equals: '=',
'not equals': '!=',
yes: true,
no: false,
like: 'LIKE',
'not like': 'NOT LIKE',
'>': '>',
'<': '<',
'>=': '>=',
'<=': '<=',
}
export function useFilter(fields) {
const route = useRoute()
const router = useRouter()
const { getUser } = usersStore()
const storage = ref(new Set())
watchEffect(() => {
const f__ = toValue(fields)
if (fields && !f__) return
storage.value = new Set()
const q = route.query.q || ''
q.split(' ')
.map((f) => {
const [fieldname, operator, value] = f
.split(':')
.map(decodeURIComponent)
const field = (f__ || []).find((f) => f.fieldname === fieldname)
return {
field,
fieldname,
operator,
value,
}
})
.filter((f) => !f__ || (f__ && f.field))
.filter((f) => operatorMap[f.operator])
.forEach((f) => storage.value.add(f))
})
function getArgs(old) {
old = old || {}
const l__ = Array.from(storage.value)
const obj = l__.map(transformIn).reduce((p, c) => {
p[c.fieldname] = [operatorMap[c.operator.toLowerCase()], c.value]
return p
}, {})
const merged = { ...old, ...obj }
return merged
}
function apply(r) {
r = r || route
const l__ = Array.from(storage.value)
const q = l__
.map(transformOut)
.map((f) =>
[f.fieldname, f.operator.toLowerCase(), f.value]
.map(encodeURIComponent)
.join(':')
)
.join(' ')
if (!q && !r.query.q) {
router.push({ ...r, query: { ...r.query } })
} else {
router.push({ ...r, query: { ...r.query, q } })
}
}
/**
* Used to set fields internally. These will not reflect in URL.
* Can be used for APIs
*/
function transformIn(f) {
if (f.fieldname === '_assign') {
f.operator = f.operator === 'is' ? 'like' : 'not like'
}
if (f.operator.includes('like') && !f.value.includes('%')) {
f.value = `%${f.value}%`
}
return f
}
/**
* Used to set fields in URL query
*/
function transformOut(f) {
if (f.value === '@me') {
f.value = getUser()
}
return f
}
return { apply, getArgs, storage }
}

View File

@ -1,23 +0,0 @@
import { useRoute, useRouter } from 'vue-router'
export function useOrderBy() {
const route = useRoute()
const router = useRouter()
function get() {
const q = route.query.sort ?? ''
const d = decodeURIComponent(q)
return d
}
function set(sort, r) {
r = r || route
const q = encodeURIComponent(sort)
router.push({ ...r, query: { ...r.query, sort: q } })
}
return {
get,
set,
}
}

View File

@ -4,21 +4,16 @@
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
</template> </template>
</LayoutHeader> </LayoutHeader>
<div class="flex items-center justify-between px-5 pb-4 pt-3"> <ViewControls v-model="callLogs" doctype="CRM Call Log" />
<div class="flex items-center gap-2">
<SortBy doctype="CRM Call Log" />
<Filter doctype="CRM Call Log" />
</div>
<div class="flex items-center gap-2">
<ViewSettings doctype="CRM Call Log" v-model="callLogs" />
</div>
</div>
<CallLogsListView <CallLogsListView
v-if="callLogs.data && rows.length" v-if="callLogs.data && rows.length"
:rows="rows" :rows="rows"
:columns="callLogs.data.columns" :columns="callLogs.data.columns"
/> />
<div v-else-if="callLogs.data" class="flex h-full items-center justify-center"> <div
v-else-if="callLogs.data"
class="flex h-full items-center justify-center"
>
<div <div
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500" class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
> >
@ -31,9 +26,7 @@
<script setup> <script setup>
import PhoneIcon from '@/components/Icons/PhoneIcon.vue' import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import SortBy from '@/components/SortBy.vue' import ViewControls from '@/components/ViewControls.vue'
import Filter from '@/components/Filter.vue'
import ViewSettings from '@/components/ViewSettings.vue'
import CallLogsListView from '@/components/ListViews/CallLogsListView.vue' import CallLogsListView from '@/components/ListViews/CallLogsListView.vue'
import { import {
secondsToDuration, secondsToDuration,
@ -43,61 +36,21 @@ import {
} from '@/utils' } from '@/utils'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts' import { contactsStore } from '@/stores/contacts'
import { useOrderBy } from '@/composables/orderby' import { Breadcrumbs } from 'frappe-ui'
import { useFilter } from '@/composables/filter' import { computed, ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { createResource, Breadcrumbs, FeatherIcon } from 'frappe-ui'
import { computed, watch } from 'vue'
const { getUser } = usersStore() const { getUser } = usersStore()
const { getContact } = contactsStore() const { getContact } = contactsStore()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const breadcrumbs = [{ label: 'Call Logs', route: { name: 'Call Logs' } }] const breadcrumbs = [{ label: 'Call Logs', route: { name: 'Call Logs' } }]
function getParams() { const callLogs = ref({})
const filters = getArgs() || {}
const order_by = getOrderBy() || 'creation desc'
return {
doctype: 'CRM Call Log',
filters: filters,
order_by: order_by,
}
}
const callLogs = createResource({
url: 'crm.api.doc.get_list_data',
params: getParams(),
auto: true,
})
watch(
() => getOrderBy(),
(value, old_value) => {
if (!value && !old_value) return
callLogs.params = getParams()
callLogs.reload()
},
{ immediate: true }
)
watch(
storage,
useDebounceFn((value, old_value) => {
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
callLogs.params = getParams()
callLogs.reload()
}, 300),
{ deep: true }
)
const rows = computed(() => { const rows = computed(() => {
if (!callLogs.data?.data) return [] if (!callLogs.value?.data?.data) return []
return callLogs.data.data.map((callLog) => { return callLogs.value?.data.data.map((callLog) => {
let _rows = {} let _rows = {}
callLogs.data.rows.forEach((row) => { callLogs.value?.data.rows.forEach((row) => {
_rows[row] = callLog[row] _rows[row] = callLog[row]
let incoming = callLog.type === 'Incoming' let incoming = callLog.type === 'Incoming'

View File

@ -9,36 +9,16 @@
</Button> </Button>
</template> </template>
</LayoutHeader> </LayoutHeader>
<div class="flex items-center justify-between px-5 pb-4 pt-3"> <ViewControls v-model="contacts" doctype="Contact" />
<div class="flex items-center gap-2">
<Dropdown :options="viewsDropdownOptions">
<template #default="{ open }">
<Button :label="currentView.label">
<template #prefix>
<FeatherIcon :name="currentView.icon" class="h-4" />
</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Dropdown>
</div>
<div class="flex items-center gap-2">
<Filter doctype="Contact" />
<SortBy doctype="Contact" />
<ViewSettings doctype="Contact" v-model="contacts" />
</div>
</div>
<ContactsListView <ContactsListView
v-if="contacts.data && rows.length" v-if="contacts.data && rows.length"
:rows="rows" :rows="rows"
:columns="contacts.data.columns" :columns="contacts.data.columns"
/> />
<div v-else-if="contacts.data" class="flex h-full items-center justify-center"> <div
v-else-if="contacts.data"
class="flex h-full items-center justify-center"
>
<div <div
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500" class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
> >
@ -57,27 +37,20 @@ import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import ContactModal from '@/components/Modals/ContactModal.vue' import ContactModal from '@/components/Modals/ContactModal.vue'
import ContactsListView from '@/components/ListViews/ContactsListView.vue' import ContactsListView from '@/components/ListViews/ContactsListView.vue'
import SortBy from '@/components/SortBy.vue' import ViewControls from '@/components/ViewControls.vue'
import Filter from '@/components/Filter.vue' import { FeatherIcon, Breadcrumbs } from 'frappe-ui'
import ViewSettings from '@/components/ViewSettings.vue'
import { FeatherIcon, Breadcrumbs, Dropdown, createResource } from 'frappe-ui'
import { organizationsStore } from '@/stores/organizations.js' import { organizationsStore } from '@/stores/organizations.js'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils' import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
import { useDebounceFn } from '@vueuse/core' import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const { getOrganization } = organizationsStore() const { getOrganization } = organizationsStore()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const route = useRoute() const route = useRoute()
const showContactModal = ref(false) const showContactModal = ref(false)
const currentContact = computed(() => { const currentContact = computed(() => {
return contacts.data?.data?.find( return contacts.value?.data?.data?.find(
(contact) => contact.name === route.params.contactId (contact) => contact.name === route.params.contactId
) )
}) })
@ -95,53 +68,13 @@ const breadcrumbs = computed(() => {
return items return items
}) })
const currentView = ref({ const contacts = ref({})
label: 'List',
icon: 'list',
})
function getParams() {
const filters = getArgs() || {}
const order_by = getOrderBy() || 'modified desc'
return {
doctype: 'Contact',
filters: filters,
order_by: order_by,
}
}
const contacts = createResource({
url: 'crm.api.doc.get_list_data',
params: getParams(),
auto: true,
})
watch(
() => getOrderBy(),
(value, old_value) => {
if (!value && !old_value) return
contacts.params = getParams()
contacts.reload()
},
{ immediate: true }
)
watch(
storage,
useDebounceFn((value, old_value) => {
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
contacts.params = getParams()
contacts.reload()
}, 300),
{ deep: true }
)
const rows = computed(() => { const rows = computed(() => {
if (!contacts.data?.data) return [] if (!contacts.value?.data?.data) return []
return contacts.data.data.map((contact) => { return contacts.value?.data.data.map((contact) => {
let _rows = {} let _rows = {}
contacts.data.rows.forEach((row) => { contacts.value?.data.rows.forEach((row) => {
_rows[row] = contact[row] _rows[row] = contact[row]
if (row == 'full_name') { if (row == 'full_name') {
@ -165,47 +98,4 @@ const rows = computed(() => {
return _rows return _rows
}) })
}) })
const viewsDropdownOptions = [
{
label: 'List',
icon: 'list',
onClick() {
currentView.value = {
label: 'List',
icon: 'list',
}
},
},
{
label: 'Table',
icon: 'grid',
onClick() {
currentView.value = {
label: 'Table',
icon: 'grid',
}
},
},
{
label: 'Calender',
icon: 'calendar',
onClick() {
currentView.value = {
label: 'Calender',
icon: 'calendar',
}
},
},
{
label: 'Board',
icon: 'columns',
onClick() {
currentView.value = {
label: 'Board',
icon: 'columns',
}
},
},
]
</script> </script>

View File

@ -9,30 +9,7 @@
</Button> </Button>
</template> </template>
</LayoutHeader> </LayoutHeader>
<div class="flex items-center justify-between px-5 pb-4 pt-3"> <ViewControls v-model="deals" doctype="CRM Deal" />
<div class="flex items-center gap-2">
<Dropdown :options="viewsDropdownOptions">
<template #default="{ open }">
<Button :label="currentView.label">
<template #prefix>
<FeatherIcon :name="currentView.icon" class="h-4" />
</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Dropdown>
</div>
<div class="flex items-center gap-2">
<Filter doctype="CRM Deal" />
<SortBy doctype="CRM Deal" />
<ViewSettings doctype="CRM Deal" v-model="deals" />
</div>
</div>
<DealsListView <DealsListView
v-if="deals.data && rows.length" v-if="deals.data && rows.length"
:rows="rows" :rows="rows"
@ -73,15 +50,10 @@ import DealsIcon from '@/components/Icons/DealsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue' import DealsListView from '@/components/ListViews/DealsListView.vue'
import NewDeal from '@/components/NewDeal.vue' import NewDeal from '@/components/NewDeal.vue'
import SortBy from '@/components/SortBy.vue' import ViewControls from '@/components/ViewControls.vue'
import Filter from '@/components/Filter.vue'
import ViewSettings from '@/components/ViewSettings.vue'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { organizationsStore } from '@/stores/organizations' import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses' import { statusesStore } from '@/stores/statuses'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
import { useDebounceFn } from '@vueuse/core'
import { import {
dateFormat, dateFormat,
dateTooltipFormat, dateTooltipFormat,
@ -93,68 +65,29 @@ import {
FeatherIcon, FeatherIcon,
Dialog, Dialog,
Button, Button,
Dropdown,
createResource, createResource,
Breadcrumbs, Breadcrumbs,
} from 'frappe-ui' } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ref, computed, reactive, watch } from 'vue' import { ref, computed, reactive } from 'vue'
const breadcrumbs = [{ label: 'Deals', route: { name: 'Deals' } }] const breadcrumbs = [{ label: 'Deals', route: { name: 'Deals' } }]
const { getUser } = usersStore() const { getUser } = usersStore()
const { getOrganization } = organizationsStore() const { getOrganization } = organizationsStore()
const { getDealStatus } = statusesStore() const { getDealStatus } = statusesStore()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const currentView = ref({ const router = useRouter()
label: 'List',
icon: 'list',
})
function getParams() { // deals data is loaded in the ViewControls component
const filters = getArgs() || {} const deals = ref({})
const order_by = getOrderBy() || 'modified desc'
return {
doctype: 'CRM Deal',
filters: filters,
order_by: order_by,
}
}
const deals = createResource({
url: 'crm.api.doc.get_list_data',
params: getParams(),
auto: true,
})
watch(
() => getOrderBy(),
(value, old_value) => {
if (!value && !old_value) return
deals.params = getParams()
deals.reload()
},
{ immediate: true }
)
watch(
storage,
useDebounceFn((value, old_value) => {
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
deals.params = getParams()
deals.reload()
}, 300),
{ deep: true }
)
// Rows
const rows = computed(() => { const rows = computed(() => {
if (!deals.data?.data) return [] if (!deals.value?.data?.data) return []
return deals.data.data.map((deal) => { return deals.value.data.data.map((deal) => {
let _rows = {} let _rows = {}
deals.data.rows.forEach((row) => { deals.value.data.rows.forEach((row) => {
_rows[row] = deal[row] _rows[row] = deal[row]
let org = getOrganization(deal.organization) let org = getOrganization(deal.organization)
@ -229,49 +162,7 @@ const rows = computed(() => {
}) })
}) })
const viewsDropdownOptions = [ // New Deal
{
label: 'List',
icon: 'list',
onClick() {
currentView.value = {
label: 'List',
icon: 'list',
}
},
},
{
label: 'Table',
icon: 'grid',
onClick() {
currentView.value = {
label: 'Table',
icon: 'grid',
}
},
},
{
label: 'Calender',
icon: 'calendar',
onClick() {
currentView.value = {
label: 'Calender',
icon: 'calendar',
}
},
},
{
label: 'Board',
icon: 'columns',
onClick() {
currentView.value = {
label: 'Board',
icon: 'columns',
}
},
},
]
const showNewDialog = ref(false) const showNewDialog = ref(false)
let newDeal = reactive({ let newDeal = reactive({
@ -294,8 +185,6 @@ const createDeal = createResource({
}, },
}) })
const router = useRouter()
function createNewDeal(close) { function createNewDeal(close) {
createDeal createDeal
.submit(newDeal, { .submit(newDeal, {

View File

@ -9,29 +9,11 @@
</Button> </Button>
</template> </template>
</LayoutHeader> </LayoutHeader>
<div class="flex items-center justify-between px-5 pb-4 pt-3"> <ViewControls
<div class="flex items-center gap-2"> v-model="leads"
<Dropdown :options="viewsDropdownOptions"> doctype="CRM Lead"
<template #default="{ open }"> :filters="{ converted: 0 }"
<Button :label="currentView.label"> />
<template #prefix
><FeatherIcon :name="currentView.icon" class="h-4"
/></template>
<template #suffix
><FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/></template>
</Button>
</template>
</Dropdown>
</div>
<div class="flex items-center gap-2">
<Filter doctype="CRM Lead" />
<SortBy doctype="CRM Lead" />
<ViewSettings doctype="CRM Lead" v-model="leads" />
</div>
</div>
<LeadsListView <LeadsListView
v-if="leads.data && rows.length" v-if="leads.data && rows.length"
:rows="rows" :rows="rows"
@ -72,86 +54,38 @@ import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import LeadsListView from '@/components/ListViews/LeadsListView.vue' import LeadsListView from '@/components/ListViews/LeadsListView.vue'
import NewLead from '@/components/NewLead.vue' import NewLead from '@/components/NewLead.vue'
import SortBy from '@/components/SortBy.vue' import ViewControls from '@/components/ViewControls.vue'
import Filter from '@/components/Filter.vue'
import ViewSettings from '@/components/ViewSettings.vue'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { organizationsStore } from '@/stores/organizations' import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses' import { statusesStore } from '@/stores/statuses'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
import { useDebounceFn } from '@vueuse/core'
import { dateFormat, dateTooltipFormat, timeAgo, formatTime } from '@/utils' import { dateFormat, dateTooltipFormat, timeAgo, formatTime } from '@/utils'
import { import {
FeatherIcon, FeatherIcon,
Dialog, Dialog,
Button, Button,
Dropdown,
createResource, createResource,
Breadcrumbs, Breadcrumbs,
} from 'frappe-ui' } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ref, computed, reactive, watch } from 'vue' import { ref, computed, reactive } from 'vue'
const breadcrumbs = [{ label: 'Leads', route: { name: 'Leads' } }] const breadcrumbs = [{ label: 'Leads', route: { name: 'Leads' } }]
const { getUser } = usersStore() const { getUser } = usersStore()
const { getOrganization } = organizationsStore() const { getOrganization } = organizationsStore()
const { getLeadStatus } = statusesStore() const { getLeadStatus } = statusesStore()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const currentView = ref({ const router = useRouter()
label: 'List',
icon: 'list',
})
function getParams() { // leads data is loaded in the ViewControls component
const filters = { const leads = ref({})
converted: 0,
...(getArgs() || {}),
}
const order_by = getOrderBy() || 'modified desc'
return {
doctype: 'CRM Lead',
filters: filters,
order_by: order_by,
}
}
const leads = createResource({
url: 'crm.api.doc.get_list_data',
params: getParams(),
auto: true,
})
watch(
() => getOrderBy(),
(value, old_value) => {
if (!value && !old_value) return
leads.params = getParams()
leads.reload()
},
{ immediate: true }
)
watch(
storage,
useDebounceFn((value, old_value) => {
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
leads.params = getParams()
leads.reload()
}, 300),
{ deep: true }
)
// Rows
const rows = computed(() => { const rows = computed(() => {
if (!leads.data?.data) return [] if (!leads.value?.data?.data) return []
return leads.data.data.map((lead) => { return leads.value?.data.data.map((lead) => {
let _rows = {} let _rows = {}
leads.data.rows.forEach((row) => { leads.value?.data.rows.forEach((row) => {
_rows[row] = lead[row] _rows[row] = lead[row]
if (row == 'lead_name') { if (row == 'lead_name') {
@ -228,49 +162,7 @@ const rows = computed(() => {
}) })
}) })
const viewsDropdownOptions = [ // New Lead
{
label: 'List',
icon: 'list',
onClick() {
currentView.value = {
label: 'List',
icon: 'list',
}
},
},
{
label: 'Table',
icon: 'grid',
onClick() {
currentView.value = {
label: 'Table',
icon: 'grid',
}
},
},
{
label: 'Calender',
icon: 'calendar',
onClick() {
currentView.value = {
label: 'Calender',
icon: 'calendar',
}
},
},
{
label: 'Board',
icon: 'columns',
onClick() {
currentView.value = {
label: 'Board',
icon: 'columns',
}
},
},
]
const showNewDialog = ref(false) const showNewDialog = ref(false)
let newLead = reactive({ let newLead = reactive({
@ -297,8 +189,6 @@ const createLead = createResource({
}, },
}) })
const router = useRouter()
function createNewLead(close) { function createNewLead(close) {
createLead createLead
.submit(newLead, { .submit(newLead, {

View File

@ -13,36 +13,16 @@
</Button> </Button>
</template> </template>
</LayoutHeader> </LayoutHeader>
<div class="flex items-center justify-between px-5 pb-4 pt-3"> <ViewControls v-model="organizations" doctype="CRM Organization" />
<div class="flex items-center gap-2">
<Dropdown :options="viewsDropdownOptions">
<template #default="{ open }">
<Button :label="currentView.label">
<template #prefix>
<FeatherIcon :name="currentView.icon" class="h-4" />
</template>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Dropdown>
</div>
<div class="flex items-center gap-2">
<Filter doctype="CRM Organization" />
<SortBy doctype="CRM Organization" />
<ViewSettings doctype="CRM Organization" v-model="organizations" />
</div>
</div>
<OrganizationsListView <OrganizationsListView
v-if="organizations.data && rows.length" v-if="organizations.data && rows.length"
:rows="rows" :rows="rows"
:columns="organizations.data.columns" :columns="organizations.data.columns"
/> />
<div v-else-if="organizations.data" class="flex h-full items-center justify-center"> <div
v-else-if="organizations.data"
class="flex h-full items-center justify-center"
>
<div <div
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500" class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
> >
@ -60,30 +40,23 @@ import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue' import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import OrganizationsListView from '@/components/ListViews/OrganizationsListView.vue' import OrganizationsListView from '@/components/ListViews/OrganizationsListView.vue'
import SortBy from '@/components/SortBy.vue' import ViewControls from '@/components/ViewControls.vue'
import Filter from '@/components/Filter.vue' import { FeatherIcon, Breadcrumbs } from 'frappe-ui'
import ViewSettings from '@/components/ViewSettings.vue'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
import { useDebounceFn } from '@vueuse/core'
import { FeatherIcon, Breadcrumbs, Dropdown, createResource } from 'frappe-ui'
import { import {
dateFormat, dateFormat,
dateTooltipFormat, dateTooltipFormat,
timeAgo, timeAgo,
formatNumberIntoCurrency, formatNumberIntoCurrency,
} from '@/utils' } from '@/utils'
import { ref, computed, watch } from 'vue' import { ref, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const showOrganizationModal = ref(false) const showOrganizationModal = ref(false)
const currentOrganization = computed(() => { const currentOrganization = computed(() => {
return organizations.data?.data?.find( return organizations.value?.data?.data?.find(
(organization) => organization.name === route.params.organizationId (organization) => organization.name === route.params.organizationId
) )
}) })
@ -101,53 +74,13 @@ const breadcrumbs = computed(() => {
return items return items
}) })
const currentView = ref({ const organizations = ref({})
label: 'List',
icon: 'list',
})
function getParams() {
const filters = getArgs() || {}
const order_by = getOrderBy() || 'modified desc'
return {
doctype: 'CRM Organization',
filters: filters,
order_by: order_by,
}
}
const organizations = createResource({
url: 'crm.api.doc.get_list_data',
params: getParams(),
auto: true,
})
watch(
() => getOrderBy(),
(value, old_value) => {
if (!value && !old_value) return
organizations.params = getParams()
organizations.reload()
},
{ immediate: true }
)
watch(
storage,
useDebounceFn((value, old_value) => {
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
organizations.params = getParams()
organizations.reload()
}, 300),
{ deep: true }
)
const rows = computed(() => { const rows = computed(() => {
if (!organizations.data?.data) return [] if (!organizations.value?.data?.data) return []
return organizations.data.data.map((organization) => { return organizations.value?.data.data.map((organization) => {
let _rows = {} let _rows = {}
organizations.data.rows.forEach((row) => { organizations.value?.data.rows.forEach((row) => {
_rows[row] = organization[row] _rows[row] = organization[row]
if (row === 'organization_name') { if (row === 'organization_name') {
@ -170,49 +103,6 @@ const rows = computed(() => {
}) })
}) })
const viewsDropdownOptions = [
{
label: 'List',
icon: 'list',
onClick() {
currentView.value = {
label: 'List',
icon: 'list',
}
},
},
{
label: 'Table',
icon: 'grid',
onClick() {
currentView.value = {
label: 'Table',
icon: 'grid',
}
},
},
{
label: 'Calender',
icon: 'calendar',
onClick() {
currentView.value = {
label: 'Calender',
icon: 'calendar',
}
},
},
{
label: 'Board',
icon: 'columns',
onClick() {
currentView.value = {
label: 'Board',
icon: 'columns',
}
},
},
]
function website(url) { function website(url) {
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '') return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
} }

View File

@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { createListResource } from 'frappe-ui'
import { reactive } from 'vue'
export const viewsStore = defineStore('crm-views', () => {
let viewsByName = reactive({})
const views = createListResource({
doctype: 'CRM View Settings',
fields: ['*'],
cache: 'crm-views',
initialData: [],
auto: true,
transform(views) {
for (let view of views) {
viewsByName[view.name] = view
}
return views
},
})
function getView(view) {
if (!view) return null
if (!viewsByName[view]) {
views.reload()
}
return viewsByName[view]
}
return {
views,
getView,
}
})