19 Commits

Author SHA1 Message Date
c13afa7554 feat(ui): improve collapsed sidebar and status action icons 2026-08-10 16:08:50 +05:30
8033450321 fix(extension-requests): replace lot terminology with assignment 2026-08-10 11:01:56 +05:30
98de8e948b feat(ticket): preserve list state across detail navigation 2026-08-07 17:56:55 +05:30
3697119656 feat(ticket): show ticket name in assignment header 2026-08-07 17:10:31 +05:30
e268b55392 style(ui): add modern thin scrollbar styling 2026-08-07 16:47:15 +05:30
da5b1009e3 feat(ticket): make assigned issue images collapsible 2026-08-07 16:43:44 +05:30
3d04995f97 perf(ticket): paginate and cache detection previews 2026-08-07 16:38:38 +05:30
d3069e1f25 feat(ui): improve review actions, table headers, and detection previews 2026-08-07 16:16:03 +05:30
47697b4421 feat(tickets): add unassigned issue triage to ticket details
- embed detection previews within work assignments
- allow discarding only unassigned detections
- improve ticket detail labels, counts, and layout
2026-08-07 15:31:47 +05:30
c8899c191e feat(ticket): show open and closed status in ticket list 2026-08-06 17:10:10 +05:30
8ab9240b77 style(ticket): use primary variants for overview actions and badges 2026-08-06 15:56:40 +05:30
f6436862c0 refactor(ticket): move back navigation before detail header 2026-08-06 15:19:56 +05:30
0ff5792670 fix(review): show generic Pothole Review heading 2026-08-06 10:46:33 +05:30
0349891f52 feat(tickets): show pending repair review count in ticket details 2026-08-05 18:17:31 +05:30
7ee621b790 feat(tickets): add summary cards above ticket list 2026-08-05 16:57:54 +05:30
44e9dc4ec8 feat(ui): add ticket attention badges and sharpen border radius 2026-08-05 16:57:12 +05:30
d261404903 feat(ui): add ticket filter reset and update border radius 2026-08-05 11:58:45 +05:30
bad9c50dbf feat(ticket): use assignment detections API for unassigned issues 2026-08-04 15:46:22 +05:30
6169bbdc28 feat(ticket): add shared extension request history for workers and managers 2026-08-03 16:04:15 +05:30
54 changed files with 1253 additions and 233 deletions

View File

