Teren-app/resources/js/Components/DataTable/DataTableClient.vue
2025-10-13 21:14:10 +02:00

376 lines
12 KiB
Vue
Raw 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.

<script setup>
import { computed, ref, watch } from "vue";
import {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
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
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));
const gotoInput = ref("");
function goToPageInput() {
const raw = String(gotoInput.value || "").trim();
const n = Number(raw);
if (!Number.isFinite(n)) return;
const target = Math.max(1, Math.min(lastPage.value, Math.floor(n)));
if (target !== currentPage.value) setPage(target);
gotoInput.value = "";
}
const visiblePages = computed(() => {
const pages = [];
const count = lastPage.value;
if (count <= 1) return [1];
const windowSize = Math.max(3, props.maxPageLinks);
const half = Math.floor(windowSize / 2);
let start = Math.max(1, currentPage.value - half);
let end = Math.min(count, start + windowSize - 1);
start = Math.max(1, Math.min(start, end - windowSize + 1));
for (let p = start; p <= end; p++) pages.push(p);
return pages;
});
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
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<FwbTable hoverable striped class="text-sm">
<FwbTableHead
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span>{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'">▲</span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
>▼</span
>
</button>
<span v-else>{{ col.label }}</span>
</FwbTableHeadCell>
<FwbTableHeadCell v-if="$slots.actions" class="w-px">&nbsp;</FwbTableHeadCell>
</FwbTableHead>
<FwbTableBody>
<template v-if="!loading && pageRows.length">
<FwbTableRow
v-for="(row, idx) in pageRows"
:key="keyOf(row)"
@click="$emit('row:click', row)"
class="cursor-default"
>
<FwbTableCell
v-for="col in columns"
:key="col.key"
:class="col.class"
:align="col.align || 'left'"
>
<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>
</FwbTableCell>
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
<slot name="actions" :row="row" :index="idx" />
</FwbTableCell>
</FwbTableRow>
</template>
<template v-else-if="loading">
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<div class="p-6 text-center text-gray-500">Nalagam...</div>
</FwbTableCell>
</FwbTableRow>
</template>
<template v-else>
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty">
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
</slot>
</FwbTableCell>
</FwbTableRow>
</template>
</FwbTableBody>
</FwbTable>
</div>
<nav class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700" aria-label="Pagination">
<div v-if="showPageStats">
<span v-if="total > 0">Prikazano: {{ showingFrom }}{{ showingTo }} od {{ total }}</span>
<span v-else>Ni zadetkov</span>
</div>
<div class="flex items-center gap-1">
<!-- First -->
<button
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage <= 1"
@click="setPage(1)"
aria-label="Prva stran"
>
««
</button>
<!-- Prev -->
<button
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage <= 1"
@click="setPage(currentPage - 1)"
aria-label="Prejšnja stran"
>
«
</button>
<!-- Leading ellipsis / first page when window doesn't include 1 -->
<button
v-if="visiblePages[0] > 1"
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50"
@click="setPage(1)"
>1</button>
<span v-if="visiblePages[0] > 2" class="px-1">…</span>
<!-- Page numbers -->
<button
v-for="p in visiblePages"
:key="p"
class="px-3 py-1 rounded border transition-colors"
:class="p === currentPage ? 'border-indigo-600 bg-indigo-600 text-white' : 'border-gray-300 hover:bg-gray-50'"
:aria-current="p === currentPage ? 'page' : undefined"
@click="setPage(p)"
>
{{ p }}
</button>
<!-- Trailing ellipsis / last page when window doesn't include last -->
<span v-if="visiblePages[visiblePages.length - 1] < lastPage - 1" class="px-1">…</span>
<button
v-if="visiblePages[visiblePages.length - 1] < lastPage"
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50"
@click="setPage(lastPage)"
>{{ lastPage }}</button>
<!-- Next -->
<button
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage >= lastPage"
@click="setPage(currentPage + 1)"
aria-label="Naslednja stran"
>
»
</button>
<!-- Last -->
<button
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage >= lastPage"
@click="setPage(lastPage)"
aria-label="Zadnja stran"
>
»»
</button>
<!-- Goto page -->
<div v-if="showGoto" class="ms-2 flex items-center gap-1">
<input
v-model="gotoInput"
type="number"
min="1"
:max="lastPage"
inputmode="numeric"
class="w-16 rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 text-sm"
:placeholder="String(currentPage)"
aria-label="Pojdi na stran"
@keyup.enter="goToPageInput"
@blur="goToPageInput"
/>
<span class="text-gray-500">/ {{ lastPage }}</span>
</div>
</div>
</nav>
</div>
</template>