Changes to UI and other stuff

This commit is contained in:
Simon Pocrnjič
2025-11-20 18:11:43 +01:00
parent b7fa2d261b
commit 3b284fa4bd
87 changed files with 7872 additions and 2330 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
<script setup>
import { h } from 'vue';
import { ArrowUpDown, ArrowUp, ArrowDown, EyeOff } from 'lucide-vue-next';
import { Button } from '@/Components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
const props = defineProps({
column: {
type: Object,
required: true,
},
title: {
type: String,
required: true,
},
class: {
type: [String, Object, Array],
default: null,
},
});
const getSortIcon = (column) => {
if (!column.getIsSorted()) return ArrowUpDown;
return column.getIsSorted() === 'asc' ? ArrowUp : ArrowDown;
};
</script>
<template>
<div v-if="column.getCanSort()" :class="cn('flex items-center space-x-2', props.class)">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
class="-ml-3 h-8 data-[state=open]:bg-accent"
>
<span>{{ title }}</span>
<component
:is="getSortIcon(column)"
class="ml-2 h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="column.toggleSorting(false)">
<ArrowUp class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem @click="column.toggleSorting(true)">
<ArrowDown class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="column.toggleVisibility(false)">
<EyeOff class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div v-else :class="cn('', props.class)">
{{ title }}
</div>
</template>
@@ -0,0 +1,703 @@
<script setup>
import { ref, computed, watch, h } from 'vue';
import { router } from '@inertiajs/vue3';
import {
useVueTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
} from '@tanstack/vue-table';
import DataTableToolbar from './DataTableToolbar.vue';
import DataTableColumnHeader from './DataTableColumnHeader.vue';
import DataTablePagination from './DataTablePagination.vue';
import DataTableViewOptions from './DataTableViewOptions.vue';
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
import EmptyState from '../EmptyState.vue';
import Pagination from '../Pagination.vue';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
import Checkbox from '@/Components/ui/checkbox/Checkbox.vue';
import { cn } from '@/lib/utils';
const props = defineProps({
// Data
rows: { type: Array, default: () => [] },
columns: {
type: Array,
required: true,
validator: (cols) =>
cols.every(
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
),
},
// Pagination (for server-side)
meta: {
type: Object,
default: null,
validator: (meta) =>
!meta ||
(typeof meta.current_page === 'number' &&
typeof meta.per_page === 'number' &&
typeof meta.total === 'number' &&
typeof meta.last_page === 'number'),
},
// Sorting
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search
search: { type: String, default: '' },
// Loading state
loading: { type: Boolean, default: false },
// Client-side pagination (when meta is null)
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
// Routing (for server-side)
routeName: { type: String, default: null },
routeParams: { type: Object, default: () => ({}) },
pageParamName: { type: String, default: 'page' },
onlyProps: { type: Array, default: () => [] },
// Features
showToolbar: { type: Boolean, default: true },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
showPagination: { type: Boolean, default: true },
showFilters: { type: Boolean, default: false },
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false },
showOptions: { type: Boolean, default: false },
showSelectedCount: { type: Boolean, default: false },
showOptionsMenu: { type: Boolean, default: false },
showViewOptions: { type: Boolean, default: false },
compactToolbar: { type: Boolean, default: false },
hasActiveFilters: { type: Boolean, default: false },
rowKey: { type: [String, Function], default: 'uuid' },
selectable: { type: Boolean, default: false },
striped: { type: Boolean, default: false },
hoverable: { type: Boolean, default: true },
// Empty state
emptyText: { type: String, default: 'Ni podatkov' },
emptyIcon: { type: [String, Object, Array], default: null },
emptyDescription: { type: String, default: null },
// Actions
showActions: { type: Boolean, default: false },
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
// Mobile
mobileCardView: { type: Boolean, default: true },
mobileBreakpoint: { type: Number, default: 768 },
// State preservation
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
});
const emit = defineEmits([
'update:search',
'update:sort',
'update:page',
'update:pageSize',
'row:click',
'row:select',
'selection:change',
]);
// Determine if this is server-side (has meta and routeName)
const isServerSide = computed(() => !!(props.meta && props.routeName));
const isClientSide = computed(() => !isServerSide.value);
// Row key helper
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);
}
// Convert simple column format to TanStack Table ColumnDef format
const columnDefinitions = computed(() => {
return props.columns.map((col) => ({
accessorKey: col.key,
id: col.key,
header: ({ column }) => {
return h(DataTableColumnHeader, {
column,
title: col.label,
class: col.class,
});
},
cell: ({ row, getValue }) => {
return getValue();
},
enableSorting: col.sortable !== false,
enableHiding: col.hideable !== false,
meta: {
align: col.align || 'left',
class: col.class,
},
}));
});
// Add selection column if selectable
const columnsWithSelection = computed(() => {
if (!props.selectable) return columnDefinitions.value;
return [
{
id: 'select',
enableHiding: false,
enableSorting: false,
header: ({ table }) => {
return h(Checkbox, {
modelValue: table.getIsAllPageRowsSelected(),
indeterminate: table.getIsSomePageRowsSelected(),
'onUpdate:modelValue': (value) => table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all',
});
},
cell: ({ row }) => {
return h(Checkbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value) => row.toggleSelected(!!value),
'aria-label': 'Select row',
});
},
},
...columnDefinitions.value,
];
});
// Add actions column if showActions
const finalColumns = computed(() => {
if (!props.showActions && !props.$slots.actions) return columnsWithSelection.value;
return [
...columnsWithSelection.value,
{
id: 'actions',
enableHiding: false,
enableSorting: false,
header: () => h('span', { class: 'sr-only' }, 'Actions'),
cell: ({ row }) => {
// Actions will be rendered via slot
return null;
},
},
];
});
// Internal search state
const internalSearch = ref(props.search);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
// Internal sorting state
const sorting = computed(() => {
if (!props.sort?.key || !props.sort?.direction) return [];
return [
{
id: props.sort.key,
desc: props.sort.direction === 'desc',
},
];
});
// Internal pagination state
const pagination = computed(() => {
if (isServerSide.value) {
return {
pageIndex: (props.meta?.current_page ?? 1) - 1,
pageSize: props.meta?.per_page ?? props.pageSize,
};
}
return {
pageIndex: internalPage.value - 1,
pageSize: internalPageSize.value,
};
});
const internalPage = ref(1);
const internalPageSize = ref(props.pageSize);
// Row selection
const rowSelection = ref({});
// Create TanStack Table instance
const table = useVueTable({
get data() {
return props.rows;
},
get columns() {
return finalColumns.value;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: isClientSide.value ? getPaginationRowModel() : undefined,
getSortedRowModel: isClientSide.value ? getSortedRowModel() : undefined,
getFilteredRowModel: isClientSide.value ? getFilteredRowModel() : undefined,
onSortingChange: (updater) => {
const newSorting = typeof updater === 'function' ? updater(sorting.value) : updater;
if (newSorting.length > 0) {
const sort = newSorting[0];
emit('update:sort', {
key: sort.id,
direction: sort.desc ? 'desc' : 'asc',
});
if (isServerSide.value) {
doServerRequest({
sort: sort.id,
direction: sort.desc ? 'desc' : 'asc',
page: 1,
});
}
} else {
emit('update:sort', { key: null, direction: null });
if (isServerSide.value) {
doServerRequest({ sort: null, direction: null, page: 1 });
}
}
},
onPaginationChange: (updater) => {
const newPagination = typeof updater === 'function' ? updater(pagination.value) : updater;
if (isServerSide.value) {
doServerRequest({ page: newPagination.pageIndex + 1 });
} else {
internalPage.value = newPagination.pageIndex + 1;
emit('update:page', newPagination.pageIndex + 1);
}
internalPageSize.value = newPagination.pageSize;
emit('update:pageSize', newPagination.pageSize);
},
onRowSelectionChange: (updater) => {
const newSelection = typeof updater === 'function' ? updater(rowSelection.value) : updater;
rowSelection.value = newSelection;
const selectedKeys = Object.keys(newSelection).filter((key) => newSelection[key]);
emit('selection:change', selectedKeys);
},
manualSorting: isServerSide.value,
manualPagination: isServerSide.value,
manualFiltering: isServerSide.value,
enableRowSelection: props.selectable,
state: {
get sorting() {
return sorting.value;
},
get pagination() {
return pagination.value;
},
get rowSelection() {
return rowSelection.value;
},
get globalFilter() {
return internalSearch.value;
},
},
});
// Server-side request
function doServerRequest(overrides = {}) {
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const q = {
...existingParams,
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSize.value,
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
};
const pageParam = props.pageParamName || 'page';
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
if (pageParam !== 'page') {
delete q.page;
}
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,
}
);
}
function handleSearchChange(value) {
internalSearch.value = value;
emit('update:search', value);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: value, page: 1 });
}, 300);
}
}
function handlePageSizeChange(size) {
const newSize = Number(size);
internalPageSize.value = newSize;
emit('update:pageSize', newSize);
if (isServerSide.value) {
doServerRequest({ perPage: newSize, page: 1 });
} else {
table.setPageSize(newSize);
}
}
const searchTimer = ref(null);
// Mobile detection
const isMobile = ref(false);
if (typeof window !== 'undefined') {
const checkMobile = () => {
isMobile.value = window.innerWidth < props.mobileBreakpoint;
};
checkMobile();
window.addEventListener('resize', checkMobile);
}
// Display rows
const displayRows = computed(() => {
if (isServerSide.value) return props.rows;
return table.getRowModel().rows.map((row) => row.original);
});
const total = computed(() => {
if (isServerSide.value) return props.meta?.total ?? 0;
return table.getFilteredRowModel().rows.length;
});
const from = computed(() => {
if (isServerSide.value) return props.meta?.from ?? 0;
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return total.value === 0 ? 0 : pageIndex * pageSize + 1;
});
const to = computed(() => {
if (isServerSide.value) return props.meta?.to ?? 0;
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return Math.min((pageIndex + 1) * pageSize, total.value);
});
// Export functionality
function handleExport(format) {
const data = displayRows.value.map((row) => {
const exported = {};
props.columns.forEach((col) => {
exported[col.label] = row?.[col.key] ?? '';
});
return exported;
});
if (format === 'csv') {
exportToCSV(data);
} else if (format === 'xlsx') {
exportToXLSX(data);
}
}
function exportToCSV(data) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(','),
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
if (value == null) return '';
const stringValue = String(value).replace(/"/g, '""');
return `"${stringValue}"`;
})
.join(',')
),
].join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToXLSX(data) {
exportToCSV(data);
}
</script>
<template>
<div class="w-full space-y-4">
<!-- Toolbar -->
<DataTableToolbar
v-if="showToolbar"
:search="internalSearch"
:show-search="showSearch"
:show-page-size="showPageSize"
:page-size="internalPageSize"
:page-size-options="pageSizeOptions"
:selected-count="Object.keys(rowSelection).filter((key) => rowSelection[key]).length"
:show-selected-count="showSelectedCount"
:show-export="showExport"
:show-add="showAdd"
:show-options="showOptions"
:show-filters="showFilters"
:show-options-menu="showOptionsMenu"
:has-active-filters="hasActiveFilters"
:compact="compactToolbar"
@update:search="handleSearchChange"
@update:page-size="handlePageSizeChange"
@export="handleExport"
>
<template #add>
<slot name="toolbar-add" />
</template>
<template #options>
<slot name="toolbar-options" />
</template>
<template #filters>
<slot name="toolbar-filters" />
</template>
<template #actions>
<slot name="toolbar-actions" />
</template>
</DataTableToolbar>
<!-- View Options -->
<div v-if="showViewOptions" class="flex items-center space-x-2">
<DataTableViewOptions :table="table" />
</div>
<!-- Table Container -->
<div data-table-container class="relative overflow-hidden">
<!-- Desktop Table View -->
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="[
header.column.columnDef.meta?.class,
header.column.columnDef.meta?.align === 'right' ? 'text-right' :
header.column.columnDef.meta?.align === 'center' ? 'text-center' : 'text-left',
]"
>
<div v-if="!header.isPlaceholder">
<component
:is="flexRender(header.column.columnDef.header, header.getContext())"
/>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- Loading State -->
<template v-if="loading">
<TableRow>
<TableCell
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
class="h-24 text-center"
>
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<!-- Empty State -->
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
<TableRow>
<TableCell
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
class="h-24 text-center"
>
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</TableCell>
</TableRow>
</template>
<!-- Rows -->
<template v-else>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() && 'selected'"
:class="cn(
hoverable && 'cursor-pointer',
striped && row.index % 2 === 1 && 'bg-muted/50',
)"
@click="(e) => {
const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative, input[type=checkbox]');
if (interactive) return;
$emit('row:click', row.original, row.index);
}"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="[
cell.column.columnDef.meta?.class,
cell.column.columnDef.meta?.align === 'right' ? 'text-right' :
cell.column.columnDef.meta?.align === 'center' ? 'text-center' : 'text-left',
]"
>
<template v-if="cell.column.id === 'actions'">
<slot name="actions" :row="row.original" :index="row.index">
<slot name="row-actions" :row="row.original" :index="row.index" />
</slot>
</template>
<template v-else>
<slot
:name="`cell-${cell.column.id}`"
:row="row.original"
:column="cell.column.columnDef"
:value="cell.getValue()"
:index="row.index"
>
<slot
name="cell"
:row="row.original"
:column="cell.column.columnDef"
:value="cell.getValue()"
:index="row.index"
>
<component
:is="flexRender(cell.column.columnDef.cell, cell.getContext())"
/>
</slot>
</slot>
</template>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
<!-- Mobile Card View -->
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
<template v-if="loading">
<div class="p-4">
<SkeletonTable :rows="3" :cols="1" />
</div>
</template>
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
<div class="p-6">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</div>
</template>
<template v-else>
<div
v-for="row in table.getRowModel().rows"
:key="row.id"
@click="$emit('row:click', row.original, row.index)"
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
>
<slot name="mobile-card" :row="row.original" :index="row.index">
<!-- Default mobile card layout -->
<div
v-for="col in columns.slice(0, 3)"
:key="col.key"
class="flex justify-between items-start"
>
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
{{ col.label }}
</span>
<span class="text-sm text-gray-900 text-right">
<slot
:name="`cell-${col.key}`"
:row="row.original"
:column="col"
:value="row.original?.[col.key]"
:index="row.index"
>
{{ row.original?.[col.key] ?? '—' }}
</slot>
</span>
</div>
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
<slot name="actions" :row="row.original" :index="row.index">
<slot name="row-actions" :row="row.original" :index="row.index" />
</slot>
</div>
</slot>
</div>
</template>
</div>
</div>
<!-- Pagination -->
<div v-if="showPagination">
<!-- Use existing Pagination component for server-side -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="from"
:to="to"
:total="total"
/>
</template>
<!-- TanStack Table Pagination for client-side -->
<template v-else>
<DataTablePagination :table="table" />
</template>
</div>
</div>
</template>
@@ -0,0 +1,601 @@
<script setup>
import { ref, computed, watch, h } from "vue";
import { router } from "@inertiajs/vue3";
import {
FlexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
useVueTable,
} from "@tanstack/vue-table";
import { valueUpdater } from "@/lib/utils";
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
import DataTablePagination from "./DataTablePagination.vue";
import DataTableViewOptions from "./DataTableViewOptions.vue";
import DataTableToolbar from "./DataTableToolbar.vue";
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
import EmptyState from "../EmptyState.vue";
import Pagination from "../Pagination.vue";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
import { cn } from "@/lib/utils";
const props = defineProps({
// Column definitions using TanStack Table format or simple format
columns: {
type: Array,
required: true,
},
// Data rows
data: {
type: Array,
default: () => [],
},
// Server-side pagination meta (Laravel pagination)
meta: {
type: Object,
default: null,
},
// Current sort state
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search/filter value
search: {
type: String,
default: "",
},
// Loading state
loading: {
type: Boolean,
default: false,
},
// Page size for client-side pagination
pageSize: {
type: Number,
default: 10,
},
pageSizeOptions: {
type: Array,
default: () => [10, 25, 50, 100],
},
// Server-side routing
routeName: {
type: String,
default: null,
},
routeParams: {
type: Object,
default: () => ({}),
},
pageParamName: {
type: String,
default: "page",
},
perPageParamName: {
type: String,
default: "per_page",
},
onlyProps: {
type: Array,
default: () => [],
},
preserveState: {
type: Boolean,
default: true,
},
preserveScroll: {
type: Boolean,
default: true,
},
// Features
showPagination: {
type: Boolean,
default: true,
},
showToolbar: {
type: Boolean,
default: true,
},
filterColumn: {
type: String,
default: null,
},
filterPlaceholder: {
type: String,
default: "Filter...",
},
rowKey: {
type: [String, Function],
default: "id",
},
enableRowSelection: {
type: Boolean,
default: false,
},
striped: {
type: Boolean,
default: false,
},
hoverable: {
type: Boolean,
default: true,
},
// Empty state
emptyText: {
type: String,
default: "No results.",
},
emptyIcon: {
type: [String, Object, Array],
default: null,
},
emptyDescription: {
type: String,
default: null,
},
});
const emit = defineEmits([
"update:search",
"update:sort",
"update:page",
"update:pageSize",
"row:click",
"row:select",
"selection:change",
]);
// Determine if this is server-side mode
const isServerSide = computed(() => !!(props.meta && props.routeName));
// Convert simple column format to TanStack ColumnDef if needed
const columnDefinitions = computed(() => {
return props.columns.map((col) => {
// If already a full ColumnDef, return as is
if (col.accessorKey || col.accessorFn) {
return col;
}
// Convert simple format to ColumnDef
return {
accessorKey: col.key,
id: col.key,
header: ({ column }) => {
return h(DataTableColumnHeader, {
column,
title: col.label,
class: col.class,
});
},
cell: ({ row }) => {
const value = row.getValue(col.key);
return h("div", { class: col.class }, value);
},
enableSorting: col.sortable !== false,
enableHiding: col.hideable !== false,
meta: {
align: col.align || "left",
class: col.class,
},
};
});
});
// Add selection column if enabled
const columnsWithSelection = computed(() => {
if (!props.enableRowSelection) return columnDefinitions.value;
return [
{
id: "select",
header: ({ table }) => {
return h(Checkbox, {
modelValue: table.getIsAllPageRowsSelected(),
indeterminate: table.getIsSomePageRowsSelected(),
"onUpdate:modelValue": (value) => table.toggleAllPageRowsSelected(!!value),
"aria-label": "Select all",
});
},
cell: ({ row }) => {
return h(Checkbox, {
modelValue: row.getIsSelected(),
"onUpdate:modelValue": (value) => row.toggleSelected(!!value),
"aria-label": "Select row",
});
},
enableSorting: false,
enableHiding: false,
},
...columnDefinitions.value,
];
});
// Internal state
const sorting = ref([]);
const columnFilters = ref([]);
const columnVisibility = ref({});
const rowSelection = ref({});
// Client-side pagination state
const clientPagination = ref({
pageIndex: 0,
pageSize: props.pageSize,
});
// Initialize sorting from props
watch(
() => props.sort,
(newSort) => {
if (newSort?.key && newSort?.direction) {
sorting.value = [
{
id: newSort.key,
desc: newSort.direction === "desc",
},
];
} else {
sorting.value = [];
}
},
{ immediate: true }
);
// Initialize filter from props
watch(
() => props.search,
(newSearch) => {
if (props.filterColumn && newSearch) {
columnFilters.value = [
{
id: props.filterColumn,
value: newSearch,
},
];
} else if (!newSearch) {
columnFilters.value = [];
}
},
{ immediate: true }
);
// Pagination state
const pagination = computed(() => {
if (isServerSide.value) {
// Check URL for custom per-page parameter
const urlParams = new URLSearchParams(window.location.search);
const perPageParam = props.perPageParamName || "per_page";
const urlPerPage = urlParams.get(perPageParam);
const pageSize = urlPerPage
? Number(urlPerPage)
: (props.meta?.per_page ?? props.pageSize);
return {
pageIndex: (props.meta?.current_page ?? 1) - 1,
pageSize: pageSize,
};
}
return clientPagination.value;
});
// Watch for prop changes to update client pagination
watch(
() => props.pageSize,
(newSize) => {
if (!isServerSide.value) {
clientPagination.value.pageSize = newSize;
}
}
);
// Create TanStack Table
const table = useVueTable({
get data() {
return props.data;
},
get columns() {
return columnsWithSelection.value;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: !isServerSide.value ? getPaginationRowModel() : undefined,
getSortedRowModel: !isServerSide.value ? getSortedRowModel() : undefined,
getFilteredRowModel: !isServerSide.value ? getFilteredRowModel() : undefined,
onSortingChange: (updater) => {
valueUpdater(updater, sorting);
const newSort = sorting.value[0];
if (newSort) {
emit("update:sort", {
key: newSort.id,
direction: newSort.desc ? "desc" : "asc",
});
if (isServerSide.value) {
doServerRequest({
sort: newSort.id,
direction: newSort.desc ? "desc" : "asc",
page: 1,
});
}
} else {
emit("update:sort", { key: null, direction: null });
if (isServerSide.value) {
doServerRequest({ sort: null, direction: null, page: 1 });
}
}
},
onColumnFiltersChange: (updater) => {
valueUpdater(updater, columnFilters);
const filter = columnFilters.value.find((f) => f.id === props.filterColumn);
const searchValue = filter?.value ?? "";
emit("update:search", searchValue);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: searchValue, page: 1 });
}, 300);
}
},
onColumnVisibilityChange: (updater) => valueUpdater(updater, columnVisibility),
onRowSelectionChange: (updater) => {
valueUpdater(updater, rowSelection);
const selectedKeys = Object.keys(rowSelection.value).filter(
(key) => rowSelection.value[key]
);
emit("selection:change", selectedKeys);
},
onPaginationChange: (updater) => {
const currentPagination = pagination.value;
const newPagination =
typeof updater === "function" ? updater(currentPagination) : updater;
// Check if page size changed
const pageSizeChanged = newPagination.pageSize !== currentPagination.pageSize;
if (isServerSide.value) {
// If page size changed, go back to page 1
const targetPage = pageSizeChanged ? 1 : newPagination.pageIndex + 1;
doServerRequest({
page: targetPage,
perPage: newPagination.pageSize,
});
} else {
// Update client-side pagination state
clientPagination.value = {
pageIndex: newPagination.pageIndex,
pageSize: newPagination.pageSize,
};
}
if (pageSizeChanged) {
emit("update:pageSize", newPagination.pageSize);
}
if (newPagination.pageIndex !== currentPagination.pageIndex) {
emit("update:page", newPagination.pageIndex + 1);
}
},
manualSorting: isServerSide.value,
manualPagination: isServerSide.value,
manualFiltering: isServerSide.value,
enableRowSelection: props.enableRowSelection,
state: {
get sorting() {
return sorting.value;
},
get columnFilters() {
return columnFilters.value;
},
get columnVisibility() {
return columnVisibility.value;
},
get rowSelection() {
return rowSelection.value;
},
get pagination() {
return pagination.value;
},
},
});
const searchTimer = ref(null);
// Server-side request handler
function doServerRequest(overrides = {}) {
if (!props.routeName) return;
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const perPageParam = props.perPageParamName || "per_page";
const pageParam = props.pageParamName || "page";
const q = {
...existingParams,
sort: overrides.sort ?? props.sort?.key ?? null,
direction: overrides.direction ?? props.sort?.direction ?? null,
search: overrides.search ?? props.search ?? "",
};
// Use custom per_page parameter name
q[perPageParam] = overrides.perPage ?? props.meta?.per_page ?? props.pageSize;
if (perPageParam !== "per_page") {
delete q.per_page;
}
// Use custom page parameter name
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
if (pageParam !== "page") {
delete q.page;
}
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,
});
}
// Row key helper
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);
}
</script>
<template>
<div class="w-full">
<!-- Toolbar -->
<DataTableToolbar
v-if="showToolbar"
:table="table"
:filter-column="filterColumn"
:filter-placeholder="filterPlaceholder"
:show-per-page-selector="isServerSide"
:per-page="pagination.pageSize"
:page-size-options="pageSizeOptions"
@update:per-page="(value) => table.setPageSize(value)"
class="px-4 py-2 border-t"
>
<template #filters="slotProps">
<slot name="toolbar-filters" v-bind="slotProps" />
</template>
<template #actions="slotProps">
<slot name="toolbar-actions" v-bind="slotProps" />
</template>
</DataTableToolbar>
<!-- Custom toolbar slot for full control -->
<slot name="toolbar" :table="table" />
<!-- Table -->
<div class="border-t">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="[
'py-4',
header.column.columnDef.meta?.class,
header.column.columnDef.meta?.align === 'right'
? 'text-right'
: header.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
]"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- Loading State -->
<template v-if="loading">
<TableRow>
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<!-- Empty State -->
<template v-else-if="table.getRowModel().rows.length === 0">
<TableRow>
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</TableCell>
</TableRow>
</template>
<!-- Data Rows -->
<template v-else>
<TableRow
v-for="row in table.getRowModel().rows"
:key="keyOf(row.original)"
:data-state="row.getIsSelected() && 'selected'"
:class="
cn(
hoverable && 'cursor-pointer hover:bg-muted/50',
striped && row.index % 2 === 1 && 'bg-muted/50'
)
"
@click="$emit('row:click', row.original, row.index)"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="[
cell.column.columnDef.meta?.class,
cell.column.columnDef.meta?.align === 'right'
? 'text-right'
: cell.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
]"
>
<!-- Use slot if provided -->
<slot
:name="`cell-${cell.column.id}`"
:row="row.original"
:column="cell.column"
:value="cell.getValue()"
:index="row.index"
>
<!-- Otherwise use FlexRender -->
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</slot>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
<!-- Pagination -->
<div v-if="showPagination">
<!-- Server-side pagination -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="meta.from"
:to="meta.to"
:total="meta.total"
/>
</template>
<!-- Client-side pagination -->
<template v-else>
<DataTablePagination :table="table" />
</template>
</div>
</div>
</template>
@@ -0,0 +1,884 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { router } from '@inertiajs/vue3';
import DataTableToolbar from './DataTableToolbar.vue';
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
import EmptyState from '../EmptyState.vue';
import Pagination from '../Pagination.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import {
faChevronLeft,
faChevronRight,
faSort,
faSortUp,
faSortDown,
} from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
// Data
rows: { type: Array, default: () => [] },
columns: {
type: Array,
required: true,
validator: (cols) =>
cols.every(
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
),
},
// Pagination (for server-side)
meta: {
type: Object,
default: null,
validator: (meta) =>
!meta ||
(typeof meta.current_page === 'number' &&
typeof meta.per_page === 'number' &&
typeof meta.total === 'number' &&
typeof meta.last_page === 'number'),
},
// Sorting
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search
search: { type: String, default: '' },
// Loading state
loading: { type: Boolean, default: false },
// Client-side pagination (when meta is null)
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
// Routing (for server-side)
routeName: { type: String, default: null },
routeParams: { type: Object, default: () => ({}) },
pageParamName: { type: String, default: 'page' },
onlyProps: { type: Array, default: () => [] },
// Features
showToolbar: { type: Boolean, default: true },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
showPagination: { type: Boolean, default: true },
showFilters: { type: Boolean, default: false },
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false }, // Show add buttons dropdown
showOptions: { type: Boolean, default: false }, // Show custom options slot
showSelectedCount: { type: Boolean, default: false }, // Show selected count badge
showOptionsMenu: { type: Boolean, default: false }, // Show options menu (three dots)
compactToolbar: { type: Boolean, default: false }, // Compact mode: move search/page size to menu
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
rowKey: { type: [String, Function], default: 'uuid' },
selectable: { type: Boolean, default: false },
striped: { type: Boolean, default: false },
hoverable: { type: Boolean, default: true },
// Empty state
emptyText: { type: String, default: 'Ni podatkov' },
emptyIcon: { type: [String, Object, Array], default: null },
emptyDescription: { type: String, default: null },
// Actions
showActions: { type: Boolean, default: false },
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
// Mobile
mobileCardView: { type: Boolean, default: true },
mobileBreakpoint: { type: Number, default: 768 }, // Tailwind md breakpoint
// State preservation
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
});
const emit = defineEmits([
'update:search',
'update:sort',
'update:page',
'update:pageSize',
'row:click',
'row:select',
'selection:change',
]);
// Determine if this is server-side (has meta and routeName)
const isServerSide = computed(() => !!(props.meta && props.routeName));
const isClientSide = computed(() => !isServerSide.value);
// Row key helper
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);
}
// Client-side sorting
const sortedRows = computed(() => {
if (isServerSide.value || !props.sort?.key || !props.sort?.direction) {
return props.rows;
}
const key = props.sort.key;
const direction = props.sort.direction;
const sorted = [...props.rows];
sorted.sort((a, b) => {
let aVal = a?.[key];
let bVal = b?.[key];
// Handle nulls/undefined
if (aVal == null) return 1;
if (bVal == null) return -1;
// Handle dates
if (aVal instanceof Date || (typeof aVal === 'string' && aVal.match(/\d{4}-\d{2}-\d{2}/))) {
aVal = new Date(aVal);
bVal = new Date(bVal);
}
// Handle numbers
if (typeof aVal === 'number' && typeof bVal === 'number') {
return direction === 'asc' ? aVal - bVal : bVal - aVal;
}
// Handle strings
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (direction === 'asc') {
return aStr.localeCompare(bStr);
}
return bStr.localeCompare(aStr);
});
return sorted;
});
// Client-side pagination
const currentPage = computed(() => {
if (isServerSide.value) return props.meta?.current_page ?? 1;
return internalPage.value;
});
const internalPage = ref(1);
// Use computed for pageSize to always reflect the correct value
// For server-side: use meta.per_page, for client-side: use internal state or props.pageSize
const internalPageSize = computed({
get: () => {
if (isServerSide.value && props.meta?.per_page) {
return props.meta.per_page;
}
return internalPageSizeState.value ?? props.pageSize;
},
set: (value) => {
internalPageSizeState.value = value;
}
});
// Internal state for client-side or when user changes page size before server responds
const internalPageSizeState = ref(null);
const totalPages = computed(() => {
if (isServerSide.value) return props.meta?.last_page ?? 1;
return Math.ceil(sortedRows.value.length / internalPageSize.value);
});
const paginatedRows = computed(() => {
if (isServerSide.value) return props.rows;
const start = (currentPage.value - 1) * internalPageSize.value;
const end = start + internalPageSize.value;
return sortedRows.value.slice(start, end);
});
// Client-side search
const filteredRows = computed(() => {
if (isServerSide.value || !internalSearch.value) {
return paginatedRows.value;
}
const searchTerm = internalSearch.value.toLowerCase();
return paginatedRows.value.filter((row) => {
return props.columns.some((col) => {
const value = row?.[col.key];
if (value == null) return false;
return String(value).toLowerCase().includes(searchTerm);
});
});
});
// Search handling
const internalSearch = ref(props.search);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
// Selection
const selectedRows = ref(new Set());
const isAllSelected = computed(() => {
if (filteredRows.value.length === 0) return false;
return filteredRows.value.every((row) => selectedRows.value.has(keyOf(row)));
});
const isSomeSelected = computed(() => {
return (
selectedRows.value.size > 0 &&
filteredRows.value.some((row) => selectedRows.value.has(keyOf(row)))
);
});
function toggleSelectAll() {
if (isAllSelected.value) {
filteredRows.value.forEach((row) => {
selectedRows.value.delete(keyOf(row));
});
} else {
filteredRows.value.forEach((row) => {
selectedRows.value.add(keyOf(row));
});
}
emit('selection:change', Array.from(selectedRows.value));
}
function toggleSelectRow(row) {
const key = keyOf(row);
if (selectedRows.value.has(key)) {
selectedRows.value.delete(key);
} else {
selectedRows.value.add(key);
}
emit('row:select', row, selectedRows.value.has(key));
emit('selection:change', Array.from(selectedRows.value));
}
// Sorting
function toggleSort(col) {
if (!col.sortable) return;
if (isServerSide.value) {
const current = props.sort || { key: null, direction: null };
let direction = 'asc';
if (current.key === col.key) {
direction =
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
}
emit('update:sort', { key: direction ? col.key : null, direction });
doServerRequest({ sort: direction ? col.key : null, direction, page: 1 });
} else {
const current = props.sort || { key: null, direction: null };
let direction = 'asc';
if (current.key === col.key) {
direction =
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
}
emit('update:sort', { key: direction ? col.key : null, direction });
}
}
function getSortIcon(col) {
if (props.sort?.key !== col.key) return faSort;
if (props.sort?.direction === 'asc') return faSortUp;
if (props.sort?.direction === 'desc') return faSortDown;
return faSort;
}
// Server-side request
function doServerRequest(overrides = {}) {
// Preserve existing query parameters from URL
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const q = {
...existingParams, // Preserve all existing query parameters
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSizeState.value ?? internalPageSize.value,
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
};
const pageParam = props.pageParamName || 'page';
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
if (pageParam !== 'page') {
delete q.page;
}
// Clean nulls and empty strings
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,
onSuccess: () => {
// Scroll to top of table after server request completes
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
},
}
);
}
function handleSearchChange(value) {
internalSearch.value = value;
emit('update:search', value);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: value, page: 1 });
}, 300);
}
}
function handlePageSizeChange(size) {
const newSize = Number(size);
internalPageSizeState.value = newSize;
emit('update:pageSize', newSize);
if (isServerSide.value) {
// Reset to page 1 when changing page size to avoid being on a non-existent page
doServerRequest({ perPage: newSize, page: 1 });
} else {
// Calculate total pages with new size
const newTotalPages = Math.ceil(sortedRows.value.length / newSize);
// If current page exceeds new total, reset to last page or page 1
const targetPage = currentPage.value > newTotalPages && newTotalPages > 0 ? newTotalPages : 1;
internalPage.value = targetPage;
emit('update:page', targetPage);
}
}
function handlePageChange(page) {
if (isServerSide.value) {
doServerRequest({ page });
} else {
internalPage.value = page;
emit('update:page', page);
}
// Scroll to top of table after page change
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, isServerSide.value ? 100 : 50);
}
const searchTimer = ref(null);
// Mobile detection
const isMobile = ref(false);
if (typeof window !== 'undefined') {
const checkMobile = () => {
isMobile.value = window.innerWidth < props.mobileBreakpoint;
};
checkMobile();
window.addEventListener('resize', checkMobile);
}
// Display rows
const displayRows = computed(() => {
if (isServerSide.value || !internalSearch.value) {
return paginatedRows.value;
}
return filteredRows.value;
});
const total = computed(() => {
if (isServerSide.value) return props.meta?.total ?? 0;
return internalSearch.value ? filteredRows.value.length : sortedRows.value.length;
});
const from = computed(() => {
if (isServerSide.value) return props.meta?.from ?? 0;
return total.value === 0 ? 0 : (currentPage.value - 1) * internalPageSize.value + 1;
});
const to = computed(() => {
if (isServerSide.value) return props.meta?.to ?? 0;
return Math.min(currentPage.value * internalPageSize.value, total.value);
});
// Export functionality
function handleExport(format) {
const data = displayRows.value.map((row) => {
const exported = {};
props.columns.forEach((col) => {
exported[col.label] = row?.[col.key] ?? '';
});
return exported;
});
if (format === 'csv') {
exportToCSV(data);
} else if (format === 'xlsx') {
exportToXLSX(data);
}
}
function exportToCSV(data) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(','),
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
if (value == null) return '';
const stringValue = String(value).replace(/"/g, '""');
return `"${stringValue}"`;
})
.join(',')
),
].join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToXLSX(data) {
// For XLSX, we'll use a CSV-like format or alert user to install xlsx library
// Simple implementation: use CSV format with .xlsx extension
exportToCSV(data);
// In production, you might want to use a library like 'xlsx' or 'exceljs'
}
// Generate visible page numbers with ellipsis
function getVisiblePages() {
const pages = [];
const total = totalPages.value;
const current = currentPage.value;
const maxVisible = 7;
if (total <= maxVisible) {
for (let i = 1; i <= total; i++) {
pages.push(i);
}
return pages;
}
// Always show first page
pages.push(1);
// Calculate start and end
let start = Math.max(2, current - 1);
let end = Math.min(total - 1, current + 1);
// Adjust if near start
if (current <= 3) {
end = Math.min(4, total - 1);
}
// Adjust if near end
if (current >= total - 2) {
start = Math.max(2, total - 3);
}
// Add ellipsis after first page if needed
if (start > 2) {
pages.push('...');
}
// Add middle pages
for (let i = start; i <= end; i++) {
pages.push(i);
}
// Add ellipsis before last page if needed
if (end < total - 1) {
pages.push('...');
}
// Always show last page
if (total > 1) {
pages.push(total);
}
return pages;
}
</script>
<template>
<div class="w-full space-y-4">
<!-- Toolbar -->
<DataTableToolbar
v-if="showToolbar"
:search="internalSearch"
:show-search="showSearch"
:show-page-size="showPageSize"
:page-size="internalPageSize"
:page-size-options="pageSizeOptions"
:selected-count="selectedRows.size"
:show-selected-count="showSelectedCount"
:show-export="showExport"
:show-add="showAdd"
:show-options="showOptions"
:show-filters="showFilters"
:show-options-menu="showOptionsMenu"
:has-active-filters="hasActiveFilters"
:compact="compactToolbar"
@update:search="handleSearchChange"
@update:page-size="handlePageSizeChange"
@export="handleExport"
>
<template #add>
<slot name="toolbar-add" />
</template>
<template #options>
<slot name="toolbar-options" />
</template>
<template #filters>
<slot name="toolbar-filters" />
</template>
<template #actions>
<slot name="toolbar-actions" />
</template>
</DataTableToolbar>
<!-- Table Container -->
<div
data-table-container
class="relative overflow-hidden"
>
<!-- Desktop Table View -->
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<!-- Header -->
<thead>
<tr>
<!-- Select All Checkbox -->
<th
v-if="selectable"
class="w-12 px-6 py-3 text-left"
scope="col"
>
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isSomeSelected && !isAllSelected"
@change="toggleSelectAll"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</th>
<!-- Column Headers -->
<th
v-for="col in columns"
:key="col.key"
scope="col"
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
:class="[
col.class,
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left',
]"
>
<button
v-if="col.sortable"
type="button"
class="group inline-flex items-center gap-1.5 hover:text-gray-700 transition-colors"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span>{{ col.label }}</span>
<FontAwesomeIcon
:icon="getSortIcon(col)"
class="w-3 h-3 transition-colors"
:class="{
'text-gray-700': sort?.key === col.key,
'text-gray-400 group-hover:text-gray-500': sort?.key !== col.key,
}"
/>
</button>
<span v-else>{{ col.label }}</span>
</th>
<!-- Actions Column -->
<th
v-if="showActions || $slots.actions"
scope="col"
class="relative w-px px-6 py-3"
>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<!-- Body -->
<tbody class="divide-y divide-gray-200 bg-white">
<!-- Loading State -->
<template v-if="loading">
<tr>
<td :colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)" class="px-6 py-4">
<SkeletonTable :rows="5" :cols="columns.length" />
</td>
</tr>
</template>
<!-- Empty State -->
<template v-else-if="!loading && displayRows.length === 0">
<tr>
<td
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
class="px-6 py-12 text-center"
>
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</td>
</tr>
</template>
<!-- Rows -->
<template v-else>
<tr
v-for="(row, idx) in displayRows"
:key="keyOf(row)"
@click="(e) => { const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative'); if (interactive) return; $emit('row:click', row, idx); }"
class="transition-colors"
:class="{
'cursor-pointer': !!$attrs.onRowClick,
'bg-gray-50': striped && idx % 2 === 1,
'hover:bg-gray-50': hoverable && !selectedRows.has(keyOf(row)),
'bg-primary-50': selectedRows.has(keyOf(row)),
}"
>
<!-- Select Checkbox -->
<td v-if="selectable" class="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
:checked="selectedRows.has(keyOf(row))"
@click.stop="toggleSelectRow(row)"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</td>
<!-- Data Cells -->
<td
v-for="col in columns"
:key="col.key"
class="px-6 py-4 whitespace-nowrap text-sm"
:class="[
col.class,
col.align === 'right'
? 'text-right text-gray-900'
: col.align === 'center'
? 'text-center text-gray-900'
: 'text-left text-gray-900',
]"
>
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row?.[col.key]"
:index="idx"
>
<slot name="cell" :row="row" :column="col" :value="row?.[col.key]" :index="idx">
<span>{{ row?.[col.key] ?? '—' }}</span>
</slot>
</slot>
</td>
<!-- Actions Cell -->
<td
v-if="showActions || $slots.actions"
class="relative whitespace-nowrap py-4 pl-3 pr-6 text-right text-sm font-medium"
>
<slot name="actions" :row="row" :index="idx">
<slot name="row-actions" :row="row" :index="idx" />
</slot>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
<template v-if="loading">
<div class="p-4">
<SkeletonTable :rows="3" :cols="1" />
</div>
</template>
<template v-else-if="!loading && displayRows.length === 0">
<div class="p-6">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</div>
</template>
<template v-else>
<div
v-for="(row, idx) in displayRows"
:key="keyOf(row)"
@click="$emit('row:click', row, idx)"
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
>
<slot name="mobile-card" :row="row" :index="idx">
<!-- Default mobile card layout -->
<div
v-for="col in columns.slice(0, 3)"
:key="col.key"
class="flex justify-between items-start"
>
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
{{ col.label }}
</span>
<span class="text-sm text-gray-900 text-right">
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row?.[col.key]"
:index="idx"
>
{{ row?.[col.key] ?? '—' }}
</slot>
</span>
</div>
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
<slot name="actions" :row="row" :index="idx">
<slot name="row-actions" :row="row" :index="idx" />
</slot>
</div>
</slot>
</div>
</template>
</div>
</div>
<!-- Pagination -->
<div v-if="showPagination && totalPages > 1">
<!-- Use existing Pagination component for server-side -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="from"
:to="to"
:total="total"
/>
</template>
<!-- Custom pagination for client-side -->
<template v-else>
<div class="flex flex-1 justify-between sm:hidden">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Prejšnja
</button>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Naslednja
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Prikazano
<span class="font-medium">{{ from }}</span>
do
<span class="font-medium">{{ to }}</span>
od
<span class="font-medium">{{ total }}</span>
rezultatov
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Prejšnja</span>
<FontAwesomeIcon :icon="faChevronLeft" class="h-5 w-5" />
</button>
<template v-for="page in getVisiblePages()" :key="page">
<button
v-if="page !== '...'"
@click="handlePageChange(page)"
:aria-current="page === currentPage ? 'page' : undefined"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20"
:class="
page === currentPage
? 'z-10 bg-primary-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600'
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
"
>
{{ page }}
</button>
<span
v-else
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300"
>
...
</span>
</template>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Naslednja</span>
<FontAwesomeIcon :icon="faChevronRight" class="h-5 w-5" />
</button>
</nav>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -0,0 +1,95 @@
<script setup>
import { computed } from 'vue';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
const props = defineProps({
table: {
type: Object,
required: true,
},
});
const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
</script>
<template>
<div class="flex items-center justify-between px-2">
<div class="flex-1 text-sm text-muted-foreground">
{{ table.getFilteredSelectedRowModel().rows.length }} of
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
</div>
<div class="flex items-center space-x-6 lg:space-x-8">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Rows per page</p>
<Select
:model-value="`${table.getState().pagination.pageSize}`"
@update:model-value="(value) => table.setPageSize(Number(value))"
>
<SelectTrigger class="h-8 w-[70px]">
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
</SelectTrigger>
<SelectContent side="top">
<SelectItem
v-for="pageSize in pageSizeOptions"
:key="pageSize"
:value="`${pageSize}`"
>
{{ pageSize }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
Page {{ table.getState().pagination.pageIndex + 1 }} of
{{ table.getPageCount() }}
</div>
<div class="flex items-center space-x-2">
<Button
variant="outline"
class="hidden h-8 w-8 p-0 lg:flex"
:disabled="!table.getCanPreviousPage()"
@click="table.setPageIndex(0)"
>
<span class="sr-only">Go to first page</span>
<ChevronsLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
class="h-8 w-8 p-0"
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
>
<span class="sr-only">Go to previous page</span>
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
class="h-8 w-8 p-0"
:disabled="!table.getCanNextPage()"
@click="table.nextPage()"
>
<span class="sr-only">Go to next page</span>
<ChevronRight class="h-4 w-4" />
</Button>
<Button
variant="outline"
class="hidden h-8 w-8 p-0 lg:flex"
:disabled="!table.getCanNextPage()"
@click="table.setPageIndex(table.getPageCount() - 1)"
>
<span class="sr-only">Go to last page</span>
<ChevronsRight class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</template>
@@ -1,318 +1,182 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faSearch, faTimes, faDownload, faEllipsisVertical, faGear, faPlus, faFilter } from '@fortawesome/free-solid-svg-icons';
import Dropdown from '../Dropdown.vue';
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
import { computed, ref } from "vue";
import { X, Settings2 } from "lucide-vue-next";
import { Input } from "@/Components/ui/input";
import { Button } from "@/Components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Label } from '@/Components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/Components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
} from "@/Components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import DataTableViewOptions from "./DataTableViewOptions.vue";
/**
* DataTable Toolbar Component
* Simplified toolbar following shadcn-vue patterns for TanStack Table integration
*/
const props = defineProps({
search: { type: String, default: '' },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
selectedCount: { type: Number, default: 0 },
showSelectedCount: { type: Boolean, default: false }, // Control visibility of selected count badge
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false }, // Control visibility of add buttons dropdown
showOptions: { type: Boolean, default: false }, // Control visibility of custom options slot
showFilters: { type: Boolean, default: false }, // Control visibility of filters button
showOptionsMenu: { type: Boolean, default: false }, // Control visibility of options menu (three dots)
compact: { type: Boolean, default: false }, // New prop to toggle compact menu mode
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
// TanStack Table instance
table: {
type: Object,
required: true,
},
// Column to filter on (e.g., 'email', 'name')
filterColumn: {
type: String,
default: null,
},
// Placeholder text for filter input
filterPlaceholder: {
type: String,
default: "Filter...",
},
// Show view options (column visibility toggle)
showViewOptions: {
type: Boolean,
default: true,
},
// Show per-page selector
showPerPageSelector: {
type: Boolean,
default: false,
},
// Current per page value
perPage: {
type: Number,
default: 15,
},
// Per page options
pageSizeOptions: {
type: Array,
default: () => [10, 15, 25, 50, 100],
},
});
const emit = defineEmits(['update:search', 'update:page-size', 'export']);
const emit = defineEmits(["update:perPage"]);
const internalSearch = ref(props.search);
const menuOpen = ref(false);
// Popover state
const settingsPopoverOpen = ref(false);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
const hasActiveFilters = computed(() => {
return !!internalSearch.value || props.selectedCount > 0;
// Check if any filters are active
const isFiltered = computed(() => {
if (!props.filterColumn) return false;
const column = props.table.getColumn(props.filterColumn);
return column && column.getFilterValue();
});
function clearSearch() {
internalSearch.value = '';
emit('update:search', '');
}
// Get/set filter value
const filterValue = computed({
get() {
if (!props.filterColumn) return "";
const column = props.table.getColumn(props.filterColumn);
return column?.getFilterValue() ?? "";
},
set(value) {
if (!props.filterColumn) return;
const column = props.table.getColumn(props.filterColumn);
column?.setFilterValue(value);
},
});
function handleSearchInput() {
emit('update:search', internalSearch.value);
}
function handlePageSizeChange(value) {
emit('update:page-size', Number(value));
}
function handleExport(format) {
emit('export', format);
menuOpen.value = false;
// Reset all filters
function resetFilters() {
props.table.resetColumnFilters();
}
</script>
<template>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<!-- Left side: Search and Add buttons dropdown -->
<div class="flex items-center gap-3 flex-1">
<!-- Search (always visible if showSearch is true) -->
<div v-if="showSearch && !compact" class="flex-1 max-w-sm">
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 z-10">
<FontAwesomeIcon :icon="faSearch" class="h-4 w-4 text-gray-400" />
</div>
<Input
v-model="internalSearch"
@input="handleSearchInput"
type="text"
placeholder="Iskanje..."
class="pl-10"
:class="internalSearch ? 'pr-10' : ''"
/>
<Button
v-if="internalSearch"
@click="clearSearch"
type="button"
variant="ghost"
size="icon"
class="absolute inset-y-0 right-0 h-full w-auto px-3 text-gray-400 hover:text-gray-600"
>
<FontAwesomeIcon :icon="faTimes" class="h-4 w-4" />
</Button>
</div>
</div>
<div class="flex items-center justify-between">
<!-- Left side: Search and Filters -->
<div class="flex flex-1 items-center space-x-2">
<!-- Filter Input -->
<Input
v-if="filterColumn"
v-model="filterValue"
:placeholder="filterPlaceholder"
class="h-8 w-[150px] lg:w-[250px]"
/>
<!-- Add buttons dropdown (after search input) -->
<Dropdown v-if="$slots.add && showAdd && !compact" align="left">
<template #trigger>
<Button
type="button"
variant="outline"
size="icon"
>
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4" />
<span class="sr-only">Dodaj</span>
</Button>
</template>
<template #content>
<slot name="add" />
</template>
</Dropdown>
<!-- Custom filter slots -->
<slot name="filters" :table="table" />
<!-- Custom options dropdown (after search input and add buttons) -->
<div v-if="$slots.options && showOptions && !compact" class="flex items-center">
<slot name="options" />
</div>
<!-- Reset filters button -->
<Button
v-if="isFiltered"
variant="ghost"
@click="resetFilters"
class="h-8 px-2 lg:px-3"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
</div>
<!-- Filters button (after options, before right side) -->
<Popover v-if="showFilters && $slots.filters && !compact">
<!-- Right side: Actions and View Options -->
<div class="flex items-center space-x-2">
<!-- Custom action slots -->
<slot name="actions" :table="table" />
<!-- Settings Popover (Per-page selector + View Options) -->
<Popover v-model:open="settingsPopoverOpen">
<PopoverTrigger as-child>
<Button variant="outline" size="icon" class="relative">
<FontAwesomeIcon :icon="faFilter" class="h-4 w-4" />
<span
v-if="hasActiveFilters"
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
></span>
<span class="sr-only">Filtri</span>
<Button variant="outline" size="sm" class="gap-2">
<Settings2 class="h-4 w-4" />
Pogled
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-4" align="start">
<slot name="filters" />
<PopoverContent class="w-[300px]" align="end">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Nastavitve pogleda</h4>
</div>
<div class="space-y-3">
<!-- Per page selector -->
<div
v-if="showPerPageSelector"
class="flex items-center justify-between gap-4"
>
<label class="text-sm whitespace-nowrap">Elementov na stran</label>
<Select
:model-value="String(perPage)"
@update:model-value="
(value) => {
emit('update:perPage', Number(value));
settingsPopoverOpen = false;
}
"
>
<SelectTrigger class="h-9 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem
v-for="size in pageSizeOptions"
:key="size"
:value="String(size)"
>
{{ size }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Column visibility -->
<div v-if="showViewOptions" class="flex items-center justify-between gap-4">
<label class="text-sm whitespace-nowrap">Vidnost stolpcev</label>
<DataTableViewOptions
:table="table"
@column-toggle="settingsPopoverOpen = false"
/>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<!-- Right side: Selected count, Page size, Menu & Actions -->
<div class="flex items-center gap-3">
<!-- Selected count badge -->
<div
v-if="selectedCount > 0 && showSelectedCount"
class="inline-flex items-center rounded-md bg-primary-50 px-3 py-1.5 text-sm font-medium text-primary-700"
>
{{ selectedCount }} izbran{{ selectedCount === 1 ? 'o' : 'ih' }}
</div>
<!-- Page size selector (visible when not in compact mode) -->
<div v-if="showPageSize && !compact" class="flex items-center gap-2">
<Label for="page-size" class="text-sm text-gray-600 whitespace-nowrap">Na stran:</Label>
<Select
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger id="page-size" class="w-[100px]">
<SelectValue :placeholder="String(pageSize)" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in pageSizeOptions"
:key="opt"
:value="String(opt)"
>
{{ opt }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Table Options Menu (compact mode or always as dropdown) -->
<DropdownMenu v-if="showOptionsMenu" v-model:open="menuOpen">
<DropdownMenuTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
:class="hasActiveFilters && !compact ? 'relative' : ''"
>
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
<span
v-if="hasActiveFilters && !compact"
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
></span>
<span class="sr-only">Možnosti tabele</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<!-- Search in menu (only in compact mode) -->
<div v-if="compact && showSearch" class="p-2 border-b">
<Label for="menu-search" class="text-xs font-medium mb-1.5 block">Iskanje</Label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 z-10">
<FontAwesomeIcon :icon="faSearch" class="h-3.5 w-3.5 text-gray-400" />
</div>
<Input
id="menu-search"
v-model="internalSearch"
@input="handleSearchInput"
type="text"
placeholder="Iskanje..."
class="pl-8 h-8 text-sm"
:class="internalSearch ? 'pr-8' : ''"
/>
<Button
v-if="internalSearch"
@click="clearSearch"
type="button"
variant="ghost"
size="icon"
class="absolute inset-y-0 right-0 h-full w-auto px-2 text-gray-400 hover:text-gray-600"
>
<FontAwesomeIcon :icon="faTimes" class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Page size in menu (only in compact mode) -->
<div v-if="compact && showPageSize" class="p-2 border-b">
<Label for="menu-page-size" class="text-xs font-medium mb-1.5 block">Elementov na stran</Label>
<Select
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger id="menu-page-size" class="w-full h-8 text-sm">
<SelectValue :placeholder="String(pageSize)" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in pageSizeOptions"
:key="opt"
:value="String(opt)"
>
{{ opt }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Export options -->
<template v-if="showExport">
<DropdownMenuLabel>Izvozi</DropdownMenuLabel>
<DropdownMenuItem @select="handleExport('csv')">
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
CSV
</DropdownMenuItem>
<DropdownMenuItem @select="handleExport('xlsx')">
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
Excel
</DropdownMenuItem>
</template>
<!-- Custom actions slot in menu -->
<template v-if="$slots.actions">
<DropdownMenuSeparator />
<slot name="actions" />
</template>
</DropdownMenuContent>
</DropdownMenu>
<template v-else>
<!-- If options menu is hidden but we have content to show, render it inline -->
<div v-if="showExport && !compact" class="flex items-center gap-2">
<Dropdown v-if="showExport" align="right">
<template #trigger>
<Button
type="button"
variant="outline"
class="gap-2"
>
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4" />
Izvozi
</Button>
</template>
<template #content>
<div class="py-1">
<button
type="button"
@click="handleExport('csv')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
CSV
</button>
<button
type="button"
@click="handleExport('xlsx')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
Excel
</button>
</div>
</template>
</Dropdown>
</div>
</template>
<!-- Custom actions slot (visible when not in compact mode) -->
<div v-if="$slots.actions && !compact" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { ref } from 'vue';
import DataTableToolbar from './DataTableToolbar.vue';
// Example: Using DataTableToolbar standalone
const search = ref('');
const pageSize = ref(10);
const selectedCount = ref(0);
const handleSearchChange = (value) => {
search.value = value;
console.log('Search changed:', value);
};
const handlePageSizeChange = (value) => {
pageSize.value = value;
console.log('Page size changed:', value);
};
const handleExport = (format) => {
console.log('Export:', format);
};
const handleAdd = () => {
console.log('Add button clicked');
};
</script>
<template>
<div class="space-y-4">
<!-- Standalone DataTableToolbar -->
<DataTableToolbar
:search="search"
:show-search="true"
:show-page-size="true"
:page-size="pageSize"
:selected-count="selectedCount"
:show-selected-count="true"
:show-export="true"
:show-add="true"
:show-filters="true"
@update:search="handleSearchChange"
@update:page-size="handlePageSizeChange"
@export="handleExport"
>
<!-- Add button dropdown content -->
<template #add>
<button
@click="handleAdd"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
Dodaj novo
</button>
</template>
<!-- Custom options -->
<template #options>
<button class="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded">
Opcija 1
</button>
</template>
<!-- Filters -->
<template #filters>
<div class="space-y-2">
<label class="text-sm font-medium">Filtriraj po:</label>
<input type="text" class="w-full px-2 py-1 border rounded" />
</div>
</template>
<!-- Custom actions -->
<template #actions>
<button class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded">
Akcija
</button>
</template>
</DataTableToolbar>
<!-- Your content here -->
<div class="p-4 bg-gray-50 rounded">
<p>Search: {{ search }}</p>
<p>Page Size: {{ pageSize }}</p>
<p>Selected: {{ selectedCount }}</p>
</div>
</div>
</template>
@@ -0,0 +1,50 @@
<script setup>
import { computed } from "vue";
import { Settings } from "lucide-vue-next";
import { Button } from "@/Components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
const props = defineProps({
table: {
type: Object,
required: true,
},
});
const columns = computed(() =>
props.table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
);
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="ml-auto hidden h-8 lg:flex">
<Settings class="mr-2 h-4 w-4" />
Pogled
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-[150px]">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="column in columns"
:key="column.id"
class="capitalize"
:model-value="column.getIsVisible()"
@update:model-value="(value) => column.toggleVisibility(!!value)"
>
{{ column.id }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -0,0 +1,291 @@
# DataTable Migration Guide
## Summary of Changes
The DataTable component has been updated to follow **shadcn-vue** architecture patterns using **TanStack Table v8**. This provides better flexibility, more features, and follows industry-standard patterns.
## What's New
### ✅ Components Created/Updated
1. **`DataTableNew2.vue`** - New main component with shadcn-vue architecture
2. **`DataTableColumnHeader.vue`** - Already good, uses lucide-vue-next icons
3. **`DataTablePagination.vue`** - Already follows shadcn-vue patterns
4. **`DataTableViewOptions.vue`** - Already follows shadcn-vue patterns
5. **`DataTableToolbar.vue`** - Already exists with advanced features
6. **`columns-example.js`** - Column definition examples
7. **`README.md`** - Comprehensive documentation
8. **`DataTableExample.vue`** - Working example page
### ✅ Utilities Added
- **`valueUpdater()`** in `lib/utils.js` - Helper for TanStack Table state management
## Key Improvements
### 1. **FlexRender Integration**
Now properly uses TanStack Table's FlexRender for column headers and cells:
```vue
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
```
### 2. **Better Column Definitions**
Supports both simple and advanced formats:
**Simple:**
```javascript
{ key: 'name', label: 'Name', sortable: true }
```
**Advanced:**
```javascript
{
accessorKey: 'name',
header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }),
cell: ({ row }) => h('div', {}, row.getValue('name')),
}
```
### 3. **Enhanced Features**
- ✅ Row selection with checkboxes
- ✅ Column visibility toggle
- ✅ Advanced filtering
- ✅ Better loading/empty states
- ✅ Custom cell slots
- ✅ Flexible toolbar
### 4. **Better State Management**
Uses `valueUpdater()` helper for proper Vue reactivity with TanStack Table:
```javascript
onSortingChange: (updater) => valueUpdater(updater, sorting)
```
## Migration Steps
### Step 1: Update Imports
**Before:**
```vue
import DataTable from '@/Components/DataTable/DataTable.vue';
```
**After:**
```vue
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
```
### Step 2: Update Props
**Before:**
```vue
<DataTable
:rows="clients.data"
:columns="columns"
:meta="clients.meta"
/>
```
**After:**
```vue
<DataTable
:data="clients.data"
:columns="columns"
:meta="clients.meta"
route-name="clients.index"
/>
```
Main prop changes:
- `rows``data`
- Add `route-name` for server-side pagination
### Step 3: Column Definitions
Your existing simple column format still works:
```javascript
const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
];
```
But you can now use advanced format for more control:
```javascript
import { h } from 'vue';
import DataTableColumnHeader from '@/Components/DataTable/DataTableColumnHeader.vue';
const columns = [
{
accessorKey: 'name',
header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }),
cell: ({ row }) => h('div', { class: 'font-medium' }, row.getValue('name')),
},
];
```
### Step 4: Custom Cell Rendering
**Before:** Required editing component
**After:** Use slots!
```vue
<DataTable :columns="columns" :data="data">
<template #cell-status="{ value, row }">
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
{{ value }}
</Badge>
</template>
</DataTable>
```
## Backward Compatibility
The **old DataTable components are still available**:
- `DataTable.vue` - Your current enhanced version
- `DataTableServer.vue` - Your server-side version
- `DataTableOld.vue` - Original version
You can migrate pages gradually. Both old and new can coexist.
## Example Migration
### Before (Client/Index.vue)
```vue
<script setup>
import DataTable from '@/Components/DataTable/DataTable.vue';
const props = defineProps({
clients: Object,
filters: Object,
});
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
];
</script>
<template>
<DataTable
:rows="clients.data"
:columns="columns"
:meta="clients.meta"
:search="filters.search"
:sort="filters.sort"
route-name="clients.index"
/>
</template>
```
### After (Using DataTableNew2)
```vue
<script setup>
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
const props = defineProps({
clients: Object,
filters: Object,
});
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'status', label: 'Status', sortable: false },
];
</script>
<template>
<DataTable
:data="clients.data"
:columns="columns"
:meta="clients.meta"
:search="filters.search"
:sort="filters.sort"
route-name="clients.index"
filter-column="email"
filter-placeholder="Search clients..."
:only-props="['clients']"
>
<!-- Add custom cell rendering -->
<template #cell-status="{ value }">
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
{{ value }}
</Badge>
</template>
</DataTable>
</template>
```
## Testing Your Migration
1. **Check the example page:**
```
Visit: /examples/datatable
```
(You'll need to add a route for this)
2. **Test features:**
- ✅ Sorting (click column headers)
- ✅ Filtering (use search input)
- ✅ Pagination (navigate pages)
- ✅ Row selection (if enabled)
- ✅ Column visibility (View button)
3. **Check browser console:**
- No errors
- Events firing correctly
## Common Issues
### Issue: "FlexRender is not defined"
**Solution:** Make sure you imported it:
```javascript
import { FlexRender } from '@tanstack/vue-table';
```
### Issue: Column not sorting
**Solution:** Make sure `sortable: true` is set:
```javascript
{ key: 'name', label: 'Name', sortable: true }
```
### Issue: Server-side not working
**Solution:** Provide both `meta` and `route-name`:
```vue
<DataTable
:data="data"
:meta="meta"
route-name="your.route.name"
/>
```
### Issue: Custom cells not rendering
**Solution:** Use the correct slot name format:
```vue
<template #cell-columnKey="{ value, row }">
<!-- Your content -->
</template>
```
## Need Help?
1. Check `README.md` for detailed documentation
2. Look at `columns-example.js` for column patterns
3. Review `DataTableExample.vue` for working examples
4. Check TanStack Table docs: https://tanstack.com/table/v8
## Rollback Plan
If you encounter issues, you can always use the old components:
```vue
import DataTable from '@/Components/DataTable/DataTable.vue';
// or
import DataTableServer from '@/Components/DataTable/DataTableServer.vue';
```
Nothing breaks your existing code!
+390
View File
@@ -0,0 +1,390 @@
# DataTable Component - Usage Guide
This DataTable component follows the shadcn-vue architecture and uses TanStack Table v8 for powerful table functionality.
## Features
- ✅ Client-side and server-side pagination
- ✅ Sorting (single column)
- ✅ Filtering/Search
- ✅ Row selection
- ✅ Column visibility toggle
- ✅ Customizable column definitions
- ✅ Loading states
- ✅ Empty states
- ✅ Flexible toolbar
- ✅ Cell-level customization via slots
- ✅ Responsive design
- ✅ Laravel Inertia integration
## Basic Usage
### Simple Format (Recommended for basic tables)
```vue
<script setup>
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'status', label: 'Status' },
];
const data = ref([
{ id: 1, name: 'John Doe', email: 'john@example.com', status: 'Active' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'Inactive' },
]);
</script>
<template>
<DataTable :columns="columns" :data="data" />
</template>
```
### Advanced Format (Full TanStack Table power)
```vue
<script setup>
import { h } from 'vue';
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
import { Badge } from '@/Components/ui/badge';
import { columns } from './columns'; // Import from separate file
const data = ref([...]);
</script>
<template>
<DataTable :columns="columns" :data="data" />
</template>
```
See `columns-example.js` for comprehensive column definition examples.
## Props
### Data Props
- `columns` (Array, required) - Column definitions (simple or TanStack format)
- `data` (Array, default: []) - Array of data objects
- `meta` (Object, default: null) - Laravel pagination meta for server-side
- `loading` (Boolean, default: false) - Loading state
### Server-side Props
- `routeName` (String) - Laravel route name for server-side requests
- `routeParams` (Object) - Additional route parameters
- `pageParamName` (String, default: 'page') - Custom page parameter name
- `onlyProps` (Array) - Inertia.js only props
- `preserveState` (Boolean, default: true)
- `preserveScroll` (Boolean, default: true)
### Sorting & Filtering
- `sort` (Object, default: {key: null, direction: null})
- `search` (String, default: '')
- `filterColumn` (String) - Column to filter on
- `filterPlaceholder` (String, default: 'Filter...')
### Pagination
- `showPagination` (Boolean, default: true)
- `pageSize` (Number, default: 10)
- `pageSizeOptions` (Array, default: [10, 25, 50, 100])
### Features
- `enableRowSelection` (Boolean, default: false)
- `showToolbar` (Boolean, default: true)
- `striped` (Boolean, default: false)
- `hoverable` (Boolean, default: true)
- `rowKey` (String|Function, default: 'id')
### Empty State
- `emptyText` (String, default: 'No results.')
- `emptyIcon` (String|Object|Array)
- `emptyDescription` (String)
## Events
- `@update:search` - Emitted when search changes
- `@update:sort` - Emitted when sort changes
- `@update:page` - Emitted when page changes
- `@update:pageSize` - Emitted when page size changes
- `@row:click` - Emitted when row is clicked
- `@selection:change` - Emitted when selection changes
## Client-side Example
```vue
<script setup>
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
];
const data = ref([
// Your data here
]);
</script>
<template>
<DataTable
:columns="columns"
:data="data"
:page-size="10"
filter-column="email"
filter-placeholder="Filter emails..."
enable-row-selection
/>
</template>
```
## Server-side Example (Laravel Inertia)
### Controller
```php
public function index(Request $request)
{
$query = Client::query();
// Search
if ($request->search) {
$query->where('name', 'like', "%{$request->search}%")
->orWhere('email', 'like', "%{$request->search}%");
}
// Sort
if ($request->sort && $request->direction) {
$query->orderBy($request->sort, $request->direction);
}
$clients = $query->paginate($request->per_page ?? 10);
return Inertia::render('Clients/Index', [
'clients' => $clients,
'filters' => $request->only(['search', 'sort', 'direction']),
]);
}
```
### Vue Component
```vue
<script setup>
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
const props = defineProps({
clients: Object,
filters: Object,
});
const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
];
</script>
<template>
<DataTable
:columns="columns"
:data="clients.data"
:meta="clients.meta"
:search="filters.search"
:sort="{ key: filters.sort, direction: filters.direction }"
route-name="clients.index"
filter-column="email"
filter-placeholder="Search clients..."
:only-props="['clients']"
/>
</template>
```
## Custom Cell Rendering
### Using Slots
```vue
<template>
<DataTable :columns="columns" :data="data">
<!-- Custom cell for status column -->
<template #cell-status="{ value, row }">
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
{{ value }}
</Badge>
</template>
<!-- Custom cell for actions -->
<template #cell-actions="{ row }">
<Button @click="editRow(row)">Edit</Button>
</template>
</DataTable>
</template>
```
### Using Column Definitions
```javascript
import { h } from 'vue';
import { Badge } from '@/Components/ui/badge';
export const columns = [
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.getValue('status');
return h(Badge, {
variant: status === 'active' ? 'default' : 'secondary'
}, () => status);
},
},
];
```
## Custom Toolbar
The new toolbar is simplified and follows shadcn-vue patterns:
```vue
<template>
<DataTable
:columns="columns"
:data="data"
filter-column="email"
filter-placeholder="Search emails..."
>
<!-- Add custom filter controls -->
<template #toolbar-filters="{ table }">
<select
@change="table.getColumn('status')?.setFilterValue($event.target.value)"
class="h-8 rounded-md border px-3"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</template>
<!-- Add custom action buttons -->
<template #toolbar-actions="{ table }">
<Button @click="exportData">Export</Button>
<Button @click="addNew">Add New</Button>
</template>
</DataTable>
</template>
```
Or completely replace the toolbar:
```vue
<template>
<DataTable :columns="columns" :data="data" :show-toolbar="false">
<template #toolbar="{ table }">
<div class="flex items-center justify-between mb-4">
<Input
:model-value="table.getColumn('email')?.getFilterValue()"
@update:model-value="table.getColumn('email')?.setFilterValue($event)"
placeholder="Filter emails..."
class="max-w-sm"
/>
<div class="flex gap-2">
<Button @click="exportData">Export</Button>
<DataTableViewOptions :table="table" />
</div>
</div>
</template>
</DataTable>
</template>
```
## Row Selection
```vue
<script setup>
import { ref } from 'vue';
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
const selectedRows = ref([]);
function handleSelectionChange(keys) {
selectedRows.value = keys;
console.log('Selected rows:', keys);
}
</script>
<template>
<DataTable
:columns="columns"
:data="data"
enable-row-selection
@selection:change="handleSelectionChange"
/>
<div v-if="selectedRows.length">
Selected {{ selectedRows.length }} row(s)
</div>
</template>
```
## Row Click Handler
```vue
<script setup>
function handleRowClick(row, index) {
console.log('Clicked row:', row);
// Navigate or perform action
router.visit(route('clients.show', row.id));
}
</script>
<template>
<DataTable
:columns="columns"
:data="data"
@row:click="handleRowClick"
/>
</template>
```
## Tips
1. **Column Keys**: Always use consistent keys/accessorKeys across your data
2. **Server-side**: Always provide `meta` and `routeName` props together
3. **Performance**: For large datasets, use server-side pagination
4. **Styling**: Use column `class` property for custom styling
5. **Slots**: Prefer slots for complex cell rendering over h() functions
## Migration from Old DataTable
### Before (Old API)
```vue
<DataTable
:rows="clients.data"
:columns="columns"
:meta="clients.meta"
/>
```
### After (New API)
```vue
<DataTableNew2
:data="clients.data"
:columns="columns"
:meta="clients.meta"
route-name="clients.index"
/>
```
Main changes:
- `rows``data`
- Added `route-name` prop for server-side
- More consistent prop naming
- Better TypeScript support
- More flexible column definitions
## Component Files
- `DataTableNew2.vue` - Main table component
- `DataTableColumnHeader.vue` - Sortable column header
- `DataTablePagination.vue` - Pagination controls
- `DataTableViewOptions.vue` - Column visibility toggle
- `DataTableToolbar.vue` - Toolbar component
- `columns-example.js` - Column definition examples
@@ -1,31 +1,36 @@
<script setup>
import { ref } from 'vue';
import Dropdown from '../Dropdown.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
import { ref } from "vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
import Button from "../ui/button/Button.vue";
const props = defineProps({
align: {
type: String,
default: 'right', // left, right
validator: (v) => ['left', 'right'].includes(v),
default: "right", // left, right
validator: (v) => ["left", "right"].includes(v),
},
size: {
type: String,
default: 'md', // sm, md
validator: (v) => ['sm', 'md'].includes(v),
default: "md", // sm, md
validator: (v) => ["sm", "md"].includes(v),
},
});
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-8 w-8',
sm: "h-6 w-6",
md: "h-8 w-8",
};
const emit = defineEmits(['action']);
const emit = defineEmits(["action"]);
function handleAction(action) {
emit('action', action);
emit("action", action);
if (action.onClick) {
action.onClick();
}
@@ -33,23 +38,14 @@ function handleAction(action) {
</script>
<template>
<Dropdown :align="align" :content-classes="['py-1']">
<template #trigger>
<button
type="button"
:class="[
'inline-flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 transition-colors',
sizeClasses[size],
]"
aria-label="Actions"
>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" aria-label="Actions">
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
</button>
</template>
<template #content>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent :align="align === 'right' ? 'end' : 'start'" class="py-1">
<slot :handle-action="handleAction" />
</template>
</Dropdown>
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -0,0 +1,267 @@
import { h } from 'vue';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { Checkbox } from '@/Components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { MoreHorizontal, ArrowUpDown } from 'lucide-vue-next';
/**
* Example columns definition following shadcn-vue DataTable patterns
*
* Usage:
* import { columns } from './columns'
* <DataTable :columns="columns" :data="data" />
*
* This is a TypeScript-like example for JavaScript.
* The columns follow TanStack Table's ColumnDef format.
*/
/**
* Simple format - automatically converted to ColumnDef
* Use this for basic tables
*/
export const simpleColumns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'status', label: 'Status', sortable: false },
];
/**
* Advanced format - full TanStack Table ColumnDef
* Use this for custom rendering, formatting, etc.
*/
export const advancedColumns = [
// Selection column (added automatically if enableRowSelection prop is true)
// {
// id: 'select',
// header: ({ table }) => {
// return h(Checkbox, {
// modelValue: table.getIsAllPageRowsSelected(),
// indeterminate: table.getIsSomePageRowsSelected(),
// 'onUpdate:modelValue': (value) => table.toggleAllPageRowsSelected(!!value),
// 'aria-label': 'Select all',
// });
// },
// cell: ({ row }) => {
// return h(Checkbox, {
// modelValue: row.getIsSelected(),
// 'onUpdate:modelValue': (value) => row.toggleSelected(!!value),
// 'aria-label': 'Select row',
// });
// },
// enableSorting: false,
// enableHiding: false,
// },
// ID column
{
accessorKey: 'id',
header: ({ column }) => {
return h(
Button,
{
variant: 'ghost',
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
},
() => ['ID', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
);
},
cell: ({ row }) => {
return h('div', { class: 'w-20 font-medium' }, row.getValue('id'));
},
},
// Name column
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return h('div', { class: 'font-medium' }, row.getValue('name'));
},
},
// Email column with custom rendering
{
accessorKey: 'email',
header: ({ column }) => {
return h(
Button,
{
variant: 'ghost',
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
},
() => ['Email', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
);
},
cell: ({ row }) => {
return h('div', { class: 'lowercase' }, row.getValue('email'));
},
},
// Amount column with formatting
{
accessorKey: 'amount',
header: () => h('div', { class: 'text-right' }, 'Amount'),
cell: ({ row }) => {
const amount = parseFloat(row.getValue('amount'));
const formatted = new Intl.NumberFormat('sl-SI', {
style: 'currency',
currency: 'EUR',
}).format(amount);
return h('div', { class: 'text-right font-medium' }, formatted);
},
},
// Status column with badge
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.getValue('status');
const variants = {
success: 'default',
pending: 'secondary',
failed: 'destructive',
};
return h(
Badge,
{
variant: variants[status] || 'outline',
},
() => status
);
},
},
// Actions column
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const item = row.original;
return h(
'div',
{ class: 'text-right' },
h(
DropdownMenu,
{},
{
default: () => [
h(
DropdownMenuTrigger,
{ asChild: true },
{
default: () =>
h(
Button,
{
variant: 'ghost',
class: 'h-8 w-8 p-0',
},
{
default: () => [
h('span', { class: 'sr-only' }, 'Open menu'),
h(MoreHorizontal, { class: 'h-4 w-4' }),
],
}
),
}
),
h(
DropdownMenuContent,
{ align: 'end' },
{
default: () => [
h(DropdownMenuLabel, {}, () => 'Actions'),
h(
DropdownMenuItem,
{
onClick: () => navigator.clipboard.writeText(item.id),
},
() => 'Copy ID'
),
h(DropdownMenuSeparator),
h(DropdownMenuItem, {}, () => 'View details'),
h(DropdownMenuItem, {}, () => 'Edit'),
],
}
),
],
}
)
);
},
},
];
/**
* Payments example from shadcn-vue docs
*/
export const paymentColumns = [
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.getValue('status');
return h('div', { class: 'capitalize' }, status);
},
},
{
accessorKey: 'email',
header: ({ column }) => {
return h(
Button,
{
variant: 'ghost',
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
},
() => ['Email', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
);
},
cell: ({ row }) => h('div', { class: 'lowercase' }, row.getValue('email')),
},
{
accessorKey: 'amount',
header: () => h('div', { class: 'text-right' }, 'Amount'),
cell: ({ row }) => {
const amount = parseFloat(row.getValue('amount'));
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
return h('div', { class: 'text-right font-medium' }, formatted);
},
},
];
/**
* Example with custom cell slots
* Use template slots in your component:
*
* <DataTable :columns="columnsWithSlots" :data="data">
* <template #cell-status="{ value }">
* <Badge :variant="value === 'active' ? 'default' : 'secondary'">
* {{ value }}
* </Badge>
* </template>
* </DataTable>
*/
export const columnsWithSlots = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'status', label: 'Status', sortable: false }, // Will use #cell-status slot
{ key: 'email', label: 'Email', sortable: true },
];
export default advancedColumns;
@@ -13,32 +13,47 @@ import {
faTrash,
faFileAlt,
} from "@fortawesome/free-solid-svg-icons";
import { ref } from "vue";
import { ref, computed } from "vue";
import { router } from "@inertiajs/vue3";
import DataTable from "../DataTable/DataTable.vue";
import DataTable from "../DataTable/DataTableNew2.vue";
import Dropdown from "@/Components/Dropdown.vue";
import DeleteDialog from "../Dialogs/DeleteDialog.vue";
import { Badge } from "@/Components/ui/badge";
import TableActions from "@/Components/DataTable/TableActions.vue";
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
documents: { type: Array, default: () => [] },
documents: { type: [Array, Object], default: () => [] },
viewUrlBuilder: { type: Function, default: null },
// Optional: direct download URL builder; if absent we emit 'download'
downloadUrlBuilder: { type: Function, default: null },
// Optional: direct delete URL builder; if absent we emit 'delete'
deleteUrlBuilder: { type: Function, default: null },
edit: { type: Boolean, default: false },
pageSize: {
type: Number,
default: 15,
},
pageSizeOptions: {
type: Array,
default: () => [10, 15, 25, 50, 100],
},
// Server-side pagination support
clientCase: { type: Object, default: null },
});
// Define columns for DataTable
const columns = [
{ key: 'name', label: 'Naziv' },
{ key: 'type', label: 'Vrsta' },
{ key: 'size', label: 'Velikost', align: 'right' },
{ key: 'created_at', label: 'Dodano' },
{ key: 'source', label: 'Vir' },
{ key: 'description', label: 'Opis', align: 'center' },
{ key: "name", label: "Naziv", sortable: false },
{ key: "type", label: "Vrsta", sortable: false },
{ key: "size", label: "Velikost", align: "right", sortable: false },
{ key: "created_at", label: "Dodano", sortable: false },
{ key: "source", label: "Vir", sortable: false },
{ key: "description", label: "Opis", align: "center", sortable: false },
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
];
// Derive a human-friendly source for a document: Case or Contract reference
const sourceLabel = (doc) => {
// Server can include optional documentable meta; fall back to type
@@ -50,6 +65,19 @@ const sourceLabel = (doc) => {
const emit = defineEmits(["view", "download", "delete", "edit"]);
// Support both array and Resource Collection (object with data property)
const documentsData = computed(() => {
if (Array.isArray(props.documents)) {
return props.documents;
}
return props.documents?.data || [];
});
// Check if using server-side pagination
const isServerSide = computed(() => {
return !!(props.documents?.links && props.clientCase);
});
const formatSize = (bytes) => {
if (bytes == null) return "-";
const thresh = 1024;
@@ -243,19 +271,27 @@ function closeActions() {
</script>
<template>
<div>
<div class="space-y-4">
<DataTable
:columns="columns"
:rows="documents"
:show-toolbar="false"
:data="documentsData"
:meta="isServerSide ? documents : null"
:route-name="isServerSide ? 'clientCase.show' : null"
:route-params="isServerSide ? { client_case: clientCase.uuid } : {}"
:only-props="isServerSide ? ['documents'] : []"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
page-param-name="documentsPage"
per-page-param-name="documentsPerPage"
:show-pagination="false"
:striped="false"
:show-toolbar="true"
:hoverable="true"
:show-actions="true"
row-key="uuid"
empty-text="Ni dokumentov."
empty-icon="faFileAlt"
>
<template #toolbar-actions>
<slot name="add" />
</template>
<!-- Name column -->
<template #cell-name="{ row }">
<div>
@@ -267,7 +303,12 @@ function closeActions() {
>
{{ row.name }}
</button>
<Badge v-if="row.is_public" variant="secondary" class="bg-green-100 text-green-700 hover:bg-green-200">Public</Badge>
<Badge
v-if="row.is_public"
variant="secondary"
class="bg-green-100 text-green-700 hover:bg-green-200"
>Public</Badge
>
</div>
<!-- Expanded description -->
<div
@@ -297,12 +338,25 @@ function closeActions() {
<!-- Created at column -->
<template #cell-created_at="{ row }">
{{ new Date(row.created_at).toLocaleString() }}
<div class="text-gray-800 font-medium leading-tight">
{{ row.created_by }}
</div>
<div v-if="row.created_at" class="mt-1">
<span
class="inline-block px-2 py-0.5 rounded text-[11px] bg-gray-100 text-gray-600 tracking-wide"
>
{{ fmtDateTime(row.created_at) }}
</span>
</div>
</template>
<!-- Source column -->
<template #cell-source="{ row }">
<Badge variant="secondary" class="bg-purple-100 text-purple-700 hover:bg-purple-200">{{ sourceLabel(row) }}</Badge>
<Badge
variant="secondary"
class="bg-purple-100 text-purple-700 hover:bg-purple-200"
>{{ sourceLabel(row) }}</Badge
>
</template>
<!-- Description column -->
@@ -321,53 +375,29 @@ function closeActions() {
</template>
<!-- Actions column -->
<template #actions="{ row }">
<div @click.stop>
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none transition-colors"
title="Možnosti"
>
<FontAwesomeIcon
:icon="faEllipsisVertical"
class="h-4 w-4 text-gray-700"
/>
</button>
</template>
<template #content>
<div class="py-1">
<button
type="button"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
@click="emit('edit', row)"
v-if="edit"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
<span>Uredi</span>
</button>
<button
type="button"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
@click="handleDownload(row)"
>
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
<span>Prenos</span>
</button>
<button
type="button"
class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 transition-colors"
@click="askDelete(row)"
v-if="edit"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
<template #cell-actions="{ row }">
<TableActions align="right">
<template #default>
<ActionMenuItem
v-if="edit"
:icon="faCircleInfo"
label="Uredi"
@click="emit('edit', row)"
/>
<ActionMenuItem
:icon="faDownload"
label="Prenos"
@click="handleDownload(row)"
/>
<ActionMenuItem
v-if="edit"
:icon="faTrash"
label="Izbriši"
danger
@click="askDelete(row)"
/>
</template>
</TableActions>
</template>
</DataTable>
+167 -221
View File
@@ -1,14 +1,40 @@
<script setup>
import { Link, router } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
import { Input } from "@/Components/ui/input";
import { Button } from "@/Components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationNext,
PaginationPrevious,
} from "@/Components/ui/pagination";
import { Separator } from "@/components/ui/separator";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-vue-next";
import { toInteger } from "lodash";
const props = defineProps({
links: { type: Array, default: () => [] },
from: { type: Number, default: 0 },
to: { type: Number, default: 0 },
total: { type: Number, default: 0 },
perPage: { type: Number, default: 15 },
pageSizeOptions: { type: Array, default: () => [10, 15, 25, 50, 100] },
currentPage: { type: Number, default: 0 },
lastPage: { type: Number, default: 0 },
perPageParam: { type: String, default: "per_page" }, // e.g., 'activities_per_page', 'contracts_per_page'
pageParam: { type: String, default: "page" }, // e.g., 'activities_page', 'contracts_page'
});
const num = props.links?.length || 0;
@@ -53,44 +79,12 @@ const lastLink = computed(() => {
return maxLink;
});
const numericLinks = computed(() => {
if (num < 3 || !props.links || !Array.isArray(props.links)) return [];
return props.links
.slice(1, num - 1)
.filter((l) => l != null)
.map((l) => ({
...l,
page: Number.parseInt(String(l?.label || "").replace(/[^0-9]/g, ""), 10),
}))
.filter((l) => !Number.isNaN(l.page) && l.page != null);
});
const currentPage = computed(() => {
const active = numericLinks.value.find((l) => l?.active);
return active?.page || 1;
});
const lastPage = computed(() => {
if (!numericLinks.value.length) return 1;
const pages = numericLinks.value.map((l) => l?.page).filter(p => p != null);
return pages.length ? Math.max(...pages) : 1;
});
const linkByPage = computed(() => {
const m = new Map();
for (const l of numericLinks.value) {
if (l?.page != null) {
m.set(l.page, l);
}
}
return m;
});
// Generate visible page numbers with ellipsis (similar to DataTableClient)
const visiblePages = computed(() => {
const pages = [];
const total = lastPage.value;
const current = currentPage.value;
const maxVisible = 5; // Match DataTableClient default
const total = props.lastPage;
const current = props.currentPage;
const maxVisible = 5;
if (total <= maxVisible) {
for (let i = 1; i <= total; i++) {
@@ -105,64 +99,55 @@ const visiblePages = computed(() => {
let end = Math.min(total, start + maxVisible - 1);
start = Math.max(1, Math.min(start, end - maxVisible + 1));
// Handle first page
if (start > 1) {
pages.push(1);
if (start > 2) pages.push("...");
}
// Add pages in window
for (let i = start; i <= end; i++) {
pages.push(i);
}
// Handle last page
if (end < total) {
if (end < total - 1) pages.push("...");
pages.push(total);
}
return pages;
});
const gotoInput = ref("");
// Handle scroll on navigation
function handleLinkClick(event) {
// Prevent default scroll behavior
event.preventDefault();
const href = event.currentTarget.getAttribute('href');
if (href) {
router.visit(href, {
preserveScroll: false,
onSuccess: () => {
// Scroll to top of table after navigation completes
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
},
});
}
// Navigate to a specific page using Laravel's pagination links
function navigateToPage(pageNum) {
if (!pageNum || pageNum < 1 || pageNum > props.lastPage) return;
const url = new URL(window.location.href);
url.searchParams.set(props.pageParam, String(pageNum));
router.get(
url.pathname + url.search,
{},
{
preserveState: true,
preserveScroll: true,
replace: true,
}
);
}
function goToPage() {
const raw = String(gotoInput.value || "").trim();
const n = Number(raw);
if (!Number.isFinite(n) || n < 1 || n > lastPage.value) {
if (!Number.isFinite(n) || n < 1 || n > props.lastPage) {
gotoInput.value = "";
return;
}
const targetLink = linkByPage.value.get(Math.floor(n));
if (targetLink?.url) {
router.visit(targetLink.url, {
preserveScroll: false,
onSuccess: () => {
// Scroll to top of table when page changes
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
},
});
} else {
// If link not found, try to construct URL manually
gotoInput.value = "";
}
navigateToPage(n);
gotoInput.value = "";
}
function handleKeyPress(event) {
@@ -170,39 +155,54 @@ function handleKeyPress(event) {
goToPage();
}
}
function handlePerPageChange(value) {
const newPerPage = Number(value);
if (!newPerPage) return;
const url = new URL(window.location.href);
url.searchParams.set(props.perPageParam, newPerPage);
url.searchParams.set(props.pageParam, "1"); // Reset to first page
router.get(
url.pathname + url.search,
{},
{
preserveState: true,
preserveScroll: true,
replace: true,
}
);
}
</script>
<template>
<nav
class="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 sm:px-6"
class="flex flex-wrap items-center justify-between gap-3 px-2 text-sm text-gray-700 sm:px-5"
aria-label="Pagination"
>
<!-- Mobile: Simple prev/next -->
<div class="flex flex-1 justify-between sm:hidden">
<Link
<button
v-if="prevLink?.url"
:href="prevLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
@click="navigateToPage(currentPage - 1)"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Prejšnja
</Link>
</button>
<span
v-else
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-400 cursor-not-allowed opacity-50"
>
Prejšnja
</span>
<Link
<button
v-if="nextLink?.url"
:href="nextLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
@click="navigateToPage(currentPage + 1)"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Naslednja
</Link>
</button>
<span
v-else
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-400 cursor-not-allowed opacity-50"
@@ -213,159 +213,105 @@ function handleKeyPress(event) {
<!-- Desktop: Full pagination -->
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<!-- Page stats -->
<div v-if="total > 0">
<span class="text-sm text-gray-700">
Prikazano: <span class="font-medium">{{ from || 0 }}</span><span class="font-medium">{{ to || 0 }}</span> od
<span class="font-medium">{{ total || 0 }}</span>
</span>
<!-- Page stats with modern badge style -->
<div v-if="total > 0" class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Prikazano</span>
<div
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm font-medium"
>
<span class="text-foreground">{{ from || 0 }}</span>
<span class="text-muted-foreground">-</span>
<span class="text-foreground">{{ to || 0 }}</span>
</div>
<span class="text-sm text-muted-foreground">od</span>
<div
class="inline-flex items-center rounded-md bg-primary/10 px-2.5 py-1 text-sm font-semibold text-primary"
>
{{ total || 0 }}
</div>
</div>
<div v-else>
<span class="text-sm text-gray-700">Ni zadetkov</span>
<div
v-else
class="inline-flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5"
>
<span class="text-sm font-medium text-muted-foreground">Ni zadetkov</span>
</div>
<!-- Pagination controls -->
<div class="flex items-center gap-1">
<!-- First -->
<Link
v-if="firstLink?.url && currentPage > 1"
:href="firstLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Prva stran"
>
««
</Link>
<span
v-else
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
aria-label="Prva stran"
>
««
</span>
<Pagination
v-slot="{ page }"
:total="total"
:items-per-page="perPage"
:sibling-count="1"
show-edges
:default-page="currentPage"
:page="currentPage"
>
<PaginationContent>
<!-- First -->
<PaginationFirst :disabled="currentPage <= 1" @click="navigateToPage(1)">
<ChevronsLeft />
</PaginationFirst>
<!-- Prev -->
<Link
v-if="prevLink?.url"
:href="prevLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Prejšnja stran"
>
«
</Link>
<span
v-else
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
aria-label="Prejšnja stran"
>
«
</span>
<!-- Leading ellipsis / first page when window doesn't include 1 -->
<Link
v-if="visiblePages[0] > 1"
:href="firstLink?.url || '#'"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-3 py-1 rounded border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-colors"
>
1
</Link>
<span v-if="visiblePages[0] > 2" class="px-1 text-gray-700">…</span>
<!-- Page numbers -->
<template v-for="p in visiblePages" :key="p">
<Link
v-if="linkByPage.get(p)?.url"
:href="linkByPage.get(p).url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-3 py-1 rounded border transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
:class="
p === currentPage
? 'border-primary-600 bg-primary-600 text-white'
: 'border-gray-300 bg-white text-gray-900 hover:bg-gray-50'
"
:aria-current="p === currentPage ? 'page' : undefined"
<!-- Previous -->
<PaginationPrevious
:disabled="currentPage <= 1"
@click="navigateToPage(currentPage - 1)"
>
{{ p }}
</Link>
<span
v-else
class="px-3 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
<ChevronLeft />
</PaginationPrevious>
<!-- Page numbers -->
<template v-for="(item, index) in visiblePages" :key="index">
<PaginationEllipsis v-if="item === '...'" />
<PaginationItem
v-else
:value="item"
:is-active="currentPage === item"
@click="navigateToPage(item)"
>
{{ item }}
</PaginationItem>
</template>
<!-- Next -->
<PaginationNext
:disabled="currentPage >= lastPage"
@click="navigateToPage(currentPage + 1)"
>
{{ p }}
</span>
</template>
<ChevronRight />
</PaginationNext>
<!-- Trailing ellipsis / last page when window doesn't include last -->
<span v-if="visiblePages[visiblePages.length - 1] < lastPage - 1" class="px-1 text-gray-700"></span>
<Link
v-if="visiblePages[visiblePages.length - 1] < lastPage && lastLink?.url"
:href="lastLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-3 py-1 rounded border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-colors"
>
{{ lastPage }}
</Link>
<!-- Last -->
<PaginationLast
:disabled="currentPage >= lastPage"
@click="navigateToPage(lastPage)"
>
<ChevronsRight />
</PaginationLast>
</PaginationContent>
</Pagination>
<!-- Next -->
<Link
v-if="nextLink?.url"
:href="nextLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Naslednja stran"
<!-- Goto page input -->
<div class="flex items-center gap-3">
<!-- Go to page -->
<div
class="inline-flex items-center gap-2 rounded-md border border-input bg-background px-2 h-8"
>
»
</Link>
<span
v-else
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
aria-label="Naslednja stran"
>
»
</span>
<!-- Last -->
<Link
v-if="lastLink?.url && currentPage < lastPage"
:href="lastLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Zadnja stran"
>
»»
</Link>
<span
v-else
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
aria-label="Zadnja stran"
>
»»
</span>
<!-- Goto page input -->
<div class="ms-2 flex items-center gap-1">
<Input
<input
v-model="gotoInput"
type="number"
min="1"
:max="lastPage"
inputmode="numeric"
class="w-16 text-sm"
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="String(currentPage)"
aria-label="Pojdi na stran"
@keyup.enter="goToPage"
@blur="goToPage"
/>
<span class="text-sm text-gray-500">/ {{ lastPage }}</span>
<Separator orientation="vertical" class="h-full" />
<span class="text-sm text-muted-foreground">{{ lastPage }}</span>
</div>
</div>
</div>
@@ -1,65 +1,64 @@
<script setup>
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button";
import { EllipsisVertical } from "lucide-vue-next";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const emit = defineEmits(["add", "edit", "delete"]);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
const handleAdd = () => emit("add");
const handleEdit = (id) => emit("edit", id);
const handleDelete = (id, label) => emit("delete", id, label);
</script>
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="address in person.addresses"
:key="address.id"
>
<div class="flex items-start justify-between mb-2">
<Card class="p-2" v-for="address in person.addresses" :key="address.id">
<div class="flex items-center justify-between mb-2">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
>
{{ address.country }}
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
{{ address.type.name }}
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Možnosti">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="handleEdit(address.id)">
<EditIcon size="sm" />
<span>Uredi</span>
</DropdownMenuItem>
<DropdownMenuItem
@click="handleDelete(address.id, address.address)"
class="text-red-600 focus:bg-red-50 focus:text-red-600"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(address.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(address.id, address.address)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
<TrashBinIcon size="sm" class="text-red-600" />
<span>Izbriši</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">
@@ -69,15 +68,14 @@ const handleDelete = (id, label) => emit('delete', id, label);
: address.address
}}
</p>
</div>
</Card>
<button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center h-full"
title="Dodaj naslov"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
</div>
</template>
@@ -1,30 +1,34 @@
<script setup>
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button";
import { EllipsisVertical } from "lucide-vue-next";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const emit = defineEmits(["add", "edit", "delete"]);
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
const handleAdd = () => emit("add");
const handleEdit = (id) => emit("edit", id);
const handleDelete = (id, label) => emit("delete", id, label);
</script>
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getEmails(person).length">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="(email, idx) in getEmails(person)"
:key="idx"
>
<div class="flex items-start justify-between mb-2" v-if="edit">
<Card class="p-2" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="flex items-center justify-between mb-2" v-if="edit">
<div class="flex flex-wrap gap-2">
<span
v-if="email?.label"
@@ -40,56 +44,54 @@ const handleDelete = (id, label) => emit('delete', id, label);
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Možnosti">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="handleEdit(email.id)">
<EditIcon size="sm" />
<span>Uredi</span>
</DropdownMenuItem>
<DropdownMenuItem
@click="
handleDelete(email.id, email?.value || email?.email || email?.address)
"
class="text-red-600 focus:bg-red-50 focus:text-red-600"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(email.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(email.id, email?.value || email?.email || email?.address)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
<TrashBinIcon size="sm" class="text-red-600" />
<span>Izbriši</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">
{{ email?.value || email?.email || email?.address || "-" }}
</p>
<p v-if="email?.note" class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
<p
v-if="email?.note"
class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed"
>
{{ email.note }}
</p>
</div>
</Card>
</template>
<button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center h-full"
title="Dodaj email"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
<p v-else-if="!edit && !getEmails(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
<p
v-else-if="!edit && !getEmails(person).length"
class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"
>
Ni e-poštnih naslovov.
</p>
</div>
</template>
@@ -4,6 +4,8 @@ import { router } from "@inertiajs/vue3";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import { Button } from "@/Components/ui/button";
import { PlusIcon } from "@/Utilities/Icons";
import { faUser, faMapMarkerAlt, faPhone, faEnvelope, faUniversity } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import PersonUpdateForm from "./PersonUpdateForm.vue";
import AddressCreateForm from "./AddressCreateForm.vue";
import AddressUpdateForm from "./AddressUpdateForm.vue";
@@ -298,13 +300,21 @@ const switchToTab = (tab) => {
<template>
<Tabs v-model="activeTab" class="mt-2">
<TabsList class="flex w-full bg-white gap-2 p-1">
<TabsTrigger value="person" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2">Oseba</TabsTrigger>
<TabsTrigger value="person" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2">
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faUser" class="h-4 w-4" />
<span>Oseba</span>
</div>
</TabsTrigger>
<TabsTrigger value="addresses" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Naslovi</span>
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faMapMarkerAlt" class="h-4 w-4" />
<span>Naslovi</span>
</div>
<span
v-if="addressesCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(addressesCount) }}
</span>
@@ -312,10 +322,13 @@ const switchToTab = (tab) => {
</TabsTrigger>
<TabsTrigger value="phones" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Telefonske</span>
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faPhone" class="h-4 w-4" />
<span>Telefonske</span>
</div>
<span
v-if="phonesCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(phonesCount) }}
</span>
@@ -323,10 +336,13 @@ const switchToTab = (tab) => {
</TabsTrigger>
<TabsTrigger value="emails" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Email</span>
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faEnvelope" class="h-4 w-4" />
<span>Email</span>
</div>
<span
v-if="emailsCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(emailsCount) }}
</span>
@@ -334,10 +350,13 @@ const switchToTab = (tab) => {
</TabsTrigger>
<TabsTrigger value="trr" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>TRR</span>
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="faUniversity" class="h-4 w-4" />
<span>TRR</span>
</div>
<span
v-if="trrsCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(trrsCount) }}
</span>
@@ -1,5 +1,6 @@
<script setup>
import { UserEditIcon } from "@/Utilities/Icons";
import { Button } from "../ui/button";
const props = defineProps({
person: Object,
@@ -35,7 +36,7 @@ const handleEdit = () => {
<template>
<div class="flex justify-end mb-3">
<button
<Button
v-if="edit && personEdit"
@click="handleEdit"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
@@ -1,6 +1,15 @@
<script setup>
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button";
import { EllipsisVertical, MessageSquare, MessageSquareText } from "lucide-vue-next";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const props = defineProps({
person: Object,
@@ -8,88 +17,84 @@ const props = defineProps({
enableSms: { type: Boolean, default: false },
});
const emit = defineEmits(['add', 'edit', 'delete', 'sms']);
const emit = defineEmits(["add", "edit", "delete", "sms"]);
const getPhones = (p) => (Array.isArray(p?.phones) ? p.phones : []);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
const handleSms = (phone) => emit('sms', phone);
const handleAdd = () => emit("add");
const handleEdit = (id) => emit("edit", id);
const handleDelete = (id, label) => emit("delete", id, label);
const handleSms = (phone) => emit("sms", phone);
</script>
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getPhones(person).length">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="phone in getPhones(person)"
:key="phone.id"
>
<div class="flex items-start justify-between mb-2">
<Card class="p-2" v-for="phone in getPhones(person)" :key="phone.id">
<div class="flex items-center justify-between mb-2">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
>
+{{ phone.country_code }}
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
{{ phone && phone.type && phone.type.name ? phone.type.name : "—" }}
</span>
</div>
<div class="flex items-center gap-1">
<!-- Send SMS only in ClientCase person context -->
<button
<Button
v-if="enableSms"
@click="handleSms(phone)"
title="Pošlji SMS"
class="px-2.5 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 hover:bg-indigo-100 rounded-lg transition-colors"
size="icon"
variant="ghost"
>
SMS
</button>
<Dropdown v-if="edit" align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
<MessageSquare />
</Button>
<DropdownMenu v-if="edit">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Možnosti">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="handleEdit(phone.id)">
<EditIcon size="sm" />
<span>Uredi</span>
</DropdownMenuItem>
<DropdownMenuItem
@click="handleDelete(phone.id, phone.nu)"
class="text-red-600 focus:bg-red-50 focus:text-red-600"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(phone.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(phone.id, phone.nu)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
<TrashBinIcon size="sm" class="text-red-600" />
<span>Izbriši</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">{{ phone.nu }}</p>
</div>
</Card>
</template>
<button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center h-full"
title="Dodaj telefon"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
<p v-else-if="!edit && !getPhones(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
<p
v-else-if="!edit && !getPhones(person).length"
class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"
>
Ni telefonov.
</p>
</div>
</template>
@@ -245,7 +245,9 @@ watch(
);
// Auto-select sender when profile changes
watch(form.values.profile_id, (profileId) => {
watch(
() => form.values.profile_id,
(profileId) => {
if (!profileId) {
form.setFieldValue("sender_id", null);
return;
@@ -268,7 +270,8 @@ watch(form.values.profile_id, (profileId) => {
} else {
form.setFieldValue("sender_id", null);
}
});
}
);
// Reset sender if not available for selected profile
watch(sendersForSelectedProfile, (list) => {
@@ -355,15 +358,21 @@ const updateSmsFromSelection = async () => {
}
};
watch(form.values.template_id, () => {
watch(
() => form.values.template_id,
() => {
if (!form.values.template_id) return;
updateSmsFromSelection();
});
}
);
watch(form.values.contract_uuid, () => {
watch(
() => form.values.contract_uuid,
() => {
if (!form.values.template_id) return;
updateSmsFromSelection();
});
}
);
watch(pageSmsTemplates, (list) => {
if (!Array.isArray(list)) return;
@@ -1,13 +1,21 @@
<script setup>
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
import { EllipsisVertical } from "lucide-vue-next";
import { Button } from "../ui/button";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const emit = defineEmits(["add", "edit", "delete"]);
const getTRRs = (p) => {
if (Array.isArray(p?.trrs)) return p.trrs;
@@ -17,20 +25,16 @@ const getTRRs = (p) => {
return [];
};
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
const handleAdd = () => emit("add");
const handleEdit = (id) => emit("edit", id);
const handleDelete = (id, label) => emit("delete", id, label);
</script>
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getTRRs(person).length">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="(acc, idx) in getTRRs(person)"
:key="idx"
>
<div class="flex items-start justify-between mb-2" v-if="edit">
<Card class="p-2" v-for="(acc, idx) in getTRRs(person)" :key="idx">
<div class="flex items-center justify-between mb-2" v-if="edit">
<div class="flex flex-wrap gap-2">
<span
v-if="acc?.bank_name"
@@ -52,35 +56,26 @@ const handleDelete = (id, label) => emit('delete', id, label);
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Možnosti">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="handleEdit(acc.id)">
<EditIcon size="sm" />
<span>Uredi</span>
</DropdownMenuItem>
<DropdownMenuItem
@click="handleDelete(acc.id, acc?.iban || acc?.account_number)"
class="text-red-600 focus:bg-red-50 focus:text-red-600"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(acc.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(acc.id, acc?.iban || acc?.account_number)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
<TrashBinIcon size="sm" class="text-red-600" />
<span>Izbriši</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed font-mono">
@@ -93,22 +88,27 @@ const handleDelete = (id, label) => emit('delete', id, label);
"-"
}}
</p>
<p v-if="acc?.notes" class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
<p
v-if="acc?.notes"
class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed"
>
{{ acc.notes }}
</p>
</div>
</Card>
</template>
<button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center h-full"
title="Dodaj TRR"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
<p v-else-if="!edit && !getTRRs(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
<p
v-else-if="!edit && !getTRRs(person).length"
class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"
>
Ni TRR računov.
</p>
</div>
</template>
+4 -5
View File
@@ -3,18 +3,17 @@ import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
+17
View File
@@ -0,0 +1,17 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
:class="
cn('rounded-xl border bg-card text-card-foreground shadow', props.class)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<h3 :class="cn('font-semibold leading-none tracking-tight', props.class)">
<slot />
</h3>
</template>
+6
View File
@@ -0,0 +1,6 @@
export { default as Card } from "./Card.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";
@@ -0,0 +1,109 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui";
import { reactive, ref, watch } from "vue";
import { cn } from "@/lib/utils";
import { provideCommandContext } from ".";
const props = defineProps({
modelValue: { type: null, required: false, default: "" },
defaultValue: { type: null, required: false },
multiple: { type: Boolean, required: false },
orientation: { type: String, required: false },
dir: { type: String, required: false },
disabled: { type: Boolean, required: false },
selectionBehavior: { type: String, required: false },
highlightOnHover: { type: Boolean, required: false },
by: { type: [String, Function], required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"update:modelValue",
"highlight",
"entryFocus",
"leave",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const allItems = ref(new Map());
const allGroups = ref(new Map());
const { contains } = useFilter({ sensitivity: "base" });
const filterState = reactive({
search: "",
filtered: {
/** The count of all visible items. */
count: 0,
/** Map from visible item id to its search score. */
items: new Map(),
/** Set of groups with at least one visible item. */
groups: new Set(),
},
});
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size;
// Do nothing, each item will know to show itself because search is empty
return;
}
// Reset the groups
filterState.filtered.groups = new Set();
let itemCount = 0;
// Check which items should be included
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search);
filterState.filtered.items.set(id, score ? 1 : 0);
if (score) itemCount++;
}
// Check which groups have at least 1 item shown
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId) > 0) {
filterState.filtered.groups.add(groupId);
break;
}
}
}
filterState.filtered.count = itemCount;
}
watch(
() => filterState.search,
() => {
filterItems();
},
);
provideCommandContext({
allItems,
allGroups,
filterState,
});
</script>
<template>
<ListboxRoot
v-bind="forwarded"
:class="
cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
props.class,
)
"
>
<slot />
</ListboxRoot>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { useForwardPropsEmits } from "reka-ui";
import { Dialog, DialogContent } from '@/components/ui/dialog';
import Command from "./Command.vue";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
modal: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<Dialog v-bind="forwarded">
<DialogContent class="overflow-hidden p-0 shadow-lg">
<Command
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
<slot />
</Command>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Primitive } from "reka-ui";
import { computed } from "vue";
import { cn } from "@/lib/utils";
import { useCommand } from ".";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const { filterState } = useCommand();
const isRender = computed(
() => !!filterState.search && filterState.filtered.count === 0,
);
</script>
<template>
<Primitive
v-if="isRender"
v-bind="delegatedProps"
:class="cn('py-6 text-center text-sm', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,53 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui";
import { computed, onMounted, onUnmounted } from "vue";
import { cn } from "@/lib/utils";
import { provideCommandGroupContext, useCommand } from ".";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
heading: { type: String, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const { allGroups, filterState } = useCommand();
const id = useId();
const isRender = computed(() =>
!filterState.search ? true : filterState.filtered.groups.has(id),
);
provideCommandGroupContext({ id });
onMounted(() => {
if (!allGroups.value.has(id)) allGroups.value.set(id, new Set());
});
onUnmounted(() => {
allGroups.value.delete(id);
});
</script>
<template>
<ListboxGroup
v-bind="delegatedProps"
:id="id"
:class="
cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
props.class,
)
"
:hidden="isRender ? undefined : true"
>
<ListboxGroupLabel
v-if="heading"
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Search } from "lucide-vue-next";
import { ListboxFilter, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { useCommand } from ".";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: { type: String, required: false },
autoFocus: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
const { filterState } = useCommand();
</script>
<template>
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
auto-focus
:class="
cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
/>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { reactiveOmit, useCurrentElement } from "@vueuse/core";
import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { cn } from "@/lib/utils";
import { useCommand, useCommandGroup } from ".";
const props = defineProps({
value: { type: null, required: true },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["select"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const id = useId();
const { filterState, allItems, allGroups } = useCommand();
const groupContext = useCommandGroup();
const isRender = computed(() => {
if (!filterState.search) {
return true;
} else {
const filteredCurrentItem = filterState.filtered.items.get(id);
// If the filtered items is undefined means not in the all times map yet
// Do the first render to add into the map
if (filteredCurrentItem === undefined) {
return true;
}
// Check with filter
return filteredCurrentItem > 0;
}
});
const itemRef = ref();
const currentElement = useCurrentElement(itemRef);
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement)) return;
// textValue to perform filter
allItems.value.set(
id,
currentElement.value.textContent ?? props?.value.toString(),
);
const groupId = groupContext?.id;
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]));
} else {
allGroups.value.get(groupId)?.add(id);
}
}
});
onUnmounted(() => {
allItems.value.delete(id);
});
</script>
<template>
<ListboxItem
v-if="isRender"
v-bind="forwarded"
:id="id"
ref="itemRef"
:class="
cn(
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
props.class,
)
"
@select="
() => {
filterState.search = '';
}
"
>
<slot />
</ListboxItem>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ListboxContent, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ListboxContent
v-bind="forwarded"
:class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)"
>
<div role="presentation">
<slot />
</div>
</ListboxContent>
</template>
@@ -0,0 +1,24 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Separator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
orientation: { type: String, required: false },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Separator
v-bind="delegatedProps"
:class="cn('-mx-1 h-px bg-border', props.class)"
>
<slot />
</Separator>
</template>
@@ -0,0 +1,17 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<span
:class="
cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)
"
>
<slot />
</span>
</template>
@@ -0,0 +1,16 @@
import { createContext } from "reka-ui";
export { default as Command } from "./Command.vue";
export { default as CommandDialog } from "./CommandDialog.vue";
export { default as CommandEmpty } from "./CommandEmpty.vue";
export { default as CommandGroup } from "./CommandGroup.vue";
export { default as CommandInput } from "./CommandInput.vue";
export { default as CommandItem } from "./CommandItem.vue";
export { default as CommandList } from "./CommandList.vue";
export { default as CommandSeparator } from "./CommandSeparator.vue";
export { default as CommandShortcut } from "./CommandShortcut.vue";
export const [useCommand, provideCommandContext] = createContext("Command");
export const [useCommandGroup, provideCommandGroupContext] =
createContext("CommandGroup");
+2 -2
View File
@@ -12,7 +12,7 @@ const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
<DialogRoot v-slot="slotProps" data-slot="dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DialogOverlay } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
props.class,
)
"
>
<slot />
</DialogOverlay>
</template>
@@ -8,7 +8,7 @@ const props = defineProps({
</script>
<template>
<DialogTrigger v-bind="props">
<DialogTrigger v-bind="props" class="cursor-pointer">
<slot />
</DialogTrigger>
</template>
@@ -22,7 +22,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
'relative flex cursor-pointer select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
props.class,
)
@@ -11,7 +11,7 @@ const forwardedProps = useForwardProps(props);
</script>
<template>
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
<DropdownMenuTrigger class="outline-none cursor-pointer" v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>
@@ -0,0 +1,33 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="input-group"
role="group"
:class="
cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,32 @@
<script setup>
import { cn } from "@/lib/utils";
import { inputGroupAddonVariants } from ".";
const props = defineProps({
align: { type: null, required: false, default: "inline-start" },
class: { type: null, required: false },
});
function handleInputGroupAddonClick(e) {
const currentTarget = e.currentTarget;
const target = e.target;
if (target && target.closest("button")) {
return;
}
if (currentTarget && currentTarget?.parentElement) {
currentTarget.parentElement?.querySelector("input")?.focus();
}
}
</script>
<template>
<div
role="group"
data-slot="input-group-addon"
:data-align="props.align"
:class="cn(inputGroupAddonVariants({ align: props.align }), props.class)"
@click="handleInputGroupAddonClick"
>
<slot />
</div>
</template>
+4 -1
View File
@@ -19,9 +19,12 @@ const modelValue = useVModel(props, "modelValue", emits, {
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
@@ -0,0 +1,33 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { PaginationRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
page: { type: Number, required: false },
defaultPage: { type: Number, required: false },
itemsPerPage: { type: Number, required: true },
total: { type: Number, required: false },
siblingCount: { type: Number, required: false },
disabled: { type: Boolean, required: false },
showEdges: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:page"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<PaginationRoot
v-slot="slotProps"
data-slot="pagination"
v-bind="forwarded"
:class="cn('mx-auto flex w-full justify-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationRoot>
</template>
@@ -0,0 +1,24 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { PaginationList } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<PaginationList
v-slot="slotProps"
data-slot="pagination-content"
v-bind="delegatedProps"
:class="cn('flex flex-row items-center gap-1', props.class)"
>
<slot v-bind="slotProps" />
</PaginationList>
</template>
@@ -0,0 +1,27 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { MoreHorizontal } from "lucide-vue-next";
import { PaginationEllipsis } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<PaginationEllipsis
data-slot="pagination-ellipsis"
v-bind="delegatedProps"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
<span class="sr-only">More pages</span>
</slot>
</PaginationEllipsis>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronLeftIcon } from "lucide-vue-next";
import { PaginationFirst, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "default" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<PaginationFirst
data-slot="pagination-first"
:class="
cn(
buttonVariants({ variant: 'ghost', size }),
'gap-1 px-2.5 sm:pr-2.5',
props.class,
)
"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">First</span>
</slot>
</PaginationFirst>
</template>
@@ -0,0 +1,35 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { PaginationListItem } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
value: { type: Number, required: true },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "icon" },
class: { type: null, required: false },
isActive: { type: Boolean, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size", "isActive");
</script>
<template>
<PaginationListItem
data-slot="pagination-item"
v-bind="delegatedProps"
:class="
cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
props.class,
)
"
>
<slot />
</PaginationListItem>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronRightIcon } from "lucide-vue-next";
import { PaginationLast, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "default" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<PaginationLast
data-slot="pagination-last"
:class="
cn(
buttonVariants({ variant: 'ghost', size }),
'gap-1 px-2.5 sm:pr-2.5',
props.class,
)
"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Last</span>
<ChevronRightIcon />
</slot>
</PaginationLast>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronRightIcon } from "lucide-vue-next";
import { PaginationNext, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "default" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<PaginationNext
data-slot="pagination-next"
:class="
cn(
buttonVariants({ variant: 'ghost', size }),
'gap-1 px-2.5 sm:pr-2.5',
props.class,
)
"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Next</span>
<ChevronRightIcon />
</slot>
</PaginationNext>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronLeftIcon } from "lucide-vue-next";
import { PaginationPrev, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
size: { type: null, required: false, default: "default" },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<PaginationPrev
data-slot="pagination-previous"
:class="
cn(
buttonVariants({ variant: 'ghost', size }),
'gap-1 px-2.5 sm:pr-2.5',
props.class,
)
"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">Previous</span>
</slot>
</PaginationPrev>
</template>
@@ -0,0 +1,8 @@
export { default as Pagination } from "./Pagination.vue";
export { default as PaginationContent } from "./PaginationContent.vue";
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue";
export { default as PaginationFirst } from "./PaginationFirst.vue";
export { default as PaginationItem } from "./PaginationItem.vue";
export { default as PaginationLast } from "./PaginationLast.vue";
export { default as PaginationNext } from "./PaginationNext.vue";
export { default as PaginationPrevious } from "./PaginationPrevious.vue";
@@ -16,11 +16,11 @@ const delegatedProps = reactiveOmit(props, "class");
<template>
<Separator
data-slot="separator"
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
props.class,
)
"
@@ -21,7 +21,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
props.class,
)
"
@@ -19,9 +19,10 @@ const modelValue = useVModel(props, "modelValue", emits, {
<template>
<textarea
v-model="modelValue"
data-slot="textarea"
:class="
cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
props.class,
)
"
@@ -0,0 +1,22 @@
<script setup>
import { TooltipRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
defaultOpen: { type: Boolean, required: false },
open: { type: Boolean, required: false },
delayDuration: { type: Number, required: false },
disableHoverableContent: { type: Boolean, required: false },
disableClosingTrigger: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
ignoreNonKeyboardFocus: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<TooltipRoot v-bind="forwarded">
<slot />
</TooltipRoot>
</template>
@@ -0,0 +1,51 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
ariaLabel: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
side: { type: null, required: false },
sideOffset: { type: Number, required: false, default: 4 },
align: { type: null, required: false },
alignOffset: { type: Number, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
updatePositionStrategy: { type: String, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["escapeKeyDown", "pointerDownOutside"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<TooltipPortal>
<TooltipContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class,
)
"
>
<slot />
</TooltipContent>
</TooltipPortal>
</template>
@@ -0,0 +1,18 @@
<script setup>
import { TooltipProvider } from "reka-ui";
const props = defineProps({
delayDuration: { type: Number, required: false },
skipDelayDuration: { type: Number, required: false },
disableHoverableContent: { type: Boolean, required: false },
disableClosingTrigger: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
ignoreNonKeyboardFocus: { type: Boolean, required: false },
});
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>
@@ -0,0 +1,15 @@
<script setup>
import { TooltipTrigger } from "reka-ui";
const props = defineProps({
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<TooltipTrigger v-bind="props">
<slot />
</TooltipTrigger>
</template>
@@ -0,0 +1,4 @@
export { default as Tooltip } from "./Tooltip.vue";
export { default as TooltipContent } from "./TooltipContent.vue";
export { default as TooltipProvider } from "./TooltipProvider.vue";
export { default as TooltipTrigger } from "./TooltipTrigger.vue";