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",
|
||||
"Int",
|
||||
"Currency",
|
||||
"Dynamic Link",
|
||||
"Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
|
||||
@ -6,4 +6,57 @@ from frappe.model.document import 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'
|
||||
|
||||
const typeCheck = ['Check']
|
||||
const typeLink = ['Link']
|
||||
const typeLink = ['Link', 'Dynamic Link']
|
||||
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
|
||||
const typeSelect = ['Select']
|
||||
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
||||
@ -324,6 +324,9 @@ function getValSelect(f) {
|
||||
})),
|
||||
})
|
||||
} else if (typeLink.includes(fieldtype)) {
|
||||
if (field.fieldtype === 'Dynamic Link') {
|
||||
return h(FormControl, { type: 'text' })
|
||||
}
|
||||
return h(Link, { class: 'form-control', doctype: options })
|
||||
} else if (typeNumber.includes(fieldtype)) {
|
||||
return h(FormControl, { type: 'number' })
|
||||
|
||||
@ -105,6 +105,7 @@ import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
|
||||
@ -146,6 +147,11 @@ const links = [
|
||||
icon: NoteIcon,
|
||||
to: 'Notes',
|
||||
},
|
||||
{
|
||||
label: 'Tasks',
|
||||
icon: TaskIcon,
|
||||
to: 'Tasks',
|
||||
},
|
||||
{
|
||||
label: 'Call Logs',
|
||||
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>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
@ -86,12 +104,14 @@
|
||||
<script setup>
|
||||
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
|
||||
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
|
||||
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { taskStatusOptions, taskPriorityOptions } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { TextEditor, Dropdown, Tooltip, DatePicker, call } from 'frappe-ui'
|
||||
import { ref, defineModel, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
@ -113,6 +133,7 @@ const tasks = defineModel('reloadTasks')
|
||||
|
||||
const emit = defineEmits(['updateTask'])
|
||||
|
||||
const router = useRouter()
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const title = ref(null)
|
||||
@ -124,6 +145,8 @@ const _task = ref({
|
||||
due_date: '',
|
||||
status: 'Backlog',
|
||||
priority: 'Low',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: null,
|
||||
})
|
||||
|
||||
function updateTaskStatus(status) {
|
||||
@ -134,6 +157,16 @@ function updateTaskPriority(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() {
|
||||
if (!_task.value.assigned_to) {
|
||||
_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',
|
||||
component: () => import('@/pages/Notes.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
name: 'Tasks',
|
||||
component: () => import('@/pages/Tasks.vue'),
|
||||
},
|
||||
{
|
||||
path: '/contacts',
|
||||
name: 'Contacts',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user