291 lines
8.9 KiB
Vue
291 lines
8.9 KiB
Vue
<script setup lang="ts">
|
|
import type { Header, Action } from '~/types/common';
|
|
import { STATUS } from '~/types/antrean';
|
|
import { numberFormat } from '~/utils/helpers';
|
|
|
|
interface Props {
|
|
headers: Header[];
|
|
items: any[];
|
|
search?: string;
|
|
itemsPerPage?: number;
|
|
minWidth?: string;
|
|
actions?: Action[];
|
|
getActions?: (item: any) => Action[];
|
|
serverSide?: boolean;
|
|
loading?: boolean;
|
|
totalItems?: number;
|
|
currentPage?: number;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
search: '',
|
|
itemsPerPage: 10,
|
|
minWidth: '1200px',
|
|
actions: () => [],
|
|
getActions: undefined,
|
|
serverSide: false,
|
|
loading: false,
|
|
totalItems: 0,
|
|
currentPage: 1
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
(e: string, item: any): void;
|
|
(e: 'update:page', page: number): void;
|
|
(e: 'update:itemsPerPage', itemsPerPage: number): void;
|
|
}>();
|
|
|
|
// Status helper
|
|
const getStatusText = (status: string) => {
|
|
switch (status) {
|
|
case STATUS.BELUM: return 'Belum';
|
|
case STATUS.SELESAI: return 'Selesai';
|
|
case STATUS.TUNDA: return 'Tunda';
|
|
case STATUS.BATAL: return 'Batal';
|
|
default: return 'Unknown';
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case STATUS.BELUM: return 'info';
|
|
case STATUS.SELESAI: return 'success';
|
|
case STATUS.TUNDA: return 'warning';
|
|
case STATUS.BATAL: return 'error';
|
|
default: return 'default';
|
|
}
|
|
};
|
|
|
|
const handleAction = (event: string, item: any) => {
|
|
emit(event, item);
|
|
};
|
|
|
|
const page = ref(props.currentPage);
|
|
const itemsPerPageLocal = ref(props.itemsPerPage);
|
|
|
|
watch(() => props.currentPage, (newVal) => {
|
|
page.value = newVal;
|
|
});
|
|
|
|
watch(page, (newVal) => {
|
|
emit('update:page', newVal);
|
|
});
|
|
|
|
watch(itemsPerPageLocal, (newVal) => {
|
|
emit('update:itemsPerPage', newVal);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="table-container">
|
|
<div class="" :class="{ 'loading-overlay-wrapper': loading }">
|
|
<!-- Loading Overlay -->
|
|
<div v-if="loading" class="loading-overlay">
|
|
<v-progress-circular
|
|
indeterminate
|
|
color="primary"
|
|
size="48"
|
|
></v-progress-circular>
|
|
<div class="text-caption text-medium-emphasis mt-3">
|
|
Memuat data...
|
|
</div>
|
|
</div>
|
|
|
|
<v-data-table-server
|
|
:headers="headers"
|
|
:items="items"
|
|
:items-length="serverSide ? totalItems : 0"
|
|
v-model:page="page"
|
|
v-model:items-per-page="itemsPerPageLocal"
|
|
:search="serverSide ? undefined : search"
|
|
:loading="loading"
|
|
elevation="0"
|
|
item-value="id"
|
|
hide-default-footer
|
|
striped="even"
|
|
>
|
|
<!-- Loading slot -->
|
|
<template #loading>
|
|
<v-skeleton-loader
|
|
type="table-row@10"
|
|
class="mx-auto"
|
|
></v-skeleton-loader>
|
|
</template>
|
|
|
|
<!-- No data slot -->
|
|
<template #no-data>
|
|
<div class="text-center pa-8">
|
|
<v-icon size="64" color="grey-lighten-1" class="mb-4">
|
|
mdi-database-off-outline
|
|
</v-icon>
|
|
<div class="text-h6 text-grey-darken-1 mb-2">Tidak ada data</div>
|
|
<div class="text-caption text-grey">
|
|
Tidak ada data yang tersedia untuk ditampilkan
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Pass through all slots to parent -->
|
|
<template v-for="(_, name) in $slots" v-slot:[name]="slotData">
|
|
<slot :name="name" v-bind="slotData" />
|
|
</template>
|
|
|
|
<!-- Default slot for Jenis Kelamin if not provided -->
|
|
<template #item.JenisKelamin="{ item }">
|
|
<slot name="item.JenisKelamin" :item="item">
|
|
<v-chip
|
|
:color="item.JenisKelamin === 'L' ? 'primary' : 'error'"
|
|
size="small"
|
|
>
|
|
{{ item.JenisKelamin }}
|
|
</v-chip>
|
|
</slot>
|
|
</template>
|
|
|
|
<!-- Default slot for Status if not provided -->
|
|
<template #item.StatusOperasi="{ item }">
|
|
<slot name="item.StatusOperasi" :item="item">
|
|
<v-chip
|
|
:color="getStatusColor(item.StatusOperasi)"
|
|
size="small"
|
|
>
|
|
{{ getStatusText(item.StatusOperasi) }}
|
|
</v-chip>
|
|
</slot>
|
|
</template>
|
|
|
|
<!-- Action slot if enabled -->
|
|
<template v-if="(actions && actions.length > 0) || getActions" #item.actions="{ item }">
|
|
<slot name="item.actions" :item="item">
|
|
<div class="d-flex ga-2">
|
|
<v-btn
|
|
v-for="(action, index) in (getActions ? getActions(item) : actions)"
|
|
:key="index"
|
|
icon
|
|
size="small"
|
|
variant="text"
|
|
:color="action.color || 'primary'"
|
|
@click="handleAction(action.event, item)"
|
|
>
|
|
<v-icon>{{ action.icon }}</v-icon>
|
|
<v-tooltip v-if="action.tooltip" activator="parent" location="top">
|
|
{{ action.tooltip }}
|
|
</v-tooltip>
|
|
</v-btn>
|
|
</div>
|
|
</slot>
|
|
</template>
|
|
</v-data-table-server>
|
|
</div>
|
|
|
|
<!-- Custom pagination footer - fixed and on the left -->
|
|
<div class="pagination-footer">
|
|
<div class="pagination-info">
|
|
<span class="text-caption">
|
|
<template v-if="loading">
|
|
Memuat data...
|
|
</template>
|
|
<template v-else>
|
|
Showing {{ ((page - 1) * itemsPerPageLocal) + 1 }} to {{ Math.min(page * itemsPerPageLocal, serverSide ? totalItems : items.length) }} of {{ serverSide ? numberFormat(totalItems) : items.length }} entries
|
|
</template>
|
|
</span>
|
|
</div>
|
|
<div class="pagination-controls">
|
|
<v-select
|
|
v-model="itemsPerPageLocal"
|
|
:items="[10, 25, 50, 100]"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
:disabled="loading"
|
|
style="max-width: 100px;"
|
|
label="Rows"
|
|
></v-select>
|
|
<v-pagination
|
|
v-model="page"
|
|
:length="Math.ceil((serverSide ? totalItems : items.length) / itemsPerPageLocal)"
|
|
:total-visible="5"
|
|
:disabled="loading"
|
|
density="comfortable"
|
|
></v-pagination>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.table-container {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.table-wrapper {
|
|
overflow-x: auto;
|
|
width: 100%;
|
|
margin-bottom: 16px;
|
|
position: relative;
|
|
}
|
|
|
|
.loading-overlay-wrapper {
|
|
position: relative;
|
|
min-height: 400px;
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(255, 255, 255, 0.9);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10;
|
|
backdrop-filter: blur(2px);
|
|
}
|
|
|
|
.table-wrapper :deep(.v-data-table) {
|
|
min-width: v-bind(minWidth);
|
|
}
|
|
|
|
.table-wrapper.loading-overlay-wrapper :deep(.v-data-table) {
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.pagination-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 16px;
|
|
border-top: 1px solid rgba(0, 0, 0, 0.12);
|
|
background-color: white;
|
|
position: sticky;
|
|
left: 0;
|
|
right: 0;
|
|
}
|
|
|
|
.pagination-info {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.pagination-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* Dark theme support */
|
|
:deep(.v-theme--dark) .pagination-footer {
|
|
border-top-color: rgba(255, 255, 255, 0.12);
|
|
background-color: rgb(30, 30, 30);
|
|
}
|
|
|
|
:deep(.v-theme--dark) .loading-overlay {
|
|
background-color: rgba(30, 30, 30, 0.9);
|
|
}
|
|
</style>
|