This commit is contained in:
Viet An
2026-05-06 08:54:02 +07:00
parent f5123ee018
commit 6e10dffd22
23 changed files with 102 additions and 2733 deletions

View File

@@ -1,4 +0,0 @@
<!-- Viewer: display when click tem from another dealer -->
<template>
<p>Rất tiếc, bạn hiện chưa quyền xem thông tin sản phẩm này.</p>
</template>

View File

@@ -1,3 +0,0 @@
<script lang="ts" setup></script>
<template>Inventory Count</template>

View File

@@ -58,7 +58,7 @@ const emit = defineEmits(["selectOrder", "unselect"]);
<div class="is-flex is-gap-0.5">
<Icon
:size="18"
name="material-symbols:calendar-month-outline-rounded"
name="material-symbols:calendar-today-outline-rounded"
/>
<span>{{ $dayjs(order.create_time).format("L") }}</span>
</div>

View File

@@ -484,7 +484,7 @@ function toggleStatus(id) {
</script>
<template>
<div>
<div class="card is-clipped">
<div class="card">
<div class="card-content">
<div class="is-flex is-gap-2 is-align-items-center">
<div class="field is-flex-grow-1 m-0">

View File

@@ -1,227 +0,0 @@
<script setup>
const props = defineProps({
carts: Array,
filters: Object,
filterTabs: Object,
productCount: Number,
});
const store = useStore();
const { $getdata } = useNuxtApp();
const emit = defineEmits(["modalevent", "close"]);
const dealers = ref([]);
onMounted(async () => {
const dealersData = await $getdata("cart", undefined, {
values: "dealer,dealer__name",
filter: {
dealer__name__icontains: "",
},
distinct_values: {
product_count_by_dealer: { type: "Count", field: "prdcart" },
},
sort: "dealer__name",
summary: "annotate",
});
dealers.value = dealersData;
});
const activeFilterTab = ref(store.lastlegendfiltertab);
const localFilterStates = ref({
cartIds: props.filters.cartIds,
dealerIds: props.filters.dealerIds,
});
function filterTabChange(newTab) {
activeFilterTab.value = newTab;
store.commit("lastlegendfiltertab", newTab);
}
function updateFilters() {
emit("close");
emit("modalevent", { name: "updateFilters", data: localFilterStates });
}
function resetCartDealerFilters() {
emit("close");
emit("modalevent", { name: "resetCartDealerFilters" });
}
watch(
() => props.filters,
() => {
localFilterStates.value = {
cartIds: props.filters.cartIds,
dealerIds: props.filters.dealerIds,
};
},
{ deep: true },
);
</script>
<template>
<div class="fs-14">
<div class="tabs is-boxed is-fullwidth">
<ul class="mx-0">
<li
v-for="tab in filterTabs"
:class="{ 'is-active': tab === activeFilterTab }"
@click="
() => {
if (tab !== activeFilterTab) filterTabChange(tab);
}
"
>
<a
:class="[
{
'has-text-weight-bold has-text-secondary': tab === activeFilterTab,
},
]"
>Lọc theo {{ tab }}</a
>
</li>
</ul>
</div>
<div v-if="activeFilterTab === filterTabs.CARTS">
<li
class="is-light p-2 is-flex is-align-items-center is-justify-content-space-between is-gap-2 is-clickable"
@click="
() => {
// toggle all
if (localFilterStates.cartIds.length === carts.length) {
localFilterStates.cartIds = [];
} else {
localFilterStates.cartIds = carts.map((c) => c.id);
}
}
"
>
<span class="has-text-weight-bold">Tất cả</span>
<div class="is-flex is-gap-2 is-flex-shrink-0">
<span class="has-text-weight-bold">{{ productCount }}</span>
<SvgIcon
v-bind="{
name:
localFilterStates.cartIds.length === carts.length
? 'checked.svg'
: localFilterStates.cartIds.length === 0
? 'uncheck.svg'
: 'indeterminate.svg',
type: localFilterStates.cartIds.length > 0 ? 'primary' : 'twitter',
size: 22,
}"
/>
</div>
</li>
<ul style="max-height: 420px; overflow-y: scroll">
<li
v-for="cart in carts"
class="border-bottom p-2 is-flex is-align-items-center is-justify-content-space-between is-gap-2 is-clickable"
@click="
() => {
const selected = localFilterStates.cartIds.includes(cart.id);
if (selected) {
localFilterStates.cartIds = localFilterStates.cartIds.filter((id) => id !== cart.id);
} else {
localFilterStates.cartIds = [...localFilterStates.cartIds, cart.id];
}
}
"
>
<span>{{ cart.name }}</span>
<div class="is-flex is-gap-2 is-flex-shrink-0">
<span>{{ cart.product_count }}</span>
<SvgIcon
v-bind="{
name: localFilterStates.cartIds.includes(cart.id) ? 'checked.svg' : 'uncheck.svg',
type: localFilterStates.cartIds.includes(cart.id) ? 'primary' : 'twitter',
size: 22,
}"
/>
</div>
</li>
</ul>
</div>
<div v-else>
<li
class="is-light p-2 is-flex is-align-items-center is-justify-content-space-between is-gap-2 is-clickable"
@click="
() => {
// toggle all
if (localFilterStates.dealerIds.length === dealers.length) {
localFilterStates.dealerIds = [];
} else {
localFilterStates.dealerIds = dealers.map((c) => c.id);
}
}
"
>
<span class="has-text-weight-bold">Tất cả</span>
<div class="is-flex is-gap-2 is-flex-shrink-0">
<span class="has-text-weight-bold">{{ productCount }}</span>
<SvgIcon
v-bind="{
name:
localFilterStates.dealerIds.length === dealers.length
? 'checked.svg'
: localFilterStates.dealerIds.length === 0
? 'uncheck.svg'
: 'indeterminate.svg',
type: localFilterStates.dealerIds.length > 0 ? 'primary' : 'twitter',
size: 22,
}"
/>
</div>
</li>
<ul>
<li
v-for="dealer in dealers"
class="border-bottom p-2 is-flex is-align-items-center is-justify-content-space-between is-gap-2 is-clickable"
@click="
() => {
const selected = localFilterStates.dealerIds.includes(dealer.dealer);
if (selected) {
localFilterStates.dealerIds = localFilterStates.dealerIds.filter((id) => id !== dealer.dealer);
} else {
localFilterStates.dealerIds = [...localFilterStates.dealerIds, dealer.dealer];
}
}
"
>
<span>{{ dealer.dealer__name }}</span>
<div class="is-flex is-gap-2 is-flex-shrink-0">
<span>{{ dealer.product_count_by_dealer }}</span>
<SvgIcon
v-bind="{
name: localFilterStates.dealerIds.includes(dealer.dealer) ? 'checked.svg' : 'uncheck.svg',
type: localFilterStates.dealerIds.includes(dealer.dealer) ? 'primary' : 'twitter',
size: 22,
}"
/>
</div>
</li>
</ul>
</div>
<div class="mt-5 is-flex is-gap-2">
<button
class="button is-primary"
@click="updateFilters"
>
Áp dụng
</button>
<button
class="button"
@click="resetCartDealerFilters"
>
Reset
</button>
</div>
</div>
</template>
<style scoped>
li:hover {
background-color: var(--bulma-primary-95);
}
</style>

