Compare commits
2 Commits
d261404903
...
7ee621b790
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ee621b790 | |||
| 44e9dc4ec8 |
@@ -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'),
|
||||
|
||||
@@ -2,11 +2,48 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { CalendarClock, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { TicketListItem } from '@/types';
|
||||
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
function TicketAttention({ ticket }: { ticket: TicketListItem }) {
|
||||
const summary = ticket.attention_summary;
|
||||
const reviewCount = summary?.pending_repair_review_count ?? 0;
|
||||
const extensionCount = summary?.pending_extension_request_count ?? 0;
|
||||
const showReview = reviewCount > 0;
|
||||
const showExtension = extensionCount > 0;
|
||||
|
||||
if (!showReview && !showExtension) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{showReview ? (
|
||||
<Badge
|
||||
className="gap-1.5 border-0 bg-orange-100 text-orange-700 shadow-none dark:bg-orange-950/60 dark:text-orange-300"
|
||||
title={`${reviewCount} pending repair ${reviewCount === 1 ? 'review' : 'reviews'}`}
|
||||
>
|
||||
<ClipboardCheck aria-hidden="true" />
|
||||
Review
|
||||
<span className="font-semibold tabular-nums">{reviewCount}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{showExtension ? (
|
||||
<Badge
|
||||
className="gap-1.5 border-0 bg-blue-100 text-blue-700 shadow-none dark:bg-blue-950/60 dark:text-blue-300"
|
||||
title={`${extensionCount} pending extension ${extensionCount === 1 ? 'request' : 'requests'}`}
|
||||
>
|
||||
<CalendarClock aria-hidden="true" />
|
||||
Extension
|
||||
<span className="font-semibold tabular-nums">+{extensionCount}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
@@ -29,6 +66,13 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
|
||||
header: 'Detections',
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'attention',
|
||||
header: 'Attention',
|
||||
size: 210,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <TicketAttention ticket={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'created_by',
|
||||
header: 'Uploaded By',
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,12 @@ function buildTicketListItem(
|
||||
created_by_email: event.created_by_email ?? null,
|
||||
created_at: event.created_at ?? event.updated_at,
|
||||
updated_at: event.updated_at,
|
||||
attention_summary: event.attention_summary ?? {
|
||||
has_pending_extension_request: false,
|
||||
pending_extension_request_count: 0,
|
||||
has_pending_repair_review: false,
|
||||
pending_repair_review_count: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +128,7 @@ function mergeTicketListItem(
|
||||
created_by_email: event.created_by_email ?? current.created_by_email,
|
||||
created_at: event.created_at ?? current.created_at,
|
||||
updated_at: event.updated_at ?? current.updated_at,
|
||||
attention_summary: event.attention_summary ?? current.attention_summary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,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,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--radius: 0.1rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<TicketSummaryResponse> => {
|
||||
const response = await axiosClient.get<TicketSummaryResponse>(
|
||||
API_ROUTES.TICKETS.SUMMARY,
|
||||
{
|
||||
params: {
|
||||
chainage_id: params?.chainage_id,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTicketOverview: async (
|
||||
ticketId: string,
|
||||
): Promise<TicketOverviewDetail> => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TicketStatus } from './status';
|
||||
import type { TicketAttentionSummary } from './list';
|
||||
|
||||
export interface SseTokenResponse {
|
||||
sse_token: string;
|
||||
@@ -18,6 +19,7 @@ export type TicketTableStatusEvent = {
|
||||
created_by_email?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
attention_summary?: TicketAttentionSummary;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './detail';
|
||||
export * from './actions';
|
||||
export * from './events';
|
||||
export * from './timeline';
|
||||
export * from './summary';
|
||||
|
||||
@@ -4,6 +4,13 @@ export interface TicketListParams extends PaginationParams {
|
||||
chainage_id?: string;
|
||||
}
|
||||
|
||||
export interface TicketAttentionSummary {
|
||||
has_pending_extension_request: boolean;
|
||||
pending_extension_request_count: number;
|
||||
has_pending_repair_review: boolean;
|
||||
pending_repair_review_count: number;
|
||||
}
|
||||
|
||||
export interface TicketListItem {
|
||||
id: string;
|
||||
ticket_name: string;
|
||||
@@ -15,6 +22,7 @@ export interface TicketListItem {
|
||||
created_by_email: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
attention_summary: TicketAttentionSummary;
|
||||
}
|
||||
|
||||
export interface TicketListResponse {
|
||||
|
||||
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