feat(tickets): add summary cards above ticket list
This commit is contained in:
@@ -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'),
|
||||||
|
|||||||
138
src/app/(modules)/ticket/components/TicketSummaryCards.tsx
Normal file
138
src/app/(modules)/ticket/components/TicketSummaryCards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() })
|
||||||
|
|||||||
@@ -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'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
13
src/types/ticket/summary.ts
Normal file
13
src/types/ticket/summary.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user