Merge pull request #15 from shariquerik/feat-crm-task

This commit is contained in:
Shariq Ansari 2023-09-29 18:45:27 +05:30 committed by GitHub
commit 31074ecd40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 787 additions and 105 deletions

View File

View File

@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("CRM Task", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,104 @@
{
"actions": [],
"autoname": "autoincrement",
"creation": "2023-09-28 15:04:28.084159",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"title",
"priority",
"start_date",
"lead",
"column_break_cqua",
"assigned_to",
"status",
"due_date",
"section_break_bzhd",
"description"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "priority",
"fieldtype": "Select",
"label": "Priority",
"options": "Low\nMedium\nHigh"
},
{
"fieldname": "start_date",
"fieldtype": "Date",
"label": "Start Date"
},
{
"fieldname": "column_break_cqua",
"fieldtype": "Column Break"
},
{
"fieldname": "assigned_to",
"fieldtype": "Link",
"label": "Assigned To",
"options": "User"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "Backlog\nTodo\nIn Progress\nDone\nCanceled"
},
{
"fieldname": "due_date",
"fieldtype": "Date",
"label": "Due Date"
},
{
"fieldname": "section_break_bzhd",
"fieldtype": "Section Break"
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"fieldname": "lead",
"fieldtype": "Link",
"label": "Lead",
"options": "CRM Lead"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-09-28 15:05:27.986420",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Task",
"naming_rule": "Autoincrement",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CRMTask(Document):
pass

View File

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCRMTask(FrappeTestCase):
pass

View File

@ -3,11 +3,13 @@
<DesktopLayout v-else-if="session().isLoggedIn">
<router-view />
</DesktopLayout>
<Dialogs />
<Toasts />
</template>
<script setup>
import DesktopLayout from '@/components/DesktopLayout.vue'
import { Dialogs } from '@/utils/dialogs'
import { sessionStore as session } from '@/stores/session'
import { Toasts } from 'frappe-ui'
</script>

View File

@ -8,14 +8,26 @@
variant="solid"
@click="makeCall(lead.data.mobile_no)"
>
<PhoneIcon class="h-4 w-4" />
<template #prefix>
<PhoneIcon class="h-4 w-4" />
</template>
<span>Make a call</span>
</Button>
<Button v-else-if="title == 'Notes'" variant="solid" @click="showNote">
<FeatherIcon name="plus" class="h-4 w-4" />
<Button v-else-if="title == 'Notes'" variant="solid" @click="showNote()">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
<span>New note</span>
</Button>
<Button v-else-if="title == 'Tasks'" variant="solid" @click="showTask()">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
<span>New task</span>
</Button>
</div>
<div v-if="activities?.length" class="flex-1 overflow-y-auto">
<div v-if="title == 'Notes'" class="grid grid-cols-3 gap-4 px-10 py-5 pt-0">
<div v-if="title == 'Notes'" class="grid grid-cols-3 gap-4 px-10 pb-5">
<div
v-for="note in activities"
class="group flex h-48 cursor-pointer flex-col justify-between gap-2 rounded-md bg-gray-50 px-4 py-3 hover:bg-gray-100"
@ -65,6 +77,93 @@
</div>
</div>
</div>
<div v-else-if="title == 'Tasks'" class="px-10 pb-5">
<div v-for="(task, i) in activities">
<div
class="flex cursor-pointer gap-6 rounded p-2.5 duration-300 ease-in-out hover:bg-gray-50"
@click="showTask(task)"
>
<div class="flex flex-1 flex-col gap-1.5 text-base">
<div class="font-medium text-gray-900">
{{ task.title }}
</div>
<div class="flex gap-1.5 text-gray-800">
<div class="flex items-center gap-1.5">
<UserAvatar :user="task.assigned_to" size="xs" />
{{ getUser(task.assigned_to).full_name }}
</div>
<div
v-if="task.due_date"
class="flex items-center justify-center"
>
<DotIcon class="h-2.5 w-2.5 text-gray-600" :radius="2" />
</div>
<div v-if="task.due_date" class="flex gap-2">
<CalendarIcon />
<Tooltip :text="dateFormat(task.due_date, 'ddd, MMM D, YYYY')">
{{ dateFormat(task.due_date, 'D MMM') }}
</Tooltip>
</div>
<div class="flex items-center justify-center">
<DotIcon class="h-2.5 w-2.5 text-gray-600" :radius="2" />
</div>
<div class="flex gap-2">
<TaskPriorityIcon class="!w-2 !h-2" :priority="task.priority" />
{{ task.priority }}
</div>
</div>
</div>
<div class="flex items-center gap-1">
<Dropdown
:options="taskStatusOptions(updateTaskStatus, task)"
@click.stop
>
<Tooltip text="Change status">
<Button variant="ghosted" class="hover:bg-gray-300">
<TaskStatusIcon :status="task.status" />
</Button>
</Tooltip>
</Dropdown>
<Dropdown
:options="[
{
icon: 'trash-2',
label: 'Delete',
onClick: () => {
$dialog({
title: 'Delete task',
message: 'Are you sure you want to delete this task?',
actions: [
{
label: 'Delete',
theme: 'red',
variant: 'solid',
onClick({ close }) {
deleteTask(task.name)
close()
},
},
],
})
},
},
]"
@click.stop
>
<Button
icon="more-horizontal"
variant="ghosted"
class="hover:bg-gray-300"
/>
</Dropdown>
</div>
</div>
<div
v-if="i < activities.length - 1"
class="mx-2 h-px border-t border-gray-200"
/>
</div>
</div>
<div v-else-if="title == 'Calls'">
<div v-for="(call, i) in activities">
<div class="grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-10">
@ -468,7 +567,7 @@
v-else-if="title == 'Notes'"
variant="solid"
label="Create note"
@click="showNote"
@click="showNote()"
/>
<Button
v-else-if="title == 'Emails'"
@ -476,6 +575,12 @@
label="Send email"
@click="$refs.emailBox.show = true"
/>
<Button
v-else-if="title == 'Tasks'"
variant="solid"
label="Create task"
@click="showTask()"
/>
</div>
<CommunicationArea
ref="emailBox"
@ -483,14 +588,29 @@
v-model="lead"
v-model:reload="reload_email"
/>
<NoteModal v-model="showNoteModal" :note="note" @updateNote="updateNote" />
<NoteModal
v-model="showNoteModal"
v-model:reloadNotes="notes"
:note="note"
:lead="lead.data?.name"
/>
<TaskModal
v-model="showTaskModal"
v-model:reloadTasks="tasks"
:task="task"
:lead="lead.data?.name"
/>
</template>
<script setup>
import UserAvatar from '@/components/UserAvatar.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import DurationIcon from '@/components/Icons/DurationIcon.vue'
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
import PlayIcon from '@/components/Icons/PlayIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
@ -500,12 +620,14 @@ import InboundCallIcon from '@/components/Icons/InboundCallIcon.vue'
import OutboundCallIcon from '@/components/Icons/OutboundCallIcon.vue'
import CommunicationArea from '@/components/CommunicationArea.vue'
import NoteModal from '@/components/NoteModal.vue'
import TaskModal from '@/components/TaskModal.vue'
import {
timeAgo,
dateFormat,
dateTooltipFormat,
secondsToDuration,
startCase,
taskStatusOptions,
} from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
@ -608,6 +730,27 @@ const notes = createListResource({
auto: true,
})
const tasks = createListResource({
type: 'list',
doctype: 'CRM Task',
cache: ['Tasks', lead.value.data.name],
fields: [
'name',
'title',
'description',
'assigned_to',
'assigned_to',
'due_date',
'priority',
'status',
'modified',
],
filters: { lead: lead.value.data.name },
orderBy: 'modified desc',
pageLength: 999,
auto: true,
})
function all_activities() {
if (!versions.data) return []
if (!calls.data) return versions.data
@ -626,6 +769,8 @@ const activities = computed(() => {
)
} else if (props.title == 'Calls') {
return calls.data
} else if (props.title == 'Tasks') {
return tasks.data
} else if (props.title == 'Notes') {
return notes.data
}
@ -682,6 +827,8 @@ const emptyText = computed(() => {
text = 'No call logs'
} else if (props.title == 'Notes') {
text = 'No notes'
} else if (props.title == 'Tasks') {
text = 'No tasks'
}
return text
})
@ -692,6 +839,8 @@ const emptyTextIcon = computed(() => {
icon = PhoneIcon
} else if (props.title == 'Notes') {
icon = NoteIcon
} else if (props.title == 'Tasks') {
icon = TaskIcon
}
return h(icon, { class: 'text-gray-500' })
})
@ -721,11 +870,9 @@ function timelineIcon(activity_type, is_lead) {
return markRaw(icon)
}
// Notes
const showNoteModal = ref(false)
const note = ref({
title: '',
content: '',
})
const note = ref({})
function showNote(n) {
note.value = n || {
@ -743,29 +890,39 @@ async function deleteNote(name) {
notes.reload()
}
async function updateNote(note) {
if (note.name) {
let d = await call('frappe.client.set_value', {
doctype: 'CRM Note',
name: note.name,
fieldname: note,
})
if (d.name) {
notes.reload()
}
} else {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Note',
title: note.title,
content: note.content,
lead: props.leadId,
},
})
if (d.name) {
notes.reload()
}
// Tasks
const showTaskModal = ref(false)
const task = ref({})
function showTask(t) {
task.value = t || {
title: '',
description: '',
assigned_to: '',
due_date: '',
priority: 'Low',
status: 'Backlog',
}
showTaskModal.value = true
}
async function deleteTask(name) {
await call('frappe.client.delete', {
doctype: 'CRM Task',
name,
})
tasks.reload()
}
function updateTaskStatus(status, task) {
call('frappe.client.set_value', {
doctype: 'CRM Task',
name: task.name,
fieldname: 'status',
value: status,
}).then(() => {
tasks.reload()
})
}
watch([reload, reload_email], ([reload_value, reload_email_value]) => {

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.5 1C4.77614 1 5 1.22386 5 1.5V2.5L5 2.50056C5.15613 2.5 5.32244 2.5 5.5 2.5H10.5C10.6776 2.5 10.8439 2.5 11 2.50056C11 2.50037 11 2.50019 11 2.5V1.5C11 1.22386 11.2239 1 11.5 1C11.7761 1 12 1.22386 12 1.5V2.5C12 2.50803 11.9998 2.51602 11.9994 2.52395C12.49 2.55387 12.838 2.62116 13.135 2.77248C13.6054 3.01217 13.9878 3.39462 14.2275 3.86502C14.5 4.3998 14.5 5.09987 14.5 6.5V10.5C14.5 11.9001 14.5 12.6002 14.2275 13.135C13.9878 13.6054 13.6054 13.9878 13.135 14.2275C12.6002 14.5 11.9001 14.5 10.5 14.5H5.5C4.09987 14.5 3.3998 14.5 2.86502 14.2275C2.39462 13.9878 2.01217 13.6054 1.77248 13.135C1.5 12.6002 1.5 11.9001 1.5 10.5V6.5C1.5 5.09987 1.5 4.3998 1.77248 3.86502C2.01217 3.39462 2.39462 3.01217 2.86502 2.77248C3.16202 2.62116 3.51 2.55387 4.00056 2.52395C4.00019 2.51602 4 2.50803 4 2.5V1.5C4 1.22386 4.22386 1 4.5 1ZM5.5 3.5H10.5C11.2166 3.5 11.6938 3.50078 12.0606 3.53074C12.4156 3.55975 12.5781 3.61105 12.681 3.66349C12.9632 3.8073 13.1927 4.03677 13.3365 4.31901C13.389 4.42194 13.4403 4.5844 13.4693 4.93944C13.4916 5.21337 13.4977 5.54901 13.4994 6H2.50061C2.50226 5.54901 2.50836 5.21337 2.53074 4.93944C2.55975 4.5844 2.61105 4.42194 2.66349 4.31901C2.8073 4.03677 3.03677 3.8073 3.31901 3.66349C3.42194 3.61105 3.5844 3.55975 3.93944 3.53074C4.30615 3.50078 4.78343 3.5 5.5 3.5ZM2.5 7V10.5C2.5 11.2166 2.50078 11.6939 2.53074 12.0606C2.55975 12.4156 2.61105 12.5781 2.66349 12.681C2.8073 12.9632 3.03677 13.1927 3.31901 13.3365C3.42194 13.389 3.5844 13.4403 3.93944 13.4693C4.30615 13.4992 4.78343 13.5 5.5 13.5H10.5C11.2166 13.5 11.6938 13.4992 12.0606 13.4693C12.4156 13.4403 12.5781 13.389 12.681 13.3365C12.9632 13.1927 13.1927 12.9632 13.3365 12.681C13.389 12.5781 13.4403 12.4156 13.4693 12.0606C13.4992 11.6939 13.5 11.2166 13.5 10.5V7H2.5Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -6,6 +6,14 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="8" cy="8" r="3.5" fill="currentColor" />
<circle cx="8" cy="8" :r="radius" fill="currentColor" />
</svg>
</template>
<script setup>
const props = defineProps({
radius: {
type: Number,
default: 3.5,
},
})
</script>

View File

@ -0,0 +1,23 @@
<template>
<div class="grid place-items-center">
<div
class="h-3 w-3 rounded-full"
:class="{
'bg-red-500': priority === 'High',
'bg-yellow-500': priority === 'Medium',
'bg-gray-300': priority === 'Low',
}, $attrs.class"
></div>
</div>
</template>
<script setup>
defineOptions({
inheritAttrs: false
})
const props = defineProps({
priority: {
type: String,
required: true,
},
})
</script>

View File

@ -0,0 +1,54 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
class="text-gray-700"
:aria-label="status"
>
<path
v-if="status == 'Backlog'"
fill="currentColor"
fill-rule="evenodd"
d="M4 5 2.576 3.574A6.968 6.968 0 0 0 1.043 7.22H3.06c.13-.824.46-1.582.94-2.22Zm-.948 3.72H1.037a6.968 6.968 0 0 0 1.54 3.707l1.425-1.424a4.975 4.975 0 0 1-.95-2.283Zm2.02 3.333-1.427 1.428a6.966 6.966 0 0 0 3.6 1.479v-2.017a4.972 4.972 0 0 1-2.173-.89Zm3.673.892v2.016a6.968 6.968 0 0 0 3.68-1.537L11.001 12a4.974 4.974 0 0 1-2.256.945Zm3.307-2.015 1.427 1.427a6.967 6.967 0 0 0 1.484-3.637H12.95a4.973 4.973 0 0 1-.897 2.21Zm.888-3.71h2.017a6.967 6.967 0 0 0-1.476-3.575l-1.428 1.427c.452.623.762 1.355.887 2.148Zm-1.937-3.218 1.424-1.425A6.968 6.968 0 0 0 8.745 1.04v2.016c.839.125 1.61.459 2.258.947Zm-3.758-.945V1.04a6.966 6.966 0 0 0-3.602 1.48L5.07 3.949a4.973 4.973 0 0 1 2.175-.891Z"
clip-rule="evenodd"
/>
<path
v-else-if="status == 'Todo'"
fill="currentColor"
fill-rule="evenodd"
d="M8 13A5 5 0 1 0 8 3a5 5 0 0 0 0 10Zm0 2A7 7 0 1 0 8 1a7 7 0 0 0 0 14Z"
clip-rule="evenodd"
/>
<path
v-else-if="status == 'In Progress'"
fill="currentColor"
fill-rule="evenodd"
d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm0-1.75a5.25 5.25 0 1 1 0-10.5 5.25 5.25 0 0 1 0 10.5Zm2.828-2.422A4 4 0 0 1 8 12V4a4 4 0 0 1 2.828 6.828Z"
clip-rule="evenodd"
/>
<path
v-else-if="status == 'Done'"
fill="currentColor"
fill-rule="evenodd"
d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm3.173-9.015a.5.5 0 0 0-.772-.636l-3.45 4.183L5.612 7.8a.5.5 0 1 0-.792.612l1.725 2.228a.5.5 0 0 0 .781.013l3.848-4.667Z"
clip-rule="evenodd"
/>
<path
v-else-if="status == 'Canceled'"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM5.14645 5.14645C4.95118 5.34171 4.95118 5.65829 5.14645 5.85355L7.29289 8L5.14645 10.1464C4.95118 10.3417 4.95118 10.6583 5.14645 10.8536C5.34171 11.0488 5.65829 11.0488 5.85355 10.8536L8 8.70711L10.1464 10.8536C10.3417 11.0488 10.6583 11.0488 10.8536 10.8536C11.0488 10.6583 11.0488 10.3417 10.8536 10.1464L8.70711 8L10.8536 5.85355C11.0488 5.65829 11.0488 5.34171 10.8536 5.14645C10.6583 4.95118 10.3417 4.95118 10.1464 5.14645L8 7.29289L5.85355 5.14645C5.65829 4.95118 5.34171 4.95118 5.14645 5.14645Z"
/>
</svg>
</template>
<script setup>
const props = defineProps({
status: {
type: String,
required: true,
},
})
</script>

View File

@ -1,70 +1,111 @@
<template>
<Dialog v-model="show" :options="{ size: '4xl' }" @close="updateNote">
<template #body-title><div></div></template>
<Dialog
v-model="show"
:options="{
title: editMode ? 'Edit Note' : 'Create Note',
size: 'xl',
actions: [
{
label: editMode ? 'Update' : 'Create',
variant: 'solid',
onClick: ({ close }) => updateNote(close),
},
],
}"
>
<template #body-content>
<div
class="flex flex-col gap-2 px-20 mt-5 mb-10 min-h-[400px] max-h-[500px] overflow-auto"
>
<TextInput
ref="title"
type="text"
class="!text-[30px] !h-10 !font-semibold bg-white border-none hover:bg-white focus:!shadow-none focus-visible:!ring-0"
v-model="updatedNote.title"
placeholder="Untitled note"
/>
<TextEditor
ref="content"
editor-class="!prose-sm !leading-5 max-w-none p-2 overflow-auto focus:outline-none"
:bubbleMenu="true"
:content="updatedNote.content"
@change="(val) => (updatedNote.content = val)"
placeholder="Type something and press enter"
/>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-gray-600">Title</div>
<TextInput
ref="title"
variant="outline"
v-model="_note.title"
placeholder="Add title"
/>
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">Content</div>
<TextEditor
variant="outline"
ref="content"
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-gray-300 bg-white hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400 text-gray-800 transition-colors"
:bubbleMenu="true"
:content="_note.content"
@change="(val) => (_note.content = val)"
placeholder="Type a content"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { TextInput, TextEditor, Dialog } from 'frappe-ui'
import { TextInput, TextEditor, Dialog, call } from 'frappe-ui'
import { ref, defineModel, nextTick, watch } from 'vue'
const props = defineProps({
note: {
type: Object,
default: {
title: '',
content: '',
},
default: {},
},
lead: {
type: String,
default: '',
},
})
const show = defineModel()
const emit = defineEmits(['updateNote'])
const notes = defineModel('reloadNotes')
const title = ref(null)
const editMode = ref(false)
let _note = ref({})
let updatedNote = ref({
title: '',
content: '',
})
function updateNote() {
async function updateNote(close) {
if (
props.note.title !== updatedNote.value.title ||
props.note.content !== updatedNote.value.content
) {
emit('updateNote', updatedNote.value)
props.note.title === _note.value.title &&
props.note.content === _note.value.content
)
return
if (_note.value.name) {
let d = await call('frappe.client.set_value', {
doctype: 'CRM Note',
name: _note.value.name,
fieldname: _note.value,
})
if (d.name) {
notes.value.reload()
}
} else {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Note',
title: _note.value.title,
content: _note.value.content,
lead: props.lead || '',
},
})
if (d.name) {
notes.value.reload()
}
}
close()
}
watch(
() => show.value,
(value) => {
if (!value) return
editMode.value = false
nextTick(() => {
title.value.el.focus()
updatedNote.value = { ...props.note }
_note.value = { ...props.note }
if (_note.value.title) {
editMode.value = true
}
})
}
)

