feat(tickets): add summary cards above ticket list
This commit is contained in:
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(
|
||||
() => ({
|
||||
ticket_status: (event: TicketTableStatusEvent) => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() });
|
||||
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
.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) {
|
||||
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'),
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user