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

391 lines
13 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 { ref, watch, computed } from "vue";
import { router } from "@inertiajs/vue3";
import {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
const props = defineProps({
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class? }]
rows: { type: Array, default: () => [] },
meta: { type: Object, required: true }, // { current_page, per_page, total, last_page }
sort: { type: Object, default: () => ({ key: null, direction: null }) },
search: { type: String, default: "" },
page: { type: Number, default: 1 },
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50] },
routeName: { type: String, required: true },
routeParams: { type: Object, default: () => ({}) },
query: { type: Object, default: () => ({}) },
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
loading: { type: Boolean, default: false },
emptyText: { type: String, default: "Ni podatkov." },
rowKey: { type: [String, Function], default: "id" },
showToolbar: { type: Boolean, default: true },
onlyProps: { type: Array, default: () => [] }, // e.g., ['contracts']
// Pagination UX options
showPageStats: { type: Boolean, default: true },
showGoto: { type: Boolean, default: true },
maxPageLinks: { type: Number, default: 5 }, // odd number preferred
// Optional custom page parameter name (Laravel custom paginator key, e.g. 'client-cases-page')
pageParamName: { type: String, default: "page" },
});
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 ?? "";
}
);
let searchTimer;
watch(internalSearch, (v) => {
emit("update:search", v);
// Debounced request, reset page to 1
clearTimeout(searchTimer);
searchTimer = setTimeout(() => doRequest({ page: 1, search: v }), 300);
});
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 });
doRequest({ sort: direction ? key : null, direction, page: 1 });
}
function setPage(p) {
emit("update:page", p);
doRequest({ page: p });
}
function setPageSize(ps) {
const perPage = Number(ps);
emit("update:pageSize", perPage);
doRequest({ page: 1, perPage });
}
function doRequest(overrides = {}) {
const q = {
...props.query,
perPage: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
sort: overrides.sort ?? props.sort?.key ?? null,
direction: overrides.direction ?? props.sort?.direction ?? null,
search: overrides.search ?? props.search ?? "",
};
const pageParam = props.pageParamName || "page";
q[pageParam] = overrides.page ?? props.meta?.current_page ?? props.page ?? 1;
if (pageParam !== "page") {
delete q.page;
}
// Clean nulls
Object.keys(q).forEach((k) => {
if (q[k] === null || q[k] === undefined || q[k] === "") delete q[k];
});
const url = route(props.routeName, props.routeParams || {});
router.get(url, q, {
preserveScroll: props.preserveScroll,
preserveState: props.preserveState,
replace: true,
only: props.onlyProps.length ? props.onlyProps : undefined,
});
}
const total = computed(() => props.meta?.total ?? 0);
const currentPage = computed(() => props.meta?.current_page ?? 1);
const lastPage = computed(() => props.meta?.last_page ?? 1);
const perPage = computed(() => props.meta?.per_page ?? props.pageSize ?? 10);
// Ensure the page-size selector always contains the current server value
const pageSizeOptionsResolved = computed(() => {
const base = Array.isArray(props.pageSizeOptions)
? [...props.pageSizeOptions]
: [10, 25, 50];
const current = perPage.value;
if (current && !base.includes(current)) {
base.push(current);
}
return base.sort((a, b) => a - b);
});
const showingFrom = computed(() => {
if (total.value === 0) return 0;
return (currentPage.value - 1) * perPage.value + 1;
});
const showingTo = computed(() => {
if (total.value === 0) return 0;
return Math.min(currentPage.value * perPage.value, total.value);
});
const gotoInput = ref("");
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);
// Adjust start if at the end
start = Math.max(1, Math.min(start, end - windowSize + 1));
for (let p = start; p <= end; p++) pages.push(p);
return pages;
});
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 = "";
}
</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="meta?.per_page || pageSize"
@change="setPageSize($event.target.value)"
>
<option v-for="opt in pageSizeOptionsResolved" :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 && rows.length">
<FwbTableRow
v-for="(row, idx) in rows"
: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>
{{ 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>