diff --git a/src/app/(modules)/extension-requests/hooks/useExtensionRequestDecision.ts b/src/app/(modules)/extension-requests/hooks/useExtensionRequestDecision.ts
index aab63df..3e26cad 100644
--- a/src/app/(modules)/extension-requests/hooks/useExtensionRequestDecision.ts
+++ b/src/app/(modules)/extension-requests/hooks/useExtensionRequestDecision.ts
@@ -55,6 +55,7 @@ export function useExtensionRequestDecision({
queryKey: ticketKeys.timeline(ticketId),
});
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
+ queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
onSuccess?.();
},
onError: () => toast.error('Failed to review extension request'),
diff --git a/src/app/(modules)/ticket/components/TicketSummaryCards.tsx b/src/app/(modules)/ticket/components/TicketSummaryCards.tsx
new file mode 100644
index 0000000..e477a24
--- /dev/null
+++ b/src/app/(modules)/ticket/components/TicketSummaryCards.tsx
@@ -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 (
+
+
+
+
+ {metric.value.toLocaleString()}
+
+
+ {metric.label}
+
+
+
+
+
+
+
+ );
+}
+
+function TicketSummarySkeleton() {
+ return (
+
+ {Array.from({ length: 5 }, (_, index) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export function TicketSummaryCards({
+ summary,
+ isLoading,
+ isError,
+}: TicketSummaryCardsProps) {
+ if (isLoading) return ;
+
+ if (isError || !summary) {
+ return (
+
+
+ Ticket summary is temporarily unavailable.
+
+ );
+ }
+
+ 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 (
+
+ {metrics.map((metric) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts b/src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
index 9c56bef..c67b209 100644
--- a/src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
+++ b/src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
@@ -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() })
diff --git a/src/app/(modules)/ticket/hooks/useTicketQueries.ts b/src/app/(modules)/ticket/hooks/useTicketQueries.ts
index e1d5cdc..156c706 100644
--- a/src/app/(modules)/ticket/hooks/useTicketQueries.ts
+++ b/src/app/(modules)/ticket/hooks/useTicketQueries.ts
@@ -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'),
});
diff --git a/src/app/(modules)/ticket/page.tsx b/src/app/(modules)/ticket/page.tsx
index 7428f99..8f2eef5 100644
--- a/src/app/(modules)/ticket/page.tsx
+++ b/src/app/(modules)/ticket/page.tsx
@@ -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}
/>
+
+
[...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,
diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts
index 7fbaaed..dbc0588 100644
--- a/src/constants/apiRoutes.ts
+++ b/src/constants/apiRoutes.ts
@@ -63,6 +63,7 @@ export const API_ROUTES = {
},
TICKETS: {
BASE: '/biz/api/v1/tickets',
+ SUMMARY: '/biz/api/v1/tickets/summary',
DETAIL: (id: string) => `/biz/api/v1/tickets/${id}`,
ASSIGNMENT_DETECTIONS: (id: string) =>
`/biz/api/v1/tickets/${id}/assignment-detections`,
diff --git a/src/services/api/ticket.service.ts b/src/services/api/ticket.service.ts
index e9df4b2..d46bf99 100644
--- a/src/services/api/ticket.service.ts
+++ b/src/services/api/ticket.service.ts
@@ -17,6 +17,8 @@ import type {
TicketOverviewDetail,
TicketListParams,
TicketListResponse,
+ TicketSummaryParams,
+ TicketSummaryResponse,
TicketTimelineResponse,
} from '@/types';
@@ -45,6 +47,20 @@ export const ticketService = {
return response.data;
},
+ getTicketSummary: async (
+ params?: TicketSummaryParams,
+ ): Promise => {
+ const response = await axiosClient.get(
+ API_ROUTES.TICKETS.SUMMARY,
+ {
+ params: {
+ chainage_id: params?.chainage_id,
+ },
+ },
+ );
+ return response.data;
+ },
+
getTicketOverview: async (
ticketId: string,
): Promise => {
diff --git a/src/types/ticket/index.ts b/src/types/ticket/index.ts
index a881f6e..dae2b31 100644
--- a/src/types/ticket/index.ts
+++ b/src/types/ticket/index.ts
@@ -4,3 +4,4 @@ export * from './detail';
export * from './actions';
export * from './events';
export * from './timeline';
+export * from './summary';
diff --git a/src/types/ticket/summary.ts b/src/types/ticket/summary.ts
new file mode 100644
index 0000000..f313bcf
--- /dev/null
+++ b/src/types/ticket/summary.ts
@@ -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;
+}