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

@@ -55,6 +55,7 @@ export function useExtensionRequestDecision({
queryKey: ticketKeys.timeline(ticketId), queryKey: ticketKeys.timeline(ticketId),
}); });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
onSuccess?.(); onSuccess?.();
}, },
onError: () => toast.error('Failed to review extension request'), onError: () => toast.error('Failed to review extension request'),

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( const events = useMemo(
() => ({ () => ({
ticket_status: (event: TicketTableStatusEvent) => { ticket_status: (event: TicketTableStatusEvent) => {
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
queryClient queryClient
.getQueryCache() .getQueryCache()
.findAll({ queryKey: ticketKeys.lists() }) .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) { export function useTicketOverviewQuery(ticketId: string | undefined) {
return useQuery({ return useQuery({
queryKey: ticketKeys.overview(ticketId ?? ''), queryKey: ticketKeys.overview(ticketId ?? ''),
@@ -214,6 +228,7 @@ export function useDiscardDetectionMutation(
queryKey: ticketKeys.overview(ticketId), queryKey: ticketKeys.overview(ticketId),
}), }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId), queryKey: ticketKeys.assignments(ticketId),
}), }),
@@ -260,6 +275,7 @@ export function useReviewDetectionRepairProofMutation({
queryKey: ticketKeys.timeline(ticketId), queryKey: ticketKeys.timeline(ticketId),
}), }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
]); ]);
}, },
onError: () => toast.error('Failed to review repair proof'), onError: () => toast.error('Failed to review repair proof'),
@@ -305,6 +321,7 @@ export function useAssignTicketMutation(ticketId: string) {
queryKey: ticketKeys.timeline(ticketId), queryKey: ticketKeys.timeline(ticketId),
}), }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.assignmentDetectionLists(ticketId), queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}), }),
@@ -349,6 +366,7 @@ export function useRequestTicketExtensionMutation(
queryKey: ticketKeys.timeline(ticketId), queryKey: ticketKeys.timeline(ticketId),
}); });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
}, },
onError: () => toast.error('Failed to request an extension'), onError: () => toast.error('Failed to request an extension'),
}); });
@@ -371,6 +389,7 @@ export function useCloseTicketMutation(ticketId: string) {
queryKey: ticketKeys.timeline(ticketId), queryKey: ticketKeys.timeline(ticketId),
}); });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
}, },
onError: () => toast.error('Failed to close ticket'), onError: () => toast.error('Failed to close ticket'),
}); });

View File

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

View File

@@ -8,6 +8,9 @@ export const ticketKeys = {
all: ['tickets'] as const, all: ['tickets'] as const,
lists: () => [...ticketKeys.all, 'list'] as const, lists: () => [...ticketKeys.all, 'list'] as const,
list: (params: TicketListParams) => [...ticketKeys.lists(), params] 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, details: () => [...ticketKeys.all, 'detail'] as const,
overview: (ticketId: string) => overview: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'overview'] as const, [...ticketKeys.details(), ticketId, 'overview'] as const,

View File

@@ -63,6 +63,7 @@ export const API_ROUTES = {
}, },
TICKETS: { TICKETS: {
BASE: '/biz/api/v1/tickets', BASE: '/biz/api/v1/tickets',
SUMMARY: '/biz/api/v1/tickets/summary',
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`, DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
ASSIGNMENT_DETECTIONS: (id: string) => ASSIGNMENT_DETECTIONS: (id: string) =>
`/biz/api/v1/tickets/${id}/assignment-detections`, `/biz/api/v1/tickets/${id}/assignment-detections`,

View File

@@ -17,6 +17,8 @@ import type {
TicketOverviewDetail, TicketOverviewDetail,
TicketListParams, TicketListParams,
TicketListResponse, TicketListResponse,
TicketSummaryParams,
TicketSummaryResponse,
TicketTimelineResponse, TicketTimelineResponse,
} from '@/types'; } from '@/types';
@@ -45,6 +47,20 @@ export const ticketService = {
return response.data; return response.data;
}, },
getTicketSummary: async (
params?: TicketSummaryParams,
): Promise<TicketSummaryResponse> => {
const response = await axiosClient.get<TicketSummaryResponse>(
API_ROUTES.TICKETS.SUMMARY,
{
params: {
chainage_id: params?.chainage_id,
},
},
);
return response.data;
},
getTicketOverview: async ( getTicketOverview: async (
ticketId: string, ticketId: string,
): Promise<TicketOverviewDetail> => { ): Promise<TicketOverviewDetail> => {

View File

@@ -4,3 +4,4 @@ export * from './detail';
export * from './actions'; export * from './actions';
export * from './events'; export * from './events';
export * from './timeline'; export * from './timeline';
export * from './summary';

View File

@@ -0,0 +1,13 @@
export interface TicketSummaryParams {
chainage_id?: string;
}
export interface TicketSummaryResponse {
total_tickets: number;
in_progress_tickets: number;
pending_actions: {
extension_requests: number;
repair_reviews: number;
};
completed_tickets: number;
}