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",
]
c = get_controller(doctype)
restricted_fields = []
if hasattr(c, "get_non_filterable_fields"):
restricted_fields = c.get_non_filterable_fields()
from_doc_fields = (
frappe.qb.from_(DocField)
.select(
@ -42,6 +47,7 @@ def get_filterable_fields(doctype: str):
.where(DocField.parent == doctype)
.where(DocField.hidden == False)
.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)
)
res = []
@ -50,21 +56,31 @@ def get_filterable_fields(doctype: str):
@frappe.whitelist()
def get_list_data(doctype: str, filters: dict, order_by: str):
columns = [
{"label": "Name", "type": "Data", "key": "name", "width": "16rem"},
{"label": "Last Modified", "type": "Datetime", "key": "modified", "width": "8rem"},
]
rows = ["name"]
def get_list_data(doctype: str, filters: dict, order_by: str, columns=None , rows=None, custom_view_name=None):
custom_view = False
filters = frappe._dict(filters)
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):
list_view_settings = frappe.get_doc("CRM List View Settings", doctype)
if not columns:
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)
rows = frappe.parse_json(list_view_settings.rows)
is_default = False
else:
elif not custom_view or is_default:
list = get_controller(doctype)
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:
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 {
"data": data,
"columns": columns,
"rows": rows,
"fields": fields,
"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):
not_allowed_fieldtypes = [

View File

@ -196,6 +196,10 @@ class CRMLead(Document):
{ "label": 'Mobile no', "value": 'mobile_no' },
]
@staticmethod
def get_non_filterable_fields():
return ["converted"]
@staticmethod
def default_list_data():
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
// For license information, please see license.txt
// frappe.ui.form.on("CRM List View Settings", {
// frappe.ui.form.on("CRM View Settings", {
// 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
class TestCRMListViewSettings(FrappeTestCase):
class TestCRMViewSettings(FrappeTestCase):
pass

View File

@ -88,17 +88,17 @@ watchDebounced(
val = val || ''
if (text.value === val) return
text.value = val
options.update({
params: {
txt: val,
doctype: props.doctype,
},
})
options.reload()
reload(val)
},
{ debounce: 300, immediate: true }
)
watchDebounced(
() => props.doctype,
() => reload(''),
{ debounce: 300, immediate: true }
)
const options = createResource({
url: 'frappe.desk.search.search_link',
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(() => {
return [
{

View File

@ -3,27 +3,27 @@
<template #target>
<Button label="Filter">
<template #prefix><FilterIcon class="h-4" /></template>
<template v-if="storage.size" #suffix>
<template v-if="filters?.size" #suffix>
<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>
</template>
</Button>
</template>
<template #body="{ close }">
<div class="rounded-lg border border-gray-100 bg-white shadow-xl my-2">
<div class="p-2 min-w-[400px]">
<div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
<div class="min-w-[400px] p-2">
<div
v-if="storage.size"
v-for="(f, i) in storage"
v-if="filters?.size"
v-for="(f, i) in filters"
:key="i"
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="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' }}
</div>
<div id="fieldname" class="!min-w-[140px]">
@ -37,23 +37,26 @@
<div id="operator">
<FormControl
type="select"
v-model="f.operator"
:value="f.operator"
@change="(e) => updateOperator(e, f)"
:options="getOperators(f.field.fieldtype)"
placeholder="Operator"
/>
</div>
<div id="value" class="!min-w-[140px]">
<SearchComplete
<Link
v-if="typeLink.includes(f.field.fieldtype)"
:doctype="f.field.options"
class="form-control"
:value="f.value"
@change="(v) => (f.value = v.value)"
:doctype="f.field.options"
@change="(v) => updateValue(v, f)"
placeholder="Value"
/>
<component
v-else
:is="getValSelect(f.field.fieldtype, f.field.options)"
v-model="f.value"
:value="f.value"
@change="(e) => updateValue(e.target.value, f)"
placeholder="Value"
/>
</div>
@ -62,7 +65,7 @@
</div>
<div
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
</div>
@ -87,7 +90,7 @@
</template>
</Autocomplete>
<Button
v-if="storage.size"
v-if="filters?.size"
class="!text-gray-600"
variant="ghost"
label="Clear all Filter"
@ -102,16 +105,20 @@
<script setup>
import NestedPopover from '@/components/NestedPopover.vue'
import FilterIcon from '@/components/Icons/FilterIcon.vue'
import SearchComplete from '@/components/SearchComplete.vue'
import { useDebounceFn } from '@vueuse/core'
import { useFilter } from '@/composables/filter'
import Link from '@/components/Controls/Link.vue'
import {
FeatherIcon,
Autocomplete,
FormControl,
createResource,
} 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({
doctype: {
@ -120,6 +127,10 @@ const props = defineProps({
},
})
const emit = defineEmits(['update'])
const list = defineModel()
const filterableFields = createResource({
url: 'crm.api.doc.get_filterable_fields',
auto: true,
@ -139,18 +150,49 @@ const filterableFields = createResource({
},
})
const { apply, storage } = useFilter(() => filterableFields.data)
const typeCheck = ['Check']
const typeLink = ['Link']
const typeNumber = ['Float', 'Int']
const typeSelect = ['Select']
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
const filters = computed(() => {
if (!list.value?.data) return new Set()
let allFilters = list.value?.params?.filters
if (!allFilters || !filterableFields.data) return new Set()
// remove default filters
if (list.value.data._defaultFilters) {
allFilters = removeCommonFilters(list.value.data._defaultFilters, allFilters)
}
return convertFilters(filterableFields.data, allFilters)
})
watch(
storage,
useDebounceFn(() => apply(), 300),
{ deep: true }
)
function removeCommonFilters(commonFilters, allFilters) {
for (const key in commonFilters) {
if (commonFilters.hasOwnProperty(key) && allFilters.hasOwnProperty(key)) {
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) {
let options = []
@ -231,7 +273,7 @@ function getSelectOptions(options) {
}
function setfilter(data) {
storage.value.add({
filters.value.add({
field: {
label: data.label,
fieldname: data.value,
@ -242,11 +284,12 @@ function setfilter(data) {
operator: getDefaultOperator(data.fieldtype),
value: getDefaultValue(data),
})
apply()
}
function updateFilter(data, index) {
storage.value.delete(Array.from(storage.value)[index])
storage.value.add({
filters.value.delete(Array.from(filters.value)[index])
filters.value.add({
fieldname: data.value,
operator: getDefaultOperator(data.fieldtype),
value: getDefaultValue(data),
@ -257,14 +300,93 @@ function updateFilter(data, index) {
options: data.options,
},
})
apply()
}
function removeFilter(index) {
storage.value.delete(Array.from(storage.value)[index])
filters.value.delete(Array.from(filters.value)[index])
apply()
}
function clearfilter(close) {
storage.value.clear()
filters.value.clear()
apply()
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>

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>
<Button label="Sort" ref="sortButtonRef">
<template #prefix><SortIcon class="h-4" /></template>
<template v-if="sortValues.length" #suffix>
<template v-if="sortValues?.size" #suffix>
<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>
</template>
</Button>
</template>
<template #body="{ close }">
<div class="rounded-lg border border-gray-100 bg-white shadow-xl my-2">
<div class="p-2 min-w-[352px]">
<div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
<div class="min-w-[352px] p-2">
<div
v-if="sortValues.length"
v-if="sortValues?.size"
id="sort-list"
class="flex flex-col gap-2 mb-3"
class="mb-3 flex flex-col gap-2"
>
<div
v-for="(sort, i) in sortValues"
:key="sort.fieldname"
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" />
</div>
<Autocomplete
@ -38,11 +38,17 @@
<FormControl
class="!w-32"
type="select"
v-model="sort.direction"
:value="sort.direction"
:options="[
{ label: 'Ascending', value: 'asc' },
{ label: 'Descending', value: 'desc' },
]"
@change="
(e) => {
sort.direction = e.target.value
apply()
}
"
placeholder="Sort by"
/>
<Button variant="ghost" icon="x" @click="removeSort(i)" />
@ -50,7 +56,7 @@
</div>
<div
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
</div>
@ -75,7 +81,7 @@
</template>
</Autocomplete>
<Button
v-if="sortValues.length"
v-if="sortValues?.size"
class="!text-gray-600"
variant="ghost"
label="Clear Sort"
@ -93,7 +99,6 @@ import NestedPopover from '@/components/NestedPopover.vue'
import SortIcon from '@/components/Icons/SortIcon.vue'
import DragIcon from '@/components/Icons/DragIcon.vue'
import { useSortable } from '@vueuse/integrations/useSortable'
import { useOrderBy } from '@/composables/orderby'
import {
FeatherIcon,
Button,
@ -101,7 +106,7 @@ import {
FormControl,
createResource,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { computed, ref, nextTick } from 'vue'
const props = defineProps({
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 sortValues = ref(initialOrderBy())
const sortOptions = createResource({
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(() => {
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 !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, {
handle: '.handle',
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) {
sortValues.value = [
...sortValues.value,
{ fieldname: data.value, direction: 'asc' },
]
sortSortable.start()
sortValues.value.add({ fieldname: data.value, direction: 'asc' })
restartSort()
apply()
}
function updateSort(data, index) {
sortValues.value[index] = {
let oldSort = Array.from(sortValues.value)[index]
sortValues.value.delete(oldSort)
sortValues.value.add({
fieldname: data.value,
direction: sortValues.value[index].direction,
}
direction: oldSort.direction,
})
apply()
}
function removeSort(index) {
sortValues.value.splice(index, 1)
sortValues.value.delete(Array.from(sortValues.value)[index])
apply()
}
function clearSort(close) {
sortValues.value = []
sortValues.value.clear()
apply()
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>

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>
</Button>
</template>
<template #body>
<template #body="{ close }">
<div
class="my-2 rounded-lg border border-gray-100 bg-white p-1.5 shadow-xl"
>
<div v-if="!edit">
<Draggable
:list="columns"
@end="updateColumnDetails"
@end="apply"
item-key="key"
class="list-group"
>
@ -64,11 +64,22 @@
</Button>
</template>
</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
v-if="!is_default"
class="w-full !justify-start !text-gray-600"
variant="ghost"
@click="resetToDefault"
@click="resetToDefault(close)"
label="Reset to Default"
>
<template #prefix>
@ -131,7 +142,8 @@ import NestedPopover from '@/components/NestedPopover.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import Draggable from 'vuedraggable'
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({
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 edit = ref(false)
const column = ref({
@ -179,7 +200,7 @@ const fields = computed(() => {
})
})
async function addColumn(c) {
function addColumn(c) {
let _column = {
label: c.label,
type: c.type,
@ -188,8 +209,7 @@ async function addColumn(c) {
}
columns.value.push(_column)
rows.value.push(c.value)
await updateColumnDetails()
list.value.reload()
apply(true)
}
function removeColumn(c) {
@ -197,7 +217,7 @@ function removeColumn(c) {
if (c.key !== 'name') {
rows.value = rows.value.filter((row) => row !== c.key)
}
updateColumnDetails()
apply()
}
function editColumn(c) {
@ -215,7 +235,7 @@ function updateColumn(c) {
if (columns.value[index].old) {
delete columns.value[index].old
}
updateColumnDetails()
apply()
}
function cancelUpdate() {
@ -225,25 +245,43 @@ function cancelUpdate() {
delete column.value.old
}
async function updateColumnDetails() {
is_default.value = false
await call(
'crm.fcrm.doctype.crm_list_view_settings.crm_list_view_settings.update',
{
doctype: props.doctype,
columns: columns.value,
rows: rows.value,
}
)
function reset(close) {
apply(true, false, true)
close()
}
async function resetToDefault() {
await call(
'crm.fcrm.doctype.crm_list_view_settings.crm_list_view_settings.reset_to_default',
{
doctype: props.doctype,
}
)
list.value.reload()
function resetToDefault(close) {
apply(true, true)
close()
}
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>

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

View File

@ -9,36 +9,16 @@
</Button>
</template>
</LayoutHeader>
<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">
<Filter doctype="Contact" />
<SortBy doctype="Contact" />
<ViewSettings doctype="Contact" v-model="contacts" />
</div>
</div>
<ViewControls v-model="contacts" doctype="Contact" />
<ContactsListView
v-if="contacts.data && rows.length"
:rows="rows"
: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
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 ContactModal from '@/components/Modals/ContactModal.vue'
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue'
import ViewSettings from '@/components/ViewSettings.vue'
import { FeatherIcon, Breadcrumbs, Dropdown, createResource } from 'frappe-ui'
import ViewControls from '@/components/ViewControls.vue'
import { FeatherIcon, Breadcrumbs } from 'frappe-ui'
import { organizationsStore } from '@/stores/organizations.js'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
import { useDebounceFn } from '@vueuse/core'
import { ref, computed, watch } from 'vue'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const { getOrganization } = organizationsStore()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const route = useRoute()
const showContactModal = ref(false)
const currentContact = computed(() => {
return contacts.data?.data?.find(
return contacts.value?.data?.data?.find(
(contact) => contact.name === route.params.contactId
)
})
@ -95,53 +68,13 @@ const breadcrumbs = computed(() => {
return items
})
const currentView = 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 contacts = ref({})
const rows = computed(() => {
if (!contacts.data?.data) return []
return contacts.data.data.map((contact) => {
if (!contacts.value?.data?.data) return []
return contacts.value?.data.data.map((contact) => {
let _rows = {}
contacts.data.rows.forEach((row) => {
contacts.value?.data.rows.forEach((row) => {
_rows[row] = contact[row]
if (row == 'full_name') {
@ -165,47 +98,4 @@ const rows = computed(() => {
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>

View File

@ -9,30 +9,7 @@
</Button>
</template>
</LayoutHeader>
<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">
<Filter doctype="CRM Deal" />
<SortBy doctype="CRM Deal" />
<ViewSettings doctype="CRM Deal" v-model="deals" />
</div>
</div>
<ViewControls v-model="deals" doctype="CRM Deal" />
<DealsListView
v-if="deals.data && rows.length"
:rows="rows"
@ -73,15 +50,10 @@ import DealsIcon from '@/components/Icons/DealsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
import NewDeal from '@/components/NewDeal.vue'
import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue'
import ViewSettings from '@/components/ViewSettings.vue'
import ViewControls from '@/components/ViewControls.vue'
import { usersStore } from '@/stores/users'
import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
import { useDebounceFn } from '@vueuse/core'
import {
dateFormat,
dateTooltipFormat,
@ -93,68 +65,29 @@ import {
FeatherIcon,
Dialog,
Button,
Dropdown,
createResource,
Breadcrumbs,
} from 'frappe-ui'
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 { getUser } = usersStore()
const { getOrganization } = organizationsStore()
const { getDealStatus } = statusesStore()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const currentView = ref({
label: 'List',
icon: 'list',
})
const router = useRouter()
function getParams() {
const filters = getArgs() || {}
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 }
)
// deals data is loaded in the ViewControls component
const deals = ref({})
// Rows
const rows = computed(() => {
if (!deals.data?.data) return []
return deals.data.data.map((deal) => {
if (!deals.value?.data?.data) return []
return deals.value.data.data.map((deal) => {
let _rows = {}
deals.data.rows.forEach((row) => {
deals.value.data.rows.forEach((row) => {
_rows[row] = deal[row]
let org = getOrganization(deal.organization)
@ -229,49 +162,7 @@ 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',
}
},
},
]
// New Deal
const showNewDialog = ref(false)
let newDeal = reactive({
@ -294,8 +185,6 @@ const createDeal = createResource({
},
})
const router = useRouter()
function createNewDeal(close) {
createDeal
.submit(newDeal, {

View File

@ -9,29 +9,11 @@
</Button>
</template>
</LayoutHeader>
<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">
<Filter doctype="CRM Lead" />
<SortBy doctype="CRM Lead" />
<ViewSettings doctype="CRM Lead" v-model="leads" />
</div>
</div>
<ViewControls
v-model="leads"
doctype="CRM Lead"
:filters="{ converted: 0 }"
/>
<LeadsListView
v-if="leads.data && rows.length"
:rows="rows"
@ -72,86 +54,38 @@ import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
import NewLead from '@/components/NewLead.vue'
import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue'
import ViewSettings from '@/components/ViewSettings.vue'
import ViewControls from '@/components/ViewControls.vue'
import { usersStore } from '@/stores/users'
import { organizationsStore } from '@/stores/organizations'
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 {
FeatherIcon,
Dialog,
Button,
Dropdown,
createResource,
Breadcrumbs,
} from 'frappe-ui'
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 { getUser } = usersStore()
const { getOrganization } = organizationsStore()
const { getLeadStatus } = statusesStore()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const currentView = ref({
label: 'List',
icon: 'list',
})
const router = useRouter()
function getParams() {
const filters = {
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 }
)
// leads data is loaded in the ViewControls component
const leads = ref({})
// Rows
const rows = computed(() => {
if (!leads.data?.data) return []
return leads.data.data.map((lead) => {
if (!leads.value?.data?.data) return []
return leads.value?.data.data.map((lead) => {
let _rows = {}
leads.data.rows.forEach((row) => {
leads.value?.data.rows.forEach((row) => {
_rows[row] = lead[row]
if (row == 'lead_name') {
@ -228,49 +162,7 @@ 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',
}
},
},
]
// New Lead
const showNewDialog = ref(false)
let newLead = reactive({
@ -297,8 +189,6 @@ const createLead = createResource({
},
})
const router = useRouter()
function createNewLead(close) {
createLead
.submit(newLead, {

View File

@ -13,36 +13,16 @@
</Button>
</template>
</LayoutHeader>
<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">
<Filter doctype="CRM Organization" />
<SortBy doctype="CRM Organization" />
<ViewSettings doctype="CRM Organization" v-model="organizations" />
</div>
</div>
<ViewControls v-model="organizations" doctype="CRM Organization" />
<OrganizationsListView
v-if="organizations.data && rows.length"
:rows="rows"
: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
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 OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import OrganizationsListView from '@/components/ListViews/OrganizationsListView.vue'
import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue'
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 ViewControls from '@/components/ViewControls.vue'
import { FeatherIcon, Breadcrumbs } from 'frappe-ui'
import {
dateFormat,
dateTooltipFormat,
timeAgo,
formatNumberIntoCurrency,
} from '@/utils'
import { ref, computed, watch } from 'vue'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const { get: getOrderBy } = useOrderBy()
const { getArgs, storage } = useFilter()
const showOrganizationModal = ref(false)
const currentOrganization = computed(() => {
return organizations.data?.data?.find(
return organizations.value?.data?.data?.find(
(organization) => organization.name === route.params.organizationId
)
})
@ -101,53 +74,13 @@ const breadcrumbs = computed(() => {
return items
})
const currentView = 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 organizations = ref({})
const rows = computed(() => {
if (!organizations.data?.data) return []
return organizations.data.data.map((organization) => {
if (!organizations.value?.data?.data) return []
return organizations.value?.data.data.map((organization) => {
let _rows = {}
organizations.data.rows.forEach((row) => {
organizations.value?.data.rows.forEach((row) => {
_rows[row] = organization[row]
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) {
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,
}
})