2 Commits

14 changed files with 268 additions and 2 deletions

View File

@@ -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'),

View File

@@ -2,11 +2,48 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; 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 type { TicketListItem } from '@/types';
import { formatDate } from '@/utils/date'; 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>[] { export function useTicketColumns(): ColumnDef<TicketListItem>[] {
return useMemo( return useMemo(
() => [ () => [
@@ -29,6 +66,13 @@ export function useTicketColumns(): ColumnDef<TicketListItem>[] {
header: 'Detections', header: 'Detections',
size: 120, size: 120,
}, },
{
id: 'attention',
header: 'Attention',
size: 210,
meta: { disableTruncate: true },
cell: ({ row }) => <TicketAttention ticket={row.original} />,
},
{ {
id: 'created_by', id: 'created_by',
header: 'Uploaded By', header: 'Uploaded By',

View 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>
);
}

View File

@@ -103,6 +103,12 @@ function buildTicketListItem(
created_by_email: event.created_by_email ?? null, created_by_email: event.created_by_email ?? null,
created_at: event.created_at ?? event.updated_at, created_at: event.created_at ?? event.updated_at,
updated_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_by_email: event.created_by_email ?? current.created_by_email,
created_at: event.created_at ?? current.created_at, created_at: event.created_at ?? current.created_at,
updated_at: event.updated_at ?? current.updated_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( 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() })

View File

@@ -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'),
}); });

View File

@@ -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}

View File

@@ -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,

View File

@@ -7,7 +7,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--radius: 0.5rem; --radius: 0.1rem;
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);

View File

@@ -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`,

View File

@@ -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> => {

View File

@@ -1,4 +1,5 @@
import type { TicketStatus } from './status'; import type { TicketStatus } from './status';
import type { TicketAttentionSummary } from './list';
export interface SseTokenResponse { export interface SseTokenResponse {
sse_token: string; sse_token: string;
@@ -18,6 +19,7 @@ export type TicketTableStatusEvent = {
created_by_email?: string | null; created_by_email?: string | null;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
attention_summary?: TicketAttentionSummary;
message?: string; message?: string;
}; };

View File

@@ -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';

View File

@@ -4,6 +4,13 @@ export interface TicketListParams extends PaginationParams {
chainage_id?: string; 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 { export interface TicketListItem {
id: string; id: string;
ticket_name: string; ticket_name: string;
@@ -15,6 +22,7 @@ export interface TicketListItem {
created_by_email: string | null; created_by_email: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
attention_summary: TicketAttentionSummary;
} }
export interface TicketListResponse { export interface TicketListResponse {

View 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;
}