Files

690 lines
18 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="admin-data-table">
<div v-if="exportable" class="clearfix admin-data-table__toolbar">
<div class="btn-group pull-right">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<i class="fa fa-download"></i> Eksport <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="#" @click.prevent="exportToCSV">Eksport CSV</a></li>
<li><a href="#" @click.prevent="exportToExcel">Eksport Excel</a></li>
<li><a href="#" @click.prevent="exportToPDF">Eksport PDF</a></li>
<li><a href="#" @click.prevent="exportToJSON">Eksport JSON</a></li>
</ul>
</div>
</div>
<div class="admin-data-table__shell">
<div class="admin-data-table__scroll">
<table class="admin-data-table__table table table-hover">
<thead>
<tr>
<th v-for="h in tableHeaders" :key="h.key" :class="{
'is-sortable': h.sortable,
'admin-data-table__col-index': h.key === '__adt_index'
}" @click="h.sortable && toggleSort(h.key)">
<div class="admin-data-table__th-inner">
<span class="admin-data-table__th-title">{{ h.title }}</span>
<span v-if="h.sortable" class="admin-data-table__sort" aria-hidden="true">
<template v-if="sortKey === h.key">
<svg v-if="sortDir === 'asc'" class="admin-data-table__sort-icon"
xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15l-6-6-6 6" />
</svg>
<svg v-else class="admin-data-table__sort-icon"
xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</template>
<span v-else class="admin-data-table__sort-hint">
<svg class="admin-data-table__sort-icon admin-data-table__sort-icon--faint"
xmlns="http://www.w3.org/2000/svg" width="10" height="10"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<path d="M18 15l-6-6-6 6" />
</svg>
<svg class="admin-data-table__sort-icon admin-data-table__sort-icon--faint"
xmlns="http://www.w3.org/2000/svg" width="10" height="10"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading" class="admin-data-table__state-row">
<td :colspan="tableHeaders.length">
<div class="admin-data-table__state">
<i class="fa fa-refresh fa-spin admin-data-table__state-icon"></i>
<div class="admin-data-table__state-title">Memuatkan</div>
</div>
</td>
</tr>
<tr v-else-if="!displayRows.length" class="admin-data-table__state-row">
<td :colspan="tableHeaders.length">
<div class="admin-data-table__state">
<i class="fa fa-inbox admin-data-table__state-icon"></i>
<div class="admin-data-table__state-title">{{ emptyText }}</div>
<div class="admin-data-table__state-hint">Tiada rekod untuk dipaparkan</div>
</div>
</td>
</tr>
<tr v-else v-for="(item, rowIdx) in displayRows" :key="String(item[itemKey])">
<td v-for="h in tableHeaders" :key="h.key" :class="cellTdClass(h)">
<template v-if="h.key === '__adt_index'">
{{ rowGlobalIndex(rowIdx) }}
</template>
<template v-else>
<slot :name="'item-' + h.key" :item="item" :value="cellValue(item, h.key)">
{{ formatCell(item, h.key) }}
</slot>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="showPagination && !loading && totalItems > 0" class="admin-data-table__footer">
<div class="admin-data-table__footer-left">
<label class="admin-data-table__footer-label">Baris setiap halaman</label>
<select v-model.number="localPerPage" class="form-control input-sm admin-data-table__page-size"
@change="onPerPageChange">
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="admin-data-table__footer-mid">
{{ rangeFrom }}{{ rangeTo }} daripada {{ totalItems }}
</div>
<nav class="admin-data-table__footer-right" aria-label="Pagination">
<ul class="pagination pagination-sm admin-data-table__pagination">
<li :class="{ disabled: page <= 1 }">
<a href="#" @click.prevent="goPage(page - 1)">Sebelum</a>
</li>
<li :class="{ disabled: page >= totalPages }">
<a href="#" @click.prevent="goPage(page + 1)">Seterusnya</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</template>
<script>
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
export default {
name: 'AdminDataTable',
props: {
headers: {
type: Array,
default: function () {
return [];
}
},
items: {
type: Array,
default: function () {
return [];
}
},
itemsPerPage: {
type: Number,
default: 10
},
showPagination: {
type: Boolean,
default: true
},
loading: {
type: Boolean,
default: false
},
itemKey: {
type: String,
default: 'id'
},
emptyText: {
type: String,
default: 'Tiada data'
},
exportable: {
type: Boolean,
default: false
},
exportFileName: {
type: String,
default: 'table-data'
},
showIndex: {
type: Boolean,
default: true
},
indexTitle: {
type: String,
default: 'Bil'
}
},
data: function () {
return {
page: 1,
localPerPage: this.itemsPerPage,
sortKey: null,
sortDir: 'asc',
perPageOptions: [10, 25, 50, 100, 500, 1000]
};
},
computed: {
totalItems: function () {
return this.sortedItems.length;
},
totalPages: function () {
var n = Math.ceil(this.totalItems / this.localPerPage) || 1;
return n;
},
sortedItems: function () {
var list = this.items.slice();
if (!this.sortKey) {
return list;
}
var key = this.sortKey;
var dir = this.sortDir === 'desc' ? -1 : 1;
list.sort(function (a, b) {
var va = a[key];
var vb = b[key];
if (va == null && vb == null) return 0;
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === 'number' && typeof vb === 'number') {
return (va - vb) * dir;
}
return String(va).localeCompare(String(vb), undefined, { numeric: true }) * dir;
});
return list;
},
displayRows: function () {
if (!this.showPagination) {
return this.sortedItems;
}
var start = (this.page - 1) * this.localPerPage;
return this.sortedItems.slice(start, start + this.localPerPage);
},
rangeFrom: function () {
if (!this.totalItems) return 0;
return (this.page - 1) * this.localPerPage + 1;
},
rangeTo: function () {
return Math.min(this.page * this.localPerPage, this.totalItems);
},
tableHeaders: function () {
if (!this.showIndex) {
return this.headers;
}
return [
{ title: this.indexTitle, key: '__adt_index', sortable: false }
].concat(this.headers);
}
},
watch: {
items: function () {
if (this.page > this.totalPages) {
this.page = Math.max(1, this.totalPages);
}
},
itemsPerPage: function (v) {
this.localPerPage = v;
}
},
methods: {
rowGlobalIndex: function (rowIdx) {
if (!this.showPagination) {
return rowIdx + 1;
}
return (this.page - 1) * this.localPerPage + rowIdx + 1;
},
cellTdClass: function (h) {
var o = {};
if (h.key === '__adt_index') o['admin-data-table__col-index'] = true;
return o;
},
cellValue: function (item, key) {
return item[key];
},
formatCell: function (item, key) {
var v = item[key];
return v == null ? '' : v;
},
toggleSort: function (key) {
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
},
goPage: function (p) {
if (p < 1 || p > this.totalPages) return;
this.page = p;
},
onPerPageChange: function () {
this.page = 1;
},
isActionColumnKey: function (key) {
if (!key) return false;
var k = String(key).toLowerCase();
return k === 'actions' || k === 'action' || k === 'opsi' || k === 'tindakan';
},
resolveFieldValue: function (item, headerKey) {
if (headerKey === '#') return '';
var keys = String(headerKey).split('.');
var value = item;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === 'object' && keys[i] in value) {
value = value[keys[i]];
} else {
return null;
}
}
if (Array.isArray(value)) {
return value
.map(function (v) {
return v && typeof v === 'object' && v.name != null ? v.name : String(v);
})
.join(', ');
}
if (value && typeof value === 'object' && 'name' in value) {
return value.name;
}
return value;
},
cellStringForExport: function (header, item, rowIndex) {
if (typeof header.exportValue === 'function') {
return header.exportValue(item, rowIndex) || '';
}
if (header.key === '__adt_index' || header.key === '#') {
return String(rowIndex + 1);
}
if (this.isActionColumnKey(header.key)) {
return '';
}
var raw = this.resolveFieldValue(item, header.key);
if (raw == null) return '';
return String(raw);
},
prepareExportMatrix: function () {
var rows = [];
var headerTitles = this.tableHeaders.map(function (h) {
return h.title || h.key;
});
var vm = this;
this.sortedItems.forEach(function (item, index) {
var line = vm.tableHeaders.map(function (h) {
return vm.cellStringForExport(h, item, index);
});
rows.push(line);
});
return { headerTitles: headerTitles, rows: rows };
},
exportEmitError: function (err) {
this.$emit('export-error', err instanceof Error ? err : new Error(String(err)));
},
exportToCSV: function () {
try {
var m = this.prepareExportMatrix();
var csvContent = [m.headerTitles]
.concat(m.rows)
.map(function (row) {
return row
.map(function (cell) {
return '"' + String(cell).replace(/"/g, '""') + '"';
})
.join(',');
})
.join('\n');
var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
saveAs(blob, this.exportFileName + '.csv');
} catch (e) {
this.exportEmitError(e);
}
},
exportToExcel: function () {
try {
var m = this.prepareExportMatrix();
var aoa = [m.headerTitles].concat(m.rows);
var worksheet = XLSX.utils.aoa_to_sheet(aoa);
var workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
var buf = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
var blob = new Blob([buf], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
saveAs(blob, this.exportFileName + '.xlsx');
} catch (e) {
this.exportEmitError(e);
}
},
exportToPDF: function () {
try {
var m = this.prepareExportMatrix();
var colCount = m.headerTitles.length;
var useLandscape = colCount > 6;
var doc = new jsPDF(useLandscape ? 'l' : 'p', 'pt', 'a4');
var fontSize = 8;
if (colCount > 12) fontSize = 6;
else if (colCount > 8) fontSize = 7;
var cellPadding = colCount > 8 ? 4 : 8;
var tableOptions = {
head: [m.headerTitles],
body: m.rows,
styles: { fontSize: fontSize, cellPadding: cellPadding },
headStyles: { fillColor: [25, 118, 210] },
margin: { top: 20 }
};
if (colCount > 8) {
tableOptions.tableWidth = 'wrap';
tableOptions.horizontalPageBreak = true;
tableOptions.styles = Object.assign({}, tableOptions.styles, {
minCellWidth: 36,
overflow: 'linebreak'
});
}
autoTable(doc, tableOptions);
doc.save(this.exportFileName + '.pdf');
} catch (e) {
this.exportEmitError(e);
}
},
exportToJSON: function () {
try {
var vm = this;
var jsonData = this.sortedItems.map(function (item, index) {
var obj = {};
vm.tableHeaders.forEach(function (h) {
var title = h.title || h.key;
if (vm.isActionColumnKey(h.key)) return;
if (h.key === '__adt_index') {
obj[title] = index + 1;
return;
}
if (typeof h.exportValue === 'function') {
obj[title] = h.exportValue(item, index);
return;
}
var keys = String(h.key).split('.');
var value = item;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === 'object' && keys[i] in value) {
value = value[keys[i]];
} else {
value = null;
break;
}
}
obj[title] = value;
});
return obj;
});
var jsonContent = JSON.stringify(jsonData, null, 2);
var blob = new Blob([jsonContent], { type: 'application/json' });
saveAs(blob, this.exportFileName + '.json');
} catch (e) {
this.exportEmitError(e);
}
}
}
};
</script>
<style scoped>
.admin-data-table__toolbar {
margin-bottom: 12px;
}
.admin-data-table {
--adt-header-bg: var(--admin-table-header-bg);
--adt-header-color: var(--admin-table-header-color);
--adt-radius: 12px;
--adt-border: 1px solid var(--admin-table-border-color);
--adt-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--adt-row-hover: var(--admin-table-row-hover-bg);
--adt-footer-bg: var(--admin-table-footer-bg);
--adt-text-muted: var(--admin-table-text-muted);
--adt-font-size: 16px;
--adt-header-font-size: 16px;
}
.admin-data-table__shell {
border-radius: var(--adt-radius);
border: var(--adt-border);
box-shadow: var(--adt-shadow);
overflow: hidden;
background: var(--admin-table-shell-bg);
}
.admin-data-table__scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-data-table__table {
width: 100%;
margin-bottom: 0;
border-collapse: separate;
border-spacing: 0;
}
.admin-data-table__table thead tr:first-child th:first-child {
border-top-left-radius: var(--adt-radius);
}
.admin-data-table__table thead tr:first-child th:last-child {
border-top-right-radius: var(--adt-radius);
}
.admin-data-table__table thead th {
background-color: var(--adt-header-bg);
color: var(--adt-header-color);
font-weight: 600;
font-size: var(--adt-header-font-size);
text-transform: none;
letter-spacing: 0.02em;
border: none !important;
padding: 14px 16px;
vertical-align: middle;
}
.admin-data-table__table thead th.is-sortable {
cursor: pointer;
user-select: none;
}
.admin-data-table__table thead th.is-sortable:hover {
filter: brightness(1.06);
}
.admin-data-table__table thead th.admin-data-table__col-index,
.admin-data-table__table tbody td.admin-data-table__col-index {
width: 3.25rem;
max-width: 4rem;
text-align: center;
font-variant-numeric: tabular-nums;
vertical-align: middle !important;
}
.admin-data-table__th-inner {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-height: 20px;
}
.admin-data-table__th-title {
flex: 1;
min-width: 0;
}
.admin-data-table__sort {
display: inline-flex;
align-items: center;
flex-shrink: 0;
opacity: 0.95;
}
.admin-data-table__sort-hint {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
opacity: 0.45;
margin-top: 1px;
}
.admin-data-table__sort-icon {
display: block;
flex-shrink: 0;
}
.admin-data-table__sort-icon--faint {
margin-top: -3px;
}
.admin-data-table__sort-hint .admin-data-table__sort-icon--faint:first-child {
margin-top: 0;
}
.admin-data-table__table tbody td {
padding: 12px 16px;
vertical-align: middle;
border-top: 1px solid var(--admin-table-row-border-color);
font-size: var(--adt-font-size);
}
.admin-data-table__table tbody tr:first-child:not(.admin-data-table__state-row) td {
border-top: 1px solid var(--admin-table-border-color);
}
.admin-data-table__table tbody tr.admin-data-table__state-row td {
border-top: none;
padding: 0;
}
.admin-data-table__table tbody tr:not(.admin-data-table__state-row):hover td {
background-color: var(--adt-row-hover);
}
.admin-data-table__state {
text-align: center;
padding: 40px 24px;
color: var(--adt-text-muted);
}
.admin-data-table__state-icon {
font-size: 40px;
opacity: 0.45;
margin-bottom: 12px;
display: block;
margin-left: auto;
margin-right: auto;
}
.admin-data-table__state-title {
font-size: 16px;
font-weight: 600;
color: var(--admin-table-text-strong);
margin-bottom: 4px;
}
.admin-data-table__state-hint {
font-size: 13px;
color: var(--adt-text-muted);
}
.admin-data-table__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
padding: 12px 16px;
border-top: var(--adt-border);
background: var(--adt-footer-bg);
}
.admin-data-table__footer-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.admin-data-table__footer-label {
margin: 0;
font-size: 12px;
font-weight: normal;
color: var(--adt-text-muted);
}
.admin-data-table__page-size {
width: auto;
min-width: 64px;
display: inline-block;
height: 30px;
padding: 4px 8px;
font-size: 12px;
}
.admin-data-table__footer-mid {
font-size: 13px;
font-weight: 500;
color: var(--admin-table-footer-mid-text);
}
.admin-data-table__footer-right {
flex-shrink: 0;
}
.admin-data-table__pagination {
margin: 0;
}
.admin-data-table__pagination>li>a {
padding: 5px 12px;
font-size: 12px;
color: var(--adt-header-bg);
}
.admin-data-table__pagination>li.disabled>a {
color: var(--admin-table-pagination-disabled);
pointer-events: none;
cursor: default;
}
</style>