Files
web/app/components/viewer/Legends.vue
2026-03-02 09:45:33 +07:00

355 lines
9.3 KiB
Vue

<script setup>
import { countBy, difference, isEqual, pick } from 'es-toolkit';
import Search from '@/components/viewer/Search.vue';
const props = defineProps({
products: Object,
carts: Object,
dealers: Object,
defaultStatusIds: Object,
defaultCartIds: Object,
defaultDealerIds: Object,
filters: Object,
statuses: Object,
statusModes: Object,
statusMode: Object,
});
const {
products,
carts,
dealers,
defaultCartIds,
defaultDealerIds,
filters,
statuses,
statusModes,
statusMode,
} = toRefs(props)
const emit = defineEmits(['switchStatusMode', 'updateFilters', 'resetCartDealerFilters', 'resetFilters']);
const store = useStore();
const showFilterModal = ref(null);
const { dealer } = store;
const filterTabs = dealer ? { CARTS: 'Giỏ hàng' } : { CARTS: 'Giỏ hàng', DEALERS: 'Đại lý' };
function openFiltersModal() {
showFilterModal.value = {
component: 'viewer/Filters',
title: 'Tuỳ chọn',
width: '40%',
height: '20%',
vbind: {
carts,
dealers,
filters,
filterTabs,
productCount: products.value.length
},
};
}
function updateFilters(updatedFilters) {
if (isEqual(pick(filters.value, ['cartIds', 'dealerIds']), updatedFilters.value))
return;
let payload;
const { lastlegendfiltertab } = store;
if (lastlegendfiltertab === filterTabs.CARTS) {
payload = {
cartIds: toRaw(updatedFilters.value.cartIds),
dealerIds: defaultDealerIds.value,
};
} else if (lastlegendfiltertab === filterTabs.DEALERS) {
payload = {
cartIds: defaultCartIds.value,
dealerIds: toRaw(updatedFilters.value.dealerIds),
};
}
emit('updateFilters', payload);
}
const cartOrDealerFilterInfo = computed(() => {
// dealer or cart filter is modified
if (filters.value.cartIds.length < carts.value.length) {
const filteredCarts = carts.value.filter(c => filters.value.cartIds.includes(c.id)).map(c => c.name.replace('Giỏ hàng ', ''));
return {
name: filterTabs.CARTS,
values: filteredCarts.join(', '),
}
}
if (filters.value.dealerIds.length < dealers.value.length) {
const filteredDealers = dealers.value.filter(d => filters.value.dealerIds.includes(d.id)).map(c => c.code);
return {
name: filterTabs.DEALERS,
values: filteredDealers.join(', '),
}
}
return false;
})
const productCountByStatus = computed(() => {
const productsFilteredBeforeStatus = products.value.filter(product => {
/*
cart__dealer: null means cart doesnt belong to any dealer
- when no filter: count null
- when filter: dont count null
*/
const { cartIds, dealerIds } = filters.value;
const { cart, cart__dealer } = product;
const diffCartFilter = difference(defaultCartIds.value, cartIds);
const diffDealerFilter = difference(defaultDealerIds.value, dealerIds);
const noCartFilter = diffCartFilter.length === 0;
const noDealerFilter = diffDealerFilter.length === 0;
const cartCondition = noCartFilter ? cartIds.includes(cart) || cart === null : cartIds.includes(cart);
const dealerCondition = noDealerFilter ? dealerIds.includes(cart__dealer) || cart__dealer === null : dealerIds.includes(cart__dealer);
return cartCondition && dealerCondition;
});
const result = countBy(productsFilteredBeforeStatus, (product) =>
statusMode.value === statusModes.value.SIMPLE ? product.status__sale_status : product.status
);
return result;
});
const visibilityAll = computed(() => {
return filters.value.statusIds.length > 0;
})
</script>
<template>
<Teleport to="#legend.docking-panel.docking-panel-container-solid-color-a">
<div id="customBtns">
<div class="is-sr-only">
<!-- hack to speed up first eye-off icon appearance -->
<SvgIcon v-bind="{
name: 'eye-autodesk-off.svg',
type: 'black',
size: 15
}" />
</div>
<button
title="Show/hide all legends"
@click="() => emit('updateFilters', {
statusIds: visibilityAll ? [] : statuses.map(s => s.id)
})"
>
<SvgIcon v-bind="{
name: `eye-autodesk-${visibilityAll ? 'on' : 'off'}.svg`,
type: 'black',
size: 17
}" />
</button>
</div>
</Teleport>
<Search :products="products" />
<div
class="tabs mb-0 has-background-white is-fullwidth is-flex-shrink-0"
style="position: sticky; top: 32.5px; z-index: -1"
>
<ul class="mx-0">
<li v-for="mode in Object.values(statusModes)"
:class="{ 'is-active' : statusMode === mode }"
@click="() => {
if (statusMode !== mode) emit('switchStatusMode', mode);
}"
>
<a :class="[
'is-size-7',
statusMode === mode && 'has-text-weight-bold has-text-secondary'
]">{{ mode }}</a>
</li>
</ul>
</div>
<div class="fs-13 has-background-white" style="z-index: -2">
<div
v-for="{ id, name, color } in statuses.filter(x => {
// hide Chưa bán in dealer
return dealer ? x.code !== 'not-sold' : true
})"
class="status is-flex is-gap-1 is-justify-content-space-between is-clickable"
@click="() => emit('updateFilters', { statusIds: [id] })"
>
<div class="statusInfo">
<div
class="swatch"
:style="{ backgroundColor: color }"
></div>
<p>
<span>{{ name }}</span>
</p>
</div>
<div>
<span class="mr-2 has-text-right is-inline-block" style="line-height: 1">
{{ productCountByStatus[id] || 0 }}
</span>
<button class="eyeBtn" @click.stop="() => {
const selected = filters.statusIds.includes(id);
const payload = {
statusIds: selected ? filters.statusIds.filter(statusId => statusId !== id) : [...filters.statusIds, id]
}
emit('updateFilters', payload);
}">
<SvgIcon v-bind="{
name: `eye-autodesk-${filters.statusIds.includes(id) ? 'on' : 'off'}.svg`,
type: 'black',
size: 17
}" />
</button>
</div>
</div>
</div>
<button
:class="[
'button is-fullwidth is-radiusless is-gap-0.5 is-small is-flex-direction-column is-align-items-stretch fs-13',
cartOrDealerFilterInfo && 'is-light'
]"
style="
position: sticky;
bottom: 0;
box-sizing: border-box;
border-inline-width: 0;
border-bottom-width: 0;
"
@click="openFiltersModal"
>
<div class="is-flex is-justify-content-space-between">
<div class="is-flex is-gap-1">
<SvgIcon v-bind="{ name: 'filter.svg', type: 'primary', size: 18 }" />
<span>Lọc</span>
</div>
<div style="margin-right: 5px">
<span class="has-text-weight-bold">{{ Object.values(productCountByStatus).reduce((p, c) => p + c, 0) }}</span>
<span style="margin-left: 9px">SP</span>
</div>
</div>
<div
v-if="cartOrDealerFilterInfo"
class="is-fullwidth is-flex is-flex-direction-column is-align-items-flex-start has-text-left"
>
<p class="is-fullwidth" style="text-wrap: wrap;">
{{ cartOrDealerFilterInfo.name }}:
{{ cartOrDealerFilterInfo.values }}
</p>
</div>
</button>
<Teleport to="#legend.docking-panel.docking-panel-container-solid-color-a">
<Modal
v-if="showFilterModal"
v-bind="showFilterModal"
@close="showFilterModal = null"
@updateFilters="updateFilters"
@resetCartDealerFilters="emit('resetCartDealerFilters')"
/>
</Teleport>
</template>
<style>
#legend.docking-panel {
right: 10px;
bottom: 10px;
min-width: 150px;
width: 275px;
height: fit-content;
--hover-bg: rgba(0, 0, 0, 0.1);
&.top-initial-important {
top: initial !important; /* fixed top until meshes & colors are added */
}
.docking-panel-title {
font-size: 16px;
font-weight: 600;
border-bottom: none;
text-transform: none;
flex-shrink: 0;
}
.docking-panel-close {
width: 40px;
background-position: 14px 19px;
&:hover {
/* make it uniform with our buttons */
background-color: var(--hover-bg);
transition-duration: var(--bulma-duration);
transition-property: background-color, border-color, color;
}
}
.docking-panel-footer {
border-radius: 0 0 5px 5px;
}
.content.docking-panel-scroll {
display: flex;
flex-direction: column;
margin-bottom: 20px;
z-index: -1;
overflow: scroll;
height: min-content;
}
.status {
&:nth-child(even) {
background-color: #f2f2f280;
}
&:hover {
background-color: rgba(0,191,255,.2);
}
.statusInfo {
padding-left: 10px;
display: flex;
gap: 0.5rem;
align-items: center;
p {
display: flex;
gap: 0.25rem;
}
}
}
#customBtns {
position: absolute;
top: 0px;
right: 40px;
width: 40px;
height: 50px;
z-index: 1;
display: flex;
& > * {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background-color: var(--hover-bg);
}
}
}
.eyeBtn {
width: 36px;
height: 36px;
&:hover {
background-color: var(--hover-bg);
}
}
.swatch {
width: 20px;
height: 20px;
}
}
</style>