feat(tickets): add summary cards above ticket list

This commit is contained in:
2026-08-05 16:57:54 +05:30
parent 44e9dc4ec8
commit 7ee621b790
10 changed files with 206 additions and 1 deletions

View File

@@ -0,0 +1,138 @@
import {
CalendarClock,
CircleCheck,
ClipboardCheck,
ListChecks,
Ticket,
TriangleAlert,
type LucideIcon,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import type { TicketSummaryResponse } from '@/types';
interface TicketSummaryCardsProps {
summary?: TicketSummaryResponse;
isLoading: boolean;
isError: boolean;
}
interface SummaryMetric {
label: string;
value: number;
icon: LucideIcon;
iconClassName: string;
}
function SummaryCard({ metric }: { metric: SummaryMetric }) {
const Icon = metric.icon;
return (
<Card size="sm" className="py-4">
<CardContent className="flex items-start justify-between gap-3 px-4">
<div className="min-w-0">
<p className="text-2xl font-semibold tracking-tight tabular-nums">
{metric.value.toLocaleString()}
</p>
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
{metric.label}
</p>
</div>
<div className={`rounded-lg p-2 ${metric.iconClassName}`}>
<Icon className="size-4" aria-hidden="true" />
</div>
</CardContent>
</Card>
);
}
function TicketSummarySkeleton() {
return (
<div
className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5"
aria-label="Loading ticket summary"
>
{Array.from({ length: 5 }, (_, index) => (
<Card key={index} size="sm" className="py-4">
<CardContent className="flex items-start justify-between gap-3 px-4">
<div className="space-y-2">
<Skeleton className="h-7 w-14" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="size-8" />
</CardContent>
</Card>
))}
</div>
);
}
export function TicketSummaryCards({
summary,
isLoading,
isError,
}: TicketSummaryCardsProps) {
if (isLoading) return <TicketSummarySkeleton />;
if (isError || !summary) {
return (
<div
role="status"
className="flex items-center gap-2 rounded-lg border border-dashed px-4 py-3 text-sm text-muted-foreground"
>
<TriangleAlert className="size-4" aria-hidden="true" />
Ticket summary is temporarily unavailable.
</div>
);
}
const metrics: SummaryMetric[] = [
{
label: 'Total Tickets',
value: summary.total_tickets,
icon: Ticket,
iconClassName:
'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
},
{
label: 'In Progress',
value: summary.in_progress_tickets,
icon: ListChecks,
iconClassName:
'bg-indigo-100 text-indigo-700 dark:bg-indigo-950/60 dark:text-indigo-300',
},
{
label: 'Repair Reviews',
value: summary.pending_actions.repair_reviews,
icon: ClipboardCheck,
iconClassName:
'bg-orange-100 text-orange-700 dark:bg-orange-950/60 dark:text-orange-300',
},
{
label: 'Extension Requests',
value: summary.pending_actions.extension_requests,
icon: CalendarClock,
iconClassName:
'bg-blue-100 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300',
},
{
label: 'Completed',
value: summary.completed_tickets,
icon: CircleCheck,
iconClassName:
'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300',
},
];
return (
<section
aria-label="Ticket summary"
className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5"
>
{metrics.map((metric) => (
<SummaryCard key={metric.label} metric={metric} />
))}
</section>
);
}

View File

@@ -139,6 +139,8 @@ export function useTenantTicketTableEvents(enabled = true) {
const events = useMemo(
() => ({
ticket_status: (event: TicketTableStatusEvent) => {
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
queryClient
.getQueryCache()
.findAll({ queryKey: ticketKeys.lists() })

View File

@@ -61,6 +61,20 @@ export function useTicketsQuery(params: UseTicketsQueryParams) {
});
}
export function useTicketSummaryQuery(chainageId?: string) {
const summaryParams = useMemo(
() => ({ chainage_id: chainageId || undefined }),
[chainageId],
);
return useQuery({
queryKey: ticketKeys.summary(summaryParams),
queryFn: () => ticketService.getTicketSummary(summaryParams),
staleTime: TICKET_TABLE_REFRESH_MS,
refetchInterval: TICKET_TABLE_REFRESH_MS,
});
}
export function useTicketOverviewQuery(ticketId: string | undefined) {
return useQuery({
queryKey: ticketKeys.overview(ticketId ?? ''),
@@ -214,6 +228,7 @@ export function useDiscardDetectionMutation(
queryKey: ticketKeys.overview(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId),
}),
@@ -260,6 +275,7 @@ export function useReviewDetectionRepairProofMutation({
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
]);
},
onError: () => toast.error('Failed to review repair proof'),
@@ -305,6 +321,7 @@ export function useAssignTicketMutation(ticketId: string) {
queryKey: ticketKeys.timeline(ticketId),
}),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
queryClient.invalidateQueries({
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
@@ -349,6 +366,7 @@ export function useRequestTicketExtensionMutation(
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
},
onError: () => toast.error('Failed to request an extension'),
});
@@ -371,6 +389,7 @@ export function useCloseTicketMutation(ticketId: string) {
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
},
onError: () => toast.error('Failed to close ticket'),
});

View File

@@ -8,10 +8,14 @@ import type { TicketListItem } from '@/types';
import { ROUTES } from '@/utils/routes';
import { TicketFilters } from './components/TicketFilters';
import { TicketSummaryCards } from './components/TicketSummaryCards';
import { TicketTable } from './components/TicketTable';
import { useTicketColumns } from './components/TicketColumns';
import { useTenantTicketTableEvents } from './hooks/useTenantTicketTableEvents';
import { useTicketsQuery } from './hooks/useTicketQueries';
import {
useTicketsQuery,
useTicketSummaryQuery,
} from './hooks/useTicketQueries';
export default function TicketPage() {
const router = useRouter();
@@ -26,6 +30,7 @@ export default function TicketPage() {
limit,
chainageId: segmentId,
});
const summaryQuery = useTicketSummaryQuery(segmentId);
const columns = useTicketColumns();
const tickets = ticketsQuery.data?.items ?? [];
@@ -62,6 +67,12 @@ export default function TicketPage() {
icon={Ticket}
/>
<TicketSummaryCards
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
isError={summaryQuery.isError}
/>
<TicketTable
columns={columns}
tickets={tickets}

View File

@@ -8,6 +8,9 @@ export const ticketKeys = {
all: ['tickets'] as const,
lists: () => [...ticketKeys.all, 'list'] as const,
list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const,
summaries: () => [...ticketKeys.all, 'summary'] as const,
summary: (params: { chainage_id?: string }) =>
[...ticketKeys.summaries(), params] as const,
details: () => [...ticketKeys.all, 'detail'] as const,
overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'overview'] as const,