Changes to import and notifications

This commit is contained in:
Simon Pocrnjič
2025-10-13 21:14:10 +02:00
parent 0bbed64542
commit 79b3e20b02
28 changed files with 2173 additions and 438 deletions
@@ -0,0 +1,375 @@
<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>
@@ -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">&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>