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 ( +
+
+ ); + } + + 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; +}