Changes to import and notifications
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
<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"> </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>
|
||||
Reference in New Issue
Block a user