View File

@ -0,0 +1,184 @@
<template>
<Dialog
v-model="show"
:options="{
title: editMode ? 'Edit Task' : 'Create Task',
size: 'xl',
actions: [
{
label: editMode ? 'Update' : 'Create',
variant: 'solid',
onClick: ({ close }) => updateTask(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-gray-600">Title</div>
<TextInput
ref="title"
variant="outline"
v-model="_task.title"
placeholder="Add title"
/>
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">Description</div>
<TextEditor
variant="outline"
ref="description"
editor-class="!prose-sm overflow-auto min-h-[80px] max-h-80 py-1.5 px-2 rounded border border-gray-300 bg-white hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400 text-gray-800 transition-colors"
:bubbleMenu="true"
:content="_task.description"
@change="(val) => (_task.description = val)"
placeholder="Type a description"
/>
</div>
<div class="flex items-center gap-2">
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
<Button :label="_task.status" class="w-full justify-between">
<template #prefix>
<TaskStatusIcon :status="_task.status" />
</template>
</Button>
</Dropdown>
<Autocomplete
:options="activeAgents"
:value="getUser(_task.assigned_to).full_name"
@change="(option) => (_task.assigned_to = option.email)"
placeholder="Assignee"
>
<template #prefix>
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.email" size="sm" />
</template>
</Autocomplete>
<DatePicker
class="datepicker w-36"
v-model="_task.due_date"
placeholder="Due date"
input-class="border-none"
:formatValue="(val) => val.split('-').reverse().join('-')"
/>
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
<Button :label="_task.priority" class="w-full justify-between">
<template #prefix>
<TaskPriorityIcon :priority="_task.priority" />
</template>
</Button>
</Dropdown>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { activeAgents, taskStatusOptions, taskPriorityOptions } from '@/utils'
import { usersStore } from '@/stores/users'
import {
TextInput,
TextEditor,
Dialog,
Dropdown,
Autocomplete,
DatePicker,
call,
} from 'frappe-ui'
import { ref, defineModel, h, watch, nextTick } from 'vue'
import { get } from '@vueuse/core'
const props = defineProps({
task: {
type: Object,
default: {},
},
lead: {
type: String,
default: '',
},
})
const show = defineModel()
const tasks = defineModel('reloadTasks')
const emit = defineEmits(['updateTask'])
const { getUser } = usersStore()
const title = ref(null)
const editMode = ref(false)
const _task = ref({
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
})
function updateTaskStatus(status) {
_task.value.status = status
}
function updateTaskPriority(priority) {
_task.value.priority = priority
}
async function updateTask(close) {
if (!_task.value.assigned_to) {
_task.value.assigned_to = getUser().email
}
if (_task.value.name) {
let d = await call('frappe.client.set_value', {
doctype: 'CRM Task',
name: _task.value.name,
fieldname: _task.value,
})
if (d.name) {
tasks.value.reload()
}
} else {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Task',
lead: props.lead || null,
..._task.value,
},
})
if (d.name) {
tasks.value.reload()
}
}
close()
}
watch(
() => show.value,
(value) => {
if (!value) return
editMode.value = false
nextTick(() => {
title.value.el.focus()
_task.value = { ...props.task }
if (_task.value.title) {
editMode.value = true
}
})
}
)
</script>
<style scoped>
:deep(.datepicker svg) {
width: 0.875rem;
height: 0.875rem;
}
</style>

