Merge pull request #209 from shariquerik/groupby-view
feat: Groupby view
This commit is contained in:
commit
c41a269962
101
crm/api/doc.py
101
crm/api/doc.py
@ -108,6 +108,54 @@ def get_filterable_fields(doctype: str):
|
||||
|
||||
return res
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_group_by_fields(doctype: str):
|
||||
allowed_fieldtypes = [
|
||||
"Check",
|
||||
"Data",
|
||||
"Float",
|
||||
"Int",
|
||||
"Currency",
|
||||
"Dynamic Link",
|
||||
"Link",
|
||||
"Select",
|
||||
"Duration",
|
||||
"Date",
|
||||
"Datetime",
|
||||
]
|
||||
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldtype not in no_value_fields and field.fieldtype in allowed_fieldtypes]
|
||||
fields = [
|
||||
{
|
||||
"label": _(field.label),
|
||||
"value": field.fieldname,
|
||||
}
|
||||
for field in fields
|
||||
if field.label and field.fieldname
|
||||
]
|
||||
|
||||
standard_fields = [
|
||||
{"label": "Name", "value": "name"},
|
||||
{"label": "Created On", "value": "creation"},
|
||||
{"label": "Last Modified", "value": "modified"},
|
||||
{"label": "Modified By", "value": "modified_by"},
|
||||
{"label": "Owner", "value": "owner"},
|
||||
{"label": "Liked By", "value": "_liked_by"},
|
||||
{"label": "Assigned To", "value": "_assign"},
|
||||
{"label": "Comments", "value": "_comments"},
|
||||
{"label": "Created On", "value": "creation"},
|
||||
{"label": "Modified On", "value": "modified"},
|
||||
]
|
||||
|
||||
for field in standard_fields:
|
||||
field["label"] = _(field["label"])
|
||||
fields.append(field)
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def get_fields_meta(DocField, doctype, allowed_fieldtypes, restricted_fields):
|
||||
parent = "parent" if DocField._table_name == "tabDocField" else "dt"
|
||||
return (
|
||||
@ -159,12 +207,16 @@ def get_list_data(
|
||||
page_length_count=20,
|
||||
columns=None,
|
||||
rows=None,
|
||||
custom_view_name=None,
|
||||
view=None,
|
||||
default_filters=None,
|
||||
):
|
||||
custom_view = False
|
||||
filters = frappe._dict(filters)
|
||||
|
||||
custom_view_name = view.get('custom_view_name') if view else None
|
||||
view_type = view.get('view_type') if view else None
|
||||
group_by_field = view.get('group_by_field') if view else None
|
||||
|
||||
for key in filters:
|
||||
value = filters[key]
|
||||
if isinstance(value, list):
|
||||
@ -197,8 +249,15 @@ def get_list_data(
|
||||
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)
|
||||
default_view_filters = {
|
||||
"dt": doctype,
|
||||
"type": view_type or 'list',
|
||||
"is_default": 1,
|
||||
"user": frappe.session.user,
|
||||
}
|
||||
|
||||
if not custom_view and frappe.db.exists("CRM View Settings", default_view_filters):
|
||||
list_view_settings = frappe.get_doc("CRM View Settings", default_view_filters)
|
||||
columns = frappe.parse_json(list_view_settings.columns)
|
||||
rows = frappe.parse_json(list_view_settings.rows)
|
||||
is_default = False
|
||||
@ -218,6 +277,10 @@ def get_list_data(
|
||||
if column.get("key") == "_liked_by" and column.get("width") == "10rem":
|
||||
column["width"] = "50px"
|
||||
|
||||
# check if rows has group_by_field if not add it
|
||||
if group_by_field and group_by_field not in rows:
|
||||
rows.append(group_by_field)
|
||||
|
||||
data = frappe.get_list(
|
||||
doctype,
|
||||
fields=rows,
|
||||
@ -264,11 +327,43 @@ def get_list_data(
|
||||
if not is_default and custom_view_name:
|
||||
is_default = frappe.db.get_value("CRM View Settings", custom_view_name, "load_default_columns")
|
||||
|
||||
if group_by_field and view_type == "group_by":
|
||||
def get_options(type, options):
|
||||
if type == "Select":
|
||||
return [option for option in options.split("\n")]
|
||||
else:
|
||||
has_empty_values = any([not d.get(group_by_field) for d in data])
|
||||
options = list(set([d.get(group_by_field) for d in data]))
|
||||
options = [u for u in options if u]
|
||||
if has_empty_values:
|
||||
options.append("")
|
||||
|
||||
if order_by and group_by_field in order_by:
|
||||
order_by_fields = order_by.split(",")
|
||||
order_by_fields = [(field.split(" ")[0], field.split(" ")[1]) for field in order_by_fields]
|
||||
if (group_by_field, "asc") in order_by_fields:
|
||||
options.sort()
|
||||
elif (group_by_field, "desc") in order_by_fields:
|
||||
options.sort(reverse=True)
|
||||
else:
|
||||
options.sort()
|
||||
return options
|
||||
|
||||
for field in fields:
|
||||
if field.get("value") == group_by_field:
|
||||
group_by_field = {
|
||||
"label": field.get("label"),
|
||||
"name": field.get("value"),
|
||||
"type": field.get("type"),
|
||||
"options": get_options(field.get("type"), field.get("options")),
|
||||
}
|
||||
|
||||
return {
|
||||
"data": data,
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"fields": fields,
|
||||
"group_by_field": group_by_field,
|
||||
"page_length": page_length,
|
||||
"page_length_count": page_length_count,
|
||||
"is_default": is_default,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"user",
|
||||
"is_default",
|
||||
"column_break_zacm",
|
||||
"type",
|
||||
"dt",
|
||||
"route_name",
|
||||
"pinned",
|
||||
@ -21,7 +22,9 @@
|
||||
"filters_tab",
|
||||
"filters",
|
||||
"order_by_tab",
|
||||
"order_by"
|
||||
"order_by",
|
||||
"group_by_tab",
|
||||
"group_by_field"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -117,11 +120,28 @@
|
||||
"fieldname": "icon",
|
||||
"fieldtype": "Data",
|
||||
"label": "Icon"
|
||||
},
|
||||
{
|
||||
"default": "list",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Type",
|
||||
"options": "list\ngroup_by"
|
||||
},
|
||||
{
|
||||
"fieldname": "group_by_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Group By"
|
||||
},
|
||||
{
|
||||
"fieldname": "group_by_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Group By Field"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-20 17:24:18.662389",
|
||||
"modified": "2024-06-01 16:58:34.952945",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM View Settings",
|
||||
|
||||
@ -27,6 +27,7 @@ def create(view):
|
||||
doc = frappe.new_doc("CRM View Settings")
|
||||
doc.name = view.label
|
||||
doc.label = view.label
|
||||
doc.type = view.type or 'list'
|
||||
doc.icon = view.icon
|
||||
doc.dt = view.doctype
|
||||
doc.user = frappe.session.user
|
||||
@ -34,6 +35,7 @@ def create(view):
|
||||
doc.load_default_columns = view.load_default_columns or False
|
||||
doc.filters = json.dumps(view.filters)
|
||||
doc.order_by = view.order_by
|
||||
doc.group_by_field = view.group_by_field
|
||||
doc.columns = json.dumps(view.columns)
|
||||
doc.rows = json.dumps(view.rows)
|
||||
doc.insert()
|
||||
@ -53,11 +55,13 @@ def update(view):
|
||||
|
||||
doc = frappe.get_doc("CRM View Settings", view.name)
|
||||
doc.label = view.label
|
||||
doc.type = view.type or 'list'
|
||||
doc.icon = view.icon
|
||||
doc.route_name = view.route_name or ""
|
||||
doc.load_default_columns = view.load_default_columns or False
|
||||
doc.filters = json.dumps(filters)
|
||||
doc.order_by = view.order_by
|
||||
doc.group_by_field = view.group_by_field
|
||||
doc.columns = json.dumps(columns)
|
||||
doc.rows = json.dumps(rows)
|
||||
doc.save()
|
||||
@ -123,28 +127,38 @@ def create_or_update_default_view(view):
|
||||
|
||||
doc = frappe.db.exists(
|
||||
"CRM View Settings",
|
||||
{"dt": view.doctype, "is_default": True, "user": frappe.session.user},
|
||||
{
|
||||
"dt": view.doctype,
|
||||
"type": view.type or 'list',
|
||||
"is_default": True,
|
||||
"user": frappe.session.user
|
||||
},
|
||||
)
|
||||
if doc:
|
||||
doc = frappe.get_doc("CRM View Settings", doc)
|
||||
doc.label = view.label
|
||||
doc.type = view.type or 'list'
|
||||
doc.route_name = view.route_name or ""
|
||||
doc.load_default_columns = view.load_default_columns or False
|
||||
doc.filters = json.dumps(filters)
|
||||
doc.order_by = view.order_by
|
||||
doc.group_by_field = view.group_by_field
|
||||
doc.columns = json.dumps(columns)
|
||||
doc.rows = json.dumps(rows)
|
||||
doc.save()
|
||||
else:
|
||||
doc = frappe.new_doc("CRM View Settings")
|
||||
doc.name = view.label or 'List View'
|
||||
doc.label = view.label or 'List View'
|
||||
label = 'Group By View' if view.type == 'group_by' else 'List View'
|
||||
doc.name = view.label or label
|
||||
doc.label = view.label or label
|
||||
doc.type = view.type or 'list'
|
||||
doc.dt = view.doctype
|
||||
doc.user = frappe.session.user
|
||||
doc.route_name = view.route_name or ""
|
||||
doc.load_default_columns = view.load_default_columns or False
|
||||
doc.filters = json.dumps(filters)
|
||||
doc.order_by = view.order_by
|
||||
doc.group_by_field = view.group_by_field
|
||||
doc.columns = json.dumps(columns)
|
||||
doc.rows = json.dumps(rows)
|
||||
doc.is_default = True
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 1394a12b6de105649c8ca5beeead62a38ef1b18e
|
||||
Subproject commit 6fd62697e6f35f1fbf1daa12a6573dc0a73a02b0
|
||||
@ -13,7 +13,7 @@
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.55",
|
||||
"frappe-ui": "^0.1.59",
|
||||
"gemoji": "^8.1.0",
|
||||
"mime": "^4.0.1",
|
||||
"pinia": "^2.0.33",
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
<NestedPopover>
|
||||
<template #target>
|
||||
<Button :label="__('Columns')">
|
||||
<template #prefix>
|
||||
<template v-if="hideLabel">
|
||||
<ColumnsIcon class="h-4" />
|
||||
</template>
|
||||
<template v-if="!hideLabel" #prefix>
|
||||
<ColumnsIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
@ -108,7 +111,11 @@
|
||||
class="w-full"
|
||||
v-model="column.width"
|
||||
placeholder="10rem"
|
||||
:description="__('Width can be in number, pixel or rem (eg. 3, 30px, 10rem)')"
|
||||
:description="
|
||||
__(
|
||||
'Width can be in number, pixel or rem (eg. 3, 30px, 10rem)'
|
||||
)
|
||||
"
|
||||
:debounce="500"
|
||||
/>
|
||||
</div>
|
||||
@ -149,6 +156,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hideLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
77
frontend/src/components/GroupBy.vue
Normal file
77
frontend/src/components/GroupBy.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<Autocomplete :options="options" value="" @change="(e) => setGroupBy(e)">
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
:label="
|
||||
hideLabel
|
||||
? groupByValue?.label
|
||||
: __('Group By: ') + groupByValue?.label
|
||||
"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<template #prefix>
|
||||
<DetailsIcon />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</template>
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hideLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const list = defineModel()
|
||||
|
||||
const groupByValue = ref({
|
||||
label: '',
|
||||
value: '',
|
||||
})
|
||||
|
||||
const groupByOptions = createResource({
|
||||
url: 'crm.api.doc.get_group_by_fields',
|
||||
cache: ['groupByOptions', props.doctype],
|
||||
params: {
|
||||
doctype: props.doctype,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (groupByOptions.data?.length) return
|
||||
groupByOptions.fetch()
|
||||
})
|
||||
|
||||
function setGroupBy(data) {
|
||||
groupByValue.value = data
|
||||
nextTick(() => emit('update', data.value))
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
if (!groupByOptions.data) return []
|
||||
if (!list.value?.data?.group_by_field) return groupByOptions.data
|
||||
groupByValue.value = list.value.data.group_by_field
|
||||
return groupByOptions.data.filter(
|
||||
(option) => option !== groupByValue.value.value
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@ -74,7 +74,7 @@
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isMobileView" class="m-2 flex flex-col gap-1">
|
||||
<div class="m-2 flex flex-col gap-1">
|
||||
<SidebarLink
|
||||
:label="isSidebarCollapsed ? __('Expand') : __('Collapse')"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
@ -116,7 +116,6 @@ import { notificationsStore } from '@/stores/notifications'
|
||||
import { FeatherIcon } from 'frappe-ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, h } from 'vue'
|
||||
import { isMobileView } from '@/stores/settings'
|
||||
|
||||
const { getPinnedViews, getPublicViews } = viewsStore()
|
||||
const { toggle: toggleNotificationPanel } = notificationsStore()
|
||||
@ -200,6 +199,7 @@ function parseView(views) {
|
||||
icon: getIcon(view.route_name, view.icon),
|
||||
to: {
|
||||
name: view.route_name,
|
||||
params: { viewType: view.type || 'list' },
|
||||
query: { view: view.name },
|
||||
},
|
||||
}
|
||||
|
||||
@ -29,18 +29,74 @@
|
||||
</Button>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows id="list-rows">
|
||||
<ListRow
|
||||
class="mx-5"
|
||||
v-for="row in rows"
|
||||
:key="row.name"
|
||||
v-slot="{ idx, column, item }"
|
||||
:row="row"
|
||||
>
|
||||
<div v-if="column.key === '_assign'" class="flex items-center">
|
||||
<MultipleAvatar
|
||||
:avatars="item"
|
||||
size="sm"
|
||||
<ListRows :rows="rows" v-slot="{ idx, column, item }">
|
||||
<div v-if="column.key === '_assign'" class="flex items-center">
|
||||
<MultipleAvatar
|
||||
:avatars="item"
|
||||
size="sm"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ListRowItem v-else :item="item">
|
||||
<template #prefix>
|
||||
<div v-if="column.key === 'status'">
|
||||
<IndicatorIcon :class="item.color" />
|
||||
</div>
|
||||
<div v-else-if="column.key === 'organization'">
|
||||
<Avatar
|
||||
v-if="item.label"
|
||||
class="flex items-center"
|
||||
:image="item.logo"
|
||||
:label="item.label"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'deal_owner'">
|
||||
<Avatar
|
||||
v-if="item.full_name"
|
||||
class="flex items-center"
|
||||
:image="item.user_image"
|
||||
:label="item.full_name"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'mobile_no'">
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
</div>
|
||||
<div v-else-if="column.key === '_liked_by'">
|
||||
<Button
|
||||
v-if="column.key == '_liked_by'"
|
||||
variant="ghosted"
|
||||
:class="isLiked(item) ? 'fill-red-500' : 'fill-white'"
|
||||
@click.stop.prevent="
|
||||
() => emit('likeDoc', { name: row.name, liked: isLiked(item) })
|
||||
"
|
||||
>
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ label }">
|
||||
<div
|
||||
v-if="
|
||||
[
|
||||
'modified',
|
||||
'creation',
|
||||
'first_response_time',
|
||||
'first_responded_on',
|
||||
'response_by',
|
||||
].includes(column.key)
|
||||
"
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
@ -51,60 +107,21 @@
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ListRowItem v-else :item="item">
|
||||
<template #prefix>
|
||||
<div v-if="column.key === 'status'">
|
||||
<IndicatorIcon :class="item.color" />
|
||||
</div>
|
||||
<div v-else-if="column.key === 'organization'">
|
||||
<Avatar
|
||||
v-if="item.label"
|
||||
class="flex items-center"
|
||||
:image="item.logo"
|
||||
:label="item.label"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'deal_owner'">
|
||||
<Avatar
|
||||
v-if="item.full_name"
|
||||
class="flex items-center"
|
||||
:image="item.user_image"
|
||||
:label="item.full_name"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'mobile_no'">
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
</div>
|
||||
<div v-else-if="column.key === '_liked_by'">
|
||||
<Button
|
||||
v-if="column.key == '_liked_by'"
|
||||
variant="ghosted"
|
||||
:class="isLiked(item) ? 'fill-red-500' : 'fill-white'"
|
||||
@click.stop.prevent="
|
||||
() =>
|
||||
emit('likeDoc', { name: row.name, liked: isLiked(item) })
|
||||
"
|
||||
>
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ label }">
|
||||
<div
|
||||
v-if="
|
||||
[
|
||||
'modified',
|
||||
'creation',
|
||||
'first_response_time',
|
||||
'first_responded_on',
|
||||
'response_by',
|
||||
].includes(column.key)
|
||||
"
|
||||
class="truncate text-base"
|
||||
>
|
||||
<Tooltip :text="item.label">
|
||||
<div>{{ item.timeAgo }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key === 'sla_status'"
|
||||
class="truncate text-base"
|
||||
>
|
||||
<Badge
|
||||
v-if="item.value"
|
||||
:variant="'subtle'"
|
||||
:theme="item.color"
|
||||
size="md"
|
||||
:label="item.value"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
@ -115,60 +132,34 @@
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
<Tooltip :text="item.label">
|
||||
<div>{{ item.timeAgo }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key === 'sla_status'"
|
||||
class="truncate text-base"
|
||||
>
|
||||
<Badge
|
||||
v-if="item.value"
|
||||
:variant="'subtle'"
|
||||
:theme="item.color"
|
||||
size="md"
|
||||
:label="item.value"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.type === 'Check'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:modelValue="item"
|
||||
:disabled="true"
|
||||
class="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.type === 'Check'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:modelValue="item"
|
||||
:disabled="true"
|
||||
class="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
</ListRowItem>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ selections, unselectAll }">
|
||||
@ -199,13 +190,12 @@ import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import ListBulkActions from '@/components/ListBulkActions.vue'
|
||||
import ListRows from '@/components/ListViews/ListRows.vue'
|
||||
import {
|
||||
Avatar,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
ListFooter,
|
||||
|
||||
@ -29,18 +29,71 @@
|
||||
</Button>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows id="list-rows">
|
||||
<ListRow
|
||||
class="mx-5"
|
||||
v-for="row in rows"
|
||||
:key="row.name"
|
||||
v-slot="{ idx, column, item }"
|
||||
:row="row"
|
||||
>
|
||||
<div v-if="column.key === '_assign'" class="flex items-center">
|
||||
<MultipleAvatar
|
||||
:avatars="item"
|
||||
size="sm"
|
||||
<ListRows :rows="rows" v-slot="{ idx, column, item }">
|
||||
<div v-if="column.key === '_assign'" class="flex items-center">
|
||||
<MultipleAvatar
|
||||
:avatars="item"
|
||||
size="sm"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ListRowItem v-else :item="item">
|
||||
<template #prefix>
|
||||
<div v-if="column.key === 'status'">
|
||||
<IndicatorIcon :class="item.color" />
|
||||
</div>
|
||||
<div v-else-if="column.key === 'lead_name'">
|
||||
<Avatar
|
||||
v-if="item.label"
|
||||
class="flex items-center"
|
||||
:image="item.image"
|
||||
:label="item.image_label"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'organization'">
|
||||
<Avatar
|
||||
v-if="item.label"
|
||||
class="flex items-center"
|
||||
:image="item.logo"
|
||||
:label="item.label"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'lead_owner'">
|
||||
<Avatar
|
||||
v-if="item.full_name"
|
||||
class="flex items-center"
|
||||
:image="item.user_image"
|
||||
:label="item.full_name"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'mobile_no'">
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ label }">
|
||||
<div
|
||||
v-if="
|
||||
[
|
||||
'modified',
|
||||
'creation',
|
||||
'first_response_time',
|
||||
'first_responded_on',
|
||||
'response_by',
|
||||
].includes(column.key)
|
||||
"
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
@ -51,56 +104,37 @@
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ListRowItem v-else :item="item">
|
||||
<template #prefix>
|
||||
<div v-if="column.key === 'status'">
|
||||
<IndicatorIcon :class="item.color" />
|
||||
</div>
|
||||
<div v-else-if="column.key === 'lead_name'">
|
||||
<Avatar
|
||||
v-if="item.label"
|
||||
class="flex items-center"
|
||||
:image="item.image"
|
||||
:label="item.image_label"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'organization'">
|
||||
<Avatar
|
||||
v-if="item.label"
|
||||
class="flex items-center"
|
||||
:image="item.logo"
|
||||
:label="item.label"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'lead_owner'">
|
||||
<Avatar
|
||||
v-if="item.full_name"
|
||||
class="flex items-center"
|
||||
:image="item.user_image"
|
||||
:label="item.full_name"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'mobile_no'">
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ label }">
|
||||
<div
|
||||
v-if="
|
||||
[
|
||||
'modified',
|
||||
'creation',
|
||||
'first_response_time',
|
||||
'first_responded_on',
|
||||
'response_by',
|
||||
].includes(column.key)
|
||||
>
|
||||
<Tooltip :text="item.label">
|
||||
<div>{{ item.timeAgo }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else-if="column.key === '_liked_by'">
|
||||
<Button
|
||||
v-if="column.key == '_liked_by'"
|
||||
variant="ghosted"
|
||||
:class="isLiked(item) ? 'fill-red-500' : 'fill-white'"
|
||||
@click.stop.prevent="
|
||||
() =>
|
||||
emit('likeDoc', {
|
||||
name: row.name,
|
||||
liked: isLiked(item),
|
||||
})
|
||||
"
|
||||
class="truncate text-base"
|
||||
>
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key === 'sla_status'"
|
||||
class="truncate text-base"
|
||||
>
|
||||
<Badge
|
||||
v-if="item.value"
|
||||
:variant="'subtle'"
|
||||
:theme="item.color"
|
||||
size="md"
|
||||
:label="item.value"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
@ -111,73 +145,34 @@
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
<Tooltip :text="item.label">
|
||||
<div>{{ item.timeAgo }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else-if="column.key === '_liked_by'">
|
||||
<Button
|
||||
v-if="column.key == '_liked_by'"
|
||||
variant="ghosted"
|
||||
:class="isLiked(item) ? 'fill-red-500' : 'fill-white'"
|
||||
@click.stop.prevent="
|
||||
() =>
|
||||
emit('likeDoc', { name: row.name, liked: isLiked(item) })
|
||||
"
|
||||
>
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key === 'sla_status'"
|
||||
class="truncate text-base"
|
||||
>
|
||||
<Badge
|
||||
v-if="item.value"
|
||||
:variant="'subtle'"
|
||||
:theme="item.color"
|
||||
size="md"
|
||||
:label="item.value"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.type === 'Check'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:modelValue="item"
|
||||
:disabled="true"
|
||||
class="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.type === 'Check'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:modelValue="item"
|
||||
:disabled="true"
|
||||
class="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
</ListRowItem>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ selections, unselectAll }">
|
||||
@ -208,13 +203,12 @@ import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||
import ListBulkActions from '@/components/ListBulkActions.vue'
|
||||
import ListRows from '@/components/ListViews/ListRows.vue'
|
||||
import {
|
||||
Avatar,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListSelectBanner,
|
||||
ListRowItem,
|
||||
ListFooter,
|
||||
|
||||
66
frontend/src/components/ListViews/ListRows.vue
Normal file
66
frontend/src/components/ListViews/ListRows.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="mx-5 mt-2 h-full overflow-y-auto" v-if="showGroupedRows">
|
||||
<div v-for="group in reactivieRows" :key="group.group">
|
||||
<ListGroupHeader :group="group">
|
||||
<div
|
||||
class="my-2 flex items-center gap-2 text-base font-medium text-gray-800"
|
||||
>
|
||||
<div>{{ __(group.label) }} -</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<component v-if="group.icon" :is="group.icon" />
|
||||
<div v-if="group.group == ' '" class="text-gray-500">
|
||||
{{ __('Empty') }}
|
||||
</div>
|
||||
<div v-else>{{ group.group }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ListGroupHeader>
|
||||
<ListGroupRows :group="group" id="list-rows">
|
||||
<ListRow
|
||||
v-for="row in group.rows"
|
||||
:key="row.name"
|
||||
v-slot="{ idx, column, item }"
|
||||
:row="row"
|
||||
>
|
||||
<slot v-bind="{ idx, column, item }" />
|
||||
</ListRow>
|
||||
</ListGroupRows>
|
||||
</div>
|
||||
</div>
|
||||
<ListRows class="mx-5" v-else id="list-rows">
|
||||
<ListRow
|
||||
v-for="row in reactivieRows"
|
||||
:key="row.name"
|
||||
v-slot="{ idx, column, item }"
|
||||
:row="row"
|
||||
>
|
||||
<slot v-bind="{ idx, column, item }" />
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ListRows, ListRow, ListGroupHeader, ListGroupRows } from 'frappe-ui'
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const reactivieRows = ref(props.rows)
|
||||
|
||||
watch(
|
||||
() => props.rows,
|
||||
(val) => (reactivieRows.value = val)
|
||||
)
|
||||
|
||||
let showGroupedRows = computed(() => {
|
||||
return props.rows.every(
|
||||
(row) => row.group && row.rows && Array.isArray(row.rows)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@ -184,6 +184,7 @@ function parseView(views) {
|
||||
icon: getIcon(view.route_name, view.icon),
|
||||
to: {
|
||||
name: view.route_name,
|
||||
params: { viewType: view.type || 'list' },
|
||||
query: { view: view.name },
|
||||
},
|
||||
}
|
||||
|
||||
@ -75,6 +75,7 @@ const duplicateMode = ref(false)
|
||||
const _view = ref({
|
||||
name: '',
|
||||
label: '',
|
||||
type: 'list',
|
||||
icon: '',
|
||||
filters: {},
|
||||
order_by: 'modified desc',
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
<NestedPopover>
|
||||
<template #target>
|
||||
<Button :label="__('Sort')" ref="sortButtonRef">
|
||||
<template #prefix><SortIcon class="h-4" /></template>
|
||||
<template v-if="hideLabel">
|
||||
<SortIcon class="h-4" />
|
||||
</template>
|
||||
<template v-if="!hideLabel" #prefix><SortIcon class="h-4" /></template>
|
||||
<template v-if="sortValues?.size" #suffix>
|
||||
<div
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
|
||||
@ -108,6 +111,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hideLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
@ -12,7 +12,12 @@
|
||||
<div v-if="isEmoji(currentView.icon)">
|
||||
{{ currentView.icon }}
|
||||
</div>
|
||||
<FeatherIcon v-else :name="currentView.icon" class="h-4" />
|
||||
<FeatherIcon
|
||||
v-else-if="typeof currentView.icon == 'string'"
|
||||
:name="currentView.icon"
|
||||
class="h-4"
|
||||
/>
|
||||
<component v-else :is="currentView.icon" class="h-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
@ -43,12 +48,26 @@
|
||||
:default_filters="filters"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<SortBy v-model="list" :doctype="doctype" @update="updateSort" />
|
||||
<GroupBy
|
||||
v-if="route.params.viewType === 'group_by'"
|
||||
v-model="list"
|
||||
:doctype="doctype"
|
||||
:hideLabel="isMobileView"
|
||||
@update="updateGroupBy"
|
||||
/>
|
||||
<SortBy
|
||||
v-model="list"
|
||||
:doctype="doctype"
|
||||
@update="updateSort"
|
||||
:hideLabel="isMobileView"
|
||||
/>
|
||||
<ColumnSettings
|
||||
v-if="!options.hideColumnsButton"
|
||||
v-model="list"
|
||||
:doctype="doctype"
|
||||
:hideLabel="isMobileView"
|
||||
@update="(isDefault) => updateColumns(isDefault)"
|
||||
/>
|
||||
</div>
|
||||
@ -69,7 +88,12 @@
|
||||
<Button :label="__(currentView.label)">
|
||||
<template #prefix>
|
||||
<div v-if="isEmoji(currentView.icon)">{{ currentView.icon }}</div>
|
||||
<FeatherIcon v-else :name="currentView.icon" class="h-4" />
|
||||
<FeatherIcon
|
||||
v-else-if="typeof currentView.icon == 'string'"
|
||||
:name="currentView.icon"
|
||||
class="h-4"
|
||||
/>
|
||||
<component v-else :is="currentView.icon" class="h-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
@ -117,6 +141,12 @@
|
||||
<RefreshIcon class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<GroupBy
|
||||
v-if="route.params.viewType === 'group_by'"
|
||||
v-model="list"
|
||||
:doctype="doctype"
|
||||
@update="updateGroupBy"
|
||||
/>
|
||||
<Filter
|
||||
v-model="list"
|
||||
:doctype="doctype"
|
||||
@ -162,7 +192,11 @@
|
||||
afterCreate: async (v) => {
|
||||
await reloadView()
|
||||
viewUpdated = false
|
||||
router.push({ name: route.name, query: { view: v.name } })
|
||||
router.push({
|
||||
name: route.name,
|
||||
params: { viewType: v.type || 'list' },
|
||||
query: { view: v.name },
|
||||
})
|
||||
},
|
||||
afterUpdate: () => {
|
||||
viewUpdated = false
|
||||
@ -212,6 +246,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
|
||||
import QuickFilterField from '@/components/QuickFilterField.vue'
|
||||
import RefreshIcon from '@/components/Icons/RefreshIcon.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
@ -221,6 +256,7 @@ import UnpinIcon from '@/components/Icons/UnpinIcon.vue'
|
||||
import ViewModal from '@/components/Modals/ViewModal.vue'
|
||||
import SortBy from '@/components/SortBy.vue'
|
||||
import Filter from '@/components/Filter.vue'
|
||||
import GroupBy from '@/components/GroupBy.vue'
|
||||
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
|
||||
import ColumnSettings from '@/components/ColumnSettings.vue'
|
||||
import { globalStore } from '@/stores/global'
|
||||
@ -247,6 +283,7 @@ const props = defineProps({
|
||||
default: {
|
||||
hideColumnsButton: false,
|
||||
defaultViewName: '',
|
||||
allowedViews: ['list'],
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -268,17 +305,35 @@ const defaultParams = ref('')
|
||||
const viewUpdated = ref(false)
|
||||
const showViewModal = ref(false)
|
||||
|
||||
function getViewType() {
|
||||
let viewType = route.params.viewType || 'list'
|
||||
let types = {
|
||||
list: {
|
||||
label: __('List View'),
|
||||
icon: 'list',
|
||||
},
|
||||
group_by: {
|
||||
label: __('Group By View'),
|
||||
icon: DetailsIcon,
|
||||
},
|
||||
}
|
||||
|
||||
return types[viewType]
|
||||
}
|
||||
|
||||
const currentView = computed(() => {
|
||||
let _view = getView(route.query.view)
|
||||
let _view = getView(route.query.view, route.params.viewType, props.doctype)
|
||||
return {
|
||||
label: _view?.label || props.options?.defaultViewName || 'List View',
|
||||
icon: _view?.icon || 'list',
|
||||
label:
|
||||
_view?.label || props.options?.defaultViewName || getViewType().label,
|
||||
icon: _view?.icon || getViewType().icon,
|
||||
}
|
||||
})
|
||||
|
||||
const view = ref({
|
||||
name: '',
|
||||
label: '',
|
||||
type: 'list',
|
||||
icon: '',
|
||||
filters: {},
|
||||
order_by: 'modified desc',
|
||||
@ -308,7 +363,7 @@ watch(updatedPageCount, (value) => {
|
||||
})
|
||||
|
||||
function getParams() {
|
||||
let _view = getView(route.query.view, props.doctype)
|
||||
let _view = getView(route.query.view, route.params.viewType, props.doctype)
|
||||
const filters = (_view?.filters && JSON.parse(_view.filters)) || {}
|
||||
const order_by = _view?.order_by || 'modified desc'
|
||||
const columns = _view?.columns || ''
|
||||
@ -318,9 +373,11 @@ function getParams() {
|
||||
view.value = {
|
||||
name: _view.name,
|
||||
label: _view.label,
|
||||
type: _view.type || 'list',
|
||||
icon: _view.icon,
|
||||
filters: _view.filters,
|
||||
order_by: _view.order_by,
|
||||
group_by_field: _view.group_by_field,
|
||||
columns: _view.columns,
|
||||
rows: _view.rows,
|
||||
route_name: _view.route_name,
|
||||
@ -331,13 +388,15 @@ function getParams() {
|
||||
} else {
|
||||
view.value = {
|
||||
name: '',
|
||||
label: '',
|
||||
label: getViewType().label,
|
||||
type: route.params.viewType || 'list',
|
||||
icon: '',
|
||||
filters: {},
|
||||
order_by: 'modified desc',
|
||||
group_by_field: 'owner',
|
||||
columns: '',
|
||||
rows: '',
|
||||
route_name: '',
|
||||
route_name: route.name,
|
||||
load_default_columns: true,
|
||||
pinned: false,
|
||||
public: false,
|
||||
@ -352,7 +411,11 @@ function getParams() {
|
||||
rows: rows,
|
||||
page_length: pageLength.value,
|
||||
page_length_count: pageLengthCount.value,
|
||||
custom_view_name: _view?.name || '',
|
||||
view: {
|
||||
custom_view_name: _view?.name || '',
|
||||
view_type: _view?.type || route.params.viewType || 'list',
|
||||
group_by_field: _view?.group_by_field || 'owner',
|
||||
},
|
||||
default_filters: props.filters,
|
||||
}
|
||||
}
|
||||
@ -360,7 +423,7 @@ function getParams() {
|
||||
list.value = createResource({
|
||||
url: 'crm.api.doc.get_list_data',
|
||||
params: getParams(),
|
||||
cache: [props.doctype, route.query.view],
|
||||
cache: [props.doctype, route.query.view, route.params.viewType],
|
||||
transform(data) {
|
||||
return {
|
||||
...data,
|
||||
@ -368,7 +431,7 @@ list.value = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
let cv = getView(route.query.view)
|
||||
let cv = getView(route.query.view, route.params.viewType, props.doctype)
|
||||
let params = list.value.params ? list.value.params : getParams()
|
||||
defaultParams.value = {
|
||||
doctype: props.doctype,
|
||||
@ -378,7 +441,11 @@ list.value = createResource({
|
||||
page_length_count: params.page_length_count,
|
||||
columns: data.columns,
|
||||
rows: data.rows,
|
||||
custom_view_name: cv?.name || '',
|
||||
view: {
|
||||
custom_view_name: cv?.name || '',
|
||||
view_type: cv?.type || route.params.viewType || 'list',
|
||||
group_by_field: params?.view?.group_by_field || 'owner',
|
||||
},
|
||||
default_filters: props.filters,
|
||||
}
|
||||
},
|
||||
@ -412,23 +479,37 @@ async function exportRows() {
|
||||
export_type.value = 'Excel'
|
||||
}
|
||||
|
||||
const defaultViews = [
|
||||
{
|
||||
let defaultViews = []
|
||||
let allowedViews = props.options.allowedViews || ['list']
|
||||
|
||||
if (allowedViews.includes('list')) {
|
||||
defaultViews.push({
|
||||
label: __(props.options?.defaultViewName) || __('List View'),
|
||||
icon: 'list',
|
||||
onClick() {
|
||||
viewUpdated.value = false
|
||||
router.push({ name: route.name })
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
if (allowedViews.includes('group_by')) {
|
||||
defaultViews.push({
|
||||
label: __(props.options?.defaultViewName) || __('Group By View'),
|
||||
icon: h(DetailsIcon, { class: 'size-4' }),
|
||||
onClick() {
|
||||
viewUpdated.value = false
|
||||
router.push({ name: route.name, params: { viewType: 'group_by' } })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getIcon(icon) {
|
||||
function getIcon(icon, type) {
|
||||
if (isEmoji(icon)) {
|
||||
return h('div', icon)
|
||||
} else {
|
||||
return icon || 'list'
|
||||
} else if (!icon && type === 'group_by') {
|
||||
return DetailsIcon
|
||||
}
|
||||
return icon || 'list'
|
||||
}
|
||||
|
||||
const viewsDropdownOptions = computed(() => {
|
||||
@ -443,14 +524,19 @@ const viewsDropdownOptions = computed(() => {
|
||||
if (list.value?.data?.views) {
|
||||
list.value.data.views.forEach((view) => {
|
||||
view.label = __(view.label)
|
||||
view.icon = getIcon(view.icon)
|
||||
view.type = view.type || 'list'
|
||||
view.icon = getIcon(view.icon, view.type)
|
||||
view.filters =
|
||||
typeof view.filters == 'string'
|
||||
? JSON.parse(view.filters)
|
||||
: view.filters
|
||||
view.onClick = () => {
|
||||
viewUpdated.value = false
|
||||
router.push({ ...route, query: { view: view.name } })
|
||||
router.push({
|
||||
name: route.name,
|
||||
params: { viewType: view.type },
|
||||
query: { view: view.name },
|
||||
})
|
||||
}
|
||||
})
|
||||
let publicViews = list.value.data.views.filter((v) => v.public)
|
||||
@ -555,6 +641,21 @@ function updateSort(order_by) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateGroupBy(group_by_field) {
|
||||
viewUpdated.value = true
|
||||
if (!defaultParams.value) {
|
||||
defaultParams.value = getParams()
|
||||
}
|
||||
list.value.params = defaultParams.value
|
||||
list.value.params.view.group_by_field = group_by_field
|
||||
view.value.group_by_field = group_by_field
|
||||
list.value.reload()
|
||||
|
||||
if (!route.query.view) {
|
||||
create_or_update_default_view()
|
||||
}
|
||||
}
|
||||
|
||||
function updateColumns(obj) {
|
||||
if (!obj) {
|
||||
obj = {
|
||||
@ -601,10 +702,12 @@ function create_or_update_default_view() {
|
||||
reloadView()
|
||||
view.value = {
|
||||
label: view.value.label,
|
||||
type: view.value.type || 'list',
|
||||
icon: view.value.icon,
|
||||
name: view.value.name,
|
||||
filters: defaultParams.value.filters,
|
||||
order_by: defaultParams.value.order_by,
|
||||
group_by_field: defaultParams.value.view.group_by_field,
|
||||
columns: defaultParams.value.columns,
|
||||
rows: defaultParams.value.rows,
|
||||
route_name: route.name,
|
||||
@ -708,7 +811,9 @@ const viewActions = computed(() => {
|
||||
const viewModalObj = ref({})
|
||||
|
||||
function duplicateView() {
|
||||
let label = __(getView(route.query.view)?.label) || __('List View')
|
||||
let label =
|
||||
__(getView(route.query.view, route.params.viewType, props.doctype)?.label) ||
|
||||
getViewType().label
|
||||
view.value.name = ''
|
||||
view.value.label = label + __(' (New)')
|
||||
viewModalObj.value = view.value
|
||||
@ -716,9 +821,9 @@ function duplicateView() {
|
||||
}
|
||||
|
||||
function editView() {
|
||||
let cView = getView(route.query.view)
|
||||
let cView = getView(route.query.view, route.params.viewType, props.doctype)
|
||||
view.value.name = route.query.view
|
||||
view.value.label = __(cView?.label) || __('List View')
|
||||
view.value.label = __(cView?.label) || getViewType().label
|
||||
view.value.icon = cView?.icon || ''
|
||||
viewModalObj.value = view.value
|
||||
showViewModal.value = true
|
||||
@ -762,10 +867,12 @@ function cancelChanges() {
|
||||
function saveView() {
|
||||
view.value = {
|
||||
label: view.value.label,
|
||||
type: view.value.type || 'list',
|
||||
icon: view.value.icon,
|
||||
name: view.value.name,
|
||||
filters: defaultParams.value.filters,
|
||||
order_by: defaultParams.value.order_by,
|
||||
group_by_field: defaultParams.value.view.group_by_field,
|
||||
columns: defaultParams.value.columns,
|
||||
rows: defaultParams.value.rows,
|
||||
route_name: route.name,
|
||||
@ -830,7 +937,7 @@ defineExpose({ applyFilter, applyLikeFilter, likeDoc })
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => getView(route.query.view),
|
||||
() => getView(route.query.view, route.params.viewType, props.doctype),
|
||||
(value, old_value) => {
|
||||
if (JSON.stringify(value) === JSON.stringify(old_value)) return
|
||||
reload()
|
||||
@ -838,11 +945,8 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route,
|
||||
(value, old_value) => {
|
||||
if (value === old_value) return
|
||||
reload()
|
||||
}
|
||||
)
|
||||
watch([() => route, () => route.params.viewType], (value, old_value) => {
|
||||
if (value[0] === old_value[0] && value[1] === value[0]) return
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -24,6 +24,9 @@
|
||||
v-model:resizeColumn="triggerResize"
|
||||
v-model:updatedPageCount="updatedPageCount"
|
||||
doctype="CRM Deal"
|
||||
:options="{
|
||||
allowedViews: ['list', 'group_by'],
|
||||
}"
|
||||
/>
|
||||
<DealsListView
|
||||
ref="dealsListView"
|
||||
@ -61,6 +64,7 @@
|
||||
|
||||
<script setup>
|
||||
import CustomActions from '@/components/CustomActions.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import DealsListView from '@/components/ListViews/DealsListView.vue'
|
||||
@ -77,7 +81,8 @@ import {
|
||||
formatTime,
|
||||
} from '@/utils'
|
||||
import { Breadcrumbs } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, computed, h } from 'vue'
|
||||
|
||||
const breadcrumbs = [{ label: __('Deals'), route: { name: 'Deals' } }]
|
||||
|
||||
@ -85,6 +90,8 @@ const { getUser } = usersStore()
|
||||
const { getOrganization } = organizationsStore()
|
||||
const { getDealStatus } = statusesStore()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const dealsListView = ref(null)
|
||||
const showDealModal = ref(false)
|
||||
|
||||
@ -98,7 +105,49 @@ const viewControls = ref(null)
|
||||
// Rows
|
||||
const rows = computed(() => {
|
||||
if (!deals.value?.data?.data) return []
|
||||
return deals.value.data.data.map((deal) => {
|
||||
if (route.params.viewType === 'group_by') {
|
||||
if (!deals.value?.data.group_by_field?.name) return []
|
||||
return getGroupedByRows(
|
||||
deals.value?.data.data,
|
||||
deals.value?.data.group_by_field
|
||||
)
|
||||
} else {
|
||||
return parseRows(deals.value?.data.data)
|
||||
}
|
||||
})
|
||||
|
||||
function getGroupedByRows(listRows, groupByField) {
|
||||
let groupedRows = []
|
||||
|
||||
groupByField.options?.forEach((option) => {
|
||||
let filteredRows = []
|
||||
|
||||
if (!option) {
|
||||
filteredRows = listRows.filter((row) => !row[groupByField.name])
|
||||
} else {
|
||||
filteredRows = listRows.filter((row) => row[groupByField.name] == option)
|
||||
}
|
||||
|
||||
let groupDetail = {
|
||||
label: groupByField.label,
|
||||
group: option || __(' '),
|
||||
collapsed: false,
|
||||
rows: parseRows(filteredRows),
|
||||
}
|
||||
if (groupByField.name == 'status') {
|
||||
groupDetail.icon = () =>
|
||||
h(IndicatorIcon, {
|
||||
class: getDealStatus(option)?.iconColorClass,
|
||||
})
|
||||
}
|
||||
groupedRows.push(groupDetail)
|
||||
})
|
||||
|
||||
return groupedRows || listRows
|
||||
}
|
||||
|
||||
function parseRows(rows) {
|
||||
return rows.map((deal) => {
|
||||
let _rows = {}
|
||||
deals.value.data.rows.forEach((row) => {
|
||||
_rows[row] = deal[row]
|
||||
@ -174,5 +223,5 @@ const rows = computed(() => {
|
||||
})
|
||||
return _rows
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -25,6 +25,9 @@
|
||||
v-model:updatedPageCount="updatedPageCount"
|
||||
doctype="CRM Lead"
|
||||
:filters="{ converted: 0 }"
|
||||
:options="{
|
||||
allowedViews: ['list', 'group_by'],
|
||||
}"
|
||||
/>
|
||||
<LeadsListView
|
||||
ref="leadsListView"
|
||||
@ -62,6 +65,7 @@
|
||||
|
||||
<script setup>
|
||||
import CustomActions from '@/components/CustomActions.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
|
||||
@ -78,8 +82,8 @@ import {
|
||||
createToast,
|
||||
} from '@/utils'
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ref, computed, reactive, h } from 'vue'
|
||||
|
||||
const breadcrumbs = [{ label: __('Leads'), route: { name: 'Leads' } }]
|
||||
|
||||
@ -88,6 +92,7 @@ const { getOrganization } = organizationsStore()
|
||||
const { getLeadStatus } = statusesStore()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const leadsListView = ref(null)
|
||||
const showLeadModal = ref(false)
|
||||
@ -102,7 +107,49 @@ const viewControls = ref(null)
|
||||
// Rows
|
||||
const rows = computed(() => {
|
||||
if (!leads.value?.data?.data) return []
|
||||
return leads.value?.data.data.map((lead) => {
|
||||
if (route.params.viewType === 'group_by') {
|
||||
if (!leads.value?.data.group_by_field?.name) return []
|
||||
return getGroupedByRows(
|
||||
leads.value?.data.data,
|
||||
leads.value?.data.group_by_field
|
||||
)
|
||||
} else {
|
||||
return parseRows(leads.value?.data.data)
|
||||
}
|
||||
})
|
||||
|
||||
function getGroupedByRows(listRows, groupByField) {
|
||||
let groupedRows = []
|
||||
|
||||
groupByField.options?.forEach((option) => {
|
||||
let filteredRows = []
|
||||
|
||||
if (!option) {
|
||||
filteredRows = listRows.filter((row) => !row[groupByField.name])
|
||||
} else {
|
||||
filteredRows = listRows.filter((row) => row[groupByField.name] == option)
|
||||
}
|
||||
|
||||
let groupDetail = {
|
||||
label: groupByField.label,
|
||||
group: option || __(' '),
|
||||
collapsed: false,
|
||||
rows: parseRows(filteredRows),
|
||||
}
|
||||
if (groupByField.name == 'status') {
|
||||
groupDetail.icon = () =>
|
||||
h(IndicatorIcon, {
|
||||
class: getLeadStatus(option)?.iconColorClass,
|
||||
})
|
||||
}
|
||||
groupedRows.push(groupDetail)
|
||||
})
|
||||
|
||||
return groupedRows || listRows
|
||||
}
|
||||
|
||||
function parseRows(rows) {
|
||||
return rows.map((lead) => {
|
||||
let _rows = {}
|
||||
leads.value?.data.rows.forEach((row) => {
|
||||
_rows[row] = lead[row]
|
||||
@ -182,7 +229,7 @@ const rows = computed(() => {
|
||||
})
|
||||
return _rows
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let newLead = reactive({
|
||||
salutation: '',
|
||||
|
||||
@ -14,10 +14,12 @@ const routes = [
|
||||
component: () => import('@/pages/MobileNotification.vue'),
|
||||
},
|
||||
{
|
||||
path: '/leads',
|
||||
alias: '/leads',
|
||||
path: '/leads/view/:viewType?',
|
||||
name: 'Leads',
|
||||
component: () => import('@/pages/Leads.vue'),
|
||||
meta: { scrollPos: { top: 0, left: 0 } },
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/leads/:leadId/:tabName?',
|
||||
@ -26,10 +28,12 @@ const routes = [
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/deals',
|
||||
alias: '/deals',
|
||||
path: '/deals/view/:viewType?',
|
||||
name: 'Deals',
|
||||
component: () => import('@/pages/Deals.vue'),
|
||||
meta: { scrollPos: { top: 0, left: 0 } },
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/deals/:dealId/:tabName?',
|
||||
|
||||
@ -20,6 +20,7 @@ export const viewsStore = defineStore('crm-views', (doctype) => {
|
||||
publicViews.value = []
|
||||
for (let view of views) {
|
||||
viewsByName[view.name] = view
|
||||
view.type = view.type || 'list'
|
||||
if (view.pinned) {
|
||||
pinnedViews.value?.push(view)
|
||||
}
|
||||
@ -27,16 +28,17 @@ export const viewsStore = defineStore('crm-views', (doctype) => {
|
||||
publicViews.value?.push(view)
|
||||
}
|
||||
if (view.is_default && view.dt) {
|
||||
defaultView.value[view.dt] = view
|
||||
defaultView.value[view.dt + ' ' + view.type] = view
|
||||
}
|
||||
}
|
||||
return views
|
||||
},
|
||||
})
|
||||
|
||||
function getView(view, doctype = null) {
|
||||
function getView(view, type, doctype = null) {
|
||||
type = type || 'list'
|
||||
if (!view && doctype) {
|
||||
return defaultView.value?.[doctype] || null
|
||||
return defaultView.value[doctype + ' ' + type] || null
|
||||
}
|
||||
return viewsByName[view]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user