Files
lucia-frontend/src/views/Files/FilesPage.vue

968 lines
30 KiB
Vue

<template>
<div class="container pt-4 2xl:max-w-none">
<!-- Recently Used & Performance Comparison -->
<div>
<!-- Performance Comparison -->
<section v-if="filesTag === 'COMPARE'">
<h2 class="h-12 font-bold py-4 mb-4 border-b border-neutral-500">
Performance Comparison
</h2>
<div
class="flex justify-start items-center gap-4 w-full h-[184px] scrollbar pb-4"
>
<!-- primaryDrag -->
<div
class="relative w-[216px] min-w-[216px] h-full"
id="primaryDragCard"
>
<div
v-if="primaryDragData.length === 0"
class="w-full h-full p-4 border rounded border-neutral-300 duration-300 text-neutral-500 absolute"
>
<div
class="h-full flex flex-col justify-center items-center gap-4"
>
<p class="text-4xl font-black">1</p>
<p class="text-sm font-medium">Drag and drop a file here</p>
</div>
</div>
<draggable
v-model="primaryDragData"
:group="{
name: 'files',
pull: false,
put: primaryDragData.length < 1,
}"
item-key="id"
class="w-full h-full"
>
<template #item="{ element }">
<div
class="w-full h-full p-4 border rounded border-neutral-300 hover:bg-primary/10 hover:border-primary duration-300 flex flex-col justify-between cursor-pointer"
:title="element.name"
>
<div class="h-full">
<span
class="material-symbols-outlined mb-2 !text-[32px] block"
>
{{ element.icon }}
</span>
<h3
class="text-sm font-medium mb-2 whitespace-nowrap break-keep overflow-hidden text-ellipsis leading-tight"
>
{{ element.name }}
</h3>
<p
class="text-sm text-neutral-500 whitespace-nowrap break-keep text-ellipsis overflow-hidden leading-tight"
>
{{ element.parentLog }}
</p>
</div>
<p class="text-sm text-neutral-500">
{{ element.updated_at }}
</p>
</div>
</template>
</draggable>
<!-- delete icon -->
<span
v-show="primaryDragData.length > 0"
class="material-symbols-outlined material-fill text-neutral-500 bg-neutral-10 block rounded-full absolute -top-[5%] -right-[5%] z-50 cursor-pointer hover:text-danger"
@click="primaryDragDelete"
>do_not_disturb_on</span
>
</div>
<!-- secondaryDrag -->
<div
class="relative w-[216px] min-w-[216px] h-full"
id="secondaryDragCard"
>
<div
v-show="secondaryDragData.length === 0"
class="w-full h-full p-4 border rounded border-neutral-300 duration-300 text-neutral-500 absolute"
>
<div
class="h-full flex flex-col justify-center items-center gap-4"
>
<p class="text-4xl font-black">2</p>
<p class="text-sm font-medium">Drag and drop a file here</p>
</div>
</div>
<draggable
v-model="secondaryDragData"
:group="{
name: 'files',
pull: false,
put: secondaryDragData.length < 1,
}"
item-key="id"
class="w-full h-full"
>
<template #item="{ element }">
<div
class="w-full h-full p-4 border rounded border-neutral-300 hover:bg-primary/10 hover:border-primary duration-300 flex flex-col justify-between cursor-pointer"
:title="element.name"
>
<div>
<span
class="material-symbols-outlined mb-2 !text-[32px] block"
>
{{ element.icon }}
</span>
<h3
class="text-sm font-medium mb-2 whitespace-nowrap break-keep overflow-hidden text-ellipsis leading-tight"
>
{{ element.name }}
</h3>
<p
class="text-sm text-neutral-500 whitespace-nowrap break-keep text-ellipsis overflow-hidden leading-tight"
>
{{ element.parentLog }}
</p>
</div>
<p class="text-sm text-neutral-500">
{{ element.updated_at }}
</p>
</div>
</template>
</draggable>
<!-- delete icon -->
<span
v-show="secondaryDragData.length > 0"
class="material-symbols-outlined material-fill bg-neutral-10 text-neutral-500 block rounded-full absolute -top-[5%] -right-[5%] cursor-pointer hover:text-danger"
@click="secondaryDragDelete"
>do_not_disturb_on</span
>
</div>
<button
class="btn btn-sm"
:class="isCompareDisabledButton ? 'btn-disable' : 'btn-c-primary'"
:disabled="isCompareDisabledButton"
@click="compareSubmit"
>
Compare
</button>
</div>
</section>
<!-- Recently Used -->
<section v-else>
<h2 class="h-12 font-bold py-4 mb-4 border-b border-neutral-500">
Recently Used
</h2>
<!-- card group, up to six items -->
<ul
class="flex justify-start items-center gap-4 overflow-x-auto w-full h-[184px] scrollbar pb-4"
>
<!-- card item v-for -->
<li
class="w-[216px] min-w-[216px] h-full p-4 border rounded border-neutral-300 hover:bg-primary/10 hover:border-primary duration-300 flex flex-col justify-between cursor-pointer"
v-for="file in recentlyUsedFiles.slice(0, 6)"
:key="file.id"
@dblclick="enterDiscover(file)"
:title="file.name"
@contextmenu="onRightClick($event, file)"
>
<div>
<span class="material-symbols-outlined mb-2 !text-[32px] block">
{{ file.icon }}
</span>
<h3
class="text-sm font-medium mb-2 whitespace-nowrap break-keep overflow-hidden text-ellipsis leading-tight"
>
{{ file.name }}
</h3>
<p
class="text-sm text-neutral-500 whitespace-nowrap break-keep text-ellipsis overflow-hidden leading-tight"
>
{{ file.parentLog }}
</p>
</div>
<p class="text-sm text-neutral-500">
{{ file.accessed_at }}
</p>
</li>
</ul>
</section>
</div>
<!-- All Files -->
<div>
<!-- Compare -->
<section v-if="filesTag === 'COMPARE'">
<!-- All Files header -->
<div
class="h-12 mb-4 border-b flex justify-between items-center border-neutral-500"
>
<h2 class="font-bold">All Files</h2>
<ul class="flex items-center gap-x-4">
<li>
<Dropdown
v-model="gridSort"
:options="columnType"
optionLabel="name"
placeholder="Sort"
class="w-full !border-neutral-500"
inputClass="!text-sm"
@change="getGridSortData($event)"
></Dropdown>
</li>
<li>
<IconGrid class="hover:bg-neutral-50 duration-300"></IconGrid>
</li>
</ul>
</div>
<!-- All Files type of grid -->
<ul>
<draggable
tag="ul"
:list="compareData"
:group="{ name: 'files' }"
itemKey="name"
class="flex justify-start items-start gap-4 flex-wrap overflow-y-scroll overflow-x-hidden max-h-[calc(100vh_-_440px)] scrollbar"
id="compareGridCards"
>
<template #item="{ element, index }">
<li
class="w-[216px] h-[168px] p-4 border rounded border-neutral-300 hover:bg-primary/10 hover:border-primary duration-300 flex flex-col justify-between cursor-pointer"
:title="element.name"
:id="'compareFile' + index"
>
<div>
<span
class="material-symbols-outlined mb-2 !text-[32px] block"
>
{{ element.icon }}
</span>
<h3
class="text-sm font-medium mb-2 whitespace-nowrap break-keep overflow-hidden text-ellipsis leading-tight"
>
{{ element.name }}
</h3>
<p
class="text-sm text-neutral-500 whitespace-nowrap break-keep text-ellipsis overflow-hidden leading-tight"
>
{{ element.parentLog }}
</p>
</div>
<p class="text-sm text-neutral-500">
{{ element.updated_at }}
</p>
</li>
</template>
</draggable>
</ul>
</section>
<!-- All & Discover -->
<section v-else>
<!-- All Files header -->
<div
class="h-12 mb-4 border-b flex justify-between items-center border-neutral-500"
>
<h2 class="font-bold">All Files</h2>
<ul class="flex items-center gap-x-4">
<li v-show="isActive !== null" class="animate-fadein">
<ul
class="flex justify-center items-center gap-x-4 px-4 py-1 rounded-full bg-neutral-200 text-neutral-700"
>
<li>
<span
class="material-symbols-outlined align-bottom cursor-pointer hover:text-primary"
@click="rename"
>edit_square</span
>
</li>
<li>
<span
class="material-symbols-outlined align-bottom cursor-pointer hover:text-primary"
@click="download"
>download</span
>
</li>
<li>
<span
class="material-symbols-outlined align-bottom cursor-pointer hover:text-primary"
@click="deleteFile"
>delete</span
>
</li>
</ul>
</li>
<li class="cursor-pointer" @click="switchListOrGrid = false">
<IconList
class="hover:fill-primary hover:bg-neutral-50 duration-300"
></IconList>
</li>
<li class="cursor-pointer" @click="switchListOrGrid = true">
<IconGrid
class="hover:fill-primary hover:bg-neutral-50 duration-300"
></IconGrid>
</li>
</ul>
</div>
<!-- All Files type of List -->
<div v-if="!switchListOrGrid">
<DataTable
:value="allFiles"
dataKey="id"
tableClass="w-full text-sm cursor-pointer relative table-fixed"
:rowClass="setRowClass"
breakpoint="0"
@row-dblclick="enterDiscover($event.data)"
contextmenu
v-model:contextMenuSelection="selectedTableFile"
@row-contextmenu="onRightClickTable"
>
<Column
field="name"
header="Name"
bodyClass="font-medium fileName"
sortable
></Column>
<Column
field="parentLog"
header="Dependency"
bodyClass="text-neutral-500"
sortable
></Column>
<Column
field="fileType"
header="File Type"
bodyClass="text-neutral-500 fileType"
sortable
></Column>
<Column
field="owner.name"
header="Owner"
bodyClass="text-neutral-500"
sortable
></Column>
<Column
field="updated_at"
header="Last Update"
bodyClass="text-neutral-500"
sortable
></Column>
<Column bodyClass="text-neutral-500">
<template #body="slotProps">
<ul
class="opacity-0 group-hover:opacity-100 flex justify-end items-center gap-x-2 text-neutral-700"
>
<li>
<span
class="material-symbols-outlined align-bottom cursor-pointer hover:text-primary"
@click="
rename(
slotProps.data.type,
slotProps.data.id,
'list-hover',
slotProps.data.name,
)
"
>edit_square</span
>
</li>
<li>
<span
class="material-symbols-outlined align-bottom cursor-pointer hover:text-primary"
@click="
download(
slotProps.data.type,
slotProps.data.id,
'list-hover',
slotProps.data.name,
)
"
>download</span
>
</li>
<li>
<span
class="material-symbols-outlined align-bottom cursor-pointer hover:text-primary"
@click="
deleteFile(
slotProps.data.type,
slotProps.data.id,
slotProps.data.name,
'list-hover',
)
"
>delete</span
>
</li>
</ul>
</template>
</Column>
</DataTable>
</div>
<!-- All Files type of grid -->
<ul
class="flex justify-start items-start gap-4 flex-wrap overflow-y-scroll overflow-x-hidden max-h-[calc(100vh_-_440px)] scrollbar"
v-else
>
<li
class="w-[216px] h-[168px] p-4 border rounded border-neutral-300 hover:bg-primary/10 hover:border-primary duration-300 flex flex-col justify-between cursor-pointer"
v-for="(file, index) in allFiles"
:key="file.id"
:class="{ 'bg-primary/10 border-primary': isActive === index }"
@dblclick="enterDiscover(file)"
:title="file.name"
@contextmenu="onRightClick($event, file)"
@click="onGridCardClick(file, index)"
:id="'li' + index"
>
<div>
<span class="material-symbols-outlined mb-2 !text-[32px] block">
{{ file.icon }}
</span>
<h3
class="text-sm font-medium mb-2 whitespace-nowrap break-keep overflow-hidden text-ellipsis leading-tight"
>
{{ file.name }}
</h3>
<p
class="text-sm text-neutral-500 whitespace-nowrap break-keep text-ellipsis overflow-hidden leading-tight"
>
{{ file.parentLog }}
</p>
</div>
<p class="text-sm text-neutral-500">
{{ file.updated_at }}
</p>
</li>
</ul>
</section>
</div>
<!-- ContextMenu -->
<ContextMenu
ref="fileRightMenuRef"
:model="items"
@hide="selectedFile = null"
class="cursor-pointer"
>
<template #item="{ item }">
<a
class="flex align-items-center px-4 py-2 duration-300 hover:bg-primary/20"
>
<span class="material-symbols-outlined">{{ item.icon }}</span>
<span class="ml-2 text-sm inline-flex items-center">{{
item.label
}}</span>
<span v-if="item.shortcut" class="border border-round p-1">{{
item.shortcut
}}</span>
</a>
</template>
</ContextMenu>
</div>
</template>
<script setup>
// The Lucia project.
// Copyright 2024-2026 DSP, inc. All rights reserved.
// Authors:
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
/**
* @module views/Files/Files File management page with data
* table listing uploaded files, discover/compare navigation,
* and file operations (rename, delete).
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useMapCompareStore } from "@/stores/mapCompareStore";
import { useLoginStore } from "@/stores/login";
import { useFilesStore } from "@/stores/files";
import { useAllMapDataStore } from "@/stores/allMapData";
import { usePageAdminStore } from "@/stores/pageAdmin";
import { useLoadingStore } from "@/stores/loading";
import IconDataFormat from "@/components/icons/IconDataFormat.vue";
import IconRule from "@/components/icons/IconRule.vue";
import IconsFilter from "@/components/icons/IconsFilter.vue";
import IconFlowChart from "@/components/icons/IconFlowChart.vue";
import IconVector from "@/components/icons/IconVector.vue";
import IconList from "@/components/icons/IconList.vue";
import IconGrid from "@/components/icons/IconGrid.vue";
import {
renameModal,
deleteFileModal,
reallyDeleteInformation,
} from "@/module/alertModal.js";
import { escapeHtml } from "@/utils/escapeHtml.js";
const router = useRouter();
// Stores
const mapCompareStore = useMapCompareStore();
const loginStore = useLoginStore();
const store = useFilesStore();
const allMapDataStore = useAllMapDataStore();
const pageAdminStore = usePageAdminStore();
const loadingStore = useLoadingStore();
const { dependentsData, filesTag } = storeToRefs(store);
const { createFilterId, baseLogId } = storeToRefs(allMapDataStore);
const { isLoading } = storeToRefs(loadingStore);
// Data
const isActive = ref(null);
const isHover = ref(null);
const switchListOrGrid = ref(false);
const selectedTableFile = ref(null);
const selectedFile = ref(null);
const selectedType = ref(null);
const selectedId = ref(null);
const selectedName = ref(null);
const compareData = ref(null);
const primaryDragData = ref([]);
const secondaryDragData = ref([]);
const gridSort = ref(null);
const fileRightMenuRef = ref(null);
const items = [
{
label: "Rename",
icon: "edit_square",
command: rename,
},
{
label: "Download",
icon: "download",
command: download,
},
{
separator: true,
},
{
label: "Delete",
icon: "delete",
command: deleteFile,
},
];
const columnType = [
{ name: "By File Name (A to Z)", code: "nameAscending" },
{ name: "By File Name (Z to A)", code: "nameDescending" },
{ name: "By Dependency (A to Z)", code: "parentLogAscending" },
{ name: "By Dependency (Z to A)", code: "parentLogDescending" },
{ name: "By File Type (A to Z)", code: "fileAscending" },
{ name: "By File Type (Z to A)", code: "fileDescending" },
{ name: "By Last Update (A to Z)", code: "updatedAscending" },
{ name: "By Last Update (Z to A)", code: "updatedDescending" },
];
// Computed
/**
* Read allFiles
*/
const allFiles = computed(() => {
if (store.allFiles.length !== 0) {
const sortFiles = Array.from(store.allFiles);
sortFiles.sort(
(x, y) => new Date(y.updated_base) - new Date(x.updated_base),
);
return sortFiles;
}
return [];
});
/**
* Sorts by time; entries without accessed_at are excluded.
*/
const recentlyUsedFiles = computed(() => {
let recentlyUsed = Array.from(store.allFiles);
recentlyUsed = recentlyUsed.filter((item) => item.accessed_at !== null);
recentlyUsed.sort(
(x, y) => new Date(y.accessed_base) - new Date(x.accessed_base),
);
return recentlyUsed;
});
/**
* Compare Submit button disabled
*/
const isCompareDisabledButton = computed(() => {
return (
primaryDragData.value.length === 0 || secondaryDragData.value.length === 0
);
});
/**
* Really deleted information
*/
const reallyDeleteData = computed(() => {
let result = [];
if (store.allFiles.length !== 0) {
result = JSON.parse(JSON.stringify(store.allFiles));
result = result.filter((file) => file.is_deleted === true);
}
return result;
});
// Watch
watch(filesTag, (newValue) => {
if (newValue !== "COMPARE") {
primaryDragData.value = [];
secondaryDragData.value = [];
}
});
watch(allFiles, (newValue) => {
if (newValue !== null)
compareData.value = JSON.parse(JSON.stringify(newValue));
});
watch(
reallyDeleteData,
(newValue, oldValue) => {
if (newValue.length !== 0 && oldValue.length === 0) {
showReallyDelete();
}
},
{ immediate: true },
);
// Methods
/**
* Set Row Style
*/
function setRowClass() {
return ["group"];
}
/**
* Set Compare Row Style
*/
function setCompareRowClass() {
return ["leading-6"];
}
/**
* Selects a file and navigates to the Discover/Compare/Design page.
* @param {object} file - The file details.
*/
function enterDiscover(file) {
let type;
let fileId;
let params;
pageAdminStore.setCurrentMapFile(file.name);
switch (file.type) {
case "log":
createFilterId.value = null;
baseLogId.value = file.id;
fileId = file.id;
type = file.type;
params = { type: type, fileId: fileId };
router.push({ name: "Map", params: params });
break;
case "filter":
createFilterId.value = file.id;
baseLogId.value = file.parent.id;
fileId = file.id;
type = file.type;
params = { type: type, fileId: fileId };
router.push({ name: "Map", params: params });
break;
case "log-check":
case "filter-check":
fileId = file.id;
type = file.parent.type;
params = { type: type, fileId: fileId };
router.push({ name: "CheckConformance", params: params });
break;
default:
break;
}
}
/**
* Right Click DOM Event
* @param {Event} event - The mouse event.
* @param {string} file file's name
*/
function onRightClick(event, file) {
selectedType.value = file.type;
selectedId.value = file.id;
selectedName.value = file.name;
fileRightMenuRef.value.show(event);
}
/**
* Right Click Table DOM Event
* @param {Event} event - The right-click event with row data.
*/
function onRightClickTable(event) {
selectedType.value = event.data.type;
selectedId.value = event.data.id;
selectedName.value = event.data.name;
fileRightMenuRef.value.show(event.originalEvent);
}
/**
* Right Click Grid Card DOM Event
* @param {object} file - The file object.
* @param {number} index - The file index.
*/
function onGridCardClick(file, index) {
selectedType.value = file.type;
selectedId.value = file.id;
selectedName.value = file.name;
isActive.value = index;
}
/**
* File's Rename
* @param {string} type - The file type.
* @param {number} id - The file ID.
* @param {string} source - The hover icon source.
* @param {string} fileName file's name
*/
function rename(type, id, source, fileName) {
if (type && id && source === "list-hover") {
selectedType.value = type;
selectedId.value = id;
selectedName.value = fileName;
}
renameModal(
store.rename,
selectedType.value,
selectedId.value,
selectedName.value,
);
}
/**
* Delete file
* @param {string} type - The file type.
* @param {number} id - The file ID.
* @param {string} name - The file name.
* @param {string} source - The hover icon source.
*/
async function deleteFile(type, id, name, source) {
let srt = "";
let data = [];
// Check if the action comes from the hover icon menu
if (type && id && name && source === "list-hover") {
selectedType.value = type;
selectedId.value = id;
selectedName.value = name;
}
// Fetch dependent files
await store.getDependents(selectedType.value, selectedId.value);
if (dependentsData.value.length !== 0) {
data = [...dependentsData.value];
data.forEach((i) => {
switch (i.type) {
case "log-check":
i.type = "rule";
break;
case "filter-check":
i.type = "rule";
break;
default:
break;
}
const content = `<li>[${escapeHtml(i.type)}] ${escapeHtml(i.name)}</li>`;
srt += content;
});
}
deleteFileModal(
srt,
selectedType.value,
selectedId.value,
selectedName.value,
);
srt = "";
}
/**
* Shows files deleted by admin or by other accounts.
*/
function showReallyDelete() {
let srt = "";
if (reallyDeleteData.value.length !== 0) {
reallyDeleteData.value.forEach((file) => {
switch (file.type) {
case "log-check":
case "filter-check":
default:
file.type = "rule";
break;
}
const content = `<li>[${escapeHtml(file.type)}] ${escapeHtml(file.name)}</li>`;
srt += content;
});
}
reallyDeleteInformation(srt, reallyDeleteData.value);
srt = "";
}
/**
* Download file as CSV
* @param {string} type - The file type.
* @param {number} id - The file ID.
* @param {string} source - The hover icon source.
* @param {string} name - The file name.
*/
function download(type, id, source, name) {
if (type && id && source === "list-hover" && name) {
selectedType.value = type;
selectedId.value = id;
selectedName.value = name;
}
store.downloadFileCSV(
selectedType.value,
selectedId.value,
selectedName.value,
);
}
/**
* Delete Compare Primary log
*/
function primaryDragDelete() {
compareData.value.unshift(primaryDragData.value[0]);
primaryDragData.value.length = 0;
}
/**
* Delete Compare Secondary log
*/
function secondaryDragDelete() {
compareData.value.unshift(secondaryDragData.value[0]);
secondaryDragData.value.length = 0;
}
/**
* Enter the Compare page
*/
function compareSubmit() {
const primaryType = primaryDragData.value[0].type;
const secondaryType = secondaryDragData.value[0].type;
const primaryId = primaryDragData.value[0].id;
const secondaryId = secondaryDragData.value[0].id;
const params = {
primaryType: primaryType,
primaryId: primaryId,
secondaryType: secondaryType,
secondaryId: secondaryId,
};
mapCompareStore.setCompareRouteParam(
primaryType,
primaryId,
secondaryType,
secondaryId,
);
router.push({ name: "CompareDashboard", params: params });
}
/**
* Filter/sort handler for the grid view template.
* @param {event} event choose columnType item
*/
function getGridSortData(event) {
const code = event.value.code;
// Text sorting: convert the name field to lowercase for comparison using localeCompare()
switch (code) {
case "nameAscending":
compareData.value = compareData.value.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);
break;
case "nameDescending":
compareData.value = compareData.value
.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
)
.reverse();
break;
case "parentLogAscending":
compareData.value = compareData.value.sort((a, b) =>
a.parentLog.toLowerCase().localeCompare(b.parentLog.toLowerCase()),
);
break;
case "parentLogDescending":
compareData.value = compareData.value
.sort((a, b) =>
a.parentLog.toLowerCase().localeCompare(b.parentLog.toLowerCase()),
)
.reverse();
break;
case "fileAscending":
compareData.value = compareData.value.sort((a, b) =>
a.fileType.toLowerCase().localeCompare(b.fileType.toLowerCase()),
);
break;
case "fileDescending":
compareData.value = compareData.value
.sort((a, b) =>
a.fileType.toLowerCase().localeCompare(b.fileType.toLowerCase()),
)
.reverse();
break;
case "updatedAscending":
compareData.value = compareData.value.sort(
(a, b) => new Date(a.updated_base) - new Date(b.updated_base),
);
break;
case "updatedDescending":
compareData.value = compareData.value
.sort((a, b) => new Date(a.updated_base) - new Date(b.updated_base))
.reverse();
break;
}
}
/**
* Clears the active file selection when clicking outside a list item.
* @param {MouseEvent} e - The click event.
*/
const handleWindowClick = (e) => {
const clickedLi = e.target.closest("li");
if (!clickedLi || !clickedLi.id.startsWith("li")) isActive.value = null;
};
// Mounted
onMounted(() => {
isLoading.value = true;
store.fetchAllFiles();
window.addEventListener("click", handleWindowClick);
// Add the .scrollbar class to the DataTable tbody
const tbodyElement = document.querySelector(".p-datatable-tbody");
tbodyElement?.classList.add("scrollbar");
isLoading.value = false;
});
onBeforeUnmount(() => {
window.removeEventListener("click", handleWindowClick);
});
</script>
<style scoped>
@reference "../../assets/tailwind.css";
:deep(thead) {
@apply sticky top-0 bg-neutral-10 after:border-b after:border-neutral-500 after:w-full after:left-0 after:bottom-0 after:absolute table table-fixed w-full;
}
:deep(tbody) {
@apply overflow-y-auto overflow-x-hidden max-h-[calc(100vh_-_480px)] block;
}
:deep(table th) {
@apply border-b !border-neutral-500 !p-2 text-left font-bold !bg-neutral-10 whitespace-nowrap break-keep overflow-hidden text-ellipsis;
}
:deep(table td) {
@apply border-b border-neutral-300 !p-2 whitespace-nowrap break-keep overflow-hidden text-ellipsis;
}
:deep(tbody > tr) {
@apply duration-300 cursor-pointer hover:bg-primary/10 focus:!outline-none table table-fixed w-full;
}
:deep(.p-sortable-column) {
@apply focus:!shadow-none !text-neutral-900;
}
:deep(.p-sortable-column-icon) {
@apply !text-[#6c757d];
}
</style>