mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add pagination to client and project table
This commit is contained in:
@@ -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>
|
||||
|
||||
104
resources/js/Components/Common/Pagination.vue
Normal file
104
resources/js/Components/Common/Pagination.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">…</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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user