add pagination to client and project table

This commit is contained in:
Gregor Vostrak
2026-06-28 17:39:54 +02:00
parent 981bcbe091
commit 2207f676ee
8 changed files with 488 additions and 77 deletions

View File

@@ -2,11 +2,12 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { UserCircleIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { type Component, computed, ref } from 'vue';
import { type Component, computed, ref, watch } from 'vue';
import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import {
@@ -100,6 +101,19 @@ const table = useVueTable({
const sortedClients = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15;
const currentPage = ref(1);
watch([() => props.sortColumn, () => props.sortDirection, () => props.clients], () => {
currentPage.value = 1;
});
const paginatedClients = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return sortedClients.value.slice(start, start + PAGE_SIZE);
});
</script>
<template>
@@ -126,10 +140,14 @@ const sortedClients = computed(() => {
>Create your First Client
</SecondaryButton>
</div>
<template v-for="client in sortedClients" :key="client.id">
<template v-for="client in paginatedClients" :key="client.id">
<ClientTableRow :client="client"></ClientTableRow>
</template>
</div>
</div>
</div>
<Pagination
v-model:page="currentPage"
:total="sortedClients.length"
:items-per-page="PAGE_SIZE"></Pagination>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import {
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'radix-vue';
import {
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
EllipsisHorizontalIcon,
} from '@heroicons/vue/20/solid';
import { buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { computed, watch } from 'vue';
const page = defineModel<number>('page', { default: 1 });
const props = withDefaults(
defineProps<{
total: number;
itemsPerPage?: number;
siblingCount?: number;
showEdges?: boolean;
}>(),
{
itemsPerPage: 15,
siblingCount: 1,
showEdges: true,
}
);
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.itemsPerPage)));
watch(page, (value) => {
if (value > pageCount.value) {
page.value = pageCount.value;
}
});
watch(pageCount, (value) => {
if (page.value > value) {
page.value = value;
}
});
// The shared buttonVariants ghost/outline hover is `bg-white/5`, which is invisible in light
// mode. Override it with a theme-aware hover that shows in both light and dark mode.
const hoverClass = 'hover:bg-black/5 dark:hover:bg-white/5';
const navButtonClass = cn(buttonVariants({ variant: 'ghost', size: 'icon' }), hoverClass);
function pageButtonClass(isActive: boolean): string {
return cn(
buttonVariants({ variant: isActive ? 'outline' : 'ghost', size: 'icon' }),
hoverClass
);
}
</script>
<template>
<PaginationRoot
v-if="pageCount > 1"
v-model:page="page"
:total="props.total"
:items-per-page="props.itemsPerPage"
:sibling-count="props.siblingCount"
:show-edges="props.showEdges"
class="mx-auto flex w-full justify-center py-8">
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst :class="navButtonClass">
<ChevronDoubleLeftIcon class="size-4" />
</PaginationFirst>
<PaginationPrev :class="navButtonClass">
<ChevronLeftIcon class="size-4" />
</PaginationPrev>
<template v-for="(item, index) in items" :key="index">
<PaginationListItem
v-if="item.type === 'page'"
:value="item.value"
:class="pageButtonClass(item.value === page)">
{{ item.value }}
</PaginationListItem>
<PaginationEllipsis
v-else
:index="index"
class="flex size-9 items-center justify-center text-text-tertiary">
<EllipsisHorizontalIcon class="size-4" />
</PaginationEllipsis>
</template>
<PaginationNext :class="navButtonClass">
<ChevronRightIcon class="size-4" />
</PaginationNext>
<PaginationLast :class="navButtonClass">
<ChevronDoubleRightIcon class="size-4" />
</PaginationLast>
</PaginationList>
</PaginationRoot>
</template>

View File

@@ -2,10 +2,11 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue';
export type SortColumn =
| 'name'
@@ -143,6 +144,19 @@ const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15;
const currentPage = ref(1);
watch([() => props.sortColumn, () => props.sortDirection, () => props.projects], () => {
currentPage.value = 1;
});
const paginatedProjects = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return sortedProjects.value.slice(start, start + PAGE_SIZE);
});
const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -199,7 +213,7 @@ const gridTemplate = computed(() => {
>Create your First Project
</SecondaryButton>
</div>
<template v-for="project in sortedProjects" :key="project.id">
<template v-for="project in paginatedProjects" :key="project.id">
<ProjectTableRow
:show-billable-rate="props.showBillableRate"
:project="project"></ProjectTableRow>
@@ -207,4 +221,8 @@ const gridTemplate = computed(() => {
</div>
</div>
</div>
<Pagination
v-model:page="currentPage"
:total="sortedProjects.length"
:items-per-page="PAGE_SIZE"></Pagination>
</template>

View File

@@ -4,15 +4,12 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import {
ChartBarIcon,
ChevronLeftIcon,
ChevronDoubleLeftIcon,
ChevronRightIcon,
ChevronDoubleRightIcon,
ClockIcon,
EllipsisVerticalIcon,
ArrowDownTrayIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import Pagination from '@/Components/Common/Pagination.vue';
import {
DropdownMenu,
DropdownMenuContent,
@@ -43,16 +40,6 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useMembersQuery } from '@/utils/useMembersQuery';
import {
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'radix-vue';
import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
@@ -414,62 +401,6 @@ async function downloadExport(format: ExportFormat) {
</div>
</div>
<PaginationRoot
v-model:page="currentPage"
:total="totalPages"
:items-per-page="pageLimit"
class="flex justify-center items-center py-8"
:sibling-count="1"
show-edges>
<PaginationList v-slot="{ items }" class="flex items-center space-x-1 relative">
<div class="pr-2 flex items-center space-x-1 border-r border-border-primary mr-1">
<PaginationFirst class="navigation-item">
<ChevronDoubleLeftIcon class="w-4"> </ChevronDoubleLeftIcon>
</PaginationFirst>
<PaginationPrev class="mr-4 navigation-item">
<ChevronLeftIcon class="w-4 text-text-tertiary hover:text-text-primary">
</ChevronLeftIcon>
</PaginationPrev>
</div>
<template v-for="(page, index) in items">
<PaginationListItem
v-if="page.type === 'page'"
:key="index"
class="pagination-item"
:value="page.value">
{{ page.value }}
</PaginationListItem>
<PaginationEllipsis
v-else
:key="page.type"
:index="index"
class="PaginationEllipsis">
<div class="px-2">&#8230;</div>
</PaginationEllipsis>
</template>
<div class="!ml-2 pl-2 flex items-center space-x-1 border-l border-border-primary">
<PaginationNext class="navigation-item">
<ChevronRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronRightIcon>
</PaginationNext>
<PaginationLast class="navigation-item">
<ChevronDoubleRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronDoubleRightIcon>
</PaginationLast>
</div>
</PaginationList>
</PaginationRoot>
<Pagination v-model:page="currentPage" :total="totalPages" :items-per-page="pageLimit" />
</AppLayout>
</template>
<style lang="postcss">
.navigation-item {
@apply bg-quaternary h-8 w-8 flex items-center justify-center rounded border border-border-primary text-text-tertiary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
}
.pagination-item {
@apply bg-secondary h-8 w-8 flex items-center justify-center rounded border border-border-tertiary text-text-secondary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
}
.pagination-item[data-selected] {
@apply text-text-primary bg-accent-300/10 border border-accent-300/20 rounded-md font-medium hover:bg-accent-300/20 active:bg-accent-300/20 outline-0 focus-visible:ring-2 focus:ring-ring transition ease-in-out duration-150;
}
</style>

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/vue-query';
import { useQuery, keepPreviousData } from '@tanstack/vue-query';
import { api, type TimeEntryResponse } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type Ref, type ComputedRef, unref } from 'vue';
@@ -21,6 +21,9 @@ export function useTimeEntriesReportQuery(
},
queries: { ...unref(filterParams) },
}),
// Keep the previous page's data (incl. meta.total) while the next page loads, so
// pagination doesn't transiently see total=1 and clamp the page back to 1.
placeholderData: keepPreviousData,
staleTime: 1000 * 30, // 30 seconds
});
}