@@ -68,13 +68,13 @@ export function useExtensionRequestColumns(): ColumnDef<TicketExtensionRequest>[
() => [ () => [
{ {
accessorKey: 'ticket_name', accessorKey: 'ticket_name',
header: 'Ticket / Lot', header: 'Ticket / Assignment',
size: 170, size: 170,
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>
<p className="font-medium">{row.original.ticket_name}</p> <p className="font-medium">{row.original.ticket_name}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Lot #{row.original.assignment_id} Assignment #{row.original.assignment_id}
</p> </p>
</div> </div>
), ),

View File

@@ -90,8 +90,8 @@ export function ExtensionRequestDecisionDialog({
: 'Reject extension request'} : 'Reject extension request'}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Review {request.ticket_name}, Lot #{request.assignment_id} before Review {request.ticket_name}, Assignment #{request.assignment_id}{' '}
confirming this decision. before confirming this decision.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -205,8 +205,8 @@ export function ExtensionRequestDecisionDialog({
<AlertDialogTitle>Reject extension request?</AlertDialogTitle> <AlertDialogTitle>Reject extension request?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to reject the extension request for{' '} Are you sure you want to reject the extension request for{' '}
{request.ticket_name}, Lot #{request.assignment_id}? This decision {request.ticket_name}, Assignment #{request.assignment_id}? This
cannot be undone. decision cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

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

@@ -95,7 +95,7 @@ export default function ExtensionRequestsPage() {
<main className="relative z-10 space-y-5"> <main className="relative z-10 space-y-5">
<PageHeader <PageHeader
title="Extension Requests" title="Extension Requests"
description="Track deadline extension requests across all ticket lots." description="Track deadline extension requests across all ticket assignments."
icon={CalendarClock} icon={CalendarClock}
/> />

View File

@@ -11,7 +11,7 @@ const ModulesLayout = ({
}>) => { }>) => {
return ( return (
<AuthGuard> <AuthGuard>
<SidebarProvider> <SidebarProvider defaultOpen={false}>
<AppSidebar /> <AppSidebar />
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"> <main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
<div className="scroll-stable min-w-0 flex-1 overflow-auto"> <div className="scroll-stable min-w-0 flex-1 overflow-auto">

View File

@@ -2,7 +2,7 @@
import type { ColumnDef, SortingState } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Edit, RotateCcw } from 'lucide-react'; import { Edit, ShieldCheck, ShieldX } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
@@ -58,7 +58,12 @@ export function RoleTable({
}, },
{ {
label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'), label: (role) => (role.effective_status ? 'Deactivate' : 'Activate'),
icon: <RotateCcw className="size-4" />, icon: (role) =>
role.effective_status ? (
<ShieldX className="size-4 text-destructive" />
) : (
<ShieldCheck className="size-4 text-emerald-600" />
),
permission: PERMISSIONS.ROLE.DELETE, permission: PERMISSIONS.ROLE.DELETE,
disabled: () => isStatusPending, disabled: () => isStatusPending,
onClick: onToggleStatus, onClick: onToggleStatus,

View File

@@ -28,7 +28,6 @@ import {
interface AssignTicketFormProps { interface AssignTicketFormProps {
ticketId: string; ticketId: string;
videoId?: string;
selectedClassNames: string[]; selectedClassNames: string[];
startPoint: AssignmentRangePoint | null; startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null; endPoint: AssignmentRangePoint | null;
@@ -37,7 +36,6 @@ interface AssignTicketFormProps {
export function AssignTicketForm({ export function AssignTicketForm({
ticketId, ticketId,
videoId,
selectedClassNames, selectedClassNames,
startPoint, startPoint,
endPoint, endPoint,
@@ -49,7 +47,7 @@ export function AssignTicketForm({
const [dueDate, setDueDate] = useState<Date>(); const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState(''); const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticketId, videoId); const assignMutation = useAssignTicketMutation(ticketId);
const today = useMemo(() => { const today = useMemo(() => {
const date = new Date(); const date = new Date();
date.setHours(0, 0, 0, 0); date.setHours(0, 0, 0, 0);

View File

@@ -5,7 +5,10 @@ import dynamic from 'next/dynamic';
import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react'; import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react';
import { getDefectVisual } from '@/constants/defectVisualConfig'; import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem, VideoDetectionsResponse } from '@/types'; import type {
TicketAssignmentDetectionItem,
TicketAssignmentDetectionsResponse,
} from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@@ -18,7 +21,7 @@ import {
import type { AssignmentRangePoint } from './assignmentRange'; import type { AssignmentRangePoint } from './assignmentRange';
type AssignmentIssuesMapProps = { type AssignmentIssuesMapProps = {
data?: VideoDetectionsResponse; data?: TicketAssignmentDetectionsResponse;
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
startPoint: AssignmentRangePoint | null; startPoint: AssignmentRangePoint | null;
@@ -32,7 +35,6 @@ type AssignmentMapItem = {
display_name: string; display_name: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
confidence: number;
sequence: number; sequence: number;
}; };
@@ -50,7 +52,10 @@ type ClientAssignmentIssuesMapProps = {
onSelectDetection: (detectionId: number) => void; onSelectDetection: (detectionId: number) => void;
}; };
function toMapItem(item: DetectionResultItem): AssignmentMapItem | null { function toMapItem(
item: TicketAssignmentDetectionItem,
sequence: number,
): AssignmentMapItem | null {
const { latitude, longitude } = item.location; const { latitude, longitude } = item.location;
if ( if (
typeof latitude !== 'number' || typeof latitude !== 'number' ||
@@ -71,8 +76,7 @@ function toMapItem(item: DetectionResultItem): AssignmentMapItem | null {
display_name: item.detection.display_name, display_name: item.detection.display_name,
latitude, latitude,
longitude, longitude,
confidence: item.detection.confidence, sequence,
sequence: item.frame.timestamp_seconds,
}; };
} }
@@ -263,12 +267,6 @@ const ClientAssignmentIssuesMap = dynamic<ClientAssignmentIssuesMapProps>(
</div> </div>
<dl className="!m-0 space-y-2 py-3 text-sm"> <dl className="!m-0 space-y-2 py-3 text-sm">
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Confidence</dt>
<dd className="!m-0 font-medium tabular-nums">
{Math.round(item.confidence * 100)}%
</dd>
</div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Latitude</dt> <dt className="text-muted-foreground">Latitude</dt>
<dd className="!m-0 font-mono text-xs tabular-nums"> <dd className="!m-0 font-mono text-xs tabular-nums">
@@ -321,8 +319,8 @@ export function AssignmentIssuesMap({
); );
const validItems = useMemo( const validItems = useMemo(
() => () =>
(data?.items ?? []).flatMap((item) => { (data?.items ?? []).flatMap((item, index) => {
const mapItem = toMapItem(item); const mapItem = toMapItem(item, index);
return mapItem ? [mapItem] : []; return mapItem ? [mapItem] : [];
}), }),
[data?.items], [data?.items],

View File

@@ -7,9 +7,9 @@ import { MultiSelectPopover } from '@/components/form/MultiSelectPopover';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { VideoDetectionsResponse } from '@/types'; import type { TicketAssignmentDetectionsResponse } from '@/types';
import { useTicketDetectionsQuery } from '../../../hooks/useTicketQueries'; import { useTicketAssignmentDetectionsQuery } from '../../../hooks/useTicketQueries';
import { import {
formatCompactRangeCoordinate, formatCompactRangeCoordinate,
formatRangeCoordinate, formatRangeCoordinate,
@@ -28,7 +28,7 @@ interface IssueTypeOption {
} }
interface ChooseIssuesSectionProps { interface ChooseIssuesSectionProps {
videoId?: string; ticketId: string;
issueTypes: IssueTypeOption[]; issueTypes: IssueTypeOption[];
selectedIssueTypes: string[]; selectedIssueTypes: string[];
cacheRevision: number; cacheRevision: number;
@@ -40,7 +40,7 @@ interface ChooseIssuesSectionProps {
} }
export function ChooseIssuesSection({ export function ChooseIssuesSection({
videoId, ticketId,
issueTypes, issueTypes,
selectedIssueTypes, selectedIssueTypes,
cacheRevision, cacheRevision,
@@ -101,22 +101,25 @@ export function ChooseIssuesSection({
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
assignment_status: 'unassigned' as const,
skip, skip,
limit, limit,
class_name: class_name:
selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined, selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined,
sort: 'timestamp_asc',
}), }),
[limit, selectedIssueTypes, skip], [limit, selectedIssueTypes, skip],
); );
const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams); const detectionsQuery = useTicketAssignmentDetectionsQuery(
ticketId,
queryParams,
);
const result = detectionsQuery.data; const result = detectionsQuery.data;
const mapCacheKey = useMemo( const mapCacheKey = useMemo(
() => JSON.stringify([videoId ?? '', selectedIssueTypes, cacheRevision]), () => JSON.stringify([ticketId, selectedIssueTypes, cacheRevision]),
[cacheRevision, selectedIssueTypes, videoId], [cacheRevision, selectedIssueTypes, ticketId],
); );
const [mapCache, setMapCache] = useState< const [mapCache, setMapCache] = useState<
Record<string, VideoDetectionsResponse> Record<string, TicketAssignmentDetectionsResponse>
>({}); >({});
useEffect(() => { useEffect(() => {

View File

@@ -5,17 +5,11 @@ import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { getDefectVisual } from '@/constants/defectVisualConfig'; import { getDefectVisual } from '@/constants/defectVisualConfig';
import type { DetectionResultItem } from '@/types'; import type { TicketAssignmentDetectionItem } from '@/types';
import type { AssignmentRangePoint } from './assignmentRange'; import type { AssignmentRangePoint } from './assignmentRange';
import { AssignmentRangeActions } from './AssignmentRangeActions'; import { AssignmentRangeActions } from './AssignmentRangeActions';
function formatTimestamp(seconds: number) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
interface UseIssueColumnsOptions { interface UseIssueColumnsOptions {
startPoint: AssignmentRangePoint | null; startPoint: AssignmentRangePoint | null;
endPoint: AssignmentRangePoint | null; endPoint: AssignmentRangePoint | null;
@@ -28,7 +22,7 @@ export function useIssueColumns({
endPoint, endPoint,
onSetStart, onSetStart,
onSetEnd, onSetEnd,
}: UseIssueColumnsOptions): ColumnDef<DetectionResultItem>[] { }: UseIssueColumnsOptions): ColumnDef<TicketAssignmentDetectionItem>[] {
return useMemo( return useMemo(
() => [ () => [
{ {
@@ -89,13 +83,6 @@ export function useIssueColumns({
); );
}, },
}, },
{
id: 'timestamp',
header: 'Timestamp',
size: 130,
cell: ({ row }) =>
formatTimestamp(row.original.frame.timestamp_seconds),
},
{ {
id: 'range_actions', id: 'range_actions',
header: 'Set range', header: 'Set range',

View File

@@ -4,11 +4,11 @@ import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { DetectionResultItem } from '@/types'; import type { TicketAssignmentDetectionItem } from '@/types';
interface IssueTableProps { interface IssueTableProps {
columns: ColumnDef<DetectionResultItem>[]; columns: ColumnDef<TicketAssignmentDetectionItem>[];
issues: DetectionResultItem[]; issues: TicketAssignmentDetectionItem[];
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
toolbar: ReactNode; toolbar: ReactNode;

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react'; import { AlertTriangle, ArrowLeft, LockKeyhole } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -45,7 +46,6 @@ export default function TicketAssignmentPage() {
const [assignmentRevision, setAssignmentRevision] = useState(0); const [assignmentRevision, setAssignmentRevision] = useState(0);
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
const overview = overviewQuery.data; const overview = overviewQuery.data;
const videoId = overview?.video_id ?? overview?.video?.id ?? undefined;
const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId); const ticketDetailUrl = ROUTES.TICKET_DETAIL(ticketId);
const backToTicket = () => router.push(ticketDetailUrl); const backToTicket = () => router.push(ticketDetailUrl);
@@ -79,7 +79,7 @@ export default function TicketAssignmentPage() {
); );
} }
const ticketLabel = overview.ticket_name || overview.id; const ticketName = overview.ticket_name || 'Unnamed ticket';
return ( return (
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden"> <main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
@@ -94,20 +94,24 @@ export default function TicketAssignmentPage() {
> >
<ArrowLeft /> <ArrowLeft />
</Button> </Button>
<div className="min-w-0 space-y-1"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
Assign ticket Assign Ticket
</h1> </h1>
<p className="truncate text-sm text-muted-foreground"> <Badge
Ticket: {ticketLabel} variant="secondary"
</p> className="max-w-full font-mono text-xs"
title={ticketName}
>
<span className="truncate">{ticketName}</span>
</Badge>
</div> </div>
</header> </header>
<div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-stretch xl:overflow-hidden"> <div className="grid min-w-0 items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-stretch xl:overflow-hidden">
<div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2"> <div className="min-w-0 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
<ChooseIssuesSection <ChooseIssuesSection
videoId={videoId} ticketId={ticketId}
issueTypes={overview.ai_result.detections_by_class} issueTypes={overview.ai_result.detections_by_class}
selectedIssueTypes={selectedIssueTypes} selectedIssueTypes={selectedIssueTypes}
cacheRevision={assignmentRevision} cacheRevision={assignmentRevision}
@@ -146,7 +150,6 @@ export default function TicketAssignmentPage() {
> >
<AssignTicketForm <AssignTicketForm
ticketId={ticketId} ticketId={ticketId}
videoId={videoId}
selectedClassNames={selectedIssueTypes} selectedClassNames={selectedIssueTypes}
startPoint={startPoint} startPoint={startPoint}
endPoint={endPoint} endPoint={endPoint}

View File

@@ -1,11 +1,22 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { BriefcaseBusiness, CalendarClock, ListChecks } from 'lucide-react'; import {
BriefcaseBusiness,
CalendarClock,
ChevronDown,
Images,
ListChecks,
} from 'lucide-react';
import { PersonInfo } from '@/components/person-avatar'; import { PersonInfo } from '@/components/person-avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { import {
Card, Card,
CardContent, CardContent,
@@ -20,9 +31,11 @@ import type { TicketActor, TicketAssignmentSummary } from '@/types';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries'; import { useTicketAssignmentsQuery } from '../../hooks/useTicketQueries';
import { TicketDetectionPreview } from './TicketDetectionPreview';
interface TicketAssignmentsCardProps { interface TicketAssignmentsCardProps {
ticketId: string; ticketId: string;
videoId?: string | null;
initialAssignments?: TicketAssignmentSummary[]; initialAssignments?: TicketAssignmentSummary[];
selectedAssignmentId?: number; selectedAssignmentId?: number;
onAssignmentChange: (assignmentId: number) => void; onAssignmentChange: (assignmentId: number) => void;
@@ -87,7 +100,7 @@ function AssignmentProgress({
); );
} }
function AssignmentLot({ function AssignmentOverview({
assignment, assignment,
}: { }: {
assignment: TicketAssignmentSummary; assignment: TicketAssignmentSummary;
@@ -109,7 +122,7 @@ function AssignmentLot({
<div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4"> <div className="min-w-0 space-y-4 rounded-lg border p-3 sm:p-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="mb-2 text-xs font-medium text-muted-foreground"> <p className="mb-2 text-xs font-medium text-muted-foreground">
Lot {assignment.id} Assignment {assignment.id}
</p> </p>
<PersonInfo person={worker} /> <PersonInfo person={worker} />
</div> </div>
@@ -138,6 +151,7 @@ function AssignmentLot({
export function TicketAssignmentsCard({ export function TicketAssignmentsCard({
ticketId, ticketId,
videoId,
initialAssignments, initialAssignments,
selectedAssignmentId, selectedAssignmentId,
onAssignmentChange, onAssignmentChange,
@@ -173,14 +187,17 @@ export function TicketAssignmentsCard({
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<BriefcaseBusiness className="size-5 text-primary" /> <BriefcaseBusiness className="size-5 text-primary" />
Assignment lots Work Assignments
</CardTitle> </CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
Live ownership, criteria, and repair progress for this ticket. Issues grouped by assignee, scope, and repair progress.
</CardDescription> </CardDescription>
</div> </div>
{assignmentsQuery.data ? ( {assignmentsQuery.data ? (
<Badge variant="secondary">{assignments.length}</Badge> <Badge variant="default">
{assignments.length}{' '}
{assignments.length === 1 ? 'Assignment' : 'Assignments'}
</Badge>
) : null} ) : null}
</div> </div>
</CardHeader> </CardHeader>
@@ -195,7 +212,7 @@ export function TicketAssignmentsCard({
) : assignmentsQuery.isError ? ( ) : assignmentsQuery.isError ? (
<div className="rounded-lg border border-dashed p-6 text-center"> <div className="rounded-lg border border-dashed p-6 text-center">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Unable to load assignment lots. Unable to load work assignments.
</p> </p>
<Button <Button
type="button" type="button"
@@ -210,7 +227,7 @@ export function TicketAssignmentsCard({
) : selectedAssignment ? ( ) : selectedAssignment ? (
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
<nav <nav
aria-label="Assignment lots" aria-label="Work assignments"
className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x" className="w-full max-w-full overflow-x-auto overflow-y-hidden overscroll-x-contain touch-pan-x"
> >
<div <div
@@ -239,7 +256,7 @@ export function TicketAssignmentsCard({
)} )}
onClick={() => onAssignmentChange(assignment.id)} onClick={() => onAssignmentChange(assignment.id)}
> >
Lot {assignment.id} Assignment {assignment.id}
</button> </button>
); );
})} })}
@@ -252,14 +269,44 @@ export function TicketAssignmentsCard({
aria-labelledby={`assignment-tab-${activeAssignmentId}`} aria-labelledby={`assignment-tab-${activeAssignmentId}`}
className="min-w-0" className="min-w-0"
> >
<AssignmentLot assignment={selectedAssignment} /> <AssignmentOverview assignment={selectedAssignment} />
{videoId ? (
<Collapsible
key={selectedAssignment.id}
className="group mt-4 overflow-hidden rounded-lg border"
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-between rounded-none px-4 py-3"
>
<span className="flex items-center gap-2 font-medium">
<Images className="size-4 text-primary" />
Assigned issue images
</span>
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t p-4">
<TicketDetectionPreview
key={`${videoId}:${selectedAssignment.id}`}
ticketId={ticketId}
videoId={videoId}
assignmentId={selectedAssignment.id}
/>
</div>
</CollapsibleContent>
</Collapsible>
) : null}
</div> </div>
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-dashed px-4 py-8 text-center"> <div className="rounded-lg border border-dashed px-4 py-8 text-center">
<p className="text-sm font-medium">No assignments yet</p> <p className="text-sm font-medium">No work assignments yet</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Assignment lots will appear here after work is allocated. Work assignments will appear here after issues are allocated.
</p> </p>
</div> </div>
)} )}

View File

@@ -3,6 +3,7 @@
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards'; import { PermissionGuard } from '@/guards';
@@ -13,30 +14,52 @@ import { OpenRepairReviewAction } from './actions/OpenRepairReviewAction';
export function TicketDetailHeader({ export function TicketDetailHeader({
ticket, ticket,
backHref,
}: { }: {
ticket: TicketOverviewDetail; ticket: TicketOverviewDetail;
backHref: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const ticketLabel = ticket.ticket_name || ticket.id; const ticketName = ticket.ticket_name || 'Unnamed ticket';
return ( return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1"> <div className="flex min-w-0 items-start gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Ticket Detail</h1> <Button
<p className="text-xs ">Ticket: {ticketLabel}</p> type="button"
variant="secondary"
size="icon"
onClick={() => router.push(backHref)}
aria-label="Back to ticket list"
className="shrink-0"
>
<ArrowLeft />
</Button>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
Ticket Detail
</h1>
<Badge
variant="secondary"
className="max-w-full font-mono text-xs"
title={ticketName}
>
<span className="truncate">{ticketName}</span>
</Badge>
</div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}> <PermissionGuard permissions={PERMISSIONS.TICKET.REVIEW}>
<OpenRepairReviewAction ticketId={ticket.id} /> <OpenRepairReviewAction
ticketId={ticket.id}
hasPendingReview={ticket.has_pending_review}
pendingReviewCount={ticket.pending_review_count}
/>
</PermissionGuard> </PermissionGuard>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}> <PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<AssignTicketAction ticketId={ticket.id} /> <AssignTicketAction ticketId={ticket.id} />
</PermissionGuard> </PermissionGuard>
<Button variant="secondary" onClick={() => router.push('/ticket')}>
<ArrowLeft className="size-4" />
Back to List
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react'; import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import DetectionLocationMap from '@/components/map/detectionLocationMap'; import DetectionLocationMap from '@/components/map/detectionLocationMap';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage'; import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
@@ -20,9 +19,13 @@ import { DetectionDiscardDialog } from './DetectionDiscardDialog';
interface TicketDetectionPreviewProps { interface TicketDetectionPreviewProps {
ticketId: string; ticketId: string;
videoId?: string | null; videoId?: string | null;
assignmentId: number; assignmentId?: number;
assignmentStatus?: 'unassigned';
onDetectionCountChange?: (count: number | undefined) => void;
} }
const DETECTION_PAGE_SIZE = 10;
function DetectionMetadataBar({ function DetectionMetadataBar({
items, items,
}: { }: {
@@ -95,32 +98,43 @@ export function TicketDetectionPreview({
ticketId, ticketId,
videoId, videoId,
assignmentId, assignmentId,
assignmentStatus,
onDetectionCountChange,
}: TicketDetectionPreviewProps) { }: TicketDetectionPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false); const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const isUnassignedPreview = assignmentStatus === 'unassigned';
const pageStart =
Math.floor(currentIndex / DETECTION_PAGE_SIZE) * DETECTION_PAGE_SIZE;
useEffect(() => { useEffect(() => {
setCurrentIndex(0); setCurrentIndex(0);
}, [assignmentId, videoId]); }, [assignmentId, assignmentStatus, videoId]);
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
skip: currentIndex, skip: pageStart,
limit: 1, limit: DETECTION_PAGE_SIZE,
assignment_id: assignmentId, assignment_id: isUnassignedPreview ? undefined : assignmentId,
assignment_status: isUnassignedPreview
? ('unassigned' as const)
: undefined,
sort: 'timestamp_asc', sort: 'timestamp_asc',
}), }),
[assignmentId, currentIndex], [assignmentId, isUnassignedPreview, pageStart],
); );
const detectionsQuery = useTicketDetectionsQuery( const detectionsQuery = useTicketDetectionsQuery(
videoId ?? undefined, videoId ?? undefined,
queryParams, queryParams,
{ keepPreviousPage: true },
); );
const detectionResult = detectionsQuery.data; const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0]; const activeDetection = detectionsQuery.isPlaceholderData
? undefined
: detectionResult?.items[currentIndex - pageStart];
const confirmedDetectionCount = detectionResult?.total; const confirmedDetectionCount = detectionResult?.total;
const detectionCount = detectionResult?.total ?? 0; const detectionCount = confirmedDetectionCount ?? 0;
const activeVisual = getDefectVisual( const activeVisual = getDefectVisual(
activeDetection?.detection.class_name ?? '', activeDetection?.detection.class_name ?? '',
); );
@@ -148,8 +162,18 @@ export function TicketDetectionPreview({
} }
}, [confirmedDetectionCount, currentIndex]); }, [confirmedDetectionCount, currentIndex]);
useEffect(() => {
onDetectionCountChange?.(detectionCount);
}, [detectionCount, onDetectionCountChange]);
const isNavigationDisabled = const isNavigationDisabled =
detectionCount === 0 || detectionsQuery.isLoading; detectionCount === 0 ||
detectionsQuery.isLoading ||
detectionsQuery.isPlaceholderData;
const isInitialLoading =
detectionsQuery.isLoading || detectionsQuery.isPlaceholderData;
const isError = detectionsQuery.isError;
const retry = () => void detectionsQuery.refetch();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -165,16 +189,8 @@ export function TicketDetectionPreview({
className={cn('size-5', activeVisual.colorClassName)} className={cn('size-5', activeVisual.colorClassName)}
/> />
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <h3>{activeDisplayName}</h3>
<h3>{activeDisplayName}</h3>
<Badge variant="secondary" className="rounded-lg">
{detectionCount} Detections
</Badge>
</div>
<p className="text-muted-foreground">
Review all detections in ascending timestamp order.
</p>
</div> </div>
</div> </div>
@@ -215,28 +231,27 @@ export function TicketDetectionPreview({
</div> </div>
</div> </div>
{detectionsQuery.isLoading && !detectionsQuery.data ? ( {isInitialLoading ? (
<TicketDetectionPreviewSkeleton /> <TicketDetectionPreviewSkeleton />
) : detectionsQuery.isError ? ( ) : isError ? (
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center"> <div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<AlertTriangle className="size-6 text-destructive" /> <AlertTriangle className="size-6 text-destructive" />
<p className="text-sm font-medium text-destructive"> <p className="text-sm font-medium text-destructive">
Failed to load detection preview. Failed to load detection preview.
</p> </p>
<Button <Button type="button" variant="outline" size="sm" onClick={retry}>
type="button"
variant="outline"
size="sm"
onClick={() => void detectionsQuery.refetch()}
>
Retry Retry
</Button> </Button>
</div> </div>
</div> </div>
) : !activeDetection || !detectionResult ? ( ) : !activeDetection || !detectionResult ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
No detections are available for this issue. {detectionCount > 0
? 'The selected detection details are unavailable. Try refreshing the preview.'
: isUnassignedPreview
? 'No unassigned issues remain on this ticket.'
: 'No detections are available for this issue.'}
</div> </div>
) : ( ) : (
<> <>
@@ -283,30 +298,34 @@ export function TicketDetectionPreview({
]} ]}
/> />
<div className="flex justify-end"> {isUnassignedPreview ? (
<Button <>
type="button" <div className="flex justify-end">
variant="destructive" <Button
onClick={() => setIsDiscardDialogOpen(true)} type="button"
> variant="destructive"
<Trash2 /> onClick={() => setIsDiscardDialogOpen(true)}
Discard detection >
</Button> <Trash2 />
</div> Discard detection
</Button>
</div>
<DetectionDiscardDialog <DetectionDiscardDialog
open={isDiscardDialogOpen} open={isDiscardDialogOpen}
detectionLabel={activeDetection.detection.display_name} detectionLabel={activeDetection.detection.display_name}
isPending={discardMutation.isPending} isPending={discardMutation.isPending}
onOpenChange={setIsDiscardDialogOpen} onOpenChange={setIsDiscardDialogOpen}
onSubmit={async (payload) => { onSubmit={async (payload) => {
await discardMutation.mutateAsync({ await discardMutation.mutateAsync({
detectionId: activeDetection.detection.id, detectionId: activeDetection.detection.id,
payload, payload,
}); });
setIsDiscardDialogOpen(false); setIsDiscardDialogOpen(false);
}} }}
/> />
</>
) : null}
</> </>
)} )}
</div> </div>

View File

@@ -22,6 +22,7 @@ export function TicketDetectionPreviewCard({
<CardContent className="p-5"> <CardContent className="p-5">
<div className="min-w-0"> <div className="min-w-0">
<TicketDetectionPreview <TicketDetectionPreview
key={`${videoId}:${assignmentId}`}
ticketId={ticketId} ticketId={ticketId}
videoId={videoId} videoId={videoId}
assignmentId={assignmentId} assignmentId={assignmentId}

View File

@@ -143,7 +143,7 @@ export function TicketOverviewCard({
<div className="space-y-5"> <div className="space-y-5">
{uploadedOn || uploaderName || locationLabel ? ( {uploadedOn || uploaderName || locationLabel ? (
<Card> <Card>
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x"> <CardContent className="grid gap-4 sm:grid-cols-3 sm:divide-x">
{uploadedOn ? ( {uploadedOn ? (
<div className="flex items-center gap-3 sm:pr-4"> <div className="flex items-center gap-3 sm:pr-4">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" /> <CalendarDays className="size-5 shrink-0 text-muted-foreground" />
@@ -183,7 +183,7 @@ export function TicketOverviewCard({
{analysisVideoId ? ( {analysisVideoId ? (
<CardAction> <CardAction>
<Button <Button
variant="secondary" variant="default"
onClick={() => setIsAnalysisOpen(true)} onClick={() => setIsAnalysisOpen(true)}
> >
<Play className="mr-2 size-4" /> <Play className="mr-2 size-4" />

View File

@@ -31,9 +31,12 @@ function SummarySkeletonCard() {
export function TicketOverviewHeaderSkeleton() { export function TicketOverviewHeaderSkeleton() {
return ( return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2"> <div className="flex min-w-0 items-start gap-3">
<Skeleton className="h-8 w-40" /> <Skeleton className="size-10 shrink-0 rounded-lg" />
<Skeleton className="h-3 w-36 max-w-full" /> <div className="min-w-0 space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-3 w-36 max-w-full" />
</div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">

View File

@@ -43,19 +43,32 @@ function getTicketAction(
ticketId: string, ticketId: string,
assignment?: TicketAssignmentSummary, assignment?: TicketAssignmentSummary,
) { ) {
if (assignment && !assignment.is_complete) { if (assignment) {
return ( return (
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.ASSIGN} permissions={PERMISSIONS.TICKET.ASSIGN}
fallback={ fallback={
<PermissionGuard <PermissionGuard
permissions={PERMISSIONS.TICKET.WORK} permissions={PERMISSIONS.TICKET.WORK}
fallback={<NoTicketAction />} fallback={
<PermissionGuard
permissions={PERMISSIONS.TICKET.READ}
fallback={<NoTicketAction />}
>
<RequestExtensionAction
ticketId={ticketId}
assignmentId={assignment.id}
dueAt={assignment.due_at}
canRequest={false}
/>
</PermissionGuard>
}
> >
<RequestExtensionAction <RequestExtensionAction
ticketId={ticketId} ticketId={ticketId}
assignmentId={assignment.id} assignmentId={assignment.id}
dueAt={assignment.due_at} dueAt={assignment.due_at}
canRequest={!assignment.is_complete}
/> />
</PermissionGuard> </PermissionGuard>
} }
@@ -63,6 +76,7 @@ function getTicketAction(
<ReviewExtensionRequestAction <ReviewExtensionRequestAction
ticketId={ticketId} ticketId={ticketId}
assignmentId={assignment.id} assignmentId={assignment.id}
canReview={!assignment.is_complete}
/> />
</PermissionGuard> </PermissionGuard>
); );

View File

@@ -0,0 +1,60 @@
'use client';
import { useState } from 'react';
import { ListChecks } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { TicketDetectionPreview } from './TicketDetectionPreview';
interface TicketUnassignedIssuesCardProps {
ticketId: string;
videoId?: string | null;
}
export function TicketUnassignedIssuesCard({
ticketId,
videoId,
}: TicketUnassignedIssuesCardProps) {
const [detectionCount, setDetectionCount] = useState<number>();
if (!videoId) return null;
return (
<Card className={'w-full'}>
<CardHeader className="min-w-0 px-3 sm:px-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2">
<ListChecks className="size-5 text-primary" />
Unassigned Issues
</CardTitle>
<CardDescription className="mt-1">
Review detections awaiting assignment and discard invalid AI
results.
</CardDescription>
</div>
<Badge variant={'default'}>{detectionCount ?? 0} Unassigned</Badge>
</div>
</CardHeader>
<CardContent className={'p-5'}>
<div className={'min-w-0'}>
<TicketDetectionPreview
key={`${videoId}:unassigned`}
ticketId={ticketId}
videoId={videoId}
assignmentStatus={'unassigned'}
onDetectionCountChange={setDetectionCount}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,162 @@
'use client';
import { ArrowRight, History } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import type {
TicketExtensionRequest,
TicketExtensionRequestStatus,
} from '@/types';
import { formatDate } from '@/utils/date';
const statusConfig: Record<
TicketExtensionRequestStatus,
{ label: string; className: string }
> = {
pending: {
label: 'Pending',
className:
'border-amber-200 bg-amber-100 text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-300',
},
approved: {
label: 'Approved',
className:
'border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-300',
},
rejected: {
label: 'Rejected',
className:
'border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300',
},
cancelled: {
label: 'Cancelled',
className: 'border-border bg-muted text-muted-foreground',
},
};
function HistoryItem({ request }: { request: TicketExtensionRequest }) {
const status = statusConfig[request.status];
return (
<article className="space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Badge variant="outline" className={cn('border', status.className)}>
{status.label}
</Badge>
<span className="text-xs text-muted-foreground">
Requested {formatDate(request.requested_at)}
</span>
</div>
<div>
<p className="text-xs text-muted-foreground">Requested By</p>
<p className="mt-0.5 text-sm font-medium">
{request.requested_by?.name ?? 'Assigned worker'}
</p>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 rounded-lg bg-muted/40 p-3">
<div>
<p className="text-xs text-muted-foreground">Previous Due Date</p>
<p className="mt-1 text-sm font-medium">
{formatDate(request.current_due_at)}
</p>
</div>
<ArrowRight className="mt-5 size-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Requested Due Date</p>
<p className="mt-1 text-sm font-medium">
{formatDate(request.requested_due_at)}
</p>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Reason</p>
<p className="mt-1 text-sm leading-relaxed">{request.reason}</p>
</div>
{request.resolved_at || request.resolved_by || request.decision_note ? (
<div className="space-y-2 border-t pt-3">
<p className="text-xs font-semibold">Manager Response</p>
<div className="grid gap-2 text-sm sm:grid-cols-2">
{request.resolved_by ? (
<div>
<p className="text-xs text-muted-foreground">Resolved By</p>
<p className="mt-0.5 font-medium">
{request.resolved_by.name} · {request.resolved_by.role.name}
</p>
</div>
) : null}
{request.resolved_at ? (
<div>
<p className="text-xs text-muted-foreground">Resolved On</p>
<p className="mt-0.5 font-medium">
{formatDate(request.resolved_at)}
</p>
</div>
) : null}
</div>
{request.decision_note ? (
<div>
<p className="text-xs text-muted-foreground">Manager Comments</p>
<p className="mt-1 text-sm leading-relaxed">
{request.decision_note}
</p>
</div>
) : null}
</div>
) : null}
</article>
);
}
export function ExtensionRequestHistoryDialog({
requests,
}: {
requests: TicketExtensionRequest[];
}) {
const sortedRequests = [...requests].sort(
(left, right) =>
new Date(right.requested_at).getTime() -
new Date(left.requested_at).getTime(),
);
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" className="w-full">
<History />
View Request History ({requests.length})
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Extension Request History</DialogTitle>
<DialogDescription>
Review every deadline extension request and manager decision for
this ticket assignment.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-3">
{sortedRequests.map((request) => (
<HistoryItem key={request.id} request={request} />
))}
</DialogBody>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
);
}

View File

@@ -3,19 +3,43 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ClipboardCheck } from 'lucide-react'; import { ClipboardCheck } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
export function OpenRepairReviewAction({ ticketId }: { ticketId: string }) { interface OpenRepairReviewActionProps {
ticketId: string;
hasPendingReview: boolean;
pendingReviewCount: number;
}
export function OpenRepairReviewAction({
ticketId,
hasPendingReview,
pendingReviewCount,
}: OpenRepairReviewActionProps) {
const router = useRouter(); const router = useRouter();
const count = Math.max(0, pendingReviewCount ?? 0);
return ( return (
<Button <Button
type="button" type="button"
disabled={!hasPendingReview}
title={
hasPendingReview
? `${count} ${count === 1 ? 'repair is' : 'repairs are'} awaiting review`
: 'No repairs are awaiting review'
}
onClick={() => router.push(ROUTES.TICKET_REVIEW(ticketId))} onClick={() => router.push(ROUTES.TICKET_REVIEW(ticketId))}
> >
<ClipboardCheck /> <ClipboardCheck />
Review Repairs Review Repairs
<Badge
variant="secondary"
className="h-5 min-w-5 rounded-full px-1.5 font-semibold tabular-nums"
>
{count}
</Badge>
</Button> </Button>
); );
} }

View File

@@ -7,21 +7,24 @@ export function RequestExtensionAction({
ticketId, ticketId,
assignmentId, assignmentId,
dueAt, dueAt,
canRequest,
}: { }: {
ticketId: string; ticketId: string;
assignmentId: number; assignmentId: number;
dueAt: string | null; dueAt: string | null;
canRequest: boolean;
}) { }) {
return ( return (
<TicketActionCard <TicketActionCard
icon={Clock3} icon={Clock3}
title="Request Extension" title={canRequest ? 'Request Extension' : 'Extension Request History'}
contextLabel={`Lot ${assignmentId}`} contextLabel={`Assignment ${assignmentId}`}
> >
<TicketExtensionRequestPanel <TicketExtensionRequestPanel
ticketId={ticketId} ticketId={ticketId}
assignmentId={assignmentId} assignmentId={assignmentId}
dueAt={dueAt} dueAt={dueAt}
canRequest={canRequest}
/> />
</TicketActionCard> </TicketActionCard>
); );

View File

@@ -33,6 +33,11 @@ import {
useExtensionRequestDecision, useExtensionRequestDecision,
} from '../../../../extension-requests/hooks/useExtensionRequestDecision'; } from '../../../../extension-requests/hooks/useExtensionRequestDecision';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries'; import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
import {
ExtensionRequestDetails,
getLatestRequest,
} from './TicketExtensionRequestPanel';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
function getPendingRequest(response: TicketExtensionRequestsResponse) { function getPendingRequest(response: TicketExtensionRequestsResponse) {
@@ -86,10 +91,12 @@ function ExtensionReviewForm({
ticketId, ticketId,
assignmentId, assignmentId,
request, request,
requests,
}: { }: {
ticketId: string; ticketId: string;
assignmentId: number; assignmentId: number;
request: TicketExtensionRequest; request: TicketExtensionRequest;
requests: TicketExtensionRequest[];
}) { }) {
const { const {
note, note,
@@ -108,7 +115,7 @@ function ExtensionReviewForm({
<TicketActionCard <TicketActionCard
icon={CalendarClock} icon={CalendarClock}
title="Extension Request Details" title="Extension Request Details"
contextLabel={`Lot ${assignmentId}`} contextLabel={`Assignment ${assignmentId}`}
> >
<div className="space-y-4"> <div className="space-y-4">
<Requester request={request} /> <Requester request={request} />
@@ -210,6 +217,8 @@ function ExtensionReviewForm({
</Button> </Button>
</div> </div>
<ExtensionRequestHistoryDialog requests={requests} />
<AlertDialog <AlertDialog
open={isRejectConfirmationOpen} open={isRejectConfirmationOpen}
onOpenChange={setIsRejectConfirmationOpen} onOpenChange={setIsRejectConfirmationOpen}
@@ -242,9 +251,11 @@ function ExtensionReviewForm({
export function ReviewExtensionRequestAction({ export function ReviewExtensionRequestAction({
ticketId, ticketId,
assignmentId, assignmentId,
canReview,
}: { }: {
ticketId: string; ticketId: string;
assignmentId: number; assignmentId: number;
canReview: boolean;
}) { }) {
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticketId, ticketId,
@@ -260,7 +271,7 @@ export function ReviewExtensionRequestAction({
<TicketActionCard <TicketActionCard
icon={Clock3} icon={Clock3}
title="Extension Request" title="Extension Request"
contextLabel={`Lot ${assignmentId}`} contextLabel={`Assignment ${assignmentId}`}
> >
<div className="flex items-center justify-center py-8 text-muted-foreground"> <div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" /> <Loader2 className="mr-2 size-4 animate-spin" />
@@ -274,13 +285,32 @@ export function ReviewExtensionRequestAction({
? getPendingRequest(extensionRequestQuery.data) ? getPendingRequest(extensionRequestQuery.data)
: null; : null;
if (!pendingRequest) return null; const requests = extensionRequestQuery.data?.items ?? [];
if (!pendingRequest || !canReview) {
const latestRequest = extensionRequestQuery.data
? getLatestRequest(extensionRequestQuery.data)
: null;
if (!latestRequest) return null;
return (
<TicketActionCard
icon={CalendarClock}
title="Extension Request"
contextLabel={`Assignment ${assignmentId}`}
>
<ExtensionRequestDetails request={latestRequest} requests={requests} />
</TicketActionCard>
);
}
return ( return (
<ExtensionReviewForm <ExtensionReviewForm
ticketId={ticketId} ticketId={ticketId}
assignmentId={assignmentId} assignmentId={assignmentId}
request={pendingRequest} request={pendingRequest}
requests={requests}
/> />
); );
} }

View File

@@ -30,7 +30,7 @@ export function TicketActionCard({
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
</CardTitle> </CardTitle>
{contextLabel ? ( {contextLabel ? (
<Badge variant="secondary" className="shrink-0"> <Badge variant="default" className="shrink-0">
{contextLabel} {contextLabel}
</Badge> </Badge>
) : null} ) : null}

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useState } from 'react';
import { import {
AlertCircle, AlertCircle,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
Clock3, Clock3,
History,
Loader2, Loader2,
Plus,
RotateCcw, RotateCcw,
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
@@ -20,6 +21,7 @@ import type {
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries'; import { useTicketExtensionRequestQuery } from '../../../hooks/useTicketQueries';
import { ExtensionRequestHistoryDialog } from './ExtensionRequestHistoryDialog';
import { RequestExtensionForm } from './RequestExtensionForm'; import { RequestExtensionForm } from './RequestExtensionForm';
interface DetailRowProps { interface DetailRowProps {
@@ -44,21 +46,21 @@ function DetailRow({ label, value, children, className }: DetailRowProps) {
const statusConfig = { const statusConfig = {
pending: { pending: {
title: 'Request Pending', title: 'Request Pending',
description: 'Your extension request is waiting for manager approval.', description: 'This extension request is waiting for manager approval.',
icon: Clock3, icon: Clock3,
className: className:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300', 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300',
}, },
approved: { approved: {
title: 'Request Approved', title: 'Request Approved',
description: 'Your extension request has been approved by the manager.', description: 'This extension request was approved by the manager.',
icon: CheckCircle2, icon: CheckCircle2,
className: className:
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300', 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300',
}, },
rejected: { rejected: {
title: 'Request Rejected', title: 'Request Rejected',
description: 'Your extension request has been rejected by the manager.', description: 'This extension request was rejected by the manager.',
icon: AlertCircle, icon: AlertCircle,
className: className:
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300', 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300',
@@ -79,7 +81,7 @@ const statusConfig = {
} }
>; >;
function getLatestRequest(response: TicketExtensionRequestsResponse) { export function getLatestRequest(response: TicketExtensionRequestsResponse) {
return response.items.reduce<TicketExtensionRequest | null>( return response.items.reduce<TicketExtensionRequest | null>(
(latest, item) => { (latest, item) => {
if (!latest) return item; if (!latest) return item;
@@ -98,10 +100,14 @@ function getExtensionDays(request: TicketExtensionRequest) {
return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000))); return Math.max(0, Math.ceil(difference / (24 * 60 * 60 * 1000)));
} }
function ExtensionRequestDetails({ export function ExtensionRequestDetails({
request, request,
requests,
onRequestAnother,
}: { }: {
request: TicketExtensionRequest; request: TicketExtensionRequest;
requests: TicketExtensionRequest[];
onRequestAnother?: () => void;
}) { }) {
const config = statusConfig[request.status]; const config = statusConfig[request.status];
const StatusIcon = config.icon; const StatusIcon = config.icon;
@@ -124,6 +130,10 @@ function ExtensionRequestDetails({
label="Requested On" label="Requested On"
value={formatDate(request.requested_at)} value={formatDate(request.requested_at)}
/> />
<DetailRow
label="Requested By"
value={request.requested_by?.name ?? 'Assigned worker'}
/>
<DetailRow <DetailRow
label="Requested Due Date" label="Requested Due Date"
value={formatDate(request.requested_due_at)} value={formatDate(request.requested_due_at)}
@@ -160,6 +170,12 @@ function ExtensionRequestDetails({
value={formatDate(request.requested_due_at)} value={formatDate(request.requested_due_at)}
/> />
) : null} ) : null}
{request.decision_note ? (
<DetailRow
label="Manager Comments"
value={request.decision_note}
/>
) : null}
</div> </div>
</section> </section>
) : null} ) : null}
@@ -176,10 +192,15 @@ function ExtensionRequestDetails({
</div> </div>
</section> </section>
<Button type="button" variant="outline" className="w-full"> <div className="space-y-2">
<History /> <ExtensionRequestHistoryDialog requests={requests} />
View Approval History {request.status !== 'pending' && onRequestAnother ? (
</Button> <Button type="button" className="w-full" onClick={onRequestAnother}>
<Plus />
Request Another Extension
</Button>
) : null}
</div>
</div> </div>
); );
} }
@@ -188,11 +209,14 @@ export function TicketExtensionRequestPanel({
ticketId, ticketId,
assignmentId, assignmentId,
dueAt, dueAt,
canRequest,
}: { }: {
ticketId: string; ticketId: string;
assignmentId: number; assignmentId: number;
dueAt: string | null; dueAt: string | null;
canRequest: boolean;
}) { }) {
const [isRequestFormOpen, setIsRequestFormOpen] = useState(false);
const extensionRequestQuery = useTicketExtensionRequestQuery( const extensionRequestQuery = useTicketExtensionRequestQuery(
ticketId, ticketId,
assignmentId, assignmentId,
@@ -231,6 +255,14 @@ export function TicketExtensionRequestPanel({
: null; : null;
if (!latestRequest) { if (!latestRequest) {
if (!canRequest) {
return (
<p className="text-sm text-muted-foreground">
No extension request history is available for this ticket assignment.
</p>
);
}
return ( return (
<RequestExtensionForm <RequestExtensionForm
ticketId={ticketId} ticketId={ticketId}
@@ -240,5 +272,33 @@ export function TicketExtensionRequestPanel({
); );
} }
return <ExtensionRequestDetails request={latestRequest} />; if (isRequestFormOpen && latestRequest.status !== 'pending') {
return (
<div className="space-y-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsRequestFormOpen(false)}
>
Back to latest request
</Button>
<RequestExtensionForm
ticketId={ticketId}
assignmentId={assignmentId}
currentDueAt={dueAt}
/>
</div>
);
}
return (
<ExtensionRequestDetails
request={latestRequest}
requests={extensionRequestQuery.data?.items ?? []}
onRequestAnother={
canRequest ? () => setIsRequestFormOpen(true) : undefined
}
/>
);
} }

View File

@@ -1,10 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { ROUTES } from '@/utils/routes';
import { TicketDetailHeader } from './components/TicketDetailHeader'; import { TicketDetailHeader } from './components/TicketDetailHeader';
import { import {
@@ -14,8 +17,8 @@ import {
import { TicketAssignmentsCard } from './components/TicketAssignmentsCard'; import { TicketAssignmentsCard } from './components/TicketAssignmentsCard';
import { TicketOverviewCard } from './components/TicketOverviewCard'; import { TicketOverviewCard } from './components/TicketOverviewCard';
import { TicketStatusActions } from './components/TicketStatusActions'; import { TicketStatusActions } from './components/TicketStatusActions';
import { TicketDetectionPreviewCard } from './components/TicketDetectionPreviewCard';
import { TicketHistoryCard } from './components/TicketHistoryCard'; import { TicketHistoryCard } from './components/TicketHistoryCard';
import { TicketUnassignedIssuesCard } from './components/TicketUnassignedIssuesCard';
import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents'; import { useTicketDetailEvents } from '../hooks/useTicketDetailEvents';
import { import {
useTicketAssignmentsQuery, useTicketAssignmentsQuery,
@@ -24,8 +27,20 @@ import {
export default function TicketDetailPage() { export default function TicketDetailPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { ticketId } = useParams() as { ticketId: string }; const { ticketId } = useParams() as { ticketId: string };
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>(); const [selectedAssignmentId, setSelectedAssignmentId] = useState<number>();
const listParams = new URLSearchParams();
for (const key of ['page', 'limit', 'segment']) {
const value = searchParams.get(key);
if (value) listParams.set(key, value);
}
const listSearch = listParams.toString();
const ticketListHref = listSearch
? `${ROUTES.TICKET}?${listSearch}`
: ROUTES.TICKET;
const overviewQuery = useTicketOverviewQuery(ticketId); const overviewQuery = useTicketOverviewQuery(ticketId);
const overview = overviewQuery.data; const overview = overviewQuery.data;
@@ -50,7 +65,7 @@ export default function TicketDetailPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => router.push('/ticket')} onClick={() => router.push(ticketListHref)}
> >
<ArrowLeft /> <ArrowLeft />
Back to Tickets Back to Tickets
@@ -63,7 +78,7 @@ export default function TicketDetailPage() {
<main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden"> <main className="relative z-10 min-w-0 max-w-full space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<div className="xl:shrink-0"> <div className="xl:shrink-0">
{overview ? ( {overview ? (
<TicketDetailHeader ticket={overview} /> <TicketDetailHeader ticket={overview} backHref={ticketListHref} />
) : ( ) : (
<TicketOverviewHeaderSkeleton /> <TicketOverviewHeaderSkeleton />
)} )}
@@ -79,17 +94,19 @@ export default function TicketDetailPage() {
{overview ? ( {overview ? (
<TicketAssignmentsCard <TicketAssignmentsCard
ticketId={ticketId} ticketId={ticketId}
videoId={overview.video_id ?? overview.video?.id}
initialAssignments={overview.assignments} initialAssignments={overview.assignments}
selectedAssignmentId={activeAssignmentId} selectedAssignmentId={activeAssignmentId}
onAssignmentChange={setSelectedAssignmentId} onAssignmentChange={setSelectedAssignmentId}
/> />
) : null} ) : null}
{activeAssignment ? ( {overview ? (
<TicketDetectionPreviewCard <PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
ticketId={ticketId} <TicketUnassignedIssuesCard
videoId={overview?.video_id ?? overview?.video?.id} ticketId={ticketId}
assignmentId={activeAssignment.id} videoId={overview.video_id ?? overview.video?.id}
/> />
</PermissionGuard>
) : null} ) : null}
<TicketHistoryCard ticketId={ticketId} /> <TicketHistoryCard ticketId={ticketId} />
</div> </div>

View File

@@ -176,13 +176,9 @@ export function IssueReviewAction({
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="default"
disabled={isPending} disabled={isPending}
onClick={() => void submitDecision('approve')} onClick={() => void submitDecision('approve')}
className={cn(
decision === 'approve' &&
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
)}
> >
{isPending && decision === 'approve' ? ( {isPending && decision === 'approve' ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
@@ -193,13 +189,9 @@ export function IssueReviewAction({
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="destructive"
disabled={isPending} disabled={isPending}
onClick={() => void submitDecision('reject')} onClick={() => void submitDecision('reject')}
className={cn(
decision === 'reject' &&
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
)}
> >
{isPending && decision === 'reject' ? ( {isPending && decision === 'reject' ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />

View File

@@ -17,7 +17,6 @@ const PROOF_STATUS_FILTERS: Array<{
interface IssueReviewHeaderProps { interface IssueReviewHeaderProps {
ticketLabel: string; ticketLabel: string;
defectLabel: string;
currentIndex: number; currentIndex: number;
total: number; total: number;
proofStatus: ProofStatusFilter; proofStatus: ProofStatusFilter;
@@ -29,7 +28,6 @@ interface IssueReviewHeaderProps {
export function IssueReviewHeader({ export function IssueReviewHeader({
ticketLabel, ticketLabel,
defectLabel,
currentIndex, currentIndex,
total, total,
proofStatus, proofStatus,
@@ -54,7 +52,7 @@ export function IssueReviewHeader({
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-2xl font-semibold tracking-tight"> <h1 className="truncate text-2xl font-semibold tracking-tight">
{defectLabel} Review Repair Review
</h1> </h1>
<Badge variant="secondary">{ticketLabel}</Badge> <Badge variant="secondary">{ticketLabel}</Badge>
</div> </div>

View File

@@ -227,7 +227,6 @@ export default function TicketReviewPage() {
<div className="space-y-5"> <div className="space-y-5">
<IssueReviewHeader <IssueReviewHeader
ticketLabel={ticket.ticket_name || ticket.id} ticketLabel={ticket.ticket_name || ticket.id}
defectLabel="Repair"
currentIndex={0} currentIndex={0}
total={0} total={0}
proofStatus={proofStatus} proofStatus={proofStatus}
@@ -256,7 +255,6 @@ export default function TicketReviewPage() {
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden"> <main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
<IssueReviewHeader <IssueReviewHeader
ticketLabel={ticket.ticket_name || ticket.id} ticketLabel={ticket.ticket_name || ticket.id}
defectLabel={defectLabel}
currentIndex={activeIndex} currentIndex={activeIndex}
total={totalCount} total={totalCount}
proofStatus={proofStatus} proofStatus={proofStatus}

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,24 @@ 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} />,
},
{
accessorKey: 'is_closed',
header: 'Status',
size: 100,
meta: { disableTruncate: true },
cell: ({ row }) => (
<Badge variant={row.original.is_closed ? 'secondary' : 'default'}>
{row.original.is_closed ? 'Closed' : 'Open'}
</Badge>
),
},
{ {
id: 'created_by', id: 'created_by',
header: 'Uploaded By', header: 'Uploaded By',

View File

@@ -1,19 +1,36 @@
'use client'; 'use client';
import { FilterX } from 'lucide-react';
import { SegmentSelect } from '@/components/lookups/SegmentSelect'; import { SegmentSelect } from '@/components/lookups/SegmentSelect';
import { Button } from '@/components/ui/button';
interface TicketFiltersProps { interface TicketFiltersProps {
segmentId: string; segmentId: string;
onSegmentChange: (segmentId: string) => void; onSegmentChange: (segmentId: string) => void;
onClear: () => void;
} }
export function TicketFilters({ export function TicketFilters({
segmentId, segmentId,
onSegmentChange, onSegmentChange,
onClear,
}: TicketFiltersProps) { }: TicketFiltersProps) {
return ( return (
<div className="w-full sm:w-64"> <div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center">
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} /> <div className="w-full sm:w-64">
<SegmentSelect value={segmentId} onValueChange={onSegmentChange} />
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!segmentId}
onClick={onClear}
>
<FilterX />
Clear Filters
</Button>
</div> </div>
); );
} }

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

@@ -99,10 +99,17 @@ function buildTicketListItem(
chainage_name: event.chainage_name ?? null, chainage_name: event.chainage_name ?? null,
default_defect_class: event.default_defect_class ?? null, default_defect_class: event.default_defect_class ?? null,
detection_count: event.detection_count, detection_count: event.detection_count,
is_closed: event.is_closed ?? false,
created_by_name: event.created_by_name ?? null, created_by_name: event.created_by_name ?? null,
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,
},
}; };
} }
@@ -118,10 +125,12 @@ function mergeTicketListItem(
default_defect_class: default_defect_class:
event.default_defect_class ?? current.default_defect_class, event.default_defect_class ?? current.default_defect_class,
detection_count: event.detection_count ?? current.detection_count, detection_count: event.detection_count ?? current.detection_count,
is_closed: event.is_closed ?? current.is_closed,
created_by_name: event.created_by_name ?? current.created_by_name, created_by_name: event.created_by_name ?? current.created_by_name,
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 +141,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

@@ -2,6 +2,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
keepPreviousData,
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
useQuery, useQuery,
@@ -17,6 +18,7 @@ import type {
RequestTicketExtensionPayload, RequestTicketExtensionPayload,
ReviewDetectionRepairProofPayload, ReviewDetectionRepairProofPayload,
TicketAssignmentSummary, TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketListParams, TicketListParams,
DetectionProofStatus, DetectionProofStatus,
VideoDetectionsParams, VideoDetectionsParams,
@@ -27,6 +29,11 @@ import { extensionRequestKeys } from '../../extension-requests/queries/extension
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000; const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
interface TicketDetectionsQueryOptions {
enabled?: boolean;
keepPreviousPage?: boolean;
}
interface UseTicketsQueryParams { interface UseTicketsQueryParams {
skip: number; skip: number;
limit: number; limit: number;
@@ -60,6 +67,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 ?? ''),
@@ -98,12 +119,15 @@ export function useTicketAssignmentsQuery(
export function useTicketDetectionsQuery( export function useTicketDetectionsQuery(
videoId: string | undefined, videoId: string | undefined,
params: VideoDetectionsParams, params: VideoDetectionsParams,
options: TicketDetectionsQueryOptions = {},
) { ) {
const { enabled = true, keepPreviousPage = false } = options;
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
skip: params.skip ?? 0, skip: params.skip ?? 0,
limit: params.limit ?? 1, limit: params.limit ?? 1,
assignment_id: params.assignment_id, assignment_id: params.assignment_id,
assignment_status: params.assignment_status,
class_name: params.class_name, class_name: params.class_name,
min_confidence: params.min_confidence, min_confidence: params.min_confidence,
sort: params.sort ?? 'timestamp_asc', sort: params.sort ?? 'timestamp_asc',
@@ -111,6 +135,7 @@ export function useTicketDetectionsQuery(
}), }),
[ [
params.assignment_id, params.assignment_id,
params.assignment_status,
params.class_name, params.class_name,
params.limit, params.limit,
params.min_confidence, params.min_confidence,
@@ -124,7 +149,8 @@ export function useTicketDetectionsQuery(
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams), queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
queryFn: () => queryFn: () =>
videoService.getVideoDetections(videoId as string, queryParams), videoService.getVideoDetections(videoId as string, queryParams),
enabled: Boolean(videoId), enabled: Boolean(enabled && videoId),
placeholderData: keepPreviousPage ? keepPreviousData : undefined,
staleTime: Infinity, staleTime: Infinity,
}); });
} }
@@ -136,6 +162,33 @@ export function useTicketClassDetectionsQuery(
return useTicketDetectionsQuery(videoId, params); return useTicketDetectionsQuery(videoId, params);
} }
export function useTicketAssignmentDetectionsQuery(
ticketId: string | undefined,
params: TicketAssignmentDetectionsParams,
enabled = true,
) {
const queryParams = useMemo(
() => ({
assignment_status: params.assignment_status ?? 'unassigned',
class_name: params.class_name,
skip: params.skip ?? 0,
limit: params.limit ?? 500,
}),
[params.assignment_status, params.class_name, params.limit, params.skip],
);
return useQuery({
queryKey: ticketKeys.assignmentDetections(ticketId ?? '', queryParams),
queryFn: () =>
ticketService.getTicketAssignmentDetections(
ticketId as string,
queryParams,
),
enabled: Boolean(enabled && ticketId),
staleTime: Infinity,
});
}
export function useTicketReviewDetectionsQuery( export function useTicketReviewDetectionsQuery(
videoId: string | undefined, videoId: string | undefined,
proofStatus?: DetectionProofStatus, proofStatus?: DetectionProofStatus,
@@ -187,6 +240,10 @@ 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({
queryKey: ticketKeys.assignmentDetectionLists(ticketId),
}),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ticketKeys.assignments(ticketId), queryKey: ticketKeys.assignments(ticketId),
}), }),
@@ -233,6 +290,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'),
@@ -262,7 +320,7 @@ export function useAssignableTicketUsersQuery(enabled: boolean) {
}); });
} }
export function useAssignTicketMutation(ticketId: string, videoId?: string) { export function useAssignTicketMutation(ticketId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@@ -278,13 +336,13 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) {
queryKey: ticketKeys.timeline(ticketId), queryKey: ticketKeys.timeline(ticketId),
}), }),
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }), queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }),
...(videoId queryClient.invalidateQueries({ queryKey: ticketKeys.summaries() }),
? [ queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: ticketKeys.assignmentDetectionLists(ticketId),
queryKey: ticketKeys.classDetectionLists(videoId), }),
}), queryClient.invalidateQueries({
] queryKey: ticketKeys.assignments(ticketId),
: []), }),
]); ]);
}, },
onError: () => toast.error('Failed to assign ticket'), onError: () => toast.error('Failed to assign ticket'),
@@ -323,6 +381,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'),
}); });
@@ -345,6 +404,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

@@ -1,23 +1,64 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Ticket } from 'lucide-react'; import { Ticket } from 'lucide-react';
import type { TicketListItem } from '@/types'; 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';
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
const PAGE_SIZE_OPTIONS = new Set([10, 20, 40, 50, 100]);
function parsePage(value: string | null) {
const page = Number(value);
return Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE;
}
function parseLimit(value: string | null) {
const limit = Number(value);
return PAGE_SIZE_OPTIONS.has(limit) ? limit : DEFAULT_LIMIT;
}
export default function TicketPage() { export default function TicketPage() {
const router = useRouter(); const router = useRouter();
const [skip, setSkip] = useState(0); const pathname = usePathname();
const [limit, setLimit] = useState(10); const searchParams = useSearchParams();
const [segmentId, setSegmentId] = useState(''); const searchParamsString = searchParams.toString();
const latestSearchParamsRef = useRef(searchParamsString);
useEffect(() => {
latestSearchParamsRef.current = searchParamsString;
}, [searchParamsString]);
const page = parsePage(searchParams.get('page'));
const limit = parseLimit(searchParams.get('limit'));
const segmentId = searchParams.get('segment') ?? '';
const skip = (page - 1) * limit;
const replaceListParams = useCallback(
(update: (params: URLSearchParams) => void) => {
const nextParams = new URLSearchParams(latestSearchParamsRef.current);
update(nextParams);
const nextSearch = nextParams.toString();
latestSearchParamsRef.current = nextSearch;
router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname, {
scroll: false,
});
},
[pathname, router],
);
useTenantTicketTableEvents(); useTenantTicketTableEvents();
@@ -26,21 +67,62 @@ 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 ?? [];
const total = ticketsQuery.data?.total ?? 0; const total = ticketsQuery.data?.total ?? 0;
const handleSegmentChange = useCallback((nextSegmentId: string) => { const handleSegmentChange = useCallback(
setSegmentId(nextSegmentId); (nextSegmentId: string) => {
setSkip(0); replaceListParams((params) => {
}, []); params.delete('page');
if (nextSegmentId) {
params.set('segment', nextSegmentId);
} else {
params.delete('segment');
}
});
},
[replaceListParams],
);
const handlePageChange = useCallback(
(nextSkip: number) => {
const nextPage = Math.floor(nextSkip / limit) + 1;
replaceListParams((params) => {
if (nextPage === DEFAULT_PAGE) {
params.delete('page');
} else {
params.set('page', String(nextPage));
}
});
},
[limit, replaceListParams],
);
const handleLimitChange = useCallback(
(nextLimit: number) => {
replaceListParams((params) => {
params.delete('page');
if (nextLimit === DEFAULT_LIMIT) {
params.delete('limit');
} else {
params.set('limit', String(nextLimit));
}
});
},
[replaceListParams],
);
const handleView = useCallback( const handleView = useCallback(
(ticket: TicketListItem) => { (ticket: TicketListItem) => {
router.push(ROUTES.TICKET_DETAIL(ticket.id)); const detailPath = ROUTES.TICKET_DETAIL(ticket.id);
router.push(
searchParamsString ? `${detailPath}?${searchParamsString}` : detailPath,
);
}, },
[router], [router, searchParamsString],
); );
const toolbar = useMemo( const toolbar = useMemo(
@@ -48,6 +130,7 @@ export default function TicketPage() {
<TicketFilters <TicketFilters
segmentId={segmentId} segmentId={segmentId}
onSegmentChange={handleSegmentChange} onSegmentChange={handleSegmentChange}
onClear={() => handleSegmentChange('')}
/> />
), ),
[handleSegmentChange, segmentId], [handleSegmentChange, segmentId],
@@ -61,6 +144,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}
@@ -69,8 +158,8 @@ export default function TicketPage() {
skip={skip} skip={skip}
limit={limit} limit={limit}
total={total} total={total}
onPageChange={setSkip} onPageChange={handlePageChange}
onLimitChange={setLimit} onLimitChange={handleLimitChange}
onView={handleView} onView={handleView}
/> />
</main> </main>

View File

@@ -1,9 +1,16 @@
import type { TicketListParams, VideoDetectionsParams } from '@/types'; import type {
TicketAssignmentDetectionsParams,
TicketListParams,
VideoDetectionsParams,
} from '@/types';
export const ticketKeys = { 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,
@@ -19,6 +26,12 @@ export const ticketKeys = {
'review', 'review',
proofStatus ?? 'all', proofStatus ?? 'all',
] as const, ] as const,
assignmentDetectionLists: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignment-detections'] as const,
assignmentDetections: (
ticketId: string,
params: TicketAssignmentDetectionsParams,
) => [...ticketKeys.assignmentDetectionLists(ticketId), params] as const,
assignments: (ticketId: string) => assignments: (ticketId: string) =>
[...ticketKeys.details(), ticketId, 'assignments'] as const, [...ticketKeys.details(), ticketId, 'assignments'] as const,
extensionRequest: (ticketId: string, assignmentId: number | undefined) => extensionRequest: (ticketId: string, assignmentId: number | undefined) =>

View File

@@ -54,7 +54,7 @@ export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
}, },
{ {
id: 'uploaded_by', id: 'uploaded_by',
header: 'Uploader', header: 'Uploaded By',
size: 190, size: 190,
cell: ({ row }) => ( cell: ({ row }) => (
<div> <div>

View File

@@ -69,6 +69,7 @@ export function useUserColumns(): ColumnDef<AdministrationUser>[] {
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Badge
className="capitalize"
variant={ variant={
row.original.effective_status === 'active' row.original.effective_status === 'active'
? 'default' ? 'default'

View File

@@ -2,7 +2,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ColumnDef, SortingState } from '@tanstack/react-table'; import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { Edit, RotateCcw } from 'lucide-react'; import { Edit, UserCheck, UserX } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
@@ -59,7 +59,12 @@ export function UserTable({
{ {
label: (user) => label: (user) =>
user.effective_status === 'active' ? 'Deactivate' : 'Activate', user.effective_status === 'active' ? 'Deactivate' : 'Activate',
icon: <RotateCcw className="size-4" />, icon: (user) =>
user.effective_status === 'active' ? (
<UserX className="size-4 text-destructive" />
) : (
<UserCheck className="size-4 text-emerald-600" />
),
permission: PERMISSIONS.USER.DELETE, permission: PERMISSIONS.USER.DELETE,
disabled: (user) => disabled: (user) =>
pendingUserId === user.id || user.effective_status === 'pending', pendingUserId === user.id || user.effective_status === 'pending',

View File

@@ -7,7 +7,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--radius: 0.225rem; --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);
@@ -140,6 +140,40 @@
* { * {
@apply outline-none border-border outline-ring/50; @apply outline-none border-border outline-ring/50;
scrollbar-color: color-mix(
in oklab,
var(--muted-foreground) 45%,
transparent
)
transparent;
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track,
*::-webkit-scrollbar-corner {
background: transparent;
}
*::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: color-mix(
in oklab,
var(--muted-foreground) 45%,
transparent
);
}
*::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in oklab,
var(--muted-foreground) 70%,
transparent
);
} }
body { body {

View File

@@ -14,6 +14,14 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -124,6 +132,7 @@ function SidebarNavItem({
item: MenuItem; item: MenuItem;
pathname: string; pathname: string;
}) { }) {
const { isMobile, state } = useSidebar();
const isActive = isRouteActive(pathname, item.path); const isActive = isRouteActive(pathname, item.path);
const isChildActive = const isChildActive =
item.children?.some((child) => isRouteActive(pathname, child.path)) ?? item.children?.some((child) => isRouteActive(pathname, child.path)) ??
@@ -136,6 +145,52 @@ function SidebarNavItem({
}, [isChildActive]); }, [isChildActive]);
if (hasChildren) { if (hasChildren) {
if (state === 'collapsed' && !isMobile) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
isActive={isChildActive}
aria-label={item.title}
>
<item.icon />
<span>{item.title}</span>
<ChevronRight className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
sideOffset={8}
className="min-w-48"
>
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.children?.map((child) =>
child.path ? (
<DropdownMenuItem
key={child.title}
asChild
className={
isRouteActive(pathname, child.path)
? 'bg-accent text-accent-foreground'
: undefined
}
>
<Link href={child.path}>
<child.icon />
<span>{child.title}</span>
</Link>
</DropdownMenuItem>
) : null,
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
}
return ( return (
<Collapsible <Collapsible
asChild asChild

View File

@@ -16,7 +16,7 @@ import { getPinnedColumnStyles } from './columnStyles';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => { const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
return ( return (
<ShadTableHeader className="sticky top-0 z-20 bg-muted/70 backdrop-blur supports-backdrop-filter:bg-muted/60"> <ShadTableHeader className="sticky top-0 z-20 bg-muted">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
@@ -39,7 +39,7 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
canSort ? header.column.getToggleSortingHandler() : undefined canSort ? header.column.getToggleSortingHandler() : undefined
} }
className={cn( className={cn(
'h-11 max-w-0 overflow-hidden border-b border-border bg-muted/70 px-4 text-sm font-semibold text-foreground transition-colors', 'h-11 max-w-0 overflow-hidden border-b border-border bg-muted px-4 text-sm font-semibold text-foreground transition-colors',
canSort && 'cursor-pointer select-none hover:bg-muted', canSort && 'cursor-pointer select-none hover:bg-muted',
sortDirection && 'bg-muted', sortDirection && 'bg-muted',
)} )}

View File

@@ -41,7 +41,7 @@ export type ColumnAlign = 'left' | 'right';
export interface DataTableAction<TData> { export interface DataTableAction<TData> {
label: string | ((item: TData) => string); label: string | ((item: TData) => string);
icon: React.ReactNode; icon: React.ReactNode | ((item: TData) => React.ReactNode);
onClick: (item: TData) => void; onClick: (item: TData) => void;
permission?: PermissionInput; permission?: PermissionInput;
disabled?: (item: TData) => boolean; disabled?: (item: TData) => boolean;
@@ -163,6 +163,10 @@ function DataTableContent<TData, TValue>({
typeof action.label === 'function' typeof action.label === 'function'
? action.label(item) ? action.label(item)
: action.label; : action.label;
const icon =
typeof action.icon === 'function'
? action.icon(item)
: action.icon;
return ( return (
<TableActionButton <TableActionButton
@@ -176,7 +180,7 @@ function DataTableContent<TData, TValue>({
action.onClick(item); action.onClick(item);
}} }}
> >
{action.icon} {icon}
</TableActionButton> </TableActionButton>
); );
})} })}

View File

@@ -41,11 +41,7 @@ export default function AnnotatedDetectionImage({
] ]
: []; : [];
const aspectRatio = const aspectRatio = aspectRatioOverride ?? '16 / 9';
aspectRatioOverride ??
(videoWidth > 0 && videoHeight > 0
? `${videoWidth} / ${videoHeight}`
: '16 / 9');
const annotatedMedia = objectUrl ? ( const annotatedMedia = objectUrl ? (
<> <>
@@ -75,14 +71,7 @@ export default function AnnotatedDetectionImage({
className="group relative flex w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border bg-black focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="group relative flex w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border bg-black focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ aspectRatio }} style={{ aspectRatio }}
> >
<Image {annotatedMedia}
src={objectUrl}
alt={`${detection.detection.display_name} detection`}
fill
unoptimized
sizes="(min-width: 1280px) 18rem, (min-width: 640px) 50vw, 100vw"
className="object-contain"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-8 text-center text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"> <span className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-8 text-center text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
Click to view annotated image Click to view annotated image
</span> </span>

View File

@@ -63,7 +63,10 @@ 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) =>
`/biz/api/v1/tickets/${id}/assignment-detections`,
TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`, TIMELINE: (id: string) => `/biz/api/v1/tickets/${id}/timeline`,
ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`, ASSIGNMENTS: (id: string) => `/biz/api/v1/tickets/${id}/assignments`,
REQUEST_EXTENSION: (ticketId: string, assignmentId: number) => REQUEST_EXTENSION: (ticketId: string, assignmentId: number) =>

View File

@@ -9,12 +9,16 @@ import type {
ReviewTicketExtensionPayload, ReviewTicketExtensionPayload,
SseTokenResponse, SseTokenResponse,
TicketAssignmentSummary, TicketAssignmentSummary,
TicketAssignmentDetectionsParams,
TicketAssignmentDetectionsResponse,
TicketAssignmentsResponse, TicketAssignmentsResponse,
TicketExtensionRequestsResponse, TicketExtensionRequestsResponse,
TicketExtensionRequestsParams, TicketExtensionRequestsParams,
TicketOverviewDetail, TicketOverviewDetail,
TicketListParams, TicketListParams,
TicketListResponse, TicketListResponse,
TicketSummaryParams,
TicketSummaryResponse,
TicketTimelineResponse, TicketTimelineResponse,
} from '@/types'; } from '@/types';
@@ -43,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> => {
@@ -52,6 +70,27 @@ export const ticketService = {
return response.data; return response.data;
}, },
getTicketAssignmentDetections: async (
ticketId: string,
params?: TicketAssignmentDetectionsParams,
): Promise<TicketAssignmentDetectionsResponse> => {
const response = await axiosClient.get<TicketAssignmentDetectionsResponse>(
API_ROUTES.TICKETS.ASSIGNMENT_DETECTIONS(ticketId),
{
params: {
assignment_status: params?.assignment_status ?? 'unassigned',
class_name: params?.class_name,
skip: params?.skip ?? 0,
limit: params?.limit ?? 500,
},
paramsSerializer: {
indexes: null,
},
},
);
return response.data;
},
getTicketTimeline: async ( getTicketTimeline: async (
ticketId: string, ticketId: string,
): Promise<TicketTimelineResponse> => { ): Promise<TicketTimelineResponse> => {

View File

@@ -74,6 +74,7 @@ export const videoService = {
skip: params?.skip ?? 0, skip: params?.skip ?? 0,
limit: params?.limit ?? 1, limit: params?.limit ?? 1,
assignment_id: params?.assignment_id, assignment_id: params?.assignment_id,
assignment_status: params?.assignment_status,
class_name: params?.class_name, class_name: params?.class_name,
proof_status: params?.proof_status, proof_status: params?.proof_status,
min_confidence: params?.min_confidence, min_confidence: params?.min_confidence,

View File

@@ -75,10 +75,13 @@ export type DetectionResultLog = DetectionResultItem & {
}; };
}; };
export type AssignmentDetectionStatus = 'assigned' | 'unassigned';
export type VideoDetectionsParams = { export type VideoDetectionsParams = {
skip?: number; skip?: number;
limit?: number; limit?: number;
assignment_id?: number; assignment_id?: number;
assignment_status?: AssignmentDetectionStatus;
class_name?: string | string[]; class_name?: string | string[];
proof_status?: DetectionProofStatus; proof_status?: DetectionProofStatus;
min_confidence?: number; min_confidence?: number;
@@ -104,6 +107,35 @@ export type VideoDetectionsResponse = {
items: DetectionResultItem[]; items: DetectionResultItem[];
}; };
export type TicketAssignmentDetectionsParams = {
assignment_status?: AssignmentDetectionStatus;
class_name?: string | string[];
skip?: number;
limit?: number;
};
export type TicketAssignmentDetectionItem = {
id: string;
detection: {
id: number;
class_name: string;
display_name: string;
};
location: {
latitude: number;
longitude: number;
};
};
export type TicketAssignmentDetectionsResponse = {
ticket_id: string;
assignment_status: AssignmentDetectionStatus;
items: TicketAssignmentDetectionItem[];
total: number;
skip: number;
limit: number;
};
export type DetectionDiscardReason = export type DetectionDiscardReason =
| 'not_a_defect' | 'not_a_defect'
| 'wrong_class' | 'wrong_class'

View File

@@ -73,6 +73,8 @@ export interface TicketAssignmentsResponse {
export interface TicketOverviewDetail { export interface TicketOverviewDetail {
id: string; id: string;
ticket_name?: string | null; ticket_name?: string | null;
has_pending_review: boolean;
pending_review_count: number;
video_id?: string | null; video_id?: string | null;
video?: TicketVideo | null; video?: TicketVideo | null;
chainage_id?: string | null; chainage_id?: string | null;

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;
@@ -14,10 +15,12 @@ export type TicketTableStatusEvent = {
chainage_name?: string | null; chainage_name?: string | null;
default_defect_class?: string | null; default_defect_class?: string | null;
detection_count?: number; detection_count?: number;
is_closed?: boolean;
created_by_name?: string | null; created_by_name?: string | null;
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;
@@ -11,10 +18,12 @@ export interface TicketListItem {
chainage_name: string | null; chainage_name: string | null;
default_defect_class: string | null; default_defect_class: string | null;
detection_count: number; detection_count: number;
is_closed: boolean;
created_by_name: string | null; created_by_name: string | null;
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;
}