Merge pull request 'Add drag and drop to Travel Extra Community table' (!189) from hyervoni/salix-front-mindshore:feature/ExtraCommunityDragAndDrop into dev
gitea/salix-front/pipeline/head This commit looks good Details
gitea/salix-front/pipeline/pr-dev This commit looks good Details

Reviewed-on: #189
Reviewed-by: JAVIER SEGARRA MARTINEZ <jsegarra@verdnatura.es>
Reviewed-by: Alex Moreno <alexm@verdnatura.es>
This commit is contained in:
Alex Moreno 2024-02-20 10:40:38 +00:00
commit e86804ea0a
5 changed files with 308 additions and 87 deletions

View File

@ -1,7 +1,8 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { onMounted, ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useArrayData } from 'composables/useArrayData';
import { useRoute } from 'vue-router';
import toDate from 'filters/toDate';
import VnFilterPanelChip from 'components/ui/VnFilterPanelChip.vue';
@ -52,6 +53,7 @@ const emit = defineEmits(['refresh', 'clear', 'search', 'init', 'remove']);
const arrayData = useArrayData(props.dataKey, {
exprBuilder: props.exprBuilder,
});
const route = useRoute();
const store = arrayData.store;
const userParams = ref({});
@ -63,6 +65,18 @@ onMounted(() => {
emit('init', { params: userParams.value });
});
watch(
() => route.query.params,
(val) => {
if (!val) {
userParams.value = {};
} else {
const parsedParams = JSON.parse(val);
userParams.value = { ...parsedParams };
}
}
);
const isLoading = ref(false);
async function search() {
isLoading.value = true;

View File

@ -13,6 +13,7 @@
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary: #ec8916;
$primary-light: lighten($primary, 35%);
$secondary: #26a69a;
$accent: #9c27b0;
$white: #fff;

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';

View File

@ -1,6 +1,6 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { QBtn, QField, QPopupEdit } from 'quasar';
import { onMounted, ref, computed, watch } from 'vue';
import { QBtn } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
@ -46,7 +46,12 @@ const arrayData = useArrayData('ExtraCommunity', {
},
});
const rows = computed(() => arrayData.store.data || []);
const rows = ref([]);
const originalRowDataCopy = ref([]);
const draggedRowIndex = ref(null);
const targetRowIndex = ref(null);
const entryRowIndex = ref(null);
const draggedEntry = ref(null);
const tableColumnComponents = {
id: {
@ -55,7 +60,12 @@ const tableColumnComponents = {
},
cargoSupplierNickname: {
component: QBtn,
attrs: { flat: true, color: 'primary', dense: true },
attrs: {
flat: true,
color: 'primary',
dense: true,
class: 'supplier-name-button',
},
},
agencyModeName: {
component: 'span',
@ -66,16 +76,24 @@ const tableColumnComponents = {
attrs: {},
},
ref: {
component: QField,
attrs: { readonly: true, dense: true, class: 'cursor-pointer' },
component: VnInput,
attrs: { dense: true },
event: (val, field, rowIndex) => ({
'keyup.enter': () => saveFieldValue(val, field, rowIndex),
blur: () => saveFieldValue(val, field, rowIndex),
}),
},
stickers: {
component: 'span',
attrs: {},
},
kg: {
component: QField,
attrs: { readonly: true, dense: true, class: 'cursor-pointer' },
component: VnInput,
attrs: { dense: true, type: 'number', min: 0, class: 'input-number' },
event: (val, field, rowIndex) => ({
'keyup.enter': () => saveFieldValue(val, field, rowIndex),
blur: () => saveFieldValue(val, field, rowIndex),
}),
},
loadedKg: {
component: 'span',
@ -199,7 +217,7 @@ const columns = computed(() => [
align: 'left',
showValue: true,
sortable: true,
format: (value) => toDate(value.substring(0, 10)),
format: (value) => toDate(value),
},
{
label: t('globals.wareHouseIn'),
@ -216,7 +234,7 @@ const columns = computed(() => [
align: 'left',
showValue: true,
sortable: true,
format: (value) => toDate(value.substring(0, 10)),
format: (value) => toDate(value),
},
]);
@ -224,6 +242,22 @@ async function getData() {
await arrayData.fetch({ append: false });
}
const onStoreDataChange = () => {
const newData = JSON.parse(JSON.stringify(arrayData.store.data)) || [];
rows.value = newData;
// el objetivo de esto es guardar una copia de los valores iniciales de todas las rows para corroborar si la data cambio antes de guardar los cambios
originalRowDataCopy.value = JSON.parse(JSON.stringify(newData));
};
watch(
arrayData.store,
() => {
if (!arrayData.store.data) return;
onStoreDataChange();
},
{ deep: true, immediate: true }
);
const openReportPdf = () => {
const params = {
...arrayData.store.userParams,
@ -235,9 +269,14 @@ const openReportPdf = () => {
const saveFieldValue = async (val, field, index) => {
try {
// Evitar la solicitud de guardado si el valor no ha cambiado
if (originalRowDataCopy.value[index][field] == val) return;
const id = rows.value[index].id;
const params = { [field]: val };
await axios.patch(`Travels/${id}`, params);
// Actualizar la copia de los datos originales con el nuevo valor
originalRowDataCopy.value[index][field] = val;
} catch (err) {
console.error('Error updating travel');
}
@ -248,6 +287,7 @@ const navigateToTravelId = (id) => {
};
const stopEventPropagation = (event, col) => {
// Detener la propagación del evento de los siguientes elementos para evitar el click sobre la row que dispararía la función navigateToTravelId
if (!['ref', 'id', 'cargoSupplierNickname', 'kg'].includes(col.name)) return;
event.preventDefault();
event.stopPropagation();
@ -263,6 +303,121 @@ onMounted(async () => {
landedTo.value.setHours(23, 59, 59, 59);
await getData();
});
// Handler del evento @dragstart (inicio del drag) y guarda información inicial
const handleDragStart = (event, rowIndex, entryIndex) => {
draggedRowIndex.value = rowIndex;
entryRowIndex.value = entryIndex;
event.dataTransfer.effectAllowed = 'move';
};
// Handler del evento @dragenter (cuando haces drag sobre une elemento y lo arrastras sobre un posible target de drop) y actualiza el targetIndex
const handleDragEnter = (_, targetIndex) => {
targetRowIndex.value = targetIndex;
};
const saveRowDrop = async (targetRowIndex) => {
const entryId = draggedEntry.value.id;
const travelId = rows.value[targetRowIndex].id;
await axios.patch(`Entries/${entryId}`, { travelFk: travelId });
};
const moveRow = async (draggedRowIndex, targetRowIndex, entryIndex) => {
try {
if (draggedRowIndex === targetRowIndex) return;
// Remover entry de la row original
draggedEntry.value = rows.value[draggedRowIndex].entries.splice(entryIndex, 1)[0];
//Si la row de destino por alguna razón no tiene la propiedad entry la creamos
if (!rows.value[targetRowIndex].entries) rows.value[targetRowIndex].entries = [];
// Añadir entry a la row de destino
rows.value[targetRowIndex].entries.push(draggedEntry.value);
await saveRowDrop(targetRowIndex);
} catch (err) {
cleanDragAndDropData();
console.error('Error moving row', err);
}
};
// Handler de cuando haces un drop tanto dentro como fuera de la tabla para limpiar acciones y data
const handleDragEnd = () => {
stopScroll();
cleanDragAndDropData();
};
// Handler del evento @drop (cuando soltas el elemento draggeado sobre un target)
const handleDrop = () => {
if (
!draggedRowIndex.value &&
draggedRowIndex.value !== 0 &&
!targetRowIndex.value &&
draggedRowIndex.value !== 0
)
return;
moveRow(draggedRowIndex.value, targetRowIndex.value, entryRowIndex.value);
handleDragEnd();
};
const cleanDragAndDropData = () => {
draggedRowIndex.value = null;
targetRowIndex.value = null;
entryRowIndex.value = null;
draggedEntry.value = null;
};
const scrollInterval = ref(null);
const startScroll = (direction) => {
// Iniciar el scroll en la dirección especificada
if (!scrollInterval.value) {
scrollInterval.value = requestAnimationFrame(() => scroll(direction));
}
};
const stopScroll = () => {
if (scrollInterval.value) {
cancelAnimationFrame(scrollInterval.value);
scrollInterval.value = null;
}
};
const scroll = (direction) => {
// Controlar el desplazamiento en la dirección especificada
const yOffset = direction === 'up' ? -2 : 2;
window.scrollBy(0, yOffset);
const windowHeight = window.innerHeight;
const documentHeight = document.body.offsetHeight;
// Verificar si se alcanzaron los límites de la ventana para detener el desplazamiento
if (
(direction === 'up' && window.scrollY > 0) ||
(direction === 'down' && windowHeight + window.scrollY < documentHeight)
) {
scrollInterval.value = requestAnimationFrame(() => scroll(direction));
} else {
stopScroll();
}
};
// Handler del scroll mientras se hace el drag de una row
const handleDragScroll = (event) => {
// Obtener la posición y dimensiones del cursor
const y = event.clientY;
const windowHeight = window.innerHeight;
// Verificar si el cursor está cerca del borde superior o inferior de la ventana
const nearTop = y < 150;
const nearBottom = y > windowHeight - 100;
if (nearTop) {
startScroll('up');
} else if (nearBottom) {
startScroll('down');
} else {
stopScroll();
}
};
</script>
<template>
@ -302,59 +457,60 @@ onMounted(async () => {
row-key="clientId"
:pagination="{ rowsPerPage: 0 }"
class="full-width"
table-style="user-select: none;"
@drag="handleDragScroll($event)"
@dragend="handleDragEnd($event)"
:separator="!targetRowIndex && targetRowIndex !== 0 ? 'horizontal' : 'none'"
>
<template #body="props">
<QTr
:props="props"
@click="navigateToTravelId(props.row.id)"
class="cursor-pointer bg-vn-primary-row"
@click="navigateToTravelId(props.row.id)"
@dragenter="handleDragEnter($event, props.rowIndex)"
@dragover.prevent
@drop="handleDrop()"
:class="{
'dashed-border --top --left --right':
targetRowIndex === props.rowIndex,
'--bottom':
targetRowIndex === props.rowIndex &&
(!props.row.entries || props.row.entries.length === 0),
}"
>
<QTd
v-for="col in props.cols"
:key="col.name"
:props="props"
@click="stopEventPropagation($event, col)"
auto-width
>
<component
:is="tableColumnComponents[col.name].component"
v-bind="tableColumnComponents[col.name].attrs"
>
<!-- Editable 'ref' and 'kg' QField slot -->
<template
v-if="col.name === 'ref' || col.name === 'kg'"
#control
>
<div
class="self-center full-width no-outline"
tabindex="0"
>
{{ col.value }}
</div>
<QPopupEdit
:key="col.name"
v-model="col.value"
label-set="Save"
label-cancel="Close"
>
<VnInput
v-model="rows[props.pageIndex][col.field]"
dense
autofocus
@keyup.enter="
saveFieldValue(
rows[props.pageIndex][col.field],
v-model="rows[props.rowIndex][col.field]"
v-on="
tableColumnComponents[col.name].event
? tableColumnComponents[col.name].event(
rows[props.rowIndex][col.field],
col.field,
props.rowIndex
)
: {}
"
/>
</QPopupEdit>
</template>
>
<template v-if="col.showValue">
{{ col.value }}
<span
:class="[
'text-left',
{
'supplier-name':
col.name === 'cargoSupplierNickname',
},
]"
>{{ col.value }}</span
>
</template>
<!-- Main Row Descriptors -->
<TravelDescriptorProxy
v-if="col.name === 'id'"
@ -367,13 +523,27 @@ onMounted(async () => {
</component>
</QTd>
</QTr>
<QTr
v-for="entry in props.row.entries"
:key="entry.id"
v-for="(entry, index) in props.row.entries"
:key="index"
:props="props"
class="bg-vn-secondary-row"
class="bg-vn-secondary-row cursor-pointer"
@dragstart="handleDragStart($event, props.rowIndex, index)"
@dragenter="handleDragEnter($event, props.rowIndex)"
@dragover.prevent
@drop="handleDrop()"
:draggable="true"
:class="{
'dragged-row':
entryRowIndex === index && props.rowIndex === draggedRowIndex,
'dashed-border --left --right': targetRowIndex === props.rowIndex,
'--bottom':
targetRowIndex === props.rowIndex &&
index === props.row.entries.length - 1,
}"
>
<QTd class="row justify-center">
<QTd>
<QBtn flat color="primary">{{ entry.id }} </QBtn>
<EntryDescriptorProxy :id="entry.id" />
</QTd>
@ -381,33 +551,75 @@ onMounted(async () => {
<QBtn flat color="primary" dense>{{ entry.supplierName }}</QBtn>
<SupplierDescriptorProxy :id="entry.supplierFk" />
</QTd>
<QTd />
<QTd>
<span>{{ toCurrency(entry.invoiceAmount) }}</span>
</QTd>
<QTd>
<span>{{ entry.reference }}</span>
</QTd>
<QTd>
<span>{{ entry.stickers }}</span>
</QTd>
<QTd></QTd>
<QTd
><span>{{ toCurrency(entry.invoiceAmount) }}</span></QTd
>
<QTd
><span>{{ entry.reference }}</span></QTd
>
<QTd
><span>{{ entry.stickers }}</span></QTd
>
<QTd></QTd>
<QTd
><span>{{ entry.loadedkg }}</span></QTd
>
<QTd
><span>{{ entry.volumeKg }}</span></QTd
>
<QTd></QTd>
<QTd></QTd>
<QTd></QTd>
<QTd></QTd>
<QTd>
<span>{{ entry.loadedkg }}</span>
</QTd>
<QTd>
<span>{{ entry.volumeKg }}</span>
</QTd>
<QTd />
<QTd />
<QTd />
<QTd />
</QTr>
</template>
</QTable>
</QPage>
</template>
<style scoped lang="scss">
:deep(.q-table) {
border-collapse: collapse;
}
.dashed-border {
&.--left {
border-left: 1px dashed #ccc;
}
&.--right {
border-right: 1px dashed #ccc;
}
&.--top {
border-top: 1px dashed #ccc;
}
&.--bottom {
border-bottom: 1px dashed #ccc;
}
}
.dragged-row {
background-color: $primary-light;
}
.supplier-name {
display: flex;
max-width: 150px;
@media (max-width: $breakpoint-md-max) {
max-width: 100px;
}
}
.supplier-name-button {
white-space: normal;
width: max-content;
}
</style>
<i18n>
en:
searchExtraCommunity: Search for extra community shipping

View File

@ -65,7 +65,7 @@ const decrement = (paramsObj, key) => {
<span>{{ formatFn(tag.value) }}</span>
</div>
</template>
<template #body="{ params }">
<template #body="{ params, searchFn }">
<QItem>
<QItemSection>
<VnInput label="id" v-model="params.id" is-outlined />
@ -116,6 +116,7 @@ const decrement = (paramsObj, key) => {
<VnSelectFilter
:label="t('params.agencyModeFk')"
v-model="params.agencyModeFk"
@update:model-value="searchFn()"
:options="agenciesOptions"
option-value="agencyFk"
option-label="name"
@ -129,8 +130,9 @@ const decrement = (paramsObj, key) => {
<QItem>
<QItemSection>
<VnInputDate
v-model="params.shippedFrom"
:label="t('params.shippedFrom')"
v-model="params.shippedFrom"
@update:model-value="searchFn()"
is-outlined
/>
</QItemSection>
@ -138,8 +140,9 @@ const decrement = (paramsObj, key) => {
<QItem>
<QItemSection>
<VnInputDate
v-model="params.landedTo"
:label="t('params.landedTo')"
v-model="params.landedTo"
@update:model-value="searchFn()"
is-outlined
/>
</QItemSection>
@ -149,6 +152,7 @@ const decrement = (paramsObj, key) => {
<VnSelectFilter
:label="t('params.warehouseOutFk')"
v-model="params.warehouseOutFk"
@update:model-value="searchFn()"
:options="warehousesOptions"
option-value="id"
option-label="name"
@ -164,6 +168,7 @@ const decrement = (paramsObj, key) => {
<VnSelectFilter
:label="t('params.warehouseInFk')"
v-model="params.warehouseInFk"
@update:model-value="searchFn()"
:options="warehousesOptions"
option-value="id"
option-label="name"
@ -179,6 +184,7 @@ const decrement = (paramsObj, key) => {
<VnSelectFilter
:label="t('supplier.pageTitles.supplier')"
v-model="params.cargoSupplierFk"
@update:model-value="searchFn()"
:options="suppliersOptions"
option-value="id"
option-label="name"
@ -194,6 +200,7 @@ const decrement = (paramsObj, key) => {
<VnSelectFilter
:label="t('params.continent')"
v-model="params.continent"
@update:model-value="searchFn()"
:options="continentsOptions"
option-value="code"
option-label="name"
@ -208,19 +215,6 @@ const decrement = (paramsObj, key) => {
</VnFilterPanel>
</template>
<style scoped>
.input-number >>> input[type='number'] {
-moz-appearance: textfield;
}
.input-number >>> input::-webkit-outer-spin-button,
.input-number >>> input::-webkit-inner-spin-button {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
</style>
<i18n>
en:
params: