feat(ticket): preserve list state across detail navigation

This commit is contained in:
2026-08-07 17:56:55 +05:30
parent 3697119656
commit 98de8e948b
4 changed files with 110 additions and 18 deletions

View File

@@ -11,7 +11,7 @@ const ModulesLayout = ({
}>) => { }>) => {
return ( return (
<AuthGuard> <AuthGuard>
<SidebarProvider> <SidebarProvider defaultOpen={false}>
<AppSidebar /> <AppSidebar />
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"> <main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
<div className="scroll-stable min-w-0 flex-1 overflow-auto"> <div className="scroll-stable min-w-0 flex-1 overflow-auto">

View File

@@ -14,8 +14,10 @@ import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({ export function TicketDetailHeader({
ticket, ticket,
backHref,
}: { }: {
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
backHref: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const ticketName = ticket.ticket_name || 'Unnamed ticket'; const ticketName = ticket.ticket_name || 'Unnamed ticket';
@@ -27,7 +29,7 @@ export function TicketDetailHeader({
type="button" type="button"
variant="secondary" variant="secondary"
size="icon" size="icon"
onClick={() => router.push('/ticket')} onClick={() => router.push(backHref)}
aria-label="Back to ticket list" aria-label="Back to ticket list"
className="shrink-0" className="shrink-0"
> >

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
import { ROUTES } from '@/utils/routes';
import { TicketDetailHeader } from './components/TicketDetailHeader'; import { TicketDetailHeader } from './components/TicketDetailHeader';
import { import {
@@ -26,8 +27,20 @@ import {
export default function TicketDetailPage() { export default function TicketDetailPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string }; const { ticketId } = useParams() as { ticketId: string };
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>(); const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
const listParams = new URLSearchParams();
for (const key of ['page', 'limit', 'segment']) {
const value = searchParams.get(key);
if (value) listParams.set(key, value);
}
const listSearch = listParams.toString();
const ticketListHref = listSearch
? `${ROUTES.TICKET}?${listSearch}`
: ROUTES.TICKET;
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
const overview = overviewQuery.data; const overview = overviewQuery.data;
@@ -52,7 +65,7 @@ export default function TicketDetailPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => router.push('/ticket')} onClick={() => router.push(ticketListHref)}
> >
<ArrowLeft /> <ArrowLeft />
Back to Tickets Back to Tickets
@@ -65,7 +78,7 @@ export default function TicketDetailPage() {
<main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden"> <main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<div className="xl:shrink-0"> <div className="xl:shrink-0">
{overview ? ( {overview ? (
<TicketDetailHeader ticket={overview} /> <TicketDetailHeader ticket={overview} backHref={ticketListHref} />
) : ( ) : (
<TicketOverviewHeaderSkeleton /> <TicketOverviewHeaderSkeleton />
)} )}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Ticket } from 'lucide-react'; import { Ticket } from 'lucide-react';
import type { TicketListItem } from '@/types'; import type { TicketListItem } from '@/types';
@@ -17,11 +17,48 @@ import {
useTicketSummaryQuery, useTicketSummaryQuery,
} from './hooks/useTicketQueries'; } from './hooks/useTicketQueries';
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
const PAGE_SIZE_OPTIONS = new Set([10, 20, 40, 50, 100]);
function parsePage(value: string | null) {
const page = Number(value);
return Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE;
}
function parseLimit(value: string | null) {
const limit = Number(value);
return PAGE_SIZE_OPTIONS.has(limit) ? limit : DEFAULT_LIMIT;
}
export default function TicketPage() { export default function TicketPage() {
const router = useRouter(); const router = useRouter();
const [skip, setSkip] = useState(0); const pathname = usePathname();
const [limit, setLimit] = useState(10); const searchParams = useSearchParams();
const [segmentId, setSegmentId] = useState(''); const searchParamsString = searchParams.toString();
const latestSearchParamsRef = useRef(searchParamsString);
useEffect(() => {
latestSearchParamsRef.current = searchParamsString;
}, [searchParamsString]);
const page = parsePage(searchParams.get('page'));
const limit = parseLimit(searchParams.get('limit'));
const segmentId = searchParams.get('segment') ?? '';
const skip = (page - 1) * limit;
const replaceListParams = useCallback(
(update: (params: URLSearchParams) => void) => {
const nextParams = new URLSearchParams(latestSearchParamsRef.current);
update(nextParams);
const nextSearch = nextParams.toString();
latestSearchParamsRef.current = nextSearch;
router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname, {
scroll: false,
});
},
[pathname, router],
);
useTenantTicketTableEvents(); useTenantTicketTableEvents();
@@ -36,16 +73,56 @@ export default function TicketPage() {
const tickets = ticketsQuery.data?.items ?? []; const tickets = ticketsQuery.data?.items ?? [];
const total = ticketsQuery.data?.total ?? 0; const total = ticketsQuery.data?.total ?? 0;
const handleSegmentChange = useCallback((nextSegmentId: string) => { const handleSegmentChange = useCallback(
setSegmentId(nextSegmentId); (nextSegmentId: string) => {
setSkip(0); replaceListParams((params) => {
}, []); params.delete('page');
if (nextSegmentId) {
params.set('segment', nextSegmentId);
} else {
params.delete('segment');
}
});
},
[replaceListParams],
);
const handlePageChange = useCallback(
(nextSkip: number) => {
const nextPage = Math.floor(nextSkip / limit) + 1;
replaceListParams((params) => {
if (nextPage === DEFAULT_PAGE) {
params.delete('page');
} else {
params.set('page', String(nextPage));
}
});
},
[limit, replaceListParams],
);
const handleLimitChange = useCallback(
(nextLimit: number) => {
replaceListParams((params) => {
params.delete('page');
if (nextLimit === DEFAULT_LIMIT) {
params.delete('limit');
} else {
params.set('limit', String(nextLimit));
}
});
},
[replaceListParams],
);
const handleView = useCallback( const handleView = useCallback(
(ticket: TicketListItem) => { (ticket: TicketListItem) => {
router.push(ROUTES.TICKET_DETAIL(ticket.id)); const detailPath = ROUTES.TICKET_DETAIL(ticket.id);
router.push(
searchParamsString ? `${detailPath}?${searchParamsString}` : detailPath,
);
}, },
[router], [router, searchParamsString],
); );
const toolbar = useMemo( const toolbar = useMemo(
@@ -81,8 +158,8 @@ export default function TicketPage() {
skip={skip} skip={skip}
limit={limit} limit={limit}
total={total} total={total}
onPageChange={setSkip} onPageChange={handlePageChange}
onLimitChange={setLimit} onLimitChange={handleLimitChange}
onView={handleView} onView={handleView}
/> />
</main> </main>