View File

@@ -1,65 +0,0 @@
<script setup>
import Modal from "@/components/Modal.vue";
const showLayerSettingListModal = ref(null);
const showLayerSettingSaveModal = ref(null);
function openLayerSettingList() {
showLayerSettingListModal.value = {
component: "viewer/LayerSettingList",
title: "Chọn thiết lập layer",
width: "400px",
height: "auto",
};
}
function openLayerSettingSave() {
showLayerSettingSaveModal.value = {
component: "viewer/LayerSettingSave",
title: "Lưu thiết lập layer",
width: "400px",
height: "auto",
};
}
</script>
<template>
<div class="is-flex is-gap-1">
<button
class="button is-small p-2 is-white"
title="Mở thiết lập"
@click="openLayerSettingList"
>
<SvgIcon v-bind="{ name: 'folder.svg', type: 'primary', size: 20 }" />
</button>
<button
class="button is-small p-2 is-white"
title="Lưu thiết lập"
@click="openLayerSettingSave"
>
<SvgIcon v-bind="{ name: 'save.svg', type: 'primary', size: 20 }" />
</button>
</div>
<Modal
v-bind="showLayerSettingListModal"
v-if="showLayerSettingListModal"
@close="showLayerSettingListModal = null"
/>
<Modal
v-bind="showLayerSettingSaveModal"
v-if="showLayerSettingSaveModal"
@close="showLayerSettingSaveModal = null"
/>
</template>
<style>
#layerSettingSFCContainer {
box-sizing: border-box;
position: absolute;
top: 0;
right: 75px;
height: 50px;
z-index: 1;
display: flex;
padding: 0.5rem;
align-items: center;
}
</style>

