Merge pull request #212 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2024-06-05 18:07:20 +05:30 committed by GitHub
commit 17ca40ea73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 1221 additions and 2097 deletions

3
.gitignore vendored
View File

@ -7,5 +7,6 @@ dev-dist
tags tags
node_modules node_modules
crm/public/frontend crm/public/frontend
frontend/yarn.lock
crm/www/crm.html crm/www/crm.html
build build

View File

@ -89,7 +89,7 @@ def get_filterable_fields(doctype: str):
"options": "User", "options": "User",
}, },
{"fieldname": "_user_tags", "fieldtype": "Data", "label": "Tags"}, {"fieldname": "_user_tags", "fieldtype": "Data", "label": "Tags"},
{"fieldname": "_liked_by", "fieldtype": "Data", "label": "Liked By"}, {"fieldname": "_liked_by", "fieldtype": "Data", "label": "Like"},
{"fieldname": "_comments", "fieldtype": "Text", "label": "Comments"}, {"fieldname": "_comments", "fieldtype": "Text", "label": "Comments"},
{"fieldname": "_assign", "fieldtype": "Text", "label": "Assigned To"}, {"fieldname": "_assign", "fieldtype": "Text", "label": "Assigned To"},
{"fieldname": "creation", "fieldtype": "Datetime", "label": "Created On"}, {"fieldname": "creation", "fieldtype": "Datetime", "label": "Created On"},
@ -108,6 +108,54 @@ def get_filterable_fields(doctype: str):
return res 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): def get_fields_meta(DocField, doctype, allowed_fieldtypes, restricted_fields):
parent = "parent" if DocField._table_name == "tabDocField" else "dt" parent = "parent" if DocField._table_name == "tabDocField" else "dt"
return ( return (
@ -159,12 +207,16 @@ def get_list_data(
page_length_count=20, page_length_count=20,
columns=None, columns=None,
rows=None, rows=None,
custom_view_name=None, view=None,
default_filters=None, default_filters=None,
): ):
custom_view = False custom_view = False
filters = frappe._dict(filters) 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: for key in filters:
value = filters[key] value = filters[key]
if isinstance(value, list): if isinstance(value, list):
@ -197,8 +249,15 @@ def get_list_data(
if not rows: if not rows:
rows = ["name"] rows = ["name"]
if not custom_view and frappe.db.exists("CRM View Settings", doctype): default_view_filters = {
list_view_settings = frappe.get_doc("CRM View Settings", doctype) "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) columns = frappe.parse_json(list_view_settings.columns)
rows = frappe.parse_json(list_view_settings.rows) rows = frappe.parse_json(list_view_settings.rows)
is_default = False is_default = False
@ -218,6 +277,10 @@ def get_list_data(
if column.get("key") == "_liked_by" and column.get("width") == "10rem": if column.get("key") == "_liked_by" and column.get("width") == "10rem":
column["width"] = "50px" 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( data = frappe.get_list(
doctype, doctype,
fields=rows, fields=rows,
@ -251,7 +314,7 @@ def get_list_data(
}, },
{"label": "Assigned To", "type": "Text", "value": "_assign"}, {"label": "Assigned To", "type": "Text", "value": "_assign"},
{"label": "Owner", "type": "Link", "value": "owner", "options": "User"}, {"label": "Owner", "type": "Link", "value": "owner", "options": "User"},
{"label": "Liked By", "type": "Data", "value": "_liked_by"}, {"label": "Like", "type": "Data", "value": "_liked_by"},
] ]
for field in std_fields: for field in std_fields:
@ -264,11 +327,43 @@ def get_list_data(
if not is_default and custom_view_name: if not is_default and custom_view_name:
is_default = frappe.db.get_value("CRM View Settings", custom_view_name, "load_default_columns") 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 { return {
"data": data, "data": data,
"columns": columns, "columns": columns,
"rows": rows, "rows": rows,
"fields": fields, "fields": fields,
"group_by_field": group_by_field,
"page_length": page_length, "page_length": page_length,
"page_length_count": page_length_count, "page_length_count": page_length_count,
"is_default": is_default, "is_default": is_default,

View File

@ -10,6 +10,7 @@
"user", "user",
"is_default", "is_default",
"column_break_zacm", "column_break_zacm",
"type",
"dt", "dt",
"route_name", "route_name",
"pinned", "pinned",
@ -21,7 +22,9 @@
"filters_tab", "filters_tab",
"filters", "filters",
"order_by_tab", "order_by_tab",
"order_by" "order_by",
"group_by_tab",
"group_by_field"
], ],
"fields": [ "fields": [
{ {
@ -117,11 +120,28 @@
"fieldname": "icon", "fieldname": "icon",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Icon" "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, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-05-20 17:24:18.662389", "modified": "2024-06-01 16:58:34.952945",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM View Settings", "name": "CRM View Settings",

View File

@ -27,6 +27,7 @@ def create(view):
doc = frappe.new_doc("CRM View Settings") doc = frappe.new_doc("CRM View Settings")
doc.name = view.label doc.name = view.label
doc.label = view.label doc.label = view.label
doc.type = view.type or 'list'
doc.icon = view.icon doc.icon = view.icon
doc.dt = view.doctype doc.dt = view.doctype
doc.user = frappe.session.user doc.user = frappe.session.user
@ -34,6 +35,7 @@ def create(view):
doc.load_default_columns = view.load_default_columns or False doc.load_default_columns = view.load_default_columns or False
doc.filters = json.dumps(view.filters) doc.filters = json.dumps(view.filters)
doc.order_by = view.order_by doc.order_by = view.order_by
doc.group_by_field = view.group_by_field
doc.columns = json.dumps(view.columns) doc.columns = json.dumps(view.columns)
doc.rows = json.dumps(view.rows) doc.rows = json.dumps(view.rows)
doc.insert() doc.insert()
@ -53,11 +55,13 @@ def update(view):
doc = frappe.get_doc("CRM View Settings", view.name) doc = frappe.get_doc("CRM View Settings", view.name)
doc.label = view.label doc.label = view.label
doc.type = view.type or 'list'
doc.icon = view.icon doc.icon = view.icon
doc.route_name = view.route_name or "" doc.route_name = view.route_name or ""
doc.load_default_columns = view.load_default_columns or False doc.load_default_columns = view.load_default_columns or False
doc.filters = json.dumps(filters) doc.filters = json.dumps(filters)
doc.order_by = view.order_by doc.order_by = view.order_by
doc.group_by_field = view.group_by_field
doc.columns = json.dumps(columns) doc.columns = json.dumps(columns)
doc.rows = json.dumps(rows) doc.rows = json.dumps(rows)
doc.save() doc.save()
@ -123,28 +127,38 @@ def create_or_update_default_view(view):
doc = frappe.db.exists( doc = frappe.db.exists(
"CRM View Settings", "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: if doc:
doc = frappe.get_doc("CRM View Settings", doc) doc = frappe.get_doc("CRM View Settings", doc)
doc.label = view.label doc.label = view.label
doc.type = view.type or 'list'
doc.route_name = view.route_name or "" doc.route_name = view.route_name or ""
doc.load_default_columns = view.load_default_columns or False doc.load_default_columns = view.load_default_columns or False
doc.filters = json.dumps(filters) doc.filters = json.dumps(filters)
doc.order_by = view.order_by doc.order_by = view.order_by
doc.group_by_field = view.group_by_field
doc.columns = json.dumps(columns) doc.columns = json.dumps(columns)
doc.rows = json.dumps(rows) doc.rows = json.dumps(rows)
doc.save() doc.save()
else: else:
doc = frappe.new_doc("CRM View Settings") doc = frappe.new_doc("CRM View Settings")
doc.name = view.label or 'List View' label = 'Group By View' if view.type == 'group_by' else 'List View'
doc.label = view.label or '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.dt = view.doctype
doc.user = frappe.session.user doc.user = frappe.session.user
doc.route_name = view.route_name or "" doc.route_name = view.route_name or ""
doc.load_default_columns = view.load_default_columns or False doc.load_default_columns = view.load_default_columns or False
doc.filters = json.dumps(filters) doc.filters = json.dumps(filters)
doc.order_by = view.order_by doc.order_by = view.order_by
doc.group_by_field = view.group_by_field
doc.columns = json.dumps(columns) doc.columns = json.dumps(columns)
doc.rows = json.dumps(rows) doc.rows = json.dumps(rows)
doc.is_default = True doc.is_default = True

@ -1 +1 @@
Subproject commit 1394a12b6de105649c8ca5beeead62a38ef1b18e Subproject commit d7ad7bd0d09f25a446da984e6006479ea218acd0

View File

@ -177,14 +177,6 @@
</head> </head>
<body class="sm:overscroll-y-none no-scrollbar"> <body class="sm:overscroll-y-none no-scrollbar">
<div id="app" class="h-full"></div> <div id="app" class="h-full"></div>
<div id="modals"></div>
<div id="popovers"></div>
<script>
{% for key in boot %}
window["{{ key }}"] = {{ boot[key] | tojson }};
{% endfor %}
</script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@ -13,8 +13,9 @@
"@vueuse/core": "^10.3.0", "@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0", "@vueuse/integrations": "^10.3.0",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.55", "frappe-ui": "^0.1.59",
"gemoji": "^8.1.0", "gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1", "mime": "^4.0.1",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",

View File

@ -10,11 +10,9 @@
<script setup> <script setup>
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { sessionStore as session } from '@/stores/session' import { sessionStore as session } from '@/stores/session'
import { useScreenSize } from '@/composables'
import { Toasts } from 'frappe-ui' import { Toasts } from 'frappe-ui'
import { computed, defineAsyncComponent } from 'vue' import { computed, defineAsyncComponent } from 'vue'
const screenSize = useScreenSize()
const MobileLayout = defineAsyncComponent(() => const MobileLayout = defineAsyncComponent(() =>
import('./components/Layouts/MobileLayout.vue') import('./components/Layouts/MobileLayout.vue')
) )
@ -22,7 +20,7 @@ const DesktopLayout = defineAsyncComponent(() =>
import('./components/Layouts/DesktopLayout.vue') import('./components/Layouts/DesktopLayout.vue')
) )
const Layout = computed(() => { const Layout = computed(() => {
if (screenSize.width < 640) { if (window.innerWidth < 640) {
return MobileLayout return MobileLayout
} else { } else {
return DesktopLayout return DesktopLayout

View File

@ -924,7 +924,7 @@ import {
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts' import { contactsStore } from '@/stores/contacts'
import { whatsappEnabled, callEnabled } from '@/stores/settings' import { whatsappEnabled, callEnabled } from '@/composables/settings'
import { import {
Button, Button,
Tooltip, Tooltip,

View File

@ -2,7 +2,10 @@
<NestedPopover> <NestedPopover>
<template #target> <template #target>
<Button :label="__('Columns')"> <Button :label="__('Columns')">
<template #prefix> <template v-if="hideLabel">
<ColumnsIcon class="h-4" />
</template>
<template v-if="!hideLabel" #prefix>
<ColumnsIcon class="h-4" /> <ColumnsIcon class="h-4" />
</template> </template>
</Button> </Button>
@ -15,6 +18,7 @@
<Draggable <Draggable
:list="columns" :list="columns"
@end="apply" @end="apply"
:delay="isTouchScreenDevice() ? 200 : 0"
item-key="key" item-key="key"
class="list-group" class="list-group"
> >
@ -98,17 +102,21 @@
size="md" size="md"
:label="__('Label')" :label="__('Label')"
v-model="column.label" v-model="column.label"
class="w-full" class="sm:w-full w-52"
:placeholder="__('First Name')" :placeholder="__('First Name')"
/> />
<FormControl <FormControl
type="text" type="text"
size="md" size="md"
:label="__('Width')" :label="__('Width')"
class="w-full" class="sm:w-full w-52"
v-model="column.width" v-model="column.width"
placeholder="10rem" 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" :debounce="500"
/> />
</div> </div>
@ -140,6 +148,7 @@ import DragIcon from '@/components/Icons/DragIcon.vue'
import ReloadIcon from '@/components/Icons/ReloadIcon.vue' import ReloadIcon from '@/components/Icons/ReloadIcon.vue'
import NestedPopover from '@/components/NestedPopover.vue' import NestedPopover from '@/components/NestedPopover.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue' import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { isTouchScreenDevice } from '@/utils'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { watchOnce } from '@vueuse/core' import { watchOnce } from '@vueuse/core'
@ -149,6 +158,10 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
hideLabel: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['update']) const emit = defineEmits(['update'])

View File

@ -23,14 +23,14 @@
> >
<div class="flex items-center p-1 text-gray-500"> <div class="flex items-center p-1 text-gray-500">
<Button variant="ghost" class="h-7 w-7" @click="prevMonth"> <Button variant="ghost" class="h-7 w-7" @click="prevMonth">
<FeatherIcon stroke-width="2" name="chevron-left" class="h-4 w-4" /> <FeatherIcon :stroke-width="2" name="chevron-left" class="h-4 w-4" />
</Button> </Button>
<div class="flex-1 text-center text-base font-medium text-gray-700"> <div class="flex-1 text-center text-base font-medium text-gray-700">
{{ formatMonth }} {{ formatMonth }}
</div> </div>
<Button variant="ghost" class="h-7 w-7" @click="nextMonth"> <Button variant="ghost" class="h-7 w-7" @click="nextMonth">
<FeatherIcon <FeatherIcon
stroke-width="2" :stroke-width="2"
name="chevron-right" name="chevron-right"
class="h-4 w-4" class="h-4 w-4"
/> />

View File

@ -22,14 +22,14 @@
> >
<div class="flex items-center p-1 text-gray-500"> <div class="flex items-center p-1 text-gray-500">
<Button variant="ghost" class="h-7 w-7" @click="prevMonth"> <Button variant="ghost" class="h-7 w-7" @click="prevMonth">
<FeatherIcon stroke-width="2" name="chevron-left" class="h-4 w-4" /> <FeatherIcon :stroke-width="2" name="chevron-left" class="h-4 w-4" />
</Button> </Button>
<div class="flex-1 text-center text-base font-medium text-gray-700"> <div class="flex-1 text-center text-base font-medium text-gray-700">
{{ formatMonth }} {{ formatMonth }}
</div> </div>
<Button variant="ghost" class="h-7 w-7" @click="nextMonth"> <Button variant="ghost" class="h-7 w-7" @click="nextMonth">
<FeatherIcon <FeatherIcon
stroke-width="2" :stroke-width="2"
name="chevron-right" name="chevron-right"
class="h-4 w-4" class="h-4 w-4"
/> />

View File

@ -20,14 +20,14 @@
> >
<div class="flex items-center p-1 text-gray-500"> <div class="flex items-center p-1 text-gray-500">
<Button variant="ghost" class="h-7 w-7" @click="prevMonth"> <Button variant="ghost" class="h-7 w-7" @click="prevMonth">
<FeatherIcon stroke-width="2" name="chevron-left" class="h-4 w-4" /> <FeatherIcon :stroke-width="2" name="chevron-left" class="h-4 w-4" />
</Button> </Button>
<div class="flex-1 text-center text-base font-medium text-gray-700"> <div class="flex-1 text-center text-base font-medium text-gray-700">
{{ formatMonth }} {{ formatMonth }}
</div> </div>
<Button variant="ghost" class="h-7 w-7" @click="nextMonth"> <Button variant="ghost" class="h-7 w-7" @click="nextMonth">
<FeatherIcon <FeatherIcon
stroke-width="2" :stroke-width="2"
name="chevron-right" name="chevron-right"
class="h-4 w-4" class="h-4 w-4"
/> />

View File

@ -33,7 +33,7 @@
<script setup> <script setup>
import { computed, h } from 'vue' import { computed, h } from 'vue'
import { Dropdown } from 'frappe-ui' import { Dropdown } from 'frappe-ui'
import { isMobileView } from '@/stores/settings' import { isMobileView } from '@/composables/settings'
const props = defineProps({ const props = defineProps({
actions: { actions: {

View File

@ -23,7 +23,7 @@ const props = defineProps({
}, },
}) })
const files = import.meta.globEager('/src/index.css') const files = import.meta.globEager('/src/index.css', { query: '?inline' })
const css = files['/src/index.css'].default const css = files['/src/index.css'].default
const iframeRef = ref(null) const iframeRef = ref(null)

View File

@ -1,44 +1,54 @@
<template> <template>
<NestedPopover> <NestedPopover>
<template #target> <template #target>
<Button <div class="flex items-center">
:label="__('Filter')" <Button
:class="filters?.size ? 'rounded-r-none' : ''" :label="__('Filter')"
> :class="filters?.size ? 'rounded-r-none' : ''"
<template #prefix><FilterIcon class="h-4" /></template> >
<template v-if="filters?.size" #suffix> <template #prefix><FilterIcon class="h-4" /></template>
<div <template v-if="filters?.size" #suffix>
class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white" <div
> class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
{{ filters.size }} >
{{ filters.size }}
</div>
</template>
</Button>
<Tooltip v-if="filters?.size" :text="__('Clear all Filter')">
<div>
<Button
class="rounded-l-none border-l"
icon="x"
@click.stop="clearfilter(false)"
/>
</div> </div>
</template> </Tooltip>
</Button> </div>
<Tooltip v-if="filters?.size" :text="__('Clear all Filter')">
<span>
<Button
class="rounded-l-none border-l"
icon="x"
@click.stop="clearfilter(false)"
/>
</span>
</Tooltip>
</template> </template>
<template #body="{ close }"> <template #body="{ close }">
<div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl"> <div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
<div class="min-w-[400px] p-2"> <div class="min-w-72 p-2 sm:min-w-[400px]">
<div <div
v-if="filters?.size" v-if="filters?.size"
v-for="(f, i) in filters" v-for="(f, i) in filters"
:key="i" :key="i"
id="filter-list" id="filter-list"
class="mb-3 flex items-center justify-between gap-2" class="sm:mb-3 mb-4"
> >
<div class="flex items-center gap-2"> <div v-if="isMobileView" class="flex flex-col gap-2">
<div class="w-13 pl-2 text-end text-base text-gray-600"> <div class="flex w-full items-center justify-between -mb-2">
{{ i == 0 ? __('Where') : __('And') }} <div class="text-base text-gray-600">
{{ i == 0 ? __('Where') : __('And') }}
</div>
<Button
class="flex"
variant="ghost"
icon="x"
@click="removeFilter(i)"
/>
</div> </div>
<div id="fieldname" class="!min-w-[140px]"> <div id="fieldname" class="w-full">
<Autocomplete <Autocomplete
:value="f.field.fieldname" :value="f.field.fieldname"
:options="filterableFields.data" :options="filterableFields.data"
@ -55,16 +65,55 @@
:placeholder="__('Equals')" :placeholder="__('Equals')"
/> />
</div> </div>
<div id="value" class="!min-w-[140px]"> <div id="value" class="w-full">
<component <component
:is="getValSelect(f)" :is="getValSelect(f)"
v-model="f.value" v-model="f.value"
@change="(v) => updateValue(v, f)" @change.stop="(v) => updateValue(v, f)"
:placeholder="__('John Doe')" :placeholder="__('John Doe')"
/> />
</div> </div>
</div> </div>
<Button variant="ghost" icon="x" @click="removeFilter(i)" /> <div v-else class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<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]">
<Autocomplete
:value="f.field.fieldname"
:options="filterableFields.data"
@change="(e) => updateFilter(e, i)"
:placeholder="__('First Name')"
/>
</div>
<div id="operator">
<FormControl
type="select"
v-model="f.operator"
@change="(e) => updateOperator(e, f)"
:options="
getOperators(f.field.fieldtype, f.field.fieldname)
"
:placeholder="__('Equals')"
/>
</div>
<div id="value" class="!min-w-[140px]">
<component
:is="getValSelect(f)"
v-model="f.value"
@change.stop="(v) => updateValue(v, f)"
:placeholder="__('John Doe')"
/>
</div>
</div>
<Button
class="flex"
variant="ghost"
icon="x"
@click="removeFilter(i)"
/>
</div>
</div> </div>
<div <div
v-else v-else
@ -115,6 +164,7 @@ import Link from '@/components/Controls/Link.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue' import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { FormControl, createResource, Tooltip } from 'frappe-ui' import { FormControl, createResource, Tooltip } from 'frappe-ui'
import { h, computed, onMounted } from 'vue' import { h, computed, onMounted } from 'vue'
import { isMobileView } from '@/composables/settings'
const typeCheck = ['Check'] const typeCheck = ['Check']
const typeLink = ['Link', 'Dynamic Link'] const typeLink = ['Link', 'Dynamic Link']

View File

@ -0,0 +1,77 @@
<template>
<Autocomplete :options="options" value="" @change="(e) => setGroupBy(e)">
<template #target="{ togglePopover, isOpen }">
<Button
:label="
hideLabel
? groupByValue?.label
: __('Group By: ') + groupByValue?.label
"
@click="togglePopover()"
>
<template #prefix>
<DetailsIcon />
</template>
<template #suffix>
<FeatherIcon
:name="isOpen ? '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>

View File

@ -0,0 +1,20 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-arrow-down-a-z"
>
<path d="m3 16 4 4 4-4" />
<path d="M7 20V4" />
<path d="M20 8h-5" />
<path d="M15 10V6.5a2.5 2.5 0 0 1 5 0V10" />
<path d="M15 14h5l-5 6h5" />
</svg>
</template>

View File

@ -0,0 +1,20 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-arrow-up-z-a"
>
<path d="m3 8 4-4 4 4" />
<path d="M7 4v16" />
<path d="M15 4h5l-5 6h5" />
<path d="M15 20v-3.5a2.5 2.5 0 0 1 5 0V20" />
<path d="M20 18h-5" />
</svg>
</template>

View File

@ -74,7 +74,7 @@
</Section> </Section>
</div> </div>
</div> </div>
<div v-if="!isMobileView" class="m-2 flex flex-col gap-1"> <div class="m-2 flex flex-col gap-1">
<SidebarLink <SidebarLink
:label="isSidebarCollapsed ? __('Expand') : __('Collapse')" :label="isSidebarCollapsed ? __('Expand') : __('Collapse')"
:isCollapsed="isSidebarCollapsed" :isCollapsed="isSidebarCollapsed"
@ -116,7 +116,6 @@ import { notificationsStore } from '@/stores/notifications'
import { FeatherIcon } from 'frappe-ui' import { FeatherIcon } from 'frappe-ui'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { computed, h } from 'vue' import { computed, h } from 'vue'
import { isMobileView } from '@/stores/settings'
const { getPinnedViews, getPublicViews } = viewsStore() const { getPinnedViews, getPublicViews } = viewsStore()
const { toggle: toggleNotificationPanel } = notificationsStore() const { toggle: toggleNotificationPanel } = notificationsStore()
@ -200,6 +199,7 @@ function parseView(views) {
icon: getIcon(view.route_name, view.icon), icon: getIcon(view.route_name, view.icon),
to: { to: {
name: view.route_name, name: view.route_name,
params: { viewType: view.type || 'list' },
query: { view: view.name }, query: { view: view.name },
}, },
} }

View File

@ -9,8 +9,9 @@
resizeColumn: options.resizeColumn, resizeColumn: options.resizeColumn,
}" }"
row-key="name" row-key="name"
v-bind="$attrs"
> >
<ListHeader class="mx-5" @columnWidthUpdated="emit('columnWidthUpdated')"> <ListHeader class="sm:mx-5 mx-3" @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeaderItem <ListHeaderItem
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
@ -28,9 +29,8 @@
</Button> </Button>
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows id="list-rows"> <ListRows class="mx-3 sm:mx-5" id="list-rows">
<ListRow <ListRow
class="mx-5"
v-for="row in rows" v-for="row in rows"
:key="row.name" :key="row.name"
v-slot="{ idx, column, item }" v-slot="{ idx, column, item }"
@ -140,7 +140,7 @@
</ListSelectBanner> </ListSelectBanner>
</ListView> </ListView>
<ListFooter <ListFooter
class="border-t px-5 py-2" class="border-t sm:px-5 px-3 py-2"
v-model="pageLengthCount" v-model="pageLengthCount"
:options="{ :options="{
rowCount: options.rowCount, rowCount: options.rowCount,

View File

@ -14,7 +14,10 @@
}" }"
row-key="name" row-key="name"
> >
<ListHeader class="mx-5" @columnWidthUpdated="emit('columnWidthUpdated')"> <ListHeader
class="mx-3 sm:mx-5"
@columnWidthUpdated="emit('columnWidthUpdated')"
>
<ListHeaderItem <ListHeaderItem
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
@ -32,9 +35,8 @@
</Button> </Button>
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows id="list-rows"> <ListRows class="mx-3 sm:mx-5" id="list-rows">
<ListRow <ListRow
class="mx-5"
v-for="row in rows" v-for="row in rows"
:key="row.name" :key="row.name"
v-slot="{ idx, column, item }" v-slot="{ idx, column, item }"
@ -136,7 +138,7 @@
</ListView> </ListView>
<ListFooter <ListFooter
v-if="pageLengthCount" v-if="pageLengthCount"
class="border-t px-5 py-2" class="border-t px-3 py-2 sm:px-5"
v-model="pageLengthCount" v-model="pageLengthCount"
:options="{ :options="{
rowCount: options.rowCount, rowCount: options.rowCount,

View File

@ -11,7 +11,7 @@
}" }"
row-key="name" row-key="name"
> >
<ListHeader class="mx-5" @columnWidthUpdated="emit('columnWidthUpdated')"> <ListHeader class="sm:mx-5 mx-3" @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeaderItem <ListHeaderItem
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
@ -29,18 +29,74 @@
</Button> </Button>
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows id="list-rows"> <ListRows :rows="rows" v-slot="{ idx, column, item }">
<ListRow <div v-if="column.key === '_assign'" class="flex items-center">
class="mx-5" <MultipleAvatar
v-for="row in rows" :avatars="item"
:key="row.name" size="sm"
v-slot="{ idx, column, item }" @click="
:row="row" (event) =>
> emit('applyFilter', {
<div v-if="column.key === '_assign'" class="flex items-center"> event,
<MultipleAvatar idx,
:avatars="item" column,
size="sm" 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=" @click="
(event) => (event) =>
emit('applyFilter', { emit('applyFilter', {
@ -51,60 +107,21 @@
firstColumn: columns[0], firstColumn: columns[0],
}) })
" "
/> >
</div> <Tooltip :text="item.label">
<ListRowItem v-else :item="item"> <div>{{ item.timeAgo }}</div>
<template #prefix> </Tooltip>
<div v-if="column.key === 'status'"> </div>
<IndicatorIcon :class="item.color" /> <div
</div> v-else-if="column.key === 'sla_status'"
<div v-else-if="column.key === 'organization'"> class="truncate text-base"
<Avatar >
v-if="item.label" <Badge
class="flex items-center" v-if="item.value"
:image="item.logo" :variant="'subtle'"
:label="item.label" :theme="item.color"
size="sm" size="md"
/> :label="item.value"
</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=" @click="
(event) => (event) =>
emit('applyFilter', { emit('applyFilter', {
@ -115,60 +132,34 @@
firstColumn: columns[0], firstColumn: columns[0],
}) })
" "
> />
<Tooltip :text="item.label"> </div>
<div>{{ item.timeAgo }}</div> <div v-else-if="column.type === 'Check'">
</Tooltip> <FormControl
</div> type="checkbox"
<div :modelValue="item"
v-else-if="column.key === 'sla_status'" :disabled="true"
class="truncate text-base" class="text-gray-900"
> />
<Badge </div>
v-if="item.value" <div
:variant="'subtle'" v-else
:theme="item.color" class="truncate text-base"
size="md" @click="
:label="item.value" (event) =>
@click=" emit('applyFilter', {
(event) => event,
emit('applyFilter', { idx,
event, column,
idx, item,
column, firstColumn: columns[0],
item, })
firstColumn: columns[0], "
}) >
" {{ label }}
/> </div>
</div> </template>
<div v-else-if="column.type === 'Check'"> </ListRowItem>
<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>
</ListRows> </ListRows>
<ListSelectBanner> <ListSelectBanner>
<template #actions="{ selections, unselectAll }"> <template #actions="{ selections, unselectAll }">
@ -182,7 +173,7 @@
</ListView> </ListView>
<ListFooter <ListFooter
v-if="pageLengthCount" v-if="pageLengthCount"
class="border-t px-5 py-2" class="border-t sm:px-5 px-3 py-2"
v-model="pageLengthCount" v-model="pageLengthCount"
:options="{ :options="{
rowCount: options.rowCount, rowCount: options.rowCount,
@ -199,13 +190,12 @@ import MultipleAvatar from '@/components/MultipleAvatar.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue' import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue' import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import ListBulkActions from '@/components/ListBulkActions.vue' import ListBulkActions from '@/components/ListBulkActions.vue'
import ListRows from '@/components/ListViews/ListRows.vue'
import { import {
Avatar, Avatar,
ListView, ListView,
ListHeader, ListHeader,
ListHeaderItem, ListHeaderItem,
ListRows,
ListRow,
ListRowItem, ListRowItem,
ListSelectBanner, ListSelectBanner,
ListFooter, ListFooter,

View File

@ -10,7 +10,7 @@
}" }"
row-key="name" row-key="name"
> >
<ListHeader class="mx-5" @columnWidthUpdated="emit('columnWidthUpdated')"> <ListHeader class="sm:mx-5 mx-3" @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeaderItem <ListHeaderItem
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
@ -28,9 +28,8 @@
</Button> </Button>
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows id="list-rows"> <ListRows class="mx-3 sm:mx-5" id="list-rows">
<ListRow <ListRow
class="mx-5"
v-for="row in rows" v-for="row in rows"
:key="row.name" :key="row.name"
v-slot="{ idx, column, item }" v-slot="{ idx, column, item }"
@ -129,7 +128,7 @@
</ListSelectBanner> </ListSelectBanner>
</ListView> </ListView>
<ListFooter <ListFooter
class="border-t px-5 py-2" class="border-t sm:px-5 px-3 py-2"
v-model="pageLengthCount" v-model="pageLengthCount"
:options="{ :options="{
rowCount: options.rowCount, rowCount: options.rowCount,

View File

@ -11,7 +11,7 @@
}" }"
row-key="name" row-key="name"
> >
<ListHeader class="mx-5" @columnWidthUpdated="emit('columnWidthUpdated')"> <ListHeader class="sm:mx-5 mx-3" @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeaderItem <ListHeaderItem
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
@ -29,18 +29,71 @@
</Button> </Button>
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows id="list-rows"> <ListRows :rows="rows" v-slot="{ idx, column, item }">
<ListRow <div v-if="column.key === '_assign'" class="flex items-center">
class="mx-5" <MultipleAvatar
v-for="row in rows" :avatars="item"
:key="row.name" size="sm"
v-slot="{ idx, column, item }" @click="
:row="row" (event) =>
> emit('applyFilter', {
<div v-if="column.key === '_assign'" class="flex items-center"> event,
<MultipleAvatar idx,
:avatars="item" column,
size="sm" 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=" @click="
(event) => (event) =>
emit('applyFilter', { emit('applyFilter', {
@ -51,56 +104,37 @@
firstColumn: columns[0], firstColumn: columns[0],
}) })
" "
/> >
</div> <Tooltip :text="item.label">
<ListRowItem v-else :item="item"> <div>{{ item.timeAgo }}</div>
<template #prefix> </Tooltip>
<div v-if="column.key === 'status'"> </div>
<IndicatorIcon :class="item.color" /> <div v-else-if="column.key === '_liked_by'">
</div> <Button
<div v-else-if="column.key === 'lead_name'"> v-if="column.key == '_liked_by'"
<Avatar variant="ghosted"
v-if="item.label" :class="isLiked(item) ? 'fill-red-500' : 'fill-white'"
class="flex items-center" @click.stop.prevent="
:image="item.image" () =>
:label="item.image_label" emit('likeDoc', {
size="sm" name: row.name,
/> liked: isLiked(item),
</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" >
<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=" @click="
(event) => (event) =>
emit('applyFilter', { emit('applyFilter', {
@ -111,73 +145,34 @@
firstColumn: columns[0], firstColumn: columns[0],
}) })
" "
> />
<Tooltip :text="item.label"> </div>
<div>{{ item.timeAgo }}</div> <div v-else-if="column.type === 'Check'">
</Tooltip> <FormControl
</div> type="checkbox"
<div v-else-if="column.key === '_liked_by'"> :modelValue="item"
<Button :disabled="true"
v-if="column.key == '_liked_by'" class="text-gray-900"
variant="ghosted" />
:class="isLiked(item) ? 'fill-red-500' : 'fill-white'" </div>
@click.stop.prevent=" <div
() => v-else
emit('likeDoc', { name: row.name, liked: isLiked(item) }) class="truncate text-base"
" @click="
> (event) =>
<HeartIcon class="h-4 w-4" /> emit('applyFilter', {
</Button> event,
</div> idx,
<div column,
v-else-if="column.key === 'sla_status'" item,
class="truncate text-base" firstColumn: columns[0],
> })
<Badge "
v-if="item.value" >
:variant="'subtle'" {{ label }}
:theme="item.color" </div>
size="md" </template>
:label="item.value" </ListRowItem>
@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>
</ListRows> </ListRows>
<ListSelectBanner> <ListSelectBanner>
<template #actions="{ selections, unselectAll }"> <template #actions="{ selections, unselectAll }">
@ -191,7 +186,7 @@
</ListView> </ListView>
<ListFooter <ListFooter
v-if="pageLengthCount" v-if="pageLengthCount"
class="border-t px-5 py-2" class="border-t sm:px-5 px-3 py-2"
v-model="pageLengthCount" v-model="pageLengthCount"
:options="{ :options="{
rowCount: options.rowCount, rowCount: options.rowCount,
@ -208,13 +203,12 @@ import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue' import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue' import MultipleAvatar from '@/components/MultipleAvatar.vue'
import ListBulkActions from '@/components/ListBulkActions.vue' import ListBulkActions from '@/components/ListBulkActions.vue'
import ListRows from '@/components/ListViews/ListRows.vue'
import { import {
Avatar, Avatar,
ListView, ListView,
ListHeader, ListHeader,
ListHeaderItem, ListHeaderItem,
ListRows,
ListRow,
ListSelectBanner, ListSelectBanner,
ListRowItem, ListRowItem,
ListFooter, ListFooter,

View File

@ -0,0 +1,66 @@
<template>
<div class="mx-3 mt-2 h-full overflow-y-auto sm:mx-5" 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-3 sm: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>

View File

@ -13,7 +13,7 @@
}" }"
row-key="name" row-key="name"
> >
<ListHeader class="mx-5" @columnWidthUpdated="emit('columnWidthUpdated')"> <ListHeader class="sm:mx-5 mx-3" @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeaderItem <ListHeaderItem
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
@ -31,9 +31,8 @@
</Button> </Button>
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows id="list-rows"> <ListRows class="mx-3 sm:mx-5" id="list-rows">
<ListRow <ListRow
class="mx-5"
v-for="row in rows" v-for="row in rows"
:key="row.name" :key="row.name"
v-slot="{ idx, column, item }" v-slot="{ idx, column, item }"
@ -122,7 +121,7 @@
</ListSelectBanner> </ListSelectBanner>
</ListView> </ListView>
<ListFooter <ListFooter
class="border-t px-5 py-2" class="border-t sm:px-5 px-3 py-2"
v-model="pageLengthCount" v-model="pageLengthCount"
:options="{ :options="{
rowCount: options.rowCount, rowCount: options.rowCount,

View File

@ -10,7 +10,10 @@
}" }"
row-key="name" row-key="name"
> >
<ListHeader class="mx-5" @columnWidthUpdated="emit('columnWidthUpdated')"> <ListHeader
class="mx-3 sm:mx-5"
@columnWidthUpdated="emit('columnWidthUpdated')"
>
<ListHeaderItem <ListHeaderItem
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
@ -28,9 +31,8 @@
</Button> </Button>
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows id="list-rows"> <ListRows class="mx-3 sm:mx-5" id="list-rows">
<ListRow <ListRow
class="mx-5"
v-for="row in rows" v-for="row in rows"
:key="row.name" :key="row.name"
v-slot="{ idx, column, item }" v-slot="{ idx, column, item }"
@ -135,7 +137,7 @@
</ListSelectBanner> </ListSelectBanner>
</ListView> </ListView>
<ListFooter <ListFooter
class="border-t px-5 py-2" class="border-t px-3 py-2 sm:px-5"
v-model="pageLengthCount" v-model="pageLengthCount"
:options="{ :options="{
rowCount: options.rowCount, rowCount: options.rowCount,

View File

@ -12,5 +12,5 @@
<script setup> <script setup>
import CallUI from '@/components/CallUI.vue' import CallUI from '@/components/CallUI.vue'
import { mobileSidebarOpened as sidebarOpened } from '@/stores/settings' import { mobileSidebarOpened as sidebarOpened } from '@/composables/settings'
</script> </script>

View File

@ -103,7 +103,7 @@ import SidebarLink from '@/components/SidebarLink.vue'
import { viewsStore } from '@/stores/views' import { viewsStore } from '@/stores/views'
import { notificationsStore } from '@/stores/notifications' import { notificationsStore } from '@/stores/notifications'
import { computed, h } from 'vue' import { computed, h } from 'vue'
import { mobileSidebarOpened as sidebarOpened } from '@/stores/settings' import { mobileSidebarOpened as sidebarOpened } from '@/composables/settings'
const { getPinnedViews, getPublicViews } = viewsStore() const { getPinnedViews, getPublicViews } = viewsStore()
@ -184,6 +184,7 @@ function parseView(views) {
icon: getIcon(view.route_name, view.icon), icon: getIcon(view.route_name, view.icon),
to: { to: {
name: view.route_name, name: view.route_name,
params: { viewType: view.type || 'list' },
query: { view: view.name }, query: { view: view.name },
}, },
} }

View File

@ -124,7 +124,7 @@ import { useRouter } from 'vue-router'
const props = defineProps({ const props = defineProps({
name: { name: {
type: Object, type: String,
default: {}, default: {},
}, },
}) })

View File

@ -60,7 +60,7 @@
type="textarea" type="textarea"
variant="outline" variant="outline"
ref="content" ref="content"
rows="10" :rows="10"
v-model="_emailTemplate.response_html" v-model="_emailTemplate.response_html"
:placeholder=" :placeholder="
__( __(

View File

@ -75,6 +75,7 @@ const duplicateMode = ref(false)
const _view = ref({ const _view = ref({
name: '', name: '',
label: '', label: '',
type: 'list',
icon: '', icon: '',
filters: {}, filters: {},
order_by: 'modified desc', order_by: 'modified desc',

View File

@ -8,7 +8,7 @@
:class="[field.hidden && 'hidden']" :class="[field.hidden && 'hidden']"
class="flex items-center gap-2 px-3 leading-5 first:mt-3" class="flex items-center gap-2 px-3 leading-5 first:mt-3"
> >
<Tooltip :text="__(field.label)" hoverDelay="1"> <Tooltip :text="__(field.label)" :hoverDelay="1">
<div class="sm:w-[106px] w-36 shrink-0 truncate text-sm text-gray-600"> <div class="sm:w-[106px] w-36 shrink-0 truncate text-sm text-gray-600">
<span>{{ __(field.label) }}</span> <span>{{ __(field.label) }}</span>
<span class="text-red-500">{{ field.reqd ? ' *' : '' }}</span> <span class="text-red-500">{{ field.reqd ? ' *' : '' }}</span>

View File

@ -21,7 +21,7 @@
</span> </span>
</slot> </slot>
</Tooltip> </Tooltip>
<Tooltip :text="label" placement="right" :disabled="isCollapsed" hoverDelay="1.5"> <Tooltip :text="label" placement="right" :disabled="isCollapsed" :hoverDelay="1.5">
<span <span
class="flex-1 flex-shrink-0 truncate text-base duration-300 ease-in-out" class="flex-1 flex-shrink-0 truncate text-base duration-300 ease-in-out"
:class=" :class="
@ -43,7 +43,7 @@
import { Tooltip } from 'frappe-ui' import { Tooltip } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { isMobileView, mobileSidebarOpened } from '@/stores/settings' import { isMobileView, mobileSidebarOpened } from '@/composables/settings'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()

View File

@ -1,8 +1,29 @@
<template> <template>
<NestedPopover> <Autocomplete
<template #target> v-if="!sortValues?.size"
<Button :label="__('Sort')" ref="sortButtonRef"> :options="options"
<template #prefix><SortIcon class="h-4" /></template> value=""
:placeholder="__('First Name')"
@change="(e) => setSort(e)"
>
<template #target="{ togglePopover }">
<Button :label="__('Sort')" @click="togglePopover()">
<template v-if="hideLabel">
<SortIcon class="h-4" />
</template>
<template v-if="!hideLabel && !sortValues?.size" #prefix>
<SortIcon class="h-4" />
</template>
</Button>
</template>
</Autocomplete>
<NestedPopover v-else>
<template #target="{ open }">
<Button v-if="sortValues.size > 1" :label="__('Sort')">
<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> <template v-if="sortValues?.size" #suffix>
<div <div
class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white" class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
@ -11,10 +32,43 @@
</div> </div>
</template> </template>
</Button> </Button>
<div v-else class="flex items-center justify-center">
<Button
v-if="sortValues.size"
class="rounded-r-none border-r"
@click.stop="
() => {
Array.from(sortValues)[0].direction =
Array.from(sortValues)[0].direction == 'asc' ? 'desc' : 'asc'
apply()
}
"
>
<AscendingIcon
v-if="Array.from(sortValues)[0].direction == 'asc'"
class="h-4"
/>
<DesendingIcon v-else class="h-4" />
</Button>
<Button
:label="getSortLabel()"
:class="sortValues.size ? 'rounded-l-none' : ''"
>
<template v-if="!hideLabel && !sortValues?.size" #prefix>
<SortIcon class="h-4" />
</template>
<template v-if="sortValues?.size" #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</div>
</template> </template>
<template #body="{ close }"> <template #body="{ close }">
<div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl"> <div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
<div class="min-w-[352px] p-2"> <div class="min-w-60 p-2">
<div <div
v-if="sortValues?.size" v-if="sortValues?.size"
id="sort-list" id="sort-list"
@ -23,34 +77,51 @@
<div <div
v-for="(sort, i) in sortValues" v-for="(sort, i) in sortValues"
:key="sort.fieldname" :key="sort.fieldname"
class="flex items-center gap-2" class="flex items-center gap-1"
> >
<div class="handle flex h-7 w-7 items-center justify-center"> <div class="handle flex h-7 w-7 items-center justify-center">
<DragIcon class="h-4 w-4 cursor-grab text-gray-600" /> <DragIcon class="h-4 w-4 cursor-grab text-gray-600" />
</div> </div>
<Autocomplete <div class="flex">
class="!w-32" <Button
:value="sort.fieldname" size="md"
:options="sortOptions.data" class="rounded-r-none border-r"
@change="(e) => updateSort(e, i)" @click="
:placeholder="__('First Name')" () => {
/> sort.direction = sort.direction == 'asc' ? 'desc' : 'asc'
<FormControl apply()
class="!w-32" }
type="select" "
v-model="sort.direction" >
:options="[ <AscendingIcon v-if="sort.direction == 'asc'" class="h-4" />
{ label: __('Ascending'), value: 'asc' }, <DesendingIcon v-else class="h-4" />
{ label: __('Descending'), value: 'desc' }, </Button>
]" <Autocomplete
@change=" class="!w-32"
(e) => { :value="sort.fieldname"
sort.direction = e.target.value :options="sortOptions.data"
apply() @change="(e) => updateSort(e, i)"
} :placeholder="__('First Name')"
" >
:placeholder="__('Ascending')" <template
/> #target="{ togglePopover, selectedValue, displayValue }"
>
<Button
class="flex w-full items-center justify-between rounded-l-none !text-gray-600"
size="md"
@click="togglePopover()"
>
{{ displayValue(selectedValue) }}
<template #suffix>
<FeatherIcon
name="chevron-down"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
</Autocomplete>
</div>
<Button variant="ghost" icon="x" @click="removeSort(i)" /> <Button variant="ghost" icon="x" @click="removeSort(i)" />
</div> </div>
</div> </div>
@ -95,26 +166,30 @@
</template> </template>
<script setup> <script setup>
import AscendingIcon from '@/components/Icons/AscendingIcon.vue'
import DesendingIcon from '@/components/Icons/DesendingIcon.vue'
import NestedPopover from '@/components/NestedPopover.vue' import NestedPopover from '@/components/NestedPopover.vue'
import SortIcon from '@/components/Icons/SortIcon.vue' import SortIcon from '@/components/Icons/SortIcon.vue'
import DragIcon from '@/components/Icons/DragIcon.vue' import DragIcon from '@/components/Icons/DragIcon.vue'
import { useSortable } from '@vueuse/integrations/useSortable'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue' import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { useSortable } from '@vueuse/integrations/useSortable'
import { createResource } from 'frappe-ui' import { createResource } from 'frappe-ui'
import { computed, ref, nextTick, onMounted } from 'vue' import { computed, nextTick, onMounted } from 'vue'
const props = defineProps({ const props = defineProps({
doctype: { doctype: {
type: String, type: String,
required: true, required: true,
}, },
hideLabel: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['update']) const emit = defineEmits(['update'])
const list = defineModel() const list = defineModel()
const sortButtonRef = ref(null)
const sortOptions = createResource({ const sortOptions = createResource({
url: 'crm.api.doc.sort_options', url: 'crm.api.doc.sort_options',
cache: ['sortOptions', props.doctype], cache: ['sortOptions', props.doctype],
@ -161,6 +236,16 @@ const sortSortable = useSortable('#sort-list', sortValues, {
onEnd: () => apply(), onEnd: () => apply(),
}) })
function getSortLabel() {
if (!sortValues.value.size) return __('Sort')
let values = Array.from(sortValues.value)
let label = sortOptions.data?.find(
(option) => option.value === values[0].fieldname
)?.label
return label || sort.fieldname
}
function setSort(data) { function setSort(data) {
sortValues.value.add({ fieldname: data.value, direction: 'asc' }) sortValues.value.add({ fieldname: data.value, direction: 'asc' })
restartSort() restartSort()

View File

@ -1,9 +1,9 @@
<template> <template>
<div <div
v-if="isMobileView" v-if="isMobileView"
class="flex flex-col justify-between gap-2 px-5 py-4" class="flex flex-col justify-between gap-2 sm:px-5 px-3 py-4"
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2 overflow-x-auto">
<div class="flex gap-2"> <div class="flex gap-2">
<Dropdown :options="viewsDropdownOptions"> <Dropdown :options="viewsDropdownOptions">
<template #default="{ open }"> <template #default="{ open }">
@ -12,7 +12,12 @@
<div v-if="isEmoji(currentView.icon)"> <div v-if="isEmoji(currentView.icon)">
{{ currentView.icon }} {{ currentView.icon }}
</div> </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>
<template #suffix> <template #suffix>
<FeatherIcon <FeatherIcon
@ -36,19 +41,35 @@
</Button> </Button>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2 overflow-x-auto">
<Filter
v-model="list"
:doctype="doctype"
:default_filters="filters"
@update="updateFilter"
/>
<div class="flex gap-2"> <div class="flex gap-2">
<SortBy v-model="list" :doctype="doctype" @update="updateSort" /> <Filter
v-model="list"
:doctype="doctype"
:default_filters="filters"
@update="updateFilter"
/>
<GroupBy
v-if="route.params.viewType === 'group_by'"
v-model="list"
:doctype="doctype"
:hideLabel="isMobileView"
@update="updateGroupBy"
/>
</div>
<div class="flex gap-2">
<SortBy
v-model="list"
:doctype="doctype"
@update="updateSort"
:hideLabel="isMobileView"
/>
<ColumnSettings <ColumnSettings
v-if="!options.hideColumnsButton" v-if="!options.hideColumnsButton"
v-model="list" v-model="list"
:doctype="doctype" :doctype="doctype"
:hideLabel="isMobileView"
@update="(isDefault) => updateColumns(isDefault)" @update="(isDefault) => updateColumns(isDefault)"
/> />
</div> </div>
@ -69,7 +90,12 @@
<Button :label="__(currentView.label)"> <Button :label="__(currentView.label)">
<template #prefix> <template #prefix>
<div v-if="isEmoji(currentView.icon)">{{ currentView.icon }}</div> <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>
<template #suffix> <template #suffix>
<FeatherIcon <FeatherIcon
@ -117,6 +143,12 @@
<RefreshIcon class="h-4 w-4" /> <RefreshIcon class="h-4 w-4" />
</template> </template>
</Button> </Button>
<GroupBy
v-if="route.params.viewType === 'group_by'"
v-model="list"
:doctype="doctype"
@update="updateGroupBy"
/>
<Filter <Filter
v-model="list" v-model="list"
:doctype="doctype" :doctype="doctype"
@ -162,7 +194,11 @@
afterCreate: async (v) => { afterCreate: async (v) => {
await reloadView() await reloadView()
viewUpdated = false 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: () => { afterUpdate: () => {
viewUpdated = false viewUpdated = false
@ -212,6 +248,7 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import QuickFilterField from '@/components/QuickFilterField.vue' import QuickFilterField from '@/components/QuickFilterField.vue'
import RefreshIcon from '@/components/Icons/RefreshIcon.vue' import RefreshIcon from '@/components/Icons/RefreshIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue' import EditIcon from '@/components/Icons/EditIcon.vue'
@ -221,6 +258,7 @@ import UnpinIcon from '@/components/Icons/UnpinIcon.vue'
import ViewModal from '@/components/Modals/ViewModal.vue' import ViewModal from '@/components/Modals/ViewModal.vue'
import SortBy from '@/components/SortBy.vue' import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue' import Filter from '@/components/Filter.vue'
import GroupBy from '@/components/GroupBy.vue'
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue' import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
import ColumnSettings from '@/components/ColumnSettings.vue' import ColumnSettings from '@/components/ColumnSettings.vue'
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
@ -228,10 +266,11 @@ import { viewsStore } from '@/stores/views'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { isEmoji } from '@/utils' import { isEmoji } from '@/utils'
import { createResource, Dropdown, call, FeatherIcon } from 'frappe-ui' import { createResource, Dropdown, call, FeatherIcon } from 'frappe-ui'
import { computed, ref, onMounted, watch, h } from 'vue' import { computed, ref, onMounted, watch, h, markRaw } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useDebounceFn } from '@vueuse/core' import { useDebounceFn } from '@vueuse/core'
import { isMobileView } from '@/stores/settings' import { isMobileView } from '@/composables/settings'
import _ from 'lodash'
const props = defineProps({ const props = defineProps({
doctype: { doctype: {
@ -247,6 +286,7 @@ const props = defineProps({
default: { default: {
hideColumnsButton: false, hideColumnsButton: false,
defaultViewName: '', defaultViewName: '',
allowedViews: ['list'],
}, },
}, },
}) })
@ -268,17 +308,35 @@ const defaultParams = ref('')
const viewUpdated = ref(false) const viewUpdated = ref(false)
const showViewModal = 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: markRaw(DetailsIcon),
},
}
return types[viewType]
}
const currentView = computed(() => { const currentView = computed(() => {
let _view = getView(route.query.view) let _view = getView(route.query.view, route.params.viewType, props.doctype)
return { return {
label: _view?.label || props.options?.defaultViewName || 'List View', label:
icon: _view?.icon || 'list', _view?.label || props.options?.defaultViewName || getViewType().label,
icon: _view?.icon || getViewType().icon,
} }
}) })
const view = ref({ const view = ref({
name: '', name: '',
label: '', label: '',
type: 'list',
icon: '', icon: '',
filters: {}, filters: {},
order_by: 'modified desc', order_by: 'modified desc',
@ -308,7 +366,7 @@ watch(updatedPageCount, (value) => {
}) })
function getParams() { 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 filters = (_view?.filters && JSON.parse(_view.filters)) || {}
const order_by = _view?.order_by || 'modified desc' const order_by = _view?.order_by || 'modified desc'
const columns = _view?.columns || '' const columns = _view?.columns || ''
@ -318,9 +376,11 @@ function getParams() {
view.value = { view.value = {
name: _view.name, name: _view.name,
label: _view.label, label: _view.label,
type: _view.type || 'list',
icon: _view.icon, icon: _view.icon,
filters: _view.filters, filters: _view.filters,
order_by: _view.order_by, order_by: _view.order_by,
group_by_field: _view.group_by_field,
columns: _view.columns, columns: _view.columns,
rows: _view.rows, rows: _view.rows,
route_name: _view.route_name, route_name: _view.route_name,
@ -331,13 +391,15 @@ function getParams() {
} else { } else {
view.value = { view.value = {
name: '', name: '',
label: '', label: getViewType().label,
type: route.params.viewType || 'list',
icon: '', icon: '',
filters: {}, filters: {},
order_by: 'modified desc', order_by: 'modified desc',
group_by_field: 'owner',
columns: '', columns: '',
rows: '', rows: '',
route_name: '', route_name: route.name,
load_default_columns: true, load_default_columns: true,
pinned: false, pinned: false,
public: false, public: false,
@ -352,7 +414,11 @@ function getParams() {
rows: rows, rows: rows,
page_length: pageLength.value, page_length: pageLength.value,
page_length_count: pageLengthCount.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, default_filters: props.filters,
} }
} }
@ -360,15 +426,9 @@ function getParams() {
list.value = createResource({ list.value = createResource({
url: 'crm.api.doc.get_list_data', url: 'crm.api.doc.get_list_data',
params: getParams(), params: getParams(),
cache: [props.doctype, route.query.view], cache: [props.doctype, route.query.view, route.params.viewType],
transform(data) {
return {
...data,
params: getParams(),
}
},
onSuccess(data) { 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() let params = list.value.params ? list.value.params : getParams()
defaultParams.value = { defaultParams.value = {
doctype: props.doctype, doctype: props.doctype,
@ -378,7 +438,11 @@ list.value = createResource({
page_length_count: params.page_length_count, page_length_count: params.page_length_count,
columns: data.columns, columns: data.columns,
rows: data.rows, 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, default_filters: props.filters,
} }
}, },
@ -412,23 +476,37 @@ async function exportRows() {
export_type.value = 'Excel' 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'), label: __(props.options?.defaultViewName) || __('List View'),
icon: 'list', icon: 'list',
onClick() { onClick() {
viewUpdated.value = false viewUpdated.value = false
router.push({ name: route.name }) router.push({ name: route.name })
}, },
}, })
] }
if (allowedViews.includes('group_by')) {
defaultViews.push({
label: __(props.options?.defaultViewName) || __('Group By View'),
icon: markRaw(DetailsIcon),
onClick() {
viewUpdated.value = false
router.push({ name: route.name, params: { viewType: 'group_by' } })
},
})
}
function getIcon(icon) { function getIcon(icon, type) {
if (isEmoji(icon)) { if (isEmoji(icon)) {
return h('div', icon) return h('div', icon)
} else { } else if (!icon && type === 'group_by') {
return icon || 'list' return markRaw(DetailsIcon)
} }
return icon || 'list'
} }
const viewsDropdownOptions = computed(() => { const viewsDropdownOptions = computed(() => {
@ -443,14 +521,19 @@ const viewsDropdownOptions = computed(() => {
if (list.value?.data?.views) { if (list.value?.data?.views) {
list.value.data.views.forEach((view) => { list.value.data.views.forEach((view) => {
view.label = __(view.label) view.label = __(view.label)
view.icon = getIcon(view.icon) view.type = view.type || 'list'
view.icon = getIcon(view.icon, view.type)
view.filters = view.filters =
typeof view.filters == 'string' typeof view.filters == 'string'
? JSON.parse(view.filters) ? JSON.parse(view.filters)
: view.filters : view.filters
view.onClick = () => { view.onClick = () => {
viewUpdated.value = false 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) let publicViews = list.value.data.views.filter((v) => v.public)
@ -491,7 +574,15 @@ const quickFilterList = computed(() => {
if (list.value.params?.filters[filter.name]) { if (list.value.params?.filters[filter.name]) {
let value = list.value.params.filters[filter.name] let value = list.value.params.filters[filter.name]
if (Array.isArray(value)) { if (Array.isArray(value)) {
filter['value'] = value[1].replace(/%/g, '') if (
(['Check', 'Select', 'Link', 'Date', 'Datetime'].includes(
filter.type
) &&
value[0]?.toLowerCase() == 'like') ||
value[0]?.toLowerCase() != 'like'
)
return
filter['value'] = value[1]?.replace(/%/g, '')
} else { } else {
filter['value'] = value.replace(/%/g, '') filter['value'] = value.replace(/%/g, '')
} }
@ -555,6 +646,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) { function updateColumns(obj) {
if (!obj) { if (!obj) {
obj = { obj = {
@ -601,10 +707,12 @@ function create_or_update_default_view() {
reloadView() reloadView()
view.value = { view.value = {
label: view.value.label, label: view.value.label,
type: view.value.type || 'list',
icon: view.value.icon, icon: view.value.icon,
name: view.value.name, name: view.value.name,
filters: defaultParams.value.filters, filters: defaultParams.value.filters,
order_by: defaultParams.value.order_by, order_by: defaultParams.value.order_by,
group_by_field: defaultParams.value.view.group_by_field,
columns: defaultParams.value.columns, columns: defaultParams.value.columns,
rows: defaultParams.value.rows, rows: defaultParams.value.rows,
route_name: route.name, route_name: route.name,
@ -708,7 +816,10 @@ const viewActions = computed(() => {
const viewModalObj = ref({}) const viewModalObj = ref({})
function duplicateView() { 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.name = ''
view.value.label = label + __(' (New)') view.value.label = label + __(' (New)')
viewModalObj.value = view.value viewModalObj.value = view.value
@ -716,9 +827,9 @@ function duplicateView() {
} }
function editView() { 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.name = route.query.view
view.value.label = __(cView?.label) || __('List View') view.value.label = __(cView?.label) || getViewType().label
view.value.icon = cView?.icon || '' view.value.icon = cView?.icon || ''
viewModalObj.value = view.value viewModalObj.value = view.value
showViewModal.value = true showViewModal.value = true
@ -762,10 +873,12 @@ function cancelChanges() {
function saveView() { function saveView() {
view.value = { view.value = {
label: view.value.label, label: view.value.label,
type: view.value.type || 'list',
icon: view.value.icon, icon: view.value.icon,
name: view.value.name, name: view.value.name,
filters: defaultParams.value.filters, filters: defaultParams.value.filters,
order_by: defaultParams.value.order_by, order_by: defaultParams.value.order_by,
group_by_field: defaultParams.value.view.group_by_field,
columns: defaultParams.value.columns, columns: defaultParams.value.columns,
rows: defaultParams.value.rows, rows: defaultParams.value.rows,
route_name: route.name, route_name: route.name,
@ -830,19 +943,16 @@ defineExpose({ applyFilter, applyLikeFilter, likeDoc })
// Watchers // Watchers
watch( watch(
() => getView(route.query.view), () => getView(route.query.view, route.params.viewType, props.doctype),
(value, old_value) => { (value, old_value) => {
if (JSON.stringify(value) === JSON.stringify(old_value)) return if (_.isEqual(value, old_value)) return
reload() reload()
}, },
{ deep: true } { deep: true }
) )
watch( watch([() => route, () => route.params.viewType], (value, old_value) => {
() => route, if (value[0] === old_value[0] && value[1] === value[0]) return
(value, old_value) => { reload()
if (value === old_value) return })
reload()
}
)
</script> </script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="reply?.message" v-if="reply?.message"
class="flex items-center justify-around gap-2 sm:px-10 px-4 pt-2" class="flex items-center justify-around gap-2 px-4 pt-2 sm:px-10"
> >
<div <div
class="mb-1 ml-13 flex-1 cursor-pointer rounded border-0 border-l-4 border-green-500 bg-gray-100 p-2 text-base text-gray-600" class="mb-1 ml-13 flex-1 cursor-pointer rounded border-0 border-l-4 border-green-500 bg-gray-100 p-2 text-base text-gray-600"
@ -18,7 +18,7 @@
<Button variant="ghost" icon="x" @click="reply = {}" /> <Button variant="ghost" icon="x" @click="reply = {}" />
</div> </div>
<div class="flex items-end gap-2 sm:px-10 px-4 py-2.5"> <div class="flex items-end gap-2 px-4 py-2.5 sm:px-10" v-bind="$attrs">
<div class="flex h-8 items-center gap-2"> <div class="flex h-8 items-center gap-2">
<FileUploader @success="(file) => uploadFile(file)"> <FileUploader @success="(file) => uploadFile(file)">
<template v-slot="{ openFileSelector }"> <template v-slot="{ openFileSelector }">

View File

@ -2,7 +2,16 @@
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }"> <Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
<Popover class="w-full" v-model:show="showOptions"> <Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }"> <template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }"> <slot
name="target"
v-bind="{
open: openPopover,
togglePopover,
isOpen: showOptions,
selectedValue,
displayValue,
}"
>
<div class="w-full"> <div class="w-full">
<button <button
class="flex w-full items-center justify-between focus:outline-none" class="flex w-full items-center justify-between focus:outline-none"

View File

@ -1,23 +0,0 @@
import { onMounted, onUnmounted, reactive } from 'vue'
export function useScreenSize() {
const size = reactive({
width: window.innerWidth,
height: window.innerHeight,
})
const onResize = () => {
size.width = window.innerWidth
size.height = window.innerHeight
}
onMounted(() => {
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
return size
}

View File

@ -1,5 +1,4 @@
import { createResource } from 'frappe-ui' import { createResource } from 'frappe-ui'
import { useScreenSize } from '@/composables'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
export const whatsappEnabled = ref(false) export const whatsappEnabled = ref(false)
@ -23,5 +22,4 @@ createResource({
export const mobileSidebarOpened = ref(false) export const mobileSidebarOpened = ref(false)
const screenSize = useScreenSize() export const isMobileView = computed(() => window.innerWidth < 768)
export const isMobileView = computed(() => screenSize.width < 768)

View File

@ -50,11 +50,7 @@
<span>{{ __('No {0} Found', [__('Logs')]) }}</span> <span>{{ __('No {0} Found', [__('Logs')]) }}</span>
</div> </div>
</div> </div>
<CallLogModal <CallLogModal v-model="showCallLogModal" :name="selectedCallLog" />
v-model="showCallLogModal"
v-model:reloadCallLogs="callLogs"
:name="selectedCallLog"
/>
</template> </template>
<script setup> <script setup>

View File

@ -238,7 +238,7 @@ import { globalStore } from '@/stores/global.js'
import { usersStore } from '@/stores/users.js' import { usersStore } from '@/stores/users.js'
import { organizationsStore } from '@/stores/organizations.js' import { organizationsStore } from '@/stores/organizations.js'
import { statusesStore } from '@/stores/statuses' import { statusesStore } from '@/stores/statuses'
import { callEnabled } from '@/stores/settings' import { callEnabled } from '@/composables/settings'
import { ref, computed, h } from 'vue' import { ref, computed, h } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'

View File

@ -321,7 +321,7 @@ import {
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
import { organizationsStore } from '@/stores/organizations' import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses' import { statusesStore } from '@/stores/statuses'
import { whatsappEnabled, callEnabled } from '@/stores/settings' import { whatsappEnabled, callEnabled } from '@/composables/settings'
import { import {
createResource, createResource,
Dropdown, Dropdown,

View File

@ -24,6 +24,9 @@
v-model:resizeColumn="triggerResize" v-model:resizeColumn="triggerResize"
v-model:updatedPageCount="updatedPageCount" v-model:updatedPageCount="updatedPageCount"
doctype="CRM Deal" doctype="CRM Deal"
:options="{
allowedViews: ['list', 'group_by'],
}"
/> />
<DealsListView <DealsListView
ref="dealsListView" ref="dealsListView"
@ -61,6 +64,7 @@
<script setup> <script setup>
import CustomActions from '@/components/CustomActions.vue' import CustomActions from '@/components/CustomActions.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue' import DealsIcon from '@/components/Icons/DealsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue' import DealsListView from '@/components/ListViews/DealsListView.vue'
@ -77,7 +81,8 @@ import {
formatTime, formatTime,
} from '@/utils' } from '@/utils'
import { Breadcrumbs } from 'frappe-ui' 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' } }] const breadcrumbs = [{ label: __('Deals'), route: { name: 'Deals' } }]
@ -85,6 +90,8 @@ const { getUser } = usersStore()
const { getOrganization } = organizationsStore() const { getOrganization } = organizationsStore()
const { getDealStatus } = statusesStore() const { getDealStatus } = statusesStore()
const route = useRoute()
const dealsListView = ref(null) const dealsListView = ref(null)
const showDealModal = ref(false) const showDealModal = ref(false)
@ -98,7 +105,49 @@ const viewControls = ref(null)
// Rows // Rows
const rows = computed(() => { const rows = computed(() => {
if (!deals.value?.data?.data) return [] 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 = {} let _rows = {}
deals.value.data.rows.forEach((row) => { deals.value.data.rows.forEach((row) => {
_rows[row] = deal[row] _rows[row] = deal[row]
@ -174,5 +223,5 @@ const rows = computed(() => {
}) })
return _rows return _rows
}) })
}) }
</script> </script>

View File

@ -293,7 +293,7 @@ import { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts' import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations' import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses' import { statusesStore } from '@/stores/statuses'
import { whatsappEnabled, callEnabled } from '@/stores/settings' import { whatsappEnabled, callEnabled } from '@/composables/settings'
import { import {
createResource, createResource,
FileUploader, FileUploader,

View File

@ -25,6 +25,9 @@
v-model:updatedPageCount="updatedPageCount" v-model:updatedPageCount="updatedPageCount"
doctype="CRM Lead" doctype="CRM Lead"
:filters="{ converted: 0 }" :filters="{ converted: 0 }"
:options="{
allowedViews: ['list', 'group_by'],
}"
/> />
<LeadsListView <LeadsListView
ref="leadsListView" ref="leadsListView"
@ -62,6 +65,7 @@
<script setup> <script setup>
import CustomActions from '@/components/CustomActions.vue' import CustomActions from '@/components/CustomActions.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue' import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue' import LayoutHeader from '@/components/LayoutHeader.vue'
import LeadsListView from '@/components/ListViews/LeadsListView.vue' import LeadsListView from '@/components/ListViews/LeadsListView.vue'
@ -78,8 +82,8 @@ import {
createToast, createToast,
} from '@/utils' } from '@/utils'
import { createResource, Breadcrumbs } from 'frappe-ui' import { createResource, Breadcrumbs } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ref, computed, reactive } from 'vue' import { ref, computed, reactive, h } from 'vue'
const breadcrumbs = [{ label: __('Leads'), route: { name: 'Leads' } }] const breadcrumbs = [{ label: __('Leads'), route: { name: 'Leads' } }]
@ -88,6 +92,7 @@ const { getOrganization } = organizationsStore()
const { getLeadStatus } = statusesStore() const { getLeadStatus } = statusesStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
const leadsListView = ref(null) const leadsListView = ref(null)
const showLeadModal = ref(false) const showLeadModal = ref(false)
@ -102,7 +107,49 @@ const viewControls = ref(null)
// Rows // Rows
const rows = computed(() => { const rows = computed(() => {
if (!leads.value?.data?.data) return [] 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 = {} let _rows = {}
leads.value?.data.rows.forEach((row) => { leads.value?.data.rows.forEach((row) => {
_rows[row] = lead[row] _rows[row] = lead[row]
@ -182,7 +229,7 @@ const rows = computed(() => {
}) })
return _rows return _rows
}) })
}) }
let newLead = reactive({ let newLead = reactive({
salutation: '', salutation: '',

View File

@ -277,7 +277,7 @@ import {
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
import { organizationsStore } from '@/stores/organizations' import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses' import { statusesStore } from '@/stores/statuses'
import { whatsappEnabled, callEnabled, isMobileView } from '@/stores/settings' import { whatsappEnabled, callEnabled, isMobileView } from '@/composables/settings'
import { import {
createResource, createResource,
Dropdown, Dropdown,

View File

@ -195,7 +195,7 @@ import { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts' import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations' import { organizationsStore } from '@/stores/organizations'
import { statusesStore } from '@/stores/statuses' import { statusesStore } from '@/stores/statuses'
import { whatsappEnabled, callEnabled, isMobileView } from '@/stores/settings' import { whatsappEnabled, callEnabled, isMobileView } from '@/composables/settings'
import { import {
createResource, createResource,
Dropdown, Dropdown,

View File

@ -22,7 +22,7 @@
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<div <div
v-if="notes.data?.data?.length" v-if="notes.data?.data?.length"
class="grid sm:grid-cols-4 grid-cols-1 gap-4 px-5 pb-3" class="grid grid-cols-1 gap-2 px-3 pb-2 sm:grid-cols-4 sm:gap-4 sm:px-5 sm:pb-3"
> >
<div <div
v-for="note in notes.data.data" v-for="note in notes.data.data"
@ -75,7 +75,7 @@
</div> </div>
<ListFooter <ListFooter
v-if="notes.data?.data?.length" v-if="notes.data?.data?.length"
class="border-t px-5 py-2" class="border-t px-3 py-2 sm:px-5"
v-model="notes.data.page_length_count" v-model="notes.data.page_length_count"
:options="{ :options="{
rowCount: notes.data.row_count, rowCount: notes.data.row_count,

View File

@ -14,25 +14,27 @@ const routes = [
component: () => import('@/pages/MobileNotification.vue'), component: () => import('@/pages/MobileNotification.vue'),
}, },
{ {
path: '/leads', alias: '/leads',
path: '/leads/view/:viewType?',
name: 'Leads', name: 'Leads',
component: () => import('@/pages/Leads.vue'), component: () => import('@/pages/Leads.vue'),
meta: { scrollPos: { top: 0, left: 0 } }, meta: { scrollPos: { top: 0, left: 0 } },
}, },
{ {
path: '/leads/:leadId/:tabName?', path: '/leads/:leadId',
name: 'Lead', name: 'Lead',
component: () => import(`@/pages/${handleMobileView('Lead')}.vue`), component: () => import(`@/pages/${handleMobileView('Lead')}.vue`),
props: true, props: true,
}, },
{ {
path: '/deals', alias: '/deals',
path: '/deals/view/:viewType?',
name: 'Deals', name: 'Deals',
component: () => import('@/pages/Deals.vue'), component: () => import('@/pages/Deals.vue'),
meta: { scrollPos: { top: 0, left: 0 } }, meta: { scrollPos: { top: 0, left: 0 } },
}, },
{ {
path: '/deals/:dealId/:tabName?', path: '/deals/:dealId',
name: 'Deal', name: 'Deal',
component: () => import(`@/pages/${handleMobileView('Deal')}.vue`), component: () => import(`@/pages/${handleMobileView('Deal')}.vue`),
props: true, props: true,

View File

@ -20,6 +20,7 @@ export const viewsStore = defineStore('crm-views', (doctype) => {
publicViews.value = [] publicViews.value = []
for (let view of views) { for (let view of views) {
viewsByName[view.name] = view viewsByName[view.name] = view
view.type = view.type || 'list'
if (view.pinned) { if (view.pinned) {
pinnedViews.value?.push(view) pinnedViews.value?.push(view)
} }
@ -27,16 +28,17 @@ export const viewsStore = defineStore('crm-views', (doctype) => {
publicViews.value?.push(view) publicViews.value?.push(view)
} }
if (view.is_default && view.dt) { if (view.is_default && view.dt) {
defaultView.value[view.dt] = view defaultView.value[view.dt + ' ' + view.type] = view
} }
} }
return views return views
}, },
}) })
function getView(view, doctype = null) { function getView(view, type, doctype = null) {
type = type || 'list'
if (!view && doctype) { if (!view && doctype) {
return defaultView.value?.[doctype] || null return defaultView.value[doctype + ' ' + type] || null
} }
return viewsByName[view] return viewsByName[view]
} }

View File

@ -212,3 +212,7 @@ export function isEmoji(str) {
const emojiList = gemoji.map((emoji) => emoji.emoji) const emojiList = gemoji.map((emoji) => emoji.emoji)
return emojiList.includes(str) return emojiList.includes(str)
} }
export function isTouchScreenDevice() {
return "ontouchstart" in document.documentElement;
}

View File

@ -24,8 +24,9 @@ export default defineConfig({
display: 'standalone', display: 'standalone',
name: 'Frappe CRM', name: 'Frappe CRM',
short_name: 'Frappe CRM', short_name: 'Frappe CRM',
start_url: "/crm", start_url: '/crm',
description: 'Modern & 100% Open-source CRM tool to supercharge your sales operations', description:
'Modern & 100% Open-source CRM tool to supercharge your sales operations',
icons: [ icons: [
{ {
src: '/assets/crm/manifest/manifest-icon-192.maskable.png', src: '/assets/crm/manifest/manifest-icon-192.maskable.png',
@ -54,6 +55,25 @@ export default defineConfig({
], ],
}, },
}), }),
{
name: 'transform-index.html',
transformIndexHtml(html, context) {
if (!context.server) {
return html.replace(
/<\/body>/,
`
<script>
{% for key in boot %}
window["{{ key }}"] = {{ boot[key] | tojson }};
{% endfor %}
</script>
</body>
`
)
}
return html
},
},
], ],
resolve: { resolve: {
alias: { alias: {

1674
yarn.lock

File diff suppressed because it is too large Load Diff