277 lines
9.0 KiB
Vue
277 lines
9.0 KiB
Vue
<script setup>
|
|
import { computed, ref, watch } from "vue";
|
|
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
|
|
import EmptyState from "@/Components/EmptyState.vue";
|
|
import DataTablePaginationClient from "@/Components/DataTable/DataTablePaginationClient.vue";
|
|
import {
|
|
Table,
|
|
TableHeader,
|
|
TableHead,
|
|
TableBody,
|
|
TableRow,
|
|
TableCell,
|
|
} from "@/Components/ui/table";
|
|
import { Button } from "../ui/button";
|
|
import { ArrowDownNarrowWide, ArrowUpWideNarrowIcon } from "lucide-vue-next";
|
|
|
|
const props = defineProps({
|
|
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
|
|
rows: { type: Array, default: () => [] },
|
|
// Sorting
|
|
sort: { type: Object, default: () => ({ key: null, direction: null }) },
|
|
// Searching
|
|
search: { type: String, default: "" },
|
|
searchKeys: { type: [Array, Function], default: () => [] },
|
|
// Pagination
|
|
page: { type: Number, default: 1 },
|
|
pageSize: { type: Number, default: 10 },
|
|
pageSizeOptions: { type: Array, default: () => [10, 25, 50] },
|
|
// UI
|
|
loading: { type: Boolean, default: false },
|
|
emptyText: { type: String, default: "Ni podatkov." },
|
|
rowKey: { type: [String, Function], default: "id" },
|
|
showToolbar: { type: Boolean, default: true },
|
|
// Pagination UX options
|
|
showPagination: { type: Boolean, default: true },
|
|
showPageStats: { type: Boolean, default: true },
|
|
showGoto: { type: Boolean, default: true },
|
|
maxPageLinks: { type: Number, default: 5 }, // odd number preferred
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
"update:sort",
|
|
"update:search",
|
|
"update:page",
|
|
"update:pageSize",
|
|
"row:click",
|
|
]);
|
|
|
|
const internalSearch = ref(props.search);
|
|
watch(
|
|
() => props.search,
|
|
(v) => {
|
|
internalSearch.value = v ?? "";
|
|
}
|
|
);
|
|
watch(internalSearch, (v, ov) => {
|
|
if (v !== props.search) {
|
|
emit("update:search", v);
|
|
// reset page when search changes
|
|
if (props.page !== 1) emit("update:page", 1);
|
|
}
|
|
});
|
|
|
|
function keyOf(row) {
|
|
if (typeof props.rowKey === "function") return props.rowKey(row);
|
|
if (typeof props.rowKey === "string" && row && row[props.rowKey] != null)
|
|
return row[props.rowKey];
|
|
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
|
}
|
|
|
|
function toggleSort(col) {
|
|
if (!col?.sortable) return;
|
|
const { key } = col;
|
|
const current = props.sort || { key: null, direction: null };
|
|
let direction = "asc";
|
|
if (current.key === key) {
|
|
direction =
|
|
current.direction === "asc" ? "desc" : current.direction === "desc" ? null : "asc";
|
|
}
|
|
emit("update:sort", { key: direction ? key : null, direction });
|
|
// reset to page 1 when sort changes
|
|
if (props.page !== 1) emit("update:page", 1);
|
|
}
|
|
|
|
const filteredRows = computed(() => {
|
|
const s = (props.search ?? "").toString().trim().toLowerCase();
|
|
if (!s) return props.rows || [];
|
|
const keys = props.searchKeys;
|
|
if (typeof keys === "function") {
|
|
return (props.rows || []).filter((r) => {
|
|
try {
|
|
return String(keys(r) ?? "")
|
|
.toLowerCase()
|
|
.includes(s);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
const arr =
|
|
Array.isArray(keys) && keys.length ? keys : Object.keys(props.rows?.[0] || {});
|
|
return (props.rows || []).filter((r) => {
|
|
return arr.some((k) =>
|
|
String(r?.[k] ?? "")
|
|
.toLowerCase()
|
|
.includes(s)
|
|
);
|
|
});
|
|
});
|
|
|
|
const sortedRows = computed(() => {
|
|
const { key, direction } = props.sort || { key: null, direction: null };
|
|
if (!key || !direction) return filteredRows.value;
|
|
const arr = [...filteredRows.value];
|
|
arr.sort((a, b) => {
|
|
const av = a?.[key];
|
|
const bv = b?.[key];
|
|
if (av == null && bv == null) return 0;
|
|
if (av == null) return direction === "asc" ? -1 : 1;
|
|
if (bv == null) return direction === "asc" ? 1 : -1;
|
|
if (typeof av === "number" && typeof bv === "number")
|
|
return direction === "asc" ? av - bv : bv - av;
|
|
return direction === "asc"
|
|
? String(av).localeCompare(String(bv))
|
|
: String(bv).localeCompare(String(av));
|
|
});
|
|
return arr;
|
|
});
|
|
|
|
const total = computed(() => sortedRows.value.length);
|
|
const lastPage = computed(() =>
|
|
Math.max(1, Math.ceil(total.value / (props.pageSize || 10)))
|
|
);
|
|
const currentPage = computed(() =>
|
|
Math.min(Math.max(1, props.page || 1), lastPage.value)
|
|
);
|
|
const startIndex = computed(() => (currentPage.value - 1) * (props.pageSize || 10));
|
|
const endIndex = computed(() =>
|
|
Math.min(startIndex.value + (props.pageSize || 10), total.value)
|
|
);
|
|
const pageRows = computed(() => sortedRows.value.slice(startIndex.value, endIndex.value));
|
|
|
|
const showingFrom = computed(() => (total.value === 0 ? 0 : startIndex.value + 1));
|
|
const showingTo = computed(() => (total.value === 0 ? 0 : endIndex.value));
|
|
|
|
function setPage(p) {
|
|
emit("update:page", Math.min(Math.max(1, p), lastPage.value));
|
|
}
|
|
function setPageSize(ps) {
|
|
emit("update:pageSize", Number(ps));
|
|
emit("update:page", 1);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="w-full">
|
|
<div v-if="showToolbar" class="mb-3 flex items-center justify-between gap-3">
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
class="w-64 rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 text-sm"
|
|
placeholder="Iskanje..."
|
|
v-model="internalSearch"
|
|
/>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<label class="text-sm text-gray-600">Na stran</label>
|
|
<select
|
|
class="rounded border-gray-300 text-sm"
|
|
:value="pageSize"
|
|
@change="setPageSize($event.target.value)"
|
|
>
|
|
<option v-for="opt in pageSizeOptions" :key="opt" :value="opt">
|
|
{{ opt }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Table class="border-t">
|
|
<TableHeader>
|
|
<TableRow class="border-b">
|
|
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
|
|
<Button
|
|
v-if="col.sortable"
|
|
variant="ghost"
|
|
class="text-left gap-1 p-1"
|
|
@click="toggleSort(col)"
|
|
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
|
|
>
|
|
<span class="uppercase">{{ col.label }}</span>
|
|
<span v-if="sort?.key === col.key && sort.direction === 'asc'"
|
|
><ArrowDownNarrowWide
|
|
/></span>
|
|
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
|
|
><ArrowUpWideNarrowIcon
|
|
/></span>
|
|
</Button>
|
|
<span v-else>{{ col.label }}</span>
|
|
</TableHead>
|
|
<TableHead v-if="$slots.actions" class="w-px"> </TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
|
|
<TableBody>
|
|
<template v-if="!loading && pageRows.length">
|
|
<TableRow
|
|
v-for="(row, idx) in pageRows"
|
|
:key="keyOf(row)"
|
|
@click="$emit('row:click', row)"
|
|
class="cursor-default hover:bg-gray-50/50"
|
|
>
|
|
<TableCell v-for="col in columns" :key="col.key" :class="col.class">
|
|
<template v-if="$slots['cell-' + col.key]">
|
|
<slot
|
|
:name="'cell-' + col.key"
|
|
:row="row"
|
|
:column="col"
|
|
:value="row?.[col.key]"
|
|
:index="idx"
|
|
/>
|
|
</template>
|
|
<template v-else-if="$slots.cell">
|
|
<slot
|
|
name="cell"
|
|
:row="row"
|
|
:column="col"
|
|
:value="row?.[col.key]"
|
|
:index="idx"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
{{ col.formatter ? col.formatter(row) : row?.[col.key] ?? "" }}
|
|
</template>
|
|
</TableCell>
|
|
<TableCell v-if="$slots.actions" class="w-px text-right">
|
|
<slot name="actions" :row="row" :index="idx" />
|
|
</TableCell>
|
|
</TableRow>
|
|
</template>
|
|
<template v-else-if="loading">
|
|
<TableRow>
|
|
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
|
<SkeletonTable :rows="5" :cols="columns.length" />
|
|
</TableCell>
|
|
</TableRow>
|
|
</template>
|
|
<template v-else>
|
|
<TableRow>
|
|
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
|
<slot name="empty">
|
|
<EmptyState :title="emptyText" size="sm" />
|
|
</slot>
|
|
</TableCell>
|
|
</TableRow>
|
|
</template>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<div class="px-2 py-4 border-t">
|
|
<DataTablePaginationClient
|
|
v-if="showPagination"
|
|
:current-page="currentPage"
|
|
:last-page="lastPage"
|
|
:total="total"
|
|
:showing-from="showingFrom"
|
|
:showing-to="showingTo"
|
|
:show-page-stats="showPageStats"
|
|
:show-goto="showGoto"
|
|
:max-page-links="maxPageLinks"
|
|
@update:page="setPage"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|