Initial commit

This commit is contained in:
2025-08-04 16:33:07 +03:30
commit f798e8e35c
9595 changed files with 1208683 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
<script setup>
import {
animations,
remapNodes,
} from '@formkit/drag-and-drop'
import { dragAndDrop } from '@formkit/drag-and-drop/vue'
import { VForm } from 'vuetify/components/VForm'
import KanbanBoardEditDrawer from '@/views/apps/kanban/KanbanBoardEditDrawer.vue'
import KanbanItems from '@/views/apps/kanban/KanbanItems.vue'
const props = defineProps({
kanbanData: {
type: null,
required: true,
},
groupName: {
type: String,
required: false,
default: 'kanban',
},
})
const emit = defineEmits([
'addNewBoard',
'renameBoard',
'deleteBoard',
'addNewItem',
'editItem',
'deleteItem',
'updateItemsState',
'updateBoardState',
])
const kanbanWrapper = ref()
const localKanbanData = ref(props.kanbanData.boards)
const isKanbanBoardEditVisible = ref(false)
const isAddNewFormVisible = ref(false)
const refAddNewBoard = ref()
const boardTitle = ref('')
const editKanbanItem = ref()
// 👉 Add new board function that emit the name and id of new board
const addNewBoard = () => {
refAddNewBoard.value?.validate().then(valid => {
if (valid.valid) {
emit('addNewBoard', boardTitle.value)
isAddNewFormVisible.value = false
boardTitle.value = ''
}
})
}
const deleteBoard = boardId => {
emit('deleteBoard', boardId)
}
const renameBoard = boardName => {
emit('renameBoard', boardName)
}
const addNewItem = item => {
emit('addNewItem', item)
}
const editKanbanItemFn = item => {
if (item) {
editKanbanItem.value = item
isKanbanBoardEditVisible.value = true
}
}
const updateStateFn = kanbanState => {
emit('updateItemsState', kanbanState)
}
// 👉 initialize the drag and drop
dragAndDrop({
parent: kanbanWrapper,
values: localKanbanData,
dragHandle: '.drag-handler',
plugins: [animations()],
})
// assign the new kanban data to the local kanban data
watch(() => props, () => {
localKanbanData.value = props.kanbanData.boards
// 👉 remap the nodes when we rename the board: https://github.com/formkit/drag-and-drop/discussions/52#discussioncomment-8995203
remapNodes(kanbanWrapper.value)
}, { deep: true })
const emitUpdatedTaskFn = item => {
emit('editItem', item)
}
const deleteKanbanItemFn = item => {
emit('deleteItem', item)
}
// 👉 update boards data when it sort
watch(localKanbanData, () => {
const getIds = localKanbanData.value.map(board => board.id)
emit('updateBoardState', getIds)
}, { deep: true })
// 👉 validators for add new board
const validateBoardTitle = () => {
return props.kanbanData.boards.some(board => boardTitle.value && board.title.toLowerCase() === boardTitle.value.toLowerCase()) ? 'Board title already exists' : true
}
const hideAddNewForm = () => {
isAddNewFormVisible.value = false
refAddNewBoard.value?.reset()
}
// close add new item form when you loose focus from the form
onClickOutside(refAddNewBoard, hideAddNewForm)
</script>
<template>
<div class="kanban-main-wrapper d-flex gap-4 h-100">
<!-- 👉 kanban render -->
<div
ref="kanbanWrapper"
class="d-flex ga-6"
>
<template
v-for="kb in localKanbanData"
:key="kb.id"
>
<!-- 👉 kanban task render -->
<KanbanItems
:group-name="groupName"
:kanban-ids="kb.itemsIds"
:board-name="kb.title"
:board-id="kb.id"
:kanban-items="kanbanData.items"
:kanban-data="kanbanData"
@delete-board="deleteBoard"
@rename-board="renameBoard"
@add-new-item="addNewItem"
@edit-item="editKanbanItemFn"
@update-items-state="updateStateFn"
@delete-item="deleteKanbanItemFn"
/>
</template>
</div>
<!-- 👉 add new form -->
<div
class="add-new-form text-no-wrap"
style="inline-size: 10rem;"
>
<h6
class="text-lg font-weight-medium cursor-pointer"
@click="isAddNewFormVisible = !isAddNewFormVisible"
>
<VIcon
size="18"
icon="tabler-plus"
/> Add New
</h6>
<!-- 👉 Form -->
<VForm
v-if="isAddNewFormVisible"
ref="refAddNewBoard"
class="mt-4"
validate-on="submit"
@submit.prevent="addNewBoard"
>
<div class="mb-4">
<VTextField
v-model="boardTitle"
:rules="[requiredValidator, validateBoardTitle]"
autofocus
placeholder="Add Board Title"
@keydown.esc="hideAddNewForm"
/>
</div>
<div class="d-flex gap-3">
<VBtn
size="small"
type="submit"
>
Add
</VBtn>
<VBtn
size="small"
variant="tonal"
color="secondary"
type="reset"
@click="hideAddNewForm"
>
Cancel
</VBtn>
</div>
</VForm>
</div>
</div>
<!-- kanban edit drawer -->
<KanbanBoardEditDrawer
v-model:is-drawer-open="isKanbanBoardEditVisible"
:kanban-item="editKanbanItem"
@update:kanban-item="emitUpdatedTaskFn"
@delete-kanban-item="deleteKanbanItemFn"
/>
</template>
<style lang="scss">
@use "@styles/variables/_vuetify.scss" as vuetify;
.kanban-main-wrapper {
overflow: auto hidden;
margin-inline-start: -0.6rem;
min-block-size: calc(100vh - 10.5rem);
padding-inline-start: 0.6rem;
.kanban-board {
inline-size: 16.875rem;
min-inline-size: 16.875rem;
.kanban-board-drop-zone {
min-block-size: 100%;
}
}
.add-new-form {
.v-field__field {
border-radius: vuetify.$border-radius-root;
background-color: rgb(var(--v-theme-surface));
}
}
}
</style>