View File

@@ -1,69 +0,0 @@
<script setup>
import SvgIcon from "@/components/SvgIcon.vue";
import { applyLayerSetting } from "@/components/viewer/utils/aps-viewer";
const store = useStore();
const { $getdata, $deleteapi } = useNuxtApp();
const layerSettings = ref([]);
async function fetchLayerSettings() {
layerSettings.value = await $getdata("layersetting", {
user: store.login.id,
});
}
async function deleteLayerSetting(id) {
try {
const result = await $deleteapi("layersetting", id);
if (result && !result.error) {
if (store.layersetting?.id === id) {
store.commit("layersetting", undefined);
}
fetchLayerSettings();
} else {
throw new Error(result.error || "Xóa thiết lập layer không thành công.");
}
} catch (error) {
console.error("Lỗi khi xóa thiết lập layer:", error);
alert("Đã xảy ra lỗi khi xóa thiết lập layer. Vui lòng thử lại.");
}
}
onMounted(fetchLayerSettings);
</script>
<template>
<ul v-if="layerSettings.length > 0">
<li
v-for="layersetting in layerSettings"
:key="layersetting.id"
:class="[
'border-bottom px-2 py-0 is-flex is-justify-content-space-between is-align-items-center is-clickable hoverable',
store.layersetting?.id === layersetting.id && 'has-text-weight-bold has-background-white-ter',
]"
@click="applyLayerSetting(layersetting, store)"
>
<span>{{ layersetting.name }}</span>
<button
class="delBtn"
@click.stop="deleteLayerSetting(layersetting.id)"
>
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'primary', size: 18 }" />
</button>
</li>
</ul>
<p v-else>Bạn chưa tạo thiết lập layer nào.</p>
</template>
<style scoped>
.hoverable:hover {
background-color: var(--bulma-primary-95);
}
.delBtn {
border-radius: 4px;
padding: 0.5rem;
&:hover {
background-color: hsla(0, 0%, 0%, 0.05);
}
}
</style>

View File

@@ -1,127 +0,0 @@
<script setup>
import { getLayerNames } from "@/components/viewer/utils/aps-viewer";
const store = useStore();
const { layersetting } = store;
const emit = defineEmits("close");
const { $insertapi, $patchapi, $snackbar } = useNuxtApp();
const radioSave = ref(layersetting ? "overwrite" : "new");
const name = ref(radioSave === "new" ? "" : layersetting ? layersetting.name : "");
const errors = ref([]);
watch(radioSave, (val) => {
name.value = val === "new" ? "" : layersetting.name;
errors.value = [];
});
function switchRadioSave(newVal) {
if (newVal === "overwrite" && !layersetting) return;
radioSave.value = newVal;
}
async function saveLayerSetting() {
const layerNames = getLayerNames();
const user = store.login.id;
if (!name.value) {
errors.value.push({
name: "name",
msg: "Tên thiết lập không được bỏ trống",
});
return;
}
const payload = {
user,
name: name.value,
detail: layerNames,
};
if (radioSave.value === "overwrite") {
payload.id = layersetting.id;
}
const res =
radioSave.value === "new" ? await $insertapi("layersetting", payload) : await $patchapi("layersetting", payload);
if (res === "error") {
$snackbar(
isVietnamese.value
? `Có lỗi xảy ra khi ${radioSave.value === "new" ? "tạo" : "cập nhật"} thiết lập`
: `Error ${radioSave.value === "new" ? "creating" : "updating"} layer setting`,
"Lỗi",
"Error",
);
} else {
store.commit("layersetting", res);
emit("close");
}
}
</script>
<template>
<div class="field">
<label class="label fs-14">Chọn chế độ lưu</label>
<div class="control">
<button
class="button is-white fs-14 px-2 py-1"
@click="switchRadioSave('overwrite')"
:disabled="layersetting === undefined"
>
<span class="icon-text">
<SvgIcon
v-bind="{
name: `radio-${radioSave === 'new' ? 'unchecked' : 'checked'}.svg`,
type: 'gray',
size: 22,
}"
/>
Ghi đè
</span>
</button>
<button
class="button is-white fs-14 px-2 py-1"
@click="switchRadioSave('new')"
>
<span class="icon-text">
<SvgIcon
v-bind="{
name: `radio-${radioSave === 'new' ? 'checked' : 'unchecked'}.svg`,
type: 'gray',
size: 22,
}"
/>
Tạo mới
</span>
</button>
</div>
</div>
<div class="field">
<label class="label fs-14">Tên thiết lập</label>
<div class="control">
<input
class="input"
style="box-sizing: border-box"
type="text"
placeholder="e.g. Đường, Mặt hồ"
v-model="name"
@keydown.enter="saveLayerSetting"
/>
</div>
<div
class="help has-text-danger"
v-if="errors.find((v) => v.name === 'name')"
>
{{ errors.find((v) => v.name === "name").msg }}
</div>
</div>
<div class="field">
<p class="control">
<button
class="button is-primary"
@click="saveLayerSetting()"
>
Lưu lại
</button>
</p>
</div>
</template>
<style></style>