View File

@ -1,6 +1,5 @@
<template>
<Avatar
v-if="user"
:label="getUser(user).full_name"
:image="getUser(user).user_image"
v-bind="$attrs"

View File

@ -18,6 +18,7 @@ import {
setConfig,
frappeRequest,
} from 'frappe-ui'
import { createDialog } from './utils/dialogs'
import socket from './socket'
import { getCachedListResource } from 'frappe-ui/src/resources/listResource'
import { getCachedResource } from 'frappe-ui/src/resources/resources'
@ -46,6 +47,8 @@ for (let key in globalComponents) {
app.component(key, globalComponents[key])
}
app.config.globalProperties.$dialog = createDialog
app.mount('#app')
socket.on('refetch_resource', (data) => {
@ -57,3 +60,7 @@ socket.on('refetch_resource', (data) => {
}
}
})
if (import.meta.env.DEV) {
window.$dialog = createDialog
}

View File

@ -342,6 +342,7 @@
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
@ -446,6 +447,10 @@ const tabs = [
label: 'Calls',
icon: PhoneIcon,
},
{
label: 'Tasks',
icon: TaskIcon,
},
{
label: 'Notes',
icon: NoteIcon,

View File

@ -314,6 +314,7 @@
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import LinkIcon from '@/components/Icons/LinkIcon.vue'
@ -347,6 +348,7 @@ import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import CameraIcon from '../components/Icons/CameraIcon.vue'
const { getUser, users } = usersStore()
const { contacts } = contactsStore()
const router = useRouter()
@ -423,6 +425,10 @@ const tabs = [
label: 'Calls',
icon: PhoneIcon,
},
{
label: 'Tasks',
icon: TaskIcon,
},
{
label: 'Notes',
icon: NoteIcon,

View File

@ -9,14 +9,17 @@
</Button>
</template>
</LayoutHeader>
<div v-if="notes.data?.length" class="grid grid-cols-4 gap-4 p-5 overflow-y-auto">
<div
v-if="notes.data?.length"
class="grid grid-cols-4 gap-4 overflow-y-auto p-5"
>
<div
v-for="note in notes.data"
class="group flex flex-col justify-between gap-2 px-5 py-4 border rounded-lg h-56 shadow-sm hover:bg-gray-50 cursor-pointer"
class="group flex h-56 cursor-pointer flex-col justify-between gap-2 rounded-lg border px-5 py-4 shadow-sm hover:bg-gray-50"
@click="editNote(note)"
>
<div class="flex items-center justify-between">
<div class="text-lg font-medium truncate">
<div class="truncate text-lg font-medium">
{{ note.title }}
</div>
<Dropdown
@ -43,7 +46,7 @@
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
class="flex-1 overflow-hidden"
/>
<div class="flex items-center justify-between gap-2 mt-2">
<div class="mt-2 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UserAvatar :user="note.owner" size="xs" />
<div class="text-sm text-gray-800">
@ -60,17 +63,17 @@
</div>
<div
v-else
class="flex-1 p-5 flex items-center justify-center font-medium text-xl text-gray-500"
class="flex flex-1 items-center justify-center p-5 text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center gap-2">
<NoteIcon class="w-10 h-10 text-gray-500" />
<NoteIcon class="h-10 w-10 text-gray-500" />
<span>No notes</span>
</div>
</div>
<NoteModal
v-model="showNoteModal"
v-model:reloadNotes="notes"
:note="currentNote"
@updateNote="updateNote"
/>
</template>
@ -86,10 +89,9 @@ import {
Button,
createListResource,
TextEditor,
TextInput,
call,
Dropdown,
Tooltip,
Tooltip,
} from 'frappe-ui'
import { ref } from 'vue'
import { usersStore } from '@/stores/users'
@ -129,30 +131,6 @@ function editNote(note) {
showNoteModal.value = true
}
async function updateNote(note) {
if (note.name) {
let d = await call('frappe.client.set_value', {
doctype: 'CRM Note',
name: note.name,
fieldname: note,
})
if (d.name) {
notes.reload()
}
} else {
let d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Note',
title: note.title,
content: note.content,
},
})
if (d.name) {
notes.reload()
}
}
}
async function deleteNote(name) {
await call('frappe.client.delete', {
doctype: 'CRM Note',

View File

@ -0,0 +1,31 @@
import { Dialog, ErrorMessage } from 'frappe-ui'
import { h, reactive, ref } from 'vue'
let dialogs = ref([])
export let Dialogs = {
name: 'Dialogs',
render() {
return dialogs.value.map((dialog) => {
return h(
Dialog,
{
options: dialog,
modelValue: dialog.show,
'onUpdate:modelValue': (val) => (dialog.show = val),
},
() => [
h('p', { class: 'text-p-base text-gray-700' }, dialog.message),
h(ErrorMessage, { class: 'mt-2', message: dialog.error }),
]
)
})
},
}
export function createDialog(options) {
let dialog = reactive(options)
dialog.key = `dialog-${Math.random().toString(36).slice(2, 9)}`
dialogs.value.push(dialog)
dialog.show = true
}

View File

@ -1,7 +1,10 @@
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
import { usersStore } from '@/stores/users'
import { useDateFormat, useTimeAgo } from '@vueuse/core'
import { toast } from 'frappe-ui'
import { h } from 'vue'
import { h, computed } from 'vue'
export function createToast(options) {
toast({
@ -91,6 +94,28 @@ export function statusDropdownOptions(data, doctype, action) {
return options
}
export function taskStatusOptions(action, data) {
return ['Backlog', 'Todo', 'In Progress', 'Done', 'Canceled'].map(
(status) => {
return {
icon: () => h(TaskStatusIcon, { status }),
label: status,
onClick: () => action && action(status, data),
}
}
)
}
export function taskPriorityOptions(action, data) {
return ['Low', 'Medium', 'High'].map((priority) => {
return {
label: priority,
icon: () => h(TaskPriorityIcon, { priority }),
onClick: () => action && action(priority, data),
}
})
}
export function openWebsite(url) {
window.open(url, '_blank')
}
@ -128,3 +153,19 @@ export function formatNumberIntoCurrency(value) {
export function startCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
const { users } = usersStore()
export const activeAgents = computed(() => {
const nonAgents = ['Administrator', 'Guest']
return users.data
.filter((user) => !nonAgents.includes(user.name))
.sort((a, b) => a.full_name - b.full_name)
.map((user) => {
return {
label: user.full_name,
value: user.email,
...user,
}
})
})