View File

@@ -0,0 +1,390 @@
<script setup>
import { Placeholder } from '@tiptap/extension-placeholder'
import { TextAlign } from '@tiptap/extension-text-align'
import { Underline } from '@tiptap/extension-underline'
import { StarterKit } from '@tiptap/starter-kit'
import {
EditorContent,
useEditor,
} from '@tiptap/vue-3'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { VForm } from 'vuetify/components/VForm'
import avatar1 from '@images/avatars/avatar-1.png'
import avatar2 from '@images/avatars/avatar-2.png'
import avatar3 from '@images/avatars/avatar-3.png'
import avatar4 from '@images/avatars/avatar-4.png'
import avatar5 from '@images/avatars/avatar-5.png'
import avatar6 from '@images/avatars/avatar-6.png'
const props = defineProps({
kanbanItem: {
type: null,
required: false,
default: () => ({
item: {
title: '',
dueDate: '2022-01-01T00:00:00Z',
labels: [],
members: [],
id: 0,
attachments: 0,
commentsCount: 0,
image: '',
comments: '',
},
boardId: 0,
boardName: '',
}),
},
isDrawerOpen: {
type: Boolean,
required: true,
},
})
const emit = defineEmits([
'update:isDrawerOpen',
'update:kanbanItem',
'deleteKanbanItem',
])
const refEditTaskForm = ref()
const labelOptions = [
'UX',
'Image',
'Code Review',
'Dashboard',
'App',
'Charts & Maps',
]
const localKanbanItem = ref(JSON.parse(JSON.stringify(props.kanbanItem.item)))
const handleDrawerModelValueUpdate = val => {
emit('update:isDrawerOpen', val)
if (!val)
refEditTaskForm.value?.reset()
}
// kanban item watcher
watch(() => props.kanbanItem, () => {
localKanbanItem.value = JSON.parse(JSON.stringify(props.kanbanItem.item))
}, { deep: true })
const updateKanbanItem = () => {
refEditTaskForm.value?.validate().then(async valid => {
if (valid.valid) {
emit('update:kanbanItem', {
item: localKanbanItem.value,
boardId: props.kanbanItem.boardId,
boardName: props.kanbanItem.boardName,
})
emit('update:isDrawerOpen', false)
await nextTick()
refEditTaskForm.value?.reset()
}
})
}
// delete kanban item
const deleteKanbanItem = () => {
emit('deleteKanbanItem', {
item: localKanbanItem.value,
boardId: props.kanbanItem.boardId,
boardName: props.kanbanItem.boardName,
})
emit('update:isDrawerOpen', false)
}
// 👉 label/chip color
const resolveLabelColor = {
'UX': 'success',
'Image': 'warning',
'Code Review': 'error',
'Dashboard': 'info',
'App': 'secondary',
'Charts & Maps': 'primary',
}
const users = [
{
img: avatar1,
name: 'John Doe',
},
{
img: avatar2,
name: 'Jane Smith',
},
{
img: avatar3,
name: 'Robert Johnson',
},
{
img: avatar4,
name: 'Lucy Brown',
},
{
img: avatar5,
name: 'Mike White',
},
{
img: avatar6,
name: 'Anna Black',
},
]
const fileAttached = ref()
const editor = useEditor({
content: '',
extensions: [
StarterKit,
TextAlign.configure({
types: [
'heading',
'paragraph',
],
}),
Placeholder.configure({ placeholder: 'Write a Comment...' }),
Underline,
],
})
const config = ref({
altFormat: 'j M, Y',
altInput: true,
dateFormat: 'Y-m-d',
})
</script>
<template>
<VNavigationDrawer
data-allow-mismatch
location="end"
:width="370"
temporary
border="0"
:model-value="props.isDrawerOpen"
@update:model-value="handleDrawerModelValueUpdate"
>
<!-- 👉 Header -->
<AppDrawerHeaderSection
title="Edit Task"
@cancel="$emit('update:isDrawerOpen', false)"
/>
<VDivider />
<PerfectScrollbar
:options="{ wheelPropagation: false }"
style="block-size: calc(100vh - 4rem);"
>
<VForm
v-if="localKanbanItem"
ref="refEditTaskForm"
@submit.prevent="updateKanbanItem"
>
<VCardText class="kanban-editor-drawer">
<VRow>
<VCol cols="12">
<AppTextField
v-model="localKanbanItem.title"
label="Title"
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="12">
<AppDateTimePicker
v-model="localKanbanItem.dueDate"
label="Due date"
:config="config"
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="localKanbanItem.labels"
:items="labelOptions"
label="Label"
multiple
eager
>
<template #chip="{ item }">
<VChip :color="resolveLabelColor[item.raw]">
{{ item.raw }}
</VChip>
</template>
</AppSelect>
</VCol>
<VCol cols="12">
<p
class="mb-1 text-body-2 text-high-emphasis"
style="line-height: 15px;"
>
Assigned
</p>
<div>
<VSelect
v-model="localKanbanItem.members"
:items="users"
item-title="name"
item-value="name"
multiple
return-object
variant="plain"
:menu-props="{
offset: 10,
}"
class="assignee-select"
>
<template #selection="{ item }">
<VAvatar size="26">
<VImg :src="item.raw.img" />
<VTooltip activator="parent">
{{ item.raw.name }}
</VTooltip>
</VAvatar>
</template>
<template #prepend-inner>
<IconBtn
size="26"
variant="tonal"
color="secondary"
>
<VIcon
size="20"
icon="tabler-plus"
/>
</IconBtn>
</template>
</VSelect>
</div>
</VCol>
<VCol cols="12">
<VFileInput
v-model="fileAttached"
prepend-icon=""
multiple
variant="outlined"
label="No file chosen"
clearable
>
<template #append>
<VBtn variant="tonal">
Choose
</VBtn>
</template>
</VFileInput>
</VCol>
<VCol cols="12">
<p
class="text-body-2 text-high-emphasis mb-1"
style="line-height: 15px;"
>
COMMENT
</p>
<div class="border rounded px-3 py-2">
<EditorContent :editor="editor" />
<div
v-if="editor"
class="d-flex justify-end flex-wrap gap-x-2"
>
<VIcon
icon="tabler-bold"
:color="editor.isActive('bold') ? 'primary' : 'secondary'"
size="20"
@click="editor.chain().focus().toggleBold().run()"
/>
<VIcon
:color="editor.isActive('underline') ? 'primary' : 'secondary'"
icon="tabler-underline"
size="20"
@click="editor.commands.toggleUnderline()"
/>
<VIcon
:color="editor.isActive('italic') ? 'primary' : 'secondary'"
icon="tabler-italic"
size="20"
@click="editor.chain().focus().toggleItalic().run()"
/>
<VIcon
:color="editor.isActive({ textAlign: 'left' }) ? 'primary' : 'secondary'"
icon="tabler-align-left"
size="20"
@click="editor.chain().focus().setTextAlign('left').run()"
/>
<VIcon
:color="editor.isActive({ textAlign: 'center' }) ? 'primary' : 'secondary'"
icon="tabler-align-center"
size="20"
@click="editor.chain().focus().setTextAlign('center').run()"
/>
<VIcon
:color="editor.isActive({ textAlign: 'right' }) ? 'primary' : 'secondary'"
icon="tabler-align-right"
size="20"
@click="editor.chain().focus().setTextAlign('right').run()"
/>
</div>
</div>
</VCol>
<VCol cols="12">
<VBtn
type="submit"
class="me-4"
>
Update
</VBtn>
<VBtn
color="error"
variant="tonal"
@click="deleteKanbanItem"
>
Delete
</VBtn>
</VCol>
</VRow>
</VCardText>
</VForm>
</PerfectScrollbar>
</VNavigationDrawer>
</template>
<style lang="scss">
.kanban-editor-drawer {
.assignee-select {
.v-field__append-inner {
.v-select__menu-icon {
display: none;
}
}
}
.ProseMirror {
padding: 0;
min-block-size: 7vh !important;
p {
margin-block-end: 0;
}
}
.ProseMirror-focused {
outline: none !important;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<script setup>
const props = defineProps({
item: {
type: null,
required: true,
},
boardId: {
type: Number,
required: true,
},
boardName: {
type: String,
required: true,
},
})
const emit = defineEmits(['deleteKanbanItem'])
const resolveLabelColor = {
'UX': 'success',
'Image': 'warning',
'Code Review': 'error',
'Dashboard': 'info',
'App': 'secondary',
'Charts & Maps': 'primary',
}
const moreOptions = [
{
title: 'Copy Task link',
href: '#',
},
{
title: 'Duplicate Task',
href: '#',
},
{
title: 'Delete',
onClick: () => {
emit('deleteKanbanItem', {
item: props.item,
boardId: props.boardId,
boardName: props.boardName,
})
},
},
]
</script>
<template>
<VCard
v-if="item"
:ripple="false"
:link="false"
class="kanban-card position-relative"
>
<VCardText class="d-flex flex-column gap-2">
<div class="d-flex align-start gap-2">
<div
v-if="item.labels && item.labels.length"
class="d-flex flex-wrap gap-2"
>
<VChip
v-for="text in item.labels"
:key="text"
size="small"
:color="resolveLabelColor[text]"
>
{{ text }}
</VChip>
</div>
<VSpacer />
<VMenu>
<template #activator="{ props: p, isActive }">
<VIcon
v-bind="p"
icon="tabler-dots-vertical"
class="position-absolute more-options"
style="inset-block-start: 16px; inset-inline-end: 10px;"
:style="isActive ? 'opacity: 1' : ''"
size="20"
@click.stop
/>
</template>
<VList
:items="moreOptions"
item-props
/>
</VMenu>
</div>
<!-- Task Img -->
<VImg
v-if="item.image && item.image.length"
:src="item.image"
class="rounded"
/>
<!-- Task title -->
<p class="text-base text-high-emphasis mb-0">
{{ item.title }}
</p>
<!-- footer -->
<div class="task-footer d-flex align-center flex-wrap justify-space-between">
<div
v-if="item.attachments || item.commentsCount"
class="d-flex align-center gap-4"
>
<div v-if="item.attachments">
<VIcon
size="20"
icon="tabler-paperclip"
class="me-1"
/>
<span class="text-body-1 d-inline-block">{{ item.attachments }}</span>
</div>
<div v-if="item.commentsCount">
<VIcon
size="20"
icon="tabler-message-2"
class="me-1"
/>
<span class="text-body-1 d-inline-block">{{ item.commentsCount }}</span>
</div>
</div>
<div
v-if="item.members && item.members.length"
class="v-avatar-group"
>
<VAvatar
v-for="avatar in item.members"
:key="avatar.name"
size="30"
>
<VImg :src="avatar.img" />
<VTooltip activator="parent">
{{ avatar.name }}
</VTooltip>
</VAvatar>
</div>
</div>
</VCardText>
</VCard>
</template>
<style lang="scss" scoped>
.kanban-card {
cursor: grab;
:active {
cursor: grabbing;
}
&[style^="z-index"] {
cursor: grabbing !important;
}
.more-options {
opacity: 0;
}
&:hover .more-options {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,312 @@
<script setup>
import {
animations,
handleEnd,
performTransfer,
} from '@formkit/drag-and-drop'
import { dragAndDrop } from '@formkit/drag-and-drop/vue'
import { VForm } from 'vuetify/components/VForm'
import KanbanCard from '@/views/apps/kanban/KanbanCard.vue'
const props = defineProps({
kanbanIds: {
type: Array,
required: true,
},
groupName: {
type: String,
required: true,
},
boardName: {
type: String,
required: true,
},
boardId: {
type: Number,
required: true,
},
kanbanData: {
type: null,
required: true,
},
})
const emit = defineEmits([
'renameBoard',
'deleteBoard',
'addNewItem',
'editItem',
'updateItemsState',
'deleteItem',
])
const refKanbanBoard = ref()
const localBoardName = ref(props.boardName)
const localIds = ref(props.kanbanIds)
const isAddNewFormVisible = ref(false)
const isBoardNameEditing = ref(false)
const refForm = ref()
const newTaskTitle = ref('')
const refKanbanBoardTitle = ref()
// 👉 required validator
const boardActions = [
{
title: 'Rename',
prependIcon: 'tabler-pencil',
onClick: () => {
isBoardNameEditing.value = true
},
},
{
title: 'Delete',
prependIcon: 'tabler-trash',
onClick: () => emit('deleteBoard', props.boardId),
},
]
// 👉 emit rename board event
const renameBoard = () => {
refKanbanBoardTitle.value?.validate().then(valid => {
if (valid.valid) {
emit('renameBoard', {
oldName: props.boardName,
newName: localBoardName.value,
boardId: props.boardId,
})
isBoardNameEditing.value = false
}
})
}
// 👉 emit add new item event
const addNewItem = () => {
refForm.value?.validate().then(valid => {
if (valid.valid) {
emit('addNewItem', {
itemTitle: newTaskTitle.value,
boardName: props.boardName,
boardId: props.boardId,
})
isAddNewFormVisible.value = false
newTaskTitle.value = ''
}
})
}
// 👉 initialize draggable
dragAndDrop({
parent: refKanbanBoard,
values: localIds,
group: props.groupName,
draggable: child => child.classList.contains('kanban-card'),
plugins: [animations()],
performTransfer: (state, data) => {
performTransfer(state, data)
emit('updateItemsState', {
boardId: props.boardId,
ids: localIds.value,
})
},
handleEnd: data => {
handleEnd(data)
emit('updateItemsState', {
boardId: props.boardId,
ids: localIds.value,
})
},
})
// 👉 watch kanbanIds its is useful when you add new task
watch(() => props, () => {
localIds.value = props.kanbanIds
}, {
immediate: true,
deep: true,
})
const resolveItemUsingId = id => props.kanbanData.items.find(item => item.id === id)
const deleteItem = item => {
emit('deleteItem', item)
}
// 👉 reset add new item form when esc or close
const hideAddNewForm = () => {
isAddNewFormVisible.value = false
refForm.value?.reset()
}
// close add new item form when you loose focus from the form
onClickOutside(refForm, hideAddNewForm)
// close board name form when you loose focus from the form
onClickOutside(refKanbanBoardTitle, () => {
isBoardNameEditing.value = false
})
// 👉 reset board rename form when esc or close
const hideResetBoardNameForm = () => {
isBoardNameEditing.value = false
localBoardName.value = props.boardName
}
const handleEnterKeydown = event => {
if (event.key === 'Enter' && !event.shiftKey)
addNewItem()
}
</script>
<template>
<div class="kanban-board">
<!-- 👉 board heading and title -->
<div class="kanban-board-header pb-4">
<VForm
v-if="isBoardNameEditing"
ref="refKanbanBoardTitle"
@submit.prevent="renameBoard"
>
<VTextField
v-model="localBoardName"
autofocus
variant="underlined"
:rules="[requiredValidator]"
hide-details
class="border-0"
@keydown.esc="hideResetBoardNameForm"
>
<template #append-inner>
<VIcon
size="20"
color="success"
icon="tabler-check"
class="me-1"
@click="renameBoard"
/>
<VIcon
size="20"
color="error"
icon="tabler-x"
@click="hideResetBoardNameForm"
/>
</template>
</VTextField>
</VForm>
<div
v-else
class="d-flex align-center justify-space-between "
>
<h4 class="text-lg font-weight-medium text-truncate">
{{ boardName }}
</h4>
<div class="d-flex align-center">
<VIcon
class="drag-handler"
size="20"
icon="tabler-arrows-move"
/>
<MoreBtn
size="28"
icon-size="20"
class="text-high-emphasis"
:menu-list="boardActions"
item-props
/>
</div>
</div>
</div>
<!-- 👉 draggable task start here -->
<div
v-if="localIds"
ref="refKanbanBoard"
class="kanban-board-drop-zone rounded d-flex flex-column gap-4"
:class="localIds.length ? 'mb-4' : ''"
>
<template
v-for="id in localIds"
:key="id"
>
<KanbanCard
:item="resolveItemUsingId(id)"
:board-id="props.boardId"
:board-name="props.boardName"
@delete-kanban-item="deleteItem"
@click="emit('editItem', { item: resolveItemUsingId(id), boardId: props.boardId, boardName: props.boardName })"
/>
</template>
<!-- 👉 Add new Form -->
<div class="add-new-form">
<h6
class="text-base font-weight-regular cursor-pointer ms-4"
@click="isAddNewFormVisible = !isAddNewFormVisible"
>
<VIcon
size="15"
icon="tabler-plus"
/> Add New Item
</h6>
<VForm
v-if="isAddNewFormVisible"
ref="refForm"
class="mt-4"
validate-on="submit"
@submit.prevent="addNewItem"
>
<div class="mb-4">
<VTextarea
v-model="newTaskTitle"
:rules="[requiredValidator]"
placeholder="Add Content"
autofocus
rows="2"
@keydown.enter="handleEnterKeydown"
@keydown.esc="hideAddNewForm"
/>
</div>
<div class="d-flex gap-4 flex-wrap">
<VBtn
size="small"
type="submit"
>
Add
</VBtn>
<VBtn
size="small"
variant="tonal"
color="secondary"
@click="hideAddNewForm"
>
Cancel
</VBtn>
</div>
</VForm>
</div>
</div>
</div>
</template>
<style lang="scss">
.kanban-board-header {
.drag-handler {
cursor: grab;
opacity: 0;
&:active {
cursor: grabbing;
}
}
&:hover {
.drag-handler {
opacity: 1;
}
}
}
</style>