|
|
|
<template>
|
|
|
|
<v-container fluid class="h-100 d-100" style="overflow: auto">
|
|
|
|
<kanban-board
|
|
|
|
:stages="kanban.groupingColumnItems"
|
|
|
|
:blocks="kanban.blocks"
|
|
|
|
class="h-100 my-0 mx-n2"
|
|
|
|
@update-block="updateBlock"
|
|
|
|
>
|
|
|
|
<div v-for="stage in kanban.groupingColumnItems" :slot="stage" :key="stage" class="mx-auto">
|
|
|
|
<enum-cell :value="stage" :column="groupingFieldColumn" />
|
|
|
|
</div>
|
|
|
|
<div v-for="block in kanban.blocks" :slot="block.id" :key="block.c_pk" class="caption">
|
|
|
|
<v-hover v-slot="{ hover }">
|
|
|
|
<v-card class="h-100" :elevation="hover ? 4 : 1" @click="$emit('expandKanbanForm', { rowIdx: block.c_pk })">
|
|
|
|
<v-card-text>
|
|
|
|
<v-container>
|
|
|
|
<v-row class="">
|
|
|
|
<v-col
|
|
|
|
v-for="col in fields"
|
|
|
|
v-show="showFields[col.title]"
|
|
|
|
:key="col.title"
|
|
|
|
class="kanban-col col-12"
|
|
|
|
>
|
|
|
|
<label :for="`data-table-form-${col.title}`" class="body-2 text-capitalize caption grey--text">
|
|
|
|
<virtual-header-cell
|
|
|
|
v-if="col.virtual"
|
|
|
|
:column="col"
|
|
|
|
:nodes="nodes"
|
|
|
|
:is-form="true"
|
|
|
|
:meta="meta"
|
|
|
|
/>
|
|
|
|
<header-cell v-else :is-form="true" :value="col.title" :column="col" />
|
|
|
|
</label>
|
|
|
|
<virtual-cell
|
|
|
|
v-if="col.virtual"
|
|
|
|
ref="virtual"
|
|
|
|
:column="col"
|
|
|
|
:row="block"
|
|
|
|
:nodes="nodes"
|
|
|
|
:meta="meta"
|
|
|
|
/>
|
|
|
|
<table-cell
|
|
|
|
v-else
|
|
|
|
:value="block[col.title]"
|
|
|
|
:column="col"
|
|
|
|
:sql-ui="sqlUi"
|
|
|
|
class="xc-input body-2"
|
|
|
|
:meta="meta"
|
|
|
|
/>
|
|
|
|
</v-col>
|
|
|
|
</v-row>
|
|
|
|
</v-container>
|
|
|
|
</v-card-text>
|
|
|
|
</v-card>
|
|
|
|
</v-hover>
|
|
|
|
</div>
|
|
|
|
<div v-for="stage in kanban.groupingColumnItems" :key="stage" :slot="`footer-${stage}`" class="kanban-footer">
|
|
|
|
<x-btn
|
|
|
|
v-if="stage"
|
|
|
|
outlined
|
|
|
|
tooltip="Add a new record"
|
|
|
|
color="primary"
|
|
|
|
class="primary"
|
|
|
|
x-small
|
|
|
|
fab
|
|
|
|
@click="insertNewRow(true, true, { [groupingField]: stage })"
|
|
|
|
>
|
|
|
|
<v-icon small> mdi-plus </v-icon>
|
|
|
|
</x-btn>
|
|
|
|
<!-- <x-btn
|
|
|
|
v-else
|
|
|
|
outlined
|
|
|
|
tooltip="New Stack"
|
|
|
|
color="primary"
|
|
|
|
class="primary"
|
|
|
|
small
|
|
|
|
@click="insertNewRow(true, true, {[groupingField]: stage})"
|
|
|
|
>
|
|
|
|
<v-icon small left>
|
|
|
|
mdi-plus
|
|
|
|
</v-icon>
|
|
|
|
New Stack
|
|
|
|
</x-btn> -->
|
|
|
|
<div class="record-cnt caption grey--text">
|
|
|
|
{{ kanban.recordCnt[stage] }} / {{ kanban.recordTotalCnt[stage] }}
|
|
|
|
{{ kanban.recordTotalCnt[stage] > 1 ? 'records' : 'record' }}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</kanban-board>
|
|
|
|
</v-container>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script>
|
|
|
|
import VirtualHeaderCell from '../components/VirtualHeaderCell';
|
|
|
|
import HeaderCell from '../components/HeaderCell';
|
|
|
|
import VirtualCell from '../components/VirtualCell';
|
|
|
|
import TableCell from '../components/Cell';
|
|
|
|
import EnumCell from '../components/cell/EnumCell';
|
|
|
|
export default {
|
|
|
|
name: 'KanbanView',
|
|
|
|
components: { TableCell, VirtualCell, HeaderCell, VirtualHeaderCell, EnumCell },
|
|
|
|
props: [
|
|
|
|
'nodes',
|
|
|
|
'table',
|
|
|
|
'showFields',
|
|
|
|
'availableColumns',
|
|
|
|
'meta',
|
|
|
|
'kanban',
|
|
|
|
'primaryValueColumn',
|
|
|
|
'showSystemFields',
|
|
|
|
'sqlUi',
|
|
|
|
'groupingField',
|
|
|
|
'api',
|
|
|
|
],
|
|
|
|
computed: {
|
|
|
|
fields() {
|
|
|
|
if (this.availableColumns) {
|
|
|
|
return this.availableColumns;
|
|
|
|
}
|
|
|
|
if (this.showSystemFields) {
|
|
|
|
return this.meta.columns || [];
|
|
|
|
}
|
|
|
|
const hideCols = ['created_at', 'updated_at'];
|
|
|
|
return (
|
|
|
|
this.meta.columns.filter(
|
|
|
|
c =>
|
|
|
|
!(c.pk && c.ai) &&
|
|
|
|
!hideCols.includes(c.column_name) &&
|
|
|
|
!(this.meta.v || []).some(v => v.bt && v.bt.column_name === c.column_name)
|
|
|
|
) || []
|
|
|
|
);
|
|
|
|
},
|
|
|
|
groupingFieldColumn() {
|
|
|
|
return this.fields.filter(f => f.title === this.groupingField)[0];
|
|
|
|
},
|
|
|
|
},
|
|
|
|
mounted() {
|
|
|
|
const kbListElements = document.querySelectorAll('.drag-inner-list');
|
|
|
|
kbListElements.forEach(kbListEle => {
|
|
|
|
kbListEle.addEventListener('scroll', async e => {
|
|
|
|
if (kbListEle.scrollTop + kbListEle.clientHeight >= kbListEle.scrollHeight) {
|
|
|
|
const groupingFieldVal = kbListEle.getAttribute('data-status');
|
|
|
|
this.$emit('loadMoreKanbanData', groupingFieldVal);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
},
|
|
|
|
methods: {
|
|
|
|
async updateBlock(c_pk, status) {
|
|
|
|
try {
|
|
|
|
if (!this.api) {
|
|
|
|
this.$toast
|
|
|
|
.error('API not found', {
|
|
|
|
position: 'bottom-center',
|
|
|
|
})
|
|
|
|
.goAway(3000);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// update kanban block
|
|
|
|
const targetBlock = this.kanban.blocks.find(b => b.c_pk === c_pk);
|
|
|
|
if (!targetBlock) {
|
|
|
|
this.$toast
|
|
|
|
.error(`Block with ID ${c_pk} not found`, {
|
|
|
|
position: 'bottom-center',
|
|
|
|
})
|
|
|
|
.goAway(3000);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (targetBlock.status === status) {
|
|
|
|
// no change
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const uncategorized = 'Uncategorized';
|
|
|
|
const prevStatus = targetBlock.status;
|
|
|
|
await this.api.update(
|
|
|
|
c_pk,
|
|
|
|
{ [this.groupingField]: status === uncategorized ? null : status }, // new data
|
|
|
|
{ [this.groupingField]: prevStatus }
|
|
|
|
); // old data
|
|
|
|
|
|
|
|
this.$set(targetBlock, 'status', status);
|
|
|
|
this.$set(targetBlock, this.groupingField, status === uncategorized ? null : status);
|
|
|
|
|
|
|
|
// update kanban data
|
|
|
|
const kanbanRow = this.kanban.data.find(d => d.row.c_pk === c_pk);
|
|
|
|
if (kanbanRow) {
|
|
|
|
this.$set(kanbanRow.row, this.groupingField, status === uncategorized ? null : status);
|
|
|
|
}
|
|
|
|
this.$set(this.kanban.recordCnt, prevStatus, this.kanban.recordCnt[prevStatus] - 1);
|
|
|
|
this.$set(this.kanban.recordCnt, status, this.kanban.recordCnt[status] + 1);
|
|
|
|
this.$set(this.kanban.recordTotalCnt, prevStatus, this.kanban.recordTotalCnt[prevStatus] - 1);
|
|
|
|
this.$set(this.kanban.recordTotalCnt, status, this.kanban.recordTotalCnt[status] + 1);
|
|
|
|
this.$forceUpdate();
|
|
|
|
this.$toast
|
|
|
|
.success(`Moved block from ${prevStatus} to ${status ?? uncategorized} successfully.`, {
|
|
|
|
position: 'bottom-center',
|
|
|
|
})
|
|
|
|
.goAway(3000);
|
|
|
|
} catch (e) {
|
|
|
|
if (e.response && e.response.data && e.response.data.msg) {
|
|
|
|
this.$toast
|
|
|
|
.error(e.response.data.msg, {
|
|
|
|
position: 'bottom-center',
|
|
|
|
})
|
|
|
|
.goAway(3000);
|
|
|
|
} else {
|
|
|
|
this.$toast
|
|
|
|
.error(`Failed to update block : ${e.message}`, {
|
|
|
|
position: 'bottom-center',
|
|
|
|
})
|
|
|
|
.goAway(3000);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
insertNewRow(atEnd = false, expand = false, presetValues = {}) {
|
|
|
|
this.$emit('insertNewRow', atEnd, expand, presetValues);
|
|
|
|
},
|
|
|
|
},
|
|
|
|
};
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
::v-deep {
|
|
|
|
.v-card {
|
|
|
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
|
|
}
|
|
|
|
|
|
|
|
ul.drag-inner-list {
|
|
|
|
overflow-y: scroll;
|
|
|
|
}
|
|
|
|
|
|
|
|
ul.drag-list,
|
|
|
|
ul.drag-inner-list {
|
|
|
|
list-style-type: none;
|
|
|
|
margin: 0;
|
|
|
|
padding: 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-container {
|
|
|
|
//max-width: 1000px;
|
|
|
|
margin: 20px 0px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-list {
|
|
|
|
display: flex;
|
|
|
|
align-items: flex-start;
|
|
|
|
}
|
|
|
|
|
|
|
|
@media (max-width: 690px) {
|
|
|
|
.drag-list {
|
|
|
|
display: block;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-column {
|
|
|
|
flex: 1;
|
|
|
|
margin: 0 10px;
|
|
|
|
position: relative;
|
|
|
|
background: var(--v-backgroundColor-base); //rgba(256, 256, 256, 0.2);
|
|
|
|
border-radius: 6px;
|
|
|
|
max-width: 240px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-column-footer {
|
|
|
|
padding: 20px 10px 10px 10px;
|
|
|
|
text-align: center;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* .drag-column-footer .v-btn {
|
|
|
|
border-radius: 50%;
|
|
|
|
border: 2px solid;
|
|
|
|
padding: 0px 0px 0px 6px;
|
|
|
|
min-width: 40px;
|
|
|
|
min-height: 38px;
|
|
|
|
}*/
|
|
|
|
|
|
|
|
.drag-column-footer .record-cnt {
|
|
|
|
height: 38px;
|
|
|
|
line-height: 38px;
|
|
|
|
font-size: 15px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-column-footer .v-btn .mdi-plug::before {
|
|
|
|
font-weight: bold;
|
|
|
|
}
|
|
|
|
|
|
|
|
@media (max-width: 690px) {
|
|
|
|
.drag-column {
|
|
|
|
margin-bottom: 30px;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-column h2 {
|
|
|
|
font-size: 0.8rem;
|
|
|
|
margin: 0;
|
|
|
|
text-transform: uppercase;
|
|
|
|
font-weight: 600;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-column-header {
|
|
|
|
display: flex;
|
|
|
|
align-items: center;
|
|
|
|
justify-content: space-between;
|
|
|
|
width: 240px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-column-header .set-item {
|
|
|
|
margin-top: 20px !important;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-item {
|
|
|
|
margin: 10px;
|
|
|
|
background: var(--v-backgroundColor-lighten2);
|
|
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
|
|
|
border-radius: 4px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-item .container {
|
|
|
|
padding: 0px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-item.is-moving {
|
|
|
|
transform: scale(1.1);
|
|
|
|
background: var(--v-backgroundColor-darken1);
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-header-more {
|
|
|
|
cursor: pointer;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-options {
|
|
|
|
left: 0;
|
|
|
|
width: 100%;
|
|
|
|
height: 100%;
|
|
|
|
transform: translateX(100%);
|
|
|
|
opacity: 0;
|
|
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-options.active {
|
|
|
|
transform: translateX(0);
|
|
|
|
opacity: 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-options-label {
|
|
|
|
display: block;
|
|
|
|
margin: 0 0 5px 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-options-label input {
|
|
|
|
opacity: 0.6;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-options-label span {
|
|
|
|
display: inline-block;
|
|
|
|
font-size: 0.9rem;
|
|
|
|
font-weight: 400;
|
|
|
|
margin-left: 5px;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* Dragula CSS */
|
|
|
|
.gu-mirror {
|
|
|
|
position: fixed !important;
|
|
|
|
margin: 0 !important;
|
|
|
|
z-index: 9999 !important;
|
|
|
|
opacity: 0.8;
|
|
|
|
list-style-type: none;
|
|
|
|
}
|
|
|
|
|
|
|
|
.gu-hide {
|
|
|
|
display: none !important;
|
|
|
|
}
|
|
|
|
|
|
|
|
.gu-unselectable {
|
|
|
|
-webkit-user-select: none !important;
|
|
|
|
-moz-user-select: none !important;
|
|
|
|
-ms-user-select: none !important;
|
|
|
|
user-select: none !important;
|
|
|
|
}
|
|
|
|
|
|
|
|
.gu-transit {
|
|
|
|
opacity: 0.2;
|
|
|
|
}
|
|
|
|
|
|
|
|
.kanban-col {
|
|
|
|
padding: 10px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.drag-container {
|
|
|
|
display: inline-block;
|
|
|
|
.drag-list {
|
|
|
|
height: 100%;
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
|
|
.drag-column {
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
max-height: max(400px, 100%);
|
|
|
|
|
|
|
|
.drag-inner-list {
|
|
|
|
overflow-y: auto;
|
|
|
|
overflow-x: hidden;
|
|
|
|
min-height: 200px;
|
|
|
|
flex-grow: 1;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
</style>
|
|
|
|
|
|
|
|
<!--
|
|
|
|
/**
|
|
|
|
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
|
|
|
|
*
|
|
|
|
* @author Wing-Kam Wong <wingkwong.code@gmail.com>
|
|
|
|
*
|
|
|
|
* @license GNU AGPL version 3 or any later version
|
|
|
|
*
|
|
|
|
* This program is free software: you can redistribute it and/or modify
|
|
|
|
* it under the terms of the GNU Affero General Public License as
|
|
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
|
|
* License, or (at your option) any later version.
|
|
|
|
*
|
|
|
|
* This program is distributed in the hope that it will be useful,
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
* GNU Affero General Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
*
|
|
|
|
*/
|
|
|
|
-->
|