437 lines
12 KiB
Vue
437 lines
12 KiB
Vue
<template>
|
|
<Dialog
|
|
:visible="listModal"
|
|
@update:visible="emit('closeModal', $event)"
|
|
modal
|
|
:style="{ width: '90vw', height: '90vh' }"
|
|
:contentClass="contentClass"
|
|
>
|
|
<template #header>
|
|
<div class="py-5">
|
|
<p class="text-base font-bold">Non-conformance Issue</p>
|
|
</div>
|
|
</template>
|
|
<div class="h-full flex items-start justify-start p-4">
|
|
<!-- Trace List -->
|
|
<section class="w-80 h-full pr-4">
|
|
<p class="h2 px-2 mb-2">Trace List ({{ traceTotal }})</p>
|
|
<p class="text-primary h2 px-2 mb-2">
|
|
<span class="material-symbols-outlined !text-sm align-[-10%] mr-2"
|
|
>info</span
|
|
>Click trace number to see more.
|
|
</p>
|
|
<div
|
|
class="overflow-y-scroll overflow-x-hidden scrollbar mx-[-8px] max-h-[calc(100%_-_96px)]"
|
|
>
|
|
<table class="border-separate border-spacing-x-2 text-sm">
|
|
<caption class="hidden">
|
|
Trace List
|
|
</caption>
|
|
<thead class="sticky top-0 z-10 bg-neutral-100">
|
|
<tr>
|
|
<th class="h2 px-2 border-b border-neutral-500">Trace</th>
|
|
<th
|
|
class="h2 px-2 border-b border-neutral-500 text-start"
|
|
colspan="3"
|
|
>
|
|
Occurrences
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="(trace, key) in traceList"
|
|
:key="key"
|
|
class="cursor-pointer hover:text-primary"
|
|
@click="switchCaseData(trace.id)"
|
|
>
|
|
<td class="p-2">#{{ trace.id }}</td>
|
|
<td class="p-2 w-24">
|
|
<div
|
|
class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden"
|
|
>
|
|
<div
|
|
class="h-full bg-primary"
|
|
:style="progressWidth(trace.value)"
|
|
></div>
|
|
</div>
|
|
</td>
|
|
<td class="py-2 text-right">{{ trace.count }}</td>
|
|
<td class="p-2 text-right">{{ trace.ratio }}%</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<!-- Trace item Table -->
|
|
<section
|
|
class="px-4 py-2 h-full w-[calc(100%_-_320px)] bg-neutral-10 rounded-xl"
|
|
>
|
|
<p class="h2 mb-2 px-4">Trace #{{ showTraceId }}</p>
|
|
<div class="h-36 w-full px-2 mb-2 border border-neutral-300 rounded">
|
|
<div class="h-full w-full">
|
|
<div
|
|
id="cfmTrace"
|
|
ref="cfmTrace"
|
|
class="h-full min-w-full relative"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="overflow-y-auto overflow-x-auto scrollbar h-[calc(100%_-_200px)] infiniteTable"
|
|
@scroll="handleScroll"
|
|
>
|
|
<DataTable
|
|
:value="caseData"
|
|
showGridlines
|
|
tableClass="text-sm"
|
|
breakpoint="0"
|
|
>
|
|
<div v-for="(col, index) in columnData" :key="index">
|
|
<Column :field="col.field" :header="col.header">
|
|
<template #body="{ data }">
|
|
<div
|
|
:class="
|
|
data[col.field]?.length > 18
|
|
? 'whitespace-normal'
|
|
: 'whitespace-nowrap'
|
|
"
|
|
>
|
|
{{ data[col.field] }}
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</div>
|
|
</DataTable>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</Dialog>
|
|
</template>
|
|
<script setup>
|
|
// The Lucia project.
|
|
// Copyright 2023-2026 DSP, inc. All rights reserved.
|
|
// Authors:
|
|
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
|
|
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
|
|
// imacat.yang@dsp.im (imacat), 2023/9/23
|
|
/**
|
|
* @module components/Discover/Conformance/MoreModal
|
|
* Modal dialog showing detailed conformance check
|
|
* results with expandable activity sequences.
|
|
*/
|
|
|
|
import { ref, computed, watch, nextTick, useTemplateRef } from "vue";
|
|
import { storeToRefs } from "pinia";
|
|
import { useConformanceStore } from "@/stores/conformance";
|
|
import cytoscapeMapTrace from "@/module/cytoscapeMapTrace.js";
|
|
|
|
const props = defineProps([
|
|
"listModal",
|
|
"listNo",
|
|
"traceId",
|
|
"firstCases",
|
|
"listTraces",
|
|
"taskSeq",
|
|
"cases",
|
|
"category",
|
|
]);
|
|
const emit = defineEmits(["closeModal"]);
|
|
|
|
const conformanceStore = useConformanceStore();
|
|
const { infinite404 } = storeToRefs(conformanceStore);
|
|
|
|
// template ref
|
|
const cfmTrace = useTemplateRef("cfmTrace");
|
|
|
|
// data
|
|
const contentClass = ref("!bg-neutral-100 border-t border-neutral-300 h-full");
|
|
const showTraceId = ref(null);
|
|
const infiniteData = ref(null);
|
|
const maxItems = ref(false);
|
|
const infiniteFinish = ref(true); // Whether infinite scroll loading is complete
|
|
const startNum = ref(0);
|
|
const processMap = ref({
|
|
nodes: [],
|
|
edges: [],
|
|
});
|
|
|
|
// computed
|
|
const traceTotal = computed(() => {
|
|
return traceList.value.length;
|
|
});
|
|
|
|
const traceList = computed(() => {
|
|
const sum = props.listTraces
|
|
.map((trace) => trace.count)
|
|
.reduce((acc, cur) => acc + cur, 0);
|
|
|
|
return props.listTraces
|
|
.map((trace) => {
|
|
return {
|
|
id: trace.id,
|
|
value: Number(getPercentLabel(trace.count / sum)),
|
|
count: trace.count.toLocaleString("en-US"),
|
|
count_base: trace.count,
|
|
ratio: getPercentLabel(trace.count / sum),
|
|
};
|
|
})
|
|
.sort((x, y) => x.id - y.id);
|
|
});
|
|
|
|
const caseData = computed(() => {
|
|
if (infiniteData.value !== null) {
|
|
const data = JSON.parse(JSON.stringify(infiniteData.value)); // Deep copy the original cases data
|
|
data.forEach((item) => {
|
|
item.facets.forEach((facet, index) => {
|
|
item[`fac_${index}`] = facet.value; // Create a new key-value pair
|
|
});
|
|
delete item.facets; // Remove the original facets property
|
|
|
|
item.attributes.forEach((attribute, index) => {
|
|
item[`att_${index}`] = attribute.value; // Create a new key-value pair
|
|
});
|
|
delete item.attributes; // Remove the original attributes property
|
|
});
|
|
return data;
|
|
}
|
|
});
|
|
|
|
const columnData = computed(() => {
|
|
const data = JSON.parse(JSON.stringify(props.cases)); // Deep copy the original cases data
|
|
const facetName = (facName) =>
|
|
facName
|
|
.trim()
|
|
.replace(
|
|
/^(.)(.*)$/,
|
|
(match, firstChar, restOfString) =>
|
|
firstChar.toUpperCase() + restOfString.toLowerCase(),
|
|
);
|
|
|
|
const result = [
|
|
{ field: "id", header: "Case Id" },
|
|
{ field: "started_at", header: "Start time" },
|
|
{ field: "completed_at", header: "End time" },
|
|
...data[0].facets.map((fac, index) => ({
|
|
field: `fac_${index}`,
|
|
header: facetName(fac.name),
|
|
})),
|
|
...data[0].attributes.map((att, index) => ({
|
|
field: `att_${index}`,
|
|
header: att.key,
|
|
})),
|
|
];
|
|
return result;
|
|
});
|
|
|
|
// watch
|
|
watch(
|
|
() => props.listModal,
|
|
(newValue) => {
|
|
// Draw the chart when the modal is opened for the first time
|
|
if (newValue) createCy();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.taskSeq,
|
|
(newValue) => {
|
|
if (newValue !== null) createCy();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.traceId,
|
|
(newValue) => {
|
|
// Update showTraceId when the traceId prop changes
|
|
showTraceId.value = newValue;
|
|
},
|
|
);
|
|
|
|
watch(showTraceId, (newValue, oldValue) => {
|
|
const isScrollTop = document.querySelector(".infiniteTable");
|
|
if (isScrollTop && typeof isScrollTop.scrollTop !== "undefined")
|
|
if (newValue !== oldValue) isScrollTop.scrollTop = 0;
|
|
});
|
|
|
|
watch(
|
|
() => props.firstCases,
|
|
(newValue) => {
|
|
infiniteData.value = newValue;
|
|
},
|
|
);
|
|
|
|
watch(infinite404, (newValue) => {
|
|
if (newValue === 404) maxItems.value = true;
|
|
});
|
|
|
|
// methods
|
|
/**
|
|
* Number to percentage
|
|
* @param {number} val - The raw ratio value.
|
|
* @returns {string} The formatted percentage string.
|
|
*/
|
|
function getPercentLabel(val) {
|
|
if ((val * 100).toFixed(1) >= 100) return 100;
|
|
else return parseFloat((val * 100).toFixed(1));
|
|
}
|
|
/**
|
|
* set progress bar width
|
|
* @param {number} value - The percentage value.
|
|
* @returns {string} The CSS width style string.
|
|
*/
|
|
function progressWidth(value) {
|
|
return `width:${value}%;`;
|
|
}
|
|
/**
|
|
* switch case data
|
|
* @param {number} id case id
|
|
*/
|
|
async function switchCaseData(id) {
|
|
if (id == showTraceId.value) return;
|
|
infinite404.value = null;
|
|
maxItems.value = false;
|
|
startNum.value = 0;
|
|
|
|
let result;
|
|
if (props.category === "issue")
|
|
result = await conformanceStore.getConformanceTraceDetail(
|
|
props.listNo,
|
|
id,
|
|
0,
|
|
);
|
|
else if (props.category === "loop")
|
|
result = await conformanceStore.getConformanceLoopsTraceDetail(
|
|
props.listNo,
|
|
id,
|
|
0,
|
|
);
|
|
infiniteData.value = await result;
|
|
showTraceId.value = id; // Set after getDetail so the case table finishes loading before switching showTraceId
|
|
}
|
|
/**
|
|
* Assembles the trace element nodes data for Cytoscape rendering.
|
|
*/
|
|
function setNodesData() {
|
|
// Clear nodes to prevent accumulation on each render
|
|
processMap.value.nodes = [];
|
|
// Populate nodes with data returned from the API call
|
|
if (props.taskSeq !== null) {
|
|
props.taskSeq.forEach((node, index) => {
|
|
processMap.value.nodes.push({
|
|
data: {
|
|
id: index,
|
|
label: node,
|
|
backgroundColor: "#CCE5FF",
|
|
bordercolor: "#003366",
|
|
shape: "round-rectangle",
|
|
height: 80,
|
|
width: 100,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Assembles the trace edge line data for Cytoscape rendering.
|
|
*/
|
|
function setEdgesData() {
|
|
processMap.value.edges = [];
|
|
if (props.taskSeq !== null) {
|
|
props.taskSeq.forEach((edge, index) => {
|
|
processMap.value.edges.push({
|
|
data: {
|
|
source: `${index}`,
|
|
target: `${index + 1}`,
|
|
lineWidth: 1,
|
|
style: "solid",
|
|
},
|
|
});
|
|
});
|
|
}
|
|
// The number of edges is one less than the number of nodes
|
|
processMap.value.edges.pop();
|
|
}
|
|
/**
|
|
* create trace cytoscape's map
|
|
*/
|
|
function createCy() {
|
|
nextTick(() => {
|
|
const graphId = cfmTrace.value;
|
|
|
|
setNodesData();
|
|
setEdgesData();
|
|
if (graphId !== null)
|
|
cytoscapeMapTrace(
|
|
processMap.value.nodes,
|
|
processMap.value.edges,
|
|
graphId,
|
|
);
|
|
});
|
|
}
|
|
/**
|
|
* Infinite scroll: loads more data.
|
|
*/
|
|
async function fetchData() {
|
|
try {
|
|
infiniteFinish.value = false;
|
|
startNum.value += 20;
|
|
const result = await conformanceStore.getConformanceTraceDetail(
|
|
props.listNo,
|
|
showTraceId.value,
|
|
startNum.value,
|
|
);
|
|
infiniteData.value = [...infiniteData.value, ...result];
|
|
infiniteFinish.value = true;
|
|
} catch (error) {
|
|
console.error("Failed to load data:", error);
|
|
}
|
|
}
|
|
/**
|
|
* Infinite scroll: listens for scroll reaching the bottom.
|
|
* @param {Event} event - The scroll event.
|
|
*/
|
|
function handleScroll(event) {
|
|
if (
|
|
maxItems.value ||
|
|
infiniteData.value.length < 20 ||
|
|
infiniteFinish.value === false
|
|
)
|
|
return;
|
|
|
|
const container = event.target;
|
|
const overScrollHeight =
|
|
container.scrollTop + container.clientHeight + 20 >= container.scrollHeight;
|
|
|
|
if (overScrollHeight) fetchData();
|
|
}
|
|
</script>
|
|
<style scoped>
|
|
@reference "../../../assets/tailwind.css";
|
|
/* Progress bar color */
|
|
:deep(.p-progressbar .p-progressbar-value) {
|
|
@apply bg-primary;
|
|
}
|
|
/* Table set */
|
|
:deep(.p-datatable-thead) {
|
|
@apply sticky top-0 left-0 z-10 bg-neutral-10;
|
|
}
|
|
:deep(.p-datatable .p-datatable-thead > tr > th) {
|
|
@apply !border-y-0 border-neutral-500 bg-neutral-100 after:absolute after:left-0 after:w-full after:h-full after:block after:top-0 after:border-b after:border-t after:border-neutral-500;
|
|
white-space: nowrap;
|
|
}
|
|
:deep(.p-datatable .p-datatable-tbody > tr > td) {
|
|
@apply border-neutral-500 !border-t-0 text-center;
|
|
}
|
|
/* Center header title */
|
|
:deep(.p-column-header-content) {
|
|
@apply justify-center;
|
|
}
|
|
:deep(.p-datatable.p-datatable-gridlines .p-datatable-tbody > tr > td) {
|
|
min-width: 72px;
|
|
max-width: 184px;
|
|
overflow-wrap: break-word;
|
|
word-wrap: break-word;
|
|
}
|
|
</style>
|