Merge pull request #66 from shariquerik/task-listview
feat: Tasks ListView
This commit is contained in:
commit
d4789e8e3c
@ -41,6 +41,7 @@ def get_filterable_fields(doctype: str):
|
|||||||
"Float",
|
"Float",
|
||||||
"Int",
|
"Int",
|
||||||
"Currency",
|
"Currency",
|
||||||
|
"Dynamic Link",
|
||||||
"Link",
|
"Link",
|
||||||
"Long Text",
|
"Long Text",
|
||||||
"Select",
|
"Select",
|
||||||
|
|||||||
@ -6,4 +6,57 @@ from frappe.model.document import Document
|
|||||||
|
|
||||||
|
|
||||||
class CRMTask(Document):
|
class CRMTask(Document):
|
||||||
pass
|
@staticmethod
|
||||||
|
def default_list_data():
|
||||||
|
columns = [
|
||||||
|
{
|
||||||
|
'label': 'Title',
|
||||||
|
'type': 'Data',
|
||||||
|
'key': 'title',
|
||||||
|
'width': '16rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Status',
|
||||||
|
'type': 'Select',
|
||||||
|
'key': 'status',
|
||||||
|
'width': '8rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Priority',
|
||||||
|
'type': 'Select',
|
||||||
|
'key': 'priority',
|
||||||
|
'width': '8rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Due Date',
|
||||||
|
'type': 'Date',
|
||||||
|
'key': 'due_date',
|
||||||
|
'width': '8rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Assigned To',
|
||||||
|
'type': 'Link',
|
||||||
|
'key': 'assigned_to',
|
||||||
|
'width': '10rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Last Modified',
|
||||||
|
'type': 'Datetime',
|
||||||
|
'key': 'modified',
|
||||||
|
'width': '8rem',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"assigned_to",
|
||||||
|
"due_date",
|
||||||
|
"status",
|
||||||
|
"priority",
|
||||||
|
"reference_doctype",
|
||||||
|
"reference_docname",
|
||||||
|
"modified",
|
||||||
|
]
|
||||||
|
return {'columns': columns, 'rows': rows}
|
||||||
|
|||||||
@ -103,7 +103,7 @@ import { FormControl, Autocomplete, createResource } from 'frappe-ui'
|
|||||||
import { h, defineModel, computed } from 'vue'
|
import { h, defineModel, computed } from 'vue'
|
||||||
|
|
||||||
const typeCheck = ['Check']
|
const typeCheck = ['Check']
|
||||||
const typeLink = ['Link']
|
const typeLink = ['Link', 'Dynamic Link']
|
||||||
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
|
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
|
||||||
const typeSelect = ['Select']
|
const typeSelect = ['Select']
|
||||||
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
||||||
@ -324,6 +324,9 @@ function getValSelect(f) {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
} else if (typeLink.includes(fieldtype)) {
|
} else if (typeLink.includes(fieldtype)) {
|
||||||
|
if (field.fieldtype === 'Dynamic Link') {
|
||||||
|
return h(FormControl, { type: 'text' })
|
||||||
|
}
|
||||||
return h(Link, { class: 'form-control', doctype: options })
|
return h(Link, { class: 'form-control', doctype: options })
|
||||||
} else if (typeNumber.includes(fieldtype)) {
|
} else if (typeNumber.includes(fieldtype)) {
|
||||||
return h(FormControl, { type: 'number' })
|
return h(FormControl, { type: 'number' })
|
||||||
|
|||||||
@ -105,6 +105,7 @@ import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
|||||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||||
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
||||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||||
|
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
|
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
|
||||||
@ -146,6 +147,11 @@ const links = [
|
|||||||
icon: NoteIcon,
|
icon: NoteIcon,
|
||||||
to: 'Notes',
|
to: 'Notes',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Tasks',
|
||||||
|
icon: TaskIcon,
|
||||||
|
to: 'Tasks',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Call Logs',
|
label: 'Call Logs',
|
||||||
icon: PhoneIcon,
|
icon: PhoneIcon,
|
||||||
|
|||||||
164
frontend/src/components/ListViews/TasksListView.vue
Normal file
164
frontend/src/components/ListViews/TasksListView.vue
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<ListView
|
||||||
|
:columns="columns"
|
||||||
|
:rows="rows"
|
||||||
|
:options="{
|
||||||
|
onRowClick: (row) => emit('showTask', row.name),
|
||||||
|
selectable: options.selectable,
|
||||||
|
}"
|
||||||
|
row-key="name"
|
||||||
|
>
|
||||||
|
<ListHeader class="mx-5" />
|
||||||
|
<ListRows id="list-rows">
|
||||||
|
<ListRow
|
||||||
|
class="mx-5"
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.name"
|
||||||
|
v-slot="{ column, item }"
|
||||||
|
:row="row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="column.key === 'due_date'"
|
||||||
|
class="flex items-center gap-2 text-base"
|
||||||
|
>
|
||||||
|
<CalendarIcon />
|
||||||
|
<div v-if="item">
|
||||||
|
<Tooltip :text="dateFormat(item, 'ddd, MMM D, YYYY')">
|
||||||
|
{{ dateFormat(item, 'D MMM') }}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListRowItem v-else :item="item">
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key === 'status'">
|
||||||
|
<TaskStatusIcon :status="item" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="column.key === 'priority'">
|
||||||
|
<TaskPriorityIcon :priority="item" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="column.key === 'assigned_to'">
|
||||||
|
<Avatar
|
||||||
|
v-if="item.full_name"
|
||||||
|
class="flex items-center"
|
||||||
|
:image="item.user_image"
|
||||||
|
:label="item.full_name"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="['modified', 'creation'].includes(column.key)"
|
||||||
|
class="truncate text-base"
|
||||||
|
>
|
||||||
|
{{ item.timeAgo }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="column.type === 'Check'">
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
:modelValue="item"
|
||||||
|
:disabled="true"
|
||||||
|
class="text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ selections, unselectAll }">
|
||||||
|
<Button
|
||||||
|
theme="red"
|
||||||
|
variant="subtle"
|
||||||
|
label="Delete"
|
||||||
|
@click="deleteTask(selections, unselectAll)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
<ListFooter
|
||||||
|
class="border-t px-5 py-2"
|
||||||
|
v-model="pageLengthCount"
|
||||||
|
:options="{
|
||||||
|
rowCount: options.rowCount,
|
||||||
|
totalCount: options.totalCount,
|
||||||
|
}"
|
||||||
|
@loadMore="emit('loadMore')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
|
||||||
|
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
|
||||||
|
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
|
||||||
|
import { dateFormat } from '@/utils'
|
||||||
|
import { globalStore } from '@/stores/global'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListSelectBanner,
|
||||||
|
ListRowItem,
|
||||||
|
ListFooter,
|
||||||
|
call,
|
||||||
|
Tooltip,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { defineModel } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
selectable: true,
|
||||||
|
totalCount: 0,
|
||||||
|
rowCount: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['loadMore', 'showTask', 'reload'])
|
||||||
|
|
||||||
|
const pageLengthCount = defineModel()
|
||||||
|
|
||||||
|
const { $dialog } = globalStore()
|
||||||
|
|
||||||
|
function deleteTask(selections, unselectAll) {
|
||||||
|
let title = 'Delete task'
|
||||||
|
let message = 'Are you sure you want to delete this task?'
|
||||||
|
|
||||||
|
if (selections.size > 1) {
|
||||||
|
title = 'Delete tasks'
|
||||||
|
message = 'Are you sure you want to delete these tasks?'
|
||||||
|
}
|
||||||
|
|
||||||
|
$dialog({
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
theme: 'red',
|
||||||
|
variant: 'solid',
|
||||||
|
async onClick(close) {
|
||||||
|
for (const selection of selections) {
|
||||||
|
await call('frappe.client.delete', {
|
||||||
|
doctype: 'CRM Task',
|
||||||
|
name: selection,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
unselectAll()
|
||||||
|
emit('reload')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -13,6 +13,24 @@
|
|||||||
],
|
],
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<template #body-title>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h3 class="text-2xl font-semibold leading-6 text-gray-900">
|
||||||
|
{{ editMode ? 'Edit Task' : 'Create Task' }}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
v-if="task?.reference_docname"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:label="task.reference_doctype == 'CRM Deal' ? 'Open Deal' : 'Open Lead'"
|
||||||
|
@click="redirect()"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<ArrowUpRightIcon class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -86,12 +104,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
|
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
|
||||||
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
|
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
|
||||||
|
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { taskStatusOptions, taskPriorityOptions } from '@/utils'
|
import { taskStatusOptions, taskPriorityOptions } from '@/utils'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { TextEditor, Dropdown, Tooltip, DatePicker, call } from 'frappe-ui'
|
import { TextEditor, Dropdown, Tooltip, DatePicker, call } from 'frappe-ui'
|
||||||
import { ref, defineModel, watch, nextTick } from 'vue'
|
import { ref, defineModel, watch, nextTick } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
task: {
|
task: {
|
||||||
@ -113,6 +133,7 @@ const tasks = defineModel('reloadTasks')
|
|||||||
|
|
||||||
const emit = defineEmits(['updateTask'])
|
const emit = defineEmits(['updateTask'])
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
|
|
||||||
const title = ref(null)
|
const title = ref(null)
|
||||||
@ -124,6 +145,8 @@ const _task = ref({
|
|||||||
due_date: '',
|
due_date: '',
|
||||||
status: 'Backlog',
|
status: 'Backlog',
|
||||||
priority: 'Low',
|
priority: 'Low',
|
||||||
|
reference_doctype: props.doctype,
|
||||||
|
reference_docname: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateTaskStatus(status) {
|
function updateTaskStatus(status) {
|
||||||
@ -134,6 +157,16 @@ function updateTaskPriority(priority) {
|
|||||||
_task.value.priority = priority
|
_task.value.priority = priority
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redirect() {
|
||||||
|
if (!props.task?.reference_docname) return
|
||||||
|
let name = props.task.reference_doctype == 'CRM Deal' ? 'Deal' : 'Lead'
|
||||||
|
let params = { leadId: props.task.reference_docname }
|
||||||
|
if (name == 'Deal') {
|
||||||
|
params = { dealId: props.task.reference_docname }
|
||||||
|
}
|
||||||
|
router.push({ name: name, params: params })
|
||||||
|
}
|
||||||
|
|
||||||
async function updateTask() {
|
async function updateTask() {
|
||||||
if (!_task.value.assigned_to) {
|
if (!_task.value.assigned_to) {
|
||||||
_task.value.assigned_to = getUser().email
|
_task.value.assigned_to = getUser().email
|
||||||
|
|||||||
110
frontend/src/pages/Tasks.vue
Normal file
110
frontend/src/pages/Tasks.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<LayoutHeader>
|
||||||
|
<template #left-header>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
</template>
|
||||||
|
<template #right-header>
|
||||||
|
<Button variant="solid" label="Create" @click="showTaskModal = true">
|
||||||
|
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</LayoutHeader>
|
||||||
|
<ViewControls
|
||||||
|
v-model="tasks"
|
||||||
|
v-model:loadMore="loadMore"
|
||||||
|
doctype="CRM Task"
|
||||||
|
/>
|
||||||
|
<TasksListView
|
||||||
|
v-if="tasks.data && rows.length"
|
||||||
|
v-model="tasks.data.page_length_count"
|
||||||
|
:rows="rows"
|
||||||
|
:columns="tasks.data.columns"
|
||||||
|
:options="{
|
||||||
|
rowCount: tasks.data.row_count,
|
||||||
|
totalCount: tasks.data.total_count,
|
||||||
|
}"
|
||||||
|
@loadMore="() => loadMore++"
|
||||||
|
@showTask="showTask"
|
||||||
|
@reload="() => tasks.reload()"
|
||||||
|
/>
|
||||||
|
<div v-else-if="tasks.data" class="flex h-full items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
|
||||||
|
>
|
||||||
|
<EmailIcon class="h-10 w-10" />
|
||||||
|
<span>No Tasks Found</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TaskModal v-model="showTaskModal" v-model:reloadTasks="tasks" :task="task" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||||
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
|
import ViewControls from '@/components/ViewControls.vue'
|
||||||
|
import TasksListView from '@/components/ListViews/TasksListView.vue'
|
||||||
|
import TaskModal from '@/components/Modals/TaskModal.vue'
|
||||||
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
|
||||||
|
import { Breadcrumbs } from 'frappe-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const breadcrumbs = [{ label: 'Tasks', route: { name: 'Tasks' } }]
|
||||||
|
|
||||||
|
const { getUser } = usersStore()
|
||||||
|
|
||||||
|
// tasks data is loaded in the ViewControls component
|
||||||
|
const tasks = ref({})
|
||||||
|
const loadMore = ref(1)
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
if (!tasks.value?.data?.data) return []
|
||||||
|
return tasks.value?.data.data.map((task) => {
|
||||||
|
let _rows = {}
|
||||||
|
tasks.value?.data.rows.forEach((row) => {
|
||||||
|
_rows[row] = task[row]
|
||||||
|
|
||||||
|
if (['modified', 'creation'].includes(row)) {
|
||||||
|
_rows[row] = {
|
||||||
|
label: dateFormat(task[row], dateTooltipFormat),
|
||||||
|
timeAgo: timeAgo(task[row]),
|
||||||
|
}
|
||||||
|
} else if (row == 'assigned_to') {
|
||||||
|
_rows[row] = {
|
||||||
|
label: task.assigned_to && getUser(task.assigned_to).full_name,
|
||||||
|
...(task.assigned_to && getUser(task.assigned_to)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return _rows
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const showTaskModal = ref(false)
|
||||||
|
|
||||||
|
const task = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
assigned_to: '',
|
||||||
|
due_date: '',
|
||||||
|
status: 'Backlog',
|
||||||
|
priority: 'Low',
|
||||||
|
reference_doctype: 'CRM Lead',
|
||||||
|
reference_docname: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function showTask(name) {
|
||||||
|
let t = rows.value?.find((row) => row.name === name)
|
||||||
|
task.value = {
|
||||||
|
title: t.title,
|
||||||
|
description: t.description,
|
||||||
|
assigned_to: t.assigned_to?.email || '',
|
||||||
|
due_date: t.due_date,
|
||||||
|
status: t.status,
|
||||||
|
priority: t.priority,
|
||||||
|
reference_doctype: t.reference_doctype,
|
||||||
|
reference_docname: t.reference_docname,
|
||||||
|
}
|
||||||
|
showTaskModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -37,6 +37,11 @@ const routes = [
|
|||||||
name: 'Notes',
|
name: 'Notes',
|
||||||
component: () => import('@/pages/Notes.vue'),
|
component: () => import('@/pages/Notes.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/tasks',
|
||||||
|
name: 'Tasks',
|
||||||
|
component: () => import('@/pages/Tasks.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/contacts',
|
path: '/contacts',
|
||||||
name: 'Contacts',
|
name: 'Contacts',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user