View File

@@ -1,368 +0,0 @@
<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, 0.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>

View File

@@ -1,166 +0,0 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const store = useStore();
const { $connectWebSocket, $subscribe } = useNuxtApp();
const { dealer } = store;
const notes = ref([]);
const isLoading = ref(true);
function navigateTo(productId) {
if (!productId) return;
window.dispatchEvent(new CustomEvent("navigateToProduct", { detail: { productId } }));
}
function handleWsMessageForNotes({ detail: response }) {
if (response.type !== "realtime_update") return;
const { name, change_type, record } = response.payload;
if (name.toLowerCase() !== "product_note") return;
if (dealer?.id && record.ref__cart__dealer !== dealer.id) {
return;
}
let newNotes = [...notes.value];
const existingNoteIndex = newNotes.findIndex((n) => n.id === record.id);
if (change_type === "created") {
if (existingNoteIndex === -1) {
newNotes.push(record);
}
} else if (change_type === "updated") {
if (existingNoteIndex > -1) {
newNotes[existingNoteIndex] = {
...newNotes[existingNoteIndex],
...record,
};
}
} else if (change_type === "deleted") {
if (existingNoteIndex > -1) {
newNotes.splice(existingNoteIndex, 1);
}
}
notes.value = newNotes.sort((a, b) => new Date(b.create_time) - new Date(a.create_time));
}
onMounted(() => {
$connectWebSocket();
const filter = dealer?.id ? { ref__cart__dealer: dealer.id } : undefined;
$subscribe("productnote", filter, (initialData) => {
if (initialData && initialData.rows) {
notes.value = initialData.rows.sort((a, b) => new Date(b.create_time) - new Date(a.create_time));
}
isLoading.value = false;
});
window.addEventListener("ws_message", handleWsMessageForNotes);
});
onUnmounted(() => {
window.removeEventListener("ws_message", handleWsMessageForNotes);
});
</script>
<template>
<div
class="is-flex is-flex-direction-column has-background-white"
style="height: 100%; overflow: hidden"
>
<!-- Loading -->
<div
v-if="isLoading"
class="is-flex is-flex-grow-1 is-justify-content-center is-align-items-center p-5"
>
<div class="has-text-centered">
<progress
class="progress is-info"
max="100"
style="width: 100px"
></progress>
<p class="has-text-grey is-size-7 mt-2">Đang tải...</p>
</div>
</div>
<!-- Empty -->
<div
v-else-if="notes.length === 0"
class="is-flex is-flex-grow-1 is-justify-content-center is-align-items-center p-5"
>
<p class="has-text-grey-light is-size-7">Chưa ghi chú</p>
</div>
<!-- Notes Table -->
<div
v-else
class="is-flex-grow-1"
style="overflow-y: auto; overflow-x: hidden; min-height: 0"
>
<table class="table is-fullwidth is-hoverable is-narrow is-size-7 mb-0">
<thead style="position: sticky; top: 0px">
<tr>
<th>Sản phẩm</th>
<th>Nội dung</th>
<th style="min-width: max-content; text-wrap: nowrap">Người tạo</th>
<th class="has-text-right">Thời gian</th>
</tr>
</thead>
<tbody>
<tr
v-for="note in notes"
:key="note.id"
class="is-clickable"
@click="navigateTo(note.ref)"
>
<td>
<span class="tag is-link is-light is-small">{{ note.ref__trade_code }}</span>
</td>
<td style="white-space: pre-wrap; word-break: break-word">
{{ note.detail }}
</td>
<td>{{ note.username || note.user__username }}</td>
<td
class="has-text-right"
style="white-space: nowrap"
>
{{ new Date(note.create_time).toLocaleString("vi-VN") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style>
#noteHistory.docking-panel {
left: 10px;
top: 10px;
width: 450px;
height: 400px;
resize: both;
overflow: hidden;
.docking-panel-title {
font-size: 16px;
font-weight: 600;
border-bottom: none;
text-transform: none;
flex-shrink: 0;
}
.docking-panel-scroll {
overflow-y: auto;
overflow-x: hidden;
margin-bottom: 20px;
flex-grow: 1;
min-height: 0;
padding: 0;
display: flex;
flex-direction: column;
.table.is-narrow td,
.table.is-narrow th {
padding: 0.5em;
}
}
}
</style>

View File

@@ -1,205 +0,0 @@
<script setup>
import SvgIcon from "@/components/SvgIcon.vue";
import { findLandByTradeCode, findTemInside, objIsTem } from "@/components/viewer/utils/aps-viewer";
import { getBounds } from "@/components/viewer/utils/geometry";
const props = defineProps({
products: Array,
});
const productInput = ref("");
const isSubmitting = ref(false);
const productMatches = computed(() => {
const val = productInput.value.trim().toUpperCase();
if (!val) return;
return props.products.filter((p) => p.trade_code.includes(val));
});
function panToLand(trade_code) {
const { viewer } = window;
viewer.getObjectTree(async (instanceTree) => {
const dbIdMap = instanceTree.nodeAccess.dbIdToIndex;
const allDbIds = Object.keys(dbIdMap).map((id) => parseInt(id));
viewer.model.getBulkProperties(allDbIds, {}, (results) => {
const frags = viewer.model.getFragmentList();
const tems = results.filter(objIsTem);
const lands = results.filter((land) => {
const layerProp = land.properties.find((prop) => prop.displayName === "Layer");
if (!layerProp) return false;
const globalWidthProp = land.properties.find((prop) => prop.displayName === "Global width");
return layerProp.displayValue === "1-bodim" && globalWidthProp.displayValue === 0;
});
const temsWithBounds = tems.map((tem) => ({
...tem,
bounds: getBounds(tem.dbId, frags),
}));
const landsWithBounds = lands.map((land) => ({
...land,
bounds: getBounds(land.dbId, frags),
}));
const temsInside = landsWithBounds.map((landWithBounds) => findTemInside(landWithBounds, temsWithBounds));
const [foundLand] = findLandByTradeCode(trade_code, landsWithBounds, temsInside);
if (!foundLand) return;
viewer.fitToView([foundLand.dbId]);
isSubmitting.value = false;
});
});
}
function clickMatchBtn(product) {
isSubmitting.value = true;
panToLand(product.trade_code);
}
function submit() {
isSubmitting.value = true;
const trade_code = productInput.value.toUpperCase();
const foundProduct = props.products.find((p) => {
return p.trade_code === trade_code;
});
if (!foundProduct) {
isSubmitting.value = false;
return;
}
panToLand(trade_code);
}
const showMatches = ref(true);
const input = useTemplateRef("input");
const matches = useTemplateRef("matches");
function clickAwayListener(e) {
if (input.value?.contains(e.target) || matches.value?.contains(e.target)) {
showMatches.value = true;
} else {
showMatches.value = false;
}
}
onMounted(() => {
document.addEventListener("click", clickAwayListener);
});
onUnmounted(() => {
document.removeEventListener("click", clickAwayListener);
});
</script>
<template>
<div
class="has-background-white"
style="position: sticky; top: 0"
>
<form class="is-flex is-gap-1">
<div class="field has-addons is-flex-grow-1">
<p
ref="input"
class="control mb-0 is-flex-grow-1"
>
<input
v-model="productInput"
class="input is-radiusless has-icons-right fs-13"
type="text"
placeholder="Tìm sản phẩm"
style="box-sizing: border-box; border-inline-width: 0; box-shadow: none"
/>
<button
type="button"
class="clearInputBtn"
v-if="productInput"
@click="productInput = ''"
tabindex="-1"
>
<SvgIcon v-bind="{ name: 'close.svg', type: 'primary', size: 14 }" />
</button>
</p>
<p class="control">
<button
:class="['button is-small is-primary is-radiusless', isSubmitting && 'is-loading']"
:disabled="!productInput"
tabindex="-1"
style="box-sizing: border-box; width: 38px; height: 100%"
@click.prevent="submit"
type="submit"
>
<span
v-if="!isSubmitting"
class="icon"
>
<SvgIcon v-bind="{ name: 'search.svg', type: 'white', size: 18 }" />
</span>
</button>
</p>
</div>
</form>
<div
v-if="showMatches"
ref="matches"
tabindex="-1"
style="
max-height: 100px;
overflow-y: scroll;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
position: absolute;
width: 100%;
z-index: 10;
box-shadow: 0 4px 4px 0 hsla(0, 0%, 0%, 0.1);
"
>
<button
v-for="product in productMatches"
@click="clickMatchBtn(product)"
class="button is-small is-fullwidth is-radiusless is-justify-content-start"
style="box-sizing: border-box; border-inline-width: 0; border-bottom-width: 0"
>
<p>{{ product.trade_code }}</p>
</button>
</div>
</div>
</template>
<style scoped>
.control.is-loading::after,
.select.is-loading::after,
.button.is-loading::after {
/* overrides spinner color */
border: 3px solid white;
border-right-color: transparent;
border-top-color: transparent;
}
.button:focus-visible,
.button.is-focused {
border-color: transparent;
box-shadow: none;
/* copy styles from is-hovered */
--bulma-button-background-l-delta: var(--bulma-button-hover-background-l-delta);
--bulma-button-border-l-delta: var(--bulma-button-hover-border-l-delta);
}
.clearInputBtn {
box-sizing: border-box;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 5;
border-radius: 9999px;
padding: 4px;
cursor: pointer;
display: flex;
background-color: hsla(0, 0%, 0%, 0.05);
&:hover {
background-color: hsla(0, 0%, 0%, 0.1);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +0,0 @@
export const utopiaUrn =
"dXJuOmFkc2sub2JqZWN0czpvcy5vYmplY3Q6cGtxYjhrbHBnZWtsZ2tlbHBzanBoYzljMm5neXhtbjY0cXZocHNhcXVodjQ2emVuLWJhc2ljLWFwcC8yNi4wMS4xNiUyMC0lMjBFeHBvcnQlMjBUTUIlMjAtJTIwUGhhbiUyMG1lbS5kd2c";
export const blankUrn =
"dXJuOmFkc2sub2JqZWN0czpvcy5vYmplY3Q6cGtxYjhrbHBnZWtsZ2tlbHBzanBoYzljMm5neXhtbjY0cXZocHNhcXVodjQ2emVuLWJhc2ljLWFwcC9ibGFuay5kd2c";
export async function getAccessToken(callback) {
try {
const { access_token, expires_in } = await $fetch("/api/apsAuthToken");
callback(access_token, expires_in);
} catch (err) {
console.error("Could not obtain access token. Error:", err);
}
}
export function loadModel(viewer, urn, xform) {
return new Promise((resolve, reject) => {
function onDocumentLoadSuccess(doc) {
const viewable = doc.getRoot().getDefaultGeometry();
const options = {
keepCurrentModels: true,
};
if (xform) {
options.placementTransform = xform;
}
viewer.loadDocumentNode(doc, viewable, options).then(resolve).catch(reject);
}
function onDocumentLoadFailure(code, message, errors) {
reject({ code, message, errors });
}
viewer.setLightPreset(0);
Autodesk.Viewing.Document.load(`urn:${urn}`, onDocumentLoadSuccess, onDocumentLoadFailure);
});
}
function showNotification(message) {
const overlay = document.getElementById("overlay");
overlay.innerHTML = `<div class="notification">${message}</div>`;
overlay.style.display = "flex";
}
function clearNotification() {
const overlay = document.getElementById("overlay");
overlay.innerHTML = "";
overlay.style.display = "none";
}
export async function setupModelSelection(viewer) {
if (window.onModelSelectedTimeout) {
clearTimeout(window.onModelSelectedTimeout);
delete window.onModelSelectedTimeout;
}
try {
const res = await $fetch(`/api/models/${utopiaUrn}/status`);
const { status } = res;
switch (status.status) {
case "n/a":
showNotification(`Model has not been translated.`);
break;
case "inprogress":
showNotification(`Model is being translated (${status.progress})...`);
window.onModelSelectedTimeout = setTimeout(onModelSelected, 5000, viewer, utopiaUrn);
break;
case "failed":
showNotification(
`Translation failed. <ul>${status.messages.map((msg) => `<li>${JSON.stringify(msg)}</li>`).join("")}</ul>`,
);
break;
default:
clearNotification();
loadModel(viewer, utopiaUrn);
break;
}
} catch (err) {
console.error("Could not load model. Error:", err);
}
}

View File

@@ -1,112 +0,0 @@
import { blankUrn, utopiaUrn } from "@/components/viewer/utils/aps-init";
import { isNotNil } from "es-toolkit";
function isTemInsideLand(landBounds, temBounds) {
const tolerance = 0.007;
return (
landBounds.min.x - temBounds.min.x <= tolerance &&
landBounds.min.y - temBounds.min.y <= tolerance &&
landBounds.max.x - temBounds.max.x >= -tolerance &&
landBounds.max.y - temBounds.max.y >= -tolerance
);
}
export function findTemInside(land, tems) {
const temInside = tems.find((tem) => {
const temBounds = tem.bounds[0];
for (const landBounds of land.bounds) {
if (isTemInsideLand(landBounds, temBounds)) return true;
}
});
return temInside;
}
export const getTradeCodeFromTem = (tem) => tem.properties.find((prop) => prop.displayName === "LO").displayValue;
export const objIsTem = (obj) => obj.name && obj.name.startsWith("Blk003");
export const objIsLand = (obj) => {
const layerProp = obj.properties.find((prop) => prop.displayName === "Layer");
if (!layerProp) return false;
const globalWidthProp = obj.properties.find((prop) => prop.displayName === "Global width");
return (
(layerProp.displayValue === "1-bodim" || layerProp.displayValue === "0") /* special case - Z.E02.02A */ &&
globalWidthProp.displayValue === 0
);
};
export function findLandByTradeCode(trade_code, lands, temsInside) {
const foundTemIndex = temsInside.findIndex((tem) => tem && getTradeCodeFromTem(tem) === trade_code);
return foundTemIndex >= 0 ? [lands[foundTemIndex], foundTemIndex] : undefined;
}
export function pan(viewer) {
const navigation = viewer.navigation;
const position = navigation.getPosition();
const target = navigation.getTarget();
// offset both target and position to maintain angle
const panOffset = new THREE.Vector3(2, 0, 0);
navigation.setPosition(position.clone().add(panOffset));
navigation.setTarget(target.clone().add(panOffset));
}
export function unloadUnusedExtensions(viewer) {
viewer.addEventListener(Autodesk.Viewing.EXTENSION_LOADED_EVENT, (e) => {
if (["Autodesk.Measure", "Autodesk.DocumentBrowser", "Autodesk.DefaultTools.NavTools"].includes(e.extensionId)) {
viewer.unloadExtension(e.extensionId);
}
});
}
export function addTemSelectionListener(viewer, products, openProductViewModal, openNoPermissionModal) {
viewer.addEventListener(
Autodesk.Viewing.AGGREGATE_SELECTION_CHANGED_EVENT,
(e) => {
viewer.clearSelection();
if (e.selections.length !== 1) return;
const [selectedObj] = e.selections;
const { model, dbIdArray } = selectedObj;
if (dbIdArray.length !== 1) return;
if (model.loader.svfUrn === utopiaUrn) {
const [dbId] = dbIdArray;
viewer.getProperties(dbId, (obj) => {
if (!objIsTem(obj)) return;
const trade_code = getTradeCodeFromTem(obj);
const product = products.find((p) => p.trade_code === trade_code);
product ? openProductViewModal(product) : openNoPermissionModal();
});
} else if (model.loader.svf.isSceneBuilder) {
const [dbId] = dbIdArray;
const product = products.find((p) => p.id === dbId);
product ? openProductViewModal(product) : openNoPermissionModal();
} else if (model.loader.svfUrn === blankUrn) {
viewer.clearSelection(); // make unselectable
}
},
(err) => console.error(err),
);
}
export function getLayerNames() {
const { viewer } = window;
if (!viewer) return;
if (!viewer.impl) return;
const layerNames = viewer.impl.layers.indexToLayer
.filter((obj) => isNotNil(obj)) // not counting root
.filter((obj) => obj.visible)
.map(({ layer }) => layer.name)
.sort((a, b) => a.localeCompare(b));
return layerNames;
}
export function applyLayerSetting(layersetting, store) {
const { viewer } = window;
viewer.setLayerVisible(null, false); // first, hide everything
viewer.setLayerVisible(layersetting?.detail || null, true); // show specific ones, or all if there's no setting
store.commit("layersetting", layersetting);
}

View File

@@ -1,37 +0,0 @@
export async function renderSvg(relativePath) {
const svgRes = await fetch(relativePath);
const svgText = await svgRes.text();
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
const svgElement = svgDoc.documentElement;
return svgElement;
}
export function html(tag, props = {}, children = []) {
const element = document.createElement(tag);
Object.entries(props).forEach(([key, value]) => {
if (key === "textContent" || key === "innerHTML") {
element[key] = value;
} else if (key === "style" && typeof value === "object") {
Object.assign(element.style, value);
} else if (key === "class") {
element.className = value;
} else if (key.startsWith("on")) {
element.addEventListener(key.slice(2).toLowerCase(), value);
} else {
element.setAttribute(key, value);
}
});
children.flat().forEach((child) => {
if (typeof child === "string") {
element.appendChild(document.createTextNode(child));
} else if (child instanceof Node) {
element.appendChild(child);
}
});
return element;
}

View File

@@ -1,94 +0,0 @@
export class GeometryCallback {
constructor(viewer, vpXform) {
this.viewer = viewer;
this.vpXform = vpXform;
this.lines = [];
}
onLineSegment(x1, y1, x2, y2) {
let pt1 = new THREE.Vector3().set(x1, y1, 0).applyMatrix4(this.vpXform);
let pt2 = new THREE.Vector3().set(x2, y2, 0).applyMatrix4(this.vpXform);
this.lines.push({
x1: pt1.x,
y1: pt1.y,
x2: pt2.x,
y2: pt2.y,
});
}
}
export function getBounds(dbId, frags) {
let fragIds = frags.fragments.dbId2fragId[dbId];
if (typeof fragIds === "number") {
fragIds = [fragIds];
}
const bounds = fragIds.map((fId) => {
const bound = new THREE.Box3();
const boundsCallback = new Autodesk.Viewing.Private.BoundsCallback(bound);
const mesh = frags.getVizmesh(fId);
const vbr = new Autodesk.Viewing.Private.VertexBufferReader(mesh.geometry, viewer.impl.use2dInstancing);
vbr.enumGeomsForObject(dbId, boundsCallback);
return bound;
});
return bounds;
}
export function extractPoints(lines) {
const tolerance = 0.001;
const allPoints = [];
for (let i = 0; i < lines.length; i++) {
const { x1, y1, x2, y2 } = lines[i];
allPoints.push({ x: x1, y: y1 });
allPoints.push({ x: x2, y: y2 });
}
const pointsDeduped = [];
for (let i = 0; i < allPoints.length; i++) {
const element = allPoints[i];
const notFound = !pointsDeduped.find(
(pt) => Math.abs(pt.x - element.x) < tolerance && Math.abs(pt.y - element.y) < tolerance,
);
if (notFound) pointsDeduped.push(element);
}
return pointsDeduped;
}
export function sortByAngle(points) {
// Calculate centroid
const centroid = new THREE.Vector3();
points.forEach((v) => centroid.add(v));
centroid.divideScalar(points.length);
// Sort by angle around centroid
return points.slice().sort((a, b) => {
const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x);
const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x);
return angleA - angleB;
});
}
export function getColorByIndex(i) {
const colors = ["magenta", "red", "orange", "yellow", "chartreuse", "green", "blue"];
if (i === 0) return "black";
return colors[i % 7];
}
/**
* - THREE.Color: `{ r: number, g: number, b: number }`
* - THREE.Vector4: `{ x: number, y: number, z: number, w: number }`
*
* @param {string} colorStr e.g. `'#ff0000'`, `'white'`, `'hotpink'`
* @returns A Vector4 object
*/
export function colorStrToVector4(colorStr) {
const color = new THREE.Color().setStyle(colorStr);
const vector4 = new THREE.Vector4(color.r, color.g, color.b, 1);
return vector4;
}